Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/troubleshooting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Common Session codes:
| `SESSION_REQUIRED` | A raw browser command needs a root Session selector. | Run `webcmd session create -f json`, then retry as `webcmd --session <session-id> browser ...`. |
| `INVALID_SESSION_SELECTOR` | The selector is not an opaque Webcmd Session ID. | Use an ID returned by `webcmd session create` or `webcmd session list`. |
| `SESSION_SELECTOR_POSITION` | `--session` was placed after the command name. | Move it before the command: `webcmd --session <session-id> browser ...`. |
| `SESSION_NOT_FOUND` | The selected Session is missing for the current Profile. | Run `webcmd session list -f json`; create a new Session if needed. |
| `SESSION_NOT_FOUND` | The selected Session is missing for the current Profile. | When the ID belongs to another Profile the hint names it — retry with `webcmd --profile <owner> session close <session-id>`. Otherwise run `webcmd session list -f json`; create a new Session if needed. |
| `INVALID_SESSION_LIMIT` | `session list --limit` is outside 1-100. | Retry with a limit from 1 to 100. |
| `SESSION_BUSY` | Another command is writing in the same Session or site scope. | Wait for the holder to finish; if it is dead, use `webcmd session close <session-id> --force` as the last resort. |
| `SESSION_PAUSED_FOR_HUMAN_HANDOFF` | The Session is waiting for user action such as sign-in. | Finish the action in the browser, then run the returned verifier before retrying. |
Expand Down
38 changes: 38 additions & 0 deletions src/browser/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,44 @@ describe('LocalBrowserSessionStore', () => {
expect(() => store.find('profile_work', 'work')).toThrowError(expect.objectContaining({ code: 'INVALID_SESSION_SELECTOR' }));
});

it('names the owning profile when the session exists under a different one', () => {
// A cleanup command that omits `--profile` looks up a real Session ID in
// the wrong Profile. A bare "not found" sent agents into retrying the same
// command; naming the owner and the retry shape ends that loop.
const store = new LocalBrowserSessionStore({ baseDir: tempDir(), idFactory: () => 'session_a' });
const created = store.create('profile_work');

expect(() => store.require('profile_personal', created.id)).toThrowError(expect.objectContaining({
code: 'SESSION_NOT_FOUND',
ownerProfileId: 'profile_work',
message: `Session not found in Profile profile_personal: ${created.id}`,
hint: `Session ${created.id} belongs to Profile profile_work. Re-run the same command with \`--profile profile_work\`, for example \`webcmd --profile profile_work session close ${created.id}\`.`,
}));
});

it('names the owning profile on mutating lookups too', () => {
// `remove`/`touch`/handoff updates resolve the record through a separate
// lookup, so the close path must not fall back to the anonymous message.
const store = new LocalBrowserSessionStore({ baseDir: tempDir(), idFactory: () => 'session_a' });
const created = store.create('profile_work');

expect(() => store.remove('profile_personal', created.id)).toThrowError(expect.objectContaining({
code: 'SESSION_NOT_FOUND',
ownerProfileId: 'profile_work',
}));
});

it('keeps the generic hint when no profile owns the session', () => {
const store = new LocalBrowserSessionStore({ baseDir: tempDir() });

expect(() => store.require('profile_work', 'session_missing')).toThrowError(expect.objectContaining({
code: 'SESSION_NOT_FOUND',
ownerProfileId: undefined,
message: 'Session not found: session_missing',
hint: 'Run `webcmd --profile profile_work session list` to choose an existing Session.',
}));
});

