diff --git a/docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md b/docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md new file mode 100644 index 00000000..e9305109 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md @@ -0,0 +1,2049 @@ +# Profile Sessions and Concurrency Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make an opaque Webcmd Session ID the deterministic browser-workspace and command-admission boundary, while keeping learned adapter commands ergonomic through a system-managed adapter default. + +**Architecture:** Raw browser work starts with `session create`, carries the returned immutable ID, and is admitted one top-level execution at a time. Browser-backed adapters may instead resolve the Profile's system-managed adapter-default Session; non-browser adapters allocate none. Locally, a Session owns an exclusive Cloak window group inside one Profile context; Webcmd Cloud keys Browser Use allocations and admission leases by `(userId, workspaceId, profileId, sessionId)`. + +**Tech Stack:** TypeScript, Node.js 20.6+, Commander 14, Playwright Core 1.61.1, Cloak Browser package 0.4.5 with Chromium v145.0.7632.109.2, Vitest, PostgreSQL, Browser Use, GCP release gates. + +## Global Constraints + +- Implement the approved contract in `docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md`; do not reintroduce Spaces. +- Work in `/Users/beubax/Desktop/AgentR/OpenCLI` for CLI/local tasks and `/Users/beubax/Desktop/AgentR/webcmd-cloud` for hosted tasks. +- Preserve unrelated user changes. Before every commit, stage only that task's files and inspect `git diff --cached`. +- Keep `cloakbrowser` exactly pinned to `0.4.5`, Playwright Core exactly pinned to `1.61.1`, and the supported Chromium artifact at v145.0.7632.109.2. Do not add capability/licence fallback branches. +- Add no dependency. Reuse Commander, Playwright/CDP, the local `SessionLeaseRegistry`, the hosted persistent write lease, existing Profile services, existing live-view storage, and existing rendering. +- `--session ` is a root option. Raw browser commands require an existing opaque ID; omission returns `SESSION_REQUIRED` with exit 2. No PID default, ambient `session use`, caller-chosen name, takeover, complete, or positional compatibility alias is allowed. +- `session create` always returns a new Webcmd-generated ID. `session list` is read-only. `session close` idempotently stops runtime state but preserves the record. Passing an ID is attachment; there is no `session bind` command. +- Non-browser adapters allocate no Session. Browser-backed adapters may omit `--session` and lazily resolve the Profile's one system-managed `adapter-default` Session; an explicit existing ID overrides it. +- Unknown, malformed, or cross-Profile IDs are never created. Malformed selectors are usage errors; unknown/cross-Profile IDs return `SESSION_NOT_FOUND`. +- Session records persist; local pages/windows and hosted allocations do not survive runtime restart or eviction. +- Different Sessions and Profiles run concurrently. A different overlapping execution in the same Session fails immediately with `SESSION_BUSY`; it never waits in a public queue. +- Generate execution IDs only at the trusted top-level CLI/server boundary. Permit re-entry by the same ID; never treat PID, agent identity, or caller-supplied hosted IDs as ownership. +- Local windows never mix Sessions. A Session may own a group of windows because CDP cannot target an existing `windowId`. Manual cross-window tab moves return `SESSION_WINDOW_CONFLICT` without moving or closing the tab. +- All page APIs require both Session and page identity. `browser run` receives a Session-scoped context facade and cannot enumerate, register, or close sibling/keeper pages. +- A human handoff pauses only its owning Session. Its verify command must contain the same Profile and immutable Session ID; sibling Sessions continue. +- Local Profile warm time is the fixed, unreferenced `60_000` ms. Graceful context close is bounded at `3_000` ms before exact Profile recovery. macOS uses a retained hidden target; Linux/Windows park a minimized Profile-owned page because a hidden target alone does not keep Chromium alive at zero windows. +- Hosted mode uses one Browser Use allocation and one live-view URL per active Session. Capacity exhaustion returns `SESSION_CAPACITY_EXCEEDED` with actionable safe counts. Do not implement Webcmd-owned cookie/storage synchronization. +- Roll out the hosted schema and runtime as one drained revision: route the old browser-worker revision to zero, wait at least the existing 45-second lease TTL, then enable Session-keyed browser traffic. Do not run legacy Profile-keyed and new Session-keyed workers concurrently. +- Active docs, help, completion, generated hints, and bundled skills must teach create/carry/list/close, adapter-default routing, and canonical root syntax in the same release. Historical specs remain historical. + +--- + +## Planned File Map + +### Webcmd CLI and local runtime + +- `src/root-command-surface.ts`: canonical root `--session` parsing for local and hosted dispatch. +- `src/cli-argv-preprocess.ts`: reject retired positional raw-browser syntax with a targeted exit-2 migration error; retain unrelated argv preprocessing. +- `src/cli.ts`, `src/commanderAdapter.ts`: consume the optional root selector and expose `session create`, `session list`, and `session close`. +- `src/hosted/browser-args.ts`, `src/hosted/runner.ts`, `src/hosted/client.ts`, `src/hosted/types.ts`: send the selector for adapter and raw-browser requests and render hosted Session lists. +- `src/browser/sessions.ts`: local persisted opaque Session records, adapter-default resolution, handoff metadata, close state, and public list rows. +- `src/browser/protocol.ts`, `src/browser/runtime/provider.ts`, `src/daemon/server.ts`: resolve a selector before admission, carry immutable Session IDs, expose Session status, and return structured errors. +- `src/execution.ts`, `src/browser/page.ts`, `src/browser/daemon-client.ts`, `src/session-lease.ts`, `src/errors.ts`, `src/main.ts`: top-level run identity, signal/cancel cleanup, Session admission, adapter tab routing, and handoff controls. +- `src/browser/runtime/local-cloak/session-manager.ts`, `src/browser/runtime/local-cloak/actions.ts`, `src/browser/runtime/local-cloak/provider.ts`, `src/browser/run/playwright-transport.ts`, `src/browser/run/runner.ts`: Session window groups, owned tabs, sandbox scoping, platform keepers, Profile idle lifecycle, and Session-scoped actions. +- `src/browser/runtime/local-cloak/process-matcher.ts`: the one exact Cloak Profile process matcher reused by recovery and teardown. +- `tests/e2e/cloak-session-concurrency.test.ts`: live gate for the pinned Cloak/Chromium pair. + +### Webcmd Cloud + +- `src/domain/types.ts`, `src/sessions/service.ts`: hosted opaque Session create/list/lookup/close and adapter-default resolution. +- `src/storage/schema.sql`, `src/storage/repository.ts`, `src/storage/postgres-repository.ts`: durable Session rows and Session-keyed admission leases; retain the physical `browser_allocations.session_key` column but store immutable Session IDs in it. +- `src/http/router.ts`, `src/executor/non-browser.ts`: expose Session lifecycle routes, accept optional adapter `session`, require raw IDs, mint trusted execution IDs, and route adapters. +- `src/browser/allocation-manager.ts`, `src/browser/dependencies.ts`, `src/browser/runtime.ts`: one durable Browser Use allocation per Session without the current `PROFILE_SESSION_KEY` collapse. +- `src/executor/browser-session-policy.ts`, `src/executor/session-write-lease.ts`, `src/browser/hosted-browser.ts`, `src/browser/session-lock.ts`: Session-keyed adapter tabs, immediate admission, and raw-browser reuse of the same allocation. +- `src/auth/hosted-auth.ts`, `src/account/browser-live-view.ts`, `src/account/live-view.ts`: Session-scoped handoff and exact allocation/view revocation. +- `src/live-gates/browser-use-spike.ts`, `src/live-gates/browser-gates.ts`, `src/live-gates/runner.ts`: concurrent same-Profile persistence and sibling-handoff release gates. + +### Active documentation and generated surfaces + +- `README.md`, `docs/authentication-and-profiles.mdx`, `docs/browser-and-sitemap-memory.mdx`, `docs/cli-reference.mdx`, `docs/local-or-cloud.mdx`, `docs/x-session-cli.mdx`, and `docs/agents/*.md`: user and harness guidance. +- `skills/webcmd-usage/SKILL.md`, `skills/webcmd-browser/SKILL.md`, `skills/webcmd-autofix/SKILL.md`, `skills/webcmd-adapter-author/SKILL.md`, `skills/webcmd-sitemap-author/SKILL.md`, `skills/webcmd-browser-sitemap/SKILL.md`, plus directly referenced active skill examples: agent instructions. +- `src/completion-shared.ts`, generated hosted contract/manifest artifacts, and their sync tests: canonical discoverability and compatibility. + +--- + +### Task 1: Canonical Root Selector and Raw-Browser Requirement + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Modify: `src/root-command-surface.ts` +- Modify: `src/cli-argv-preprocess.ts` +- Modify: `src/main.ts` +- Modify: `src/cli.ts` +- Modify: `src/commanderAdapter.ts` +- Modify: `src/hosted/browser-args.ts` +- Modify: `src/hosted/runner.ts` +- Modify: `src/hosted/client.ts` +- Modify: `src/hosted/types.ts` +- Test: `src/hosted/root-command-surface.test.ts` +- Test: `src/cli-argv-preprocess.test.ts` +- Test: `src/cli.test.ts` +- Test: `src/hosted/browser-args.test.ts` +- Test: `src/hosted/runner.test.ts` +- Test: `src/hosted/client.test.ts` + +**Interfaces:** + +- Produces `ROOT_SESSION_FLAGS`, root option `session?: string`, and hosted adapter request field `session?: string`. +- Raw browser dispatch rejects omission before any daemon/cloud call; adapter dispatch leaves omission unresolved for Task 2/Task 7 routing. +- Produces `rejectPositionalBrowserSessionArgv(argv): string[]`, which never rewrites a Session selector, retains the existing trailing-`--window` normalization, or throws `BrowserSessionArgvError`. +- Later tasks consume the selector as an unresolved optional opaque ID; this task does not create Session records. + +- [ ] **Step 1: Replace positional-success tests with canonical and migration-error tests** + +Add these central assertions and update every existing positional fixture in the listed test files: + +```ts +expect(parseHostedRootCommandSurface([ + '--profile', 'work', '--session', 'session_a', 'github', 'issues', +])).toEqual({ + kind: 'dispatch', + argv: ['github', 'issues'], + profile: 'work', + session: 'session_a', + literal: false, +}); + +expect(() => rejectPositionalBrowserSessionArgv(['browser', 'session_a', 'run', '--stdin'])) + .toThrowError(/webcmd --session session_a browser run --stdin/); + +expect(rejectPositionalBrowserSessionArgv(['--session', 'session_a', 'browser', 'run', '--stdin'])) + .toEqual(['--session', 'session_a', 'browser', 'run', '--stdin']); + +expect(() => validateRawBrowserSession(undefined)).toThrowError( + expect.objectContaining({ code: 'SESSION_REQUIRED', exitCode: 2 }), +); +expect(() => validateRawBrowserSession('work')).toThrowError( + expect.objectContaining({ code: 'INVALID_SESSION_SELECTOR', exitCode: 2 }), +); +``` + +For hosted transport, assert both surfaces carry the same selector: + +```ts +expect(executeRequest.body).toMatchObject({ + command: 'github/issues', + session: 'session_a', +}); +expect(browserRequest.pathname).toBe('/v1/browser/session_a/commands'); +``` + +- [ ] **Step 2: Run the focused tests and confirm the old grammar still wins** + +Run: + +```bash +npx vitest run --project unit src/hosted/root-command-surface.test.ts src/cli-argv-preprocess.test.ts src/cli.test.ts src/hosted/browser-args.test.ts src/hosted/runner.test.ts src/hosted/client.test.ts +``` + +Expected: FAIL because root parsing omits `session`, positional browser argv is rewritten successfully, and hosted adapter requests omit the selector. + +- [ ] **Step 3: Add the root option and remove the hidden browser option** + +Use the shared root surface as the sole source of truth: + +```ts +export const ROOT_SESSION_FLAGS = '--session '; +export const ROOT_SESSION_DESCRIPTION = 'Existing opaque Session ID from `webcmd session create`'; + +export function configureRootCommandSurface(program: Command): Command { + return program + .version(PKG_VERSION) + .option(ROOT_PROFILE_FLAGS, ROOT_PROFILE_DESCRIPTION) + .option(ROOT_SESSION_FLAGS, ROOT_SESSION_DESCRIPTION) + .enablePositionalOptions(); +} +``` + +Extend `HostedRootCommandSurface` dispatch results with `session?: string`; make `findRootCommandBoundary` consume `--session value` and `--session=value` exactly as it already consumes `--profile`. Remove the hidden browser `--session` option, positional usage override, and positional examples from both `src/cli.ts` and `src/hosted/browser-args.ts`. + +- [ ] **Step 4: Replace rewriting with a targeted detector** + +Keep `BROWSER_SUBCOMMAND_NAMES`, because it distinguishes `browser run` from the retired `browser session_a run`. Replace only `rewriteBrowserArgv`: + +```ts +export function rejectPositionalBrowserSessionArgv(argv: readonly string[]): string[] { + const result = [...argv]; + const commandIndex = findRootCommandIndex(result, new Set(['--profile', '--session', '--workspace'])); + if (result[commandIndex] !== 'browser') return result; + const candidate = result[commandIndex + 1]; + if (!candidate || candidate.startsWith('-') || BROWSER_SUBCOMMAND_NAMES.has(candidate)) { + hoistBrowserWindowOption(result, commandIndex + 1); + return result; + } + const replacement = [ + ...result.slice(0, commandIndex), + '--session', candidate, + 'browser', + ...result.slice(commandIndex + 2), + ]; + throw new BrowserSessionArgvError( + `Browser sessions are root selectors. Use: webcmd ${replacement.join(' ')}`, + ); +} +``` + +Retain `hoistBrowserWindowOption` only if canonical tests still require trailing `--window`; otherwise delete it with the positional rewrite. Update `main.ts` and hosted dispatch to call the detector once before Commander parsing. A thrown `BrowserSessionArgvError` must print the replacement and exit `2`. + +- [ ] **Step 5: Thread the selector through adapter and browser hosted requests** + +In `commanderAdapter.ts`, pass the root global without interpreting it: + +```ts +...(typeof globals.session === 'string' && globals.session.trim() + ? { session: globals.session.trim() } + : {}), +``` + +Add `session?: string` to `HostedClient.execute` and `runPreparedExecution`, their JSON validators/types, and `dispatchHosted`. Adapter omission remains omitted. Before local or hosted raw-browser dispatch, call `validateRawBrowserSession`; its `SESSION_REQUIRED` help prints complete `session create` and `session list` commands carrying the selected Profile. Continue using the validated ID in the existing encoded path segment. Do not accept a browser-namespace selector or friendly string. + +- [ ] **Step 6: Verify and commit** + +Run: + +```bash +npx vitest run --project unit src/hosted/root-command-surface.test.ts src/cli-argv-preprocess.test.ts src/cli.test.ts src/hosted/browser-args.test.ts src/hosted/runner.test.ts src/hosted/client.test.ts +npm run typecheck +``` + +Expected: PASS; positional syntax exits 2 with the canonical replacement, raw omission/malformed selectors exit 2 before transport, and adapters may omit the root selector. + +Commit: + +```bash +git add src/root-command-surface.ts src/cli-argv-preprocess.ts src/main.ts src/cli.ts src/commanderAdapter.ts src/hosted/browser-args.ts src/hosted/runner.ts src/hosted/client.ts src/hosted/types.ts src/hosted/root-command-surface.test.ts src/cli-argv-preprocess.test.ts src/cli.test.ts src/hosted/browser-args.test.ts src/hosted/runner.test.ts src/hosted/client.test.ts +git diff --cached --check +git commit -m "feat: make session a root cli selector" +``` + +--- + +### Task 2: Create, Persist, List, and Close Local Sessions + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Create: `src/browser/sessions.ts` +- Create: `src/browser/sessions.test.ts` +- Modify: `src/browser/protocol.ts` +- Modify: `src/browser/runtime/provider.ts` +- Modify: `src/browser/runtime/local-cloak/provider.ts` +- Modify: `src/daemon/server.ts` +- Modify: `src/browser/daemon-client.ts` +- Modify: `src/cli.ts` +- Test: `src/daemon/server.test.ts` +- Test: `src/cli.test.ts` + +**Interfaces:** + +- Produces `BrowserSessionRecord`, `BrowserSessionListRow`, and `LocalBrowserSessionStore`. +- Produces `create(profileId)`, `find(profileId, sessionId)`, `require(profileId, sessionId)`, `resolveAdapterDefault(profileId)`, `list(profileId)`, `markHandoff`, `clearHandoff`, and `touch`. +- Provider produces `resolveSession(command)` before browser dispatch and includes `sessions` in a Profile-filtered status response. + +- [ ] **Step 1: Write persistence and resolution tests** + +Use a temporary base directory and deterministic dependencies: + +```ts +const store = new LocalBrowserSessionStore({ + baseDir, + now: () => new Date('2026-08-11T00:00:00.000Z'), + idFactory: () => 'session_11111111-1111-4111-8111-111111111111', +}); + +const created = store.create('profile_work'); +expect(created).toMatchObject({ + id: 'session_11111111-1111-4111-8111-111111111111', + profileId: 'profile_work', + kind: 'explicit', +}); +expect(store.create('profile_work').id).not.toBe(created.id); +expect(new LocalBrowserSessionStore({ baseDir }).find('profile_work', created.id)?.id) + .toBe(created.id); +expect(() => store.require('profile_other', created.id)).toThrowError( + expect.objectContaining({ code: 'SESSION_NOT_FOUND' }), +); +const adapterDefault = store.resolveAdapterDefault('profile_work'); +expect(adapterDefault.kind).toBe('adapter-default'); +expect(store.resolveAdapterDefault('profile_work').id).toBe(adapterDefault.id); +``` + +At the daemon layer, activate the created Session in a fake manager, close it, and assert `{ closed: true, alreadyIdle: false }`; close it again and assert `{ closed: false, alreadyIdle: true }`. Also assert `list` does not create the adapter default, a caller cannot supply an ID or name to `create`, malformed JSON fails closed with a useful configuration error, the state file is mode `0600`, and a temp file is renamed over the destination. + +- [ ] **Step 2: Run tests to verify the store and status fields do not exist** + +Run: + +```bash +npx vitest run --project unit src/browser/sessions.test.ts src/daemon/server.test.ts src/cli.test.ts +``` + +Expected: FAIL because `LocalBrowserSessionStore`, Session status, and `session list` are absent. + +- [ ] **Step 3: Implement the minimal local store** + +Use this exact record shape: + +```ts +export interface BrowserSessionRecord { + id: string; + profileId: string; + kind: 'explicit' | 'adapter-default'; + createdAt: string; + updatedAt: string; + lastUsedAt: string; + handoff?: { site: string; expiresAt: string }; +} + +export interface BrowserSessionListRow extends BrowserSessionRecord { + runtimeState: 'idle' | 'active'; +} +``` + +Persist `{ version: 1, sessions: BrowserSessionRecord[] }` at `path.join(baseDir ?? getWebcmdConfigDir(), 'browser-sessions.json')`. Generate IDs with `session_${randomUUID()}`. Explicit creation always inserts. Lookup and adapter-default resolution are: + +```ts +requireSessionIdShape(sessionId); +const found = sessions.find(row => row.id === sessionId && row.profileId === profileId); +if (!found) throw new SessionNotFoundError(sessionId, profileId); +return touch(found); + +const existing = sessions.find(row => row.profileId === profileId && row.kind === 'adapter-default'); +return existing ?? insert({ id: idFactory(), profileId, kind: 'adapter-default' }); +``` + +Enforce at most one `adapter-default` record per Profile in memory and during load validation. Runtime active/idle state is derived from the Session manager, not persisted in this file. + +Write JSON to `....tmp` with mode `0600`, then `renameSync`. The daemon is the sole writer, so do not add filesystem locking. + +- [ ] **Step 4: Resolve before daemon admission and expose list state** + +Extend the provider contract: + +```ts +requireSession(command: BrowserRuntimeCommand): Promise; +resolveAdapterDefault(command: BrowserRuntimeCommand): Promise; +listSessions(input: { profileId?: string }): Promise; +``` + +At `/command`, resolve once according to surface, then dispatch an enriched copy: + +```ts +const session = body.surface === 'adapter' && !body.session + ? await provider.resolveAdapterDefault(body) + : await provider.requireSession(body); +const command = { ...body, sessionId: session.id, sessionKind: session.kind }; +``` + +Do not resolve `lease-release` or Session lifecycle/status controls. Add explicit daemon controls for `session-create`, `session-list`, and `session-close`. Local `runtimeState` is `active` only when the manager owns an open task tab/window for that ID. `session-close` first checks admission/handoff, closes only the Session window group, clears manager metadata, and then marks the durable record idle. + +- [ ] **Step 5: Add the three Session lifecycle commands** + +Register `session create`, `session list`, and `session close ` in `cli.ts`. Read the root Profile selector through existing Profile resolution. `create` accepts no name/ID argument. Render the minimal default columns: + +```ts +['id', 'kind', 'runtimeState', 'handoff'] +``` + +When the daemon is absent, `create` may start the daemon through the normal mutation path; `list` reads persisted state and reports rows as `idle`; `close` of an idle persisted record succeeds as a no-op. None launches Cloak. Listing must explicitly report zero results and must not create the adapter default. Use the existing renderer and structured error conventions instead of a new formatter. + +- [ ] **Step 6: Verify restart persistence and commit** + +Run: + +```bash +npx vitest run --project unit src/browser/sessions.test.ts src/daemon/server.test.ts src/cli.test.ts +npm run typecheck +``` + +Expected: PASS; each create returns a unique immutable ID, adapter-default resolution is singleton and lazy, cross-Profile IDs fail, close is idempotent, and list works with or without a running daemon. + +Commit: + +```bash +git add src/browser/sessions.ts src/browser/sessions.test.ts src/browser/protocol.ts src/browser/runtime/provider.ts src/browser/runtime/local-cloak/provider.ts src/daemon/server.ts src/browser/daemon-client.ts src/cli.ts src/daemon/server.test.ts src/cli.test.ts +git diff --cached --check +git commit -m "feat: persist local browser sessions" +``` + +--- + +### Task 3: Make Local Admission and Adapter Routing Session-Scoped + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Modify: `src/session-lease.ts` +- Modify: `src/daemon/server.ts` +- Modify: `src/browser/protocol.ts` +- Modify: `src/browser/daemon-client.ts` +- Modify: `src/browser/page.ts` +- Modify: `src/execution.ts` +- Modify: `src/main.ts` +- Modify: `src/errors.ts` +- Test: `src/session-lease.test.ts` +- Test: `src/daemon/server.test.ts` +- Test: `src/browser/daemon-client.test.ts` +- Test: `src/execution.test.ts` + +**Interfaces:** + +- Consumes immutable `sessionId` from Task 2. +- Produces `getSessionLeaseKey(profileId, sessionId)` and admission for every browser-backed top-level run. +- Produces separate adapter tab identity `(profileId, sessionId, site)` while preserving `siteSession: persistent|ephemeral`. +- Produces tracked cancellation and dead-PID recovery so widening admission does not turn the 45-second TTL into the normal recovery experience. + +- [ ] **Step 1: Write admission and routing tests** + +Cover the same-ID re-entry, different-ID rejection, same PID rejection, sequential success, and cross-Session parallelism: + +```ts +const key = getSessionLeaseKey('profile_work', 'session_a'); +expect(registry.acquire({ key, runId: 'run_7_one', command: 'browser/run', pid: 7 }, () => true).acquired) + .toBe(true); +expect(registry.acquire({ key, runId: 'run_7_one', command: 'browser/tabs', pid: 7 }, () => true).acquired) + .toBe(true); +expect(registry.acquire({ key, runId: 'run_7_two', command: 'github/issues', pid: 7 }, () => true)) + .toMatchObject({ acquired: false }); +expect(registry.acquire({ + key: getSessionLeaseKey('profile_work', 'session_b'), + runId: 'run_7_two', command: 'github/issues', pid: 7, +}, () => true).acquired).toBe(true); +registry.releaseByRunId('run_7_one'); +expect(registry.acquire({ key, runId: 'run_7_three', command: 'browser/run' }, () => false).acquired) + .toBe(true); +``` + +Add daemon-level tests where a holder PID becomes dead while work remains. Assert the daemon aborts that run, waits for its operation `finally`, releases, and only then admits the successor; no overlap occurs. Add signal/disconnect tests asserting one best-effort cancel, cleanup on repeated signals, and a busy hint that never recommends killing an already-dead PID. + +In `execution.test.ts`, assert persistent tabs for the same site produce different daemon Session IDs when root Sessions differ, while ephemeral tabs are released only inside their owning Session. +Also hold a persistent GitHub adapter in `session_a` and assert an overlapping LinkedIn adapter +in `session_a` receives `SESSION_BUSY`; run LinkedIn in `session_b` and assert it proceeds. This +pins the intentional Session-wide—not `(Session, site)`—admission decision. + +- [ ] **Step 2: Run the focused tests and observe Profile/surface-scoped behavior** + +Run: + +```bash +npx vitest run --project unit src/session-lease.test.ts src/daemon/server.test.ts src/browser/daemon-client.test.ts src/execution.test.ts +``` + +Expected: FAIL because the lease predicate only covers persistent adapter writes and keys include `surface` plus the adapter-generated site session. + +- [ ] **Step 3: Broaden the existing registry instead of adding a queue** + +Change the key and predicate: + +```ts +export function getSessionLeaseKey(profileId: string, sessionId: string): string { + return `${profileId}\u241f${sessionId}`; +} + +export function isSessionLeaseCommand(command: SessionLeaseCommand): command is SessionLeaseCommand & { + sessionId: string; + runId: string; +} { + return command.action !== 'lease-release' + && command.action !== 'run-cancel' + && typeof command.sessionId === 'string' + && command.sessionId.length > 0 + && typeof command.runId === 'string' + && command.runId.length > 0; +} +``` + +Delete `access` from `SessionLeaseCommand`, `DaemonRunContext`, and the daemon predicate; it is currently hardcoded to `write` and gates nothing useful. Acquire after Session resolution and before `provider.dispatch`. On conflict return HTTP 409 with `{ code: 'SESSION_BUSY', session: { id, kind }, holder: { command, pid?, acquiredAt, heartbeatAt } }`; never include `runId`. + +- [ ] **Step 4: Mint and propagate one run ID for every browser-backed CLI invocation** + +Move `generateRunId()` to the top-level browser-backed branch in `executeCommand`, not the persistent-write branch. Run the complete adapter command inside `runWithDaemonRunContext`, and release by run ID in the existing `finally`. Raw browser actions must use the same wrapper in their Commander action so nested daemon operations re-enter. This is mandatory: changing `isSessionLeaseCommand` alone cannot admit commands that never carry a `runId`. + +Hosted callers are unrelated here; do not accept a CLI flag or environment variable as `runId`. + +Add a daemon `run-cancel` control backed by an `AbortController` per active run. `main.ts` installs one-shot `SIGINT`/`SIGTERM` cleanup that asks the daemon to cancel the active run and preserves the conventional signal exit. The daemon also cancels on request disconnect. Admission recovery for a dead holder PID calls `cancelAndSettle(runId, 2_000)`; it retries acquisition only after tracked work settles. If it does not settle, return retryable `SESSION_BUSY` and retain the 45-second TTL as the final unknown-outcome path. + +- [ ] **Step 5: Separate selected Session from adapter tab lifecycle** + +Delete `resolveAdapterBrowserSession(cmd, siteSession)` and today's `site::` Session minting. Pass the optional root ID to `BrowserPage`; the daemon resolves explicit versus adapter-default before constructing the tab key: + +```ts +const tabKey = command.surface === 'adapter' && command.siteSession === 'persistent' + ? `${command.sessionId}\0site:${command.adapterSite}` + : `${command.sessionId}\0ephemeral:${command.runId}`; +``` + +Add `adapterSite?: string` to the daemon protocol and set it from `cmd.site` in `execution.ts`. `siteSession` still decides whether release closes the adapter tab. It must never choose the Session admission key. + +- [ ] **Step 6: Add consistent typed errors and verify** + +Add `SessionRequiredError` (exit 2), `InvalidSessionSelectorError` (exit 2), `SessionNotFoundError` (exit 66), enhance `SessionBusyError` with safe Session ID/kind metadata (exit 75), add `SessionPausedForHumanHandoffError` (exit 77), and `SessionWindowConflictError` (exit 75). Map the same uppercase daemon codes in `daemon-client.ts`. Busy help checks recorded PID liveness before including any kill guidance. + +Run: + +```bash +npx vitest run --project unit src/session-lease.test.ts src/daemon/server.test.ts src/browser/daemon-client.test.ts src/execution.test.ts +npm run typecheck +``` + +Expected: PASS; overlapping runs in one Session fail immediately, the same run re-enters, different Sessions progress concurrently, raw commands actually carry run IDs, and killed/timed-out clients do not brick or overlap the Session. + +Commit: + +```bash +git add src/session-lease.ts src/daemon/server.ts src/browser/protocol.ts src/browser/daemon-client.ts src/browser/page.ts src/execution.ts src/main.ts src/errors.ts src/session-lease.test.ts src/daemon/server.test.ts src/browser/daemon-client.test.ts src/execution.test.ts +git diff --cached --check +git commit -m "feat: admit local browser work by session" +``` + +--- + +### Task 4: Enforce Local Page Isolation and Owned Cloak Window Groups + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Modify: `src/browser/runtime/local-cloak/session-manager.ts` +- Modify: `src/browser/runtime/local-cloak/actions.ts` +- Modify: `src/browser/runtime/local-cloak/provider.ts` +- Modify: `src/browser/runtime/local-cloak/darwin-background-launch.ts` +- Modify: `src/browser/run/playwright-transport.ts` +- Modify: `src/browser/run/runner.ts` +- Test: `src/browser/runtime/local-cloak/session-manager.test.ts` +- Test: `src/browser/runtime/local-cloak/browser-run.test.ts` +- Test: `src/browser/runtime/local-cloak/provider.test.ts` +- Test: `src/browser/run/playwright-transport.test.ts` +- Test: `src/browser/run/runner.test.ts` + +**Interfaces:** + +- Consumes immutable `sessionId` and adapter tab key from Tasks 2-3. +- Produces `SessionRuntime` ownership of `windowIds`, `pages`, and `selectedPageId` under one `ProfileRuntime` context. +- Produces `SESSION_WINDOW_CONFLICT` before any operation on a tab whose actual `windowId` belongs to another Session. +- Produces a Session-scoped Playwright context facade; raw `BrowserContext.pages()` and context-wide page adoption are no longer reachable from `browser run`. + +- [ ] **Step 1: Add CDP-backed window ownership tests** + +Extend the existing fake context with a browser CDP session that records `Target.createTarget`, `Browser.getWindowForTarget`, and `Target.closeTarget`. Assert: + +```ts +const first = await manager.getPage({ + profileId: 'work', sessionId: 'session_a', surface: 'browser', +}); +const second = await manager.getPage({ + profileId: 'work', sessionId: 'session_b', surface: 'browser', +}); + +expect(cdp.sent.filter(call => call.method === 'Target.createTarget')).toEqual([ + expect.objectContaining({ params: expect.objectContaining({ newWindow: true }) }), + expect.objectContaining({ params: expect.objectContaining({ newWindow: true }) }), +]); +expect(windowIdFor(first.pageId)).not.toBe(windowIdFor(second.pageId)); +expect((await manager.listPages({ profileId: 'work', sessionId: 'session_a' })) + .map(tab => tab.sessionId)).toEqual(['session_a']); +``` + +Create another tab for `session_a`. Assert its actual window is either an existing `session_a` window or a newly registered `session_a` window, never `session_b`'s. Emit a popup with `opener() === first.page` and assert it inherits `session_a`, including when Chromium gives it a child popup window. + +Simulate a manual move by returning `session_b`'s window ID for a `session_a` target. Assert `list`, `select`, `bind`, and `close` reject with `SESSION_WINDOW_CONFLICT`, the page remains open, and neither ownership map changes. + +Add the two current escape-path regressions: + +```ts +expect(manager.findPageById({ profileId: 'work', sessionId: 'session_a', pageId: pageB })) + .toBeNull(); +await expect(runInSessionA('return (await context.pages()).map(p => p.url())')) + .resolves.toEqual(['https://a.example/']); +await expect(runInSessionA('await (await context.pages())[1].close()')) + .rejects.toMatchObject({ code: 'BROWSER_RUN_API_UNSUPPORTED' }); +expect(pageB.isClosed()).toBe(false); +``` + +Replace the provider test that intentionally accepts misleading Session/Profile metadata for +`--page` with a denial test. Create a page in Session B during Session A's `browser run` and +assert the runner never registers it as A. + +- [ ] **Step 2: Run the local runtime tests and confirm Profile-global page state fails them** + +Run: + +```bash +npx vitest run --project unit src/browser/runtime/local-cloak/session-manager.test.ts src/browser/runtime/local-cloak/browser-run.test.ts src/browser/runtime/local-cloak/provider.test.ts +``` + +Expected: FAIL because `ProfileRuntime` has one global `pages` map/selection, page lookup ignores Session, `context.newPage()` does not establish distinct windows, and `browser run` receives every context page plus a context-wide listener. + +- [ ] **Step 3: Replace Profile-global page ownership with Session runtimes** + +Use these internal shapes in `session-manager.ts`: + +```ts +interface SessionRuntime { + id: string; + windowIds: Set; + pages: Map; + selectedPageId?: string; +} + +interface ProfileRuntime { + context: BrowserContext; + cdp: CDPSession; + sessions: Map; + windowOwners: Map; + targetPages: Map; + lastSeenAt: number; +} +``` + +Keep the existing per-Profile `pageCreationQueues`. On the first visible page for a Session, send: + +```ts +const { targetId } = await runtime.cdp.send('Target.createTarget', { + url: 'about:blank', + newWindow: true, + background: input.windowMode === 'background', +}); +``` + +Wait for the matching Playwright page, read `Browser.getWindowForTarget({ targetId })`, and register the window only if unowned or already owned by that Session. Add `--disable-popup-blocking` to the Darwin background launch argument list (the custom launcher does not inherit Playwright's switch). Later tabs call `window.open('about:blank', '_blank')` from an owned page while the Profile creation lock is held. Inspect the actual new `windowId`: register it in an existing owned window or add a new unowned window to that Session's group. There is no `windowId` parameter, reparent attempt, detect-and-retry loop, or assumption that the second tab lands in the first window. + +- [ ] **Step 4: Register popups and verify ownership before every public action** + +Install one manager-owned `context.on('page')` listener for the Profile runtime. Resolve `await page.opener()`, copy a known opener's immutable Session ID, then verify/register the popup target and `windowId`. If a page has no known opener, leave it unowned until a Session-scoped browser-tab bind verifies that its window is unowned or owned by the selected Session. Never install a command-owned context-wide listener. + +All methods must take `sessionId`: `listPages`, `findPageById`, `profileIdForPage`, `selectPage`, `bindPage`, `closePage`, `newPage`, and `release`. `pageId` remains an address, never authorization. Store `selectedPageId` only on `SessionRuntime`. Before returning/mutating a page, call one shared guard: + +```ts +private async assertOwnedWindow(runtime: ProfileRuntime, sessionId: string, entry: PageEntry): Promise { + const actual = await this.windowIdForTarget(runtime, entry.targetId); + const owner = runtime.windowOwners.get(actual); + if (owner !== undefined && owner !== sessionId) { + throw new SessionWindowConflictError(entry.pageId, sessionId, owner); + } + if (!runtime.sessions.get(sessionId)?.windowIds.has(actual)) { + throw new SessionWindowConflictError(entry.pageId, sessionId, owner); + } +} +``` + +Never close or reassign on this error. + +- [ ] **Step 5: Replace the raw BrowserContext with a Session facade** + +Change `runBrowserProgram` and `PlaywrightTransport` inputs to consume an explicit scope: + +```ts +export interface BrowserRunSessionScope { + browser: Browser; + context: BrowserContext; + page: Page; + pages(): readonly Page[]; + createPage(): Promise; + onPage(listener: (page: Page) => void): () => void; +} +``` + +Build the Playwright dispatcher with a `scopedContext` implementation proxy. Its `pages()` and +page events use the scope, `newPage()` delegates to manager-owned creation, and `close`, raw +context/browser CDP creation, or any method that can enumerate all targets is denied with +`BROWSER_RUN_API_UNSUPPORTED`. Runner startup registers `scope.pages()` only and unsubscribes +the scoped listener during normal, timeout, and error cleanup. The hidden/parking keeper has no +Session and therefore can never enter the scope. + +- [ ] **Step 6: Preserve fresh-page and adapter semantics inside the window group** + +For `freshPage`, open and register the replacement in the Session window group first, update that Session's selected and canonical adapter tab entries, then close the old target. `release` closes only ephemeral entries for that Session. Closing the last tab invokes Task 5's keeper transition rather than returning a potentially dying context. + +- [ ] **Step 7: Verify and commit** + +Run: + +```bash +npx vitest run --project unit src/browser/runtime/local-cloak/session-manager.test.ts src/browser/runtime/local-cloak/browser-run.test.ts src/browser/runtime/local-cloak/provider.test.ts src/browser/run/playwright-transport.test.ts src/browser/run/runner.test.ts +npm run typecheck +``` + +Expected: PASS; two Sessions receive exclusive window groups, tabs/popups stay in their owner, selection/page IDs are scoped, `browser run` cannot see sibling pages, and a manual move is non-destructive. + +Commit: + +```bash +git add src/browser/runtime/local-cloak/session-manager.ts src/browser/runtime/local-cloak/actions.ts src/browser/runtime/local-cloak/provider.ts src/browser/runtime/local-cloak/darwin-background-launch.ts src/browser/run/playwright-transport.ts src/browser/run/runner.ts src/browser/runtime/local-cloak/session-manager.test.ts src/browser/runtime/local-cloak/browser-run.test.ts src/browser/runtime/local-cloak/provider.test.ts src/browser/run/playwright-transport.test.ts src/browser/run/runner.test.ts +git diff --cached --check +git commit -m "feat: isolate local sessions by cloak window" +``` + +--- + +### Task 5: Add Cross-Platform Profile Keepers, Race-Free Lifecycle, and Exact Teardown + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Create: `src/browser/runtime/local-cloak/process-matcher.ts` +- Create: `src/browser/runtime/local-cloak/process-matcher.test.ts` +- Modify: `src/browser/runtime/local-cloak/session-manager.ts` +- Modify: `src/browser/runtime/local-cloak/provider.ts` +- Modify: `src/browser/runtime/local-cloak/darwin-background-launch.ts` +- Test: `src/browser/runtime/local-cloak/session-manager.test.ts` +- Test: `src/browser/runtime/local-cloak/provider.test.ts` +- Test: `src/browser/runtime/local-cloak/darwin-background-launch.test.ts` + +**Interfaces:** + +- Produces a retained macOS hidden `anchorTargetId` or Linux/Windows Profile-owned parking page, plus one browser CDP session per live Profile runtime. +- Produces a fixed `PROFILE_IDLE_TIMEOUT_MS = 60_000`, `PROFILE_CLOSE_TIMEOUT_MS = 3_000`, and one per-Profile lifecycle lock shared by launch, cancellation, anchor repair, idle close, and recovery. +- Produces `findExactCloakProfileProcesses(userDataDir)` reused by locked-Profile recovery and background teardown. +- Produces shutdown fencing that awaits in-flight Profile launches and prevents late runtime publication. + +- [ ] **Step 1: Add platform-keeper, lifecycle-race, and shutdown tests** + +Assert the runtime is not published before anchor creation: + +```ts +const pending = manager.getPage({ profileId: 'work', sessionId: 'session_a' }); +await vi.waitFor(() => expect(cdp.sent).toContainEqual({ + method: 'Target.createTarget', + params: { url: 'about:blank', hidden: true, background: true }, +})); +expect(manager.activeProfileIds()).toEqual([]); +resolveAnchorTarget(); +await pending; +expect(manager.activeProfileIds()).toEqual(['work']); +``` + +With fake time, close the final Session window, advance `59_999` ms, and assert the context remains. Advance one millisecond and assert it is removed from `activeProfileIds()` before `context.close()` begins. Assert the timer's `unref` was called. + +For `platform: 'darwin'`, assert the hidden target is retained and never adopted even though the fake Playwright context reports it in `context.pages()`. Assert the browser CDP session is not detached until runtime close. For `linux` and `win32`, assert the final task page is navigated to `about:blank`, removed from Session maps, minimized/parked as Profile-owned, and excluded from list/run APIs. Simulate the user closing that window and assert the next command performs one clean relaunch. + +Start a new command at `59_999` ms and assert it cancels eviction and reuses the context. Start a command after close begins and assert it waits, then all simultaneous callers receive one replacement runtime from one launch. Resolve `context.close()` after more than 3 seconds and assert exact recovery runs once before relaunch. + +Start `getPage`, hold `launchPersistentContext`, call `shutdown`, then resolve launch. Assert shutdown awaits and closes the late context, `profiles` and `profileLaunches` are empty, and no runtime is published. Assert a post-shutdown call fails deterministically rather than launching. + +- [ ] **Step 2: Add exact process-match tests for issue #242** + +Use command lines covering equals/separate and quoted values: + +```ts +expect(matchCloakProfileCommand(cloak, '/profiles/work')).toBe(true); +expect(matchCloakProfileCommand(cloakSeparate, '/profiles/work')).toBe(true); +expect(matchCloakProfileCommand(cloakQuoted, '/profiles/work')).toBe(true); +expect(matchCloakProfileCommand(cloakWork2, '/profiles/work')).toBe(false); +expect(matchCloakProfileCommand(chromeWork, '/profiles/work')).toBe(false); +expect(matchCloakProfileCommand(`node tool.js --user-data-dir=/profiles/work`, '/profiles/work')) + .toBe(false); +``` + +- [ ] **Step 3: Run focused tests and observe final-page context invalidation/races** + +Run: + +```bash +npx vitest run --project unit src/browser/runtime/local-cloak/process-matcher.test.ts src/browser/runtime/local-cloak/session-manager.test.ts src/browser/runtime/local-cloak/darwin-background-launch.test.ts +``` + +Expected: FAIL because no keeper/timer exists, close and launch are separate paths, shutdown ignores `profileLaunches`, and the matcher accepts only a substring form. + +- [ ] **Step 4: Create and maintain the platform keeper** + +Immediately after Cloak launch and before `profiles.set`, obtain `context.browser()`, create `browser.newBrowserCDPSession()`, and send: + +```ts +const { targetId: anchorTargetId } = await cdp.send('Target.createTarget', { + url: 'about:blank', + hidden: true, + background: true, +}); +``` + +On macOS, if the pinned runtime returns `null` from `context.browser()` or rejects the hidden target, fail launch and let the live gate block the release. Retain the browser CDP session; do not execute the existing helper's `finally { cdp.detach() }`. Store the anchor outside Session/page maps and filter its target ID before adoption, manager page events, public lists, and runner scopes. If destroyed while healthy, recreate it under the Profile lifecycle lock. + +On Linux/Windows, still create/filter the hidden target for uniform ownership bookkeeping, but do not depend on it for liveness. When the final task page would close, navigate that page to `about:blank`, clear captures/listeners, remove it from its Session, record it as `parkingPage`, and minimize its window with `Browser.setWindowBounds` when supported. After a new Session window is successfully registered, close the old parking page. If a user closes it first and Chromium exits, invalidate that exact generation and relaunch on demand. + +- [ ] **Step 5: Serialize idle close and relaunch** + +Use one `withProfileLifecycleLock(profileId, task)` queue. Add `activeCommands: number` to `ProfileRuntime` and a `runWithProfileActivity(profileId, task)` wrapper: increment/cancel idle before provider dispatch, then decrement/reschedule in `finally`. Accept `hasActiveHandoff(profileId)` as a manager option backed by Task 2's Session store. Schedule eviction only when every Session has zero visible pages, `activeCommands === 0`, and that callback is false. The callback rechecks those conditions, deletes the exact runtime from `profiles`, then awaits: + +```ts +await Promise.race([ + runtime.context.close(), + new Promise((_, reject) => setTimeout( + () => reject(new Error('Cloak Profile close timed out')), + PROFILE_CLOSE_TIMEOUT_MS, + )), +]); +``` + +On timeout call exact Profile recovery and await it before the lock releases. Guard context `close` events by runtime object identity/generation so an old event cannot delete a replacement. A runtime in `closing` state is removed before close begins and is never returned. + +Add `shuttingDown` and a launch-generation fence. `shutdown()` sets the fence first, awaits `Promise.allSettled([...profileLaunches.values()])`, closes every runtime including launches that completed after the snapshot, detaches retained CDP sessions during close, and clears maps only after no launch can reinsert. `launchProfileRuntime` checks the fence immediately before `profiles.set`; if closing began, it closes the candidate and throws `DAEMON_SHUTTING_DOWN`. + +- [ ] **Step 6: Extract and reuse the exact matcher** + +Move process discovery out of `session-manager.ts`. Recognize a Cloak executable path first, then match only complete `--user-data-dir=/path` or `--user-data-dir /path` arguments, including single/double-quoted values. Both locked-profile recovery and Darwin background teardown call this helper. Session close never calls it. + +- [ ] **Step 7: Verify issue #276 cycles and commit** + +Run: + +```bash +npx vitest run --project unit src/browser/runtime/local-cloak/process-matcher.test.ts src/browser/runtime/local-cloak/session-manager.test.ts src/browser/runtime/local-cloak/provider.test.ts src/browser/runtime/local-cloak/darwin-background-launch.test.ts +npm run typecheck +``` + +Expected: PASS for repeated release/close/fresh-page/idle cycles, macOS zero-window hidden reuse, Linux/Windows parking-window reuse, accidental keeper close, shutdown during launch, the close/arrival race, and `work` versus `work-2`. + +Commit: + +```bash +git add src/browser/runtime/local-cloak/process-matcher.ts src/browser/runtime/local-cloak/process-matcher.test.ts src/browser/runtime/local-cloak/session-manager.ts src/browser/runtime/local-cloak/provider.ts src/browser/runtime/local-cloak/darwin-background-launch.ts src/browser/runtime/local-cloak/session-manager.test.ts src/browser/runtime/local-cloak/provider.test.ts src/browser/runtime/local-cloak/darwin-background-launch.test.ts +git diff --cached --check +git commit -m "fix: keep cloak profiles alive between sessions" +``` + +--- + +### Task 6: Make Local Authentication Handoff Session-Scoped + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Modify: `src/browser/sessions.ts` +- Modify: `src/browser/protocol.ts` +- Modify: `src/browser/daemon-client.ts` +- Modify: `src/daemon/server.ts` +- Modify: `src/execution.ts` +- Modify: `src/plugin-runtime.ts` +- Modify: `src/browser/runtime/local-cloak/session-manager.ts` +- Test: `src/browser/sessions.test.ts` +- Test: `src/daemon/server.test.ts` +- Test: `src/execution.test.ts` +- Test: `src/plugin-runtime.test.ts` + +**Interfaces:** + +- Consumes local Session records, admission, and Session window foregrounding. +- Produces daemon controls `session-handoff-start` and `session-handoff-clear` owned by the same `runId`. +- Produces an immutable verify command and `SESSION_PAUSED_FOR_HUMAN_HANDOFF` for non-verification work in that Session only. + +- [ ] **Step 1: Add two-Session handoff tests** + +Execute login under `session_a`, return `action_required`, and assert: + +```ts +expect(row.verify_command).toBe( + "webcmd --profile 'work' --session session_a github whoami", +); +await expect(run({ session: 'session_a', command: 'github/issues' })) + .rejects.toMatchObject({ code: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF' }); +await expect(run({ session: 'session_b', command: 'linkedin/search' })).resolves.toBeDefined(); +await expect(run({ session: 'session_a', command: 'github/whoami' })).resolves.toBeDefined(); +``` + +Assert successful `whoami` clears the pause; failed verification retains it; expiry clears it; foregrounding targets only `session_a`'s owned window; the Profile idle timer does not start while a live handoff remains. + +- [ ] **Step 2: Run tests and verify current helper returns an unscoped command** + +Run: + +```bash +npx vitest run --project unit src/browser/sessions.test.ts src/daemon/server.test.ts src/execution.test.ts src/plugin-runtime.test.ts +``` + +Expected: FAIL because `verify_command` is `webcmd github whoami` and no Session pause exists. + +- [ ] **Step 3: Mark handoff after adapter outcome and rewrite the command** + +Keep `registerSiteAuthCommands` as the shared login/whoami protocol. In `execution.ts`, after a browser-backed `login` returns its first row, detect `status === 'action_required'`, call `session-handoff-start` with `{ sessionId, site, expiresAt }`, foreground that Session window, and replace only that row's verify command. + +Use a small POSIX-safe argument formatter: + +```ts +const quote = (value: string): string => `'${value.replaceAll("'", "'\\''")}'`; +const verifyCommand = `webcmd --profile ${quote(profileId)} --session ${sessionId} ${cmd.site} whoami`; +``` + +Session IDs need no quoting; Profile values do. Preserve the existing login result columns. + +- [ ] **Step 4: Enforce pause before admission and allow only verification** + +After Session resolution but before normal lease acquisition, read its unexpired handoff. Permit only a command marked internally as auth verification with the same Session ID and `${site}/whoami`; reject everything else immediately with `SESSION_PAUSED_FOR_HUMAN_HANDOFF`. Do not use a public flag to mark verification. + +On successful `whoami` (`logged_in === true`) call `session-handoff-clear`. Store expiry in the persisted record, clear stale values during resolve/list, and let the existing 60-second Profile lifecycle start only after the last handoff/window/command is gone. + +- [ ] **Step 5: Verify and commit** + +Run: + +```bash +npx vitest run --project unit src/browser/sessions.test.ts src/daemon/server.test.ts src/execution.test.ts src/plugin-runtime.test.ts src/browser/runtime/local-cloak/session-manager.test.ts +npm run typecheck +``` + +Expected: PASS; only the selected Session pauses, verification resumes it, and siblings continue. + +Commit: + +```bash +git add src/browser/sessions.ts src/browser/protocol.ts src/browser/daemon-client.ts src/daemon/server.ts src/execution.ts src/plugin-runtime.ts src/browser/runtime/local-cloak/session-manager.ts src/browser/sessions.test.ts src/daemon/server.test.ts src/execution.test.ts src/plugin-runtime.test.ts src/browser/runtime/local-cloak/session-manager.test.ts +git diff --cached --check +git commit -m "feat: scope local auth handoff to sessions" +``` + +--- + +### Task 7: Create, Persist, List, and Close Hosted Sessions + +**Repository:** `/Users/beubax/Desktop/AgentR/webcmd-cloud` + +**Files:** + +- Create: `src/sessions/service.ts` +- Create: `tests/sessions-service.test.ts` +- Modify: `src/domain/types.ts` +- Modify: `src/storage/schema.sql` +- Modify: `src/storage/repository.ts` +- Modify: `src/storage/postgres-repository.ts` +- Modify: `src/http/router.ts` +- Test: `tests/schema.test.ts` +- Test: `tests/repository.test.ts` +- Test: `tests/postgres-repository.integration.test.ts` +- Create: `tests/http-sessions.test.ts` + +**Interfaces:** + +- Produces `WebcmdSession`, `HostedSessionService.create`, `require`, `resolveAdapterDefault`, `list`, and `close`. +- Extends `CloudRepository` with `createSession`, `getSession`, `getOrCreateAdapterDefaultSession`, `listSessions`, `touchSession`, `setSessionHandoff`, and `clearSessionHandoff`. +- Produces `POST /v1/sessions`, `GET /v1/sessions`, and `POST /v1/sessions/:id/close` with exact tenant/Profile scoping. + +- [ ] **Step 1: Add schema, repository, and resolver tests** + +Use this record shape in tests: + +```ts +const session = { + id: 'session_11111111-1111-4111-8111-111111111111', + userId: tenant.userId, + workspaceId: tenant.workspaceId, + profileId: profile.id, + kind: 'explicit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', +}; +``` + +Assert two concurrent explicit creates return different collision-free IDs; concurrent `getOrCreateAdapterDefaultSession` calls return one `adapter-default` row per Profile; unknown/cross-Profile IDs throw `SESSION_NOT_FOUND`; listing does not create a row; close is idempotent and handoff set/clear is owner-scoped. + +For HTTP: + +```ts +const response = await fetch(`${baseUrl}/v1/sessions?profile=work`, { + headers: { authorization: `Bearer ${demoKey}` }, +}); +expect(await response.json()).toEqual({ + ok: true, + sessions: [expect.objectContaining({ + id: session.id, + kind: 'explicit', + runtimeState: 'idle', + handoff: null, + })], +}); +``` + +- [ ] **Step 2: Run tests and confirm no hosted Session entity exists** + +Run: + +```bash +npx vitest run tests/schema.test.ts tests/repository.test.ts tests/sessions-service.test.ts tests/http-sessions.test.ts tests/postgres-repository.integration.test.ts +``` + +Expected: FAIL because `webcmd_sessions` and its repository/service/API are absent; PostgreSQL integration skips when `TEST_DATABASE_URL` is unset. + +- [ ] **Step 3: Add the table and idempotent migration `0010_profile_sessions`** + +Add the base table before browser allocations: + +```sql +create table if not exists webcmd_sessions ( + id text primary key, + user_id text not null, + workspace_id text not null, + profile_id text not null, + kind text not null check (kind in ('explicit', 'adapter-default')), + handoff_site text, + handoff_expires_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + last_used_at timestamptz not null default now(), + unique (user_id, workspace_id, profile_id, id), + constraint webcmd_sessions_profile_fkey + foreign key (user_id, workspace_id, profile_id) + references webcmd_profiles(user_id, workspace_id, id) on delete cascade +); + +create unique index if not exists webcmd_sessions_one_adapter_default + on webcmd_sessions(user_id, workspace_id, profile_id) + where kind = 'adapter-default'; +``` + +Add a guarded `0010_profile_sessions` block for existing databases that creates the same table/index and records the migration. Do not delete or rewrite current `browser_allocations` rows; they expire through the existing reaper and new code addresses only rows whose `session_key` equals a resolved immutable Session ID. + +- [ ] **Step 4: Implement atomic repository semantics** + +Define: + +```ts +export interface CreateSessionInput { + tenant: TenantContext; + profileId: string; + id: string; + kind: 'explicit' | 'adapter-default'; +} +``` + +Explicit create uses a plain insert and retries only a generated-ID primary-key collision. Adapter-default resolution runs `insert ... on conflict do nothing returning *`; if no row returns, it selects the existing `kind = 'adapter-default'` row by the full tenant/Profile tuple in the same READ COMMITTED transaction. The partial unique index serializes concurrent inserts. The Profile foreign key validates tenancy. The in-memory repository mirrors both semantics and returns clones. + +Handoff updates must require the full tenant/Profile/Session tuple and set both `handoff_site` and `handoff_expires_at`; `clearSessionHandoff` must use the same tuple and may optionally guard the expected site. + +- [ ] **Step 5: Implement one lifecycle service** + +Use the Profile service before Session lookup. `HostedSessionService` accepts an `idFactory` defaulting to `session_${randomUUID()}` and implements these distinct paths: + +```ts +async create(tenant: TenantContext, profile: WebcmdProfile): Promise { + return this.repository.createSession({ + tenant, profileId: profile.id, id: this.idFactory(), kind: 'explicit', + }); +} + +async require(tenant: TenantContext, profile: WebcmdProfile, id: string): Promise { + requireSessionIdShape(id); + const found = await this.repository.getSession(tenant, profile.id, id); + if (!found) throw new PublicHttpError(404, 'SESSION_NOT_FOUND', + `Session ${id} was not found in Profile ${profile.displayName}.`, undefined, 66); + return found; +} + +async resolveAdapterDefault(tenant: TenantContext, profile: WebcmdProfile): Promise { + return this.repository.getOrCreateAdapterDefaultSession({ + tenant, profileId: profile.id, id: this.idFactory(), kind: 'adapter-default', + }); +} +``` + +Clear expired handoff metadata when requiring/listing. `close` rejects busy/handoff ownership, closes the exact allocation through Task 8, clears exact live views, and leaves the durable row intact; list derives `idle` from the absence of an allocation. + +- [ ] **Step 6: Add the list endpoint and verify** + +`POST /v1/sessions` accepts Profile selection only and rejects a caller-supplied Session ID/name. `GET /v1/sessions` resolves the Profile, lists rows, and joins active allocations by `session_key === session.id`. `POST /v1/sessions/:id/close` is idempotent. Return `runtimeState: 'active'|'idle'` and `handoff: null|{site,expiresAt}`; never expose Browser Use IDs, CDP URLs, or viewer tokens. + +Run: + +```bash +npx vitest run tests/schema.test.ts tests/repository.test.ts tests/sessions-service.test.ts tests/http-sessions.test.ts tests/postgres-repository.integration.test.ts +npm run typecheck +``` + +Expected: PASS; explicit creation never collides, adapter-default creation is singleton, close is idempotent, and immutable IDs remain tenant/Profile scoped. + +Commit: + +```bash +git add src/sessions/service.ts tests/sessions-service.test.ts src/domain/types.ts src/storage/schema.sql src/storage/repository.ts src/storage/postgres-repository.ts src/http/router.ts tests/schema.test.ts tests/repository.test.ts tests/postgres-repository.integration.test.ts tests/http-sessions.test.ts +git diff --cached --check +git commit -m "feat: persist hosted browser sessions" +``` + +--- + +### Task 8: Re-key Hosted Admission and Browser Use Allocations by Session + +**Repository:** `/Users/beubax/Desktop/AgentR/webcmd-cloud` + +**Files:** + +- Modify: `src/storage/schema.sql` +- Modify: `src/storage/repository.ts` +- Modify: `src/storage/postgres-repository.ts` +- Modify: `src/executor/session-write-lease.ts` +- Modify: `src/browser/allocation-manager.ts` +- Modify: `src/browser/dependencies.ts` +- Modify: `src/browser/runtime.ts` +- Test: `tests/schema.test.ts` +- Test: `tests/session-write-lease.test.ts` +- Test: `tests/postgres-session-write-leases.integration.test.ts` +- Test: `tests/browser-allocations.test.ts` +- Test: `tests/browser-allocation-manager.test.ts` +- Test: `tests/browser-runtime.test.ts` +- Test: `tests/browser-dependencies.test.ts` + +**Interfaces:** + +- Consumes immutable hosted Session IDs from Task 7. +- Produces `PersistentSessionWriteLeaseKey { tenant, profileId, sessionId }` and `acquireSessionBrowserLease`. +- Produces one allocation row per Session by storing `sessionId` in existing `browser_allocations.session_key`; removes `PROFILE_SESSION_KEY` and the in-memory one-allocation-per-Profile guard. +- Produces exact Session allocation close and structured `SESSION_CAPACITY_EXCEEDED` translation. + +- [ ] **Step 1: Add Session-partitioned lease and allocation tests** + +Assert same-Session conflict and sibling success: + +```ts +const first = await acquireSessionBrowserLease(repository, { + key: { tenant, profileId: profile.id, sessionId: 'session_a' }, + ownerExecutionId: 'exec_a', command: 'github/issues', onOwnershipLost, +}); +await expect(acquireSessionBrowserLease(repository, { + key: { tenant, profileId: profile.id, sessionId: 'session_a' }, + ownerExecutionId: 'exec_b', command: 'browser/run', onOwnershipLost, +})).rejects.toMatchObject({ code: 'SESSION_BUSY' }); +await expect(acquireSessionBrowserLease(repository, { + key: { tenant, profileId: profile.id, sessionId: 'session_b' }, + ownerExecutionId: 'exec_b', command: 'browser/run', onOwnershipLost, +})).resolves.toBeDefined(); +await first.release(); +``` + +For allocations, concurrently acquire persistent `session_a` and `session_b` under one Profile. Assert `openSession` is called twice, both rows persist, their `browserSessionId` and live URLs differ, reacquiring `session_a` reconnects only its row, and closing `session_a` leaves `session_b` active. + +Configure a two-allocation fake quota and request a third Session. Assert the public error is: + +```ts +expect(error).toMatchObject({ + code: 'SESSION_CAPACITY_EXCEEDED', + status: 429, + details: { active: 2, limit: 2, retryAfterSessionClose: true }, +}); +``` + +The hint must include `session list` and `session close ` and may mention upgrade; it must not expose Browser Use response bodies or allocation IDs. + +- [ ] **Step 2: Run focused tests and observe Profile collapse** + +Run: + +```bash +npx vitest run tests/schema.test.ts tests/session-write-lease.test.ts tests/postgres-session-write-leases.integration.test.ts tests/browser-allocations.test.ts tests/browser-allocation-manager.test.ts tests/browser-runtime.test.ts tests/browser-dependencies.test.ts +``` + +Expected: FAIL because the lease primary key omits Session, `PROFILE_SESSION_KEY` collapses persistent allocations, and the in-memory repository rejects a second allocation in one Profile. + +- [ ] **Step 3: Extend the existing lease key and migration** + +Change the contract: + +```ts +export interface PersistentSessionWriteLeaseKey { + tenant: TenantContext; + profileId: string; + sessionId: string; +} +``` + +Add `session_id text not null default 'legacy-profile'` to the base `persistent_session_write_leases` definition. Add a separate idempotent migration `0011_session_browser_leases` that adds the column for existing databases and replaces the primary key with `(user_id, workspace_id, profile_id, session_id)`. Include Session ID in the advisory-lock tuple and every acquire/heartbeat/release/get predicate. Retain the default only for schema compatibility during the drained rollout required by Global Constraints; it does not make mixed old/new browser workers safe. + +Rename only TypeScript symbols and public copy to `Session`; keep the physical table name to avoid a second migration. Conflict response is HTTP 409, code `SESSION_BUSY`, exit 75, safe Session ID/kind plus holder command/timestamps, never `ownerExecutionId`. + +- [ ] **Step 4: Stop holding admission for the allocation lifetime** + +`BrowserAllocationManager` must not acquire or retain the write lease. Admission belongs to the top-level executor/controller in Task 9 and is released at command outcome. Delete `lease` from `Entry`, `#releaseLeftoverLease`, and all Profile-lease cleanup branches. Keep the existing in-process per-key lifecycle lock and Cloud Run's current single-instance allocation ownership assumption; do not add a second distributed ownership service. + +- [ ] **Step 5: Remove allocation collapse and duplicate-profile cleanup** + +Delete `PROFILE_SESSION_KEY`. In `acquire`, use `input.sessionId` as `sessionKey` for every persistent Session allocation. `#reconnectDurable` queries only `getBrowserAllocation(tenant, profileId, sessionId)` and never scans/discards sibling rows. Remove this in-memory guard: + +```ts +browserAllocations.some(allocation => + allocation.userId === input.userId + && allocation.workspaceId === input.workspaceId + && allocation.profileId === input.profileId + && allocation.sessionKey !== input.sessionKey) +``` + +Keep uniqueness of `browserSessionId` and tuple uniqueness. Pass `sessionId` through `RemoteBrowserRuntime.openSession` unchanged. + +Add `closeSession(tenant, profileId, sessionId)` under the existing per-key lifecycle lock. It stops and deletes only that durable allocation row and revokes only its views. Translate a provider concurrency/quota response into `SESSION_CAPACITY_EXCEEDED`, deriving safe active/limit counts from Webcmd state/config when available. Do not retry invisibly or collapse the error into `BROWSER_UNAVAILABLE`. + +- [ ] **Step 6: Verify and commit** + +Run: + +```bash +npx vitest run tests/schema.test.ts tests/session-write-lease.test.ts tests/postgres-session-write-leases.integration.test.ts tests/browser-allocations.test.ts tests/browser-allocation-manager.test.ts tests/browser-runtime.test.ts tests/browser-dependencies.test.ts +npm run typecheck +``` + +Expected: PASS; one Session conflicts only with itself and two Browser Use allocations coexist under one Profile. + +Commit: + +```bash +git add src/storage/schema.sql src/storage/repository.ts src/storage/postgres-repository.ts src/executor/session-write-lease.ts src/browser/allocation-manager.ts src/browser/dependencies.ts src/browser/runtime.ts tests/schema.test.ts tests/session-write-lease.test.ts tests/postgres-session-write-leases.integration.test.ts tests/browser-allocations.test.ts tests/browser-allocation-manager.test.ts tests/browser-runtime.test.ts tests/browser-dependencies.test.ts +git diff --cached --check +git commit -m "feat: key hosted browsers by session" +``` + +--- + +### Task 9: Route Hosted Adapters and Raw Browser Commands Through One Session + +**Repository:** `/Users/beubax/Desktop/AgentR/webcmd-cloud` + +**Files:** + +- Modify: `src/http/router.ts` +- Modify: `src/http/execution-artifacts.ts` +- Modify: `src/executor/non-browser.ts` +- Modify: `src/executor/browser-session-policy.ts` +- Modify: `src/browser/hosted-browser.ts` +- Modify: `src/browser/session-lock.ts` +- Modify: `src/browser/dependencies.ts` +- Test: `tests/http-execute.test.ts` +- Test: `tests/http-execution-artifacts.test.ts` +- Test: `tests/http-browser.test.ts` +- Test: `tests/executor-browser-adapter.test.ts` +- Test: `tests/browser-session-policy.test.ts` +- Test: `tests/hosted-browser.test.ts` + +**Interfaces:** + +- Consumes optional `session` from `/v1/execute` and required raw-browser path IDs, then uses the correct explicit/default/no-Session route. +- Acquires the Task 8 admission lease after the trusted execution row exists and before auth, allocation, CDP, pre-navigation, worker, or adapter work. +- Keys persistent adapter tabs by `(profileId, sessionId, site)` and uses the same Session allocation for raw actions. + +- [ ] **Step 1: Add trusted-boundary, no-queue, and cross-surface tests** + +Send an execute body containing a fake `executionId` and assert the server ignores/rejects it while minting its own execution. Hold `session_a`, then submit raw browser and adapter commands to it and assert immediate 409 `SESSION_BUSY` with zero Browser Use/worker calls. Submit the same commands to `session_b` and assert they run before `session_a` releases. + +Also assert: + +- a non-browser adapter with no selector creates no Session, lease, or allocation; +- a non-browser adapter with an explicit ID validates it but still creates no allocation; +- a browser-backed adapter with no selector resolves the singleton adapter-default; +- persistent and ephemeral browser-backed adapters both use that resolved Session; +- raw browser omission never reaches this server path because the CLI rejects it, and a malformed/unknown route ID fails without creation. + +Use wall-clock barriers instead of arbitrary sleeps: + +```ts +await holderStarted.promise; +const sibling = execute({ profile: 'work', session: 'session_b' }); +await expect(Promise.race([sibling, timeoutAfter(250)])).resolves.toBeDefined(); +expect(browserUse.createBrowser).toHaveBeenCalledTimes(2); +holderRelease.resolve(); +``` + +Assert adapter `siteSession: 'persistent'` reuses a site tab only within the same immutable Session, while an ephemeral adapter tab closes without stopping the Session allocation. + +- [ ] **Step 2: Run tests and confirm the raw queue/profile adapter split** + +Run: + +```bash +npx vitest run tests/http-execute.test.ts tests/http-execution-artifacts.test.ts tests/http-browser.test.ts tests/executor-browser-adapter.test.ts tests/browser-session-policy.test.ts tests/hosted-browser.test.ts +``` + +Expected: FAIL because adapter requests have no Session, raw commands use `SessionLockManager.runExclusive` to wait, and adapter manager keys entries by Profile. + +- [ ] **Step 3: Parse and resolve selectors at each trusted boundary** + +Add `session?: string` validation to `readExecuteRequest`; reject non-string values. Load command metadata before Session allocation. Resolve Profile, then: + +```ts +const session = command.browser + ? request.session + ? await sessions.require(tenant, profile, request.session) + : await sessions.resolveAdapterDefault(tenant, profile) + : request.session + ? await sessions.require(tenant, profile, request.session) + : undefined; +``` + +Only browser-backed work acquires admission/allocation. For prepared executions, persist no caller ownership token: reuse the existing queued execution ID only after `startQueuedExecution` succeeds. + +For raw browser paths, require the encoded segment to be an opaque ID, resolve it with `require`, and pass `session.id` plus `session.kind` to `HostedBrowserController`. Never lazily create from the route. + +- [ ] **Step 4: Acquire immediate admission once per top-level request** + +Wrap both executor and raw controller work: + +```ts +const admission = await acquireSessionBrowserLease(repository, { + key: { tenant, profileId: profile.id, sessionId: session.id }, + ownerExecutionId: execution.id, + command: canonicalCommand, + onOwnershipLost: error => executionDeadline.abort(error), +}); +try { + return await runBrowserBackedWork(); +} finally { + await admission.release(); +} +``` + +Acquire after execution creation but before auth/allocation and release only after cleanup reaches a known outcome. Keep unknown-outcome TTL/heartbeat behavior. Delete `SessionLockManager.runExclusive` from the public raw path; either remove the file if unused or reduce it to a non-waiting `tryAcquire` used only for defensive same-execution internals. + +- [ ] **Step 5: Key higher-level managers by immutable Session** + +Add `session: WebcmdSession` to `OpenBrowserSessionInput` and `HostedAdapterBrowserSessionManager.acquire`. Change its metadata-map key to `JSON.stringify([userId, workspaceId, profileId, session.id])`. Keep only `siteTabs: Map` and recovered-tab metadata there; do not cache a separate `BrowserSession` wrapper. Each top-level command acquires the current managed allocation handle and releases it after use. `browserUseHostedRuntime` calls allocation acquire with `sessionId: input.session.id`, not `executionId`. + +Change `HostedBrowserSessionManager` to the same ID key and likewise cache only selected-tab/display metadata, never a browser wrapper. Raw browser and adapter managers may keep their existing action/tab policies, but both reacquire the current handle from `BrowserAllocationManager`, verify its `browserSessionId` generation before reusing remembered tab IDs, and never create a second allocation for one Session. This prevents one manager from retaining a stale wrapper after the other invalidates the allocation. + +- [ ] **Step 6: Verify and commit** + +Run: + +```bash +npx vitest run tests/http-execute.test.ts tests/http-execution-artifacts.test.ts tests/http-browser.test.ts tests/executor-browser-adapter.test.ts tests/browser-session-policy.test.ts tests/hosted-browser.test.ts +npm run typecheck +``` + +Expected: PASS; Session admission is immediate, trusted, cross-surface, parallel across siblings, and allocated only for commands that actually need browser state. + +Commit: + +```bash +git add src/http/router.ts src/http/execution-artifacts.ts src/executor/non-browser.ts src/executor/browser-session-policy.ts src/browser/hosted-browser.ts src/browser/session-lock.ts src/browser/dependencies.ts tests/http-execute.test.ts tests/http-execution-artifacts.test.ts tests/http-browser.test.ts tests/executor-browser-adapter.test.ts tests/browser-session-policy.test.ts tests/hosted-browser.test.ts +git diff --cached --check +git commit -m "feat: route hosted commands through sessions" +``` + +--- + +### Task 10: Scope Hosted Handoff and Live Views to One Session + +**Repository:** `/Users/beubax/Desktop/AgentR/webcmd-cloud` + +**Files:** + +- Modify: `src/auth/hosted-auth.ts` +- Modify: `src/executor/browser-session-policy.ts` +- Modify: `src/executor/non-browser.ts` +- Modify: `src/browser/hosted-browser.ts` +- Modify: `src/browser/allocation-manager.ts` +- Modify: `src/account/browser-live-view.ts` +- Modify: `src/account/live-view.ts` +- Modify: `src/server.ts` +- Test: `tests/hosted-auth.test.ts` +- Test: `tests/browser-session-policy.test.ts` +- Test: `tests/browser-live-view.test.ts` +- Test: `tests/account-live-view.test.ts` +- Test: `tests/http-execute.test.ts` +- Test: `tests/http-browser.test.ts` + +**Interfaces:** + +- Consumes persisted `WebcmdSession.handoff*` and one allocation per Session. +- Produces verify commands containing Profile plus immutable Session ID. +- Produces `SESSION_PAUSED_FOR_HUMAN_HANDOFF` only for normal commands in the selected Session and revokes only that Session's view/allocation. + +- [ ] **Step 1: Add sibling-handoff and exact-revocation tests** + +Start login in `session_a`, capture its view, and assert: + +```ts +expect(firstRow(login.result)).toMatchObject({ + status: 'action_required', + verify_command: "webcmd --profile 'work' --session session_a github whoami", + view_url: expect.stringContaining('/account/live/'), +}); +await expect(execute({ session: 'session_a', command: 'linkedin/search' })) + .rejects.toMatchObject({ code: 'SESSION_PAUSED_FOR_HUMAN_HANDOFF' }); +await expect(execute({ session: 'session_b', command: 'linkedin/search' })).resolves.toBeDefined(); +``` + +Assert `session_b`'s allocation, viewer token, live view, and command usability survive all of: `session_a` successful verification, handoff expiry, explicit allocation invalidation, and server restart/recovery from the stored allocation. + +- [ ] **Step 2: Run tests and observe Profile-wide handoff lookup/revocation** + +Run: + +```bash +npx vitest run tests/hosted-auth.test.ts tests/browser-session-policy.test.ts tests/browser-live-view.test.ts tests/account-live-view.test.ts tests/http-execute.test.ts tests/http-browser.test.ts +``` + +Expected: FAIL because handoff maps/searches and cleanup are Profile/site scoped, `humanControlledSites` belongs to the Profile entry, and some live-view cleanup calls omit the Browser allocation identity. + +- [ ] **Step 3: Make persisted Session state authoritative** + +Add `session: WebcmdSession` to `HostedAuthCommandInput`. Key in-memory handoff timing by `[tenant, profile.id, session.id, site]`. Delete `activeProfileHandoff`; replace `activeStoredProfileHandoff` with an exact lookup of `getBrowserAllocation(tenant, profile.id, session.id)`. + +On `action_required`, call: + +```ts +await repository.setSessionHandoff({ + tenant: input.tenant, + profileId: input.profile.id, + sessionId: input.session.id, + site: login.site, + expiresAt: capability.expiresAt, +}); +``` + +Successful `whoami` clears exactly that Session. Expiry clears the row, invalidates only its allocation, and revokes only its views. Remove `humanControlledSites`; subsequent requests learn pause state from the resolved Session record. + +- [ ] **Step 4: Enforce pause across adapter and raw browser surfaces** + +Before admission, reject a live handoff unless the server itself classified the command as the same `${site}/whoami` verification. Raw browser commands have no verification classification and therefore receive `SESSION_PAUSED_FOR_HUMAN_HANDOFF`. Do not accept a client-supplied `allowHumanControlled` or execution owner. + +Use code `SESSION_PAUSED_FOR_HUMAN_HANDOFF`, HTTP 409, and exit 77. Return Session ID/kind and expiry but never the sibling view URL or Browser Use identifiers. + +- [ ] **Step 5: Generate the scoped verify command** + +Use the immutable ID and POSIX-safe Profile quoting: + +```ts +function quoteCliArg(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + +function verifyCommand(profile: WebcmdProfile, session: WebcmdSession, site: string): string { + return `webcmd --profile ${quoteCliArg(profile.displayName)} --session ${session.id} ${site} whoami`; +} +``` + +The existing public workflow stays `login -> human -> returned whoami`; add no takeover/complete route. + +- [ ] **Step 6: Revoke live views by exact allocation** + +Change `BrowserAllocationLiveViewScope` to carry `browserSessionId` (and optionally `targetUrl`) in addition to tenant/Profile/Session. Every close/reap/stale-row path knows the allocation row and must pass its `browserSessionId`. `LiveViewStore.revokeLiveViews` and `clearBrowserAllocationViewer` must include that ID whenever one allocation closes; only Profile deletion/workspace deletion may omit it and revoke all. + +- [ ] **Step 7: Verify and commit** + +Run: + +```bash +npx vitest run tests/hosted-auth.test.ts tests/browser-session-policy.test.ts tests/browser-live-view.test.ts tests/account-live-view.test.ts tests/http-execute.test.ts tests/http-browser.test.ts +npm run typecheck +``` + +Expected: PASS; handoff and cleanup affect one Session and sibling allocations continue. + +Commit: + +```bash +git add src/auth/hosted-auth.ts src/executor/browser-session-policy.ts src/executor/non-browser.ts src/browser/hosted-browser.ts src/browser/allocation-manager.ts src/account/browser-live-view.ts src/account/live-view.ts src/server.ts tests/hosted-auth.test.ts tests/browser-session-policy.test.ts tests/browser-live-view.test.ts tests/account-live-view.test.ts tests/http-execute.test.ts tests/http-browser.test.ts +git diff --cached --check +git commit -m "feat: scope hosted handoff to sessions" +``` + +--- + +### Task 11: Advertise Hosted Session Protocol Capability + +**Repository:** `/Users/beubax/Desktop/AgentR/webcmd-cloud` + +**Files:** + +- Modify: `src/domain/types.ts` +- Modify: `src/adapter-packages/manifest-builder.ts` +- Modify: `tests/http-manifest.test.ts` +- Modify: `tests/default-adapters.test.ts` +- Modify: `tests/live-gate-matrix.test.ts` + +**Interfaces:** + +- Produces `HostedManifestMetadata.sessionProtocolVersion: 1`. +- Does not bump the unrelated hosted adapter contract schema or release-evidence schema. +- Task 12's CLI refuses hosted browser work unless this exact capability is present. + +- [ ] **Step 1: Add the manifest capability assertion** + +```ts +expect(body.manifest.metadata).toEqual({ + contractSchemaVersion: 1, + sessionProtocolVersion: 1, + webcmdPackageVersion: loadDefaultAdapterSource().packageVersion, + generatedAt: expect.any(String), +}); +``` + +Also assert the release matrix reads the same value from the public manifest. + +- [ ] **Step 2: Run focused tests and verify the field is absent** + +Run: + +```bash +npx vitest run tests/http-manifest.test.ts tests/default-adapters.test.ts tests/live-gate-matrix.test.ts +``` + +Expected: FAIL because metadata has no Session protocol capability. + +- [ ] **Step 3: Add the literal capability and verify** + +Extend `HostedManifestMetadata` with `sessionProtocolVersion: 1` and emit the literal from `manifest-builder.ts`. Do not derive it from package version and do not add negotiation branches. + +Run: + +```bash +npx vitest run tests/http-manifest.test.ts tests/default-adapters.test.ts tests/live-gate-matrix.test.ts +npm run typecheck +``` + +Expected: PASS. + +Commit: + +```bash +git add src/domain/types.ts src/adapter-packages/manifest-builder.ts tests/http-manifest.test.ts tests/default-adapters.test.ts tests/live-gate-matrix.test.ts +git diff --cached --check +git commit -m "feat: advertise hosted session protocol" +``` + +--- + +### Task 12: Require the Capability and Finish Hosted CLI Session UX + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Modify: `src/hosted/types.ts` +- Modify: `src/hosted/client.ts` +- Modify: `src/hosted/runner.ts` +- Modify: `src/hosted/manifest.ts` +- Modify: `src/completion-shared.ts` +- Modify: `src/hosted/contract.ts` +- Modify: `src/build-manifest.ts` +- Test: `src/hosted/client.test.ts` +- Test: `src/hosted/runner.test.ts` +- Test: `src/hosted/manifest.test.ts` +- Test: `src/hosted/contract.test.ts` +- Test: `src/build-manifest.test.ts` +- Test: `src/check-hosted-contract.test.ts` + +**Interfaces:** + +- Consumes `sessionProtocolVersion: 1` from Task 11. +- Produces `HostedClient.createSession`, `listSessions`, and `closeSession`, plus hosted lifecycle rendering. +- Fails incompatible CLI/server pairs with `HOSTED_CONTRACT_MISMATCH` before `/v1/execute` or `/v1/browser/...` is called. + +- [ ] **Step 1: Add fail-fast and list tests** + +For an old manifest, assert zero browser/execute calls: + +```ts +await expect(runHostedCli(['--session', 'session_a', 'github', 'issues'], oldServerOptions)) + .resolves.toMatchObject({ exitCode: 78 }); +expect(requests.map(request => request.pathname)).toEqual(['/v1/manifest']); +expect(stderr).toContain('HOSTED_CONTRACT_MISMATCH'); +``` + +For a compatible server: + +```ts +await runHostedCli(['--profile', 'work', 'session', 'list', '-f', 'json'], options); +expect(requests.at(-1)?.url).toContain('/v1/sessions?profile=work'); +expect(JSON.parse(stdout)).toEqual([ + expect.objectContaining({ id: 'session_a', kind: 'explicit' }), +]); +``` + +Assert `session create` sends no name/ID, `session close session_a` calls the exact close route and treats already-idle as exit 0, completion includes all three lifecycle commands, and contains no `browser ` template. + +- [ ] **Step 2: Run focused tests and verify old metadata is accepted/list is unknown** + +Run: + +```bash +npx vitest run --project unit src/hosted/client.test.ts src/hosted/runner.test.ts src/hosted/manifest.test.ts src/hosted/contract.test.ts src/build-manifest.test.ts src/check-hosted-contract.test.ts +``` + +Expected: FAIL because the client validator has no capability field/list response and completion still advertises positional browser syntax. + +- [ ] **Step 3: Validate capability once before hosted dispatch** + +Extend the hosted manifest metadata type and validator. Immediately after `getManifest()` and before adapter or browser dispatch: + +```ts +if (manifest.metadata.sessionProtocolVersion !== 1) { + throw new ConfigError( + 'HOSTED_CONTRACT_MISMATCH: this Webcmd Cloud server does not support Session protocol v1.', + 'Upgrade Webcmd Cloud or use a compatible webcmd CLI.', + ); +} +``` + +Reuse the fetched manifest within that dispatch; do not add a second capability endpoint. + +- [ ] **Step 4: Add hosted Session list and canonical generated surfaces** + +Add response validators plus `createSession(profile?)`, `listSessions(profile?)`, and `closeSession(profile?, sessionId)`, then route the lifecycle commands through the existing renderer. Replace completion/help templates with: + +```ts +`${CLI_COMMAND} --session [--profile ] browser [args] [options]` +``` + +Browser help must show `session create` and `session list`. Adapter help describes `--session` as optional isolation and must not imply a raw-browser default. + +Keep hosted adapter contract `schemaVersion: 1`; this feature changes CLI selectors and manifest metadata, not adapter argument schemas. Regenerate `cli-manifest.json`, `hosted-contract.json`, and plugin command manifests with existing build scripts. + +- [ ] **Step 5: Verify and commit** + +Run: + +```bash +npx vitest run --project unit src/hosted/client.test.ts src/hosted/runner.test.ts src/hosted/manifest.test.ts src/hosted/contract.test.ts src/build-manifest.test.ts src/check-hosted-contract.test.ts +npm run build +npm run check:hosted-contract +``` + +Expected: PASS; old servers fail before browser work, list is available, and generated surfaces use root syntax. + +Commit: + +```bash +git add src/hosted/types.ts src/hosted/client.ts src/hosted/runner.ts src/hosted/manifest.ts src/completion-shared.ts src/hosted/contract.ts src/build-manifest.ts src/hosted/client.test.ts src/hosted/runner.test.ts src/hosted/manifest.test.ts src/hosted/contract.test.ts src/build-manifest.test.ts src/check-hosted-contract.test.ts cli-manifest.json hosted-contract.json plugin-command-manifest.json +git diff --cached --check +git commit -m "feat: require hosted session protocol" +``` + +--- + +### Task 13: Gate the Pinned Cloak Runtime for Issues #225, #242, and #276 + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Create: `tests/e2e/cloak-session-concurrency.test.ts` +- Modify: `package.json` +- Modify: `src/browser/runtime/local-cloak/session-manager.test.ts` +- Modify: `src/browser/runtime/local-cloak/process-matcher.test.ts` + +**Interfaces:** + +- Produces runnable `npm run gate:cloak-sessions` against the exact installed Cloak/Chromium pair. +- Blocks release on concurrency, focus, anchor, lifecycle, or teardown failure; it does not select a degraded mode. + +- [ ] **Step 1: Add a live test gated only by explicit release intent** + +Create the suite with: + +```ts +const live = process.env.WEBCMD_LIVE_CLOAK === '1'; +describe.skipIf(!live)('pinned Cloak Profile Sessions', () => { + it('runs two Profiles and two Sessions in one Profile concurrently', async () => { + const baseDir = mkdtempSync(join(tmpdir(), 'webcmd-cloak-session-gate-')); + const manager = new CloakSessionManager({ baseDir }); + try { + const [profileA, profileB] = await Promise.all([ + manager.getPage({ profileId: 'profile-a', sessionId: 'session_a' }), + manager.getPage({ profileId: 'profile-b', sessionId: 'session_b' }), + ]); + await Promise.all([ + profileA.page.goto('data:text/html,A'), + profileB.page.goto('data:text/html,B'), + ]); + expect(await profileA.page.title()).toBe('A'); + expect(await profileB.page.title()).toBe('B'); + } finally { + await manager.shutdown(); + rmSync(baseDir, { recursive: true, force: true }); + } + }); +}); +``` + +The body must use barriers and real navigations to two locally served pages, not timing guesses. It must assert all of these in one cleanup-safe suite: + +1. `resolveCloakBrowserVersion()` is exactly `0.4.5`, and the runtime-reported Chromium version is `145.0.7632.109.2`. +2. Two Profile contexts with distinct temp user-data directories launch through `Promise.all` and both navigate. +3. Two Sessions in one Profile receive distinct window IDs and navigate simultaneously. +4. Same-Session extra tabs and popups retain ownership; background creation does not change `document.hasFocus()` in the foreground human window. +5. Closing Session A leaves Session B operational; after the last task page closes, the supported host's hidden/parking keeper keeps the Profile reusable. +6. A new Session opens during the warm period; another opens during forced idle close and receives one clean replacement runtime; shutdown during an in-flight launch leaks no process. +7. `work` teardown does not match `work-2` or an unrelated Chrome process fixture. + +Always close contexts and delete only suite-created temp directories in `afterEach`/`finally`. + +- [ ] **Step 2: Add the package command and static pin assertion** + +Add: + +```json +"gate:cloak-sessions": "WEBCMD_LIVE_CLOAK=1 vitest run --project e2e tests/e2e/cloak-session-concurrency.test.ts" +``` + +Keep dependencies unchanged. Add a unit assertion that `package.json` and lockfile both contain exact `cloakbrowser: 0.4.5` and `playwright-core: 1.61.1`, not ranges. + +- [ ] **Step 3: Run unit coverage, then the real gate** + +Run: + +```bash +npx vitest run --project unit src/browser/runtime/local-cloak/session-manager.test.ts src/browser/runtime/local-cloak/process-matcher.test.ts +npm run gate:cloak-sessions +``` + +Expected: unit tests PASS; on every supported release OS, its live suite PASSes the applicable keeper and window invariants. At minimum, macOS release evidence is required before the first implementation lands, and Windows/Linux support cannot be claimed until their live keeper gates pass. Any live failure blocks release and returns the pinned runtime/launcher to repair. + +- [ ] **Step 4: Commit** + +```bash +git add tests/e2e/cloak-session-concurrency.test.ts package.json src/browser/runtime/local-cloak/session-manager.test.ts src/browser/runtime/local-cloak/process-matcher.test.ts +git diff --cached --check +git commit -m "test: gate pinned cloak session concurrency" +``` + +--- + +### Task 14: Gate Browser Use Same-Profile Persistence and Sibling Handoff + +**Repository:** `/Users/beubax/Desktop/AgentR/webcmd-cloud` + +**Files:** + +- Modify: `src/live-gates/browser-use-spike.ts` +- Modify: `src/live-gates/browser-gates.ts` +- Modify: `src/live-gates/runner.ts` +- Modify: `src/live-gates/types.ts` +- Modify: `tests/browser-use-spike.test.ts` +- Modify: `tests/live-gate-matrix.test.ts` +- Modify: `tests/live-gate-runner.test.ts` + +**Interfaces:** + +- Extends the existing `npm run gate:browser-use-spike`; adds no new provider abstraction. +- Produces release evidence for both stop orders and Session-scoped handoff sibling usability. + +- [ ] **Step 1: Add deterministic fake-provider gate tests** + +Model two allocations from the same Browser Use Profile and assert the gate performs: + +```ts +await sessionA.page.context().addCookies([{ name: 'marker_a', value: 'A', domain: 'a.example', path: '/' }]); +await sessionA.page.goto('https://a.example'); +await sessionA.page.evaluate(() => localStorage.setItem('marker_a', 'A')); + +await sessionB.page.context().addCookies([{ name: 'marker_b', value: 'B', domain: 'b.example', path: '/' }]); +await sessionB.page.goto('https://b.example'); +await sessionB.page.evaluate(() => localStorage.setItem('marker_b', 'B')); +``` + +Run the scenario once stopping A then B and once B then A. A later allocation must read both domain cookies and both origins' local-storage markers. During each scenario, mark A as handoff-controlled and prove B can navigate/evaluate before A is verified/expired. + +- [ ] **Step 2: Run focused tests and confirm the gate lacks merge-order coverage** + +Run: + +```bash +npx vitest run tests/browser-use-spike.test.ts tests/live-gate-matrix.test.ts tests/live-gate-runner.test.ts +``` + +Expected: FAIL because the existing spike does not run two same-Profile allocations with both persistence orders and sibling handoff. + +- [ ] **Step 3: Extend the existing spike and evidence** + +Use unique HTTPS origins owned by the live-gate fixture, not public third-party sites. Record sanitized evidence containing Session aliases, stop order, marker booleans, and sibling success; exclude cookies' values, Browser Use IDs, CDP URLs, and live-view URLs. + +Register two required gate IDs: + +```ts +'browser-use.same-profile-session-merge-both-orders' +'browser-use.session-handoff-sibling-continues' +``` + +Do not add a cookie sync fallback. A failed provider merge is a release-blocking architecture review. + +- [ ] **Step 4: Run unit and live gates, then commit** + +Run: + +```bash +npx vitest run tests/browser-use-spike.test.ts tests/live-gate-matrix.test.ts tests/live-gate-runner.test.ts +npm run gate:browser-use-spike +``` + +Expected: unit tests PASS; with release credentials, both Browser Use persistence orders and sibling handoff gate PASS. + +Commit: + +```bash +git add src/live-gates/browser-use-spike.ts src/live-gates/browser-gates.ts src/live-gates/runner.ts src/live-gates/types.ts tests/browser-use-spike.test.ts tests/live-gate-matrix.test.ts tests/live-gate-runner.test.ts +git diff --cached --check +git commit -m "test: gate browser use session isolation" +``` + +--- + +### Task 15: Teach Users and Agents the Session Contract + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Modify: `README.md` +- Modify: `docs/authentication-and-profiles.mdx` +- Modify: `docs/browser-and-sitemap-memory.mdx` +- Modify: `docs/cli-reference.mdx` +- Modify: `docs/local-or-cloud.mdx` +- Modify: `docs/x-session-cli.mdx` +- Modify: `docs/agents/claude-code.md` +- Modify: `docs/agents/codex-cli.md` +- Modify: `docs/agents/cursor.md` +- Modify: `docs/agents/hermes.md` +- Modify: `docs/agents/openclaw.md` +- Modify: `docs/agents/opencode.md` +- Modify: `skills/webcmd-usage/SKILL.md` +- Modify: `skills/webcmd-browser/SKILL.md` +- Modify: `skills/webcmd-autofix/SKILL.md` +- Modify: `skills/webcmd-adapter-author/SKILL.md` +- Modify: `skills/webcmd-sitemap-author/SKILL.md` +- Modify: `skills/webcmd-browser-sitemap/SKILL.md` +- Modify: active Markdown references directly linked from those skills and containing `webcmd browser` examples +- Modify: `src/skills.test.ts` +- Modify: `src/docs-sync-review-cli.test.ts` + +**Interfaces:** + +- Documents the already-implemented behavior; introduces no runtime switches. +- Active raw-browser examples create and carry opaque IDs; adapter examples omit the selector unless demonstrating explicit isolation. + +- [ ] **Step 1: Add documentation/skill contract tests first** + +In `skills.test.ts`, require all six bundled skills to use root syntax and explain Session selection where relevant: + +```ts +expect(browserSkill).toContain('webcmd --profile work session create'); +expect(browserSkill).toContain('webcmd --session session_'); +expect(browserSkill).toContain('webcmd --profile work session list'); +expect(browserSkill).toContain('webcmd --profile work session close'); +expect(browserSkill).toMatch(/different agents[\s\S]*different Sessions/i); +expect(usageSkill).toMatch(/SESSION_BUSY[\s\S]*same Session/i); +expect(usageSkill).toMatch(/adapter[\s\S]*default Session/i); +expect(autofixSkill).toMatch(/verify_command[\s\S]*immutable Session ID/i); +``` + +Add an active-doc scan that excludes `docs/superpowers/**` and fails on positional templates or examples: + +```ts +expect(activeText).not.toMatch(/webcmd browser (?:|[a-z][\w-]*)\s+(?:run|tabs|bind|close|snapshot)\b/); +expect(activeText).not.toContain('browser --session'); +``` + +- [ ] **Step 2: Run the focused docs tests and observe stale examples** + +Run: + +```bash +npx vitest run --project unit src/skills.test.ts src/docs-sync-review-cli.test.ts +``` + +Expected: FAIL on positional syntax, unscoped verify guidance, and missing Profile/Session/tab explanations. + +- [ ] **Step 3: Update the user documentation with one consistent model** + +Every overview must use this concise distinction: + +```text +Profile = shared login state (cookies and browser storage). +Session = one agent task's browser workspace and command lock, selected by an opaque ID. +Tab = a page owned by that Session. +``` + +Document: + +- raw browser work runs `session create` once and carries its returned opaque ID; omission is `SESSION_REQUIRED`; +- `session list` lists stable IDs/state and attaching means passing an ID—there is no Session bind command; +- `session close` frees local windows or a hosted allocation, succeeds when already idle, and does not delete the record; +- non-browser adapters allocate no Session; browser-backed adapters use a system adapter default unless an explicit ID is supplied; +- `siteSession` ephemeral/persistent controls tab lifetime inside the selected/default Session, so ephemeral adapters do not mint one window per command; +- the adapter default is never chosen implicitly for raw browser work, but its listed ID may be passed deliberately and then shares admission/tabs with default-routed adapters; +- parallel agents use distinct created IDs; +- local Sessions are exclusive Cloak window groups inside one Profile context; hosted Sessions consume distinct Browser Use allocations; +- local Profiles remain warm for 60 seconds behind the platform keeper; only macOS promises a zero-visible-window hidden target; +- already-running hosted allocations do not receive live cookie injection; +- overlapping same-Session commands can receive `SESSION_BUSY`, including commands from the same agent/PID; sequential commands work; +- hosted quota exhaustion is `SESSION_CAPACITY_EXCEEDED` with close/wait/upgrade guidance; +- a handoff pauses only its Session and the returned immutable-ID `verify_command` is authoritative. + +Use canonical examples: + +```bash +webcmd --profile work github issues +webcmd --profile work session create +webcmd --profile work --session session_7d8f... browser run --stdin +webcmd --profile work session list +webcmd --profile work session close session_7d8f... +``` + +- [ ] **Step 4: Update all active skill examples and error playbooks** + +Replace positional browser calls in each selected skill and its directly referenced active example files. Teach `SESSION_REQUIRED`, `SESSION_NOT_FOUND`, `SESSION_BUSY`, `SESSION_PAUSED_FOR_HUMAN_HANDOFF`, `SESSION_WINDOW_CONFLICT`, and `SESSION_CAPACITY_EXCEEDED` as structured runtime states, not adapter breakage. Explain that cookie APIs are Profile-wide even though pages are Session-scoped. Keep the existing prohibition on agents entering passwords, OTPs, cookies, recovery codes, or CAPTCHAs. + +Do not edit historical `docs/superpowers/specs/**` or `docs/superpowers/plans/**` to rewrite history. + +- [ ] **Step 5: Verify scans and commit** + +Run: + +```bash +npx vitest run --project unit src/skills.test.ts src/docs-sync-review-cli.test.ts +rg -n "webcmd browser (|[a-z][[:alnum:]_-]*) (run|tabs|bind|close|snapshot)|browser --session" README.md docs skills -g '!docs/superpowers/**' +``` + +Expected: tests PASS; the final `rg` returns no matches. + +Commit: + +```bash +git add README.md docs/authentication-and-profiles.mdx docs/browser-and-sitemap-memory.mdx docs/cli-reference.mdx docs/local-or-cloud.mdx docs/x-session-cli.mdx docs/agents skills src/skills.test.ts src/docs-sync-review-cli.test.ts +git diff --cached --check +git commit -m "docs: teach profile sessions and concurrency" +``` + +--- + +### Task 16: Run Coordinated Verification and Package Parity + +**Repositories:** `/Users/beubax/Desktop/AgentR/OpenCLI` and `/Users/beubax/Desktop/AgentR/webcmd-cloud` + +**Files:** + +- Modify only if generated artifacts are stale: `cli-manifest.json`, `hosted-contract.json`, `plugin-command-manifest.json` +- Modify only if the packed version is intentionally advanced for release: `/Users/beubax/Desktop/AgentR/webcmd-cloud/package.json`, `/Users/beubax/Desktop/AgentR/webcmd-cloud/package-lock.json` +- Test: all local and cloud suites named below + +**Interfaces:** + +- Produces a packed CLI whose manifest advertises canonical Session UX and a cloud image requiring `sessionProtocolVersion: 1`. +- Produces final evidence that issues #225, #242, and #276 are covered by automated/live checks. + +- [ ] **Step 1: Verify the local repository from a clean build** + +Run: + +```bash +cd /Users/beubax/Desktop/AgentR/OpenCLI +npm run typecheck +npm test +npm run build +npm run check:hosted-contract +npm run check:codex-plugin +npm run gate:cloak-sessions +git status --short +``` + +Expected: every command exits 0; the live Cloak gate reports the exact pinned versions; only intentional generated changes appear. + +- [ ] **Step 2: Pack the CLI and run cloud compatibility/parity** + +Run: + +```bash +cd /Users/beubax/Desktop/AgentR/webcmd-cloud +npm run typecheck +npm test +npm run build +npm run test:parity:packed +git status --short +``` + +Expected: all cloud unit/integration-with-fakes and packed CLI differential tests PASS. If the cloud package pin must advance to the just-packed CLI version, use the existing `npm run bump:webcmd -- ` workflow, inspect its lockfile diff, rerun this step, and commit only that intentional pin. + +- [ ] **Step 3: Run PostgreSQL integration with the migrated schema** + +With the repository's normal test PostgreSQL URL: + +```bash +TEST_DATABASE_URL="$WEBCMD_TEST_DATABASE_URL" npx vitest run tests/postgres-migration.integration.test.ts tests/postgres-repository.integration.test.ts tests/postgres-session-write-leases.integration.test.ts +``` + +Expected: migrations `0010_profile_sessions` and `0011_session_browser_leases` are idempotent; concurrent adapter-default creation returns one row while explicit creates remain distinct; Session leases arbitrate across two pools. + +- [ ] **Step 4: Run hosted live release gates** + +Run: + +```bash +npm run gate:browser-ready +npm run gate:browser-use-spike +npm run gate:session-write-lease +npm run gate:workspace-profiles +``` + +Expected: all gates PASS, including two same-Profile Session allocations, both persistence stop orders, sibling handoff continuation, and same-Session immediate busy behavior. No release proceeds with a skipped/failed Cloak or Browser Use gate. + +- [ ] **Step 5: Perform the final issue/contract smoke test** + +On the release candidate, create two Sessions, capture their IDs from structured output, then run two shells against one Profile: + +```bash +webcmd --profile release-work session create -f json +webcmd --profile release-work session create -f json + +webcmd --profile release-work --session session_A_FROM_OUTPUT browser run --stdin <<'JS' +await page.goto('data:text/html,agent-a'); +await new Promise(resolve => setTimeout(resolve, 15000)); +return { title: await page.title() }; +JS +webcmd --profile release-work --session session_B_FROM_OUTPUT browser run --stdin <<'JS' +await page.goto('data:text/html,agent-b'); +await new Promise(resolve => setTimeout(resolve, 15000)); +return { title: await page.title() }; +JS +webcmd --profile release-work session list -f json +``` + +Start the heredocs in separate shells so their 15-second holds overlap. Expected: distinct local window groups or hosted live views, both commands progress, and list returns the two stable IDs. During that hold, launch Session A from a third shell; expected `SESSION_BUSY` while B remains usable. Verify raw omission returns `SESSION_REQUIRED`. Run a browser-backed adapter with no selector and verify one `adapter-default` row appears without a per-command Session. Run login in A; expected its returned verify command contains the same immutable ID, A pauses, and B continues. Finally close both IDs and verify repeated close is a successful no-op. + +- [ ] **Step 6: Commit any final generated/package-only changes** + +In each repository with intentional final changes: + +```bash +git diff --check +git diff --cached --check +git status --short +``` + +Commit generated artifacts in OpenCLI as: + +```bash +git add cli-manifest.json hosted-contract.json plugin-command-manifest.json +git commit -m "build: refresh session command contracts" +``` + +Commit an intentional cloud package pin separately as: + +```bash +git add package.json package-lock.json +git commit -m "build: pin session-capable webcmd" +``` + +Do not create either commit when its staged diff is empty. + +--- + +## Completion Matrix + +| Requirement | Implemented and verified by | +|---|---| +| Root selector, explicit create/list/close, raw requirement, adapter default | Tasks 1-2, 7, 9, 12 | +| Parallel Sessions, immediate same-Session busy, same-run re-entry, dead-owner cleanup | Tasks 3, 8-9 | +| Local window groups, tab/popup/page ownership, browser-run sandbox | Task 4 | +| Issue #276 platform keeper, close/arrival race, shutdown launch fence | Task 5 | +| Issue #242 exact Profile teardown | Tasks 5 and 13 | +| Issue #225 pinned concurrent Cloak guarantee | Task 13 | +| One Browser Use allocation/live view per hosted Session | Tasks 8-10 | +| Structured hosted Session capacity errors | Tasks 8, 12, 15 | +| Session-scoped local/hosted handoff with sibling continuation | Tasks 6, 10, 14 | +| Browser Use same-Profile storage merge in both stop orders | Task 14 | +| Capability break for incompatible CLI/cloud pairs | Tasks 11-12 | +| Help, completion, docs, harness guides, and bundled skills | Tasks 12 and 15 | +| Full build, packed parity, PostgreSQL, live gates, smoke test | Task 16 | diff --git a/docs/superpowers/plans/2026-08-12-client-owned-web-fetch.md b/docs/superpowers/plans/2026-08-12-client-owned-web-fetch.md new file mode 100644 index 00000000..50e04456 --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-client-owned-web-fetch.md @@ -0,0 +1,828 @@ +# Client-Owned Web Fetch Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the split `web fetch` / `web fetch-browser` surface with one always-installed, client-owned `web fetch` command whose only stages are native HTTP, Impit Chrome, and Impit Firefox, then teach agents to create a Session and use the existing browser commands explicitly when rendering is required. + +**Architecture:** `web/fetch` is registered once in the core registry and parsed once through the normal Commander adapter. The entrypoint intercepts that client-owned command before local discovery or hosted dispatch, while hosted presentation merges the same core metadata into help, list, and completion. The fetch client owns one safe proxy and one deadline across three non-browser transports; Cloud sees the command as `client-owned`/`local-only` and never imports or executes it. + +**Tech Stack:** TypeScript, Node.js 20.6+, Commander 14, Undici, Impit 0.14.3, Vitest, the existing Webcmd registry/manifest/hosted-contract generators, and the existing Session plus `browser run` surface. + +## Global Constraints + +- Implement the approved contract in `docs/superpowers/specs/2026-08-12-client-owned-web-fetch-design.md`. +- Revise PR #295 rather than merging its current auto-escalating implementation. Preserve its useful core-registration, package-export, classifier, and Markdown-rendering work only where it matches this plan. +- Drop PR #295's unrelated `.gitattributes` PNG change from this PR; it can be proposed separately if still needed. +- Rebase the implementation branch onto the branch containing the approved Profile Sessions work in `docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md` before Task 6. The browser fallback examples intentionally use `session create`, root `--session`, and `session close`; do not document commands that the target release does not ship. +- Work in `/Users/beubax/Desktop/AgentR/OpenCLI` for Tasks 1–6 and 8, and `/Users/beubax/Desktop/AgentR/webcmd-cloud` for Task 7. +- Preserve unrelated user changes. The existing modifications to the Profile Sessions plan/spec and `docs/2026-08-11-profile-sessions-.textClipping` are user-owned; never stage them with this work. +- Add no dependency and no browser helper. Reuse Commander, the registry, Undici, Impit, `session create`, `browser run`, `browser snapshot`, and `session close`. +- `web fetch` never imports or calls `executeCommand`, daemon clients, Cloak, Browser Use, browser allocation code, or browser Session creation. +- Keep exactly these fetch stages, in order: native HTTP, Impit Chrome, Impit Firefox. No automatic retry outside this ladder and no browser stage. +- Keep one deadline and one safe-proxy policy across all stages. Argument, private-address, body-size, and exhausted-deadline failures are terminal. +- Remove `web fetch-browser`, `--browser`, and fetch-specific `--wait` completely. Do not add aliases or deprecations; the auto-escalating release has not shipped. +- `web/fetch` is client-owned in metadata, visible in local and hosted help/list/completion, and excluded from Cloud execution. +- Historical dated plans/specifications may retain old names as history. Active documentation, current skills, generated artifacts, error hints, and the current `search_spec.md` must not contain `fetch-browser`, `web read`, or implicit browser escalation. +- Retain `src/browser/article-extract.ts`, `src/download/article-download.ts`, and their package exports. Caller audit proves that browser snapshots, `src/fetch/extract.ts`, and their focused tests still use them. +- Publish and deploy only after the OpenCLI tarball checks, hosted contract checks, Cloud pin checks, and active-document scan all pass together. + +--- + +## Planned File Map + +### OpenCLI core and fetch runtime + +- `src/fetch/command.ts`: sole `web/fetch` registration, argument validation, result-to-Markdown renderer, and lazy fetch-client import. +- `src/fetch/client.ts`: native/Chrome/Firefox ladder, shared deadline, transport selection, body bounds, and structured non-browser failures. +- `src/fetch/classify.ts`: status-gated challenge evidence and JavaScript-shell detection. +- `src/fetch/safe-proxy.ts`: private-destination policy signal that the fetch ladder can fail immediately. +- `src/main.ts`: client-owned routing before hosted mode or user-adapter discovery. +- `src/cli.ts`: core command registration through the normal Commander program. +- `src/registry.ts`, `src/commanderAdapter.ts`, `src/output.ts`: command ownership metadata and the existing renderer's small command-supplied Markdown hook. + +### Manifest, hosted presentation, and packaging + +- `src/manifest-types.ts`, `src/build-manifest.ts`: serialize the core command with `clientOwned: true` and `packageExport: './fetch/command'` without a fake `clis/` path. +- `src/hosted/availability.ts`, `src/hosted/contract.ts`: encode `{ mode: 'local-only', reason: 'client-owned' }`. +- `src/hosted/manifest.ts`, `src/hosted/runner.ts`, `src/hosted/types.ts`, `src/completion-shared.ts`: merge the client-owned core command into hosted presentation without server dispatch. +- `cli-manifest.json`, `hosted-contract.json`: generated artifacts containing exactly one `web/fetch` entry and no `web/fetch-browser`. +- `scripts/check-package-bin.mjs`, `src/package-exports.test.ts`: packed-install discovery/import/execution smoke coverage. + +### Removal, skills, and docs + +- Delete `clis/web/README.md`, `clis/web/fetch.js`, `clis/web/fetch-browser.js`, and `clis/web/test/fetch-browser.test.js`. +- Delete PR #295's `src/fetch/browser.ts` and `src/fetch/browser.test.ts`; do not move the browser exporter into core. +- Delete `tests/e2e/article-download-pipeline.test.ts`; its `web read` invocation has been vacuous, while shared extraction/download behavior remains covered by `src/browser/article-extract.e2e.test.ts` and `src/download/article-download.test.ts`. +- Update `skills/smart-search/SKILL.md`, `skills/webcmd-browser/SKILL.md`, `docs/cli-reference.mdx`, `docs/superpowers/specs/search_spec.md`, `src/skills.test.ts`, and generated lint baselines. + +### Webcmd Cloud + +- `docs/superpowers/plans/2026-07-08-webcmd-cloud-global-plan.md`: record `web fetch` as the explicit client-owned exception to server execution and Browser Use as the browser-command backend. +- `tests/default-adapters.test.ts`, `tests/contract-compatibility.test.ts`: prove the pinned package contract excludes `web/fetch` from hosted commands and has no `web/fetch-browser`. +- `package.json`, `package-lock.json`, `Dockerfile`, `.github/workflows/ci.yml`: exact released Webcmd version/SHA pin through the existing bump script; no Cloud loader or embedded-executor feature is added. + +--- + +### Task 1: Replace the Hand-Written Fast Path with the Core Command + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Modify: `src/fetch/command.ts` +- Modify: `src/fetch/command.test.ts` +- Modify: `src/cli.ts` +- Modify: `src/main.ts` +- Modify: `src/registry.ts` +- Modify: `src/commanderAdapter.ts` +- Modify: `src/output.ts` +- Test: `src/commanderAdapter.test.ts` +- Test: `src/hosted/main-lifecycle.test.ts` + +**Interfaces:** + +- `webFetchCommand` is the only registered `web/fetch` command. +- `webFetchCommand.clientOwned === true`, `browser === false`, and `defaultFormat === 'md'`. +- `formatWebFetchMarkdown(result)` remains the readable default while JSON/YAML/plain/table/csv continue through the shared renderer. +- `src/main.ts` uses the existing root structural parser only to identify `web fetch`; Commander remains the sole parser for its arguments and common options. +- The command module lazily imports `./client.js`, so unrelated CLI startup does not eagerly load Impit or Undici. + +- [ ] **Step 1: Add failing canonical-parser tests** + +Replace tests of `runClientOwnedWebFetch` with tests that drive `createProgram('', '').parseAsync(...)` or `registerCommandToProgram` and mock `./fetch/client.js` at the module boundary. Cover all of these assertions: + +```ts +expect(webFetchCommand).toMatchObject({ + site: 'web', + name: 'fetch', + browser: false, + clientOwned: true, + defaultFormat: 'md', +}); + +await program.parseAsync([ + 'web', 'fetch', + '--url=https://example.com', + '--timeout=9', + '--max-chars=1200', + '--allow-private=false', + '--format=json', +], { from: 'user' }); + +expect(mockWebFetch).toHaveBeenCalledWith({ + url: 'https://example.com', + timeoutSeconds: 9, + maxChars: 1200, + allowPrivate: false, +}); +``` + +Also assert: + +- `web fetch --help` succeeds without `--url`; +- `web fetch --help -f json` uses structured help; +- `--unknown` is rejected by Commander; +- `--timeout nope`, negative timeout, negative max chars, and non-HTTP URLs return `ARGUMENT`; +- `--browser` and `--wait` are unknown; +- `-f md` uses `formatWebFetchMarkdown` and `-f json` returns the structured result; +- both spellings `--format json` and `--format=json` are identical. + +Run: + +```bash +npx vitest run --project unit src/fetch/command.test.ts src/commanderAdapter.test.ts +``` + +Expected: FAIL because the current fast path owns a second argv parser and the command has no ownership/custom-Markdown metadata. + +- [ ] **Step 2: Make the registry command the sole command grammar** + +In `src/fetch/command.ts`, delete `clientOptions(argv)` and `runClientOwnedWebFetch`. Keep one command definition with exactly these args: + +```ts +args: [ + { name: 'url', type: 'string', required: true, help: 'HTTP or HTTPS URL to fetch' }, + { name: 'timeout', type: 'int', default: 30, help: 'Total fetch budget in seconds' }, + { name: 'max-chars', type: 'int', default: 50_000, help: 'Maximum extracted characters; 0 disables truncation' }, + { name: 'allow-private', type: 'boolean', default: false, help: 'Allow private and loopback destinations' }, +], +``` + +Use `validateArgs` to require a syntactically valid `http:` or `https:` URL and non-negative integer bounds. Convert the already-coerced kwargs to `WebFetchOptions` in `func`; do not parse strings again. Keep only a type import from `./client.js` at module initialization and use `await import('./client.js')` inside `func`. + +Add `clientOwned?: boolean` and `renderMarkdown?: (data: unknown) => string | undefined` to the existing `BaseCliCommand`, copy them through `cli()`, and pass `renderMarkdown` to the existing output renderer from `commanderAdapter.ts`. Add only the corresponding optional `markdown` callback to `RenderOptions`; do not build a second renderer. + +- [ ] **Step 3: Register and route the core command before either mode boundary** + +Add the side-effect import below near the other core CLI imports: + +```ts +import './fetch/command.js'; +``` + +In `src/main.ts`, remove the direct `runClientOwnedWebFetch(argv)` call. Use `parseHostedRootCommandSurface(argv)` in a guarded structural check; if it returns a dispatch whose normalized argv starts with `web`, `fetch`, load `createProgram` and parse the original argv with empty built-in/user adapter directories: + +```ts +await createProgram('', '').parseAsync(argv, { from: 'user' }); +``` + +If the structural probe rejects malformed root syntax, fall through to the normal mode path so its existing error handling remains authoritative. Do not copy root-option or fetch-option parsing into `main.ts`. + +Add a hosted process test using the existing local HTTP fixture and a hosted config. Invoke: + +```text +web fetch --url http://127.0.0.1:/article --allow-private true -f json +``` + +Assert success, parsed content, zero requests to the fake Cloud API, and no local adapter-discovery sentinel. Repeat the command in local mode and assert the same result shape. + +- [ ] **Step 4: Run the focused tests and commit** + +```bash +npx vitest run --project unit src/fetch/command.test.ts src/commanderAdapter.test.ts src/hosted/main-lifecycle.test.ts +npm run typecheck +git add src/fetch/command.ts src/fetch/command.test.ts src/cli.ts src/main.ts src/registry.ts src/commanderAdapter.ts src/output.ts src/commanderAdapter.test.ts src/hosted/main-lifecycle.test.ts +git diff --cached --check +git commit -m "refactor(fetch): route client-owned fetch through core parser" +``` + +Expected: PASS. `src/fetch/command.ts` contains no argv loop and `src/main.ts` contains no fetch option names. + +--- + +### Task 2: Enforce the Three-Stage Non-Browser Ladder + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Modify: `src/fetch/client.ts` +- Modify: `src/fetch/client.test.ts` +- Modify: `src/fetch/classify.ts` +- Modify: `src/fetch/classify.test.ts` +- Modify: `src/fetch/safe-proxy.ts` +- Modify: `src/fetch/safe-proxy.test.ts` +- Modify: `src/fetch/extract.ts` + +**Interfaces:** + +- Successful results expose `tier: 'plain' | 'impit'` and `profile?: 'chrome' | 'firefox'`. +- `SafeProxy.policyError()` returns the first private-address policy error observed by the per-invocation proxy, if any. +- Retry eligibility is local to `webFetch`: non-policy transport/TLS failures may advance; `CliError`, deadline exhaustion, body limits, and proxy policy failures do not. +- `FETCH_REQUIRES_BROWSER` and `FETCH_BLOCKED` hints name the explicit Session workflow, never another fetch command. + +- [ ] **Step 1: Add the failing ladder and terminal-error matrix** + +Expand `src/fetch/client.test.ts` with one table-driven stage recorder and assert: + +1. healthy plain response calls no Impit client; +2. plain challenge -> Chrome success; +3. plain challenge -> Chrome challenge -> Firefox success; +4. three challenges -> `FETCH_BLOCKED`; +5. plain transport failure -> Chrome transport failure -> Firefox success; +6. a JavaScript shell at any completed stage -> immediate `FETCH_REQUIRES_BROWSER` and no later stage; +7. `FETCH_BODY_TOO_LARGE`, a proxy policy error, and an exhausted deadline stop immediately; +8. Chrome and Firefox receive decreasing positive timeout values from one deadline; +9. `proxy.close()` runs once on success and every failure; +10. neither browser execution nor hosted configuration is imported or consulted. + +Use fake responses and fake clients; no public network call belongs in this unit test. Assert the exact creation order: + +```ts +expect(createdProfiles).toEqual(['chrome', 'firefox']); +expect(result).toMatchObject({ tier: 'impit', profile: 'firefox' }); +``` + +Run: + +```bash +npx vitest run --project unit src/fetch/client.test.ts +``` + +Expected: FAIL because native transport errors currently escape immediately and browser hints still name `fetch-browser`. + +- [ ] **Step 2: Add private-policy signaling to the existing safe proxy** + +Extend `SafeProxy` with `policyError(): Error | undefined`. Record the first error raised by `resolve(...)` in both the HTTP proxy and CONNECT handlers, while preserving the existing 403/connection-close behavior for the transport peer. Do not add an event emitter or a second policy validator. + +Add a focused proxy test that sends a request for `127.0.0.1` through a proxy created with `allowPrivate: false`, then asserts `policyError()?.message` contains `Unsafe fetch destination`. Add the corresponding allow-private test asserting the policy slot remains empty. + +- [ ] **Step 3: Implement the minimum three-stage loop** + +In `webFetch`, keep the existing safe proxy and deadline. Represent the fixed ladder as plain code or a three-item tuple; do not add a transport class/interface. For each stage: + +- call `remaining()` immediately before client creation/request; +- after a response or caught transport error, check `proxy.policyError()` first and throw a structured `FETCH_UNSAFE_ADDRESS` error; +- map exhausted/abort timeout to the existing `TimeoutError`; +- rethrow all existing `CliError` instances; +- advance on other fetch/Impit transport failures while a stage remains; +- read the bounded body; +- return `FETCH_REQUIRES_BROWSER` immediately for a JavaScript shell; +- advance on a recognized challenge; +- extract and return the first usable response. + +After the Firefox stage, return `FETCH_BLOCKED` when the completed ladder remained challenged. If only transport failures occurred, rethrow the last transport failure without a browser hint. + +Use this concise next action for both browser-worthy structured errors: + +```text +Create a browser Session with `webcmd --profile work session create`, then navigate with `webcmd --profile work --session browser run --stdin`. +``` + +Update the unsupported-content hint in `src/fetch/extract.ts` to the same explicit workflow only if the error actually means rendered content can help. Do not recommend a browser for DNS, timeout, refused connection, body limit, or private-address policy errors. + +- [ ] **Step 4: Status-gate challenge classification** + +Add these regressions to `src/fetch/classify.test.ts`: + +```ts +expect(isChallengeResponse(200, { 'x-datadome': 'protected' }, '
real article
')).toBe(false); +expect(isChallengeResponse(200, { 'set-cookie': '__cf_bm=abc' }, '
real article
')).toBe(false); +expect(isChallengeResponse(200, { 'content-security-policy': 'script-src https://cdnjs.cloudflare.com' }, '
ok
')).toBe(false); +expect(isChallengeResponse(200, { 'cf-mitigated': 'challenge' }, '')).toBe(true); +expect(isChallengeResponse(403, { server: 'cloudflare' }, 'Just a moment')).toBe(true); +expect(isChallengeResponse(403, {}, 'forbidden')).toBe(false); +``` + +Implement the smallest classifier that passes: + +- `cf-mitigated: challenge` and explicit challenge bodies are decisive; +- Cloudflare/DataDome/PerimeterX/Akamai/CAPTCHA provider evidence is only corroborating on 403, 429, or 503; +- arbitrary CSP, cookies, or a healthy 200 provider header are not challenges. + +- [ ] **Step 5: Run the fetch tests and commit** + +```bash +npx vitest run --project unit src/fetch/client.test.ts src/fetch/classify.test.ts src/fetch/safe-proxy.test.ts src/fetch/extract.test.ts +npm run typecheck +git add src/fetch/client.ts src/fetch/client.test.ts src/fetch/classify.ts src/fetch/classify.test.ts src/fetch/safe-proxy.ts src/fetch/safe-proxy.test.ts src/fetch/extract.ts +git diff --cached --check +git commit -m "fix(fetch): stop after three non-browser transports" +``` + +Expected: PASS. A source scan of `src/fetch` finds no `executeCommand`, daemon client, Cloak, Browser Use, `--browser`, or fetch-specific `--wait`. + +--- + +### Task 3: Publish One Core Manifest Entry and Hosted Ownership Contract + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Modify: `src/manifest-types.ts` +- Modify: `src/build-manifest.ts` +- Modify: `src/build-manifest.test.ts` +- Modify: `src/hosted/availability.ts` +- Modify: `src/hosted/availability.test.ts` +- Modify: `src/hosted/contract.ts` +- Modify: `src/hosted/contract.test.ts` +- Modify: `src/package-exports.test.ts` +- Regenerate: `cli-manifest.json` +- Regenerate: `hosted-contract.json` + +**Interfaces:** + +- `ManifestEntry.clientOwned?: boolean` and `ManifestEntry.packageExport?: string`. +- `HostedContractCommandInput.clientOwned?: boolean`. +- `deriveHostedAvailability({ clientOwned: true })` returns `{ mode: 'local-only', reason: 'client-owned' }` before strategy/domain classification. +- The core build map contains exactly `['web', './fetch/command']` and emits no `modulePath`/`sourceFile` for the core entry. + +- [ ] **Step 1: Add failing core-manifest and availability tests** + +Assert `coreCommandEntries()` returns one command and that its serialized shape contains: + +```ts +expect(entries).toEqual([ + expect.objectContaining({ + site: 'web', + name: 'fetch', + clientOwned: true, + packageExport: './fetch/command', + }), +]); +expect(entries[0]).not.toHaveProperty('modulePath'); +expect(entries[0]).not.toHaveProperty('sourceFile'); +``` + +Assert the hosted contract entry has `sessionPolicy: 'local-only'` and availability reason `client-owned`. Assert ordinary PUBLIC commands remain hosted. + +Run: + +```bash +npx vitest run --project unit src/build-manifest.test.ts src/hosted/availability.test.ts src/hosted/contract.test.ts src/package-exports.test.ts +``` + +Expected: FAIL because the current manifest generator scans only `clis/` and has no core ownership fields. + +- [ ] **Step 2: Serialize the existing core registration** + +Make `toManifestEntry` accept an optional adapter path. Add the fixed core export map and import only `src/fetch/command.ts` when building core entries. Merge core entries with any legacy scan results by canonical command key so one command cannot be emitted twice. + +Copy `clientOwned` through `cli()`, `toManifestEntry`, `HostedContractCommandInput`, and `deriveHostedAvailability`. Add the documented `packageExport` field to `ManifestEntry`. Do not teach Cloud to load this export: it exists for package discovery/import verification, while `clientOwned` explicitly forbids Cloud execution. + +- [ ] **Step 3: Verify the package export and regenerate artifacts** + +Keep the existing `package.json` export: + +```json +"./fetch/command": "./dist/src/fetch/command.js" +``` + +Update `src/package-exports.test.ts` so every manifest entry has either a contained adapter path or a resolvable package export, and assert the `web/fetch` export maps to `src/fetch/command.ts` in source plus `dist/src/fetch/command.js` after build. + +Generate the intentional one-for-one replacement: + +```bash +npm run build-manifest -- --allow-removals=1 +``` + +Assert with a small Node/Vitest check that both JSON artifacts contain one `web/fetch`, no `web/fetch-browser`, and the ownership/availability fields above. + +- [ ] **Step 4: Run contract checks and commit** + +```bash +npx vitest run --project unit src/build-manifest.test.ts src/hosted/availability.test.ts src/hosted/contract.test.ts src/package-exports.test.ts +npm run check:hosted-contract +npm run typecheck +git add src/manifest-types.ts src/build-manifest.ts src/build-manifest.test.ts src/hosted/availability.ts src/hosted/availability.test.ts src/hosted/contract.ts src/hosted/contract.test.ts src/package-exports.test.ts cli-manifest.json hosted-contract.json +git diff --cached --check +git commit -m "feat(fetch): publish client-owned core manifest entry" +``` + +Expected: PASS. The artifacts have one `web/fetch` and no loadable Cloud module path. + +--- + +### Task 4: Keep the Client-Owned Command Visible in Hosted Presentation + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Modify: `src/hosted/types.ts` +- Modify: `src/hosted/manifest.ts` +- Modify: `src/hosted/manifest.test.ts` +- Modify: `src/hosted/runner.ts` +- Modify: `src/hosted/runner.test.ts` +- Modify: `src/completion-shared.ts` +- Modify: `src/hosted/root-command-surface.test.ts` + +**Interfaces:** + +- `HostedCommand.clientOwned?: boolean` distinguishes client presentation from Cloud execution. +- `withClientOwnedCommands(manifest)` returns a copy containing the core `web/fetch` metadata exactly once. +- Every manifest received from Cloud is contract-validated before the client-owned presentation entry is merged. +- Direct execution still exits through the pre-mode core path from Task 1; the merged entry is never sent to `HostedClient.execute`. + +- [ ] **Step 1: Add failing hosted visibility and no-dispatch tests** + +Using the existing fake hosted client/manifest fixtures, prove: + +- root help contains `web`; +- `web --help` contains `fetch`; +- `web fetch --help` exposes the same four command args as local mode; +- hosted `list -f json` contains one `web/fetch` and does not label it Cloud-executable; +- hosted `--get-completions --cursor 2 web` returns `fetch`; +- merging a Cloud manifest that maliciously/already contains `web/fetch` still yields one client-owned entry; +- executing `web fetch` records zero `getManifest`/`execute` HTTP calls because Task 1 handles it first. + +Run: + +```bash +npx vitest run --project unit src/hosted/manifest.test.ts src/hosted/runner.test.ts src/hosted/root-command-surface.test.ts +``` + +Expected: FAIL because hosted presentation currently uses only the tenant Cloud manifest. + +- [ ] **Step 2: Merge one local core entry into presentation** + +In `src/hosted/manifest.ts`, import `webFetchCommand` and map its serializable fields to `HostedCommand`. Implement `withClientOwnedCommands` by filtering any same canonical command from the server list, then appending the local authoritative entry. Do not load `cli-manifest.json` from disk and do not add a generic plugin merge path. + +In `src/hosted/runner.ts`, replace repeated `client.getManifest()` presentation use with one small helper that: + +1. gets the tenant manifest; +2. validates contract identity; +3. returns `withClientOwnedCommands(manifest)`. + +Use it for hosted list, site/command help, and completions. Keep server execution guarded: if a `clientOwned` command somehow reaches hosted dispatch instead of Task 1, execute it through the local core program or fail an internal invariant before any Cloud execute call; never send it to the server. + +Add `web` to `HOSTED_ROOT_HELP.commands` with description `Fetch URLs locally without launching a browser`. + +- [ ] **Step 3: Run hosted surface tests and commit** + +```bash +npx vitest run --project unit src/hosted/manifest.test.ts src/hosted/runner.test.ts src/hosted/root-command-surface.test.ts src/hosted/main-lifecycle.test.ts +npm run typecheck +git add src/hosted/types.ts src/hosted/manifest.ts src/hosted/manifest.test.ts src/hosted/runner.ts src/hosted/runner.test.ts src/completion-shared.ts src/hosted/root-command-surface.test.ts src/hosted/main-lifecycle.test.ts +git diff --cached --check +git commit -m "fix(hosted): present client-owned web fetch locally" +``` + +Expected: PASS. Hosted discovery sees the command; hosted execution makes no Cloud request. + +--- + +### Task 5: Delete Browser Fetch and Its Dead Command Pipeline + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Delete: `clis/web/README.md` +- Delete: `clis/web/fetch.js` +- Delete: `clis/web/fetch-browser.js` +- Delete: `clis/web/test/fetch-browser.test.js` +- Delete: `src/fetch/browser.ts` (present on PR #295) +- Delete: `src/fetch/browser.test.ts` (present on PR #295) +- Delete: `tests/e2e/article-download-pipeline.test.ts` +- Modify: `scripts/silent-column-drop-baseline.json` +- Modify: `scripts/typed-error-lint-baseline.json` +- Test: `src/package-exports.test.ts` + +**Interfaces:** + +- There is no `web/fetch-browser` registration, alias, manifest entry, package export, test, or runtime module. +- Shared article extraction/Markdown/download utilities remain available to fetch extraction and browser snapshots. + +- [ ] **Step 1: Add an absence-and-caller-audit test** + +Add focused assertions to `src/package-exports.test.ts` that the generated manifest and registered core commands do not contain `web/fetch-browser`, and that `./fetch/command` is the only fetch command package export. + +Run the caller audit before deletion: + +```bash +rg -n "extractArticle\(|articleHtmlToMarkdown\(|downloadArticle\(" src tests clis +``` + +Expected: `src/browser/runtime/local-cloak/actions.ts`, `src/fetch/extract.ts`, `src/browser/article-extract*.test.ts`, and `src/download/article-download.test.ts` prove the shared utilities are live. + +- [ ] **Step 2: Delete only the obsolete command path** + +Delete the listed command/adapter/browser-export files. Remove their exact entries from both lint baseline JSON files. Do not delete: + +- `src/browser/article-extract.ts`; +- `src/download/article-download.ts`; +- `src/browser/runtime/local-cloak/actions.ts` readable snapshots; +- their package exports or focused tests. + +Delete the real-site `article-download-pipeline` test because it invokes the removed command and silently skips CLI failures. The retained fixture/unit suites cover the shared pipeline deterministically. + +- [ ] **Step 3: Run focused shared-utility tests and commit** + +```bash +npx vitest run --project unit src/package-exports.test.ts src/browser/article-extract.test.ts src/browser/article-extract.e2e.test.ts src/download/article-download.test.ts src/fetch/extract.test.ts +npm run check:silent-column-drop +npm run check:typed-error-lint +git add -A clis/web src/fetch/browser.ts src/fetch/browser.test.ts tests/e2e/article-download-pipeline.test.ts scripts/silent-column-drop-baseline.json scripts/typed-error-lint-baseline.json src/package-exports.test.ts +git diff --cached --check +git commit -m "refactor(fetch): remove browser-backed fetch command" +``` + +Expected: PASS. Shared extraction remains green and only the command-specific browser path is gone. + +--- + +### Task 6: Teach the Explicit Session and Browser Workflow + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Dependency:** The Profile Sessions implementation must already provide `session create`, root `--session`, `browser run`, `browser snapshot`, and `session close` in local and hosted modes. + +**Files:** + +- Modify: `skills/smart-search/SKILL.md` +- Modify: `skills/webcmd-browser/SKILL.md` +- Modify: `docs/cli-reference.mdx` +- Modify: `docs/superpowers/specs/search_spec.md` +- Modify: `src/skills.test.ts` +- Modify only when scan flags active stale text: `README.md`, `docs/**/*.mdx`, `skills/**/*.md` + +**Interfaces:** + +- Agents try `web fetch` once and branch only on `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER`. +- Browser fallback always creates a Session, carries its complete opaque ID, uses root `--session`, observes with snapshot, and closes the Session. +- Smart-search budgets count browser Sessions/URLs, not browser-fetch commands. + +- [ ] **Step 1: Add failing active-guidance tests** + +Update `src/skills.test.ts` to assert both bundled skills contain: + +```text +webcmd --profile work session create +webcmd --profile work --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 browser run --stdin +webcmd --profile work --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 browser snapshot --snapshot-mode read +webcmd --profile work session close session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +``` + +Also assert active skills contain no `fetch-browser`, `web read`, `--browser`, or claim that `web fetch` launches a browser. + +Run: + +```bash +npx vitest run --project unit src/skills.test.ts +``` + +Expected: FAIL on the current smart-search/browser-fetch instructions. + +- [ ] **Step 2: Replace smart-search browser fetches with Session workflows** + +Keep the existing site-named adapter fast path and search budgets. Replace each `web fetch-browser` instruction with: + +1. create one Session for the browser portion of the request; +2. navigate the exact failed URL using `browser run`; +3. read it with `browser snapshot --snapshot-mode read` or a targeted `browser run`; +4. reuse that Session for allowed browser fallbacks; +5. close it in cleanup. + +Include this complete example in `smart-search` and the browser skill: + +```bash +webcmd --profile work session create +# Copy the returned full ID: +# session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 + +webcmd --profile work \ + --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 \ + 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 \ + browser snapshot --snapshot-mode read + +webcmd --profile work session close \ + session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +``` + +State mode ownership beside the example: local browser commands use Cloak; hosted browser commands use Webcmd Cloud and Browser Use. `web fetch` itself remains local in both modes. + +- [ ] **Step 3: Update active docs and the current search spec** + +Rewrite the Search and Fetch section of `docs/cli-reference.mdx`, the `web` row, and the Local/Hosted routing table in `docs/superpowers/specs/search_spec.md`. Remove the old rename/deprecation language because `web read` and `fetch-browser` do not exist in the release. + +Run the active-surface scan, excluding historical dated plans/specs and the approved design's removal discussion: + +```bash +rg -n "fetch-browser|web read|--browser|implicit.*browser|auto.*escalat" README.md docs skills src \ + -g '!docs/superpowers/plans/**' \ + -g '!docs/superpowers/specs/2026-08-12-client-owned-web-fetch-design.md' \ + -g '!**/*.test.*' +``` + +Expected after edits: no stale active instruction. Review every match rather than blindly replacing prose. + +- [ ] **Step 4: Run docs/skill checks and commit** + +```bash +npx vitest run --project unit src/skills.test.ts src/docs-sync-review.test.ts src/docs-sync-review-cli.test.ts +npm run docs-sync-review -- --base HEAD~1 +git add skills/smart-search/SKILL.md skills/webcmd-browser/SKILL.md docs/cli-reference.mdx docs/superpowers/specs/search_spec.md src/skills.test.ts README.md docs skills +git diff --cached --check +git commit -m "docs(fetch): require explicit browser sessions after fetch" +``` + +Before committing, unstage all historical files that did not need an active correction and confirm the cached diff does not include the user-owned Profile Sessions documents. + +--- + +### Task 7: Prove Webcmd Cloud Excludes the Client-Owned Command + +**Repository:** `/Users/beubax/Desktop/AgentR/webcmd-cloud` + +**Files:** + +- Modify: `docs/superpowers/plans/2026-07-08-webcmd-cloud-global-plan.md` +- Modify: `tests/default-adapters.test.ts` +- Modify: `tests/contract-compatibility.test.ts` +- Modify after the OpenCLI package is published: `package.json` +- Modify after the OpenCLI package is published: `package-lock.json` +- Modify after the OpenCLI package is published: `Dockerfile` +- Modify after the OpenCLI package is published: `.github/workflows/ci.yml` + +**Interfaces:** + +- Cloud's server manifest contains no `web/fetch` and no `web/fetch-browser`. +- The pinned OpenCLI hosted contract contains `web/fetch` only as `{ mode: 'local-only', reason: 'client-owned' }`. +- Cloud does not resolve `packageExport`, import `@agentrhq/webcmd/fetch/command`, set `WEBCMD_EMBEDDED_EXECUTOR`, or add article-worker stdout/materialization special cases. + +- [ ] **Step 1: Add failing pinned-contract exclusion tests** + +After publishing the OpenCLI release candidate, update the default-adapter assertions to prove: + +```ts +const fetchContract = source.hostedContract.commands.find( + command => command.command === 'web/fetch', +); +expect(fetchContract?.availability).toEqual({ + mode: 'local-only', + reason: 'client-owned', +}); +expect(source.hostedContract.commands.some( + command => command.command === 'web/fetch-browser', +)).toBe(false); +expect(loadDefaultHostedCommands(source).some( + command => command.command === 'web/fetch', +)).toBe(false); +``` + +Build a tenant manifest and assert neither fetch command is advertised. Call the hosted executor with `web/fetch` and assert it cannot resolve a default hosted implementation and imports no package export. + +Run: + +```bash +npx vitest run tests/default-adapters.test.ts tests/contract-compatibility.test.ts tests/hosted-manifest-executability.test.ts tests/executor-non-browser.test.ts +``` + +Expected before the dependency pin: FAIL because Cloud still inspects `@agentrhq/webcmd@0.6.0`. + +- [ ] **Step 2: Update the Cloud north star without adding runtime code** + +Amend the non-negotiable/source-of-truth/parity/repository-boundary sections to state: + +- hosted adapters and explicit browser commands execute server-side; +- `web fetch` is the sole client-owned exception and always runs in the installed CLI; +- the released manifest may contain client-owned discovery metadata that Cloud must not advertise or execute; +- explicit hosted browser commands continue through Browser Use; +- there is no embedded core-command executor for fetch. + +Replace stale Kernel wording in the touched browser rules with Browser Use where the currently deployed architecture already uses it. Do not rewrite unrelated historical implementation records. + +- [ ] **Step 3: Pin the published Webcmd release through the existing script** + +From the Cloud repository, run with the exact published version and exact OpenCLI release commit: + +```bash +release_version=$(node -p "require('../OpenCLI/package.json').version") +npm run bump:webcmd -- "$release_version" --opencli-dir ../OpenCLI +``` + +The script reads the exact OpenCLI HEAD, enforces matching exact semver, and updates `package.json`, `package-lock.json`, Dockerfile, and CI provenance together. Do not hand-edit the four pins. + +- [ ] **Step 4: Run Cloud tests and commit** + +```bash +npx vitest run tests/default-adapters.test.ts tests/contract-compatibility.test.ts tests/hosted-manifest-executability.test.ts tests/executor-non-browser.test.ts +npm run typecheck +npm run build +git add docs/superpowers/plans/2026-07-08-webcmd-cloud-global-plan.md tests/default-adapters.test.ts tests/contract-compatibility.test.ts package.json package-lock.json Dockerfile .github/workflows/ci.yml +git diff --cached --check +git commit -m "chore(fetch): exclude client-owned fetch from cloud runtime" +``` + +Expected: PASS with no changes to `src/default-adapters/source.ts`, `src/executor/load-command.ts`, or server execution code. Close or supersede webcmd-cloud PR #33 because this feature does not need its package-export loader. + +--- + +### Task 8: Packed Installation, Full Regression, and Release Gate + +**Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` + +**Files:** + +- Modify: `scripts/check-package-bin.mjs` +- Modify: `src/package-exports.test.ts` +- Modify only if CI selection requires it: `vitest.config.ts` + +**Interfaces:** + +- A fresh installed tarball can import and present `web/fetch` without `clis/`. +- The package smoke test proves behavior rather than only checking file presence/version. + +- [ ] **Step 1: Extend the packed-install smoke before rebuilding** + +After the existing global tarball install, run the installed binary and assert: + +- `web fetch --help` exits 0 and lists `--url`, `--timeout`, `--max-chars`, `--allow-private`; +- `web --help` lists `fetch` and not `fetch-browser`; +- `list -f json` contains exactly one `web/fetch`; +- `--get-completions --cursor 2 web` contains `fetch` and not `fetch-browser`; +- the installed package's exported `dist/src/fetch/command.js` imports successfully through its package export target; +- the packed paths contain no `clis/web/`, `src/fetch/browser`, or fetch-browser artifact. + +Use the installed prefix's real package path for the import smoke; do not depend on the developer checkout resolving a global package. +Run every installed-binary smoke with an isolated `HOME`, `USERPROFILE`, and +`WEBCMD_CONFIG_DIR` under the script's temporary directory so the check cannot +read developer adapters/plugins or a real hosted configuration. + +Run against the pre-change script once. Expected: FAIL because the current smoke runs only `--version`. + +- [ ] **Step 2: Build and run the complete OpenCLI verification matrix** + +```bash +npm run build +npm run typecheck +npm run check:package-bin +npm run check:hosted-contract +npm run check:silent-column-drop +npm run check:typed-error-lint +npm run check:plugin-parity +npm test +npm run test:bun +``` + +Then run the browser routing regressions that prove this change did not alter explicit browser ownership: + +```bash +npx vitest run --project unit src/browser/command-catalog.test.ts src/hosted/browser-args.test.ts src/hosted/runner.test.ts +``` + +Expected: local explicit browser commands retain Cloak routing; hosted explicit browser commands retain Cloud/Browser Use routing; `web fetch` remains non-browser in both. + +- [ ] **Step 3: Run final source/artifact invariants** + +```bash +node --input-type=module -e "import fs from 'node:fs'; const m=JSON.parse(fs.readFileSync('cli-manifest.json','utf8')); if(m.filter(x=>x.site==='web'&&x.name==='fetch').length!==1||m.some(x=>x.name==='fetch-browser')) process.exit(1)" +rg -n "executeCommand|daemon-client|cloak|Browser Use|fetch-browser|--browser|name: 'wait'" src/fetch +rg -n "fetch-browser|web read|--browser|implicit.*browser|auto.*escalat" README.md docs skills src \ + -g '!docs/superpowers/plans/**' \ + -g '!docs/superpowers/specs/2026-08-12-client-owned-web-fetch-design.md' \ + -g '!**/*.test.*' +``` + +Expected: the manifest assertion exits 0. The `src/fetch` scan may show shared article-extraction imports but no execution/runtime/browser-command path. The active guidance scan has no stale instruction. + +- [ ] **Step 4: Commit the package gate** + +```bash +git add scripts/check-package-bin.mjs src/package-exports.test.ts vitest.config.ts +git diff --cached --check +git commit -m "test(fetch): verify packed core command surface" +``` + +- [ ] **Step 5: Review PR #295 against its issue claims** + +Before marking ready, record this evidence in the PR description/comment: + +- #246: malformed/missing arguments use the normal structured CLI envelope; no raw Node stack; +- #247: the installed tarball imports the core command from `dist/src` and needs no `clis/`; +- #252/#263: local and hosted help/list/completion expose one client-owned `web/fetch`; +- #264 and classifier half of #283: healthy 200 provider headers no longer trigger false challenges; #265 already owns the safe-proxy close/EPIPE half; +- removed behavior: no auto browser escalation, no `web/fetch-browser`, no embedded Cloud executor; +- Cloud: pinned contract excludes fetch from server manifest/execution while explicit browser commands remain Browser Use-backed. + +Do not claim the browser article-export command is preserved; its removal is intentional and approved. + +- [ ] **Step 6: Release in dependency order** + +1. Merge the revised OpenCLI PR after all OpenCLI checks pass. +2. Publish the exact `@agentrhq/webcmd` version. +3. Execute Task 7's exact Cloud pin and tests. +4. Merge and deploy the Cloud compatibility/docs commit. +5. Run one live hosted smoke: `web fetch` makes no Cloud request, while a created Session plus `browser run` reaches Browser Use. +6. Close superseded PRs #263, #271, and webcmd-cloud #33 with links to the merged replacement and verification evidence. + +Do not publish/deploy an intermediate commit where the client advertises Session syntax that the corresponding local/Cloud runtime does not support. + +--- + +## Final Self-Review Checklist + +- [ ] Every approved design decision is covered by an implementation task or explicit global constraint. +- [ ] No task adds `browser open`, browser auto-escalation, an embedded core executor, a second argv parser, or a new dependency. +- [ ] Every non-trivial behavior starts with a failing runnable test and names its expected failure. +- [ ] Every command/path/type name matches the current repositories or the approved Profile Sessions plan. +- [ ] `grep -nE 'TO''DO|T''BD|implement ''later' docs/superpowers/plans/2026-08-12-client-owned-web-fetch.md` returns no planning gaps. +- [ ] The staged plan/implementation commits exclude all unrelated user-owned Profile Sessions files. diff --git a/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md b/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md new file mode 100644 index 00000000..5a08fb28 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md @@ -0,0 +1,513 @@ +# Webcmd Profile Sessions and Concurrency Design + +**Status:** Approved design; implementation plan updated + +**Date:** 2026-08-11 + +## Context + +Webcmd runs locally through a Cloak-backed daemon and remotely through Webcmd Cloud with +Browser Use. Multiple agents must be able to work concurrently with the same authenticated +identity without sharing tabs, selecting each other's pages, or corrupting browser state. + +Webcmd already uses the word `session`. This design keeps that name and makes a Session the +explicit browser-workspace and command-admission boundary. It supersedes +`2026-08-06-profile-spaces-design.md`; Webcmd will not add a separate Space primitive. + +This design also addresses: + +- [#225](https://github.com/agentrhq/webcmd/issues/225): the distributed Cloak/browser pair + must support concurrent Profile contexts and Session windows. +- [#242](https://github.com/agentrhq/webcmd/issues/242): Profile teardown must match the exact + Cloak process and never a prefix-sharing Profile or unrelated Chrome process. +- [#276](https://github.com/agentrhq/webcmd/issues/276): closing the final task page must not + return a dying Profile runtime to an immediately arriving command. + +The design assumes Webcmd pins and distributes a Cloak/browser pair whose concurrency and +window behavior pass the release gates below. There is no product branch for a less capable +unpinned runtime. + +## Final decisions + +1. A Profile is the persistent authentication jar. Sessions under it deliberately share + cookies and browser storage. +2. A Session is a durable browser-workspace identity with an opaque, Webcmd-generated + `session_...` ID. Callers do not choose Session names. +3. Raw `browser` commands require an explicitly supplied existing Session ID. Omission is a + usage error; there is no raw-browser default and no PID-derived or process-global current + Session. +4. `session create` creates a new ID. `session list` discovers existing IDs. Passing an ID in + `--session` is the only attach operation; there is no separate `session bind` command. +5. `session close` intentionally stops that Session's local window group or hosted allocation + and frees runtime capacity. It is idempotent, preserves the durable record, and is not a + delete, complete, or takeover operation. +6. Adapter commands remain CLI-like. A non-browser adapter allocates no Session. A + browser-backed adapter may omit `--session`, in which case Webcmd lazily uses one + system-managed adapter-default Session for the Profile. An explicit Session ID overrides + the default. +7. `siteSession: persistent|ephemeral` controls adapter-tab lifetime inside the resolved + Session; it never creates or selects the user Session. +8. Local Sessions own exclusive Cloak window groups inside one Profile `BrowserContext`. + Hosted Sessions own separate Browser Use allocations created from the same Profile. +9. Admission is intentionally keyed by immutable Session ID, not site. One top-level execution + owns a Session at a time; sibling Sessions run concurrently. +10. A human authentication handoff pauses only the Session that requested it. Sibling Sessions + under the same Profile continue. + +The create/list/opaque-ID flow follows the useful part of ego-lite's agent experience—agents +receive isolated workspaces and can enumerate them—without introducing ambient UI selection +into a shell CLI. Explicit IDs are important because independent agent harnesses do not share +a trustworthy process-local “current Session.” + +## Goals + +- Let multiple agents use one authenticated Profile concurrently without sharing pages. +- Make accidental same-Session attachment impossible when callers follow the create-and-carry + contract. +- Give adapters an ergonomic default while preserving explicit isolation for parallel work. +- Give local and hosted modes the same Session identity, admission, close, and handoff model. +- Scope every tab/page API, including `browser run` and `--page`, to the selected Session. +- Preserve Session records across browser, daemon, or allocation restarts without promising + tab restoration. +- Fix #225, #242, and #276 as part of the same lifecycle and concurrency change. +- Update help, completion, docs, generated hints, and bundled agent skills in the same release. + +## Non-goals + +- Cookie isolation between Sessions; use separate Profiles for separate identities. +- A process-global `session use`, `session bind`, caller-chosen Session name, or PID default. +- `session delete`, `session complete`, or `session takeover` in v1. +- Restoring tabs after a Profile runtime or hosted allocation is evicted. +- Live injection of newly persisted hosted cookies into already-running sibling allocations. +- A Webcmd-owned cookie/storage merge layer for Browser Use. +- Arbitrary CDP target reparenting; Chromium exposes no API to move a target into a chosen + existing window. +- A general output-format migration. New Session output is compact and structured using the + existing renderer; unrelated commands keep their current defaults. + +## Concepts and invariants + +| Concept | Responsibility | +|---|---| +| Profile | Persistent authentication state: cookies, local storage, and provider Profile data. | +| Local Profile runtime | One persistent Cloak `BrowserContext` plus lifecycle keeper for a Profile. | +| Session | Durable task identity, exclusive command-admission key, and owner of one local window group or one hosted allocation. | +| Window group | One or more OS windows owned exclusively by one local Session. A window never mixes Sessions. | +| Tab | A Playwright `Page` owned by one Session. `Page` and CDP `Target` are implementation terms. | +| Adapter-default Session | Lazily created system Session used only when a browser-backed adapter omits `--session`. | +| Profile keeper | Local-only runtime-owned target/window that prevents the final task-tab race during the fixed warm period. | + +Session IDs are globally unambiguous and immutable. Session records are scoped and authorized +through their Profile even though the ID is globally unique. Every lookup verifies the selected +Profile; a cross-Profile ID returns `SESSION_NOT_FOUND`. + +A local window belongs to at most one Session. A Session may own more than one window because +CDP can request a new window but cannot place a new target into a specified existing window. +Every tab, selected tab, popup, network capture, browser-run page, and page ID belongs to exactly +one Session. + +## CLI and agent experience + +`--session ` is a root selector alongside `--profile`: + +```bash +webcmd --profile work session create +webcmd --profile work session list +webcmd --profile work --session session_7d8f... browser run --stdin +webcmd --profile work --session session_7d8f... github issues +webcmd --profile work session close session_7d8f... +``` + +The existing positional raw-browser syntax is removed: + +```text +webcmd browser work run # invalid +``` + +It is not retained as a compatibility alias. The parser returns exit code 2 with the canonical +root form. A raw browser command without `--session` returns `SESSION_REQUIRED`, exit code 2, +and complete next actions: + +```text +error: SESSION_REQUIRED +help[2]: + webcmd --profile session create + webcmd --profile session list +``` + +The selector must be an opaque `session_...` ID. Friendly arbitrary strings are rejected as +usage errors rather than lazily creating a shared name. + +### `session create` + +`webcmd --profile session create` atomically inserts a new durable record and returns +at minimum its `id`, `kind`, and `runtimeState`. It does not launch a browser. Each successful +call creates a different ID, so concurrent agents cannot collide on a shared name. Agents call +it once per browser task and carry the returned ID in every later raw-browser invocation. + +### `session list` + +`webcmd --profile session list` is read-only and does not launch a browser or create +the adapter default. Its default row schema is intentionally small: + +```text +sessions[N]{id,kind,runtimeState,handoff}: +``` + +`kind` is `explicit` or `adapter-default`. Detail fields such as timestamps are available +through the existing field/format mechanisms. An empty list explicitly reports zero Sessions. + +Listing is how a human or agent resumes a known Session. “Attaching” means selecting one of +those IDs with `--session`; a separate bind command would only duplicate the selector. + +### `session close` + +`webcmd --profile session close ` closes only the selected Session's local +windows or hosted allocation, clears its selected-tab/runtime metadata, and releases its live +view and expired handoff metadata. It preserves the Session record so the ID can be reused later, at which point +a fresh window/allocation is opened with the Profile's persisted authentication state. Closing +an already-idle Session is a successful no-op. + +Before this design, “sessions” were implicit page-lease keys. Tabs were closed through browser +tab/close operations or adapter release, and the daemon/browser lifecycle eventually reclaimed +the runtime. There was no durable task-level runtime to close. The new command exists so agents +and humans can deliberately free a window or hosted allocation/quota slot; automatic idle and +shutdown cleanup still remains. + +`session close` fails with `SESSION_BUSY` while another execution owns the Session and with +`SESSION_PAUSED_FOR_HUMAN_HANDOFF` while a human controls it. It never steals either owner. + +### Tabs and binding + +Existing browser tab commands remain Session-scoped. `browser tabs`, `browser tabs select`, +`browser tabs close`, and `browser bind --page ` can see or mutate only tabs owned by the +explicit Session ID. `browser bind` binds a tab within that Session's own window group; it is +not a way to move a tab or Session across ownership boundaries. + +The adapter-default row is not an implicit raw-browser choice. Because it is still a real listed +Session, a caller may deliberately pass its ID to a raw command; doing so knowingly shares +admission and tabs with default-routed adapters. + +## Adapter routing + +Session resolution depends on whether the adapter actually needs browser state: + +| Invocation | Session behavior | +|---|---| +| Non-browser/API/local-tool adapter | Ignore omitted `--session`; create no Session, allocation, or lease. If an explicit ID is supplied, validate it but do not launch a browser. | +| Browser-backed adapter, explicit ID | Use that existing Session. Unknown IDs fail; no lazy explicit creation. | +| Browser-backed adapter, omitted ID | Resolve or lazily create the Profile's single system `adapter-default` Session. | +| Raw browser command | Require an existing explicit ID; never use the adapter default implicitly. | + +Within the resolved Session: + +- A persistent adapter tab is keyed by `(profileId, sessionId, site)` and is reused by later + persistent commands in that same Session. +- An ephemeral adapter command creates a tab in the Session's window group/allocation and closes + that tab when the command ends. It does not mint a Session or a window group of its own. +- Closing an ephemeral tab does not close the Session while other tabs exist. When it was the + final task tab, the Profile keeper/lifecycle rules below prevent #276. +- An explicit Session is the opt-in escape hatch when concurrent adapters would otherwise + collide on the adapter-default Session. + +Admission remains Session-wide, including across sites. This is an intentional safety tradeoff: +`github issues` and `linkedin posts` overlap only if they target the same Session. A caller that +needs them in parallel creates two Sessions. This avoids a site-keyed admission scheme that +would still let `browser run` race both site tabs. + +This routing removes today's `site::` Session creation for ephemeral adapters and +therefore avoids one OS window or hosted allocation per one-shot command. + +## Runtime architecture + +### Shared model + +```text +Profile authentication state +├── Session session_A +│ ├── local: exclusive window group +│ └── hosted: Browser Use allocation + live URL +├── Session session_B +│ ├── local: exclusive window group +│ └── hosted: Browser Use allocation + live URL +└── adapter-default Session (only after first browser-backed adapter without --session) +``` + +Sessions never share a visible local window, hosted allocation, live-view URL, selected tab, or +command lease. They intentionally share the Profile's persisted authentication state. + +### Local Cloak Profile context + +Local mode uses one persistent `BrowserContext` per Profile. This is the cookie/storage scope, +not the Session scope. Multiple Session window groups live inside it and therefore observe the +same Profile authentication changes. + +The first task page for a Session is created with CDP +`Target.createTarget({ newWindow: true })`. Webcmd records the target ID and its actual +`windowId` before making it public. + +Chromium has no CDP method that creates a target in a specified existing `windowId`, and its +window APIs cannot reparent targets. Therefore later tabs use this explicit policy: + +1. If the Session has an owned page, call `window.open` from that page under the existing short + per-Profile creation lock. The pinned background launcher includes + `--disable-popup-blocking` so this path is not dependent on a user gesture. +2. Inspect the created target's actual `windowId`. +3. If Chromium placed it in an already owned Session window, register it there. +4. If Chromium created a new window, add that window to the same Session's window group. +5. Never loop hoping for a particular window, never reparent, and never register a target in a + window owned by a sibling Session. + +The pinned-runtime live gate must prove both foreground and background paths. A failure blocks +the pinned runtime/launcher release; it does not weaken Session isolation. + +Site-created tabs and popups inherit ownership from a known opener. Pages without a known +opener remain unowned until a Session-scoped bind verifies that their window is either unowned +or already belongs to the selected Session. A manual cross-Session tab move returns +`SESSION_WINDOW_CONFLICT` without reassigning or closing the page. + +`selectedPageId` is stored per Session, never per Profile. Every page lookup accepts both +`sessionId` and `pageId`; a globally unique page ID is not authorization. The test that currently +allows a misleading Session/Profile to resolve `--page` is replaced with a cross-Session denial +test. + +### `browser run` isolation + +The QuickJS sandbox must not receive the raw Profile `BrowserContext`. Its Playwright transport +receives a Session-scoped context facade whose: + +- `pages()` returns only owned Session pages and never the keeper/anchor or sibling pages; +- `newPage()` routes through the Session window-creation policy above; +- page events are filtered to targets attributed to the Session; +- context/browser close and unrestricted browser-level CDP operations remain denied. + +The Session manager owns the one context-wide `page` listener. `browser run` no longer installs +a listener that labels every new context page with the command's Session. Runner startup +registers only the selected Session's existing pages. A one-line program in Session A therefore +cannot enumerate or close Session B's tab or the Profile keeper. + +Cookie APIs continue to act at Profile scope because Profile authentication is deliberately +shared. Documentation must make this boundary explicit: Sessions isolate browser workspaces, +not credentials. + +### Hosted Browser Use + +Hosted mode uses one Browser Use allocation per active Session. Every allocation is created +from the same Browser Use Profile ID but has its own CDP endpoint, tabs, live-view URL, timeout, +and lifecycle. The durable key is `(userId, workspaceId, profileId, sessionId)`; the current +one-allocation-per-Profile collapse is removed. + +New or restarted allocations load persisted Profile state. Webcmd does not promise live cookie +injection into a sibling allocation that is already running. Before release, a live gate starts +two allocations from one Profile, writes different-domain cookies and local storage, stops them +in both orders, and proves that a later allocation loads both markers. Failure returns the +architecture to design review; it does not trigger Webcmd-owned synchronization. + +Hosted adapter `/v1/execute` accepts an optional `session` field. Raw browser endpoints require +the Session ID in their existing route/command contract. The public manifest advertises Session +protocol capability so incompatible CLI/server pairs fail before browser work. + +Allocation-capacity failures use a dedicated structured `SESSION_CAPACITY_EXCEEDED` response, +not a generic provider error. It includes safe `active` and `limit` counts when known, whether +retrying after another Session closes can succeed, and help for `session list`, `session close`, +or plan upgrade. Provider internals, CDP URLs, and allocation IDs remain private. + +## Concurrency and execution admission + +- Different Profiles run concurrently. +- Different Sessions in one Profile run concurrently. +- A different overlapping top-level execution in the same Session fails immediately with + `SESSION_BUSY`; it does not wait in an invisible public queue. +- Nested operations from the same logical execution may re-enter admission and may use a small + defensive internal queue. +- The brief local target/window creation critical section remains per Profile. +- Hosted allocations require no cross-Session browser lock. + +Admission distinguishes logical executions, not sources, agents, processes, sites, or clients. +Every top-level browser-backed adapter invocation and every raw browser invocation receives a +unique trusted execution ID before its first daemon/provider operation. Two rapidly launched +commands from the same agent and PID have different IDs and conflict if they overlap; two truly +sequential commands do not. + +Local raw browser commands must enter `runWithDaemonRunContext`; merely widening +`isSessionLeaseCommand` without minting a `runId` is a no-op. The current `access` field is +removed from the lease predicate and daemon protocol because the sender hardcodes it to +`write`; it is not used to reason about safety. + +The local lease registry remains the implementation, re-keyed to `(profileId, sessionId)` and +broadened to all browser-backed commands. It also gains bounded owner recovery: + +1. CLI `SIGINT`/`SIGTERM` handlers send a best-effort cancel for the current run. +2. Daemon request disconnect/cancel aborts the tracked operation and releases admission only + after its `finally` settles. +3. If a new acquisition finds a recorded holder PID is dead, the daemon aborts that holder's + tracked work and waits a short bounded cleanup interval before retrying acquisition. It does + not allow overlapping work merely because the PID disappeared. +4. The existing 45-second heartbeat TTL remains only the final unknown-outcome recovery path. +5. Busy hints check PID liveness and never tell the caller to kill a PID already known dead. + +Hosted mode mints ownership at the trusted server execution boundary. Caller-supplied execution +IDs cannot impersonate an owner. Both providers release only after the top-level outcome and +cleanup are known. + +## Profile keeper, lifecycle, and issue #276 + +The Profile keeper is runtime-owned and never appears in Session tab APIs, selection, snapshots, +network capture, or `browser run`. + +On macOS with the pinned Chromium pair, the keeper is one hidden background CDP target created +with `Target.createTarget({ hidden: true, background: true })`. Its browser-level CDP session is +retained for the complete Profile runtime lifetime; detaching it would destroy the hidden +target. The target ID is filtered before Playwright page adoption or context-wide page events +can register it. + +A hidden target does not keep Chromium alive at zero windows on Linux and Windows. On those +platforms, when the final Session task page would close, Webcmd parks one final page as a +Profile-owned `about:blank` keeper window for the fixed warm period and minimizes it where the +platform supports that operation. It is excluded from public APIs. A user can close this +parking window; that only forfeits the warm runtime, and the next command launches a fresh one. +The design does not falsely claim an invisible zero-window keeper on those platforms. + +When the Profile has no task pages, active commands, or handoffs, Webcmd starts one fixed +60-second unreferenced idle timer. New work cancels it under the Profile lifecycle lock. + +If the timer fires, it acquires that same lock, rechecks the conditions, removes the exact +runtime from the reusable map, and closes the context before allowing relaunch. A command that +wins first reuses the runtime. A command that arrives after closing starts waits for closure and +then joins one single-flight relaunch. A closing runtime is never returned, and a late close +event from an old generation cannot invalidate its replacement. + +`freshPage` creates and registers the replacement before closing or parking the old page. +Keeper loss recreates or relaunches under the same lifecycle lock. If graceful close exceeds +three seconds, exact Profile recovery from #242 completes before the lock releases. + +`shutdown()` first marks the manager as shutting down, awaits every `profileLaunches` promise, +closes any runtime that completed during shutdown, and only then clears the maps. A launch may +not publish a runtime after shutdown begins. This prevents an invisible leaked browser process. + +Hosted allocations need no keeper because ending one Session cannot invalidate a sibling +allocation. + +## Pinned Cloak concurrency and issues #225/#242 + +Webcmd pins `cloakbrowser` `0.4.5`, Playwright Core `1.61.1`, and Chromium +`v145.0.7632.109.2`. Release evidence, not runtime licence probing, defines support. The live gate +must prove: + +- two Profile contexts with separate user-data directories launch and navigate concurrently; +- two Sessions in one Profile navigate concurrently in distinct exclusive window groups; +- additional tabs/popups remain owned and background creation does not steal human focus; +- the platform keeper strategy survives final-task-page close and immediate reuse; +- idle close versus concurrent arrival produces one clean replacement runtime; +- closing one Session leaves its sibling usable; +- foreground and background launch paths remain connected. + +There is no `CLOAK_CONCURRENCY_LIMIT` fallback. A failure blocks the pinned package/artifact. + +All discovery and teardown paths reuse one exact Cloak Profile matcher. It recognizes Cloak +browser commands, parses complete `--user-data-dir` arguments and supported spelling/quoting +forms, distinguishes `work` from `work-2`, and never terminates unrelated Chrome/Chromium. +Closing a Session never invokes Profile process teardown. + +## Human authentication handoff + +The existing `login -> human action -> returned whoami` protocol remains public. There are no +generic handoff, takeover, or complete commands. + +When login needs human action: + +1. Resolve the adapter's explicit or adapter-default immutable Session. +2. Mark only that Session human-controlled. +3. Local mode foregrounds one of its owned windows; hosted mode returns only its allocation's + live-view URL. +4. Return `action_required`, expiry, and a verify command containing the same Profile and + immutable Session ID. +5. Normal commands in that Session fail immediately with + `SESSION_PAUSED_FOR_HUMAN_HANDOFF`; they do not queue. +6. Only server-classified verification for the same site and Session may proceed. +7. Successful `whoami` or expiry clears the handoff. + +Sibling Sessions continue and never receive the handoff URL or pause error. Profile auth saved +by the human becomes available according to the local shared-context and hosted persisted- +Profile semantics already described. + +## Errors + +| Code | Meaning | +|---|---| +| `SESSION_REQUIRED` | A raw browser command omitted `--session`; exit 2 includes create/list help. | +| `SESSION_NOT_FOUND` | The opaque ID is unknown or does not belong to the selected Profile. | +| `SESSION_BUSY` | Another execution currently owns the selected Session. | +| `SESSION_PAUSED_FOR_HUMAN_HANDOFF` | A human controls the selected Session. | +| `SESSION_WINDOW_CONFLICT` | A local tab is in a window owned by another Session. | +| `SESSION_CAPACITY_EXCEEDED` | Hosted concurrent-allocation capacity is exhausted; response says whether close/wait or upgrade is actionable. | + +Errors are structured consistently across local and hosted modes. Usage mistakes exit 2; +temporary ownership/capacity errors use the existing temporary-failure convention. Errors +include safe Session and holder metadata but never internal execution IDs, tokens, CDP URLs, or +provider stack traces. + +## Documentation and skills + +The same release updates root/browser/session help, completions, README, active browser/auth +docs, harness setup guides, generated hints, hosted contracts, and bundled `webcmd-usage`, +`webcmd-browser`, `webcmd-autofix`, adapter-author, sitemap-author, and browser-sitemap skills. + +They must teach: + +- Profile = shared authentication; Session = task browser workspace/lock; tab = owned page. +- Raw agents run `session create` once, save the ID, and pass it to every browser command. +- `session list` resumes known IDs; passing the ID is attachment; no bind command exists. +- `session close` frees a window/allocation and is not tab close or record deletion. +- Learned adapter commands do not require ceremony: non-browser adapters allocate nothing and + browser-backed adapters use the adapter default unless explicitly isolated. +- Ephemeral/persistent describe tab lifetime, not Session creation. +- Separate agents use separate explicit Session IDs; same-Session overlap may return busy even + for one PID or one agent. +- Local Sessions are exclusive window groups; hosted Sessions consume separate allocations. +- Cookie state is Profile-shared, while pages, selection, live views, and handoffs are Session- + scoped. +- The returned immutable-ID verify command is authoritative and sibling Sessions continue. + +Active examples use opaque IDs, not caller-chosen names. Historical specs remain historical. + +## Verification + +Automated and live checks cover: + +1. `session create/list/close`, opaque-ID validation, Profile scoping, restart persistence, and + definitive empty/no-op output. +2. Raw omission returning `SESSION_REQUIRED`; adapters using explicit/default/no Session + according to browser need; positional syntax rejection. +3. No per-command Session/window explosion for ephemeral adapters; persistent site tabs remain + keyed inside the resolved Session. +4. Parallel Sessions and Profiles; immediate same-Session busy; same-run re-entry; same-agent + overlapping conflict; sequential success. +5. Signal/disconnect cancellation, dead-PID cleanup, no unsafe overlap, and TTL fallback. +6. Per-Session selected tab and strict Session checks on every `--page` action. +7. `browser run` seeing only owned pages, creating only owned pages, and never adopting sibling + or keeper targets through context-wide events. +8. First-window creation, same-window/new-window follow-up policy, popup inheritance, focus, and + non-destructive manual-move conflicts. +9. macOS hidden keeper and Linux/Windows parking keeper behavior, including accidental keeper + close and immediate relaunch. +10. Idle expiry/arrival race, `freshPage`, bounded close, late-generation events, and shutdown + awaiting in-flight launches. +11. Exact `work` versus `work-2` teardown and unrelated Chrome exclusion. +12. One hosted allocation/live view per Session, explicit capacity errors, and adapter + `/v1/execute` routing. +13. Same-Profile Browser Use persistence in both stop orders. +14. Session-scoped local/hosted handoff with a sibling continuing throughout. +15. Pinned Cloak concurrency gates for #225 and lifecycle coverage for #242/#276. +16. Help, generated surfaces, active docs, and skills teaching only the canonical contract. + +## Rollout + +This is one coordinated local/cloud contract change. The cloud advertises Session protocol v1; +the CLI refuses incompatible hosted browser work before execution. Rollout drains the old +Profile-keyed browser-worker revision, waits at least the existing 45-second lease TTL, applies +the Session schema, and then enables Session-keyed traffic. Legacy and Session-keyed browser +workers do not run concurrently. + +The CLI syntax break ships with targeted migration errors. There is no long-lived positional or +friendly-name compatibility shim. diff --git a/docs/superpowers/specs/2026-08-12-client-owned-web-fetch-design.md b/docs/superpowers/specs/2026-08-12-client-owned-web-fetch-design.md new file mode 100644 index 00000000..e79c2f14 --- /dev/null +++ b/docs/superpowers/specs/2026-08-12-client-owned-web-fetch-design.md @@ -0,0 +1,258 @@ +# Client-Owned Web Fetch Design + +**Date:** 2026-08-12 +**Status:** Approved + +## Goal + +Make `webcmd web fetch` one small, deterministic, non-browser command. It always +runs on the user's machine, including while Webcmd is configured for hosted +mode. It tries only native HTTP and two Impit TLS fingerprints. Browser work is +never an implicit fetch tier; an agent must create a Session and use the +existing `browser run` surface explicitly. + +## Decisions + +- `web fetch` is a core, client-owned command, not an adapter. +- It runs locally in both local and hosted configurations. +- It never opens Cloak, Chromium, Browser Use, a daemon connection, or a browser + Session. +- Its only network stages are native HTTP, Impit Chrome, and Impit Firefox. +- `web fetch-browser` is removed rather than deprecated or retained as an + alias. +- No `browser open` or other fetch-specific browser helper is added. +- Explicit browser fallback uses `session create`, `browser run`, and + `browser snapshot`. +- In hosted mode, those browser commands continue to execute in Webcmd Cloud + against Browser Use. + +## Non-goals + +- Rendering JavaScript inside `web fetch`. +- Automatically allocating a local or hosted browser. +- Automatically forwarding a failed fetch to Webcmd Cloud. +- Replacing the existing raw-browser command surface. +- Adding a second article-export or browser-download command. + +## Command surface + +`web fetch` keeps one registered argument definition: + +```text +webcmd web fetch --url + [--timeout ] + [--max-chars ] + [--allow-private ] + [-f|--format ] + [--trace ] +``` + +The command removes `--browser` and `--wait`. Because these options have not +shipped as part of an auto-escalating release, no compatibility alias or +deprecation period is needed. + +`web fetch-browser` is removed from command registration, manifests, help, +completion, documentation, skills, tests, and error hints. + +The core registry is the sole source of the command's arguments, help, output +formatting, and validation. There is no separate hand-written argv grammar. +Equals-form options and unknown-option rejection therefore behave like every +other Webcmd command. + +## Fetch ladder + +The command owns one deadline and one private-network policy across all stages: + +1. Native `fetch` through the safe proxy. +2. Impit with the Chrome fingerprint through the same safe proxy. +3. Impit with the Firefox fingerprint through the same safe proxy. + +A stage succeeds only when it returns usable content that is neither a +recognized challenge nor a JavaScript-only shell. A challenge or a recoverable +transport/TLS failure advances to the next stage while deadline remains. + +Argument errors, unsafe-address rejections, oversized bodies, and an exhausted +deadline fail immediately. They are not fingerprint-dependent and retrying +would only waste the shared budget. + +If a response is a JavaScript-only shell, the command returns +`FETCH_REQUIRES_BROWSER` immediately. Impit changes HTTP/TLS fingerprints; it +does not execute JavaScript, so trying its second fingerprint cannot make that +response usable. + +If all eligible stages remain challenged, the command returns `FETCH_BLOCKED`. +It never imports browser execution code in either error path. + +The result records which successful non-browser tier answered: + +```text +tier: plain +tier: impit +profile: chrome | firefox # Impit results only +``` + +`--timeout` remains one total deadline across the ladder. `--max-chars` and +`--allow-private` retain their current meanings and never need to cross into a +browser runtime. + +## Explicit browser fallback + +`FETCH_BLOCKED` and `FETCH_REQUIRES_BROWSER` return structured errors with a +concise next action: + +```text +Create a browser Session with `webcmd --profile session create`, then +navigate with `webcmd --profile --session browser run --stdin`. +``` + +Bundled skills and user-facing documentation include the complete workflow. +Agents create one opaque Session for the browser task, carry its exact ID, and +close it when finished: + +```bash +webcmd --profile work session create +# Copy the returned ID, for example: +# session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 + +webcmd --profile work \ + --session session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 \ + 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 \ + browser snapshot --snapshot-mode read + +webcmd --profile work session close \ + session_7d8f2c10-4a11-4f3e-9c22-1b6de0a91f45 +``` + +The error itself stays compact. The longer snippet belongs in `smart-search`, +the browser skill, and CLI documentation so routine error output does not grow +into a shell tutorial. + +## Local and hosted routing + +| Configuration | `web fetch` | Explicit browser commands | +|---|---|---| +| Local | Local native HTTP/Impit | Local Cloak runtime | +| Hosted | Local native HTTP/Impit | Webcmd Cloud and Browser Use | + +The manifest marks `web/fetch` as client-owned. It remains visible in local and +hosted help, list output, and completion, but it is excluded from Cloud's +server-executable command set. The hosted CLI dispatches it through the same +core command parser and renderer as local mode without reading user adapter +directories. + +`webcmd-cloud` must not load or execute `web/fetch`. The core-command loader and +embedded-executor mechanism proposed by `webcmd-cloud#33` are unnecessary for +this feature. The Cloud north-star documentation records `web fetch` as the +explicit client-owned exception to hosted server execution; raw browser +commands remain server-owned in hosted mode. + +`web/fetch-browser` is absent from the hosted contract. Cloud therefore has no +article-directory materialization or worker-stdout special case to support. + +## Packaging and discovery + +The npm package ships `src/fetch` through the existing compiled `dist/src` +tree. `cli-manifest.json` contains one core `web/fetch` entry using the package +export for its registration module. There is no `clis/web` adapter tree. + +An installed-tarball smoke test must prove that a fresh global installation: + +- lists `web/fetch` as built in; +- exposes it through root/site help and completion; +- runs `web fetch -h` successfully; +- imports its declared package export; and +- contains no `web/fetch-browser` command. + +## Errors + +The existing structured envelope and exit-code rules remain authoritative: + +- `ARGUMENT` for invalid URLs, values, or flags; +- the existing unsafe-address error for blocked private destinations; +- `TIMEOUT` when the shared deadline expires; +- `FETCH_BODY_TOO_LARGE` for the body limit; +- `FETCH_REQUIRES_BROWSER` for JavaScript-only content; and +- `FETCH_BLOCKED` after every eligible non-browser fingerprint remains + challenged. + +Only the last two errors suggest the explicit Session/browser workflow. +Timeouts, DNS failures, refused connections, policy failures, and oversized +bodies do not recommend a browser because a browser does not correct them +reliably. + +## Skills and documentation + +All bundled, user-facing skills and active documentation use the same model: + +1. Try `web fetch` once. +2. Treat its structured code, not message prose, as the escalation decision. +3. For `FETCH_BLOCKED` or `FETCH_REQUIRES_BROWSER`, create a Session. +4. Navigate with `browser run`. +5. Inspect with `browser snapshot --snapshot-mode read` or `act` as appropriate. +6. Continue browser interaction through `browser run` and close the Session + when finished. + +`smart-search` keeps its fetch-first budgets but counts explicit browser +Sessions rather than hidden browser-fetch tiers. The browser skill owns the +detailed Playwright example and Session lifecycle. Active README, CLI reference, +help text, error examples, generated docs, and skills contain no +`fetch-browser`, `web read`, or automatic-escalation instructions. + +Historical dated plans and specifications may retain historical command names +when rewriting them would falsify the record. They must not be linked as active +instructions, and current north-star documents must describe the approved +behavior. + +## Removal and reuse + +Delete fetch-browser-specific registration, browser extraction glue, and tests. +Before deleting shared article extraction or download utilities, inspect every +caller. Shared utilities used by other browser or adapter workflows remain; +dead utilities are removed rather than preserved for a speculative future +command. + +## Verification + +The implementation is complete only when tests prove: + +- exact native HTTP -> Chrome Impit -> Firefox Impit ordering for challenged + responses and eligible transport failures; +- immediate exit for policy, argument, body-size, and exhausted-deadline errors; +- early `FETCH_REQUIRES_BROWSER` for JavaScript-only shells; +- `FETCH_BLOCKED` after three challenged non-browser stages; +- one timeout budget across the ladder; +- no browser, daemon, Cloak, Browser Use, or command self-dispatch import during + `web fetch`; +- local execution of `web fetch` under both local and hosted configuration; +- identical help, formats, equals-form parsing, unknown-flag rejection, list, + and completion behavior in both configurations; +- `clientOwned` availability excludes `web/fetch` from Cloud execution while + retaining client discovery; +- a packed global installation loads the command from its package export; +- `web/fetch-browser` is absent from active code, generated contracts, + manifests, skills, and documentation; +- local browser commands still use Cloak; and +- hosted browser commands still use Browser Use with the explicit Session ID. + +The normal full unit/plugin suite, package checks, hosted-contract checks, and +Cloud compatibility tests remain required. Live gates should exercise one +local Session through Cloak and one hosted Session through Browser Use; neither +gate involves `web fetch` opening or allocating a browser. + +## Release coordination + +Revise `webcmd#295` to this design rather than merging its auto-escalating +implementation. Close or replace draft `webcmd-cloud#33`; Cloud must not load +these client-owned commands. Publish the Webcmd release only after the client, +generated contract, bundled skills, active docs, and Cloud compatibility checks +agree on the ownership boundary. + +Release notes call out the intentional removal of the previously advertised +but inconsistently shipped `web fetch-browser` command and direct agents to the +explicit Session plus `browser run` workflow.