From 115fa70b903ba2702eb63720fc009843488fd9ca Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 11 Aug 2026 15:42:08 +0530 Subject: [PATCH 1/9] docs: design profile session concurrency --- ...-11-profile-sessions-concurrency-design.md | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md 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..56fa0cc1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md @@ -0,0 +1,299 @@ +# Webcmd Profile Sessions and Concurrency Design + +**Status:** Approved design, pending implementation plan + +**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 task tabs or corrupting browser state. + +Webcmd already uses the word `session` in its browser runtime. This design keeps that name +and promotes it into the user-facing task boundary. It supersedes the task-space model in +`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): reliable concurrent Cloak profiles. +- [#242](https://github.com/agentrhq/webcmd/issues/242): exact profile process matching during teardown. +- [#276](https://github.com/agentrhq/webcmd/issues/276): closing the final leased tab must not invalidate a shared profile runtime. + +## Goals + +- Let different agents run concurrently in separate Sessions under one Profile. +- Share Profile cookies and authentication while isolating each Session's tabs and command state. +- Use one consistent Session selector for adapters and raw browser commands. +- Preserve Session identity across browser or daemon restarts without promising tab restoration. +- Make same-Session collisions and human handoffs explicit to agents. +- Give local Cloak and hosted Browser Use the same observable behavior. +- Update user documentation, CLI help, and bundled agent skills as part of the release. + +## Non-goals + +- Cookie isolation between Sessions; that remains a Profile responsibility. +- Arc-style spaces, tab groups, colors, themes, pinned tabs, or one window per task. +- Automatic tab restoration after a Profile runtime is restarted or evicted. +- `session create`, `session complete`, `session takeover`, or a process-global current Session. +- Cloak licence/capability detection or a degraded single-context mode. +- A general output-format migration; existing output defaults remain unchanged. + +## Concepts + +| Concept | Responsibility | +|---|---| +| Profile | Persistent browser identity: cookies, local storage, authentication, and Cloak/Browser Use profile data. | +| Profile runtime | One running browser context/allocation for a Profile. | +| Session | Persistent logical identity for one agent task under a Profile. Owns task tabs and command admission. | +| Tab | A Playwright `Page` owned by a Session. `Page` remains an implementation term. | +| Anchor tab | Local-only, Profile-owned blank tab that keeps a Cloak runtime alive. It is never exposed as a Session tab. | + +Session names are unique within a Profile. Immutable `session_...` IDs are globally +unambiguous. Sessions under the same Profile share authentication but cannot list, select, +bind, close, or otherwise act on each other's tabs. + +## CLI contract + +`--session ` is a root selector alongside `--profile`: + +```bash +webcmd --profile work --session invoice-audit github issues +webcmd --profile work --session invoice-audit browser run --stdin +webcmd --profile work session list +``` + +The existing positional raw-browser syntax is removed: + +```text +webcmd browser invoice-audit run # invalid +``` + +It is not retained as a compatibility alias. The parser returns a usage error with exit code +2 and the replacement command. Runtime help, completion, hosted argument routing, generated +hints, tests, documentation, and bundled skills must all use the root flag. + +### Resolution + +1. Resolve the Profile exactly as Webcmd does today. +2. If `--session` is omitted, resolve the reserved Session name `default` in that Profile. +3. If the selector is a known name, reuse its immutable ID. +4. If the selector is a missing name, lazily create it and return its ID. +5. If a `session_...` ID does not exist or does not belong to the selected Profile, return + `SESSION_NOT_FOUND`; never create an ID supplied by the caller. + +There is no PID-derived default and no process-global `session use` state. Parallel agents +must not overwrite an ambient selection. Explicit `session create` adds no value because +normal use already creates atomically and idempotently. + +### Listing and persistence + +`webcmd --profile session list` returns the Profile's Sessions with stable ID, +name, current runtime state, last activity, and handoff state. The default human format and +machine formats follow existing CLI output conventions. + +Session metadata persists locally in Webcmd state and remotely in the Cloud database. A +runtime restart or idle eviction preserves the Session record but discards its owned-tab and +selected-tab state. The next command creates a fresh owned tab while the Profile supplies +the preserved login state. + +Session records are small and are not automatically deleted in v1. A delete/complete command +can be added if real usage shows that accumulated records are a problem. + +## Runtime architecture + +### Shared invariant + +Both providers implement this hierarchy: + +```text +Profile +└── Profile runtime (shared authentication) + ├── Session default + │ └── owned tabs + ├── Session invoice-audit + │ └── owned tabs + └── Session research + └── owned tabs +``` + +Ending or idling a Session releases only its runtime tab resources. It must not end the +Profile runtime. Only Profile eviction, daemon shutdown, an explicit Profile shutdown, or an +unrecoverable browser disconnect ends the Profile runtime. + +### Local Cloak + +Local mode uses one persistent `BrowserContext` per Profile. The Session manager partitions +tracked tabs and selected-tab state by immutable Session ID. + +The current command queue is keyed only by Profile. It will be re-keyed by Profile and +Session so different Sessions can execute concurrently. The existing per-Profile page- +creation lock remains because creating/adopting tabs mutates shared context state; it covers +only that short critical section. + +### Hosted Browser Use + +Hosted mode uses one Browser Use browser allocation per Profile and partitions logical tabs, +active-tab state, and command admission by Session ID. The existing Profile allocation is +not duplicated per Session because doing so would lose immediate cookie sharing and spend +unnecessary browser infrastructure. + +The current in-process Profile/session-name lock and persistent Profile-wide database lease +must use the immutable Session ID. A Profile remains the allocation key; a Session becomes +the write-admission key. + +### Adapter routing + +The user-selected Session and adapter `siteSession` are different concerns: + +- `--session` chooses the agent task and its tab boundary. +- `siteSession: persistent|ephemeral` remains an adapter tab-lifecycle policy. + +A persistent adapter tab is keyed by `(profileId, sessionId, site)`. An ephemeral adapter +command creates a new tab within the selected Session and closes it when the command ends. +Raw browser commands act on that Session's selected owned tab. Tab IDs may be globally +unique, but every operation must still verify Session ownership. + +## Concurrency and admission + +- Different Sessions under one Profile execute concurrently. +- Different Profiles execute concurrently. +- Independently submitted commands targeting a busy Session fail immediately with + `SESSION_BUSY`; they do not wait in an invisible public queue. +- Commands belonging to the same logical execution may use the existing defensive internal + queue. +- Brief shared operations such as page creation remain serialized by the Profile's existing + critical-section lock. +- Human handoff pause takes precedence over Session admission. + +The existing local `SessionLeaseRegistry` and hosted persistent write-lease mechanism should +be extended and re-keyed rather than replaced. `SESSION_BUSY` includes the Session ID/name +and safe holder metadata, uses the existing temporary-failure exit-code convention, and is +retryable by the caller. + +## Local runtime anchor and issue #276 + +Cloak's initial clean `about:blank` tab becomes a Profile runtime anchor; if launch provides +none, Webcmd creates one. It is never leased to a Session. The first Session command always +creates a separate user tab. + +The anchor: + +- Is stored separately from the Session tab map. +- Has no public page ID and never appears in tab listing, selection, snapshots, or network capture. +- Survives Session release, explicit tab close, `freshPage`, and Session tab idle expiry. +- Is closed only when the Profile runtime is intentionally ended or disconnected. +- Is recreated under the existing per-Profile page-creation lock if it is unexpectedly closed + while the context remains healthy, before Webcmd closes the final leased tab. + +`freshPage` creates and registers its replacement before closing the previous leased tab. +This removes the transient zero-user-tab interval and ensures failure leaves the old tab +available. Recovery retries remain a fallback for genuine browser disconnects, not the +primary lifecycle mechanism. + +## Pinned Cloak concurrency and issue #225 + +Webcmd pins and distributes a tested Cloak package/browser artifact pair. Concurrency is a +supported-runtime guarantee, not a runtime capability negotiated from licence state. + +A candidate pair cannot be released unless it passes live gates for: + +- Two persistent Profile contexts launched concurrently with separate user-data directories. +- Two Sessions in one Profile navigating concurrently in separate tabs. +- The macOS foreground/background launch paths used by Webcmd remaining connected through navigation. +- Closing Session tabs without closing the Profile runtime. + +There is no `CLOAK_CONCURRENCY_LIMIT` product branch for the supported pinned runtime. A +failure is a Webcmd runtime/launcher defect to fix before release. + +## Exact Profile teardown and issue #242 + +All process discovery and teardown paths must reuse one exact Cloak Profile matcher. The +matcher must: + +- Recognize only Cloak browser commands. +- Match `--user-data-dir` as a complete argument, including its accepted spelling variants. +- Never treat Profile `work` as matching `work-2`. +- Never terminate unrelated Chrome/Chromium processes. + +The safe matcher already used by locked-profile recovery should become the shared path for +background teardown and recovery. Closing a Session never invokes Profile process teardown. + +## Human authentication handoff + +The existing `login` -> human action -> `whoami` protocol remains the public workflow. No +generic handoff, takeover, or complete commands are added. + +When a hosted login needs human action: + +1. Record the initiating immutable Session ID and pause browser work for that Profile. +2. Return `action_required`, the live-view URL, expiry, and a verify command that includes + the same `--profile` and immutable `--session` selectors. +3. Other browser commands under the Profile fail immediately with + `PROFILE_PAUSED_FOR_HUMAN_HANDOFF`; they do not queue. +4. Only the initiating Session's verification command may use the browser while paused. +5. Successful `whoami` releases the pause. Expiry also releases it. + +The pause is Profile-wide because Browser Use live view exposes the shared browser. The error +payload tells other agents that a human handoff is active and includes Profile ID, initiating +Session ID, and expiry, but does not disclose the initiator's live-view URL. Other Profiles +and non-browser commands continue normally. + +Local handoff keeps its visible-browser workflow. It uses the same Session-bound verify +command and Session tab-ownership checks, but does not add the hosted live-view pause. + +## Errors + +| Code | Meaning | +|---|---| +| `SESSION_NOT_FOUND` | An immutable Session ID is unknown or belongs to another Profile. | +| `SESSION_BUSY` | Another execution currently owns command admission for the selected Session. | +| `PROFILE_PAUSED_FOR_HUMAN_HANDOFF` | A human controls the Profile through another Session's hosted handoff. | + +These are structured errors in local and hosted modes with consistent exit codes and safe +metadata. They must not be collapsed into generic browser-closed, timeout, or HTTP errors. + +## Agent and user documentation + +The feature is incomplete until agents and users can discover and use it correctly. The same +release updates: + +- Root and browser help, completion output, examples, and targeted migration errors. +- README and active browser/auth documentation. +- Bundled `webcmd-usage`, `webcmd-browser`, `webcmd-autofix`, adapter-author, sitemap-author, + and browser-sitemap skills where they select browser state or explain handoff. +- Agent harness setup guides that show Webcmd browser commands. +- Generated command hints and auth `verify_command` output. +- Hosted help/contract examples and release notes. + +Documentation must explain Profile versus Session versus tab, lazy/default Session behavior, +how separate agents choose separate Sessions, how to list Sessions, `SESSION_BUSY`, and how a +Profile-wide human handoff affects sibling Sessions. Examples use immutable IDs when resuming +an auth handoff and friendly names for normal task selection. + +Historical design documents remain historical. This specification explicitly supersedes the +old Spaces decision; active documentation must not teach Spaces or positional browser syntax. + +## Verification + +Focused automated and live checks must cover: + +1. Root `--session` parsing for adapters and browser commands, plus rejection of positional syntax. +2. Lazy name creation, reserved `default`, immutable-ID lookup, Profile scoping, listing, and restart persistence. +3. Parallel wall-clock execution for two Sessions in one Profile and for two Profiles. +4. Immediate `SESSION_BUSY` for concurrent independent commands in one Session. +5. Session-scoped list/select/bind/close behavior, including popup registration. +6. Persistent adapter tab separation by Session and unchanged `siteSession` lifecycle behavior. +7. Hosted Profile-wide handoff pause, safe sibling error, successful verification, and expiry recovery. +8. Anchor exclusion and repeated release/close/`freshPage`/idle-expiry -> immediate create -> navigate cycles. +9. Intentional Profile eviction and daemon shutdown closing the context and anchor. +10. Exact Profile teardown for `work` versus `work-2` and unrelated Chrome processes. +11. Live release gates for the pinned Cloak concurrency contract. +12. Help, generated hints, bundled skill examples, and active docs containing only canonical syntax. + +## Rollout + +This is one coordinated local/cloud contract change. The hosted protocol advertises the new +Session capability so incompatible CLI/server pairs fail before browser work. The release is +a clean CLI syntax break with a targeted migration error; there is no long-lived positional +compatibility shim. From b9efc59ea667ae25d2456c073779241fdd4d8401 Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 11 Aug 2026 16:31:05 +0530 Subject: [PATCH 2/9] docs: make sessions browser workspaces --- ...-11-profile-sessions-concurrency-design.md | 226 +++++++++++------- 1 file changed, 135 insertions(+), 91 deletions(-) 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 index 56fa0cc1..3813b67f 100644 --- a/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md +++ b/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md @@ -22,19 +22,21 @@ This design also addresses: ## Goals -- Let different agents run concurrently in separate Sessions under one Profile. -- Share Profile cookies and authentication while isolating each Session's tabs and command state. +- Let different agents run concurrently in separate Session browser workspaces under one Profile. +- Make a local Session a Cloak window group and a hosted Session a Browser Use allocation. +- Reuse Profile authentication while isolating each Session's tabs, live view, and runtime lifecycle. - Use one consistent Session selector for adapters and raw browser commands. - Preserve Session identity across browser or daemon restarts without promising tab restoration. - Make same-Session collisions and human handoffs explicit to agents. -- Give local Cloak and hosted Browser Use the same observable behavior. +- Give local Cloak and hosted Browser Use the same Session selection, ownership, and handoff behavior. - Update user documentation, CLI help, and bundled agent skills as part of the release. ## Non-goals -- Cookie isolation between Sessions; that remains a Profile responsibility. -- Arc-style spaces, tab groups, colors, themes, pinned tabs, or one window per task. +- Cookie isolation between Sessions; Profile authentication is intentionally reusable. +- Arc-style spaces, tab groups, colors, themes, or pinned tabs. - Automatic tab restoration after a Profile runtime is restarted or evicted. +- Live injection of newly changed hosted cookies into already-running Browser Use allocations. - `session create`, `session complete`, `session takeover`, or a process-global current Session. - Cloak licence/capability detection or a degraded single-context mode. - A general output-format migration; existing output defaults remain unchanged. @@ -43,15 +45,15 @@ This design also addresses: | Concept | Responsibility | |---|---| -| Profile | Persistent browser identity: cookies, local storage, authentication, and Cloak/Browser Use profile data. | -| Profile runtime | One running browser context/allocation for a Profile. | -| Session | Persistent logical identity for one agent task under a Profile. Owns task tabs and command admission. | +| Profile | Persistent authentication state: cookies, local storage, and Cloak/Browser Use profile data. | +| Local Profile runtime | One persistent Cloak browser context for a Profile, shared by its Session windows. | +| Session | Persistent logical identity for one agent task. Owns a local window group or one hosted allocation, plus its tabs and command admission. | | Tab | A Playwright `Page` owned by a Session. `Page` remains an implementation term. | -| Anchor tab | Local-only, Profile-owned blank tab that keeps a Cloak runtime alive. It is never exposed as a Session tab. | +| Anchor target | Local-only, hidden Profile-owned CDP target that keeps Cloak alive with no visible Session windows. | Session names are unique within a Profile. Immutable `session_...` IDs are globally -unambiguous. Sessions under the same Profile share authentication but cannot list, select, -bind, close, or otherwise act on each other's tabs. +unambiguous. A browser window never mixes tabs from different Sessions. Sessions under the +same Profile cannot list, select, bind, close, or otherwise act on each other's tabs. ## CLI contract @@ -93,9 +95,9 @@ name, current runtime state, last activity, and handoff state. The default human machine formats follow existing CLI output conventions. Session metadata persists locally in Webcmd state and remotely in the Cloud database. A -runtime restart or idle eviction preserves the Session record but discards its owned-tab and -selected-tab state. The next command creates a fresh owned tab while the Profile supplies -the preserved login state. +runtime restart or idle eviction preserves the Session record but discards its owned-window, +owned-tab, selected-tab, and hosted-allocation state. The next command opens a fresh local +window or hosted allocation using the Profile's persisted authentication state. Session records are small and are not automatically deleted in v1. A delete/complete command can be added if real usage shows that accumulated records are a problem. @@ -104,43 +106,63 @@ can be added if real usage shows that accumulated records are a problem. ### Shared invariant -Both providers implement this hierarchy: +Both providers make Session the running browser-workspace boundary, but use their native +isolation primitive: ```text -Profile -└── Profile runtime (shared authentication) - ├── Session default - │ └── owned tabs - ├── Session invoice-audit - │ └── owned tabs - └── Session research - └── owned tabs +Local Profile context Hosted Browser Use Profile +├── hidden anchor target ├── Session invoice-audit allocation +├── Session invoice-audit window(s) │ └── owned tabs + live URL +│ └── owned tabs └── Session research allocation +└── Session research window(s) └── owned tabs + live URL + └── owned tabs ``` -Ending or idling a Session releases only its runtime tab resources. It must not end the -Profile runtime. Only Profile eviction, daemon shutdown, an explicit Profile shutdown, or an -unrecoverable browser disconnect ends the Profile runtime. +Ending or idling a Session closes its local window group or hosted allocation but preserves +the Session record and Profile authentication state. Sessions never share a visible window, +hosted allocation, live-view URL, selected tab, or command lock. ### Local Cloak -Local mode uses one persistent `BrowserContext` per Profile. The Session manager partitions -tracked tabs and selected-tab state by immutable Session ID. +Local mode uses one persistent `BrowserContext` per Profile so cookies and browser storage +remain live-shared. A Session's first page is created with CDP +`Target.createTarget({ newWindow: true })`; its `windowId`, targets, tabs, and selected tab are +registered under the immutable Session ID. + +Later tabs are created in that Session's window under the existing short per-Profile page- +creation lock. Webcmd verifies the resulting `windowId` before registering the tab. Site- +created tabs and popup windows inherit their opener's Session. A Session may therefore own a +primary window and child popup windows, but no window may contain targets from two Sessions. +Webcmd never adopts a target whose window ownership conflicts with its Session. +Manual tab moves between Session windows are unsupported. If Webcmd detects one, it leaves +the tab untouched and returns `SESSION_WINDOW_CONFLICT`; it never reassigns or closes the tab. The current command queue is keyed only by Profile. It will be re-keyed by Profile and -Session so different Sessions can execute concurrently. The existing per-Profile page- -creation lock remains because creating/adopting tabs mutates shared context state; it covers -only that short critical section. +Session so different Session windows execute concurrently. Profile eviction or daemon +shutdown closes the whole context; closing a Session closes only its owned window targets. ### Hosted Browser Use -Hosted mode uses one Browser Use browser allocation per Profile and partitions logical tabs, -active-tab state, and command admission by Session ID. The existing Profile allocation is -not duplicated per Session because doing so would lose immediate cookie sharing and spend -unnecessary browser infrastructure. +Hosted mode uses one Browser Use browser allocation per 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 allocation key becomes +`(userId, workspaceId, profileId, sessionId)` and the current one-allocation-per-Profile +constraint is removed. + +Browser Use Profiles persist cookies and local storage across browsers. A new or restarted +Session loads that state. Webcmd does not promise that a cookie changed in one allocation is +injected live into another already-running allocation. Browser Use's concurrent same-Profile +save/merge behavior must pass the release gate below; Webcmd will not add its own cookie or +storage synchronization layer. -The current in-process Profile/session-name lock and persistent Profile-wide database lease -must use the immutable Session ID. A Profile remains the allocation key; a Session becomes -the write-admission key. +This model consumes one hosted browser allocation per active Session. That cost and provider +concurrency usage are intentional consequences of clean Session isolation. + +Before release, a live Browser Use gate must start two allocations concurrently from one +Profile, write different-domain cookies and local storage in each, stop them in both orders, +and prove that a later allocation loads both markers. It must also prove that a handoff in one +allocation leaves the sibling allocation usable. Failure blocks the release and returns this +architecture to design review; it does not trigger a Webcmd-owned cookie-sync subsystem. ### Adapter routing @@ -150,9 +172,10 @@ The user-selected Session and adapter `siteSession` are different concerns: - `siteSession: persistent|ephemeral` remains an adapter tab-lifecycle policy. A persistent adapter tab is keyed by `(profileId, sessionId, site)`. An ephemeral adapter -command creates a new tab within the selected Session and closes it when the command ends. -Raw browser commands act on that Session's selected owned tab. Tab IDs may be globally -unique, but every operation must still verify Session ownership. +command creates a tab within the Session's local window group or hosted allocation and closes +it when the command ends. Raw browser commands act on that Session's selected owned tab. Tab +IDs may be globally unique, but every operation must still verify Session and window/allocation +ownership. ## Concurrency and admission @@ -162,34 +185,37 @@ unique, but every operation must still verify Session ownership. `SESSION_BUSY`; they do not wait in an invisible public queue. - Commands belonging to the same logical execution may use the existing defensive internal queue. -- Brief shared operations such as page creation remain serialized by the Profile's existing - critical-section lock. -- Human handoff pause takes precedence over Session admission. +- Brief local window/tab placement remains serialized by the Profile's existing critical- + section lock; hosted Sessions need no cross-Session browser lock. +- A human handoff blocks normal automation only in its owning Session. The existing local `SessionLeaseRegistry` and hosted persistent write-lease mechanism should -be extended and re-keyed rather than replaced. `SESSION_BUSY` includes the Session ID/name -and safe holder metadata, uses the existing temporary-failure exit-code convention, and is -retryable by the caller. +be extended and re-keyed by immutable Session ID rather than replaced. `SESSION_BUSY` +includes the Session ID/name and safe holder metadata, uses the existing temporary-failure +exit-code convention, and is retryable by the caller. -## Local runtime anchor and issue #276 +## Hidden local anchor and issue #276 -Cloak's initial clean `about:blank` tab becomes a Profile runtime anchor; if launch provides -none, Webcmd creates one. It is never leased to a Session. The first Session command always -creates a separate user tab. +Each local Profile runtime owns one hidden `about:blank` CDP target created with +`Target.createTarget({ hidden: true, background: true })`. Its browser-level CDP session +remains open for the runtime's lifetime. The hidden target keeps Cloak connected when no +visible Session windows exist without adding a blank window or tab-strip entry. -The anchor: +The anchor target: -- Is stored separately from the Session tab map. -- Has no public page ID and never appears in tab listing, selection, snapshots, or network capture. -- Survives Session release, explicit tab close, `freshPage`, and Session tab idle expiry. -- Is closed only when the Profile runtime is intentionally ended or disconnected. -- Is recreated under the existing per-Profile page-creation lock if it is unexpectedly closed - while the context remains healthy, before Webcmd closes the final leased tab. +- Is stored separately from every Session window and tab map. +- Has no public page ID, is never registered as a Playwright Session page, and never appears + in tab listing, selection, snapshots, or network capture. +- Survives Session window close, release, `freshPage`, and Session idle expiry. +- Is recreated under the existing per-Profile creation lock if unexpectedly destroyed while + the context remains healthy. +- Is closed only when the Profile runtime is intentionally evicted, disconnected, or shut down. -`freshPage` creates and registers its replacement before closing the previous leased tab. -This removes the transient zero-user-tab interval and ensures failure leaves the old tab -available. Recovery retries remain a fallback for genuine browser disconnects, not the -primary lifecycle mechanism. +`freshPage` creates and registers its replacement in the same Session window before closing +the previous tab. Closing the final visible Session window therefore leaves only the hidden +anchor, never a stale runtime awaiting an asynchronous close event. Hosted allocations need +no anchor because ending one Session intentionally stops that allocation and cannot affect a +sibling allocation. ## Pinned Cloak concurrency and issue #225 @@ -199,12 +225,17 @@ supported-runtime guarantee, not a runtime capability negotiated from licence st A candidate pair cannot be released unless it passes live gates for: - Two persistent Profile contexts launched concurrently with separate user-data directories. -- Two Sessions in one Profile navigating concurrently in separate tabs. +- Two Sessions in one Profile navigating concurrently in distinct OS windows. +- Additional tabs and popups remaining inside their owning Session's window group. +- Background window/tab creation not stealing focus from a human-controlled Session window. +- A hidden anchor keeping the Profile connected with zero visible Session windows, followed + by successful creation of a new Session window. - The macOS foreground/background launch paths used by Webcmd remaining connected through navigation. -- Closing Session tabs without closing the Profile runtime. +- Closing one Session window without affecting sibling windows or the Profile runtime. There is no `CLOAK_CONCURRENCY_LIMIT` product branch for the supported pinned runtime. A -failure is a Webcmd runtime/launcher defect to fix before release. +failure, including focus theft during background tab placement, blocks release until the +runtime or launcher is fixed; Webcmd does not fall back to Profile-wide serialization. ## Exact Profile teardown and issue #242 @@ -224,23 +255,22 @@ background teardown and recovery. Closing a Session never invokes Profile proces The existing `login` -> human action -> `whoami` protocol remains the public workflow. No generic handoff, takeover, or complete commands are added. -When a hosted login needs human action: - -1. Record the initiating immutable Session ID and pause browser work for that Profile. -2. Return `action_required`, the live-view URL, expiry, and a verify command that includes - the same `--profile` and immutable `--session` selectors. -3. Other browser commands under the Profile fail immediately with - `PROFILE_PAUSED_FOR_HUMAN_HANDOFF`; they do not queue. -4. Only the initiating Session's verification command may use the browser while paused. -5. Successful `whoami` releases the pause. Expiry also releases it. +When a login needs human action: -The pause is Profile-wide because Browser Use live view exposes the shared browser. The error -payload tells other agents that a human handoff is active and includes Profile ID, initiating -Session ID, and expiry, but does not disclose the initiator's live-view URL. Other Profiles -and non-browser commands continue normally. +1. Mark the initiating immutable Session as human-controlled. +2. Local mode foregrounds that Session's Cloak window. Hosted mode returns that Session + allocation's live-view URL. +3. Return `action_required`, expiry, and a verify command containing the same `--profile` + and immutable `--session` selectors. +4. Normal browser commands targeting that Session fail immediately with + `SESSION_PAUSED_FOR_HUMAN_HANDOFF`; they do not queue. +5. Only that Session's verification command may automate the browser while human-controlled. +6. Successful `whoami` or handoff expiry releases human control. -Local handoff keeps its visible-browser workflow. It uses the same Session-bound verify -command and Session tab-ownership checks, but does not add the hosted live-view pause. +Sibling Sessions under the same Profile continue normally in their own local windows or +hosted allocations, including when they are working on different sites. They receive neither +the handoff URL nor a pause error. Authentication persisted to the Profile becomes available +to future or restarted hosted allocations according to the Profile semantics above. ## Errors @@ -248,7 +278,8 @@ command and Session tab-ownership checks, but does not add the hosted live-view |---|---| | `SESSION_NOT_FOUND` | An immutable Session ID is unknown or belongs to another Profile. | | `SESSION_BUSY` | Another execution currently owns command admission for the selected Session. | -| `PROFILE_PAUSED_FOR_HUMAN_HANDOFF` | A human controls the Profile through another Session's hosted handoff. | +| `SESSION_PAUSED_FOR_HUMAN_HANDOFF` | A human controls the selected Session during authentication handoff. | +| `SESSION_WINDOW_CONFLICT` | A local tab was manually moved into a window owned by another Session. | These are structured errors in local and hosted modes with consistent exit codes and safe metadata. They must not be collapsed into generic browser-closed, timeout, or HTTP errors. @@ -268,8 +299,11 @@ release updates: Documentation must explain Profile versus Session versus tab, lazy/default Session behavior, how separate agents choose separate Sessions, how to list Sessions, `SESSION_BUSY`, and how a -Profile-wide human handoff affects sibling Sessions. Examples use immutable IDs when resuming -an auth handoff and friendly names for normal task selection. +Session-scoped human handoff leaves sibling Sessions running. It must also explain that local +Sessions appear as separate Cloak windows, hosted Sessions consume separate Browser Use +allocations, and already-running hosted allocations do not receive live cookie injection. +Examples use immutable IDs when resuming an auth handoff and friendly names for normal task +selection. Historical design documents remain historical. This specification explicitly supersedes the old Spaces decision; active documentation must not teach Spaces or positional browser syntax. @@ -282,14 +316,24 @@ Focused automated and live checks must cover: 2. Lazy name creation, reserved `default`, immutable-ID lookup, Profile scoping, listing, and restart persistence. 3. Parallel wall-clock execution for two Sessions in one Profile and for two Profiles. 4. Immediate `SESSION_BUSY` for concurrent independent commands in one Session. -5. Session-scoped list/select/bind/close behavior, including popup registration. -6. Persistent adapter tab separation by Session and unchanged `siteSession` lifecycle behavior. -7. Hosted Profile-wide handoff pause, safe sibling error, successful verification, and expiry recovery. -8. Anchor exclusion and repeated release/close/`freshPage`/idle-expiry -> immediate create -> navigate cycles. -9. Intentional Profile eviction and daemon shutdown closing the context and anchor. -10. Exact Profile teardown for `work` versus `work-2` and unrelated Chrome processes. -11. Live release gates for the pinned Cloak concurrency contract. -12. Help, generated hints, bundled skill examples, and active docs containing only canonical syntax. +5. Distinct local `windowId` ownership, same-Session tab placement, popup inheritance, and no + cross-Session target adoption, including a non-destructive error after a manual tab move. +6. Session-scoped list/select/bind/close behavior and persistent adapter separation with + unchanged `siteSession` lifecycle behavior. +7. A hidden anchor absent from all public surfaces while zero visible windows -> immediate + Session window creation remains reliable. +8. Repeated release/close/`freshPage`/idle-expiry cycles without target-closed errors. +9. Intentional Profile eviction and daemon shutdown closing every Session window, the context, + and the hidden anchor. +10. One hosted Browser Use allocation and distinct live-view URL per active Session. +11. Concurrent hosted allocations from one Browser Use Profile preserving different-domain + cookies and local storage after both stop, regardless of stop order, and a later allocation + loading both markers. +12. Session-scoped local and hosted handoff, successful verification, expiry recovery, and a + sibling Session continuing throughout. +13. Exact Profile teardown for `work` versus `work-2` and unrelated Chrome processes. +14. Live release gates for the pinned Cloak concurrency contract. +15. Help, generated hints, bundled skill examples, and active docs containing only canonical syntax. ## Rollout From 61618df9fa660c0ec209a6209ae5540dee558a17 Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 11 Aug 2026 18:05:07 +0530 Subject: [PATCH 3/9] docs: define profile idle lifecycle --- ...-11-profile-sessions-concurrency-design.md | 76 +++++++++++++------ 1 file changed, 52 insertions(+), 24 deletions(-) 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 index 3813b67f..bce46433 100644 --- a/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md +++ b/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md @@ -38,6 +38,7 @@ This design also addresses: - Automatic tab restoration after a Profile runtime is restarted or evicted. - Live injection of newly changed hosted cookies into already-running Browser Use allocations. - `session create`, `session complete`, `session takeover`, or a process-global current Session. +- A user-configurable local Profile warm period; v1 uses a fixed 60 seconds. - Cloak licence/capability detection or a degraded single-context mode. - A general output-format migration; existing output defaults remain unchanged. @@ -120,12 +121,14 @@ Local Profile context Hosted Browser Use Profile Ending or idling a Session closes its local window group or hosted allocation but preserves the Session record and Profile authentication state. Sessions never share a visible window, -hosted allocation, live-view URL, selected tab, or command lock. +hosted allocation, live-view URL, selected tab, or command lock. A local Profile with no +active Session windows remains warm for 60 seconds before its runtime is closed. ### Local Cloak Local mode uses one persistent `BrowserContext` per Profile so cookies and browser storage -remain live-shared. A Session's first page is created with CDP +remain live-shared. Runtime launch creates the hidden Profile anchor before publishing the +runtime for Session use. A Session's first page is then created with CDP `Target.createTarget({ newWindow: true })`; its `windowId`, targets, tabs, and selected tab are registered under the immutable Session ID. @@ -138,8 +141,9 @@ Manual tab moves between Session windows are unsupported. If Webcmd detects one, the tab untouched and returns `SESSION_WINDOW_CONFLICT`; it never reassigns or closes the tab. The current command queue is keyed only by Profile. It will be re-keyed by Profile and -Session so different Session windows execute concurrently. Profile eviction or daemon -shutdown closes the whole context; closing a Session closes only its owned window targets. +Session so different Session windows execute concurrently. The fixed local Profile idle +expiry or daemon shutdown closes the whole context; closing a Session closes only its owned +window targets. ### Hosted Browser Use @@ -187,6 +191,7 @@ ownership. queue. - Brief local window/tab placement remains serialized by the Profile's existing critical- section lock; hosted Sessions need no cross-Session browser lock. +- Local Profile launch, idle shutdown, and anchor recovery use that same per-Profile lock. - A human handoff blocks normal automation only in its owning Session. The existing local `SessionLeaseRegistry` and hosted persistent write-lease mechanism should @@ -196,10 +201,11 @@ exit-code convention, and is retryable by the caller. ## Hidden local anchor and issue #276 -Each local Profile runtime owns one hidden `about:blank` CDP target created with -`Target.createTarget({ hidden: true, background: true })`. Its browser-level CDP session -remains open for the runtime's lifetime. The hidden target keeps Cloak connected when no -visible Session windows exist without adding a blank window or tab-strip entry. +Each local Profile runtime creates one hidden `about:blank` CDP target with +`Target.createTarget({ hidden: true, background: true })` before the runtime enters the +reusable Profile map. Its browser-level CDP session remains open for the runtime's lifetime. +The hidden target keeps Cloak connected when no visible Session windows exist without adding +a blank window or tab-strip entry. The anchor target: @@ -209,13 +215,29 @@ The anchor target: - Survives Session window close, release, `freshPage`, and Session idle expiry. - Is recreated under the existing per-Profile creation lock if unexpectedly destroyed while the context remains healthy. -- Is closed only when the Profile runtime is intentionally evicted, disconnected, or shut down. +- Is not visible or closable through normal Cloak or Webcmd tab UI; low-level CDP clients can + observe and explicitly close it, after which Webcmd recreates it if the context is healthy. +- Is closed with its context after local Profile idle expiry, daemon shutdown, explicit + Profile teardown, or an unrecoverable disconnect. + +When the last local Session window closes and the Profile has no running command or human +handoff, Webcmd starts one fixed 60-second, unreferenced idle timer. New local browser work +cancels the timer under the per-Profile lifecycle lock and reuses the warm runtime. + +If the timer fires, it acquires that same lock, rechecks the idle conditions, removes the +runtime from the reusable Profile map, and then closes the entire context while still holding +the lock. A command that arrives first cancels eviction; a command that arrives after shutdown +starts waits briefly on the lock and launches a new runtime after closure completes. Multiple +arriving commands share the existing single-flight launch. A closing runtime is never returned +to a command, and a late close event from an old runtime cannot invalidate its replacement. +If graceful close exceeds three seconds, the exact Profile recovery path from #242 +finishes teardown before relaunch; the old runtime is never put back in the reusable map. `freshPage` creates and registers its replacement in the same Session window before closing the previous tab. Closing the final visible Session window therefore leaves only the hidden -anchor, never a stale runtime awaiting an asynchronous close event. Hosted allocations need -no anchor because ending one Session intentionally stops that allocation and cannot affect a -sibling allocation. +anchor during the warm period, never a stale runtime awaiting an asynchronous close event. +Hosted allocations need no anchor or Profile timer because ending one Session intentionally +stops that allocation and cannot affect a sibling allocation. ## Pinned Cloak concurrency and issue #225 @@ -230,6 +252,8 @@ A candidate pair cannot be released unless it passes live gates for: - Background window/tab creation not stealing focus from a human-controlled Session window. - A hidden anchor keeping the Profile connected with zero visible Session windows, followed by successful creation of a new Session window. +- Profile idle shutdown and concurrent arrival using one lifecycle lock without returning a + closing context. - The macOS foreground/background launch paths used by Webcmd remaining connected through navigation. - Closing one Session window without affecting sibling windows or the Profile runtime. @@ -301,9 +325,9 @@ Documentation must explain Profile versus Session versus tab, lazy/default Sessi how separate agents choose separate Sessions, how to list Sessions, `SESSION_BUSY`, and how a Session-scoped human handoff leaves sibling Sessions running. It must also explain that local Sessions appear as separate Cloak windows, hosted Sessions consume separate Browser Use -allocations, and already-running hosted allocations do not receive live cookie injection. -Examples use immutable IDs when resuming an auth handoff and friendly names for normal task -selection. +allocations, a windowless local Profile remains warm behind an invisible anchor for 60 seconds, +and already-running hosted allocations do not receive live cookie injection. Examples use +immutable IDs when resuming an auth handoff and friendly names for normal task selection. Historical design documents remain historical. This specification explicitly supersedes the old Spaces decision; active documentation must not teach Spaces or positional browser syntax. @@ -322,18 +346,22 @@ Focused automated and live checks must cover: unchanged `siteSession` lifecycle behavior. 7. A hidden anchor absent from all public surfaces while zero visible windows -> immediate Session window creation remains reliable. -8. Repeated release/close/`freshPage`/idle-expiry cycles without target-closed errors. -9. Intentional Profile eviction and daemon shutdown closing every Session window, the context, - and the hidden anchor. -10. One hosted Browser Use allocation and distinct live-view URL per active Session. -11. Concurrent hosted allocations from one Browser Use Profile preserving different-domain +8. The 60-second Profile timer starting only at zero Session windows, remaining unreferenced, + and being cancelled by new work or a handoff. +9. Commands winning just before idle expiry reusing the runtime, and commands arriving during + shutdown waiting for one close and single-flight relaunch without target-closed errors. +10. Repeated release/close/`freshPage`/idle-expiry cycles without target-closed errors. +11. Idle expiry and daemon shutdown closing every Session window, the context, and the hidden + anchor; bounded failed close uses exact Profile recovery before relaunch. +12. One hosted Browser Use allocation and distinct live-view URL per active Session. +13. Concurrent hosted allocations from one Browser Use Profile preserving different-domain cookies and local storage after both stop, regardless of stop order, and a later allocation loading both markers. -12. Session-scoped local and hosted handoff, successful verification, expiry recovery, and a +14. Session-scoped local and hosted handoff, successful verification, expiry recovery, and a sibling Session continuing throughout. -13. Exact Profile teardown for `work` versus `work-2` and unrelated Chrome processes. -14. Live release gates for the pinned Cloak concurrency contract. -15. Help, generated hints, bundled skill examples, and active docs containing only canonical syntax. +15. Exact Profile teardown for `work` versus `work-2` and unrelated Chrome processes. +16. Live release gates for the pinned Cloak concurrency contract. +17. Help, generated hints, bundled skill examples, and active docs containing only canonical syntax. ## Rollout From cf339d0fcc10255c22a099aaac3111bd72d59436 Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 11 Aug 2026 18:14:27 +0530 Subject: [PATCH 4/9] docs: define session execution admission --- ...-11-profile-sessions-concurrency-design.md | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) 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 index bce46433..dc65b90e 100644 --- a/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md +++ b/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md @@ -185,18 +185,32 @@ ownership. - Different Sessions under one Profile execute concurrently. - Different Profiles execute concurrently. -- Independently submitted commands targeting a busy Session fail immediately with - `SESSION_BUSY`; they do not wait in an invisible public queue. -- Commands belonging to the same logical execution may use the existing defensive internal - queue. +- A top-level execution targeting a Session already owned by another execution fails + immediately with `SESSION_BUSY`; it does not wait in an invisible public queue. +- Operations belonging to the owning execution may re-enter its admission lease and use the + existing defensive internal queue. - Brief local window/tab placement remains serialized by the Profile's existing critical- section lock; hosted Sessions need no cross-Session browser lock. - Local Profile launch, idle shutdown, and anchor recovery use that same per-Profile lock. - A human handoff blocks normal automation only in its owning Session. +Admission distinguishes logical executions, not agents, processes, or clients. Every top-level +browser-backed CLI invocation or hosted request receives a unique execution ID before its first +browser operation. All nested operations belonging to that invocation carry the same ID. Two +commands from the same agent or PID still conflict if their execution IDs overlap in one +Session; two commands issued sequentially do not conflict after the first releases admission. +PID and command name are diagnostic holder metadata only. + +The local CLI reuses its existing `runId` across one invocation's daemon operations. Hosted +mode mints the admission ID at the trusted server execution boundary and maps idempotent retries +of the same request back to that execution; a caller-supplied ID cannot impersonate a current +holder. The execution ID is released when the top-level command reaches a known outcome. The +existing unknown-outcome TTL behavior remains the recovery path when completion is uncertain. + The existing local `SessionLeaseRegistry` and hosted persistent write-lease mechanism should -be extended and re-keyed by immutable Session ID rather than replaced. `SESSION_BUSY` -includes the Session ID/name and safe holder metadata, uses the existing temporary-failure +be extended from persistent adapter writes to all browser-backed commands and re-keyed by +immutable Session ID rather than replaced. `SESSION_BUSY` includes the Session ID/name and +safe holder metadata, never the internal execution ID, uses the existing temporary-failure exit-code convention, and is retryable by the caller. ## Hidden local anchor and issue #276 @@ -326,8 +340,10 @@ how separate agents choose separate Sessions, how to list Sessions, `SESSION_BUS Session-scoped human handoff leaves sibling Sessions running. It must also explain that local Sessions appear as separate Cloak windows, hosted Sessions consume separate Browser Use allocations, a windowless local Profile remains warm behind an invisible anchor for 60 seconds, -and already-running hosted allocations do not receive live cookie injection. Examples use -immutable IDs when resuming an auth handoff and friendly names for normal task selection. +and already-running hosted allocations do not receive live cookie injection. Same-agent +commands launched concurrently against one Session may receive `SESSION_BUSY`; sequential +commands do not. Examples use immutable IDs when resuming an auth handoff and friendly names +for normal task selection. Historical design documents remain historical. This specification explicitly supersedes the old Spaces decision; active documentation must not teach Spaces or positional browser syntax. @@ -339,7 +355,9 @@ Focused automated and live checks must cover: 1. Root `--session` parsing for adapters and browser commands, plus rejection of positional syntax. 2. Lazy name creation, reserved `default`, immutable-ID lookup, Profile scoping, listing, and restart persistence. 3. Parallel wall-clock execution for two Sessions in one Profile and for two Profiles. -4. Immediate `SESSION_BUSY` for concurrent independent commands in one Session. +4. One execution ID re-entering a Session across nested operations; a different ID receiving + immediate `SESSION_BUSY` even with the same agent/PID; sequential executions succeeding; + hosted callers unable to spoof the current holder's ID. 5. Distinct local `windowId` ownership, same-Session tab placement, popup inheritance, and no cross-Session target adoption, including a non-destructive error after a manual tab move. 6. Session-scoped list/select/bind/close behavior and persistent adapter separation with From a41843148abbad6261a92fbb66b278eccf6cba54 Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 11 Aug 2026 18:36:12 +0530 Subject: [PATCH 5/9] docs: plan profile sessions and concurrency --- ...2026-08-11-profile-sessions-concurrency.md | 1909 +++++++++++++++++ 1 file changed, 1909 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md 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..2ad7689d --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md @@ -0,0 +1,1909 @@ +# 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 a Webcmd Session the deterministic browser-workspace and command-admission boundary in local and hosted modes, so agents can run concurrently under one authenticated Profile without sharing windows, allocations, tabs, live views, or handoffs. + +**Architecture:** The local daemon atomically resolves a friendly Session selector to a persisted immutable ID, admits one top-level execution per Session, and maps that Session to a Cloak window group inside the Profile's single persistent context. Webcmd Cloud stores the same Session identity in PostgreSQL and keys Browser Use allocations and admission leases by `(userId, workspaceId, profileId, sessionId)`; both modes retain existing adapter `siteSession` behavior inside the selected Session. + +**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.159, 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.159. 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. Omission resolves the reserved name `default`; no PID default, ambient `session use`, explicit create, takeover, complete, or positional compatibility alias is allowed. +- A missing friendly name is created atomically. An unknown or cross-Profile `session_...` selector returns `SESSION_NOT_FOUND` and is never created. +- 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. Manual cross-window tab moves return `SESSION_WINDOW_CONFLICT` without moving or closing the tab. +- 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. +- Hosted mode uses one Browser Use allocation and one live-view URL per active Session. 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 use canonical root syntax in the same release. Historical specs and plans 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 root selector and expose `session list`. +- `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 Session records, lazy/default resolution, handoff metadata, 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`: top-level run identity, 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`: Session window groups, owned tabs, hidden anchor, 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 Session record and selector 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`: accept/resolve `session`, 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 Session Selector and Syntax Break + +**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`. +- 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 name-or-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', 'invoice-audit', 'github', 'issues', +])).toEqual({ + kind: 'dispatch', + argv: ['github', 'issues'], + profile: 'work', + session: 'invoice-audit', + literal: false, +}); + +expect(() => rejectPositionalBrowserSessionArgv(['browser', 'invoice-audit', 'run', '--stdin'])) + .toThrowError(/webcmd --session invoice-audit browser run --stdin/); + +expect(rejectPositionalBrowserSessionArgv(['--session', 'invoice-audit', 'browser', 'run', '--stdin'])) + .toEqual(['--session', 'invoice-audit', 'browser', 'run', '--stdin']); +``` + +For hosted transport, assert both surfaces carry the same selector: + +```ts +expect(executeRequest.body).toMatchObject({ + command: 'github/issues', + session: 'invoice-audit', +}); +expect(browserRequest.pathname).toBe('/v1/browser/invoice-audit/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 = 'Agent task session name or immutable session ID'; + +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 invoice-audit 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`. For raw browser commands, choose `normalized.session ?? 'default'` and continue using the existing encoded path segment. Do not accept a browser-namespace selector. + +- [ ] **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, and root syntax works for adapters and browser commands. + +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: Persist and Resolve Local Session Identity + +**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 `resolve(profileId, selector?): BrowserSessionRecord`, `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.resolve('profile_work', 'invoice-audit'); +expect(created).toMatchObject({ + id: 'session_11111111-1111-4111-8111-111111111111', + profileId: 'profile_work', + name: 'invoice-audit', +}); +expect(store.resolve('profile_work', 'invoice-audit').id).toBe(created.id); +expect(new LocalBrowserSessionStore({ baseDir }).resolve('profile_work', created.id).name) + .toBe('invoice-audit'); +expect(() => store.resolve('profile_other', created.id)).toThrowError( + expect.objectContaining({ code: 'SESSION_NOT_FOUND' }), +); +expect(store.resolve('profile_work').name).toBe('default'); +``` + +Also assert `list` does not create `default`, 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; + name: string; + 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()}`. Resolution is: + +```ts +const value = selector?.trim() || 'default'; +if (value.startsWith('session_')) { + const found = sessions.find(row => row.id === value && row.profileId === profileId); + if (!found) throw new SessionNotFoundError(value, profileId); + return touch(found); +} +const found = sessions.find(row => row.profileId === profileId && row.name === value); +return found ? touch(found) : create(profileId, value); +``` + +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 +resolveSession(command: BrowserRuntimeCommand): Promise; +listSessions(input: { profileId?: string }): Promise; +``` + +At `/command`, resolve once, then dispatch an enriched copy: + +```ts +const session = await provider.resolveSession(body); +const command = { ...body, sessionId: session.id, sessionName: session.name }; +``` + +Do not resolve `lease-release` or Session-list/status controls. Add Profile-filtered `sessions` to `/status`; local `runtimeState` is `active` only when the manager owns an open visible tab/window for that immutable ID. + +- [ ] **Step 5: Add `webcmd --profile work session list`** + +Register `session list` in `cli.ts`, read the root Profile selector through the existing Profile resolution, and render columns: + +```ts +['id', 'name', 'runtimeState', 'lastUsedAt', 'handoff'] +``` + +When the daemon is absent, read the persisted store and report every row as `idle`; listing must not launch Cloak or create `default`. Use the existing `render` function for `table`, `json`, `yaml`, `md`, and `csv` 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; a new store instance resolves the same immutable ID, cross-Profile IDs fail, 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/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`. + +- [ ] **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); +``` + +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. + +- [ ] **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' + && typeof command.sessionId === 'string' + && command.sessionId.length > 0 + && typeof command.runId === 'string' + && command.runId.length > 0; +} +``` + +Acquire after Session resolution and before `provider.dispatch`. On conflict return HTTP 409 with `{ code: 'SESSION_BUSY', session: { id, name }, 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. + +Hosted callers are unrelated here; do not accept a CLI flag or environment variable as `runId`. + +- [ ] **Step 5: Separate selected Session from adapter tab lifecycle** + +Delete `resolveAdapterBrowserSession(cmd, siteSession)` as a source of the user Session. Pass root `options.session` to `BrowserPage`; after daemon resolution, use: + +```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 `SessionNotFoundError` (exit 66), enhance `SessionBusyError` with safe Session ID/name metadata (exit 75), add `SessionPausedForHumanHandoffError` (exit 77), and `SessionWindowConflictError` (exit 75). Map the same uppercase daemon codes in `daemon-client.ts`. + +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, and different Sessions progress concurrently. + +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/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: Give Each Local Session an Owned Cloak Window Group + +**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` +- 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` + +**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. + +- [ ] **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` and assert it has `session_a`'s existing `windowId`, not `session_b`'s. Emit a popup with `opener() === first.page` and assert it inherits `session_a`, including when it has a child popup window. + +Simulate a manual move by returning `session_b`'s window ID for an `session_a` target. Assert `list`, `select`, `bind`, and `close` reject with `SESSION_WINDOW_CONFLICT`, the page remains open, and neither ownership map changes. + +- [ ] **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, `context.newPage()` does not establish distinct windows, and actions can see cross-Session tabs. + +- [ ] **Step 3: Replace Profile-global page ownership with Session runtimes** + +Use these internal shapes in `session-manager.ts`: + +```ts +interface SessionRuntime { + id: string; + name: 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. Later tabs use `window.open('about:blank', '_blank')` from an open owned page while the Profile creation lock is held, then verify the new target's `windowId` before registration. + +- [ ] **Step 4: Register popups and verify ownership before every public action** + +Listen to `context.on('page')`; resolve `await page.opener()`, copy the opener's immutable Session ID, then verify/register the popup target and `windowId`. If a page has no known opener, leave it unadopted until an explicit bind verifies that its window is unowned or owned by the selected Session. + +All methods must take `sessionId`: `listPages`, `findPageById`, `selectPage`, `bindPage`, `closePage`, `newPage`, and `release`. 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: Preserve fresh-page and adapter semantics inside the window group** + +For `freshPage`, open and register the replacement in the same Session window first, update the selected and canonical adapter tab entries, then close the old target. `release` closes only ephemeral entries for that Session. Closing the last tab closes that Session's visible window targets but leaves sibling Session windows and the Profile context intact. + +- [ ] **Step 6: 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 +npm run typecheck +``` + +Expected: PASS; two Sessions receive distinct OS window IDs, tabs/popups stay in their owner, 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/session-manager.test.ts src/browser/runtime/local-cloak/browser-run.test.ts src/browser/runtime/local-cloak/provider.test.ts +git diff --cached --check +git commit -m "feat: isolate local sessions by cloak window" +``` + +--- + +### Task 5: Add the Hidden Anchor, Warm Profile 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 one hidden Profile-owned `anchorTargetId` and 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. + +- [ ] **Step 1: Add anchor and lifecycle race tests with fake timers** + +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. + +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. + +- [ ] **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 hidden anchor/timer exists, close and launch are separate paths, and the matcher accepts only a substring form. + +- [ ] **Step 4: Create and maintain the hidden anchor** + +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, +}); +``` + +If the pinned runtime returns `null` from `context.browser()` or rejects the hidden target, fail launch and let the live gate block the release; do not fall back to a visible page. Store the anchor outside Session/page maps. Filter its `targetId` from all page events and public lists. If `Target.targetDestroyed` reports the anchor while the context is healthy, recreate it under the Profile lifecycle lock. + +- [ ] **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 so an old event cannot delete a replacement. + +- [ ] **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, zero-visible-window anchor reuse, shutdown cleanup, 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: Store and Resolve 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.resolve(tenant, profile, selector?)`, and `list`. +- Extends `CloudRepository` with `getSession`, `getOrCreateSession`, `listSessions`, `touchSession`, `setSessionHandoff`, and `clearSessionHandoff`. +- Produces `GET /v1/sessions?profile=` returning persisted metadata plus runtime state derived from Browser allocations. + +- [ ] **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, + name: 'invoice-audit', + createdAt: '2026-08-11T00:00:00.000Z', + updatedAt: '2026-08-11T00:00:00.000Z', + lastUsedAt: '2026-08-11T00:00:00.000Z', +}; +``` + +Assert two concurrent `getOrCreateSession` calls for the same `(tenant, profileId, name)` return one ID; the same name in another Profile differs; omitted selector resolves `default`; unknown/cross-Profile immutable IDs throw `SESSION_NOT_FOUND`; listing does not create a row; 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, + name: 'invoice-audit', + 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, + name text not null, + 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, name), + 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 +); +``` + +Add a guarded `0010_profile_sessions` block for existing databases that creates the same table 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 GetOrCreateSessionInput { + tenant: TenantContext; + profileId: string; + id: string; + name: string; +} +``` + +PostgreSQL must use `insert ... on conflict (user_id, workspace_id, profile_id, name) do update set last_used_at = excluded.last_used_at returning *`; the Profile foreign key validates tenancy. The in-memory repository uses `JSON.stringify([userId, workspaceId, profileId, name])` 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 resolver service** + +Use the Profile service before Session resolution. `HostedSessionService` accepts an `idFactory` defaulting to `session_${randomUUID()}` and implements exactly: + +```ts +async resolve(tenant: TenantContext, profile: WebcmdProfile, selector?: string): Promise { + const value = selector?.trim() || 'default'; + if (value.startsWith('session_')) { + const found = await this.repository.getSession(tenant, profile.id, value); + if (!found) throw new PublicHttpError(404, 'SESSION_NOT_FOUND', + `Session ${value} was not found in Profile ${profile.displayName}.`, undefined, 66); + return found; + } + return (await this.repository.getOrCreateSession({ + tenant, profileId: profile.id, id: this.idFactory(), name: value, + })).session; +} +``` + +Clear expired handoff metadata when resolving/listing. Do not create an explicit create endpoint. + +- [ ] **Step 6: Add the list endpoint and verify** + +`GET /v1/sessions` authenticates the tenant, resolves the requested/default Profile, lists rows, and joins active `browser_allocations` in memory by `session_key === session.id`. 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; friendly creation is atomic 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. + +- [ ] **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. + +- [ ] **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 name/ID 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. + +- [ ] **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 `session?: string` from `/v1/execute` and raw-browser path selectors, then resolves them with `HostedSessionService`. +- 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. + +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. Resolve Profile, then Session, before calling adapter/auth browser code. For prepared executions, persist no caller ownership token: reuse the existing queued execution ID only after `startQueuedExecution` succeeds. + +For raw browser paths, treat the encoded segment as a selector, resolve it, and pass both `session.id` and `session.name` to `HostedBrowserController`. Its execution records and public run output use the immutable ID for ownership and may include the friendly name for display. + +- [ ] **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, and parallel across siblings. + +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/name 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.listSessions(profile?)` and hosted `webcmd --profile work session list` 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', 'work-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', name: 'invoice-audit' }), +]); +``` + +Assert completion includes root `--session` and `session list`, 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 `HostedSessionsResponse` validator and `listSessions(profile?)`, then route `session list` through it and existing renderer. Replace completion/help templates with: + +```ts +`${CLI_COMMAND} [--profile ] [--session ] browser [args] [options]` +``` + +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.159`. +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 and leaves the Profile connected behind the hidden anchor at zero visible windows. +6. A new Session opens during the warm period; another opens during forced idle close and receives one clean replacement runtime. +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 the supported macOS release host, the live suite PASSes every listed invariant. 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 examples use friendly names for normal work and immutable Session IDs for handoff verification. + +- [ ] **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 --session work browser run --stdin'); +expect(browserSkill).toContain('webcmd --profile work session list'); +expect(browserSkill).toMatch(/different agents[\s\S]*different Sessions/i); +expect(usageSkill).toMatch(/SESSION_BUSY[\s\S]*same 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 = persistent login state (cookies and browser storage). +Session = one agent task's browser workspace and command lock. +Tab = a page owned by that Session. +``` + +Document: + +- omitted selector lazily uses `default`; a new friendly name is lazily created; +- `webcmd --profile work session list` lists stable IDs and state; +- parallel agents choose distinct names, for example `--session invoice-audit` and `--session research`; +- local Sessions are distinct Cloak windows inside one Profile context; hosted Sessions consume distinct Browser Use allocations; +- local zero-window Profiles remain warm behind an invisible anchor for 60 seconds; +- 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; +- a handoff pauses only its Session and the returned immutable-ID `verify_command` is authoritative. + +Use canonical examples: + +```bash +webcmd --profile work --session invoice-audit github issues +webcmd --profile work --session invoice-audit browser run --stdin +webcmd --profile work session list +``` + +- [ ] **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_NOT_FOUND`, `SESSION_BUSY`, `SESSION_PAUSED_FOR_HUMAN_HANDOFF`, and `SESSION_WINDOW_CONFLICT` as structured runtime states, not adapter breakage. 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 friendly creation returns one row; 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, run two shells against one Profile: + +```bash +webcmd --profile release-work --session agent-a 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 agent-b 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 first two heredocs in separate shells so their 15-second holds overlap. Expected: distinct local windows or hosted live views, both commands progress, and list returns two stable IDs. During that hold, launch the same `agent-a` command from a third shell; expected `SESSION_BUSY` while `agent-b` remains usable. Run a login in `agent-a`; expected its returned verify command contains `--session session_...`, `agent-a` pauses, and `agent-b` continues. + +- [ ] **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, lazy/default resolution, immutable IDs, listing | Tasks 1-2, 7, 12 | +| Parallel Sessions, immediate same-Session busy, same-run re-entry | Tasks 3, 8-9 | +| Local Session windows, tab/popup ownership, manual-move error | Task 4 | +| Issue #276 anchor and close/arrival race | 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 | +| 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 | From 343607c90590366445835aa1b291dea7959c70db Mon Sep 17 00:00:00 2001 From: beubax Date: Tue, 11 Aug 2026 23:45:27 +0530 Subject: [PATCH 6/9] docs: finalize explicit browser session design --- ...2026-08-11-profile-sessions-concurrency.md | 444 +++++++---- ...-11-profile-sessions-concurrency-design.md | 750 ++++++++++-------- 2 files changed, 729 insertions(+), 465 deletions(-) diff --git a/docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md b/docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md index 2ad7689d..e42c5307 100644 --- a/docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md +++ b/docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md @@ -2,9 +2,9 @@ > **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 a Webcmd Session the deterministic browser-workspace and command-admission boundary in local and hosted modes, so agents can run concurrently under one authenticated Profile without sharing windows, allocations, tabs, live views, or handoffs. +**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:** The local daemon atomically resolves a friendly Session selector to a persisted immutable ID, admits one top-level execution per Session, and maps that Session to a Cloak window group inside the Profile's single persistent context. Webcmd Cloud stores the same Session identity in PostgreSQL and keys Browser Use allocations and admission leases by `(userId, workspaceId, profileId, sessionId)`; both modes retain existing adapter `siteSession` behavior inside the selected Session. +**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.159, Vitest, PostgreSQL, Browser Use, GCP release gates. @@ -15,17 +15,20 @@ - 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.159. 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. Omission resolves the reserved name `default`; no PID default, ambient `session use`, explicit create, takeover, complete, or positional compatibility alias is allowed. -- A missing friendly name is created atomically. An unknown or cross-Profile `session_...` selector returns `SESSION_NOT_FOUND` and is never created. +- `--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. Manual cross-window tab moves return `SESSION_WINDOW_CONFLICT` without moving or closing the tab. +- 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. -- Hosted mode uses one Browser Use allocation and one live-view URL per active Session. Do not implement Webcmd-owned cookie/storage synchronization. +- 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 use canonical root syntax in the same release. Historical specs and plans remain historical. +- 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. --- @@ -35,20 +38,20 @@ - `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 root selector and expose `session list`. +- `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 Session records, lazy/default resolution, handoff metadata, and public list rows. +- `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`: top-level run identity, 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`: Session window groups, owned tabs, hidden anchor, Profile idle lifecycle, and Session-scoped actions. +- `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 Session record and selector resolution. +- `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`: accept/resolve `session`, mint trusted execution IDs, and route adapters. +- `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. @@ -62,7 +65,7 @@ --- -### Task 1: Canonical Root Session Selector and Syntax Break +### Task 1: Canonical Root Selector and Raw-Browser Requirement **Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` @@ -87,8 +90,9 @@ **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 name-or-ID; this task does not create Session records. +- 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** @@ -96,20 +100,27 @@ Add these central assertions and update every existing positional fixture in the ```ts expect(parseHostedRootCommandSurface([ - '--profile', 'work', '--session', 'invoice-audit', 'github', 'issues', + '--profile', 'work', '--session', 'session_a', 'github', 'issues', ])).toEqual({ kind: 'dispatch', argv: ['github', 'issues'], profile: 'work', - session: 'invoice-audit', + session: 'session_a', literal: false, }); -expect(() => rejectPositionalBrowserSessionArgv(['browser', 'invoice-audit', 'run', '--stdin'])) - .toThrowError(/webcmd --session invoice-audit browser run --stdin/); +expect(() => rejectPositionalBrowserSessionArgv(['browser', 'session_a', 'run', '--stdin'])) + .toThrowError(/webcmd --session session_a browser run --stdin/); -expect(rejectPositionalBrowserSessionArgv(['--session', 'invoice-audit', 'browser', 'run', '--stdin'])) - .toEqual(['--session', 'invoice-audit', '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: @@ -117,9 +128,9 @@ For hosted transport, assert both surfaces carry the same selector: ```ts expect(executeRequest.body).toMatchObject({ command: 'github/issues', - session: 'invoice-audit', + session: 'session_a', }); -expect(browserRequest.pathname).toBe('/v1/browser/invoice-audit/commands'); +expect(browserRequest.pathname).toBe('/v1/browser/session_a/commands'); ``` - [ ] **Step 2: Run the focused tests and confirm the old grammar still wins** @@ -137,8 +148,8 @@ Expected: FAIL because root parsing omits `session`, positional browser argv is Use the shared root surface as the sole source of truth: ```ts -export const ROOT_SESSION_FLAGS = '--session '; -export const ROOT_SESSION_DESCRIPTION = 'Agent task session name or immutable session ID'; +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 @@ -153,7 +164,7 @@ Extend `HostedRootCommandSurface` dispatch results with `session?: string`; make - [ ] **Step 4: Replace rewriting with a targeted detector** -Keep `BROWSER_SUBCOMMAND_NAMES`, because it distinguishes `browser run` from the retired `browser invoice-audit run`. Replace only `rewriteBrowserArgv`: +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[] { @@ -189,7 +200,7 @@ In `commanderAdapter.ts`, pass the root global without interpreting it: : {}), ``` -Add `session?: string` to `HostedClient.execute` and `runPreparedExecution`, their JSON validators/types, and `dispatchHosted`. For raw browser commands, choose `normalized.session ?? 'default'` and continue using the existing encoded path segment. Do not accept a browser-namespace selector. +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** @@ -200,7 +211,7 @@ npx vitest run --project unit src/hosted/root-command-surface.test.ts src/cli-ar npm run typecheck ``` -Expected: PASS; positional syntax exits 2 with the canonical replacement, and root syntax works for adapters and browser commands. +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: @@ -212,7 +223,7 @@ git commit -m "feat: make session a root cli selector" --- -### Task 2: Persist and Resolve Local Session Identity +### Task 2: Create, Persist, List, and Close Local Sessions **Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` @@ -232,7 +243,7 @@ git commit -m "feat: make session a root cli selector" **Interfaces:** - Produces `BrowserSessionRecord`, `BrowserSessionListRow`, and `LocalBrowserSessionStore`. -- Produces `resolve(profileId, selector?): BrowserSessionRecord`, `list(profileId)`, `markHandoff`, `clearHandoff`, and `touch`. +- 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** @@ -246,22 +257,24 @@ const store = new LocalBrowserSessionStore({ idFactory: () => 'session_11111111-1111-4111-8111-111111111111', }); -const created = store.resolve('profile_work', 'invoice-audit'); +const created = store.create('profile_work'); expect(created).toMatchObject({ id: 'session_11111111-1111-4111-8111-111111111111', profileId: 'profile_work', - name: 'invoice-audit', + kind: 'explicit', }); -expect(store.resolve('profile_work', 'invoice-audit').id).toBe(created.id); -expect(new LocalBrowserSessionStore({ baseDir }).resolve('profile_work', created.id).name) - .toBe('invoice-audit'); -expect(() => store.resolve('profile_other', created.id)).toThrowError( +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' }), ); -expect(store.resolve('profile_work').name).toBe('default'); +const adapterDefault = store.resolveAdapterDefault('profile_work'); +expect(adapterDefault.kind).toBe('adapter-default'); +expect(store.resolveAdapterDefault('profile_work').id).toBe(adapterDefault.id); ``` -Also assert `list` does not create `default`, malformed JSON fails closed with a useful configuration error, the state file is mode `0600`, and a temp file is renamed over the destination. +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** @@ -281,7 +294,7 @@ Use this exact record shape: export interface BrowserSessionRecord { id: string; profileId: string; - name: string; + kind: 'explicit' | 'adapter-default'; createdAt: string; updatedAt: string; lastUsedAt: string; @@ -293,19 +306,20 @@ export interface BrowserSessionListRow extends BrowserSessionRecord { } ``` -Persist `{ version: 1, sessions: BrowserSessionRecord[] }` at `path.join(baseDir ?? getWebcmdConfigDir(), 'browser-sessions.json')`. Generate IDs with `session_${randomUUID()}`. Resolution is: +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 -const value = selector?.trim() || 'default'; -if (value.startsWith('session_')) { - const found = sessions.find(row => row.id === value && row.profileId === profileId); - if (!found) throw new SessionNotFoundError(value, profileId); - return touch(found); -} -const found = sessions.find(row => row.profileId === profileId && row.name === value); -return found ? touch(found) : create(profileId, value); +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** @@ -313,28 +327,31 @@ Write JSON to `....tmp` with mode `0600`, then `renameSync` Extend the provider contract: ```ts -resolveSession(command: BrowserRuntimeCommand): Promise; +requireSession(command: BrowserRuntimeCommand): Promise; +resolveAdapterDefault(command: BrowserRuntimeCommand): Promise; listSessions(input: { profileId?: string }): Promise; ``` -At `/command`, resolve once, then dispatch an enriched copy: +At `/command`, resolve once according to surface, then dispatch an enriched copy: ```ts -const session = await provider.resolveSession(body); -const command = { ...body, sessionId: session.id, sessionName: session.name }; +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-list/status controls. Add Profile-filtered `sessions` to `/status`; local `runtimeState` is `active` only when the manager owns an open visible tab/window for that immutable ID. +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 `webcmd --profile work session list`** +- [ ] **Step 5: Add the three Session lifecycle commands** -Register `session list` in `cli.ts`, read the root Profile selector through the existing Profile resolution, and render columns: +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', 'name', 'runtimeState', 'lastUsedAt', 'handoff'] +['id', 'kind', 'runtimeState', 'handoff'] ``` -When the daemon is absent, read the persisted store and report every row as `idle`; listing must not launch Cloak or create `default`. Use the existing `render` function for `table`, `json`, `yaml`, `md`, and `csv` instead of a new formatter. +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** @@ -345,7 +362,7 @@ npx vitest run --project unit src/browser/sessions.test.ts src/daemon/server.tes npm run typecheck ``` -Expected: PASS; a new store instance resolves the same immutable ID, cross-Profile IDs fail, and list works with or without a running daemon. +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: @@ -369,6 +386,7 @@ git commit -m "feat: persist local browser sessions" - 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` @@ -380,6 +398,7 @@ git commit -m "feat: persist local browser sessions" - 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** @@ -402,7 +421,12 @@ expect(registry.acquire({ key, runId: 'run_7_three', command: 'browser/run' }, ( .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** @@ -428,6 +452,7 @@ export function isSessionLeaseCommand(command: SessionLeaseCommand): command is runId: string; } { return command.action !== 'lease-release' + && command.action !== 'run-cancel' && typeof command.sessionId === 'string' && command.sessionId.length > 0 && typeof command.runId === 'string' @@ -435,17 +460,19 @@ export function isSessionLeaseCommand(command: SessionLeaseCommand): command is } ``` -Acquire after Session resolution and before `provider.dispatch`. On conflict return HTTP 409 with `{ code: 'SESSION_BUSY', session: { id, name }, holder: { command, pid?, acquiredAt, heartbeatAt } }`; never include `runId`. +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. +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)` as a source of the user Session. Pass root `options.session` to `BrowserPage`; after daemon resolution, use: +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' @@ -457,7 +484,7 @@ Add `adapterSite?: string` to the daemon protocol and set it from `cmd.site` in - [ ] **Step 6: Add consistent typed errors and verify** -Add `SessionNotFoundError` (exit 66), enhance `SessionBusyError` with safe Session ID/name metadata (exit 75), add `SessionPausedForHumanHandoffError` (exit 77), and `SessionWindowConflictError` (exit 75). Map the same uppercase daemon codes in `daemon-client.ts`. +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: @@ -466,19 +493,19 @@ npx vitest run --project unit src/session-lease.test.ts src/daemon/server.test.t npm run typecheck ``` -Expected: PASS; overlapping runs in one Session fail immediately, the same run re-enters, and different Sessions progress concurrently. +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/errors.ts src/session-lease.test.ts src/daemon/server.test.ts src/browser/daemon-client.test.ts src/execution.test.ts +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: Give Each Local Session an Owned Cloak Window Group +### Task 4: Enforce Local Page Isolation and Owned Cloak Window Groups **Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` @@ -487,15 +514,21 @@ git commit -m "feat: admit local browser work by session" - 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** @@ -518,9 +551,25 @@ expect((await manager.listPages({ profileId: 'work', sessionId: 'session_a' })) .map(tab => tab.sessionId)).toEqual(['session_a']); ``` -Create another tab for `session_a` and assert it has `session_a`'s existing `windowId`, not `session_b`'s. Emit a popup with `opener() === first.page` and assert it inherits `session_a`, including when it has a child popup window. +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. -Simulate a manual move by returning `session_b`'s window ID for an `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** @@ -530,7 +579,7 @@ Run: 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, `context.newPage()` does not establish distinct windows, and actions can see cross-Session tabs. +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** @@ -539,7 +588,6 @@ Use these internal shapes in `session-manager.ts`: ```ts interface SessionRuntime { id: string; - name: string; windowIds: Set; pages: Map; selectedPageId?: string; @@ -565,13 +613,13 @@ const { targetId } = await runtime.cdp.send('Target.createTarget', { }); ``` -Wait for the matching Playwright page, read `Browser.getWindowForTarget({ targetId })`, and register the window only if unowned or already owned by that Session. Later tabs use `window.open('about:blank', '_blank')` from an open owned page while the Profile creation lock is held, then verify the new target's `windowId` before registration. +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** -Listen to `context.on('page')`; resolve `await page.opener()`, copy the opener's immutable Session ID, then verify/register the popup target and `windowId`. If a page has no known opener, leave it unadopted until an explicit bind verifies that its window is unowned or owned by the selected Session. +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`, `selectPage`, `bindPage`, `closePage`, `newPage`, and `release`. Before returning/mutating a page, call one shared guard: +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 { @@ -588,32 +636,54 @@ private async assertOwnedWindow(runtime: ProfileRuntime, sessionId: string, entr Never close or reassign on this error. -- [ ] **Step 5: Preserve fresh-page and adapter semantics inside the window group** +- [ ] **Step 5: Replace the raw BrowserContext with a Session facade** -For `freshPage`, open and register the replacement in the same Session window first, update the selected and canonical adapter tab entries, then close the old target. `release` closes only ephemeral entries for that Session. Closing the last tab closes that Session's visible window targets but leaves sibling Session windows and the Profile context intact. +Change `runBrowserProgram` and `PlaywrightTransport` inputs to consume an explicit scope: -- [ ] **Step 6: Verify and commit** +```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 +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 distinct OS window IDs, tabs/popups stay in their owner, and a manual move is non-destructive. +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/session-manager.test.ts src/browser/runtime/local-cloak/browser-run.test.ts src/browser/runtime/local-cloak/provider.test.ts +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 the Hidden Anchor, Warm Profile Lifecycle, and Exact Teardown +### Task 5: Add Cross-Platform Profile Keepers, Race-Free Lifecycle, and Exact Teardown **Repository:** `/Users/beubax/Desktop/AgentR/OpenCLI` @@ -630,11 +700,12 @@ git commit -m "feat: isolate local sessions by cloak window" **Interfaces:** -- Produces one hidden Profile-owned `anchorTargetId` and one browser CDP session per live Profile runtime. +- 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 anchor and lifecycle race tests with fake timers** +- [ ] **Step 1: Add platform-keeper, lifecycle-race, and shutdown tests** Assert the runtime is not published before anchor creation: @@ -652,8 +723,12 @@ 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: @@ -676,9 +751,9 @@ Run: 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 hidden anchor/timer exists, close and launch are separate paths, and the matcher accepts only a substring form. +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 hidden anchor** +- [ ] **Step 4: Create and maintain the platform keeper** Immediately after Cloak launch and before `profiles.set`, obtain `context.browser()`, create `browser.newBrowserCDPSession()`, and send: @@ -690,7 +765,9 @@ const { targetId: anchorTargetId } = await cdp.send('Target.createTarget', { }); ``` -If the pinned runtime returns `null` from `context.browser()` or rejects the hidden target, fail launch and let the live gate block the release; do not fall back to a visible page. Store the anchor outside Session/page maps. Filter its `targetId` from all page events and public lists. If `Target.targetDestroyed` reports the anchor while the context is healthy, recreate it under the Profile lifecycle lock. +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** @@ -706,7 +783,9 @@ await Promise.race([ ]); ``` -On timeout call exact Profile recovery and await it before the lock releases. Guard context `close` events by runtime object identity so an old event cannot delete a replacement. +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** @@ -721,7 +800,7 @@ npx vitest run --project unit src/browser/runtime/local-cloak/process-matcher.te npm run typecheck ``` -Expected: PASS for repeated release/close/fresh-page/idle cycles, zero-visible-window anchor reuse, shutdown cleanup, the close/arrival race, and `work` versus `work-2`. +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: @@ -823,7 +902,7 @@ git commit -m "feat: scope local auth handoff to sessions" --- -### Task 7: Store and Resolve Hosted Sessions +### Task 7: Create, Persist, List, and Close Hosted Sessions **Repository:** `/Users/beubax/Desktop/AgentR/webcmd-cloud` @@ -843,9 +922,9 @@ git commit -m "feat: scope local auth handoff to sessions" **Interfaces:** -- Produces `WebcmdSession`, `HostedSessionService.resolve(tenant, profile, selector?)`, and `list`. -- Extends `CloudRepository` with `getSession`, `getOrCreateSession`, `listSessions`, `touchSession`, `setSessionHandoff`, and `clearSessionHandoff`. -- Produces `GET /v1/sessions?profile=` returning persisted metadata plus runtime state derived from Browser allocations. +- 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** @@ -857,14 +936,14 @@ const session = { userId: tenant.userId, workspaceId: tenant.workspaceId, profileId: profile.id, - name: 'invoice-audit', + 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 `getOrCreateSession` calls for the same `(tenant, profileId, name)` return one ID; the same name in another Profile differs; omitted selector resolves `default`; unknown/cross-Profile immutable IDs throw `SESSION_NOT_FOUND`; listing does not create a row; handoff set/clear is owner-scoped. +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: @@ -876,7 +955,7 @@ expect(await response.json()).toEqual({ ok: true, sessions: [expect.objectContaining({ id: session.id, - name: 'invoice-audit', + kind: 'explicit', runtimeState: 'idle', handoff: null, })], @@ -903,63 +982,73 @@ create table if not exists webcmd_sessions ( user_id text not null, workspace_id text not null, profile_id text not null, - name 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, name), 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 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. +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 GetOrCreateSessionInput { +export interface CreateSessionInput { tenant: TenantContext; profileId: string; id: string; - name: string; + kind: 'explicit' | 'adapter-default'; } ``` -PostgreSQL must use `insert ... on conflict (user_id, workspace_id, profile_id, name) do update set last_used_at = excluded.last_used_at returning *`; the Profile foreign key validates tenancy. The in-memory repository uses `JSON.stringify([userId, workspaceId, profileId, name])` and returns clones. +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 resolver service** +- [ ] **Step 5: Implement one lifecycle service** -Use the Profile service before Session resolution. `HostedSessionService` accepts an `idFactory` defaulting to `session_${randomUUID()}` and implements exactly: +Use the Profile service before Session lookup. `HostedSessionService` accepts an `idFactory` defaulting to `session_${randomUUID()}` and implements these distinct paths: ```ts -async resolve(tenant: TenantContext, profile: WebcmdProfile, selector?: string): Promise { - const value = selector?.trim() || 'default'; - if (value.startsWith('session_')) { - const found = await this.repository.getSession(tenant, profile.id, value); - if (!found) throw new PublicHttpError(404, 'SESSION_NOT_FOUND', - `Session ${value} was not found in Profile ${profile.displayName}.`, undefined, 66); - return found; - } - return (await this.repository.getOrCreateSession({ - tenant, profileId: profile.id, id: this.idFactory(), name: value, - })).session; +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 resolving/listing. Do not create an explicit create endpoint. +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** -`GET /v1/sessions` authenticates the tenant, resolves the requested/default Profile, lists rows, and joins active `browser_allocations` in memory by `session_key === session.id`. Return `runtimeState: 'active'|'idle'` and `handoff: null|{site,expiresAt}`; never expose Browser Use IDs, CDP URLs, or viewer tokens. +`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: @@ -968,7 +1057,7 @@ npx vitest run tests/schema.test.ts tests/repository.test.ts tests/sessions-serv npm run typecheck ``` -Expected: PASS; friendly creation is atomic and immutable IDs remain tenant/Profile scoped. +Expected: PASS; explicit creation never collides, adapter-default creation is singleton, close is idempotent, and immutable IDs remain tenant/Profile scoped. Commit: @@ -1006,6 +1095,7 @@ git commit -m "feat: persist hosted browser sessions" - 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** @@ -1029,6 +1119,18 @@ 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: @@ -1053,7 +1155,7 @@ export interface PersistentSessionWriteLeaseKey { 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 name/ID plus holder command/timestamps, never `ownerExecutionId`. +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** @@ -1073,6 +1175,8 @@ browserAllocations.some(allocation => 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: @@ -1116,7 +1220,7 @@ git commit -m "feat: key hosted browsers by session" **Interfaces:** -- Consumes `session?: string` from `/v1/execute` and raw-browser path selectors, then resolves them with `HostedSessionService`. +- 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. @@ -1124,6 +1228,14 @@ git commit -m "feat: key hosted browsers by session" 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 @@ -1148,9 +1260,21 @@ Expected: FAIL because adapter requests have no Session, raw commands use `Sessi - [ ] **Step 3: Parse and resolve selectors at each trusted boundary** -Add `session?: string` validation to `readExecuteRequest`; reject non-string values. Resolve Profile, then Session, before calling adapter/auth browser code. For prepared executions, persist no caller ownership token: reuse the existing queued execution ID only after `startQueuedExecution` succeeds. +Add `session?: string` validation to `readExecuteRequest`; reject non-string values. Load command metadata before Session allocation. Resolve Profile, then: -For raw browser paths, treat the encoded segment as a selector, resolve it, and pass both `session.id` and `session.name` to `HostedBrowserController`. Its execution records and public run output use the immutable ID for ownership and may include the friendly name for display. +```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** @@ -1187,7 +1311,7 @@ npx vitest run tests/http-execute.test.ts tests/http-execution-artifacts.test.ts npm run typecheck ``` -Expected: PASS; Session admission is immediate, trusted, cross-surface, and parallel across siblings. +Expected: PASS; Session admission is immediate, trusted, cross-surface, parallel across siblings, and allocated only for commands that actually need browser state. Commit: @@ -1275,7 +1399,7 @@ Successful `whoami` clears exactly that Session. Expiry clears the row, invalida 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/name and expiry but never the sibling view URL or Browser Use identifiers. +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** @@ -1405,7 +1529,7 @@ git commit -m "feat: advertise hosted session protocol" **Interfaces:** - Consumes `sessionProtocolVersion: 1` from Task 11. -- Produces `HostedClient.listSessions(profile?)` and hosted `webcmd --profile work session list` rendering. +- 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** @@ -1413,7 +1537,7 @@ git commit -m "feat: advertise hosted session protocol" For an old manifest, assert zero browser/execute calls: ```ts -await expect(runHostedCli(['--session', 'work-a', 'github', 'issues'], oldServerOptions)) +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'); @@ -1425,11 +1549,11 @@ For a compatible server: 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', name: 'invoice-audit' }), + expect.objectContaining({ id: 'session_a', kind: 'explicit' }), ]); ``` -Assert completion includes root `--session` and `session list`, and contains no `browser ` template. +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** @@ -1458,12 +1582,14 @@ Reuse the fetched manifest within that dispatch; do not add a second capability - [ ] **Step 4: Add hosted Session list and canonical generated surfaces** -Add `HostedSessionsResponse` validator and `listSessions(profile?)`, then route `session list` through it and existing renderer. Replace completion/help templates with: +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} [--profile ] [--session ] browser [args] [options]` +`${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** @@ -1539,8 +1665,8 @@ The body must use barriers and real navigations to two locally served pages, not 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 and leaves the Profile connected behind the hidden anchor at zero visible windows. -6. A new Session opens during the warm period; another opens during forced idle close and receives one clean replacement runtime. +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`. @@ -1564,7 +1690,7 @@ npx vitest run --project unit src/browser/runtime/local-cloak/session-manager.te npm run gate:cloak-sessions ``` -Expected: unit tests PASS; on the supported macOS release host, the live suite PASSes every listed invariant. Any live failure blocks release and returns the pinned runtime/launcher to repair. +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** @@ -1686,17 +1812,20 @@ git commit -m "test: gate browser use session isolation" **Interfaces:** - Documents the already-implemented behavior; introduces no runtime switches. -- Active examples use friendly names for normal work and immutable Session IDs for handoff verification. +- 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 --session work browser run --stdin'); +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); ``` @@ -1722,33 +1851,40 @@ Expected: FAIL on positional syntax, unscoped verify guidance, and missing Profi Every overview must use this concise distinction: ```text -Profile = persistent login state (cookies and browser storage). -Session = one agent task's browser workspace and command lock. +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: -- omitted selector lazily uses `default`; a new friendly name is lazily created; -- `webcmd --profile work session list` lists stable IDs and state; -- parallel agents choose distinct names, for example `--session invoice-audit` and `--session research`; -- local Sessions are distinct Cloak windows inside one Profile context; hosted Sessions consume distinct Browser Use allocations; -- local zero-window Profiles remain warm behind an invisible anchor for 60 seconds; +- 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 --session invoice-audit github issues -webcmd --profile work --session invoice-audit browser run --stdin +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_NOT_FOUND`, `SESSION_BUSY`, `SESSION_PAUSED_FOR_HUMAN_HANDOFF`, and `SESSION_WINDOW_CONFLICT` as structured runtime states, not adapter breakage. Keep the existing prohibition on agents entering passwords, OTPs, cookies, recovery codes, or CAPTCHAs. +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. @@ -1828,7 +1964,7 @@ With the repository's normal test PostgreSQL URL: 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 friendly creation returns one row; Session leases arbitrate across two pools. +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** @@ -1845,15 +1981,18 @@ Expected: all gates PASS, including two same-Profile Session allocations, both p - [ ] **Step 5: Perform the final issue/contract smoke test** -On the release candidate, run two shells against one Profile: +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 agent-a browser run --stdin <<'JS' +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 agent-b browser run --stdin <<'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() }; @@ -1861,7 +2000,7 @@ JS webcmd --profile release-work session list -f json ``` -Start the first two heredocs in separate shells so their 15-second holds overlap. Expected: distinct local windows or hosted live views, both commands progress, and list returns two stable IDs. During that hold, launch the same `agent-a` command from a third shell; expected `SESSION_BUSY` while `agent-b` remains usable. Run a login in `agent-a`; expected its returned verify command contains `--session session_...`, `agent-a` pauses, and `agent-b` continues. +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** @@ -1895,13 +2034,14 @@ Do not create either commit when its staged diff is empty. | Requirement | Implemented and verified by | |---|---| -| Root selector, lazy/default resolution, immutable IDs, listing | Tasks 1-2, 7, 12 | -| Parallel Sessions, immediate same-Session busy, same-run re-entry | Tasks 3, 8-9 | -| Local Session windows, tab/popup ownership, manual-move error | Task 4 | -| Issue #276 anchor and close/arrival race | Task 5 | +| 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 | 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 index dc65b90e..f2453cec 100644 --- a/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md +++ b/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md @@ -1,6 +1,6 @@ # Webcmd Profile Sessions and Concurrency Design -**Status:** Approved design, pending implementation plan +**Status:** Approved design; implementation plan updated **Date:** 2026-08-11 @@ -8,382 +8,506 @@ 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 task tabs or corrupting browser state. +identity without sharing tabs, selecting each other's pages, or corrupting browser state. -Webcmd already uses the word `session` in its browser runtime. This design keeps that name -and promotes it into the user-facing task boundary. It supersedes the task-space model in +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): reliable concurrent Cloak profiles. -- [#242](https://github.com/agentrhq/webcmd/issues/242): exact profile process matching during teardown. -- [#276](https://github.com/agentrhq/webcmd/issues/276): closing the final leased tab must not invalidate a shared profile runtime. +- [#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 different agents run concurrently in separate Session browser workspaces under one Profile. -- Make a local Session a Cloak window group and a hosted Session a Browser Use allocation. -- Reuse Profile authentication while isolating each Session's tabs, live view, and runtime lifecycle. -- Use one consistent Session selector for adapters and raw browser commands. -- Preserve Session identity across browser or daemon restarts without promising tab restoration. -- Make same-Session collisions and human handoffs explicit to agents. -- Give local Cloak and hosted Browser Use the same Session selection, ownership, and handoff behavior. -- Update user documentation, CLI help, and bundled agent skills as part of the release. +- 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; Profile authentication is intentionally reusable. -- Arc-style spaces, tab groups, colors, themes, or pinned tabs. -- Automatic tab restoration after a Profile runtime is restarted or evicted. -- Live injection of newly changed hosted cookies into already-running Browser Use allocations. -- `session create`, `session complete`, `session takeover`, or a process-global current Session. -- A user-configurable local Profile warm period; v1 uses a fixed 60 seconds. -- Cloak licence/capability detection or a degraded single-context mode. -- A general output-format migration; existing output defaults remain unchanged. +- 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 +## Concepts and invariants | Concept | Responsibility | |---|---| -| Profile | Persistent authentication state: cookies, local storage, and Cloak/Browser Use profile data. | -| Local Profile runtime | One persistent Cloak browser context for a Profile, shared by its Session windows. | -| Session | Persistent logical identity for one agent task. Owns a local window group or one hosted allocation, plus its tabs and command admission. | -| Tab | A Playwright `Page` owned by a Session. `Page` remains an implementation term. | -| Anchor target | Local-only, hidden Profile-owned CDP target that keeps Cloak alive with no visible Session windows. | +| 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 names are unique within a Profile. Immutable `session_...` IDs are globally -unambiguous. A browser window never mixes tabs from different Sessions. Sessions under the -same Profile cannot list, select, bind, close, or otherwise act on each other's tabs. +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`. -## CLI contract +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. -`--session ` is a root selector alongside `--profile`: +## CLI and agent experience + +`--session ` is a root selector alongside `--profile`: ```bash -webcmd --profile work --session invoice-audit github issues -webcmd --profile work --session invoice-audit browser run --stdin +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 invoice-audit run # invalid +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 ``` -It is not retained as a compatibility alias. The parser returns a usage error with exit code -2 and the replacement command. Runtime help, completion, hosted argument routing, generated -hints, tests, documentation, and bundled skills must all use the root flag. +The selector must be an opaque `session_...` ID. Friendly arbitrary strings are rejected as +usage errors rather than lazily creating a shared name. -### Resolution +### `session create` -1. Resolve the Profile exactly as Webcmd does today. -2. If `--session` is omitted, resolve the reserved Session name `default` in that Profile. -3. If the selector is a known name, reuse its immutable ID. -4. If the selector is a missing name, lazily create it and return its ID. -5. If a `session_...` ID does not exist or does not belong to the selected Profile, return - `SESSION_NOT_FOUND`; never create an ID supplied by the caller. +`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. -There is no PID-derived default and no process-global `session use` state. Parallel agents -must not overwrite an ambient selection. Explicit `session create` adds no value because -normal use already creates atomically and idempotently. +### `session list` -### Listing and persistence +`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: -`webcmd --profile session list` returns the Profile's Sessions with stable ID, -name, current runtime state, last activity, and handoff state. The default human format and -machine formats follow existing CLI output conventions. +```text +sessions[N]{id,kind,runtimeState,handoff}: +``` -Session metadata persists locally in Webcmd state and remotely in the Cloud database. A -runtime restart or idle eviction preserves the Session record but discards its owned-window, -owned-tab, selected-tab, and hosted-allocation state. The next command opens a fresh local -window or hosted allocation using the Profile's persisted authentication state. +`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. -Session records are small and are not automatically deleted in v1. A delete/complete command -can be added if real usage shows that accumulated records are a problem. +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. -## Runtime architecture +### `session close` -### Shared invariant +`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. -Both providers make Session the running browser-workspace boundary, but use their native -isolation primitive: +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 -Local Profile context Hosted Browser Use Profile -├── hidden anchor target ├── Session invoice-audit allocation -├── Session invoice-audit window(s) │ └── owned tabs + live URL -│ └── owned tabs └── Session research allocation -└── Session research window(s) └── owned tabs + live URL - └── owned tabs +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) ``` -Ending or idling a Session closes its local window group or hosted allocation but preserves -the Session record and Profile authentication state. Sessions never share a visible window, -hosted allocation, live-view URL, selected tab, or command lock. A local Profile with no -active Session windows remains warm for 60 seconds before its runtime is closed. +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. -### Local Cloak +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: -Local mode uses one persistent `BrowserContext` per Profile so cookies and browser storage -remain live-shared. Runtime launch creates the hidden Profile anchor before publishing the -runtime for Session use. A Session's first page is then created with CDP -`Target.createTarget({ newWindow: true })`; its `windowId`, targets, tabs, and selected tab are -registered under the immutable Session ID. +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. -Later tabs are created in that Session's window under the existing short per-Profile page- -creation lock. Webcmd verifies the resulting `windowId` before registering the tab. Site- -created tabs and popup windows inherit their opener's Session. A Session may therefore own a -primary window and child popup windows, but no window may contain targets from two Sessions. -Webcmd never adopts a target whose window ownership conflicts with its Session. -Manual tab moves between Session windows are unsupported. If Webcmd detects one, it leaves -the tab untouched and returns `SESSION_WINDOW_CONFLICT`; it never reassigns or closes the tab. +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. -The current command queue is keyed only by Profile. It will be re-keyed by Profile and -Session so different Session windows execute concurrently. The fixed local Profile idle -expiry or daemon shutdown closes the whole context; closing a Session closes only its owned -window targets. +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 browser allocation per 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 allocation key becomes -`(userId, workspaceId, profileId, sessionId)` and the current one-allocation-per-Profile -constraint is removed. - -Browser Use Profiles persist cookies and local storage across browsers. A new or restarted -Session loads that state. Webcmd does not promise that a cookie changed in one allocation is -injected live into another already-running allocation. Browser Use's concurrent same-Profile -save/merge behavior must pass the release gate below; Webcmd will not add its own cookie or -storage synchronization layer. - -This model consumes one hosted browser allocation per active Session. That cost and provider -concurrency usage are intentional consequences of clean Session isolation. - -Before release, a live Browser Use gate must start two allocations concurrently from one -Profile, write different-domain cookies and local storage in each, stop them in both orders, -and prove that a later allocation loads both markers. It must also prove that a handoff in one -allocation leaves the sibling allocation usable. Failure blocks the release and returns this -architecture to design review; it does not trigger a Webcmd-owned cookie-sync subsystem. - -### Adapter routing - -The user-selected Session and adapter `siteSession` are different concerns: - -- `--session` chooses the agent task and its tab boundary. -- `siteSession: persistent|ephemeral` remains an adapter tab-lifecycle policy. - -A persistent adapter tab is keyed by `(profileId, sessionId, site)`. An ephemeral adapter -command creates a tab within the Session's local window group or hosted allocation and closes -it when the command ends. Raw browser commands act on that Session's selected owned tab. Tab -IDs may be globally unique, but every operation must still verify Session and window/allocation -ownership. - -## Concurrency and admission - -- Different Sessions under one Profile execute concurrently. -- Different Profiles execute concurrently. -- A top-level execution targeting a Session already owned by another execution fails - immediately with `SESSION_BUSY`; it does not wait in an invisible public queue. -- Operations belonging to the owning execution may re-enter its admission lease and use the - existing defensive internal queue. -- Brief local window/tab placement remains serialized by the Profile's existing critical- - section lock; hosted Sessions need no cross-Session browser lock. -- Local Profile launch, idle shutdown, and anchor recovery use that same per-Profile lock. -- A human handoff blocks normal automation only in its owning Session. - -Admission distinguishes logical executions, not agents, processes, or clients. Every top-level -browser-backed CLI invocation or hosted request receives a unique execution ID before its first -browser operation. All nested operations belonging to that invocation carry the same ID. Two -commands from the same agent or PID still conflict if their execution IDs overlap in one -Session; two commands issued sequentially do not conflict after the first releases admission. -PID and command name are diagnostic holder metadata only. - -The local CLI reuses its existing `runId` across one invocation's daemon operations. Hosted -mode mints the admission ID at the trusted server execution boundary and maps idempotent retries -of the same request back to that execution; a caller-supplied ID cannot impersonate a current -holder. The execution ID is released when the top-level command reaches a known outcome. The -existing unknown-outcome TTL behavior remains the recovery path when completion is uncertain. - -The existing local `SessionLeaseRegistry` and hosted persistent write-lease mechanism should -be extended from persistent adapter writes to all browser-backed commands and re-keyed by -immutable Session ID rather than replaced. `SESSION_BUSY` includes the Session ID/name and -safe holder metadata, never the internal execution ID, uses the existing temporary-failure -exit-code convention, and is retryable by the caller. - -## Hidden local anchor and issue #276 - -Each local Profile runtime creates one hidden `about:blank` CDP target with -`Target.createTarget({ hidden: true, background: true })` before the runtime enters the -reusable Profile map. Its browser-level CDP session remains open for the runtime's lifetime. -The hidden target keeps Cloak connected when no visible Session windows exist without adding -a blank window or tab-strip entry. - -The anchor target: - -- Is stored separately from every Session window and tab map. -- Has no public page ID, is never registered as a Playwright Session page, and never appears - in tab listing, selection, snapshots, or network capture. -- Survives Session window close, release, `freshPage`, and Session idle expiry. -- Is recreated under the existing per-Profile creation lock if unexpectedly destroyed while - the context remains healthy. -- Is not visible or closable through normal Cloak or Webcmd tab UI; low-level CDP clients can - observe and explicitly close it, after which Webcmd recreates it if the context is healthy. -- Is closed with its context after local Profile idle expiry, daemon shutdown, explicit - Profile teardown, or an unrecoverable disconnect. - -When the last local Session window closes and the Profile has no running command or human -handoff, Webcmd starts one fixed 60-second, unreferenced idle timer. New local browser work -cancels the timer under the per-Profile lifecycle lock and reuses the warm runtime. - -If the timer fires, it acquires that same lock, rechecks the idle conditions, removes the -runtime from the reusable Profile map, and then closes the entire context while still holding -the lock. A command that arrives first cancels eviction; a command that arrives after shutdown -starts waits briefly on the lock and launches a new runtime after closure completes. Multiple -arriving commands share the existing single-flight launch. A closing runtime is never returned -to a command, and a late close event from an old runtime cannot invalidate its replacement. -If graceful close exceeds three seconds, the exact Profile recovery path from #242 -finishes teardown before relaunch; the old runtime is never put back in the reusable map. - -`freshPage` creates and registers its replacement in the same Session window before closing -the previous tab. Closing the final visible Session window therefore leaves only the hidden -anchor during the warm period, never a stale runtime awaiting an asynchronous close event. -Hosted allocations need no anchor or Profile timer because ending one Session intentionally -stops that allocation and cannot affect a sibling allocation. - -## Pinned Cloak concurrency and issue #225 - -Webcmd pins and distributes a tested Cloak package/browser artifact pair. Concurrency is a -supported-runtime guarantee, not a runtime capability negotiated from licence state. - -A candidate pair cannot be released unless it passes live gates for: - -- Two persistent Profile contexts launched concurrently with separate user-data directories. -- Two Sessions in one Profile navigating concurrently in distinct OS windows. -- Additional tabs and popups remaining inside their owning Session's window group. -- Background window/tab creation not stealing focus from a human-controlled Session window. -- A hidden anchor keeping the Profile connected with zero visible Session windows, followed - by successful creation of a new Session window. -- Profile idle shutdown and concurrent arrival using one lifecycle lock without returning a - closing context. -- The macOS foreground/background launch paths used by Webcmd remaining connected through navigation. -- Closing one Session window without affecting sibling windows or the Profile runtime. - -There is no `CLOAK_CONCURRENCY_LIMIT` product branch for the supported pinned runtime. A -failure, including focus theft during background tab placement, blocks release until the -runtime or launcher is fixed; Webcmd does not fall back to Profile-wide serialization. - -## Exact Profile teardown and issue #242 - -All process discovery and teardown paths must reuse one exact Cloak Profile matcher. The -matcher must: - -- Recognize only Cloak browser commands. -- Match `--user-data-dir` as a complete argument, including its accepted spelling variants. -- Never treat Profile `work` as matching `work-2`. -- Never terminate unrelated Chrome/Chromium processes. - -The safe matcher already used by locked-profile recovery should become the shared path for -background teardown and recovery. Closing a Session never invokes Profile process teardown. +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.159`. 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 -> `whoami` protocol remains the public workflow. No -generic handoff, takeover, or complete commands are added. +The existing `login -> human action -> returned whoami` protocol remains public. There are no +generic handoff, takeover, or complete commands. -When a login needs human action: +When login needs human action: -1. Mark the initiating immutable Session as human-controlled. -2. Local mode foregrounds that Session's Cloak window. Hosted mode returns that Session - allocation's live-view URL. -3. Return `action_required`, expiry, and a verify command containing the same `--profile` - and immutable `--session` selectors. -4. Normal browser commands targeting that Session fail immediately with +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. -5. Only that Session's verification command may automate the browser while human-controlled. -6. Successful `whoami` or handoff expiry releases human control. +6. Only server-classified verification for the same site and Session may proceed. +7. Successful `whoami` or expiry clears the handoff. -Sibling Sessions under the same Profile continue normally in their own local windows or -hosted allocations, including when they are working on different sites. They receive neither -the handoff URL nor a pause error. Authentication persisted to the Profile becomes available -to future or restarted hosted allocations according to the Profile semantics above. +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_NOT_FOUND` | An immutable Session ID is unknown or belongs to another Profile. | -| `SESSION_BUSY` | Another execution currently owns command admission for the selected Session. | -| `SESSION_PAUSED_FOR_HUMAN_HANDOFF` | A human controls the selected Session during authentication handoff. | -| `SESSION_WINDOW_CONFLICT` | A local tab was manually moved into a window owned by another Session. | - -These are structured errors in local and hosted modes with consistent exit codes and safe -metadata. They must not be collapsed into generic browser-closed, timeout, or HTTP errors. - -## Agent and user documentation - -The feature is incomplete until agents and users can discover and use it correctly. The same -release updates: - -- Root and browser help, completion output, examples, and targeted migration errors. -- README and active browser/auth documentation. -- Bundled `webcmd-usage`, `webcmd-browser`, `webcmd-autofix`, adapter-author, sitemap-author, - and browser-sitemap skills where they select browser state or explain handoff. -- Agent harness setup guides that show Webcmd browser commands. -- Generated command hints and auth `verify_command` output. -- Hosted help/contract examples and release notes. - -Documentation must explain Profile versus Session versus tab, lazy/default Session behavior, -how separate agents choose separate Sessions, how to list Sessions, `SESSION_BUSY`, and how a -Session-scoped human handoff leaves sibling Sessions running. It must also explain that local -Sessions appear as separate Cloak windows, hosted Sessions consume separate Browser Use -allocations, a windowless local Profile remains warm behind an invisible anchor for 60 seconds, -and already-running hosted allocations do not receive live cookie injection. Same-agent -commands launched concurrently against one Session may receive `SESSION_BUSY`; sequential -commands do not. Examples use immutable IDs when resuming an auth handoff and friendly names -for normal task selection. - -Historical design documents remain historical. This specification explicitly supersedes the -old Spaces decision; active documentation must not teach Spaces or positional browser syntax. +| `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 -Focused automated and live checks must cover: - -1. Root `--session` parsing for adapters and browser commands, plus rejection of positional syntax. -2. Lazy name creation, reserved `default`, immutable-ID lookup, Profile scoping, listing, and restart persistence. -3. Parallel wall-clock execution for two Sessions in one Profile and for two Profiles. -4. One execution ID re-entering a Session across nested operations; a different ID receiving - immediate `SESSION_BUSY` even with the same agent/PID; sequential executions succeeding; - hosted callers unable to spoof the current holder's ID. -5. Distinct local `windowId` ownership, same-Session tab placement, popup inheritance, and no - cross-Session target adoption, including a non-destructive error after a manual tab move. -6. Session-scoped list/select/bind/close behavior and persistent adapter separation with - unchanged `siteSession` lifecycle behavior. -7. A hidden anchor absent from all public surfaces while zero visible windows -> immediate - Session window creation remains reliable. -8. The 60-second Profile timer starting only at zero Session windows, remaining unreferenced, - and being cancelled by new work or a handoff. -9. Commands winning just before idle expiry reusing the runtime, and commands arriving during - shutdown waiting for one close and single-flight relaunch without target-closed errors. -10. Repeated release/close/`freshPage`/idle-expiry cycles without target-closed errors. -11. Idle expiry and daemon shutdown closing every Session window, the context, and the hidden - anchor; bounded failed close uses exact Profile recovery before relaunch. -12. One hosted Browser Use allocation and distinct live-view URL per active Session. -13. Concurrent hosted allocations from one Browser Use Profile preserving different-domain - cookies and local storage after both stop, regardless of stop order, and a later allocation - loading both markers. -14. Session-scoped local and hosted handoff, successful verification, expiry recovery, and a - sibling Session continuing throughout. -15. Exact Profile teardown for `work` versus `work-2` and unrelated Chrome processes. -16. Live release gates for the pinned Cloak concurrency contract. -17. Help, generated hints, bundled skill examples, and active docs containing only canonical syntax. +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 hosted protocol advertises the new -Session capability so incompatible CLI/server pairs fail before browser work. The release is -a clean CLI syntax break with a targeted migration error; there is no long-lived positional -compatibility shim. +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. From ecbbbeaaf495377f3deda3d9b2869b86876cbc13 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 22:54:11 +0530 Subject: [PATCH 7/9] docs: design client-owned web fetch --- ...026-08-12-client-owned-web-fetch-design.md | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-12-client-owned-web-fetch-design.md 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. From 8af6f780bd4448c58d0bfa80bf8880f92de7e6b0 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 23:06:16 +0530 Subject: [PATCH 8/9] docs: plan client-owned web fetch implementation --- .../2026-08-12-client-owned-web-fetch.md | 828 ++++++++++++++++++ 1 file changed, 828 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-12-client-owned-web-fetch.md 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. From 9f95c6a9cd74695c85fede45f9348f4d7efb3d89 Mon Sep 17 00:00:00 2001 From: beubax Date: Wed, 12 Aug 2026 23:39:00 +0530 Subject: [PATCH 9/9] docs fix cloak chromium pin --- .../plans/2026-08-11-profile-sessions-concurrency.md | 6 +++--- .../specs/2026-08-11-profile-sessions-concurrency-design.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md b/docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md index e42c5307..e9305109 100644 --- a/docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md +++ b/docs/superpowers/plans/2026-08-11-profile-sessions-concurrency.md @@ -6,14 +6,14 @@ **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.159, Vitest, PostgreSQL, Browser Use, GCP release gates. +**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.159. Do not add capability/licence fallback branches. +- 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. @@ -1661,7 +1661,7 @@ describe.skipIf(!live)('pinned Cloak Profile Sessions', () => { 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.159`. +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. 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 index f2453cec..5a08fb28 100644 --- a/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md +++ b/docs/superpowers/specs/2026-08-11-profile-sessions-concurrency-design.md @@ -391,7 +391,7 @@ allocation. ## Pinned Cloak concurrency and issues #225/#242 Webcmd pins `cloakbrowser` `0.4.5`, Playwright Core `1.61.1`, and Chromium -`v145.0.7632.159`. Release evidence, not runtime licence probing, defines support. The live gate +`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;