it('resolves one lazy adapter-default per profile without list side effects', () => {
const store = new LocalBrowserSessionStore({
baseDir: tempDir(),
Expand Down
30 changes: 24 additions & 6 deletions src/browser/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
import { CONFIG_DIR_NAME, ENV_PREFIX } from '../brand.js';
import { CLI_COMMAND, CONFIG_DIR_NAME, ENV_PREFIX } from '../brand.js';
import { CliError, ConfigError, EXIT_CODES } from '../errors.js';

export interface BrowserSessionRecord {
Expand Down Expand Up @@ -30,13 +30,24 @@ type StateFile = { version: 1; sessions: BrowserSessionRecord[] };
const SESSION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;

export class SessionNotFoundError extends CliError {
constructor(sessionId: string, profileId: string) {
/** Profile that actually owns the Session, when the ID exists under a different one. */
readonly ownerProfileId?: string;

constructor(sessionId: string, profileId: string, ownerProfileId?: string) {
// A Session ID is only ever looked up inside the selected Profile, so a
// caller that omits `--profile` sees "not found" for a Session that does
// exist. Naming the owner turns a dead end into a one-step retry.
super(
'SESSION_NOT_FOUND',
`Session not found: ${sessionId}`,
`Run \`webcmd --profile ${profileId} session list\` to choose an existing Session.`,
ownerProfileId
? `Session not found in Profile ${profileId}: ${sessionId}`
: `Session not found: ${sessionId}`,
ownerProfileId
? `Session ${sessionId} belongs to Profile ${ownerProfileId}. Re-run the same command with \`--profile ${ownerProfileId}\`, for example \`${CLI_COMMAND} --profile ${ownerProfileId} session close ${sessionId}\`.`
: `Run \`${CLI_COMMAND} --profile ${profileId} session list\` to choose an existing Session.`,
EXIT_CODES.EMPTY_RESULT,
);
this.ownerProfileId = ownerProfileId;
}
}

Expand Down Expand Up @@ -84,7 +95,7 @@ export class LocalBrowserSessionStore {
requireSessionIdShape(id);
const state = this.load();
const record = state.sessions.find((row) => row.id === id && row.profileId === profileId);
if (!record) throw new SessionNotFoundError(id, profileId);
if (!record) throw new SessionNotFoundError(id, profileId, findOwnerProfileId(state, id));
this.touchRecord(state, record);
return { ...record };
}
Expand Down Expand Up @@ -176,7 +187,7 @@ export class LocalBrowserSessionStore {
private requireMutable(state: StateFile, profileId: string, sessionId: string): BrowserSessionRecord {
requireSessionIdShape(sessionId);
const record = state.sessions.find((row) => row.id === sessionId && row.profileId === profileId);
if (!record) throw new SessionNotFoundError(sessionId, profileId);
if (!record) throw new SessionNotFoundError(sessionId, profileId, findOwnerProfileId(state, sessionId));
return record;
}

Expand Down Expand Up @@ -225,6 +236,13 @@ export class LocalBrowserSessionStore {
}
}

// Every Profile's Sessions share one state file, so the owning Profile of a
// Session that missed the scoped lookup is already in hand — no daemon or
// provider round trip is needed to name it.
function findOwnerProfileId(state: StateFile, sessionId: string): string | undefined {
return state.sessions.find((row) => row.id === sessionId)?.profileId;
}

function validateState(value: unknown): StateFile {
if (!value || typeof value !== 'object') throw new ConfigError('browser-sessions.json must contain an object.');
const state = value as { version?: unknown; sessions?: unknown };
Expand Down
27 changes: 27 additions & 0 deletions src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2449,6 +2449,33 @@ describe('browser Session lifecycle commands', () => {
});
});

it('names the owning Profile when closing a Session without its --profile', async () => {
// The eval failure this guards: cleanup omitted `--profile`, the CLI said
// the Session did not exist, and the agent retried the same command.
mockSendCommand.mockRejectedValueOnce(new Error('daemon unavailable'));
const baseDir = path.join(isolatedCliTestHome, '.webcmd');
fs.mkdirSync(baseDir, { recursive: true });
fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), JSON.stringify({
version: 1,
sessions: [{
id: 'session_owned',
profileId: 'work',
kind: 'explicit',
createdAt: '2026-08-11T00:00:00.000Z',
updatedAt: '2026-08-11T00:00:00.000Z',
lastUsedAt: '2026-08-11T00:00:00.000Z',
}],
}), { mode: 0o600 });

await expect(
createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'session_owned', '-f', 'json']),
).rejects.toThrowError(expect.objectContaining({
code: 'SESSION_NOT_FOUND',
ownerProfileId: 'work',
hint: 'Session session_owned belongs to Profile work. Re-run the same command with `--profile work`, for example `webcmd --profile work session close session_owned`.',
}));
});

it.each([
['create'],
['list'],
Expand Down
5 changes: 4 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -892,7 +892,10 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi
console.log(`No browser Sessions found for Profile ${profileId}.`);
return;
}
await renderOutput(output, { fmt, fmtExplicit: outputFormatIsExplicit(command), columns: ['id', 'kind', 'runtimeState', 'handoff'] });
// `profileId` is a column because these rows are scoped to the selected
// Profile. Without it the table reads as every Session on the machine,
// which is what led agents to act on Sessions from unrelated Profiles.
await renderOutput(output, { fmt, fmtExplicit: outputFormatIsExplicit(command), columns: ['id', 'profileId', 'kind', 'runtimeState', 'handoff'] });
});

const sessionCloseCmd = addOutputFormatOption(sessionCmd
Expand Down
Loading