From 1a3fce2c828412650dfd55ca0a6356b5f72f65d1 Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Tue, 25 Aug 2026 20:30:05 +0530 Subject: [PATCH 1/5] feat: add readable local session identifiers --- src/browser/session-identifiers.test.ts | 36 ++++ src/browser/session-identifiers.ts | 67 +++++++ .../session-normalization.fixtures.json | 8 + src/browser/sessions.test.ts | 165 +++++++++--------- src/browser/sessions.ts | 142 +++++++-------- 5 files changed, 251 insertions(+), 167 deletions(-) create mode 100644 src/browser/session-identifiers.test.ts create mode 100644 src/browser/session-identifiers.ts create mode 100644 src/browser/session-normalization.fixtures.json diff --git a/src/browser/session-identifiers.test.ts b/src/browser/session-identifiers.test.ts new file mode 100644 index 00000000..f5bdac93 --- /dev/null +++ b/src/browser/session-identifiers.test.ts @@ -0,0 +1,36 @@ +import fs from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { + generateSessionSuffix, + normalizeSessionBase, + requireSessionIdShape, + requireSessionName, +} from './session-identifiers.js'; + +const normalizationCases = JSON.parse( + fs.readFileSync(new URL('./session-normalization.fixtures.json', import.meta.url), 'utf8'), +) as Array<{ input: string; base?: string; error?: string }>; + +describe('session identifiers', () => { + it.each(normalizationCases)('normalizes $input', ({ input, base, error }) => { + if (base !== undefined) { + expect(normalizeSessionBase(input)).toBe(base); + expect(requireSessionName(input)).toBe(base); + } else { + expect(() => requireSessionName(input)).toThrowError(expect.objectContaining({ code: error })); + } + }); + + it('uses readable random suffixes', () => { + expect(generateSessionSuffix(() => 0)).toBe('22'); + expect(generateSessionSuffix(() => 31)).toBe('zz'); + }); + + it('accepts only readable session selectors', () => { + expect(() => requireSessionIdShape('a-k7')).not.toThrow(); + expect(() => requireSessionIdShape('adapter-default')).not.toThrow(); + expect(() => requireSessionIdShape('a-k7m4q2')).toThrowError( + expect.objectContaining({ code: 'INVALID_SESSION_SELECTOR' }), + ); + }); +}); diff --git a/src/browser/session-identifiers.ts b/src/browser/session-identifiers.ts new file mode 100644 index 00000000..8d81d42b --- /dev/null +++ b/src/browser/session-identifiers.ts @@ -0,0 +1,67 @@ +import { randomInt } from 'node:crypto'; +import { CliError, EXIT_CODES } from '../errors.js'; + +export const SESSION_SUFFIX_ALPHABET = '23456789abcdefghijkmnpqrstuvwxyz'; +export const SESSION_SUFFIX_LENGTH = 2; +export const SESSION_GENERATION_ATTEMPTS = 10; +export const ADAPTER_DEFAULT_SESSION_ID = 'adapter-default'; + +export class InvalidSessionNameError extends CliError { + constructor(input: string) { + super( + 'INVALID_SESSION_NAME', + `Session name must contain at least one letter or number: ${input}`, + 'Use a short readable name such as `work` or `research-2026`.', + EXIT_CODES.USAGE_ERROR, + ); + } +} + +export class InvalidSessionSelectorError extends CliError { + constructor(sessionId: string) { + super( + 'INVALID_SESSION_SELECTOR', + `Session selector must be a readable Session ID: ${sessionId}`, + 'Run `webcmd session create ` and pass the returned readable ID.', + EXIT_CODES.USAGE_ERROR, + ); + } +} + +export class SessionIdGenerationError extends CliError { + constructor(name: string) { + super( + 'SESSION_ID_GENERATION_FAILED', + `Could not generate a unique readable Session ID for: ${name}`, + 'Choose a different Session name and try again.', + EXIT_CODES.GENERIC_ERROR, + ); + } +} + +export function normalizeSessionBase(input: string): string { + return input.trim().toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 60) + .replace(/-+$/g, ''); +} + +export function requireSessionName(input: string): string { + const base = normalizeSessionBase(input); + if (!base) throw new InvalidSessionNameError(input); + return base; +} + +export function requireSessionIdShape(sessionId: string): void { + if (sessionId !== ADAPTER_DEFAULT_SESSION_ID && !/^[a-z0-9][a-z0-9-]{0,59}-[23456789abcdefghijkmnpqrstuvwxyz]{2}$/u.test(sessionId)) { + throw new InvalidSessionSelectorError(sessionId); + } +} + +export function generateSessionSuffix( + randomIndex: (max: number) => number = randomInt, +): string { + return Array.from({ length: SESSION_SUFFIX_LENGTH }, () => + SESSION_SUFFIX_ALPHABET[randomIndex(SESSION_SUFFIX_ALPHABET.length)]).join(''); +} diff --git a/src/browser/session-normalization.fixtures.json b/src/browser/session-normalization.fixtures.json new file mode 100644 index 00000000..15e96687 --- /dev/null +++ b/src/browser/session-normalization.fixtures.json @@ -0,0 +1,8 @@ +[ + { "input": "Work Project", "base": "work-project" }, + { "input": " Client / A ", "base": "client-a" }, + { "input": "Research 2026", "base": "research-2026" }, + { "input": "---A___B---", "base": "a-b" }, + { "input": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "base": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" }, + { "input": " / ", "error": "INVALID_SESSION_NAME" } +] diff --git a/src/browser/sessions.test.ts b/src/browser/sessions.test.ts index c9e63d86..e5c1ab4b 100644 --- a/src/browser/sessions.test.ts +++ b/src/browser/sessions.test.ts @@ -17,101 +17,65 @@ afterEach(() => { }); describe('LocalBrowserSessionStore', () => { - it('creates unique explicit sessions and persists them', () => { + it('creates readable IDs that are unique within each profile', () => { const baseDir = tempDir(); + const suffixes = ['k7', 'k7', '8n', 'k7']; const store = new LocalBrowserSessionStore({ baseDir, - now: () => new Date('2026-08-11T00:00:00.000Z'), - idFactory: () => 'session_11111111-1111-4111-8111-111111111111', + suffixFactory: () => suffixes.shift()!, }); - const created = store.create('profile_work'); - - expect(created).toMatchObject({ - id: 'session_11111111-1111-4111-8111-111111111111', - profileId: 'profile_work', - kind: 'explicit', - createdAt: '2026-08-11T00:00:00.000Z', - updatedAt: '2026-08-11T00:00:00.000Z', - lastUsedAt: '2026-08-11T00:00:00.000Z', - }); - expect(store.create('profile_work').id).not.toBe(created.id); - expect(new LocalBrowserSessionStore({ baseDir }).find('profile_work', created.id)?.id).toBe(created.id); - }); - - it('scopes lookup by profile and validates opaque ids', () => { - const store = new LocalBrowserSessionStore({ baseDir: tempDir(), idFactory: () => 'session_a' }); - const created = store.create('profile_work'); - - expect(() => store.require('profile_other', created.id)).toThrowError(expect.objectContaining({ code: 'SESSION_NOT_FOUND' })); - expect(() => store.find('profile_work', 'work')).toThrowError(expect.objectContaining({ code: 'INVALID_SESSION_SELECTOR' })); + expect(store.create('profile-a', 'Work Project').id).toBe('work-project-k7'); + expect(store.create('profile-a', 'Work Project').id).toBe('work-project-8n'); + expect(store.create('profile-b', 'Work Project').id).toBe('work-project-k7'); + expect(store.resolveAdapterDefault('profile-a').id).toBe('adapter-default'); }); - 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('findOwner names the owning profile without scoping to a selected one', () => { - const store = new LocalBrowserSessionStore({ baseDir: tempDir(), idFactory: () => 'session_a' }); - const created = store.create('profile_work'); + it('fails after ten readable ID collisions', () => { + const baseDir = tempDir(); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), `${JSON.stringify({ + version: 2, + sessions: [sessionRecord('work-project-k7', 'explicit', '2026-08-11T00:00:00.000Z')], + })}\n`, { mode: 0o600 }); + const store = new LocalBrowserSessionStore({ + baseDir, + suffixFactory: () => 'k7', + }); - expect(store.findOwner(created.id)).toBe('profile_work'); - expect(store.findOwner('session_missing')).toBeUndefined(); + expect(() => store.create('work', 'Work Project')).toThrowError( + expect.objectContaining({ code: 'SESSION_ID_GENERATION_FAILED' }), + ); }); - it('keeps the generic hint when no profile owns the session', () => { - const store = new LocalBrowserSessionStore({ baseDir: tempDir() }); + it('scopes lookups by profile and validates readable IDs', () => { + const store = new LocalBrowserSessionStore({ baseDir: tempDir(), suffixFactory: () => 'k7' }); + const created = store.create('profile-work', 'Work'); - expect(() => store.require('profile_work', 'session_missing')).toThrowError(expect.objectContaining({ + expect(() => store.require('profile-other', created.id)).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, then `webcmd session close `. If it belongs to another Profile, pass `--profile `.', })); + expect(() => store.find('profile-work', 'work')).toThrowError(expect.objectContaining({ code: 'INVALID_SESSION_SELECTOR' })); }); it('resolves one lazy adapter-default per profile without list side effects', () => { const store = new LocalBrowserSessionStore({ baseDir: tempDir(), - idFactory: () => 'session_default', + suffixFactory: () => 'k7', }); - expect(store.list('profile_work')).toEqual([]); - const adapterDefault = store.resolveAdapterDefault('profile_work'); + expect(store.list('profile-work')).toEqual([]); + const adapterDefault = store.resolveAdapterDefault('profile-work'); - expect(adapterDefault.kind).toBe('adapter-default'); - expect(store.resolveAdapterDefault('profile_work').id).toBe(adapterDefault.id); - expect(store.list('profile_work')).toHaveLength(1); + expect(adapterDefault).toMatchObject({ id: 'adapter-default', kind: 'adapter-default' }); + expect(store.resolveAdapterDefault('profile-work').id).toBe('adapter-default'); + expect(store.list('profile-work')).toHaveLength(1); }); it('writes state atomically with private file mode', () => { const baseDir = tempDir(); - const store = new LocalBrowserSessionStore({ baseDir, idFactory: () => 'session_private' }); + const store = new LocalBrowserSessionStore({ baseDir, suffixFactory: () => 'k7' }); - store.create('profile_work'); + store.create('profile-work', 'Private'); const statePath = path.join(baseDir, 'browser-sessions.json'); expect(fs.existsSync(statePath)).toBe(true); @@ -119,11 +83,37 @@ describe('LocalBrowserSessionStore', () => { expect(fs.readdirSync(baseDir).filter((name) => name.includes('.tmp'))).toEqual([]); }); + it('discards version-1 state into an empty version-2 state without a backup', () => { + const baseDir = tempDir(); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), `${JSON.stringify({ + version: 1, + sessions: [sessionRecord('old-k7', 'explicit', '2026-08-11T00:00:00.000Z')], + })}\n`, { mode: 0o600 }); + + expect(new LocalBrowserSessionStore({ baseDir }).list('work')).toEqual([]); + expect(JSON.parse(fs.readFileSync(path.join(baseDir, 'browser-sessions.json'), 'utf8'))).toEqual({ + version: 2, + sessions: [], + }); + expect(fs.readdirSync(baseDir)).toEqual(['browser-sessions.json']); + }); + it('fails closed on malformed persisted JSON', () => { const baseDir = tempDir(); fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), '{not json', { mode: 0o600 }); - expect(() => new LocalBrowserSessionStore({ baseDir }).list('profile_work')) + expect(() => new LocalBrowserSessionStore({ baseDir }).list('profile-work')) + .toThrowError(expect.objectContaining({ code: 'CONFIG' })); + }); + + it.each([ + sessionRecord('adapter-default', 'explicit', '2026-08-11T00:00:00.000Z'), + sessionRecord('work-k7', 'adapter-default', '2026-08-11T00:00:00.000Z'), + ])('rejects persisted rows that violate the kind/ID invariant', (record) => { + const baseDir = tempDir(); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), `${JSON.stringify({ version: 2, sessions: [record] })}\n`, { mode: 0o600 }); + + expect(() => new LocalBrowserSessionStore({ baseDir }).list('work')) .toThrowError(expect.objectContaining({ code: 'CONFIG' })); }); @@ -132,9 +122,9 @@ describe('LocalBrowserSessionStore', () => { const store = new LocalBrowserSessionStore({ baseDir: tempDir(), now: () => now, - idFactory: () => 'session_a', + suffixFactory: () => 'k7', }); - const session = store.create('work'); + const session = store.create('work', 'Work'); store.markHandoff('work', session.id, { site: 'github', expiresAt: '2026-08-11T00:15:00.000Z', @@ -153,13 +143,14 @@ describe('LocalBrowserSessionStore', () => { it('prunes explicit Sessions idle for 30 days while preserving adapter defaults and handoffs', () => { const baseDir = tempDir(); fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), `${JSON.stringify({ - version: 1, + version: 2, sessions: [ - sessionRecord('session_old', 'explicit', '2026-07-11T23:59:59.000Z'), - sessionRecord('session_boundary', 'explicit', '2026-07-12T00:00:01.000Z'), - sessionRecord('session_handoff', 'explicit', '2026-07-01T00:00:00.000Z', { site: 'github', expiresAt: '2026-08-12T00:15:00.000Z' }), - sessionRecord('session_expired_handoff', 'explicit', '2026-07-01T00:00:00.000Z', { site: 'github', expiresAt: '2026-08-10T00:15:00.000Z' }), - sessionRecord('session_default', 'adapter-default', '2026-07-01T00:00:00.000Z'), + sessionRecord('old-k7', 'explicit', '2026-07-11T23:59:59.000Z'), + sessionRecord('boundary-k7', 'explicit', '2026-07-12T00:00:00.000Z'), + sessionRecord('recent-k7', 'explicit', '2026-07-12T00:00:01.000Z'), + sessionRecord('handoff-k7', 'explicit', '2026-07-01T00:00:00.000Z', { site: 'github', expiresAt: '2026-08-12T00:15:00.000Z' }), + sessionRecord('expired-handoff-k7', 'explicit', '2026-07-01T00:00:00.000Z', { site: 'github', expiresAt: '2026-08-10T00:15:00.000Z' }), + sessionRecord('adapter-default', 'adapter-default', '2026-07-01T00:00:00.000Z'), ], })}\n`, { mode: 0o600 }); @@ -168,32 +159,32 @@ describe('LocalBrowserSessionStore', () => { now: () => new Date('2026-08-11T00:00:00.000Z'), }).list('work'); - expect(rows.map((row) => row.id)).toEqual(['session_boundary', 'session_default', 'session_handoff']); + expect(rows.map((row) => row.id)).toEqual(['recent-k7', 'adapter-default', 'handoff-k7']); }); it('retains active Sessions and limits newest-first listings', () => { const baseDir = tempDir(); fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), `${JSON.stringify({ - version: 1, + version: 2, sessions: [ - sessionRecord('session_active', 'explicit', '2026-07-01T00:00:00.000Z'), - sessionRecord('session_newest', 'explicit', '2026-08-10T00:00:00.000Z'), - sessionRecord('session_middle', 'explicit', '2026-08-09T00:00:00.000Z'), + sessionRecord('active-k7', 'explicit', '2026-07-01T00:00:00.000Z'), + sessionRecord('newest-k7', 'explicit', '2026-08-10T00:00:00.000Z'), + sessionRecord('middle-k7', 'explicit', '2026-08-09T00:00:00.000Z'), ], })}\n`, { mode: 0o600 }); const rows = new LocalBrowserSessionStore({ baseDir, now: () => new Date('2026-08-11T00:00:00.000Z'), - isActive: session => session.id === 'session_active', + isActive: session => session.id === 'active-k7', }).list('work', 2); - expect(rows.map((row) => row.id)).toEqual(['session_newest', 'session_middle']); + expect(rows.map((row) => row.id)).toEqual(['newest-k7', 'middle-k7']); expect(new LocalBrowserSessionStore({ baseDir, now: () => new Date('2026-08-11T00:00:00.000Z'), - isActive: session => session.id === 'session_active', - }).find('work', 'session_active')).toBeDefined(); + isActive: session => session.id === 'active-k7', + }).find('work', 'active-k7')).toBeDefined(); }); }); diff --git a/src/browser/sessions.ts b/src/browser/sessions.ts index e667ba6b..f783f4ef 100644 --- a/src/browser/sessions.ts +++ b/src/browser/sessions.ts @@ -1,9 +1,17 @@ +import { randomUUID } from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { randomUUID } from 'node:crypto'; import { CLI_COMMAND, CONFIG_DIR_NAME, ENV_PREFIX } from '../brand.js'; import { CliError, ConfigError, EXIT_CODES } from '../errors.js'; +import { + ADAPTER_DEFAULT_SESSION_ID, + generateSessionSuffix, + requireSessionIdShape, + requireSessionName, + SESSION_GENERATION_ATTEMPTS, + SessionIdGenerationError, +} from './session-identifiers.js'; export interface BrowserSessionRecord { id: string; @@ -22,76 +30,48 @@ export interface BrowserSessionListRow extends BrowserSessionRecord { export interface LocalBrowserSessionStoreOptions { baseDir?: string; now?: () => Date; - idFactory?: () => string; + suffixFactory?: () => string; isActive?: (record: BrowserSessionRecord) => boolean; } -type StateFile = { version: 1; sessions: BrowserSessionRecord[] }; +type StateFile = { version: 2; sessions: BrowserSessionRecord[] }; const SESSION_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; export class SessionNotFoundError extends CliError { - /** 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. + constructor(sessionId: string, profileId: string) { super( 'SESSION_NOT_FOUND', - 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, then \`${CLI_COMMAND} session close \`. If it belongs to another Profile, pass \`--profile \`.`, + `Session not found: ${sessionId}`, + `Run \`${CLI_COMMAND} --profile ${profileId} session list\` to choose an existing readable Session ID, then \`${CLI_COMMAND} session close \`.`, EXIT_CODES.EMPTY_RESULT, ); - this.ownerProfileId = ownerProfileId; - } -} - -export class InvalidSessionSelectorError extends CliError { - constructor(sessionId: string) { - super( - 'INVALID_SESSION_SELECTOR', - `Session selector must be an opaque Session ID: ${sessionId}`, - 'Run `webcmd session create` and pass the returned `session_...` ID.', - EXIT_CODES.USAGE_ERROR, - ); } } export class LocalBrowserSessionStore { private readonly baseDir: string; private readonly now: () => Date; - private readonly idFactory: () => string; + private readonly suffixFactory: () => string; private readonly isActive: (record: BrowserSessionRecord) => boolean; constructor(opts: LocalBrowserSessionStoreOptions = {}) { this.baseDir = opts.baseDir ?? getWebcmdConfigDir(); this.now = opts.now ?? (() => new Date()); - this.idFactory = opts.idFactory ?? (() => `session_${randomUUID()}`); + this.suffixFactory = opts.suffixFactory ?? generateSessionSuffix; this.isActive = opts.isActive ?? (() => false); } - create(profileId: string): BrowserSessionRecord { + create(profileId: string, name: string): BrowserSessionRecord { const state = this.load(); - const record = this.newRecord(profileId, 'explicit', state.sessions); + const record = this.newExplicitRecord(profileId, name, state.sessions); state.sessions.push(record); this.save(state); return { ...record }; } - /** Profile that owns this Session ID, regardless of which one is selected. */ - findOwner(sessionId: string): string | undefined { - return findOwnerProfileId(this.load(), sessionId); - } - find(profileId: string, sessionId: string): BrowserSessionRecord | undefined { requireSessionIdShape(sessionId); - const state = this.load(); - const record = state.sessions.find((row) => row.id === sessionId && row.profileId === profileId); + const record = this.load().sessions.find((row) => row.id === sessionId && row.profileId === profileId); return record ? { ...record } : undefined; } @@ -100,19 +80,27 @@ 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, findOwnerProfileId(state, id)); + if (!record) throw new SessionNotFoundError(id, profileId); this.touchRecord(state, record); return { ...record }; } resolveAdapterDefault(profileId: string): BrowserSessionRecord { const state = this.load(); - const existing = state.sessions.find((row) => row.profileId === profileId && row.kind === 'adapter-default'); + const existing = state.sessions.find((row) => row.profileId === profileId && row.id === ADAPTER_DEFAULT_SESSION_ID); if (existing) { this.touchRecord(state, existing); return { ...existing }; } - const record = this.newRecord(profileId, 'adapter-default', state.sessions); + const timestamp = this.now().toISOString(); + const record: BrowserSessionRecord = { + id: ADAPTER_DEFAULT_SESSION_ID, + profileId, + kind: 'adapter-default', + createdAt: timestamp, + updatedAt: timestamp, + lastUsedAt: timestamp, + }; state.sessions.push(record); this.save(state); return { ...record }; @@ -160,26 +148,17 @@ export class LocalBrowserSessionStore { return { ...record }; } - private newRecord( - profileId: string, - kind: BrowserSessionRecord['kind'], - existing: BrowserSessionRecord[], - ): BrowserSessionRecord { - const timestamp = this.now().toISOString(); - const id = this.uniqueId(existing); - return { id, profileId, kind, createdAt: timestamp, updatedAt: timestamp, lastUsedAt: timestamp }; - } - - private uniqueId(existing: BrowserSessionRecord[]): string { - const used = new Set(existing.map((row) => row.id)); - const first = this.idFactory(); - if (!used.has(first)) { - requireSessionIdShape(first); - return first; + private newExplicitRecord(profileId: string, name: string, existing: BrowserSessionRecord[]): BrowserSessionRecord { + const base = requireSessionName(name); + const used = new Set(existing.filter((row) => row.profileId === profileId).map((row) => row.id)); + for (let attempt = 0; attempt < SESSION_GENERATION_ATTEMPTS; attempt += 1) { + const id = `${base}-${this.suffixFactory()}`; + if (used.has(id)) continue; + requireSessionIdShape(id); + const timestamp = this.now().toISOString(); + return { id, profileId, kind: 'explicit', createdAt: timestamp, updatedAt: timestamp, lastUsedAt: timestamp }; } - let candidate = `session_${randomUUID()}`; - while (used.has(candidate)) candidate = `session_${randomUUID()}`; - return candidate; + throw new SessionIdGenerationError(name); } private touchRecord(state: StateFile, record: BrowserSessionRecord): void { @@ -192,19 +171,24 @@ 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, findOwnerProfileId(state, sessionId)); + if (!record) throw new SessionNotFoundError(sessionId, profileId); return record; } private load(): StateFile { const file = this.statePath(); - if (!fs.existsSync(file)) return { version: 1, sessions: [] }; + if (!fs.existsSync(file)) return { version: 2, sessions: [] }; let parsed: unknown; try { parsed = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (error) { throw new ConfigError(`Could not read browser sessions: ${error instanceof Error ? error.message : String(error)}`); } + if (isVersionOneState(parsed)) { + const state: StateFile = { version: 2, sessions: [] }; + this.save(state); + return state; + } const state = validateState(parsed); const now = this.now().getTime(); let changed = false; @@ -214,7 +198,7 @@ export class LocalBrowserSessionStore { changed = true; } } - const retained = state.sessions.filter((record) => { + state.sessions = state.sessions.filter((record) => { const expired = record.kind === 'explicit' && !record.handoff && !this.isActive(record) @@ -222,7 +206,6 @@ export class LocalBrowserSessionStore { if (expired) changed = true; return !expired; }); - state.sessions = retained; if (changed) this.save(state); return state; } @@ -241,31 +224,35 @@ 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 isVersionOneState(value: unknown): value is { version: 1 } { + return Boolean(value) && typeof value === 'object' && (value as { version?: unknown }).version === 1; } 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 }; - if (state.version !== 1 || !Array.isArray(state.sessions)) { + if (state.version !== 2 || !Array.isArray(state.sessions)) { throw new ConfigError('browser-sessions.json has an unsupported schema.'); } const adapterDefaults = new Set(); const sessions = state.sessions.map((row) => validateRecord(row, adapterDefaults)); - return { version: 1, sessions }; + return { version: 2, sessions }; } function validateRecord(value: unknown, adapterDefaults: Set): BrowserSessionRecord { if (!value || typeof value !== 'object') throw new ConfigError('browser-sessions.json contains an invalid Session record.'); const row = value as Partial; if (typeof row.id !== 'string') throw new ConfigError('browser-sessions.json contains a Session without an id.'); - requireSessionIdShape(row.id); + try { + requireSessionIdShape(row.id); + } catch { + throw new ConfigError('browser-sessions.json contains a Session with an invalid readable id.'); + } if (typeof row.profileId !== 'string' || !row.profileId.trim()) throw new ConfigError('browser-sessions.json contains a Session without a profileId.'); if (row.kind !== 'explicit' && row.kind !== 'adapter-default') throw new ConfigError('browser-sessions.json contains an invalid Session kind.'); + if ((row.kind === 'adapter-default') !== (row.id === ADAPTER_DEFAULT_SESSION_ID)) { + throw new ConfigError('browser-sessions.json contains a Session with an invalid kind/id combination.'); + } const createdAt = row.createdAt; const updatedAt = row.updatedAt; const lastUsedAt = row.lastUsedAt; @@ -279,9 +266,8 @@ function validateRecord(value: unknown, adapterDefaults: Set): BrowserSe throw new ConfigError('browser-sessions.json contains an invalid lastUsedAt.'); } if (row.kind === 'adapter-default') { - const key = row.profileId; - if (adapterDefaults.has(key)) throw new ConfigError(`browser-sessions.json contains multiple adapter-default Sessions for ${key}.`); - adapterDefaults.add(key); + if (adapterDefaults.has(row.profileId)) throw new ConfigError(`browser-sessions.json contains multiple adapter-default Sessions for ${row.profileId}.`); + adapterDefaults.add(row.profileId); } const handoff = row.handoff; if (handoff && ( @@ -303,10 +289,6 @@ function validateRecord(value: unknown, adapterDefaults: Set): BrowserSe }; } -export function requireSessionIdShape(sessionId: string): void { - if (!/^session_[A-Za-z0-9_-]+$/u.test(sessionId)) throw new InvalidSessionSelectorError(sessionId); -} - function getWebcmdConfigDir(): string { return process.env[`${ENV_PREFIX}_CONFIG_DIR`] || path.join(os.homedir(), CONFIG_DIR_NAME); } From 8e0619a5f7bf0c0a391bd56ff39911cffa7e95ca Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Tue, 25 Aug 2026 20:45:20 +0530 Subject: [PATCH 2/5] feat: require names for local sessions --- src/browser/protocol.ts | 2 + .../runtime/local-cloak/provider.test.ts | 38 +++ src/browser/runtime/local-cloak/provider.ts | 2 +- src/cli.test.ts | 225 ++++++++++++------ src/cli.ts | 70 ++---- src/daemon/server.test.ts | 22 +- src/hosted/browser-args.ts | 7 +- src/hosted/runner.test.ts | 30 +-- src/root-command-surface.ts | 2 +- src/session-lease.test.ts | 22 ++ 10 files changed, 270 insertions(+), 150 deletions(-) diff --git a/src/browser/protocol.ts b/src/browser/protocol.ts index b170cc57..0dde149e 100644 --- a/src/browser/protocol.ts +++ b/src/browser/protocol.ts @@ -37,6 +37,8 @@ export interface BrowserRuntimeCommand { page?: string; code?: string; session?: string; + /** Raw human Session name. Normalized only by the local Session store. */ + sessionName?: string; sessionId?: string; sessionKind?: 'explicit' | 'adapter-default'; surface?: BrowserSurface; diff --git a/src/browser/runtime/local-cloak/provider.test.ts b/src/browser/runtime/local-cloak/provider.test.ts index daf0187d..bd436bad 100644 --- a/src/browser/runtime/local-cloak/provider.test.ts +++ b/src/browser/runtime/local-cloak/provider.test.ts @@ -3,6 +3,7 @@ import os from 'node:os'; import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { LocalCloakRuntimeProvider } from './provider.js'; +import { LocalBrowserSessionStore } from '../../sessions.js'; import { BrowserRunError } from '../../run/types.js'; const runBrowserProgram = vi.hoisted(() => vi.fn()); @@ -172,6 +173,7 @@ describe('LocalCloakRuntimeProvider', () => { id: 'create-doctor-session', action: 'session-create', contextId: 'default', + sessionName: 'doctor', }); await provider.closeSession({ @@ -190,6 +192,41 @@ describe('LocalCloakRuntimeProvider', () => { } }); + it('passes a readable Session name to the profile-scoped store', async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-provider-session-')); + const create = vi.spyOn(LocalBrowserSessionStore.prototype, 'create'); + try { + const provider = new LocalCloakRuntimeProvider({ baseDir }); + const session = await provider.createSession({ + id: 'create-work-project', + action: 'session-create', + contextId: 'profile-a', + sessionName: 'Work Project', + }); + + expect(create).toHaveBeenCalledWith('profile-a', 'Work Project'); + expect(session).toMatchObject({ profileId: 'profile-a', id: expect.stringMatching(/^work-project-[23456789abcdefghijkmnpqrstuvwxyz]{2}$/) }); + } finally { + create.mockRestore(); + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + + it('resolves an omitted adapter --session as adapter-default', async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-provider-session-')); + try { + const provider = new LocalCloakRuntimeProvider({ baseDir }); + await expect(provider.resolveAdapterDefault({ + id: 'adapter-default', + action: 'exec', + contextId: 'profile-a', + surface: 'adapter', + })).resolves.toMatchObject({ id: 'adapter-default', profileId: 'profile-a', kind: 'adapter-default' }); + } finally { + fs.rmSync(baseDir, { recursive: true, force: true }); + } + }); + it('does not discard a Session record unless close is forced', async () => { const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-provider-session-')); try { @@ -198,6 +235,7 @@ describe('LocalCloakRuntimeProvider', () => { id: 'create-user-session', action: 'session-create', contextId: 'default', + sessionName: 'user', }); await provider.closeSession({ diff --git a/src/browser/runtime/local-cloak/provider.ts b/src/browser/runtime/local-cloak/provider.ts index 8e8b6457..6b6d599a 100644 --- a/src/browser/runtime/local-cloak/provider.ts +++ b/src/browser/runtime/local-cloak/provider.ts @@ -50,7 +50,7 @@ export class LocalCloakRuntimeProvider implements BrowserRuntimeProvider { } async createSession(command: BrowserRuntimeCommand): Promise { - return this.sessions.create(this.resolveProfileId(command)); + return this.sessions.create(this.resolveProfileId(command), command.sessionName ?? ''); } async requireSession(command: BrowserRuntimeCommand): Promise { diff --git a/src/cli.test.ts b/src/cli.test.ts index 445a6ab0..9bf6f584 100644 --- a/src/cli.test.ts +++ b/src/cli.test.ts @@ -1217,7 +1217,7 @@ name: 'search', const browser = program.commands.find(cmd => cmd.name() === 'browser'); expect(browser).toBeTruthy(); - process.argv = ['node', 'webcmd', '--session', 'session_test', 'browser', '--help', '-f', 'yaml']; + process.argv = ['node', 'webcmd', '--session', 'work-k7', 'browser', '--help', '-f', 'yaml']; const data = yaml.load(browser!.helpInformation()) as any; expect(data.namespace).toBe('browser'); @@ -1636,7 +1636,7 @@ describe('browser verify', () => { fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'verify', 'hn/top', '--no-fixture', '--trace', 'retain-on-failure']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'verify', 'hn/top', '--no-fixture', '--trace', 'retain-on-failure']); expect(mockExecFileSync).toHaveBeenCalledTimes(1); const [, execArgs] = mockExecFileSync.mock.calls[0] as [string, string[]]; @@ -1663,7 +1663,7 @@ describe('browser verify', () => { fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'verify', 'hn/top', '--no-fixture', '--seed-args', 'webcmd-verify']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'verify', 'hn/top', '--no-fixture', '--seed-args', 'webcmd-verify']); expect(mockExecFileSync).toHaveBeenCalledTimes(1); const [, execArgs] = mockExecFileSync.mock.calls[0] as [string, string[]]; @@ -1691,7 +1691,7 @@ describe('browser verify', () => { fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'verify', 'hn/top', '--write-fixture', '--seed-args', 'webcmd-verify']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'verify', 'hn/top', '--write-fixture', '--seed-args', 'webcmd-verify']); const fixtureFile = path.join(fakeHome, '.webcmd', 'sites', 'hn', 'verify', 'top.json'); const fixture = JSON.parse(fs.readFileSync(fixtureFile, 'utf-8')); @@ -1722,7 +1722,7 @@ describe('browser verify', () => { fs.writeFileSync(path.join(adapterDir, 'top.js'), 'export default {};\n', 'utf-8'); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'verify', 'hn/top', '--no-fixture']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'verify', 'hn/top', '--no-fixture']); expect(process.exitCode).toBe(1); const output = consoleLogSpy.mock.calls.map((args) => args.join(' ')).join('\n'); @@ -2245,11 +2245,11 @@ describe('browser raw session commands', () => { it('lists tabs without allocating a local browser runtime', async () => { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'tabs']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'tabs']); expect(mockBrowserConnect).not.toHaveBeenCalled(); expect(mockSendCommand).not.toHaveBeenCalled(); - expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('session_test', {}); + expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('work-k7', {}); expect(consoleLogSpy).toHaveBeenLastCalledWith('[]'); }); @@ -2257,33 +2257,33 @@ describe('browser raw session commands', () => { mockListExistingBrowserTabs.mockResolvedValue([{ page: 'page-123' }]); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'tabs']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'tabs']); expect(mockBrowserConnect).not.toHaveBeenCalled(); - expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('session_test', {}); + expect(mockListExistingBrowserTabs).toHaveBeenCalledWith('work-k7', {}); }); it('binds only an explicit stable page id', async () => { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--page', 'page-123']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'bind', '--page', 'page-123']); expect(mockSendCommand).toHaveBeenCalledWith('bind', { - session: 'session_test', surface: 'browser', page: 'page-123', + session: 'work-k7', surface: 'browser', page: 'page-123', }); - await expect(program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--index', '0'])) + await expect(program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'bind', '--index', '0'])) .rejects.toThrow(/process\.exit unexpectedly called/); - await expect(program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--page', ' '])) + await expect(program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'bind', '--page', ' '])) .rejects.toThrow(/process\.exit unexpectedly called/); }); it('sends snapshot inspection options to the browser runtime', async () => { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'snapshot', '--snapshot-mode', 'read', '--ref', 'e12', '--max-output', '1000']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'snapshot', '--snapshot-mode', 'read', '--ref', 'e12', '--max-output', '1000']); expect(mockSendCommand).toHaveBeenCalledWith('snapshot', { - session: 'session_test', surface: 'browser', snapshotMode: 'read', ref: 'e12', maxOutputChars: 1000, + session: 'work-k7', surface: 'browser', snapshotMode: 'read', ref: 'e12', maxOutputChars: 1000, }); }); @@ -2298,7 +2298,7 @@ describe('browser raw session commands', () => { delete process.env.WEBCMD_VERBOSE; const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', leaf, '-v']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', leaf, '-v']); expect(process.exitCode).toBeUndefined(); expect(process.env.WEBCMD_VERBOSE).toBe('1'); @@ -2308,11 +2308,11 @@ describe('browser raw session commands', () => { delete process.env.WEBCMD_VERBOSE; const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'bind', '--page', 'page-123', '-v']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'bind', '--page', 'page-123', '-v']); expect(process.env.WEBCMD_VERBOSE).toBe('1'); expect(mockSendCommand).toHaveBeenCalledWith('bind', { - session: 'session_test', surface: 'browser', page: 'page-123', + session: 'work-k7', surface: 'browser', page: 'page-123', }); }); @@ -2320,7 +2320,7 @@ describe('browser raw session commands', () => { delete process.env.WEBCMD_VERBOSE; const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'tabs']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'tabs']); expect(process.env.WEBCMD_VERBOSE).toBeUndefined(); }); @@ -2354,7 +2354,7 @@ describe('browser raw session commands', () => { }); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'snapshot']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'snapshot']); expect(run).toMatchObject({ runId: expect.stringMatching(/^run_/), @@ -2367,7 +2367,7 @@ describe('browser raw session commands', () => { mockSendCommand.mockRejectedValue(new BrowserCommandError('Result unknown', 'command_result_unknown')); const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'snapshot']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'snapshot']); expect(process.exitCode).toBe(1); expect(mockReleaseSiteSessionLease).not.toHaveBeenCalled(); @@ -2378,18 +2378,18 @@ describe('browser raw session commands', () => { fs.writeFileSync(sourcePath, 'return 42;', 'utf8'); try { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'run', '--file', sourcePath]); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'run', '--file', sourcePath]); expect(mockSendCommand).toHaveBeenCalledWith('run', { - session: 'session_test', surface: 'browser', source: 'return 42;', snapshotMode: 'act', + session: 'work-k7', surface: 'browser', source: 'return 42;', snapshotMode: 'act', }); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'run', '--stdin', '--file', sourcePath]); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'run', '--stdin', '--file', sourcePath]); expect(mockSendCommand).toHaveBeenCalledTimes(1); expect(process.exitCode).toBeDefined(); process.exitCode = undefined; fs.writeFileSync(sourcePath, '', 'utf8'); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'run', '--file', sourcePath]); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'run', '--file', sourcePath]); expect(mockSendCommand).toHaveBeenCalledTimes(1); expect(process.exitCode).toBeDefined(); } finally { @@ -2402,10 +2402,10 @@ describe('browser raw session commands', () => { fs.writeFileSync(sourcePath, 'return 42;', 'utf8'); try { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'run', '--file', sourcePath, '--json']); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'run', '--file', sourcePath, '--json']); expect(mockSendCommand).toHaveBeenCalledWith('run', { - session: 'session_test', surface: 'browser', source: 'return 42;', snapshotMode: 'act', + session: 'work-k7', surface: 'browser', source: 'return 42;', snapshotMode: 'act', }); } finally { fs.rmSync(sourcePath, { force: true }); @@ -2414,8 +2414,8 @@ describe('browser raw session commands', () => { it('closes the named session through the daemon', async () => { const program = createProgram('', ''); - await program.parseAsync(['node', 'webcmd', '--session', 'session_test', 'browser', 'close']); - expect(mockSendCommand).toHaveBeenCalledWith('close-window', { session: 'session_test', surface: 'browser' }); + await program.parseAsync(['node', 'webcmd', '--session', 'work-k7', 'browser', 'close']); + expect(mockSendCommand).toHaveBeenCalledWith('close-window', { session: 'work-k7', surface: 'browser' }); }); }); @@ -2433,37 +2433,121 @@ describe('browser Session lifecycle commands', () => { vi.unstubAllGlobals(); }); + it('requires a readable name when creating a Session', async () => { + await expect(createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'create'])) + .rejects.toThrow('process.exit unexpectedly called with "1"'); + expect(mockSendCommand).not.toHaveBeenCalled(); + }); + + it('sends the raw readable name to the durable selected Profile', async () => { + mockSendCommand.mockResolvedValue({ + id: 'work-project-k7', + kind: 'explicit', + profileId: 'work', + runtimeState: 'idle', + }); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'create', 'work']); + consoleLogSpy.mockClear(); + await createProgram('', '').parseAsync(['node', 'webcmd', '--profile', 'work', 'session', 'create', 'Work Project']); + + expect(mockSendCommand).toHaveBeenCalledWith('session-create', { + contextId: 'work', + sessionName: 'Work Project', + }); + expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('work-project-k7'); + }); + + it('keeps the same readable Session ID valid in two Profiles', async () => { + const baseDir = path.join(isolatedCliTestHome, '.webcmd'); + fs.mkdirSync(baseDir, { recursive: true }); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), JSON.stringify({ + version: 2, + sessions: ['default', 'work'].map((profileId) => ({ + id: 'work-project-k7', + profileId, + 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 createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'create', 'work']); + consoleLogSpy.mockClear(); + + await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'list', '-f', 'json']); + expect(JSON.parse(consoleLogSpy.mock.calls.flat().join('\n'))).toEqual([ + expect.objectContaining({ id: 'work-project-k7', profileId: 'default' }), + ]); + + consoleLogSpy.mockClear(); + await createProgram('', '').parseAsync(['node', 'webcmd', '--profile', 'work', 'session', 'list', '-f', 'json']); + expect(JSON.parse(consoleLogSpy.mock.calls.flat().join('\n'))).toEqual([ + expect.objectContaining({ id: 'work-project-k7', profileId: 'work' }), + ]); + }); + + it('reports a missing selected-Profile Session without cross-Profile owner guidance', async () => { + const baseDir = path.join(isolatedCliTestHome, '.webcmd'); + fs.mkdirSync(baseDir, { recursive: true }); + fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), JSON.stringify({ + version: 2, + sessions: [{ + id: 'work-project-k7', + 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 }); + mockSendCommand.mockRejectedValueOnce(new Error('daemon unavailable')); + + await expect(createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'work-project-k7'])) + .rejects.toMatchObject({ + code: 'SESSION_NOT_FOUND', + hint: expect.stringContaining('webcmd --profile default session list'), + }); + }); + + it.each(['session_uuid', 'work-abcdef'])('rejects legacy raw browser selectors: %s', async (selector) => { + await createProgram('', '').parseAsync(['node', 'webcmd', '--session', selector, 'browser', 'tabs', '-f', 'json']); + + expect(mockListExistingBrowserTabs).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(EXIT_CODES.USAGE_ERROR); + }); + it('creates a Session through the daemon mutation path', async () => { mockSendCommand.mockResolvedValue({ - id: 'session_abc', + id: 'work-project-k7', kind: 'explicit', profileId: 'default', runtimeState: 'idle', }); - await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'create']); + await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'create', 'Work Project']); - expect(mockSendCommand).toHaveBeenCalledWith('session-create', { contextId: 'default' }); + expect(mockSendCommand).toHaveBeenCalledWith('session-create', { contextId: 'default', sessionName: 'Work Project' }); const output = consoleLogSpy.mock.calls.flat().join('\n'); - expect(output).toContain('session_abc'); + expect(output).toContain('work-project-k7'); expect(output).toContain('runtimeState'); expect(output).not.toContain('profileId'); }); it('creates a session under a newly created profile', async () => { mockSendCommand.mockResolvedValue({ - id: 'session_eval', + id: 'eval-k7', kind: 'explicit', profileId: 'eval-a', runtimeState: 'idle', }); await createProgram('', '').parseAsync(['node', 'webcmd', 'profile', 'create', 'eval-a']); - await createProgram('', '').parseAsync(['node', 'webcmd', '--profile', 'eval-a', 'session', 'create']); - expect(mockSendCommand).toHaveBeenCalledWith('session-create', { contextId: 'eval-a' }); + await createProgram('', '').parseAsync(['node', 'webcmd', '--profile', 'eval-a', 'session', 'create', 'Eval']); + expect(mockSendCommand).toHaveBeenCalledWith('session-create', { contextId: 'eval-a', sessionName: 'Eval' }); }); it('rejects an unknown --profile on session create with PROFILE_NOT_FOUND', async () => { - await expect(createProgram('', '').parseAsync(['node', 'webcmd', '--profile', 'does-not-exist', 'session', 'create'])) + await expect(createProgram('', '').parseAsync(['node', 'webcmd', '--profile', 'does-not-exist', 'session', 'create', 'Work'])) .rejects.toMatchObject({ code: 'PROFILE_NOT_FOUND', message: expect.stringContaining('does-not-exist'), @@ -2476,9 +2560,9 @@ describe('browser Session lifecycle commands', () => { const baseDir = path.join(isolatedCliTestHome, '.webcmd'); fs.mkdirSync(baseDir, { recursive: true }); fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), JSON.stringify({ - version: 1, + version: 2, sessions: [{ - id: 'session_existing', + id: 'existing-k7', profileId: 'default', kind: 'explicit', createdAt: '2026-08-11T00:00:00.000Z', @@ -2491,7 +2575,7 @@ describe('browser Session lifecycle commands', () => { expect(mockSendCommand).not.toHaveBeenCalled(); const rows = JSON.parse(consoleLogSpy.mock.calls.flat().join('\n')); - expect(rows).toEqual([expect.objectContaining({ id: 'session_existing', runtimeState: 'idle' })]); + expect(rows).toEqual([expect.objectContaining({ id: 'existing-k7', runtimeState: 'idle' })]); }); it('closes an idle persisted Session as a no-op when daemon is absent', async () => { @@ -2499,9 +2583,9 @@ describe('browser Session lifecycle commands', () => { const baseDir = path.join(isolatedCliTestHome, '.webcmd'); fs.mkdirSync(baseDir, { recursive: true }); fs.writeFileSync(path.join(baseDir, 'browser-sessions.json'), JSON.stringify({ - version: 1, + version: 2, sessions: [{ - id: 'session_idle', + id: 'work-project-k7', profileId: 'default', kind: 'explicit', createdAt: '2026-08-11T00:00:00.000Z', @@ -2510,31 +2594,28 @@ describe('browser Session lifecycle commands', () => { }], }), { mode: 0o600 }); - await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'session_idle', '-f', 'json']); + await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'work-project-k7', '-f', 'json']); expect(mockSendCommand).toHaveBeenCalledWith('session-close', { contextId: 'default', - session: 'session_idle', + session: 'work-project-k7', force: false, }); expect(JSON.parse(consoleLogSpy.mock.calls.flat().join('\n'))).toMatchObject({ closed: false, alreadyIdle: true, - session: 'session_idle', + session: 'work-project-k7', }); }); - it('names the owning Profile instead of reporting a cross-Profile close as done', async () => { - // Idempotent close must not swallow a Session that plainly exists next - // door: reporting alreadyClosed there leaves the Session open while the - // agent believes cleanup ran. + it('keeps missing Session guidance scoped to the selected Profile', async () => { 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, + version: 2, sessions: [{ - id: 'session_owned', + id: 'work-project-k7', profileId: 'work', kind: 'explicit', createdAt: '2026-08-11T00:00:00.000Z', @@ -2544,35 +2625,25 @@ describe('browser Session lifecycle commands', () => { }), { mode: 0o600 }); await expect( - createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'session_owned', '-f', 'json']), + createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'work-project-k7', '-f', 'json']), ).rejects.toMatchObject({ code: 'SESSION_NOT_FOUND', - ownerProfileId: 'work', + hint: expect.stringContaining('webcmd --profile default session list'), }); }); - it('closes a missing Session idempotently instead of failing', async () => { + it('reports a missing Session instead of claiming close cleanup completed', async () => { mockSendCommand.mockRejectedValueOnce(new Error('daemon unavailable')); - await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'session_missing', '-f', 'json']); - - expect(JSON.parse(consoleLogSpy.mock.calls.flat().join('\n'))).toMatchObject({ - ok: true, - closed: false, - alreadyClosed: true, - session: 'session_missing', - }); + await expect(createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'missing-k7', '-f', 'json'])) + .rejects.toMatchObject({ code: 'SESSION_NOT_FOUND' }); }); - it('accepts the Session ID from the root --session selector', async () => { + it('accepts a readable Session ID from the root --session selector', async () => { mockSendCommand.mockRejectedValueOnce(new Error('daemon unavailable')); - await createProgram('', '').parseAsync(['node', 'webcmd', '--session', 'session_missing', 'session', 'close', '-f', 'json']); - - expect(JSON.parse(consoleLogSpy.mock.calls.flat().join('\n'))).toMatchObject({ - alreadyClosed: true, - session: 'session_missing', - }); + await expect(createProgram('', '').parseAsync(['node', 'webcmd', '--session', 'missing-k7', 'session', 'close', '-f', 'json'])) + .rejects.toMatchObject({ code: 'SESSION_NOT_FOUND' }); }); it('rejects a malformed Session selector as a usage error', async () => { @@ -2581,9 +2652,9 @@ describe('browser Session lifecycle commands', () => { }); it.each([ - ['create'], + ['create', 'Work'], ['list'], - ['close', 'session_abc'], + ['close', 'work-project-k7'], ])('rejects an unsupported format before local Session %s side effects', async (...subcommand) => { const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { @@ -2599,18 +2670,18 @@ describe('browser Session lifecycle commands', () => { it('normalizes aliases and case across local Session create/list/close', async () => { mockSendCommand.mockImplementation(async (command) => command === 'session-create' - ? { id: 'session_abc', kind: 'explicit', runtimeState: 'idle' } - : { closed: true, session: 'session_abc' }); + ? { id: 'work-project-k7', kind: 'explicit', runtimeState: 'idle' } + : { closed: true, session: 'work-project-k7' }); - await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'create', '-f', 'JSON']); - expect(JSON.parse(consoleLogSpy.mock.calls.flat().join('\n'))).toMatchObject({ id: 'session_abc' }); + await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'create', 'Work Project', '-f', 'JSON']); + expect(JSON.parse(consoleLogSpy.mock.calls.flat().join('\n'))).toMatchObject({ id: 'work-project-k7' }); consoleLogSpy.mockClear(); await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'list', '-f', 'YML']); expect(yaml.load(consoleLogSpy.mock.calls.flat().join('\n'))).toEqual([]); consoleLogSpy.mockClear(); - await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'session_abc', '-f', 'Markdown']); + await createProgram('', '').parseAsync(['node', 'webcmd', 'session', 'close', 'work-project-k7', '-f', 'Markdown']); expect(consoleLogSpy.mock.calls.flat().join('\n')).toContain('| closed | session |'); }); @@ -2659,7 +2730,7 @@ function installSelectorFirstTestHarness(label: string, pageOverrides: () => Par setActivePage: vi.fn(), getActivePage: vi.fn().mockReturnValue('tab-1'), tabs: vi.fn().mockResolvedValue([{ page: 'tab-1', active: true }]), - session: 'session_test', + session: 'work-k7', ...pageOverrides(), } as unknown as IPage; }); diff --git a/src/cli.ts b/src/cli.ts index bae75c48..b87eaadd 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -54,7 +54,8 @@ import type { BrowserDownloadWaitResult, IPage, ScreenshotOptions } from './type import type { BrowserWindowMode } from './runtime.js'; import { configureRootCommandSurface } from './root-command-surface.js'; import { validateRawBrowserSession } from './hosted/browser-args.js'; -import { LocalBrowserSessionStore, SessionNotFoundError, requireSessionIdShape, type BrowserSessionListRow } from './browser/sessions.js'; +import { LocalBrowserSessionStore, type BrowserSessionListRow } from './browser/sessions.js'; +import { requireSessionIdShape } from './browser/session-identifiers.js'; import { getAdapterLoadFailures, PLUGINS_DIR } from './discovery.js'; import { unknownRootCommandMessage, unknownSubcommandHelp, unknownSubcommandMessage } from './command-suggest.js'; import { loadBrowserRunSource } from './browser/run/input.js'; @@ -597,12 +598,6 @@ async function requireKnownProfileId(command?: Command): Promise { return profileId; } -/** True for "this Session does not exist here" errors, from either the durable store or the runtime. */ -function isSessionMissingError(error: unknown): boolean { - const code = (error as { code?: unknown } | null)?.code; - return code === 'SESSION_NOT_FOUND' || code === 'session_not_found'; -} - function formatHandoff(row: BrowserSessionListRow): string { return row.handoff ? `${row.handoff.site} until ${row.handoff.expiresAt}` : ''; } @@ -910,12 +905,13 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi const sessionCreateCmd = addOutputFormatOption(sessionCmd .command('create') - .description('Create a new opaque browser Session ID for the selected Profile'), 'yaml'); - sessionCreateCmd.action(async (opts, command) => { + .description('Create a readable browser Session ID for the selected Profile') + .argument('', 'Human-readable Session base name'), 'yaml'); + sessionCreateCmd.action(async (name: string, opts, command) => { const fmt = resolveCommandOutputFormat(command, opts.format); if (fmt === null) return; const profileId = await requireKnownProfileId(command); - const data = await sendCommand('session-create', { contextId: profileId }); + const data = await sendCommand('session-create', { contextId: profileId, sessionName: name }); await renderOutput(sessionCreateOutput(data), { fmt, fmtExplicit: outputFormatIsExplicit(command), columns: ['id', 'kind', 'runtimeState'] }); }); @@ -948,7 +944,7 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi const sessionCloseCmd = addOutputFormatOption(sessionCmd .command('close') .description('Close a browser Session runtime without deleting its durable record') - .argument('[session-id]', 'Existing opaque Session ID from `webcmd session create` (or pass the root `--session ` selector)') + .argument('[session-id]', 'Existing readable Session ID from `webcmd session create ` (or pass the root `--session ` selector)') .option('--force', 'Close even while the Session is busy or paused for handoff'), 'yaml'); sessionCloseCmd.action(async (positionalSessionId: string | undefined, opts: { format?: string; force?: boolean }, command) => { const fmt = resolveCommandOutputFormat(command, opts.format); @@ -959,48 +955,30 @@ export function createProgram(BUILTIN_CLIS: string, USER_CLIS: string, pluginsDi if (!sessionId) { throw new ArgumentError( 'Missing Session ID.', - `Use \`${CLI_COMMAND} session close \` or \`${CLI_COMMAND} --session session close\`.`, + `Use \`${CLI_COMMAND} session close \` or \`${CLI_COMMAND} --session session close\` with a readable Session ID.`, ); } requireSessionIdShape(sessionId); - try { - const status = await fetchDaemonStatus({ contextId: profileId }); - if (!status || (status.runtimeConnected && !isDaemonStale(status, PKG_VERSION))) { - try { - const data = await sendCommand('session-close', { - contextId: profileId, - session: sessionId, - force: opts.force === true, - }); - await renderOutput(data, { fmt, fmtExplicit: outputFormatIsExplicit(command) }); - return; - } catch (error) { - if (status || opts.force === true) throw error; - } - } - if (opts.force === true) { - const data = await sendCommand('session-close', { contextId: profileId, session: sessionId, force: true }); + const status = await fetchDaemonStatus({ contextId: profileId }); + if (!status || (status.runtimeConnected && !isDaemonStale(status, PKG_VERSION))) { + try { + const data = await sendCommand('session-close', { + contextId: profileId, + session: sessionId, + force: opts.force === true, + }); await renderOutput(data, { fmt, fmtExplicit: outputFormatIsExplicit(command) }); return; + } catch (error) { + if (status || opts.force === true) throw error; } - new LocalBrowserSessionStore().require(profileId, sessionId); - } catch (error) { - // Closing an already-closed / reaped / never-existed Session is a no-op, - // not a failure: cleanup must stay idempotent for unattended agents. - if (!isSessionMissingError(error)) throw error; - // A Session that exists under another Profile is the one case that must - // not report success: the Session stays open, so "alreadyClosed" tells - // an unattended agent its cleanup ran when nothing was closed. - const owner = new LocalBrowserSessionStore().findOwner(sessionId); - if (owner !== undefined && owner !== profileId) { - throw new SessionNotFoundError(sessionId, profileId, owner); - } - await renderOutput( - { ok: true, closed: false, alreadyClosed: true, session: sessionId }, - { fmt, fmtExplicit: outputFormatIsExplicit(command) }, - ); + } + if (opts.force === true) { + const data = await sendCommand('session-close', { contextId: profileId, session: sessionId, force: true }); + await renderOutput(data, { fmt, fmtExplicit: outputFormatIsExplicit(command) }); return; } + new LocalBrowserSessionStore().require(profileId, sessionId); await renderOutput({ closed: false, alreadyIdle: true, session: sessionId }, { fmt, fmtExplicit: outputFormatIsExplicit(command) }); }); @@ -1085,7 +1063,7 @@ cli({ ok: true, action: 'init', adapter: name, path: filePath, created: true, }, () => { console.log(`Created: ${filePath}`); - console.log('First time on this site? Run: webcmd session create, then webcmd --session browser run --stdin'); + console.log('First time on this site? Run: webcmd session create , then webcmd --session browser run --stdin'); console.log(`Edit the file to implement your adapter, then run: webcmd browser verify ${name}`); }); } catch (err) { diff --git a/src/daemon/server.test.ts b/src/daemon/server.test.ts index 93cc63d7..2cfda62c 100644 --- a/src/daemon/server.test.ts +++ b/src/daemon/server.test.ts @@ -9,6 +9,7 @@ import { createDaemonServer } from './server.js'; class FakeProvider implements BrowserRuntimeProvider { commands: BrowserRuntimeCommand[] = []; + createSessionCommands: BrowserRuntimeCommand[] = []; sessions: BrowserSessionRecord[] = []; activeSessions = new Set(); foregroundedSessions: string[] = []; @@ -16,7 +17,7 @@ class FakeProvider implements BrowserRuntimeProvider { delayMs = 0; dispatchImpl?: (command: BrowserRuntimeCommand, signal?: AbortSignal) => Promise; resolveProfileId?: (command: BrowserRuntimeCommand) => string; - sessionId = 'session_11111111-1111-4111-8111-111111111111'; + sessionId = 'work-project-k7'; private result(command: BrowserRuntimeCommand) { return { id: command.id, ok: true as const, data: { action: command.action }, page: 'page-1' }; @@ -35,6 +36,7 @@ class FakeProvider implements BrowserRuntimeProvider { } async createSession(command: BrowserRuntimeCommand): Promise { + this.createSessionCommands.push(command); const profileId = command.contextId ?? 'default'; const session = { id: this.sessionId, @@ -220,29 +222,37 @@ describe('createDaemonServer', () => { it('handles local Session lifecycle controls outside normal dispatch', async () => { const { provider, baseUrl } = await start(); - const created = await postCommand(baseUrl, { id: 'create-session', action: 'session-create' as BrowserRuntimeCommand['action'], contextId: 'profile_work' }); + const created = await postCommand(baseUrl, { + id: 'create', + action: 'session-create' as BrowserRuntimeCommand['action'], + contextId: 'profile-a', + sessionName: 'Work Project', + }); expect(created.status).toBe(200); await expect(created.json()).resolves.toMatchObject({ ok: true, - data: { id: provider.sessionId, profileId: 'profile_work', kind: 'explicit' }, + data: { id: 'work-project-k7', profileId: 'profile-a', kind: 'explicit' }, }); + expect(provider.createSessionCommands).toEqual([ + expect.objectContaining({ contextId: 'profile-a', sessionName: 'Work Project' }), + ]); provider.activeSessions.add(provider.sessionId); - const listed = await postCommand(baseUrl, { id: 'list-sessions', action: 'session-list' as BrowserRuntimeCommand['action'], contextId: 'profile_work' }); + const listed = await postCommand(baseUrl, { id: 'list-sessions', action: 'session-list' as BrowserRuntimeCommand['action'], contextId: 'profile-a' }); expect(listed.status).toBe(200); await expect(listed.json()).resolves.toMatchObject({ ok: true, data: [{ id: provider.sessionId, runtimeState: 'active' }], }); - const closed = await postCommand(baseUrl, { id: 'close-session', action: 'session-close' as BrowserRuntimeCommand['action'], contextId: 'profile_work', session: provider.sessionId }); + const closed = await postCommand(baseUrl, { id: 'close-session', action: 'session-close' as BrowserRuntimeCommand['action'], contextId: 'profile-a', session: provider.sessionId }); expect(closed.status).toBe(200); await expect(closed.json()).resolves.toMatchObject({ ok: true, data: { closed: true, alreadyIdle: false, session: provider.sessionId }, }); - const closedAgain = await postCommand(baseUrl, { id: 'close-session-again', action: 'session-close' as BrowserRuntimeCommand['action'], contextId: 'profile_work', session: provider.sessionId }); + const closedAgain = await postCommand(baseUrl, { id: 'close-session-again', action: 'session-close' as BrowserRuntimeCommand['action'], contextId: 'profile-a', session: provider.sessionId }); expect(closedAgain.status).toBe(200); await expect(closedAgain.json()).resolves.toMatchObject({ ok: true, diff --git a/src/hosted/browser-args.ts b/src/hosted/browser-args.ts index abdcbd70..eead1e27 100644 --- a/src/hosted/browser-args.ts +++ b/src/hosted/browser-args.ts @@ -8,6 +8,7 @@ import { import { addOutputFormatOption, CommanderStructuralError } from '../command-surface.js'; import { CliError, EXIT_CODES } from '../errors.js'; import { configureRootCommandSurface } from '../root-command-surface.js'; +import { requireSessionIdShape } from '../browser/session-identifiers.js'; export class HostedBrowserHelp extends Error { constructor(readonly output: string) { @@ -28,11 +29,9 @@ export interface ParsedHostedBrowserStructure { export function validateRawBrowserSession(value: unknown, profile?: string): string { const session = typeof value === 'string' ? value.trim() : ''; const profileFlag = profile?.trim() ? ` --profile ${profile.trim()}` : ''; - const help = `Create one: webcmd${profileFlag} session create\nList sessions: webcmd${profileFlag} session list`; + const help = `Create one: webcmd${profileFlag} session create \nList sessions: webcmd${profileFlag} session list`; if (!session) throw new CliError('SESSION_REQUIRED', 'A Session selector is required for browser commands.', help, EXIT_CODES.USAGE_ERROR); - if (!/^session_[A-Za-z0-9_-]+$/u.test(session)) { - throw new CliError('INVALID_SESSION_SELECTOR', 'Session selector must be an opaque Session ID.', help, EXIT_CODES.USAGE_ERROR); - } + requireSessionIdShape(session); return session; } diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index bf783af2..0cdd1adf 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -2499,7 +2499,7 @@ describe('runHostedCli', () => { const stderr = sink(); const fetchImpl = vi.fn(); - const result = await runHostedCli(['--session', 'session_work', 'browser', ...parts, '--help'], { + const result = await runHostedCli(['--session', 'work-k7', 'browser', ...parts, '--help'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: stdout.stream, stderr: stderr.stream, @@ -2531,7 +2531,7 @@ describe('runHostedCli', () => { : contract.command === 'run' ? ['--file', uploadFile] : []; - const result = await runHostedCli(['--session', 'session_work', 'browser', ...contract.command.split('/'), ...positionals, ...options], { + const result = await runHostedCli(['--session', 'work-k7', 'browser', ...contract.command.split('/'), ...positionals, ...options], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: sink().stream, stderr: sink().stream, @@ -2540,7 +2540,7 @@ describe('runHostedCli', () => { const body = init?.body ? JSON.parse(String(init.body)) as Record : undefined; requests.push({ pathname: parsedUrl.pathname, ...(body ? { body } : {}) }); if (parsedUrl.pathname === '/v1/manifest') return manifestResponse(); - if (parsedUrl.pathname === '/v1/browser/session_work/commands') { + if (parsedUrl.pathname === '/v1/browser/work-k7/commands') { return new Response(JSON.stringify({ ok: true, result: {}, @@ -2548,7 +2548,7 @@ describe('runHostedCli', () => { trace: null, run: { executionId: `exec_${contract.command.replaceAll('/', '_')}`, - session: 'session_work', + session: 'work-k7', profile: { id: 'profile_default', displayName: 'default' }, }, execution: { id: `exec_${contract.command.replaceAll('/', '_')}`, status: 'succeeded' }, @@ -2567,7 +2567,7 @@ describe('runHostedCli', () => { }); expect({ command: contract.command, - action: requests.find(request => request.pathname === '/v1/browser/session_work/commands')?.body, + action: requests.find(request => request.pathname === '/v1/browser/work-k7/commands')?.body, }).toMatchObject({ command: contract.command, action: { command: `browser/${contract.command}`, action: contract.action }, @@ -2581,7 +2581,7 @@ describe('runHostedCli', () => { it('dispatches hosted browser verify with its local verification options', async () => { const requests: Array<{ url: string; body?: Record }> = []; const result = await runHostedCli([ - '--session', 'session_work', 'browser', 'verify', 'hn/top', + '--session', 'work-k7', 'browser', 'verify', 'hn/top', '--no-fixture', '--write-fixture', '--update-fixture', '--strict-memory', '--seed-args', '{"limit":3}', '--trace', 'retain-on-failure', '--max-top-level-keys', '20', ], { @@ -2597,7 +2597,7 @@ describe('runHostedCli', () => { result: {}, columns: [], trace: null, - run: { executionId: 'exec_browser_verify', session: 'session_work', profile: { id: 'profile_default', displayName: 'default' } }, + run: { executionId: 'exec_browser_verify', session: 'work-k7', profile: { id: 'profile_default', displayName: 'default' } }, execution: { id: 'exec_browser_verify', status: 'succeeded' }, }), { status: 200 }); }, @@ -2622,7 +2622,7 @@ describe('runHostedCli', () => { it('dispatches hosted browser verify with a numeric default maxTopLevelKeys', async () => { const requests: Array<{ body?: Record }> = []; - const result = await runHostedCli(['--session', 'session_work', 'browser', 'verify', 'hn/top'], { + const result = await runHostedCli(['--session', 'work-k7', 'browser', 'verify', 'hn/top'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: sink().stream, stderr: sink().stream, @@ -2635,7 +2635,7 @@ describe('runHostedCli', () => { result: {}, columns: [], trace: null, - run: { executionId: 'exec_browser_verify', session: 'session_work', profile: { id: 'profile_default', displayName: 'default' } }, + run: { executionId: 'exec_browser_verify', session: 'work-k7', profile: { id: 'profile_default', displayName: 'default' } }, execution: { id: 'exec_browser_verify', status: 'succeeded' }, }), { status: 200 }); }, @@ -2651,7 +2651,7 @@ describe('runHostedCli', () => { await writeFile(sourcePath, 'return 42;'); const requests: Array<{ url: string; body?: Record }> = []; try { - const result = await runHostedCli(['--session', 'session_work', 'browser', 'run', '--file', sourcePath, '--snapshot-mode', 'tree', '--no-snapshot-diff'], { + const result = await runHostedCli(['--session', 'work-k7', 'browser', 'run', '--file', sourcePath, '--snapshot-mode', 'tree', '--no-snapshot-diff'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: sink().stream, stderr: sink().stream, @@ -2664,7 +2664,7 @@ describe('runHostedCli', () => { result: {}, columns: [], trace: null, - run: { executionId: 'exec_browser_run', session: 'session_work', profile: { id: 'profile_default', displayName: 'default' } }, + run: { executionId: 'exec_browser_run', session: 'work-k7', profile: { id: 'profile_default', displayName: 'default' } }, execution: { id: 'exec_browser_run', status: 'succeeded' }, }), { status: 200 }); }, @@ -2684,7 +2684,7 @@ describe('runHostedCli', () => { it('forwards browser snapshot mode to hosted browser actions', async () => { const requests: Array<{ url: string; body?: Record }> = []; - const result = await runHostedCli(['--session', 'session_work', 'browser', 'snapshot', '--snapshot-mode', 'read', '--ref', 'l7', '--max-output', '1000'], { + const result = await runHostedCli(['--session', 'work-k7', 'browser', 'snapshot', '--snapshot-mode', 'read', '--ref', 'l7', '--max-output', '1000'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: sink().stream, stderr: sink().stream, @@ -2694,7 +2694,7 @@ describe('runHostedCli', () => { if (String(url).endsWith('/v1/manifest')) return manifestResponse(); return new Response(JSON.stringify({ ok: true, - run: { executionId: 'exec_browser_snapshot', session: 'session_work', profile: { id: 'profile_default', displayName: 'default' } }, + run: { executionId: 'exec_browser_snapshot', session: 'work-k7', profile: { id: 'profile_default', displayName: 'default' } }, result: { ok: true, tree: '', page: { url: 'https://example.test', title: 'Example' }, warnings: [], limits: { snapshotTruncated: false } }, }), { status: 200 }); }, @@ -2709,7 +2709,7 @@ describe('runHostedCli', () => { it('prints hosted snapshot trees', async () => { const stdout = sink(); - const result = await runHostedCli(['--session', 'session_work', 'browser', 'snapshot'], { + const result = await runHostedCli(['--session', 'work-k7', 'browser', 'snapshot'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: stdout.stream, stderr: sink().stream, @@ -2717,7 +2717,7 @@ describe('runHostedCli', () => { ? manifestResponse() : new Response(JSON.stringify({ ok: true, - run: { executionId: 'exec_browser_snapshot', session: 'session_work', profile: { id: 'profile_default', displayName: 'default' } }, + run: { executionId: 'exec_browser_snapshot', session: 'work-k7', profile: { id: 'profile_default', displayName: 'default' } }, result: { ok: true, tree: '', page: { url: 'https://example.test', title: 'Example' }, warnings: [], limits: { snapshotTruncated: false } }, }), { status: 200 }), }); diff --git a/src/root-command-surface.ts b/src/root-command-surface.ts index b312644f..e68f7690 100644 --- a/src/root-command-surface.ts +++ b/src/root-command-surface.ts @@ -5,7 +5,7 @@ import { PKG_VERSION } from './version.js'; export const ROOT_PROFILE_FLAGS = '--profile '; export const ROOT_PROFILE_DESCRIPTION = 'Chrome profile/context alias for browser runtime commands'; export const ROOT_SESSION_FLAGS = '--session '; -export const ROOT_SESSION_DESCRIPTION = 'Existing opaque Session ID from `webcmd session create`'; +export const ROOT_SESSION_DESCRIPTION = 'Existing readable Session ID from `webcmd session create `'; export const ROOT_SESSION_SELECTOR_POSITION = 'root'; export const COMPLETION_SENTINEL = '--get-completions'; diff --git a/src/session-lease.test.ts b/src/session-lease.test.ts index bc31ca92..4a9f50e8 100644 --- a/src/session-lease.test.ts +++ b/src/session-lease.test.ts @@ -346,4 +346,26 @@ describe('SessionLeaseRegistry', () => { expect(leases.releaseByRunId('run_111_1_1')).toBe(2); expect(leases.list(() => false)).toEqual([]); }); + + it('fences stale work when a readable Session ID is recreated', () => { + const leases = registry(); + const recreatedKey = getSessionLeaseKey('work', 'work-project-k7'); + const oldRun = 'run_111_1_1'; + const newRun = 'run_222_2_2'; + + expect(acquire(leases, oldRun, recreatedKey).acquired).toBe(true); + expect(leases.releaseByRunId(oldRun)).toBe(1); + expect(acquire(leases, newRun, recreatedKey).acquired).toBe(true); + expect(leases.heartbeat(recreatedKey, oldRun)).toBe(false); + expect(leases.releaseByRunId(oldRun)).toBe(0); + expect(leases.list(() => false)).toEqual([ + expect.objectContaining({ key: recreatedKey, runId: newRun }), + ]); + + setDaemonRunContext({ runId: oldRun, command: 'old Session work' }); + setDaemonRunContext({ runId: newRun, command: 'new Session work' }); + clearDaemonRunContext(oldRun); + expect(getDaemonRunContext()).toMatchObject({ runId: newRun }); + clearDaemonRunContext(newRun); + }); }); From 829eb2406fd26168638a0b0155d369a8653222f0 Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Tue, 25 Aug 2026 21:02:54 +0530 Subject: [PATCH 3/5] feat: send readable session names to cloud --- .../__fixtures__/differential-backend.ts | 8 ++- src/hosted/client.test.ts | 13 +++-- src/hosted/client.ts | 4 +- src/hosted/programmatic-differential.test.ts | 2 +- src/hosted/runner.test.ts | 51 +++++++++++++++++-- src/hosted/runner.ts | 23 +++++---- 6 files changed, 80 insertions(+), 21 deletions(-) diff --git a/src/hosted/__fixtures__/differential-backend.ts b/src/hosted/__fixtures__/differential-backend.ts index 473f1c96..6758f21d 100644 --- a/src/hosted/__fixtures__/differential-backend.ts +++ b/src/hosted/__fixtures__/differential-backend.ts @@ -103,7 +103,13 @@ export async function startDifferentialBackend(): Promise { if (method === 'GET' && path === '/v1/profiles') return json(response, { ok: true, profiles: [] }); if (method === 'DELETE' && path === '/v1/profiles/profile_fixture') return json(response, { ok: true, deleted: true }); if (method === 'GET' && path === '/v1/sessions') return json(response, { ok: true, sessions: [] }); - if (method === 'POST' && path === '/v1/sessions') return json(response, { ok: true, session: { ...session, liveViewUrl: 'https://cloud.example.test/account/live/fixture-token' } }); + if (method === 'POST' && path === '/v1/sessions') { + const requestBody = JSON.parse(body) as { name?: unknown }; + if (typeof requestBody.name !== 'string') { + return json(response, { ok: false, error: { code: 'INVALID_SESSION_NAME', message: 'Session name is required.', exitCode: 2 } }, 422); + } + return json(response, { ok: true, session: { ...session, liveViewUrl: 'https://cloud.example.test/account/live/fixture-token' } }); + } if (method === 'POST' && path === '/v1/sessions/session_fixture/close') return json(response, { ok: true, closed: true, alreadyIdle: false, session: 'session_fixture' }); if (method === 'POST' && path === '/v1/browser/session_fixture/commands') { const invocation = JSON.parse(body) as { action?: string }; diff --git a/src/hosted/client.test.ts b/src/hosted/client.test.ts index e7f4cb1b..a852d1c7 100644 --- a/src/hosted/client.test.ts +++ b/src/hosted/client.test.ts @@ -161,7 +161,7 @@ describe('HostedClient', () => { }, }); - await expect(client.createBrowserSession()).resolves.toEqual({ ok: true, session: createdSession }); + await expect(client.createBrowserSession('Work Project', 'work')).resolves.toEqual({ ok: true, session: createdSession }); await expect(client.listBrowserSessions('default', 20)).resolves.toEqual({ ok: true, sessions: [session] }); await expect(client.closeBrowserSession('session_wire')).resolves.toEqual({ ok: true, @@ -169,8 +169,13 @@ describe('HostedClient', () => { alreadyIdle: false, session: 'session_wire', }); - expect(requests.map(({ url, method, liveViewCapability }) => ({ url, method, liveViewCapability }))).toEqual([ - { url: 'https://api.example.com/v1/sessions', method: 'POST', liveViewCapability: 'hosted-live-view-v1' }, + expect(requests[0]).toEqual({ + url: 'https://api.example.com/v1/sessions', + method: 'POST', + body: '{"name":"Work Project","profile":"work"}', + liveViewCapability: 'hosted-live-view-v1', + }); + expect(requests.slice(1).map(({ url, method, liveViewCapability }) => ({ url, method, liveViewCapability }))).toEqual([ { url: 'https://api.example.com/v1/sessions?profile=default&limit=20', method: 'GET', liveViewCapability: 'hosted-live-view-v1' }, { url: 'https://api.example.com/v1/sessions/session_wire/close', method: 'POST', liveViewCapability: 'hosted-live-view-v1' }, ]); @@ -210,7 +215,7 @@ describe('HostedClient', () => { }, }); - await expect(client.createBrowserSession()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' }); + await expect(client.createBrowserSession('Work Project')).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' }); await expect(client.listBrowserSessions()).rejects.toMatchObject({ code: 'HOSTED_PROTOCOL' }); await expect(client.prepareExecution({ command: 'github/whoami', profile: 'work', session: 'session_work', executionScope: 'profile', diff --git a/src/hosted/client.ts b/src/hosted/client.ts index eb6b2329..9fdc512a 100644 --- a/src/hosted/client.ts +++ b/src/hosted/client.ts @@ -144,10 +144,10 @@ export class HostedClient { return { ok: true, deleted: true }; } - async createBrowserSession(profile?: string): Promise { + async createBrowserSession(name: string, profile?: string): Promise { const body = await this.request('/v1/sessions', { method: 'POST', - body: JSON.stringify(profile !== undefined ? { profile } : {}), + body: JSON.stringify(profile !== undefined ? { name, profile } : { name }), }); if (!isHostedBrowserSessionResponse(body)) { throw protocolError('Webcmd Cloud returned an invalid browser session response.'); diff --git a/src/hosted/programmatic-differential.test.ts b/src/hosted/programmatic-differential.test.ts index 955dcbbd..20b10322 100644 --- a/src/hosted/programmatic-differential.test.ts +++ b/src/hosted/programmatic-differential.test.ts @@ -72,7 +72,7 @@ const FIXTURES: { name: string; argv: string[]; files?: readonly HostedVirtualFi { name: 'auth refresh', argv: ['auth', 'refresh', '--site', 'acme'] }, { name: 'browser tabs', argv: ['browser', 'tabs', '--session', 'session_fixture'] }, { name: 'browser snapshot', argv: ['browser', 'snapshot', '--session', 'session_fixture', '-f', 'json'] }, - { name: 'session create', argv: ['session', 'create', '-f', 'json'] }, + { name: 'session create', argv: ['session', 'create', 'Work Project', '-f', 'json'] }, { name: 'session list', argv: ['session', 'list'] }, { name: 'session close', argv: ['session', 'close', 'session_fixture'] }, { name: 'profile list', argv: ['profile', 'list'] }, diff --git a/src/hosted/runner.test.ts b/src/hosted/runner.test.ts index 0cdd1adf..db7c0812 100644 --- a/src/hosted/runner.test.ts +++ b/src/hosted/runner.test.ts @@ -15,6 +15,7 @@ import { HOSTED_ROOT_HELP } from '../completion-shared.js'; import { PKG_VERSION } from '../version.js'; import { makeHostedConfig, makeLocalConfig } from './config.js'; import { createCaptureStream } from './capture-stream.js'; +import { HostedClient } from './client.js'; import { runHostedCli } from './runner.js'; import { createVirtualFileMap, createVirtualOutputSink } from './virtual-files.js'; @@ -910,7 +911,7 @@ describe('runHostedCli', () => { ['plugin search'], ['profile list'], ['list'], - ['session create'], + ['session create Work-Project'], ['session list'], ['session close session_abc'], ])('rejects an unknown hosted %s format without an API call', async (argvCommand) => { @@ -933,6 +934,48 @@ describe('runHostedCli', () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + it('treats a missing hosted Session create name as a structural usage error without an API call', async () => { + const stdout = sink(); + const stderr = sink(); + const fetchImpl = vi.fn(); + + const result = await runHostedCli(['session', 'create'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + stderr: stderr.stream, + fetchImpl, + }); + + expect(result).toEqual({ handled: true, exitCode: 2 }); + expect(stdout.text()).toBe(''); + expect(stderr.text()).toContain("error: missing required argument 'name'"); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + it('forwards the raw hosted Session create name and Profile to the client', async () => { + const stdout = sink(); + const createdSession = { + id: 'session_work', kind: 'explicit', profileId: 'profile_work', runtimeState: 'idle', handoff: null, + createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:01:00.000Z', lastUsedAt: '2026-01-01T00:02:00.000Z', + liveViewUrl: 'https://api.example.com/account/live/session_work', + } as const; + const createBrowserSession = vi.spyOn(HostedClient.prototype, 'createBrowserSession').mockResolvedValue({ ok: true, session: createdSession }); + try { + const result = await runHostedCli(['--profile', 'work', 'session', 'create', 'Work Project'], { + config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), + stdout: stdout.stream, + fetchImpl: async (url) => String(url).endsWith('/v1/manifest') + ? manifestResponse() + : new Response(JSON.stringify({ ok: false, error: { code: 'UNEXPECTED', message: String(url), exitCode: 1 } })), + }); + + expect(result).toEqual({ handled: true, exitCode: 0 }); + expect(createBrowserSession).toHaveBeenCalledExactlyOnceWith('Work Project', 'work'); + } finally { + createBrowserSession.mockRestore(); + } + }); + it('normalizes hosted list output format aliases and case', async () => { const stdout = sink(); const stderr = sink(); @@ -1011,7 +1054,7 @@ describe('runHostedCli', () => { const outputs: string[] = []; for (const argv of [ - ['--profile', 'work', 'session', 'create', '-f', 'json'], + ['--profile', 'work', 'session', 'create', 'Work Project', '-f', 'json'], ['--profile', 'work', 'session', 'list', '-f', 'json'], ['--profile', 'work', 'session', 'close', session.id, '--force', '-f', 'json'], ]) { @@ -1032,7 +1075,7 @@ describe('runHostedCli', () => { expect(requests).toEqual([ { url: 'https://api.example.com/v1/manifest', method: 'GET' }, - { url: 'https://api.example.com/v1/sessions', method: 'POST', body: { profile: 'work' } }, + { url: 'https://api.example.com/v1/sessions', method: 'POST', body: { name: 'Work Project', profile: 'work' } }, { url: 'https://api.example.com/v1/manifest', method: 'GET' }, { url: 'https://api.example.com/v1/sessions?profile=work&limit=20', method: 'GET' }, { url: 'https://api.example.com/v1/manifest', method: 'GET' }, @@ -1060,7 +1103,7 @@ describe('runHostedCli', () => { }); const create = sink(); - await runHostedCli(['session', 'create', '-f', 'JSON'], { + await runHostedCli(['session', 'create', 'Work Project', '-f', 'JSON'], { config: makeHostedConfig({ apiBaseUrl: 'https://api.example.com', apiKey: 'key' }), stdout: create.stream, fetchImpl, }); expect(JSON.parse(create.text())).toMatchObject({ id: session.id }); diff --git a/src/hosted/runner.ts b/src/hosted/runner.ts index 7a140275..848be50a 100644 --- a/src/hosted/runner.ts +++ b/src/hosted/runner.ts @@ -814,7 +814,9 @@ async function hostedAdapterSourceMetadata(client: HostedClient, key: string): P type ParsedHostedSessionSurface = | { kind: 'help'; output: string } - | { kind: 'run'; command: 'create' | 'list' | 'close'; format: string; formatExplicit: boolean; session?: string; force?: boolean; limit?: number }; + | { kind: 'run'; command: 'create'; name: string; format: string; formatExplicit: boolean } + | { kind: 'run'; command: 'list'; format: string; formatExplicit: boolean; limit: number } + | { kind: 'run'; command: 'close'; format: string; formatExplicit: boolean; session: string; force: boolean }; function parseHostedSessionSurface(argv: readonly string[], literal: boolean): ParsedHostedSessionSurface { let stdout = ''; @@ -829,15 +831,18 @@ function parseHostedSessionSurface(argv: readonly string[], literal: boolean): P root.exitOverride().configureOutput(output); session.exitOverride().configureOutput(output); const configure = (command: Command, format: string): Command => addOutputFormatOption(command, format); - const setParsed = (command: 'create' | 'list' | 'close', surface: Command, format: string, extras: Omit, 'kind' | 'command' | 'format' | 'formatExplicit'> = {}): void => { - parsed = { kind: 'run', command, format: validateHostedFormat(String(requestedOutputFormat(surface, format))), formatExplicit: outputFormatIsExplicit(surface), ...extras }; - }; - const create = configure(session.command('create'), 'yaml'); - create.action((options: { format: string }) => setParsed('create', create, options.format)); + const create = configure(session.command('create').argument(''), 'yaml'); + create.action((name: string, options: { format: string }) => { + parsed = { kind: 'run', command: 'create', name, format: validateHostedFormat(String(requestedOutputFormat(create, options.format))), formatExplicit: outputFormatIsExplicit(create) }; + }); const list = configure(session.command('list').option('--limit ', 'Maximum Sessions to return (1-100)', parseHostedSessionListLimit, 20), 'table'); - list.action((options: { format: string; limit: number }) => setParsed('list', list, options.format, { limit: options.limit })); + list.action((options: { format: string; limit: number }) => { + parsed = { kind: 'run', command: 'list', format: validateHostedFormat(String(requestedOutputFormat(list, options.format))), formatExplicit: outputFormatIsExplicit(list), limit: options.limit }; + }); const close = configure(session.command('close').argument('').option('--force', 'Close even while the Session is busy or paused for handoff'), 'yaml'); - close.action((sessionId: string, options: { format: string; force?: boolean }) => setParsed('close', close, options.format, { session: sessionId, force: options.force === true })); + close.action((sessionId: string, options: { format: string; force?: boolean }) => { + parsed = { kind: 'run', command: 'close', format: validateHostedFormat(String(requestedOutputFormat(close, options.format))), formatExplicit: outputFormatIsExplicit(close), session: sessionId, force: options.force === true }; + }); try { root.parse(literal ? ['--', 'session', ...argv] : ['session', ...argv], { from: 'user' }); } catch (error) { @@ -856,7 +861,7 @@ async function dispatchHostedSession( profile?: string, ): Promise { if (parsed.command === 'create') { - await renderOutput(sessionCreateOutput((await client.createBrowserSession(profile)).session), { fmt: parsed.format, fmtExplicit: parsed.formatExplicit, columns: ['id', 'kind', 'runtimeState'], stdout }); + await renderOutput(sessionCreateOutput((await client.createBrowserSession(parsed.name, profile)).session), { fmt: parsed.format, fmtExplicit: parsed.formatExplicit, columns: ['id', 'kind', 'runtimeState'], stdout }); return; } if (parsed.command === 'list') { From eb8a5cff424f41429a18fb081d25230ab52afc39 Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Tue, 25 Aug 2026 21:17:12 +0530 Subject: [PATCH 4/5] docs: teach readable session identifiers --- README.md | 17 ++++---- docs/agents/claude-code.md | 2 +- docs/agents/codex-cli.md | 2 +- docs/agents/cursor.md | 6 +-- docs/agents/hermes.md | 4 +- docs/agents/openclaw.md | 2 +- docs/agents/opencode.md | 4 +- docs/agents/pi.md | 2 +- docs/cli-reference.mdx | 40 +++++++++---------- docs/concepts.mdx | 2 +- docs/skills.mdx | 2 +- docs/troubleshooting.mdx | 4 +- mcp-skills/webcmd-adapter-author.md | 7 +++- mcp-skills/webcmd-autofix.md | 9 +++-- mcp-skills/webcmd-browser-sitemap.md | 6 ++- mcp-skills/webcmd-browser.md | 17 ++++---- mcp-skills/webcmd-sitemap-author.md | 6 ++- mcp-skills/webcmd-usage.md | 16 ++++---- skill-src/cli/smart-search/SKILL.src.md | 17 ++++---- skill-src/cli/webcmd-browser/SKILL.src.md | 36 +++++++++-------- .../references/browser-run-playwright.src.md | 8 +++- skill-src/cli/webcmd-usage/SKILL.src.md | 19 +++++---- skill-src/mcp/webcmd-adapter-author.src.md | 7 +++- skill-src/mcp/webcmd-autofix.src.md | 9 +++-- skill-src/mcp/webcmd-browser-sitemap.src.md | 6 ++- skill-src/mcp/webcmd-browser.src.md | 17 ++++---- skill-src/mcp/webcmd-sitemap-author.src.md | 6 ++- skill-src/mcp/webcmd-usage.src.md | 16 ++++---- skills/smart-search/SKILL.md | 15 ++++--- skills/webcmd-browser/SKILL.md | 33 ++++++++------- .../references/browser-run-playwright.md | 8 +++- skills/webcmd-usage/SKILL.md | 17 ++++---- src/doctor.test.ts | 14 +++---- src/doctor.ts | 2 +- src/session-docs-sync.test.ts | 8 ++-- src/skills.test.ts | 13 +++--- 36 files changed, 224 insertions(+), 175 deletions(-) diff --git a/README.md b/README.md index 205e91ef..05785fa7 100644 --- a/README.md +++ b/README.md @@ -38,17 +38,20 @@ For local, multi-step browser exploration, agents can send one sandboxed Playwright-style program to an explicit browser session: ```bash -webcmd session create -f json -webcmd --session session_abc browser run --file explore.js +webcmd --profile work session create "Work Project" -f json +# id: work-project-k7 +webcmd --profile work --session work-project-k7 browser tabs +webcmd --profile work --session work-project-k7 browser run --file explore.js printf 'return await page.title();' \ - | webcmd --session session_abc browser run --stdin -webcmd session close session_abc + | webcmd --profile work --session work-project-k7 browser run --stdin +webcmd --profile work session close work-project-k7 ``` Profiles are cookie jars; Sessions are independent browser windows within a -profile, so parallel agents should create separate Sessions. Adapter commands -use an adapter-default Session unless `--session` intentionally routes them to -an explicit one. +profile, so Session IDs are immutable, Profile-scoped, and safe to reuse for +that Session's lifetime. Parallel agents should create separate Sessions. +Adapter commands without `--session` reuse the Profile's `adapter-default` +Session. Raw browser commands require an explicit readable Session ID. ## Demo diff --git a/docs/agents/claude-code.md b/docs/agents/claude-code.md index d697d339..c0f70cc9 100644 --- a/docs/agents/claude-code.md +++ b/docs/agents/claude-code.md @@ -88,7 +88,7 @@ Denying these tools does not affect the Bash tool, which is how `webcmd` is driv | Skill text looks out of date | `webcmd update` upgrades only the CLI. Run `claude plugin update webcmd@webcmd` to refresh plugin skills. | | Claude Code still uses `WebFetch` / `WebSearch` | Confirm `permissions.deny` lists both in the active settings file, then restart `claude`. | | `claude` requires permission prompts for `webcmd` | The Bash tool still asks before non-approved commands; run `claude --dangerously-skip-permissions` or allow the shell command if you accept the risk. | -| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | +| Browser Session idles or loses its window | Keep its immutable, Profile-scoped ID; `webcmd --profile work --session work-project-k7 browser tabs` reopens it. Start with `webcmd --profile work session create "Work Project"`; use `webcmd --profile work session list` and `webcmd --profile work session close work-project-k7` for lifecycle. Adapter commands without `--session` reuse `adapter-default`; raw browser commands require an explicit readable selector. | ## See also diff --git a/docs/agents/codex-cli.md b/docs/agents/codex-cli.md index 07d9f2a4..b93e5505 100644 --- a/docs/agents/codex-cli.md +++ b/docs/agents/codex-cli.md @@ -84,7 +84,7 @@ disabled_tools = ["navigate", "screenshot"] | Search results look stale | `web_search` defaults to `"cached"`. Set `web_search = "live"` in `~/.codex/config.toml`, then restart `codex`. | | `web_search` was disabled and search stopped working | Expected. Set it back to `"live"` or `"cached"` — Webcmd does not replace search. | | `webcmd` not found in Codex shell | Confirm `webcmd` is on the PATH Codex uses; restart after installing the CLI. | -| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | +| Browser Session idles or loses its window | Keep its immutable, Profile-scoped ID; `webcmd --profile work --session work-project-k7 browser tabs` reopens it. Start with `webcmd --profile work session create "Work Project"`; use `webcmd --profile work session list` and `webcmd --profile work session close work-project-k7` for lifecycle. Adapter commands without `--session` reuse `adapter-default`; raw browser commands require an explicit readable selector. | ## See also diff --git a/docs/agents/cursor.md b/docs/agents/cursor.md index 0a602807..feb9692c 100644 --- a/docs/agents/cursor.md +++ b/docs/agents/cursor.md @@ -52,8 +52,8 @@ Use Webcmd for anything on the open web — fetching, authenticated third-party sites, multi-step automation, workflows worth making reusable: - Check `webcmd list -f json` for an adapter that covers the task; use it first. -- Otherwise create a session, then drive it with `webcmd --session browser ...` via the shell tool. -- Run `webcmd doctor` first; use `webcmd session list` to inspect state and `webcmd session close ` when finished. +- Otherwise run `webcmd --profile work session create "Work Project"`, then drive its returned readable ID with `webcmd --profile work --session work-project-k7 browser tabs` via the shell tool. +- Run `webcmd doctor` first; use `webcmd --profile work session list` to inspect state and `webcmd --profile work session close work-project-k7` when finished. - For login walls, use Webcmd's human handoff; never type passwords, OTPs, cookies, or credentials. Use the native Browser tool only for the app being edited: localhost dev server, @@ -77,7 +77,7 @@ Note that the rule is guidance, not a block. Cursor's Browser Automation has bee | Cursor uses its Browser tool for external sites | Confirm `.cursor/rules/webcmd-browser.mdc` has `alwaysApply: true`; for a hard block, set Browser Automation to Off. | | Browser Automation turns itself back on | Known behaviour — a prompt mentioning "browser" can re-enable it. Avoid the word, or turn it off in the agent window. | | `webcmd` not found in Cursor shell | Confirm `webcmd` is on the PATH the Cursor shell uses; restart Cursor after installing the CLI. | -| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | +| Browser Session idles or loses its window | Keep its immutable, Profile-scoped ID; `webcmd --profile work --session work-project-k7 browser tabs` reopens it. Start with `webcmd --profile work session create "Work Project"`; use `webcmd --profile work session list` and `webcmd --profile work session close work-project-k7` for lifecycle. Adapter commands without `--session` reuse `adapter-default`; raw browser commands require an explicit readable selector. | ## See also diff --git a/docs/agents/hermes.md b/docs/agents/hermes.md index 07e3f2b7..3e7bbcd4 100644 --- a/docs/agents/hermes.md +++ b/docs/agents/hermes.md @@ -52,7 +52,7 @@ Hermes' web surface spans three toolsets: **Hermes toggles toolsets, not individual tools.** There is no way to drop `web_extract` while keeping `web_search`, so leave the `web` toolset on and steer the agent with instructions instead. Add this to your Hermes system prompt or project instructions: -> Use Webcmd (`webcmd list`, then `webcmd session create -f json` and `webcmd --session browser ...` via the `terminal` toolset) for anything on the open web: fetching, authenticated third-party sites, multi-step automation. Prefer it over `web_extract`. Use the `browser_*` tools only for the app being edited — localhost dev server, console and network triage, visual checks. Keep using `web_search` and `x_search` to find URLs. +> Use Webcmd (`webcmd list`, then `webcmd --profile work session create "Work Project"` and `webcmd --profile work --session work-project-k7 browser tabs` via the `terminal` toolset) for anything on the open web: fetching, authenticated third-party sites, multi-step automation. Prefer it over `web_extract`. Use the `browser_*` tools only for the app being edited — localhost dev server, console and network triage, visual checks. Keep using `web_search` and `x_search` to find URLs. Also check the `computer_use` toolset. It drives the whole desktop rather than a browser, so it overlaps with Webcmd whenever it is aimed at a website. Disable it if the user does not need desktop control. @@ -85,7 +85,7 @@ Do not disable the `terminal` toolset — that is how Hermes runs `webcmd`. | Search disappeared after disabling `web` | Expected: `web_search` and `web_extract` share one toolset. Re-enable `web` and steer the agent with instructions instead. | | `x_search` appeared on its own | Expected: it auto-registers when `XAI_API_KEY` or Grok OAuth is configured. Leave it — it is search. | | `webcmd` not found in Hermes terminal | Confirm `webcmd` is on the host PATH that Hermes' `terminal` toolset uses; non-interactive shells may skip shell init files. | -| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | +| Browser Session idles or loses its window | Keep its immutable, Profile-scoped ID; `webcmd --profile work --session work-project-k7 browser tabs` reopens it. Start with `webcmd --profile work session create "Work Project"`; use `webcmd --profile work session list` and `webcmd --profile work session close work-project-k7` for lifecycle. Adapter commands without `--session` reuse `adapter-default`; raw browser commands require an explicit readable selector. | ## See also diff --git a/docs/agents/openclaw.md b/docs/agents/openclaw.md index ea4effba..59582a9d 100644 --- a/docs/agents/openclaw.md +++ b/docs/agents/openclaw.md @@ -82,7 +82,7 @@ Or remove it entirely — CLI, `browser.request` gateway method, and agent tool | OpenClaw uses `browser` for external sites | Remind it that Webcmd handles the open web; for a hard block, set `browser.enabled: false`. | | Search stopped working | Check whether `web_search` was denied. Webcmd does not replace search — remove it from `tools.deny`. | | `webcmd` not found in OpenClaw exec | Confirm `webcmd` is on the PATH the Gateway's `exec` tool uses; restart after installing the CLI. | -| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | +| Browser Session idles or loses its window | Keep its immutable, Profile-scoped ID; `webcmd --profile work --session work-project-k7 browser tabs` reopens it. Start with `webcmd --profile work session create "Work Project"`; use `webcmd --profile work session list` and `webcmd --profile work session close work-project-k7` for lifecycle. Adapter commands without `--session` reuse `adapter-default`; raw browser commands require an explicit readable selector. | ## See also diff --git a/docs/agents/opencode.md b/docs/agents/opencode.md index c3fab330..9b697a49 100644 --- a/docs/agents/opencode.md +++ b/docs/agents/opencode.md @@ -59,8 +59,8 @@ Deny `webfetch` so OpenCode cannot fall back to it while Webcmd is its browser s | Skills not loading in OpenCode | Run `webcmd skills add` with the `agents` provider, restart OpenCode, and check `/skills`. | | OpenCode still uses `webfetch` | Confirm `permission.webfetch` is `deny` in the active config, then restart OpenCode. | | `websearch` is missing entirely | It registers only with the OpenCode provider or `OPENCODE_ENABLE_EXA=1`. Not a Webcmd problem. | -| `webcmd browser` errors | Read `webcmd-usage` and `webcmd-browser` skills; create a session and pass its ID as root `--session`. | -| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | +| `webcmd browser` errors | Read `webcmd-usage` and `webcmd-browser`; create a named Session and pass its readable ID as root `--session`. | +| Browser Session idles or loses its window | Keep its immutable, Profile-scoped ID; `webcmd --profile work --session work-project-k7 browser tabs` reopens it. Start with `webcmd --profile work session create "Work Project"`; use `webcmd --profile work session list` and `webcmd --profile work session close work-project-k7` for lifecycle. Adapter commands without `--session` reuse `adapter-default`; raw browser commands require an explicit readable selector. | ## See also diff --git a/docs/agents/pi.md b/docs/agents/pi.md index d245104f..ede230d9 100644 --- a/docs/agents/pi.md +++ b/docs/agents/pi.md @@ -66,7 +66,7 @@ To remove one outright, delete its folder — for example `~/.pi/agent/skills/pi | Pi still uses `browser-tools` or a web-fetch extension | Remove the skill folder or prompt Pi to prefer Webcmd, then restart Pi. | | Search stopped working after removing an extension | Some extensions bundle search with extraction. Reinstall it and steer Pi with instructions instead — Webcmd does not replace search. | | `webcmd` not found in Pi's shell | Confirm `webcmd` is on the PATH Pi's `bash` tool uses; restart Pi after installing the CLI. | -| Browser Session idles or loses its window | Keep the same Session ID; the next `webcmd --session browser ...` command reopens it. Use `webcmd session create -f json`, `webcmd session list`, and `webcmd session close ` for lifecycle. | +| Browser Session idles or loses its window | Keep its immutable, Profile-scoped ID; `webcmd --profile work --session work-project-k7 browser tabs` reopens it. Start with `webcmd --profile work session create "Work Project"`; use `webcmd --profile work session list` and `webcmd --profile work session close work-project-k7` for lifecycle. Adapter commands without `--session` reuse `adapter-default`; raw browser commands require an explicit readable selector. | ## See also diff --git a/docs/cli-reference.mdx b/docs/cli-reference.mdx index 70358747..dba232e8 100644 --- a/docs/cli-reference.mdx +++ b/docs/cli-reference.mdx @@ -52,44 +52,44 @@ webcmd web fetch --url "https://www.bing.com/search?q=agentic%20browser%20automa For either code, create one Session, navigate with `browser run`, inspect with a read snapshot, reuse the Session for allowed browser work, and close it: ```bash -webcmd --profile work session create -# Copy the returned full ID: -# session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +webcmd --profile work session create "Work Project" +# id: work-project-k7 +webcmd --profile work --session work-project-k7 browser tabs webcmd --profile work \ - --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 \ + --session work-project-k7 \ browser run --stdin <<'JS' await page.goto('https://example.com'); return { url: page.url(), title: await page.title() }; JS webcmd --profile work \ - --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 \ + --session work-project-k7 \ browser snapshot --snapshot-mode read -webcmd --profile work session close \ - session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +webcmd --profile work session close work-project-k7 ``` Local browser commands use Cloak. Hosted browser commands use Webcmd Cloud and Browser Use; `web fetch` still runs locally. ## Browser Programs -Create an opaque session before raw browser work. Profiles hold cookie/auth -state; sessions are browser workspaces within that profile. Adapter commands -may omit `--session` and use their profile's adapter-default session; pass -`--session ` only when intentionally routing an adapter into an -explicit session. Raw browser commands must always pass it. The retired -positional session form is invalid: +Create a named Session before raw browser work. The returned readable ID is +immutable and Profile-scoped: keep using the same Profile and ID for that +Session's lifetime. Adapter commands without `--session` reuse their Profile's +`adapter-default` Session. Raw browser commands must always pass an explicit +readable selector at the root: ```bash -webcmd session create -f json -webcmd --session session_abc browser snapshot --snapshot-mode act -webcmd --session session_abc browser snapshot --snapshot-mode read -webcmd --session session_abc browser run --stdin --timeout 45 -webcmd --session session_abc browser run --stdin --no-snapshot-diff -webcmd session list -webcmd session close session_abc +webcmd --profile work session create "Work Project" -f json +# id: work-project-k7 +webcmd --profile work --session work-project-k7 browser tabs +webcmd --profile work --session work-project-k7 browser snapshot --snapshot-mode act +webcmd --profile work --session work-project-k7 browser snapshot --snapshot-mode read +webcmd --profile work --session work-project-k7 browser run --stdin --timeout 45 +webcmd --profile work --session work-project-k7 browser run --stdin --no-snapshot-diff +webcmd --profile work session list +webcmd --profile work session close work-project-k7 ``` `session create`, `session list`, and `session close` accept the universal output diff --git a/docs/concepts.mdx b/docs/concepts.mdx index 58583f3e..cc4bcb32 100644 --- a/docs/concepts.mdx +++ b/docs/concepts.mdx @@ -43,7 +43,7 @@ The agent chooses the strategy; the human describes the outcome and constraints. ## Profiles, Sessions, And Tabs -A Profile is the browser identity and storage bucket, such as `default` or `work`. A Session is an opaque browser workspace inside a Profile; raw browser work creates one with `webcmd session create` and selects it at the root with `--session `. A tab is one page inside that Session. +A Profile is the browser identity and storage bucket, such as `default` or `work`. A Session is a named browser workspace inside a Profile. Create one with `webcmd --profile work session create "Work Project"`, then use its returned readable ID with `webcmd --profile work --session work-project-k7 browser tabs`. Session IDs are immutable and Profile-scoped. Adapter commands without `--session` reuse `adapter-default`; raw browser commands require an explicit readable selector. A tab is one page inside that Session. Adapter browser commands can use `siteSession: 'ephemeral'` for an isolated tab or `siteSession: 'persistent'` for a longer same-site workflow. Those adapter site-session modes are separate from raw browser Sessions. diff --git a/docs/skills.mdx b/docs/skills.mdx index 52b8bd38..976f9b94 100644 --- a/docs/skills.mdx +++ b/docs/skills.mdx @@ -32,4 +32,4 @@ Do not also add the skills with `webcmd skills add` in Codex. ## Other Agents or Plugin-Free Setup Run `webcmd skills add` to install or refresh the bundled Webcmd skills for your agent. The agent can then start with `webcmd-usage` and load the specialized skill that matches the outcome. -For raw browser work, agents should create a Session with `webcmd session create -f json` and pass it as a root selector: `webcmd --session browser ...`. +For raw browser work, agents should run `webcmd --profile work session create "Work Project"`, keep the returned immutable, Profile-scoped ID, and pass it at the root: `webcmd --profile work --session work-project-k7 browser tabs`. Adapter commands without `--session` reuse `adapter-default`; raw browser commands require an explicit readable selector. diff --git a/docs/troubleshooting.mdx b/docs/troubleshooting.mdx index 7981be14..07502fbc 100644 --- a/docs/troubleshooting.mdx +++ b/docs/troubleshooting.mdx @@ -45,8 +45,8 @@ Common Session codes: | Code | Meaning | Next step | | --- | --- | --- | -| `SESSION_REQUIRED` | A raw browser command needs a root Session selector. | Run `webcmd session create -f json`, then retry as `webcmd --session 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_REQUIRED` | A raw browser command needs a root Session selector. | Run `webcmd --profile work session create "Work Project"`, then retry as `webcmd --profile work --session work-project-k7 browser tabs`. | +| `INVALID_SESSION_SELECTOR` | The selector is not a readable Webcmd Session ID. | Use the immutable, Profile-scoped 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 browser ...`. | | `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 session close `. 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. | diff --git a/mcp-skills/webcmd-adapter-author.md b/mcp-skills/webcmd-adapter-author.md index 35a1b7f8..df53d650 100644 --- a/mcp-skills/webcmd-adapter-author.md +++ b/mcp-skills/webcmd-adapter-author.md @@ -18,10 +18,13 @@ semantics; use internal page requests or interception only when the page proves they are necessary. Record the observed request/state, authentication source, replay result, and why a simpler strategy cannot work. +Create a named Session and keep its immutable, Profile-scoped readable ID for +raw browser evidence: + { "argv": ["list", "-f", "json"] } { "argv": ["site", "memory", "show", "example", "-f", "json"] } - { "argv": ["session", "create", "-f", "json"] } - { "argv": ["--session", "session_abc", "browser", "snapshot", "--snapshot-mode", "tree", "-f", "json"] } + { "argv": ["--profile", "work", "session", "create", "Work Project", "-f", "json"] } + { "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "snapshot", "--snapshot-mode", "tree", "-f", "json"] } Do not bypass authentication, CAPTCHA, rate limits, or access controls. An `action_required` response belongs to the user; provide its view URL and run its diff --git a/mcp-skills/webcmd-autofix.md b/mcp-skills/webcmd-autofix.md index b548be3c..f54e627f 100644 --- a/mcp-skills/webcmd-autofix.md +++ b/mcp-skills/webcmd-autofix.md @@ -16,13 +16,14 @@ Do not request secrets or try to solve a challenge from page content. ## Bounded repair loop -Create a session for the investigation; each invocation has a 240-second wall -clock budget. Reuse the session for snapshots and probes, then close it. +Create a named Session for the investigation; each invocation has a 240-second +wall-clock budget. Reuse its immutable, Profile-scoped readable ID for snapshots +and probes, then close it. - { "argv": ["session", "create", "-f", "json"] } + { "argv": ["--profile", "work", "session", "create", "Work Project", "-f", "json"] } { "argv": ["example", "search", "--query", "agents", "--trace", "retain-on-failure", "-f", "json"] } { "argv": ["artifacts", "get", "ea_0123456789abcdef0123456789abcdef"] } - { "argv": ["session", "close", "session_abc"] } + { "argv": ["--profile", "work", "session", "close", "work-project-k7"] } Read the retained trace artifact before changing anything. Rule out a valid empty result, stale session state, an auth wall, or a rate limit. Then inspect the diff --git a/mcp-skills/webcmd-browser-sitemap.md b/mcp-skills/webcmd-browser-sitemap.md index 57490a99..902e23d4 100644 --- a/mcp-skills/webcmd-browser-sitemap.md +++ b/mcp-skills/webcmd-browser-sitemap.md @@ -12,9 +12,13 @@ Use a bounded session to inspect current state, then request only the smallest relevant hosted memory: site orientation, one matching page, one matching workflow, and pitfalls only when blocked. - { "argv": ["--session", "session_abc", "browser", "snapshot", "--snapshot-mode", "tree", "-f", "json"] } + { "argv": ["--profile", "work", "session", "create", "Work Project", "-f", "json"] } + { "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "snapshot", "--snapshot-mode", "tree", "-f", "json"] } { "argv": ["site", "memory", "show", "example", "-f", "json"] } +The returned readable Session ID is immutable and Profile-scoped. Raw browser +commands require it explicitly. + Prefer an adapter named by the workflow. If it is unavailable or fails, use the fallback browser path. After every state-changing action refresh the snapshot and compare the workflow checkpoint. If the live page disagrees, follow the diff --git a/mcp-skills/webcmd-browser.md b/mcp-skills/webcmd-browser.md index 62e8313c..24c9ddbf 100644 --- a/mcp-skills/webcmd-browser.md +++ b/mcp-skills/webcmd-browser.md @@ -14,13 +14,16 @@ plugin search have no suitable command, browser work is the fallback: ## Session lifecycle -Create one session, use its id on each bounded browser action, and close it when -finished. Each invocation has a 240-second wall-clock budget. +Create one named Session, use its returned readable ID on each bounded browser +action, and close it when finished. IDs are immutable and Profile-scoped. Raw +browser commands require an explicit readable selector; adapter commands without +`--session` reuse `adapter-default`. Each invocation has a 240-second wall-clock +budget. - { "argv": ["session", "create", "-f", "json"] } - { "argv": ["--session", "session_abc", "browser", "tabs", "-f", "json"] } - { "argv": ["--session", "session_abc", "browser", "snapshot", "--snapshot-mode", "act", "-f", "json"] } - { "argv": ["session", "close", "session_abc"] } + { "argv": ["--profile", "work", "session", "create", "Work Project", "-f", "json"] } + { "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "tabs", "-f", "json"] } + { "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "snapshot", "--snapshot-mode", "act", "-f", "json"] } + { "argv": ["--profile", "work", "session", "close", "work-project-k7"] } Take a fresh snapshot after navigation, submits, SPA transitions, login, or a human handoff. Prefer semantic locators and scoped extraction. Return compact @@ -32,7 +35,7 @@ fields, never an unbounded DOM dump. Put a browser program in an attached virtual file and invoke it with argv: { - "argv": ["--session", "session_abc", "browser", "run", "--file", "probe.js", "-f", "json"], + "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "run", "--file", "probe.js", "-f", "json"], "files": [{ "path": "probe.js", "content": "await page.goto('https://example.com'); return { url: page.url(), title: await page.title() };", "encoding": "utf8" }] } diff --git a/mcp-skills/webcmd-sitemap-author.md b/mcp-skills/webcmd-sitemap-author.md index 5c84edcb..e7c7466c 100644 --- a/mcp-skills/webcmd-sitemap-author.md +++ b/mcp-skills/webcmd-sitemap-author.md @@ -13,7 +13,11 @@ record only task-relevant structure actually observed. Current browser evidence wins over remembered state. { "argv": ["site", "memory", "show", "example", "-f", "json"] } - { "argv": ["--session", "session_abc", "browser", "snapshot", "--snapshot-mode", "tree", "-f", "json"] } + { "argv": ["--profile", "work", "session", "create", "Work Project", "-f", "json"] } + { "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "snapshot", "--snapshot-mode", "tree", "-f", "json"] } + +The returned readable Session ID is immutable and Profile-scoped. Raw browser +commands require it explicitly. Use stable ids for pages, actions, and workflows. Mark unverified paths `draft` or `stale`; never call them verified. Do not record secrets, private messages, diff --git a/mcp-skills/webcmd-usage.md b/mcp-skills/webcmd-usage.md index 4abf0f11..1af3aec3 100644 --- a/mcp-skills/webcmd-usage.md +++ b/mcp-skills/webcmd-usage.md @@ -78,21 +78,23 @@ the `verifyCommand` before resuming. A single `webcmd_cli_run` invocation is capped at **240 seconds** of wall clock. Budget against that number rather than discovering it as a timeout. -Anything longer is an explicit session: create one, issue bounded interactions -against it, and poll. +Anything longer is an explicit named Session: create one, issue bounded +interactions against its immutable, Profile-scoped readable ID, and poll. Raw +browser commands require that explicit selector. Adapter commands without +`--session` reuse `adapter-default`. - { "argv": ["session", "create", "-f", "json"] } + { "argv": ["--profile", "work", "session", "create", "Work Project", "-f", "json"] } { - "argv": ["--session", "session_abc", "browser", "run", "--file", "navigate.js", "-f", "json"], + "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "run", "--file", "navigate.js", "-f", "json"], "files": [{ "path": "navigate.js", "content": "await page.goto('https://example.com'); return { url: page.url(), title: await page.title() };", "encoding": "utf8" }] } - { "argv": ["--session", "session_abc", "browser", "snapshot", "-f", "json"] } - { "argv": ["session", "close", "session_abc"] } + { "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "snapshot", "-f", "json"] } + { "argv": ["--profile", "work", "session", "close", "work-project-k7"] } Each interaction is its own invocation and its own 240-second budget. The session holds the browser state between them. -Create the long-lived session with `session create`. +Create the long-lived Session with `session create `. ## Files diff --git a/skill-src/cli/smart-search/SKILL.src.md b/skill-src/cli/smart-search/SKILL.src.md index 358b5f63..aad67b83 100644 --- a/skill-src/cli/smart-search/SKILL.src.md +++ b/skill-src/cli/smart-search/SKILL.src.md @@ -43,26 +43,25 @@ webcmd web fetch --url Run `webcmd web fetch` before browser work or non-Webcmd HTTP clients. Only `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` permits browser fallback; otherwise report the returned failure rather than retrying the URL. If a URL was already fetched outside Webcmd and got non-2xx, 403, blocked, or Cloudflare, that does not change the order: run `webcmd web fetch --url ` once before any browser escalation. -For browser fallback, create one Session, navigate the failed URL, inspect it, reuse that Session for allowed fallbacks, then close it. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes. +For browser fallback, create one Session, navigate the failed URL, inspect it, reuse that Session for allowed fallbacks, then close it. The returned readable ID is immutable and Profile-scoped. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes. ```bash -webcmd --profile work session create -# Copy the returned full ID: -# session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +webcmd --profile work session create "Work Project" +# id: work-project-k7 +webcmd --profile work --session work-project-k7 browser tabs webcmd --profile work \ - --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 \ + --session work-project-k7 \ browser run --stdin <<'JS' await page.goto('https://example.com'); return { url: page.url(), title: await page.title() }; JS webcmd --profile work \ - --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 \ + --session work-project-k7 \ browser snapshot --snapshot-mode read -webcmd --profile work session close \ - session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +webcmd --profile work session close work-project-k7 ``` If the fetch is rate-limited, login-gated, geo-gated, or returns unusable extracted text, report that state rather than retrying the same URL. @@ -164,4 +163,6 @@ what won. a supplied URL through `webcmd web fetch`; the explicit external non-2xx, 403, and Cloudflare wording improves discoverability rather than adding new runtime behavior. +- 2026-08-25: Raw browser fallback now starts from a required human-readable + Session name and reuses the returned immutable, Profile-scoped ID. --> diff --git a/skill-src/cli/webcmd-browser/SKILL.src.md b/skill-src/cli/webcmd-browser/SKILL.src.md index a1ee6d44..5eab13e4 100644 --- a/skill-src/cli/webcmd-browser/SKILL.src.md +++ b/skill-src/cli/webcmd-browser/SKILL.src.md @@ -30,37 +30,36 @@ Until `doctor` is green, browser commands may fail. Registry and plugin discover ## Session lifecycle -- Create an opaque browser session before raw browser work: `webcmd --profile session create`. +- Create a named browser Session before raw browser work: `webcmd --profile session create `. - Create a named profile first: `webcmd profile create `. If an explicit profile returns `PROFILE_NOT_FOUND`, create it, then retry session creation. -- Raw browser commands require that ID at the root: `webcmd --session browser ...`; the old positional session form is retired. -- Profiles are cookie jars and auth scope; sessions are browser workspaces/windows within a profile. Parallel agents use separate sessions. +- Raw browser commands require the returned readable ID at the root: `webcmd --profile --session browser ...`. +- Profiles are cookie jars and auth scope; Sessions are browser workspaces/windows within a Profile. Session IDs are immutable and Profile-scoped. Parallel agents use separate Sessions. - `webcmd session list` shows sessions and their handoff/runtime state; close finished work with `webcmd session close `. Close is blocked while that Session has a live handoff. - Browser state in the bound page persists between calls, but each `run` gets a fresh JavaScript scope. - `webcmd --session browser tabs` lists existing pages without creating a new one. - `webcmd --session browser bind --page ` explicitly attaches the session to an existing page. - If the user manually signs in or changes the visible tab, re-bind or inspect with a fresh snapshot before continuing. -For a `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` fallback, use one Session for the browser portion, preserve its complete opaque ID, and close it in cleanup. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes and never opens a browser. +For a `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` fallback, use one Session for the browser portion, preserve its readable ID, and close it in cleanup. Adapter commands without `--session` reuse the Profile's `adapter-default` Session; raw browser commands require an explicit readable selector. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes and never opens a browser. ```bash webcmd profile create work -webcmd --profile work session create -# Copy the returned full ID: -# session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +webcmd --profile work session create "Work Project" +# id: work-project-k7 +webcmd --profile work --session work-project-k7 browser tabs webcmd --profile work \ - --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 \ + --session work-project-k7 \ browser run --stdin --no-snapshot-diff <<'JS' await page.goto('https://example.com'); return { url: page.url(), title: await page.title() }; JS webcmd --profile work \ - --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 \ + --session work-project-k7 \ browser snapshot --snapshot-mode read -webcmd --profile work session close \ - session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +webcmd --profile work session close work-project-k7 ``` --- @@ -98,7 +97,7 @@ Choose diff behavior from the evidence the program returns: Research with sufficient returned evidence: ```bash -webcmd --session session_abc browser run --stdin --no-snapshot-diff <<'JS' +webcmd --profile work --session work-project-k7 browser run --stdin --no-snapshot-diff <<'JS' await page.goto('https://example.com/archive'); const text = await page.locator('main').innerText(); return { @@ -112,7 +111,7 @@ JS Keep the default diff when discovering an unfamiliar state change: ```bash -webcmd --session session_abc browser run --stdin <<'JS' +webcmd --profile work --session work-project-k7 browser run --stdin <<'JS' await page.goto('https://example.com'); await page.getByRole('link', { name: 'More information' }).click(); return { title: await page.title(), url: page.url() }; @@ -142,7 +141,7 @@ Prefer one `run` over shell-chaining multiple browser calls. It keeps Playwright Good: ```bash -webcmd --session session_abc browser run --stdin <<'JS' +webcmd --profile work --session work-project-k7 browser run --stdin <<'JS' await page.goto('https://example.com/cart'); const pending = page.waitForResponse(r => r.url().includes('/api/checkout')); await page.getByRole('button', { name: /checkout/i }).click(); @@ -199,7 +198,7 @@ await page.locator('input[type="file"]').setInputFiles({ Inspect the exact accessible name before using `getByLabel`; do not invent punctuation such as a required `*`. After filling a datepicker or masked input, verify `inputValue()` and use the widget UI if the value was cleared or rejected. ```bash -webcmd --session session_abc browser run --stdin <<'JS' +webcmd --profile work --session work-project-k7 browser run --stdin <<'JS' await page.goto('https://example.com/form'); const country = page.locator('select[name="country"]'); return { @@ -220,7 +219,7 @@ For custom React/Radix/shadcn/Material UI dropdowns, use semantic locators and v ### Capture a request triggered by UI ```bash -webcmd --session session_abc browser run --stdin --no-snapshot-diff <<'JS' +webcmd --profile work --session work-project-k7 browser run --stdin --no-snapshot-diff <<'JS' await page.goto('https://example.com/search'); const pending = page.waitForResponse(r => r.url().includes('/api/search')); await page.getByRole('textbox', { name: /search/i }).fill('browser automation'); @@ -271,7 +270,7 @@ Use `run` and inspect `page.frames()`; target the frame by URL/name and keep ifr | Bound page is wrong or stale | Run `tabs`, choose the current page id, then `bind --page ` again. | | `run` times out before returning | Increase `--timeout` only after checking whether the wait condition is wrong. | | Write may have happened before timeout | Take a fresh snapshot before retrying. Avoid duplicate submissions. | -| `SESSION_REQUIRED` | Create a Session, then retry with root `--session `. | +| `SESSION_REQUIRED` | Create a named Session, then retry with its readable root `--session `. | | `SESSION_BUSY` | Wait for the listed holder; if it is dead, `webcmd session close --force` is the last resort. | | `SESSION_PAUSED_FOR_HUMAN_HANDOFF` | Finish the handoff and run the returned verifier before retrying. | | Login wall appears | Use the Authentication and human handoff recipe. | @@ -296,4 +295,7 @@ Author-only. Stripped by litprompt, so it costs the running agent nothing. Append one dated line whenever a correction lands, or whenever an approach is tried and rejected. Record what was tried and why it failed, not just what won. + +- 2026-08-25: Session creation requires a readable name; raw browser commands + keep the returned immutable, Profile-scoped ID explicit at the root. --> diff --git a/skill-src/cli/webcmd-browser/references/browser-run-playwright.src.md b/skill-src/cli/webcmd-browser/references/browser-run-playwright.src.md index 3e63323f..7bb46325 100644 --- a/skill-src/cli/webcmd-browser/references/browser-run-playwright.src.md +++ b/skill-src/cli/webcmd-browser/references/browser-run-playwright.src.md @@ -33,7 +33,11 @@ do not — each run starts with a fresh scope. `context.newPage()` works and creates a tab the Webcmd session tracks. You cannot close it from inside `run` (see below); list tabs with `webcmd --session browser tabs`. -`page.snapshotForAI()` is not available; use `webcmd browser snapshot` instead. +Create the owning Session with `webcmd --profile work session create "Work Project"`, +then use the returned immutable, Profile-scoped readable ID, for example +`webcmd --profile work --session work-project-k7 browser tabs`. + +`page.snapshotForAI()` is not available; use `webcmd --session browser snapshot` instead. ## What is blocked, and what to use instead @@ -44,7 +48,7 @@ Webcmd session, not to your program: |---|---| | `page.close()` | Leave the tab open, or `webcmd session close ` | | `context.close()`, `browser.close()` | `webcmd session close ` | -| `browser.newContext()` | `webcmd session create` — one run is scoped to one context | +| `browser.newContext()` | `webcmd session create ` — one run is scoped to one Session | | `browser.newBrowserCDPSession()`, `context.newCDPSession()` | Not exposed inside `run` | | `playwright.request` (`newRequest`) | `page.request` for calls in the page's context | diff --git a/skill-src/cli/webcmd-usage/SKILL.src.md b/skill-src/cli/webcmd-usage/SKILL.src.md index 444a65b3..d3f6b3a3 100644 --- a/skill-src/cli/webcmd-usage/SKILL.src.md +++ b/skill-src/cli/webcmd-usage/SKILL.src.md @@ -61,23 +61,24 @@ npx tsx src/main.ts ## Sessions -Profiles are cookie jars and authentication scope. Sessions are browser workspaces/windows within a profile. Create one for each parallel raw-browser agent, then route every raw command through the opaque ID: +Profiles are cookie jars and authentication scope. Sessions are browser workspaces/windows within a Profile. Their readable IDs are immutable and Profile-scoped. Create one for each parallel raw-browser agent, then route every raw command through its returned ID: ```bash -webcmd session create -f json webcmd profile create work -webcmd --profile work session create -f json -webcmd --session session_abc browser snapshot --snapshot-mode act -webcmd --session session_abc browser run --stdin -webcmd session list -webcmd session close session_abc +webcmd --profile work session create "Work Project" -f json +# id: work-project-k7 +webcmd --profile work --session work-project-k7 browser tabs +webcmd --profile work --session work-project-k7 browser snapshot --snapshot-mode act +webcmd --profile work --session work-project-k7 browser run --stdin +webcmd --profile work session list +webcmd --profile work session close work-project-k7 ``` Create a named profile before using it. If `--profile session create` returns `PROFILE_NOT_FOUND`, run `webcmd profile create ` and retry. `webcmd session close ` is blocked while that Session has a live human handoff. -Adapter commands may omit `--session` and use the selected profile's adapter-default session. Pass `--session ` to route one into an explicit session. Raw browser commands never omit it; the retired positional session form is invalid. +Adapter commands without `--session` reuse the selected Profile's `adapter-default` Session. Pass `--session ` to route one into an explicit Session. Raw browser commands always require an explicit readable selector. Structured Session failures are runtime state, not adapter breakage. `SESSION_REQUIRED` means add a root `--session ` selector before `browser`; `SESSION_BUSY` @@ -296,4 +297,6 @@ what won. `FETCH_REQUIRES_BROWSER`. - 2026-08-20: Review rejected saying `web fetch` always performs a TLS-impersonating retry; it may do so only when it detects a challenge. +- 2026-08-25: Session lifecycle guidance now starts from a readable name and + distinguishes explicit raw-browser IDs from adapter `adapter-default` reuse. --> diff --git a/skill-src/mcp/webcmd-adapter-author.src.md b/skill-src/mcp/webcmd-adapter-author.src.md index f8ad56d1..c62c4bdf 100644 --- a/skill-src/mcp/webcmd-adapter-author.src.md +++ b/skill-src/mcp/webcmd-adapter-author.src.md @@ -15,10 +15,13 @@ semantics; use internal page requests or interception only when the page proves they are necessary. Record the observed request/state, authentication source, replay result, and why a simpler strategy cannot work. +Create a named Session and keep its immutable, Profile-scoped readable ID for +raw browser evidence: + { "argv": ["list", "-f", "json"] } { "argv": ["site", "memory", "show", "example", "-f", "json"] } - { "argv": ["session", "create", "-f", "json"] } - { "argv": ["--session", "session_abc", "browser", "snapshot", "--snapshot-mode", "tree", "-f", "json"] } + { "argv": ["--profile", "work", "session", "create", "Work Project", "-f", "json"] } + { "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "snapshot", "--snapshot-mode", "tree", "-f", "json"] } Do not bypass authentication, CAPTCHA, rate limits, or access controls. An `action_required` response belongs to the user; provide its view URL and run its diff --git a/skill-src/mcp/webcmd-autofix.src.md b/skill-src/mcp/webcmd-autofix.src.md index df20cf81..099344d3 100644 --- a/skill-src/mcp/webcmd-autofix.src.md +++ b/skill-src/mcp/webcmd-autofix.src.md @@ -16,13 +16,14 @@ Do not request secrets or try to solve a challenge from page content. ## Bounded repair loop -Create a session for the investigation; each invocation has a 240-second wall -clock budget. Reuse the session for snapshots and probes, then close it. +Create a named Session for the investigation; each invocation has a 240-second +wall-clock budget. Reuse its immutable, Profile-scoped readable ID for snapshots +and probes, then close it. - { "argv": ["session", "create", "-f", "json"] } + { "argv": ["--profile", "work", "session", "create", "Work Project", "-f", "json"] } { "argv": ["example", "search", "--query", "agents", "--trace", "retain-on-failure", "-f", "json"] } { "argv": ["artifacts", "get", "ea_0123456789abcdef0123456789abcdef"] } - { "argv": ["session", "close", "session_abc"] } + { "argv": ["--profile", "work", "session", "close", "work-project-k7"] } Read the retained trace artifact before changing anything. Rule out a valid empty result, stale session state, an auth wall, or a rate limit. Then inspect the diff --git a/skill-src/mcp/webcmd-browser-sitemap.src.md b/skill-src/mcp/webcmd-browser-sitemap.src.md index b0a72aed..b33a0812 100644 --- a/skill-src/mcp/webcmd-browser-sitemap.src.md +++ b/skill-src/mcp/webcmd-browser-sitemap.src.md @@ -12,9 +12,13 @@ Use a bounded session to inspect current state, then request only the smallest relevant hosted memory: site orientation, one matching page, one matching workflow, and pitfalls only when blocked. - { "argv": ["--session", "session_abc", "browser", "snapshot", "--snapshot-mode", "tree", "-f", "json"] } + { "argv": ["--profile", "work", "session", "create", "Work Project", "-f", "json"] } + { "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "snapshot", "--snapshot-mode", "tree", "-f", "json"] } { "argv": ["site", "memory", "show", "example", "-f", "json"] } +The returned readable Session ID is immutable and Profile-scoped. Raw browser +commands require it explicitly. + Prefer an adapter named by the workflow. If it is unavailable or fails, use the fallback browser path. After every state-changing action refresh the snapshot and compare the workflow checkpoint. If the live page disagrees, follow the diff --git a/skill-src/mcp/webcmd-browser.src.md b/skill-src/mcp/webcmd-browser.src.md index 265cdf7d..4c2fccea 100644 --- a/skill-src/mcp/webcmd-browser.src.md +++ b/skill-src/mcp/webcmd-browser.src.md @@ -14,13 +14,16 @@ plugin search have no suitable command, browser work is the fallback: ## Session lifecycle -Create one session, use its id on each bounded browser action, and close it when -finished. Each invocation has a 240-second wall-clock budget. +Create one named Session, use its returned readable ID on each bounded browser +action, and close it when finished. IDs are immutable and Profile-scoped. Raw +browser commands require an explicit readable selector; adapter commands without +`--session` reuse `adapter-default`. Each invocation has a 240-second wall-clock +budget. - { "argv": ["session", "create", "-f", "json"] } - { "argv": ["--session", "session_abc", "browser", "tabs", "-f", "json"] } - { "argv": ["--session", "session_abc", "browser", "snapshot", "--snapshot-mode", "act", "-f", "json"] } - { "argv": ["session", "close", "session_abc"] } + { "argv": ["--profile", "work", "session", "create", "Work Project", "-f", "json"] } + { "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "tabs", "-f", "json"] } + { "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "snapshot", "--snapshot-mode", "act", "-f", "json"] } + { "argv": ["--profile", "work", "session", "close", "work-project-k7"] } Take a fresh snapshot after navigation, submits, SPA transitions, login, or a human handoff. Prefer semantic locators and scoped extraction. Return compact @@ -32,7 +35,7 @@ fields, never an unbounded DOM dump. Put a browser program in an attached virtual file and invoke it with argv: { - "argv": ["--session", "session_abc", "browser", "run", "--file", "probe.js", "-f", "json"], + "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "run", "--file", "probe.js", "-f", "json"], "files": [{ "path": "probe.js", "content": "await page.goto('https://example.com'); return { url: page.url(), title: await page.title() };", "encoding": "utf8" }] } diff --git a/skill-src/mcp/webcmd-sitemap-author.src.md b/skill-src/mcp/webcmd-sitemap-author.src.md index e60b456b..4d176954 100644 --- a/skill-src/mcp/webcmd-sitemap-author.src.md +++ b/skill-src/mcp/webcmd-sitemap-author.src.md @@ -13,7 +13,11 @@ record only task-relevant structure actually observed. Current browser evidence wins over remembered state. { "argv": ["site", "memory", "show", "example", "-f", "json"] } - { "argv": ["--session", "session_abc", "browser", "snapshot", "--snapshot-mode", "tree", "-f", "json"] } + { "argv": ["--profile", "work", "session", "create", "Work Project", "-f", "json"] } + { "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "snapshot", "--snapshot-mode", "tree", "-f", "json"] } + +The returned readable Session ID is immutable and Profile-scoped. Raw browser +commands require it explicitly. Use stable ids for pages, actions, and workflows. Mark unverified paths `draft` or `stale`; never call them verified. Do not record secrets, private messages, diff --git a/skill-src/mcp/webcmd-usage.src.md b/skill-src/mcp/webcmd-usage.src.md index d8f13778..99cebdfe 100644 --- a/skill-src/mcp/webcmd-usage.src.md +++ b/skill-src/mcp/webcmd-usage.src.md @@ -78,21 +78,23 @@ the `verifyCommand` before resuming. A single `webcmd_cli_run` invocation is capped at **240 seconds** of wall clock. Budget against that number rather than discovering it as a timeout. -Anything longer is an explicit session: create one, issue bounded interactions -against it, and poll. +Anything longer is an explicit named Session: create one, issue bounded +interactions against its immutable, Profile-scoped readable ID, and poll. Raw +browser commands require that explicit selector. Adapter commands without +`--session` reuse `adapter-default`. - { "argv": ["session", "create", "-f", "json"] } + { "argv": ["--profile", "work", "session", "create", "Work Project", "-f", "json"] } { - "argv": ["--session", "session_abc", "browser", "run", "--file", "navigate.js", "-f", "json"], + "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "run", "--file", "navigate.js", "-f", "json"], "files": [{ "path": "navigate.js", "content": "await page.goto('https://example.com'); return { url: page.url(), title: await page.title() };", "encoding": "utf8" }] } - { "argv": ["--session", "session_abc", "browser", "snapshot", "-f", "json"] } - { "argv": ["session", "close", "session_abc"] } + { "argv": ["--profile", "work", "--session", "work-project-k7", "browser", "snapshot", "-f", "json"] } + { "argv": ["--profile", "work", "session", "close", "work-project-k7"] } Each interaction is its own invocation and its own 240-second budget. The session holds the browser state between them. -Create the long-lived session with `session create`. +Create the long-lived Session with `session create `. ## Files diff --git a/skills/smart-search/SKILL.md b/skills/smart-search/SKILL.md index a54351bd..5bd1659c 100644 --- a/skills/smart-search/SKILL.md +++ b/skills/smart-search/SKILL.md @@ -43,26 +43,25 @@ webcmd web fetch --url Run `webcmd web fetch` before browser work or non-Webcmd HTTP clients. Only `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` permits browser fallback; otherwise report the returned failure rather than retrying the URL. If a URL was already fetched outside Webcmd and got non-2xx, 403, blocked, or Cloudflare, that does not change the order: run `webcmd web fetch --url ` once before any browser escalation. -For browser fallback, create one Session, navigate the failed URL, inspect it, reuse that Session for allowed fallbacks, then close it. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes. +For browser fallback, create one Session, navigate the failed URL, inspect it, reuse that Session for allowed fallbacks, then close it. The returned readable ID is immutable and Profile-scoped. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes. ```bash -webcmd --profile work session create -# Copy the returned full ID: -# session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +webcmd --profile work session create "Work Project" +# id: work-project-k7 +webcmd --profile work --session work-project-k7 browser tabs webcmd --profile work \ - --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 \ + --session work-project-k7 \ browser run --stdin <<'JS' await page.goto('https://example.com'); return { url: page.url(), title: await page.title() }; JS webcmd --profile work \ - --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 \ + --session work-project-k7 \ browser snapshot --snapshot-mode read -webcmd --profile work session close \ - session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +webcmd --profile work session close work-project-k7 ``` If the fetch is rate-limited, login-gated, geo-gated, or returns unusable extracted text, report that state rather than retrying the same URL. diff --git a/skills/webcmd-browser/SKILL.md b/skills/webcmd-browser/SKILL.md index c9bcfa2c..21356fd1 100644 --- a/skills/webcmd-browser/SKILL.md +++ b/skills/webcmd-browser/SKILL.md @@ -30,37 +30,36 @@ Until `doctor` is green, browser commands may fail. Registry and plugin discover ## Session lifecycle -- Create an opaque browser session before raw browser work: `webcmd --profile session create`. +- Create a named browser Session before raw browser work: `webcmd --profile session create `. - Create a named profile first: `webcmd profile create `. If an explicit profile returns `PROFILE_NOT_FOUND`, create it, then retry session creation. -- Raw browser commands require that ID at the root: `webcmd --session browser ...`; the old positional session form is retired. -- Profiles are cookie jars and auth scope; sessions are browser workspaces/windows within a profile. Parallel agents use separate sessions. +- Raw browser commands require the returned readable ID at the root: `webcmd --profile --session browser ...`. +- Profiles are cookie jars and auth scope; Sessions are browser workspaces/windows within a Profile. Session IDs are immutable and Profile-scoped. Parallel agents use separate Sessions. - `webcmd session list` shows sessions and their handoff/runtime state; close finished work with `webcmd session close `. Close is blocked while that Session has a live handoff. - Browser state in the bound page persists between calls, but each `run` gets a fresh JavaScript scope. - `webcmd --session browser tabs` lists existing pages without creating a new one. - `webcmd --session browser bind --page ` explicitly attaches the session to an existing page. - If the user manually signs in or changes the visible tab, re-bind or inspect with a fresh snapshot before continuing. -For a `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` fallback, use one Session for the browser portion, preserve its complete opaque ID, and close it in cleanup. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes and never opens a browser. +For a `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER` fallback, use one Session for the browser portion, preserve its readable ID, and close it in cleanup. Adapter commands without `--session` reuse the Profile's `adapter-default` Session; raw browser commands require an explicit readable selector. Local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` remains local in both modes and never opens a browser. ```bash webcmd profile create work -webcmd --profile work session create -# Copy the returned full ID: -# session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +webcmd --profile work session create "Work Project" +# id: work-project-k7 +webcmd --profile work --session work-project-k7 browser tabs webcmd --profile work \ - --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 \ + --session work-project-k7 \ browser run --stdin --no-snapshot-diff <<'JS' await page.goto('https://example.com'); return { url: page.url(), title: await page.title() }; JS webcmd --profile work \ - --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 \ + --session work-project-k7 \ browser snapshot --snapshot-mode read -webcmd --profile work session close \ - session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +webcmd --profile work session close work-project-k7 ``` --- @@ -98,7 +97,7 @@ Choose diff behavior from the evidence the program returns: Research with sufficient returned evidence: ```bash -webcmd --session session_abc browser run --stdin --no-snapshot-diff <<'JS' +webcmd --profile work --session work-project-k7 browser run --stdin --no-snapshot-diff <<'JS' await page.goto('https://example.com/archive'); const text = await page.locator('main').innerText(); return { @@ -112,7 +111,7 @@ JS Keep the default diff when discovering an unfamiliar state change: ```bash -webcmd --session session_abc browser run --stdin <<'JS' +webcmd --profile work --session work-project-k7 browser run --stdin <<'JS' await page.goto('https://example.com'); await page.getByRole('link', { name: 'More information' }).click(); return { title: await page.title(), url: page.url() }; @@ -142,7 +141,7 @@ Prefer one `run` over shell-chaining multiple browser calls. It keeps Playwright Good: ```bash -webcmd --session session_abc browser run --stdin <<'JS' +webcmd --profile work --session work-project-k7 browser run --stdin <<'JS' await page.goto('https://example.com/cart'); const pending = page.waitForResponse(r => r.url().includes('/api/checkout')); await page.getByRole('button', { name: /checkout/i }).click(); @@ -199,7 +198,7 @@ await page.locator('input[type="file"]').setInputFiles({ Inspect the exact accessible name before using `getByLabel`; do not invent punctuation such as a required `*`. After filling a datepicker or masked input, verify `inputValue()` and use the widget UI if the value was cleared or rejected. ```bash -webcmd --session session_abc browser run --stdin <<'JS' +webcmd --profile work --session work-project-k7 browser run --stdin <<'JS' await page.goto('https://example.com/form'); const country = page.locator('select[name="country"]'); return { @@ -220,7 +219,7 @@ For custom React/Radix/shadcn/Material UI dropdowns, use semantic locators and v ### Capture a request triggered by UI ```bash -webcmd --session session_abc browser run --stdin --no-snapshot-diff <<'JS' +webcmd --profile work --session work-project-k7 browser run --stdin --no-snapshot-diff <<'JS' await page.goto('https://example.com/search'); const pending = page.waitForResponse(r => r.url().includes('/api/search')); await page.getByRole('textbox', { name: /search/i }).fill('browser automation'); @@ -271,7 +270,7 @@ Use `run` and inspect `page.frames()`; target the frame by URL/name and keep ifr | Bound page is wrong or stale | Run `tabs`, choose the current page id, then `bind --page ` again. | | `run` times out before returning | Increase `--timeout` only after checking whether the wait condition is wrong. | | Write may have happened before timeout | Take a fresh snapshot before retrying. Avoid duplicate submissions. | -| `SESSION_REQUIRED` | Create a Session, then retry with root `--session `. | +| `SESSION_REQUIRED` | Create a named Session, then retry with its readable root `--session `. | | `SESSION_BUSY` | Wait for the listed holder; if it is dead, `webcmd session close --force` is the last resort. | | `SESSION_PAUSED_FOR_HUMAN_HANDOFF` | Finish the handoff and run the returned verifier before retrying. | | Login wall appears | Use the Authentication and human handoff recipe. | diff --git a/skills/webcmd-browser/references/browser-run-playwright.md b/skills/webcmd-browser/references/browser-run-playwright.md index 3e63323f..7bb46325 100644 --- a/skills/webcmd-browser/references/browser-run-playwright.md +++ b/skills/webcmd-browser/references/browser-run-playwright.md @@ -33,7 +33,11 @@ do not — each run starts with a fresh scope. `context.newPage()` works and creates a tab the Webcmd session tracks. You cannot close it from inside `run` (see below); list tabs with `webcmd --session browser tabs`. -`page.snapshotForAI()` is not available; use `webcmd browser snapshot` instead. +Create the owning Session with `webcmd --profile work session create "Work Project"`, +then use the returned immutable, Profile-scoped readable ID, for example +`webcmd --profile work --session work-project-k7 browser tabs`. + +`page.snapshotForAI()` is not available; use `webcmd --session browser snapshot` instead. ## What is blocked, and what to use instead @@ -44,7 +48,7 @@ Webcmd session, not to your program: |---|---| | `page.close()` | Leave the tab open, or `webcmd session close ` | | `context.close()`, `browser.close()` | `webcmd session close ` | -| `browser.newContext()` | `webcmd session create` — one run is scoped to one context | +| `browser.newContext()` | `webcmd session create ` — one run is scoped to one Session | | `browser.newBrowserCDPSession()`, `context.newCDPSession()` | Not exposed inside `run` | | `playwright.request` (`newRequest`) | `page.request` for calls in the page's context | diff --git a/skills/webcmd-usage/SKILL.md b/skills/webcmd-usage/SKILL.md index 326d0dad..60f93577 100644 --- a/skills/webcmd-usage/SKILL.md +++ b/skills/webcmd-usage/SKILL.md @@ -61,23 +61,24 @@ npx tsx src/main.ts ## Sessions -Profiles are cookie jars and authentication scope. Sessions are browser workspaces/windows within a profile. Create one for each parallel raw-browser agent, then route every raw command through the opaque ID: +Profiles are cookie jars and authentication scope. Sessions are browser workspaces/windows within a Profile. Their readable IDs are immutable and Profile-scoped. Create one for each parallel raw-browser agent, then route every raw command through its returned ID: ```bash -webcmd session create -f json webcmd profile create work -webcmd --profile work session create -f json -webcmd --session session_abc browser snapshot --snapshot-mode act -webcmd --session session_abc browser run --stdin -webcmd session list -webcmd session close session_abc +webcmd --profile work session create "Work Project" -f json +# id: work-project-k7 +webcmd --profile work --session work-project-k7 browser tabs +webcmd --profile work --session work-project-k7 browser snapshot --snapshot-mode act +webcmd --profile work --session work-project-k7 browser run --stdin +webcmd --profile work session list +webcmd --profile work session close work-project-k7 ``` Create a named profile before using it. If `--profile session create` returns `PROFILE_NOT_FOUND`, run `webcmd profile create ` and retry. `webcmd session close ` is blocked while that Session has a live human handoff. -Adapter commands may omit `--session` and use the selected profile's adapter-default session. Pass `--session ` to route one into an explicit session. Raw browser commands never omit it; the retired positional session form is invalid. +Adapter commands without `--session` reuse the selected Profile's `adapter-default` Session. Pass `--session ` to route one into an explicit Session. Raw browser commands always require an explicit readable selector. Structured Session failures are runtime state, not adapter breakage. `SESSION_REQUIRED` means add a root `--session ` selector before `browser`; `SESSION_BUSY` diff --git a/src/doctor.test.ts b/src/doctor.test.ts index 5c797825..e14dd2cc 100644 --- a/src/doctor.test.ts +++ b/src/doctor.test.ts @@ -94,7 +94,7 @@ describe('doctor report rendering', () => { }); mockClose.mockResolvedValue(undefined); mockSendCommand.mockImplementation(async (action: string) => { - if (action === 'session-create') return { id: 'session_doctor_11111111' }; + if (action === 'session-create') return { id: 'doctor-probe-k7' }; if (action === 'session-close') return { closed: true }; throw new Error(`Unexpected doctor command: ${action}`); }); @@ -338,12 +338,12 @@ describe('doctor report rendering', () => { ])); }); - it('uses a temporary opaque Session for live connectivity checks', async () => { + it('uses a temporary named Session for live connectivity checks', async () => { let timeoutSeen: number | undefined; const closeWindow = vi.fn().mockResolvedValue(undefined); mockConnect.mockImplementationOnce(async (opts?: { timeout?: number; session?: string; surface?: string }) => { timeoutSeen = opts?.timeout; - expect(opts?.session).toBe('session_doctor_11111111'); + expect(opts?.session).toBe('doctor-probe-k7'); expect(opts?.surface).toBe('browser'); return { evaluate: vi.fn().mockResolvedValue(2), @@ -356,9 +356,9 @@ describe('doctor report rendering', () => { expect(timeoutSeen).toBe(8); expect(closeWindow).toHaveBeenCalledTimes(1); - expect(mockSendCommand).toHaveBeenNthCalledWith(1, 'session-create', {}); + expect(mockSendCommand).toHaveBeenNthCalledWith(1, 'session-create', { sessionName: 'Doctor Probe' }); expect(mockSendCommand).toHaveBeenLastCalledWith('session-close', { - session: 'session_doctor_11111111', + session: 'doctor-probe-k7', surface: 'browser', force: true, discard: true, @@ -382,9 +382,9 @@ describe('doctor report rendering', () => { finishInstall(); await expect(connectivity).resolves.toMatchObject({ ok: true }); expect(mockSetDaemonCommandTimeoutSeconds).toHaveBeenNthCalledWith(1, 8); - expect(mockSendCommand).toHaveBeenNthCalledWith(1, 'session-create', {}); + expect(mockSendCommand).toHaveBeenNthCalledWith(1, 'session-create', { sessionName: 'Doctor Probe' }); expect(mockSendCommand).toHaveBeenLastCalledWith('session-close', { - session: 'session_doctor_11111111', + session: 'doctor-probe-k7', surface: 'browser', force: true, discard: true, diff --git a/src/doctor.ts b/src/doctor.ts index aaca731f..f70d1791 100644 --- a/src/doctor.ts +++ b/src/doctor.ts @@ -105,7 +105,7 @@ export async function checkConnectivity(opts?: { timeout?: number }): Promise { for (const guide of guides) { const text = fs.readFileSync(guide, 'utf8'); - expect(text, path.relative(ROOT, guide)).toContain('webcmd session create'); - expect(text, path.relative(ROOT, guide)).toContain('webcmd --session '); - expect(text, path.relative(ROOT, guide)).toContain('webcmd session list'); - expect(text, path.relative(ROOT, guide)).toContain('webcmd session close '); + expect(text, path.relative(ROOT, guide)).toContain('webcmd --profile work session create "Work Project"'); + expect(text, path.relative(ROOT, guide)).toContain('webcmd --profile work --session work-project-k7'); + expect(text, path.relative(ROOT, guide)).toContain('webcmd --profile work session list'); + expect(text, path.relative(ROOT, guide)).toContain('webcmd --profile work session close work-project-k7'); } }); }); diff --git a/src/skills.test.ts b/src/skills.test.ts index b4f04d7e..0d5c745b 100644 --- a/src/skills.test.ts +++ b/src/skills.test.ts @@ -54,9 +54,9 @@ describe('webcmd skills content', () => { const skill = bundledSkill('smart-search'); const browser = bundledSkill('webcmd-browser'); const skills = [skill, browser]; - const sessionId = 'session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45'; + const sessionId = 'work-project-k7'; const sessionWorkflow = [ - 'webcmd --profile work session create', + 'webcmd --profile work session create "Work Project"', `webcmd --profile work --session ${sessionId} browser run --stdin`, `webcmd --profile work --session ${sessionId} browser snapshot --snapshot-mode read`, `webcmd --profile work session close ${sessionId}`, @@ -280,14 +280,13 @@ describe('webcmd skills content', () => { const browser = bundledSkill('webcmd-browser'); const autofix = bundledSkill('webcmd-autofix'); - expect(usage).toContain('webcmd session create -f json'); + expect(usage).toContain('webcmd --profile work session create "Work Project" -f json'); expect(usage).toContain('first-choice Webcmd fetch path'); expect(usage).toContain('webcmd profile create work'); - expect(usage).toContain('webcmd --session session_abc browser'); + expect(usage).toContain('webcmd --profile work --session work-project-k7 browser'); expect(usage).toContain('SESSION_BUSY'); expect(usage).toContain('SESSION_REQUIRED'); - expect(usage).toMatch(/Adapter commands may omit `--session`[\s\S]{0,200}adapter-default session/i); - expect(usage).toMatch(/retired positional session form is invalid/i); + expect(usage).toMatch(/Adapter commands without `--session`[\s\S]{0,200}`adapter-default` Session/i); expect(browser).toContain('webcmd profile create work'); expect(browser).toMatch(/Profiles are cookie jars[\s\S]{0,180}sessions are browser workspaces\/windows/i); expect(browser).toMatch(/Parallel agents use separate sessions/i); @@ -319,7 +318,7 @@ describe('webcmd skills content', () => { for (const required of [ 'Absence from truncated output never proves that no adapter exists', - 'Create an opaque browser session before raw browser work', + 'Create a named browser Session before raw browser work', 'fresh JavaScript scope', 'persistent browser state', 'Never ask for or type passwords', From a1d44bc880c6511a8a08e5439da924ad22bcdb07 Mon Sep 17 00:00:00 2001 From: rishabhraj36 Date: Tue, 25 Aug 2026 21:31:14 +0530 Subject: [PATCH 5/5] fix: enforce readable session selector segments --- src/browser/session-identifiers.test.ts | 12 ++++++++++++ src/browser/session-identifiers.ts | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/browser/session-identifiers.test.ts b/src/browser/session-identifiers.test.ts index f5bdac93..26ca25c4 100644 --- a/src/browser/session-identifiers.test.ts +++ b/src/browser/session-identifiers.test.ts @@ -33,4 +33,16 @@ describe('session identifiers', () => { expect.objectContaining({ code: 'INVALID_SESSION_SELECTOR' }), ); }); + + it.each([ + 'work--k7', + 'a--zz', + 'work-project--k7', + '-work-k7', + 'work-k7-', + ])('rejects malformed selector segments: %s', (sessionId) => { + expect(() => requireSessionIdShape(sessionId)).toThrowError( + expect.objectContaining({ code: 'INVALID_SESSION_SELECTOR' }), + ); + }); }); diff --git a/src/browser/session-identifiers.ts b/src/browser/session-identifiers.ts index 8d81d42b..d97998b7 100644 --- a/src/browser/session-identifiers.ts +++ b/src/browser/session-identifiers.ts @@ -54,7 +54,7 @@ export function requireSessionName(input: string): string { } export function requireSessionIdShape(sessionId: string): void { - if (sessionId !== ADAPTER_DEFAULT_SESSION_ID && !/^[a-z0-9][a-z0-9-]{0,59}-[23456789abcdefghijkmnpqrstuvwxyz]{2}$/u.test(sessionId)) { + if (sessionId !== ADAPTER_DEFAULT_SESSION_ID && !/^(?=.{4,63}$)[a-z0-9]+(?:-[a-z0-9]+)*-[2-9a-km-np-z]{2}$/u.test(sessionId)) { throw new InvalidSessionSelectorError(sessionId); } }