From 6b0e6e6fdeb65f038392a4d55473b3e9ebb424d4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 00:44:43 -0700 Subject: [PATCH 1/3] docs(host specs): correct the host specs against the code and move mechanism to the modules that own it The browser-dev harness is standalone's, not the adapter-agnostic protocol's, so its section moves from transport.md to standalone.md with every pointer updated. Agent-recovery capture, the peer-link bind-as-lease walk and the peer-link trust handshake keep their rules in the specs and lose the mechanism paragraphs the modules already carry verbatim; the two sentences those comments lacked are added there. Corrections against the code: a VS Code extension-host crash restores structure rather than starting fresh, since the periodic save writes workspaceState; a snapshot pane may be a tool, and a save probes every non-browser pane's cwd; a token-less reconnect answer is taken because the hosts that do not echo run one collector per realm, not because they have one webview; standalone's boot sequence sets the window label first, installs the window-close listener, boots a tear-out and arms Workspace moves after the restore, and checks for updates in main alone; the sidecar's shutdown has five steps; a blocking Tauri command must be async in either form; quit-teardown is an event; the Windows SIGTERM note belongs to step 3; an arrival carries allIds and its journal record carries settled; the bare-Wall default id comes from wrapSessionInWindow; standalone's drag controller must not assume in-range pointer coordinates; only the two recovery answers ride respondAsync; the updater log tail is 10,000 bytes in both specs; VS Code installs whatever AlertManager consumes rather than only inactivityTimeoutMs, its shipped command set is named where the manifest is described, burrow:command wraps its payload, and HANDSHAKE_BUDGET_MS and the PTY route table are attributed to peer-link.ts. The quit flow splits into Trigger interception / Quit protocol / Teardown ordering, the duplicated Terminal context host operations sections fold into transport.md, the VS Code webview-hosting diagram defers to transport.md's, auto-update's file table keeps the five files a reader opens first, and three cross-spec duplicates become pointers. Co-Authored-By: Claude Fable 5.1 --- .../debug-standalone-agent-browser/SKILL.md | 4 +- AGENTS.md | 2 +- TESTING_AND_MODIFICATION_GUIDE.md | 2 +- docs/specs/auto-update.md | 7 +- docs/specs/standalone.md | 132 ++++++++++------- docs/specs/standalone.rationale.md | 11 +- docs/specs/transport.md | 57 ++------ docs/specs/transport.rationale.md | 20 --- docs/specs/vscode.md | 137 ++++++------------ lib/src/host/recovery-capture.ts | 8 + lib/src/host/remote/burrow-state-store.ts | 4 +- lib/src/lib/platform/types.ts | 5 +- lib/src/lib/terminal-controls.ts | 1 + scripts/spec-word-budgets.json | 8 +- standalone/src-tauri/src/lib.rs | 2 +- standalone/src/browser-sidecar-host.test.ts | 2 +- standalone/src/browser-sidecar-host.ts | 2 +- 17 files changed, 183 insertions(+), 221 deletions(-) diff --git a/.claude/skills/debug-standalone-agent-browser/SKILL.md b/.claude/skills/debug-standalone-agent-browser/SKILL.md index 31c3b4e12..c674b0cff 100644 --- a/.claude/skills/debug-standalone-agent-browser/SKILL.md +++ b/.claude/skills/debug-standalone-agent-browser/SKILL.md @@ -24,7 +24,7 @@ The harness: - opens the app in `agent-browser` - mirrors browser console logs as `[browser log] ...` in the harness terminal -Port and session overrides are documented in `docs/specs/transport.md` → "Standalone browser-dev harness". +Port and session overrides are documented in `docs/specs/standalone.md` → "Standalone browser-dev harness". ## Freshness @@ -46,7 +46,7 @@ agent-browser --session eval '(()=>(!!document.querySelector("te Browser console mirroring (`[browser log] ...`) keeps working after a manual re-open, so you don't lose log visibility. -Parallel worktrees are isolated automatically (`docs/specs/transport.md` → "Standalone browser-dev harness"). Inside Dormouse, use `dor ensure -- pnpm innerdogfood`; the harness opens its browser pane. Stop only your own harness with Ctrl-C after a timing run. +Parallel worktrees are isolated automatically (`docs/specs/standalone.md` → "Standalone browser-dev harness"). Inside Dormouse, use `dor ensure -- pnpm innerdogfood`; the harness opens its browser pane. Stop only your own harness with Ctrl-C after a timing run. ## Driving Dormouse diff --git a/AGENTS.md b/AGENTS.md index 07a6e8e6b..1151e4357 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -11,7 +11,7 @@ pnpm build # build lib, vscode extension, Pocket, and website **Inside Dormouse, run `innerdogfood`** — `dor ensure -- pnpm innerdogfood`. The harness opens its browser pane and prints its URL and browser command -(`docs/specs/transport.md` → "Standalone browser-dev harness"). +(`docs/specs/standalone.md` → "Standalone browser-dev harness"). **Open every PR as a draft.** Chromatic bills per snapshot and skips drafts, so marking a PR ready for review is what spends them. diff --git a/TESTING_AND_MODIFICATION_GUIDE.md b/TESTING_AND_MODIFICATION_GUIDE.md index 4137ec574..9563650b0 100644 --- a/TESTING_AND_MODIFICATION_GUIDE.md +++ b/TESTING_AND_MODIFICATION_GUIDE.md @@ -217,7 +217,7 @@ the heading, and the rule gets a `(rationale)` marker. | Persistence and restore | `lib/src/lib/window-session-aggregator.ts`, `standalone/src/window-restore.ts`, Rust `save_session` etc. | `standalone.md` → Persistence; `transport.md` | | `dor workspace` verbs, `dor list` | `dor/src/commands/workspace.ts`, `list.ts`, `dor/src/protocol.ts`, `dor/src/control-client.ts`, `dor/src/commands/types.ts`; window handler `lib/src/components/wall/workspace-control.ts`; router `dor-control-router.ts` | `dor-cli.md` → dor workspace, Standalone, Handle Model | | Platform hooks the stack added | `onPtyMarked`, `transferWorkspace` on `PlatformAdapter` (`lib/src/lib/platform/types.ts`), implemented in `standalone/src/tauri-adapter.ts` and `browser-sidecar-adapter.ts` | `transport.md` | -| Browser harness | `standalone/scripts/dev-agent-browser.mjs` (`invokeMap`, `fireAndForget`), `browser-sidecar-adapter.ts` | `transport.md` → Standalone browser-dev harness | +| Browser harness | `standalone/scripts/dev-agent-browser.mjs` (`invokeMap`, `fireAndForget`), `browser-sidecar-adapter.ts` | `standalone.md` → Standalone browser-dev harness | ## 7. Rules that bite diff --git a/docs/specs/auto-update.md b/docs/specs/auto-update.md index d952b0fb1..c14cea8d0 100644 --- a/docs/specs/auto-update.md +++ b/docs/specs/auto-update.md @@ -45,7 +45,7 @@ Update status is a text notice in the Baseboard, the always-visible bottom strip ### Debug report on failure -**"Click here to debug" opens `UpdateDebugModal`, which snapshots the failure** (version + error string) so a later state change cannot alter it. Two steps (rationale): a GitHub issue *search* seeded with the error's first 80 characters **unquoted**, so GitHub can fuzzy-match; and a copyable markdown report from `buildDebugReport()` — app version, `PLATFORM_STRING`, the error, and the log tail. The tail is `read_update_log`'s last 10 KB of `dormouse.log`, sliced on a char boundary; **a failed read is embedded as a placeholder, never aborts the report**. +**"Click here to debug" opens `UpdateDebugModal`, which snapshots the failure** (version + error string) so a later state change cannot alter it. Two steps (rationale): a GitHub issue *search* seeded with the error's first 80 characters **unquoted**, so GitHub can fuzzy-match; and a copyable markdown report from `buildDebugReport()` — app version, `PLATFORM_STRING`, the error, and the log tail. The tail is `read_update_log`'s last 10,000 bytes of `dormouse.log`, sliced on a char boundary; **a failed read is embedded as a placeholder, never aborts the report**. ### Threading @@ -84,12 +84,7 @@ Single key: `dormouse:update-result` | [`standalone/src/updater.test.ts`](../../standalone/src/updater.test.ts) | Pins the updater lifecycle and ordering | | [`standalone/src/UpdateBanner.tsx`](../../standalone/src/UpdateBanner.tsx) | Presentational notice content for the Baseboard | | [`standalone/src/UpdateDebugModal.tsx`](../../standalone/src/UpdateDebugModal.tsx) | Failure modal: issue search + copyable report | -| [`standalone/src/quit.ts`](../../standalone/src/quit.ts) | Quit orchestrator (`docs/specs/standalone.md` §Quit flow); calls `installPendingUpdate()` last | -| [`standalone/src/main.tsx`](../../standalone/src/main.tsx) | `` (banner + modal) as ``'s `baseboardNotice`; `startUpdateCheck()` after restore | | [`standalone/src-tauri/tauri.conf.json`](../../standalone/src-tauri/tauri.conf.json) | Updater endpoint, public key, artifact mode, Windows install mode | -| [`standalone/src-tauri/src/lib.rs`](../../standalone/src-tauri/src/lib.rs) | Plugin registration, sidecar teardown, update-log tail, `quit_restart` | -| [`standalone/src-tauri/capabilities/default.json`](../../standalone/src-tauri/capabilities/default.json) | Shell and window permissions, for `main` and every `ws-*` window | -| [`standalone/src-tauri/capabilities/main-only.json`](../../standalone/src-tauri/capabilities/main-only.json) | Updater and app-version permissions, scoped to `main` alone | ## Configuration diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index c9a5a39cb..055c0345f 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -32,33 +32,41 @@ bundles them into the sidecar's `.cjs` copies, so the two hosts cannot drift. Source of truth: `standalone/src/main.tsx` (`bootstrap()`). -1. Pick the platform: `BrowserSidecarAdapter` when `VITE_DORMOUSE_BROWSER_DEV_HOST` - is set (the browser-dev harness, `docs/specs/transport.md`), else `TauriAdapter`. -2. `setPlatform(platform)`, then `await platform.init()` **before** the restore — +1. `setWindowLabel(await resolveWindowLabel())` **first**: every window-keyed Rust + command and `isMainWindow()` below read it. +2. Pick the platform: `BrowserSidecarAdapter` when `VITE_DORMOUSE_BROWSER_DEV_HOST` + is set (§Standalone browser-dev harness), else `TauriAdapter`. +3. `setPlatform(platform)`, then `await platform.init()` **before** the restore — init registers the listeners resume replay arrives on and hydrates the session cache (§Persistence). -3. `installPeerSurfaceResponder()` **after `init()`, never before** (§Burrow +4. `installPeerSurfaceResponder()` **after `init()`, never before** (§Burrow service) — the responder seeds itself with a `status` command that the adapter must already have listeners for (rationale). -4. `getAvailableShells()` **without awaiting**, so its webview → Rust → sidecar - round trip overlaps steps 5–6. -5. Tauri branch only: `initQuitFlow(platform)` and +5. `getAvailableShells()` **without awaiting**, so its webview → Rust → sidecar + round trip overlaps steps 6–7. +6. Tauri branch only: `initQuitFlow(platform)`, `initWindowClose(platform)` — the + listener §Per-window close's ack watchdog waits on — and `setQuitConfirmGate(openQuitConfirm)` (§Quit flow). -6. `initAlertStateReceiver()`, `restoreActiveTheme()` (`docs/specs/theme.md`). -7. `seedShellStore` on the awaited shell list — restores the persisted selection +7. `initAlertStateReceiver()`, `restoreActiveTheme()` (`docs/specs/theme.md`). +8. `seedShellStore` on the awaited shell list — restores the persisted selection (`dormouse:selected-shell`) and publishes it via `setDefaultShellOpts`, the default-shell slot for split/spawn/restore (`docs/specs/layout.md`). **Awaited**: seeding must finish before the Wall mounts, so the first restored pane already spawns with that shell. -8. `restoreWindowOrFresh(platform)` — the per-Workspace boot (§Persistence) over - the priority-based recovery from `docs/specs/transport.md`. -9. `startUpdateCheck()` (`docs/specs/auto-update.md`), then render `AppBar` + - `App` with `multiWorkspace` — one Wall per Workspace (`docs/specs/layout.md` - → Workspaces) — and `enableBurrow`, the mount gate for the lazily-imported - Burrow UI chunk (§Burrow service); the Burrow itself runs in the - sidecar regardless. `` rides the `baseboardNotice` - slot, `` the `dialogHost` slot; both go to the - visible Workspace's Wall. +9. Tauri branch only: `bootFromTearOut(platform)` and `initDropCaret()`, then + `restoreWindowOrFresh(platform)` for every window a tear-out did not answer — + the per-Workspace boot (§Persistence) over the priority-based recovery from + `docs/specs/transport.md`. **`armWorkspaceMoves()` runs strictly after that + restore**, since arming drains whatever was dropped on this window while it + booted and the restore installs the Workspace store wholesale (§Arrival queue). +10. `startUpdateCheck()` **in `main` only** (`docs/specs/auto-update.md`) — the + window `capabilities/main-only.json` scopes `updater:default` to — then render + `AppBar` + `App` with `multiWorkspace` — one Wall per Workspace + (`docs/specs/layout.md` → Workspaces) — and `enableBurrow`, the mount gate for + the lazily-imported Burrow UI chunk (§Burrow service); the Burrow itself runs + in the sidecar regardless. `` rides the + `baseboardNotice` slot, `` the `dialogHost` slot; + both go to the visible Workspace's Wall. **Must display fatal bootstrap errors with a reload action**, for both Tauri and the browser harness, instead of leaving a blank window. @@ -88,14 +96,15 @@ Request/response commands block on the sidecar's reply under a timeout. constants in `lib/src/lib/platform/types.ts` (and `standalone/sidecar/pty-core.js`); `lib/src/lib/mirrored-constants.test.ts` pins the copies together. -**Blocking commands must be `#[tauri::command(async)]`** — Tauri runs a *plain* -sync command on the main thread, where the `recv_timeout` inside +**A blocking command must be async** — `#[tauri::command(async)]` or a plain +`#[tauri::command]` over an `async fn`, which the guard below accepts equally. +Tauri runs a *sync* command on the main thread, where the `recv_timeout` inside `request_from_sidecar` / `request_from_sidecar_timeout` stops the webview painting for the whole round trip, up to `AGENT_BROWSER_TIMEOUT` (30s) (rationale). **The three clipboard readers included**: their non-Windows branches round-trip through -the sidecar, and the attribute is per command, not per branch. A unit test in +the sidecar, and the declaration is per command, not per branch. A unit test in `lib.rs` scans the source and fails on any command that reaches the blocking -helpers without it. +helpers in neither form. `pty_graceful_kill` (`TauriAdapter.gracefulKillPtys`) SIGTERMs the calling window's live PTYs (§Windows) and awaits the sidecar's `gracefulKillDone` (echoing the request's @@ -262,9 +271,10 @@ ordered**: headed Chrome window, and a hung agent-browser must not wedge the exit (as in the VS Code host's `deactivate()`; `docs/specs/dor-browser.md`). 2. Close the dor control socket. -3. Dispose the Burrow service, dropping the relay socket and settling every +3. `alertStore.dispose()`. +4. Dispose the Burrow service, dropping the relay socket and settling every outstanding ask so nothing waits on a webview that is going away. -4. `mgr.killAll()` (all PTYs), then `process.exit(0)`. +5. `mgr.killAll()` (all PTYs), then `process.exit(0)`. **A parent-PID watchdog polls every 2s** and self-triggers shutdown if the Tauri process disappears: stdin EOF is not always delivered when the host is @@ -527,8 +537,8 @@ anyway if that listener is dead), asks about *its own* running work, archives `close_window`. - **A close is deliberate, so it archives and it removes the blob** — geometry - and temp sibling included — and the next launch does not reopen the window. - A quit keeps every blob, which is the whole difference. + and temp sibling included — and the next launch does not reopen the window + (`docs/specs/transport.md` → "The governing rule"). - **It runs no agent-recovery capture**: nothing is coming back. - **A cancelled close retires its watchdog's token and never reuses it**: the next close on that window is a fresh seq, so a watchdog still sleeping on the @@ -618,8 +628,10 @@ Everything else is the transfer above. ### Arrival queue -**An arrival is one transaction keyed by `workspaceId`**, carrying -`{ from, to, workspace, notepad, terminalIds }`. Rust holds the record from the +**An arrival is one transaction keyed by `workspaceId`**, carrying the source's +`{ workspaceId, workspace, notepad, terminalIds, allIds }` — `allIds` naming every +member Surface, browser ones included, which is what the target hydrates notes +against — under Rust's own `from` / `to`. Rust holds the record from the source's invoke until the target adopts the Workspace or dies, and every step below reads that record rather than inferring itself from the suppression map. @@ -715,8 +727,8 @@ below reads that record rather than inferring itself from the suppression map. moves at the invoke, so those shells would otherwise be listed as top-level panes beside the Workspace about to mount them. - **`begin_arrival` records the arrival in `sessions/arrivals.json`** — a JSON - array of `{ workspaceId, from, to, workspace }`, never an entry in either - window's snapshot (rationale). **Must retain an adopted record until target + array of `{ workspaceId, from, to, workspace, settled }`, never an entry in + either window's snapshot (rationale); the tombstone rules below read `settled`. **Must retain an adopted record until target and source snapshots both reflect the move**, marking it settled at `adopt_done` and checking after each `save_session` or source-window close (`adoption_keeps_the_journal_until_both_snapshots_are_durable`). **Must reverse @@ -774,15 +786,18 @@ the release. **The target decides the drop index**: it alone knows its own tabs. a window the drag has already left, where it would burn until the next one. - **The caret clears when the pointer comes back over its own strip**, where the live reorder takes the gesture back. +- **Never assume in-range pointer coordinates.** A captured pointer keeps + reporting client coordinates past the window's edges and negative rather than + clamping (rationale). Source of truth: `window_at` in `standalone/src-tauri/src/routing.rs`; `standalone/src/workspace-drag.ts`; `standalone/src/workspace-drop-caret.ts`. ## Persistence -**Standalone persists one `PersistedWindow` per window** and restores every -Workspace in it on the next launch (`docs/specs/transport.md` → "The governing -rule"). The webview owns the composition: each Workspace's Wall publishes its +One `PersistedWindow` per window, restored on the next launch +(`docs/specs/transport.md` → "The governing rule", which owns the rule). The +webview owns the composition: each Workspace's Wall publishes its `PersistedSession` to the Window aggregator, whose one debounced writer is `TauriAdapter.saveWindowState` (`docs/specs/transport.md` → "Persisted session types"). `getWindowState` is the boot reader, and it **parses the blob once** — @@ -814,7 +829,8 @@ into fresh shells at its saved cwds. window opened after the first one closed would write a blob naming a Workspace id already live in another window's blob, and the next launch would meet the same id twice and refuse the whole restore. A bare Wall — one Window's whole - application — keeps the default id (`standalone/src/window-restore.test.ts`). + application — keeps the default id, from `wrapSessionInWindow`'s default + parameter in `lib/src/lib/session-types.ts`. Source of truth: `restoreWindowOrFresh` / `routeUnownedPtys` in `standalone/src/window-restore.ts`. @@ -841,8 +857,8 @@ every terminal's answer, as `getCwdsForPids` already guards. **Nothing is deleted at boot but orphaned session temp files** (`docs/specs/transport.md` → "Retiring the transcripts already on disk"). **The -harness mirrors this answer** (`docs/specs/transport.md` → Standalone browser-dev -harness), in `localStorage` and a per-run temp state directory. +harness mirrors this answer** (§Standalone browser-dev harness), in +`localStorage` and a per-run temp state directory. **Never back the session blob with WebKit `localStorage`** — a WAL that grows without bound (rationale). The blob rides the `SessionKeyValueStore` seam instead, @@ -896,10 +912,10 @@ own per-run temp directory. Source of truth: `recovery_state_dir` in **The sidecar owns the capture and the record** (`docs/specs/transport.md` → "Consuming it"): it holds the replay buffers the detection reads, and its lifetime is exactly one activation, so read-and-unlink has one home. Rust only bridges — -`capture_agent_recovery` and `take_recovery_commands`, both -`#[tauri::command(async)]` like every command reaching the blocking sidecar helper. -**Every sidecar answer rides `respondAsync`**, so a throw comes back as `{ error }` -rather than stranding the Rust invoke to its timeout. +`capture_agent_recovery` and `take_recovery_commands`, both async like every +command reaching the blocking sidecar helper. **Both sidecar answers ride +`respondAsync`**, so a throw comes back as `{ error }` rather than stranding the +Rust invoke to its timeout. - **The record is `/recovery.json`**, owner-only, temp-then-rename, written on every detection rather than once at the end, because the quit budget @@ -977,7 +993,7 @@ teardown at a time. Source of truth: `QuitMachine` in | Phase | What happens | |---|---| | Voting | every window acks, archives its own notes, asks about its own running work, and calls `quit_vote` — or `quit_cancel`, which tells every window and destroys nothing | -| Walking | `quit_teardown` reaches one window at a time, **`main` last**; each hands on with `quit_window_done`, and the last one installs and calls `quit_proceed` | +| Walking | the `dormouse://quit-teardown` event reaches one window at a time, **`main` last**; each hands on with `quit_window_done`, and the last one installs and calls `quit_proceed` | - **A cancel is refused once the walk starts**: the first window is already gone. - **A window that leaves outside the flow is forgotten**, so its vote is never @@ -1007,6 +1023,8 @@ Every trigger funnels into `request_quit(app)`: Source of truth: `standalone/src-tauri/src/macos_terminate.rs`. +### Quit protocol + **The ack / vote / progress / proceed / cancel protocol.** `request_quit` clears every window's `acked`, bumps `seq`, and broadcasts `dormouse://quit-requested`. It **must leave a walk in flight alone** — a repeat @@ -1103,7 +1121,9 @@ and `retains the letter across host remount and names hidden workspaces` in `ignores repeat Cmd+Q, other modified letters, and bare modifiers` in `lib/src/components/WorkspaceKillConfirm.test.tsx`. -**Teardown ordering (`runQuitTeardown`), and why.** **Every step is individually +### Teardown ordering + +**`runQuitTeardown`.** **Every step is individually bounded** so a stall cannot wedge quit, and the whole is wrapped in a ceiling **derived from the sum of those bounds, never a literal** — one below the sum aborts the final save of a slow teardown instead of guarding a wedged one. The two @@ -1140,10 +1160,10 @@ able to stop the quit (`docs/specs/notepad.md` -> "Standalone quit"): `finally`, even on throw/timeout). **Windows note.** node-pty's `kill('SIGTERM')` is an immediate kill under ConPTY, -so step 2 terminates promptly there, retaining the same final-output grace tick. +so step 3 terminates promptly there, retaining the same final-output grace tick. **Dev-mode note.** The browser-dev harness has no Rust quit interception, and the -flow never initializes there (§Boot sequence, step 5). +flow never initializes there (§Boot sequence, step 6). ### Restart @@ -1249,11 +1269,25 @@ Source of truth: `standalone/package.json` (package scripts), - **Must re-stage and restart after changing sidecar, staged CLI, or bundled host sources.** Frontend edits hot-reload; Tauri watches Rust. - `pnpm innerdogfood` runs the sidecar + webview in a normal browser via the - browser-dev harness instead of the Tauri WebView (`docs/specs/transport.md`, - Standalone browser-dev harness). + browser-dev harness instead of the Tauri WebView (below). + +### Standalone browser-dev harness + +`pnpm innerdogfood` starts the standalone sidecar directly, a localhost-only HTTP bridge, and Vite with `VITE_DORMOUSE_BROWSER_DEV_HOST`, then opens the app URL in an `agent-browser` session. The browser build uses `BrowserSidecarAdapter` instead of `TauriAdapter` whenever that env var is present. + +- **Must bind OS-assigned ports for Vite and the HTTP bridge by default** — native dev keeps `tauri.conf.json`'s `1420` unless `runDev` overrides it (above). +- **Must derive the default browser key from the canonical worktree path**, stable across restarts. **Must open through `dor ab` when `DORMOUSE_SURFACE_ID` is set**, otherwise through `agent-browser`; print the actual app URL, the browser identity it passed, and the command to drive it. **Must print a `--key` as a key, never as a session**: only the Workspace that will hold the browser can namespace one (`docs/specs/dor-browser.md` → "Managed identity"). Inside Dormouse, `dor ensure -- pnpm innerdogfood` starts and opens the harness. +- **May pin ports with `DORMOUSE_BROWSER_DEV_VITE_PORT` / `DORMOUSE_BROWSER_DEV_HOST_PORT` and the session with `DORMOUSE_BROWSER_DEV_AB_SESSION`.** An occupied pinned port fails startup; `0` requests an OS-assigned port. Explicit overrides are the caller's isolation responsibility. +- **Must await Vite's own listener before opening the browser and use the actual ports for bridge authentication and CORS.** **Must close the bridge and Vite and terminate owned sidecar and browser-launch children on startup failure or shutdown**, escalating to SIGKILL after three seconds. Pinned by `standalone/scripts/dev-agent-browser.test.mjs`. + +The bridge is a transport shim over the same sidecar protocol, not a second PTY implementation: fire-and-forget commands `POST /__dormouse_dev_host/send`, request/response commands `POST /__dormouse_dev_host/invoke`, host→webview events as SSE on `GET /__dormouse_dev_host/events`, and browser console output mirrored to `POST /__dormouse_dev_host/console` so one terminal shows sidecar, Vite, and in-browser logs together. The Burrow rides it too, on the message names in `docs/specs/transport.md` → "Message protocol", so the harness runs a real Burrow against a per-run temp state directory (§Burrow service). + +**The harness must keep logging the Burrow state directory in a form the pairing walkthrough parses**, which is how the walkthrough records that path before enrollment; pinned by `lib/src/lib/mirrored-constants.test.ts`. + +**The bridge is authenticated, and loopback is not what makes it safe** — it dispatches `pty_spawn` with caller-supplied `shell`, `args`, `cwd` and `env`, so reaching it is arbitrary command execution as the developer. Four rules hold **before routing and before any body read**, each argued in `dev-host-guard.mjs`'s comments: **every request carries `?t=`**, a per-run credential baked into the `VITE_DORMOUSE_BROWSER_DEV_HOST` URL that `BrowserSidecarHost.url()` alone attaches and that is never the `dor` control-API `controlToken` (rationale); **`Host` must be `127.0.0.1:` or `localhost:`**; **non-GET requests must be `application/json`**; and **`access-control-allow-origin` names the Vite origin exactly, never `*`** (rationale), on every response including the SSE stream. -## Terminal context host operations +**A CORS preflight is the one carve-out**: an `OPTIONS` answers `204` carrying those headers *before* the token check, since it can never present the non-GET content type the gate demands. **Any other unauthorized caller gets the same `404 not found` as an unknown path**, so the port does not identify itself. The harness prints the token and a ready-made `curl` on startup. -The adapter forwards every `TerminalContextRequest` to the PTY host as a correlated request (`docs/specs/transport.md` → Auxiliary helper metadata); directory opening follows `docs/specs/security-local.md` → Terminal context directory actions, and inspection failure follows `docs/specs/terminal-context.md` → Helper lifecycle. +The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser, and the sidecar-hosted alert stores (`alert_command` in, their broadcasts back; `docs/specs/alert.md`) — so a rule that only holds across the host boundary is exercised rather than answered by a private copy. **`BrowserSidecarHost.init()` resolves on the SSE stream being open, not on its construction**, so a seed cannot precede the stream that carries its reply; **must let retryable connection failures reconnect within the open timeout**; after a reconnect the adapter re-sends its last seeds so the stores republish (`resolves on the stream's open event, not on construction` in `standalone/src/browser-sidecar-host.test.ts`; `re-sends its last seeds when the event stream reconnects` in `standalone/src/browser-sidecar-adapter.test.ts`). Fatal startup handling follows §Boot sequence. It **must mirror** standalone's Session-persistence answer (`docs/specs/transport.md` → "The governing rule"): one `PersistedWindow` per window, in `localStorage` rather than the Rust file store, and the same agent-recovery *claim* against a per-run temp state directory. **The harness must never capture**: a reload there is a live resume over PTYs that survive it, and capture is a quit-only step (§Agent recovery). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView. -Source of truth: `terminalContext` in `standalone/src/tauri-adapter.ts`; `pty_context` in `standalone/src-tauri/src/lib.rs`; `context` in `standalone/sidecar/pty-core.js`. +Source of truth: `standalone/scripts/dev-agent-browser.mjs`, `standalone/scripts/dev-run.mjs`, `standalone/scripts/dev-host-guard.mjs`, `standalone/src/browser-sidecar-host.ts`, `standalone/src/browser-sidecar-adapter.ts`; `stepBurrow` in `scripts/pairing-walkthrough/steps.mjs`; `sessionForKey` in `dor-lib-common/src/agent-browser.ts`. diff --git a/docs/specs/standalone.rationale.md b/docs/specs/standalone.rationale.md index dee5dcf84..17a3bb5e6 100644 --- a/docs/specs/standalone.rationale.md +++ b/docs/specs/standalone.rationale.md @@ -195,8 +195,7 @@ edges and negative rather than clamping — measured against macOS 26.6 / WKWebV 2026-09, by synthesizing an AppKit drag whose later points fall outside a 400×300 window and reading the page's own event log: `down` inside the element, then moves at client x 600 and (900, −152), then the release. So the gesture -stays the webview's throughout and no polling loop is needed; the drag -controller must simply not assume in-range coordinates. +stays the webview's throughout and no polling loop is needed. ## Persistence @@ -284,3 +283,11 @@ and the Vite server it loads from. `tauri::process::restart` just calls `exit(0) when `current_binary` fails — on macOS, for a path through a symlink (`tauri-utils-2.9.3/src/platform/starting_binary.rs`) — so the check turns a silent quit into an answer. + +## Standalone browser-dev harness + +**Why the bridge token is not the `dor` control token.** The `dor` control-API `controlToken` is handed to every shell Dormouse spawns; the bridge's circle is smaller than "every terminal on the machine", so it mints its own per-run credential. + +**Why the CORS origin is never `*`.** It was `*` once: the bridge's clipboard invokes were readable cross-origin under it — a foreign page could POST an invoke and read the reply. + +**Agent workflows were unaffected by the gate.** The token reaches the page through the `VITE_DORMOUSE_BROWSER_DEV_HOST` env var the harness already sets, and `agent-browser` drives the Vite origin, never the bridge. diff --git a/docs/specs/transport.md b/docs/specs/transport.md index bf33da727..82ef1b6ef 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -24,32 +24,6 @@ Optional booleans: | `hostOwnsTheme?` | `false` | `VSCodeAdapter` → `true` | Settings hides its theme picker (`docs/specs/theme.md` → "Where the user picks a theme") | | `hostOwnsShells?` | `false` | `VSCodeAdapter` → `true` | Settings hides its Shell row for the native QuickPick (`docs/specs/vscode.md` → "Shell selection") | -### Standalone browser-dev harness - -`pnpm innerdogfood` starts the standalone sidecar directly, a localhost-only HTTP bridge, and Vite with `VITE_DORMOUSE_BROWSER_DEV_HOST`, then opens the app URL in an `agent-browser` session. The browser build uses `BrowserSidecarAdapter` instead of `TauriAdapter` whenever that env var is present. - -- **Must bind OS-assigned ports for Vite and the HTTP bridge by default.** -- **Must derive the default browser key from the canonical worktree path**, stable across restarts. **Must open through `dor ab` when `DORMOUSE_SURFACE_ID` is set**, otherwise through `agent-browser`; print the actual app URL, session, and command to drive it. Inside Dormouse, `dor ensure -- pnpm innerdogfood` starts and opens the harness. -- **May pin ports with `DORMOUSE_BROWSER_DEV_VITE_PORT` / `DORMOUSE_BROWSER_DEV_HOST_PORT` and the session with `DORMOUSE_BROWSER_DEV_AB_SESSION`.** An occupied pinned port fails startup; `0` requests an OS-assigned port. Explicit overrides are the caller's isolation responsibility. -- **Must await Vite's own listener before opening the browser and use the actual ports for bridge authentication and CORS.** **Must close the bridge and Vite and terminate owned sidecar and browser-launch children on startup failure or shutdown**, escalating to SIGKILL after three seconds. Pinned by `standalone/scripts/dev-agent-browser.test.mjs`. - -The bridge is a transport shim over the same sidecar protocol, not a second PTY implementation: fire-and-forget commands `POST /__dormouse_dev_host/send`, request/response commands `POST /__dormouse_dev_host/invoke`, host→webview events as SSE on `GET /__dormouse_dev_host/events`, and browser console output mirrored to `POST /__dormouse_dev_host/console` so one terminal shows sidecar, Vite, and in-browser logs together. The Burrow rides it too, on the message names below ("Message protocol"), so the harness runs a real Burrow against a per-run temp state directory (`docs/specs/standalone.md` → "Burrow service"). - -**The harness must keep logging the Burrow state directory in a form the pairing walkthrough parses**, which is how the walkthrough records that path before enrollment; pinned by `lib/src/lib/mirrored-constants.test.ts`. - -**The bridge is authenticated, and loopback is not what makes it safe** — it dispatches `pty_spawn` with caller-supplied `shell`, `args`, `cwd` and `env`, so reaching it is arbitrary command execution as the developer (rationale). Four rules, enforced in `dev-host-guard.mjs` **before routing and before any body read**: - -- **Every request carries `?t=`**, a per-run 24-byte credential baked into the `VITE_DORMOUSE_BROWSER_DEV_HOST` URL and compared with `timingSafeEqual` over SHA-256 digests (rationale). It rides the query rather than an `Authorization` header because `EventSource` cannot set headers and `/events` is gated like the rest; `BrowserSidecarHost.url()` is the only place that attaches it. Not the `dor` control-API `controlToken` (rationale). -- **`Host` must be `127.0.0.1:` or `localhost:`**, against DNS rebinding (rationale). -- **Non-GET requests must be `application/json`**, which keeps the endpoints out of CORS-*simple* (rationale). Enforced in the gate rather than the body reader, so a route that never parses a body is covered too. -- **`access-control-allow-origin` names the Vite origin exactly, never `*`**, on every response including the SSE stream; both loopback spellings of that origin are accepted and echoed back (rationale). - -**An unauthorized caller gets the same `404 not found` as an unknown path**, so the port does not identify itself. The harness prints the token and a ready-made `curl` on startup. - -The harness **may omit** native-only desktop chrome (window controls, update checks) but **must preserve** every `PlatformAdapter` contract the app uses — PTY, control-request, clipboard, iframe-proxy, Burrow, agent-browser, and the sidecar-hosted alert stores (`alert_command` in, their broadcasts back; `docs/specs/alert.md`) — so a rule that only holds across the host boundary is exercised rather than answered by a private copy. **`BrowserSidecarHost.init()` resolves on the SSE stream being open, not on its construction**, so a seed cannot precede the stream that carries its reply; **must let retryable connection failures reconnect within the open timeout**; after a reconnect the adapter re-sends its last seeds so the stores republish (`resolves on the stream's open event, not on construction` in `standalone/src/browser-sidecar-host.test.ts`; `re-sends its last seeds when the event stream reconnects` in `standalone/src/browser-sidecar-adapter.test.ts`). Fatal startup handling follows `docs/specs/standalone.md` → Boot sequence. It **must mirror** standalone's Session-persistence answer ("The governing rule"): the same `persistsSession`, one `PersistedWindow` per window (in `localStorage` rather than the Rust file store), and the same agent-recovery *claim* against a per-run temp state directory. **The harness must never capture**: a reload there is a live resume over PTYs that survive it, and capture is a quit-only step (`docs/specs/standalone.md` → "Agent recovery"). **Tauri APIs must not be required at static module-evaluation time** when `VITE_DORMOUSE_BROWSER_DEV_HOST` is set — a normal browser loads the page, not the Tauri WebView. - -Source of truth: `standalone/scripts/dev-agent-browser.mjs`, `standalone/scripts/dev-run.mjs`, `standalone/scripts/dev-host-guard.mjs`, `standalone/src/browser-sidecar-host.ts`, `standalone/src/browser-sidecar-adapter.ts`; `stepBurrow` in `scripts/pairing-walkthrough/steps.mjs`; `sessionForKey` in `dor-lib-common/src/agent-browser.ts`. - ## PTY lifecycle PTYs are managed by the platform host, not by the webview. The webview **resumes** over live PTYs (host-preserved) or **restores** from a Snapshot (cold start). @@ -119,7 +93,8 @@ carrying a different one. One webview can have two collections outstanding — a boot and a Workspace arriving from another window, or two arrivals — and every listener sees every answer, so without the token each finishes on the other's list and concludes the host holds nothing. **An answer carrying no token is -taken**: a host with one webview has nothing to tell apart. **A collection that +taken**: the hosts that do not echo one (VS Code, Pocket, the website) run one +collector per JS realm, so there is nothing to tell apart. **A collection that timed out is not a collection that found no PTYs** — those shells are still running, and a caller that cold-restores there starts a second set over them, so `LivePtys` reports which it was. **A collector given `retryTimeoutMs` asks once @@ -200,7 +175,7 @@ remainder` in `standalone/sidecar/pty-core.test.js` and Source of truth: the message schema in `vscode-ext/src/message-types.ts` (`WebviewMessage`, `ExtensionMessage`; other adapters import or mirror it), persisted-session types in `lib/src/lib/session-types.ts`, webview handlers in the adapter modules (`lib/src/lib/platform/vscode-adapter.ts`, `lib/src/lib/platform/fake-adapter.ts`), host handlers in the per-adapter message router. The schema is exhaustive there; below are only the contracts the types do not carry. -**Sender authenticity is the adapter's job, not the protocol's**, and **an adapter whose transport is reachable by page content must authenticate before it branches on `type`.** Tauri uses private IPC; browser-dev uses the authenticated HTTP/SSE bridge ("Standalone browser-dev harness"). VS Code shares its `window` inbox with framed surfaces and requires a per-boot token on every host message (`docs/specs/vscode.md` → "Webview message authentication"). +**Sender authenticity is the adapter's job, not the protocol's**, and **an adapter whose transport is reachable by page content must authenticate before it branches on `type`.** Tauri uses private IPC; browser-dev uses the authenticated HTTP/SSE bridge (`docs/specs/standalone.md` → "Standalone browser-dev harness"). VS Code shares its `window` inbox with framed surfaces and requires a per-boot token on every host message (`docs/specs/vscode.md` → "Webview message authentication"). **`dormouse:runWorkbenchCommand` (webview → host) is allowlisted** against `lib/src/lib/vscode-keybindings.ts` before `vscode.commands.executeCommand`; generic command execution over the webview boundary is not allowed. @@ -216,7 +191,7 @@ Each host maps those calls onto its own transport: Transport constraints: - **VS Code broadcasts results**, safe because a `burrowRequestId` carries a per-adapter random tag and is globally unique, so only the adapter that asked can settle one (rationale; `docs/specs/vscode.md` → "Peer surfaces across windows"). -- **Standalone's correlation field is `burrowRequestId`, never `requestId`** — Rust swallows any sidecar line whose `data.requestId` matches a pending invoke (`docs/specs/standalone.md` → "Burrow service"). +- **Standalone's correlation field is `burrowRequestId`, never `requestId`** (`docs/specs/standalone.md` → "Burrow service", which owns the rule). **Workspace union status adds no message.** Its projection and shipped host displays are owned by `docs/specs/alert.md` → Workspace union; the browser Surface-state message is staged in `docs/specs/vscode.md` → Future. @@ -248,13 +223,13 @@ OSC parsing/stripping rules for those rows, and the rule that **only the process **Workspace-scoped dor refs.** A `PersistedSession` may record `surfaceRefs` — stable Surface id → Workspace-local `dor` short ref (`surface:N`) — plus `surfaceRefsNext`, the next number to hand out. Ref-preserving layout moves and replacement transfers follow `docs/specs/dor-cli.md` → Handle Model. **Must drop a killed Surface's entry without reusing its retired ref**: persist `surfaceRefsNext` independently rather than deriving it from the map, and clamp it above the map's highest ref on load. Old snapshots without the fields allocate refs from the restored Surfaces on first mount. -**Surface kinds in the snapshot.** Each `PersistedPane` records a `surfaceType` (`docs/specs/glossary.md`): `'terminal'` — the default, **omitted from the row** so terminal snapshots stay byte-identical — or `'browser'`. It routes restore/resume, and **a pane lacking it reads as `'terminal'`**. `restoreSession` skips terminal restoration for a browser pane rather than minting a stray PTY + xterm per browser pane id, and the resume plan keeps browser panes and minimized browser doors despite their having no live PTY, so the saved layout's leaf set still matches and is not discarded. A browser pane rebuilds from the persisted layout (visible) or `PersistedDoor.params` (minimized) — its render params (`renderMode`, `url`, agent-browser `session`) live there, not in `PersistedPane`. **Must reject a layout whose leaves differ from the visible pane set during restore or resume, and omit visible browser ids from the terminal fallback.** Browser doors retain their independent render params; pinned by `lib/src/lib/session-restore.test.ts` and `lib/src/lib/reconnect.test.ts`. +**Surface kinds in the snapshot.** Each `PersistedPane` records a `surfaceType` (`docs/specs/glossary.md`): `'terminal'` — the default, **omitted from the row** so terminal snapshots stay byte-identical — `'browser'`, or `'tool'`, whose extra `command` and `tool` fields are `docs/specs/dor-tool.md` → Persistence and hosts. It routes restore/resume, and **a pane lacking it reads as `'terminal'`**. `restoreSession` skips terminal restoration for a browser pane rather than minting a stray PTY + xterm per browser pane id, and the resume plan keeps browser panes and minimized browser doors despite their having no live PTY, so the saved layout's leaf set still matches and is not discarded. A browser pane rebuilds from the persisted layout (visible) or `PersistedDoor.params` (minimized) — its render params (`renderMode`, `url`, agent-browser `session`) live there, not in `PersistedPane`. **Must reject a layout whose leaves differ from the visible pane set during restore or resume, and omit visible browser ids from the terminal fallback.** Browser doors retain their independent render params; pinned by `lib/src/lib/session-restore.test.ts` and `lib/src/lib/reconnect.test.ts`. **Each mounted Workspace publishes its `PersistedSession` to a Window collector**, which orders them by the Workspace store and writes the whole Window through one debounced writer the host installs at boot. **A Workspace with neither a published nor a boot-seeded session is dropped rather than written empty**, so a snapshot taken mid-boot cannot replace a restored Workspace with a blank one. **A Workspace's save compares against its own previous record** — seeded from disk until its Wall publishes — never the Window's active one, or a dead PTY's retained cwd and alert would come from the wrong Workspace. **Reordering, renaming, or switching the active Workspace writes too**: each changes the blob with no Session changing. A `PersistedWorkspace` is a `WorkspaceId`, a user-facing `name`, and that Workspace's `PersistedSession`. The top-level snapshot is a `PersistedWindow` (its own `version: 1`) wrapping v3 sessions: the ordered `PersistedWorkspace` list plus the active `WorkspaceId`. **VS Code does not use it** — each webview persists one bare `PersistedSession`, its single Workspace, through its own per-surface state API (`docs/specs/vscode.md`). **The Window wrapping lives at the standalone adapter boundary, never in the shared save/restore code.** `window-persistence.ts` owns the JSON and the storage slot over a `SessionKeyValueStore` synchronous slot (`docs/specs/standalone.md` → Persistence); the shared save/restore code still operates on a bare `PersistedSession`, which the boot hands it per Workspace. **A Window-persisting adapter answers through `getWindowState` / `saveWindowState`, and answers nothing on the bare-Session `getState` / `saveState` pair** — its blob is a Window and every shared reader of `getState` wants a Session. **A blob written before standalone persisted Windows is wrapped as the window's one Workspace**; that is the only migration. -**A save probes every terminal pane's cwd in one host round trip where the adapter offers `getCwds`**, falling back to one `getCwd` per id: standalone resolves them with a synchronous process scan on the sidecar's only event loop, so N panes must cost one scan rather than N. **A flush may say `probeCwd: false`** and keep each pane's previously persisted cwd — the post-kill quit flush, where every probe answers null and is discarded for that value anyway (`docs/specs/standalone.md` → "Quit flow"). Pinned by `lib/src/lib/session-save.test.ts`. +**A save probes every non-browser pane's cwd in one host round trip where the adapter offers `getCwds`**, falling back to one `getCwd` per id: standalone resolves them with a synchronous process scan on the sidecar's only event loop, so N panes must cost one scan rather than N. **A flush may say `probeCwd: false`** and keep each pane's previously persisted cwd — the post-kill quit flush, where every probe answers null and is discarded for that value anyway (`docs/specs/standalone.md` → "Quit flow"). Pinned by `lib/src/lib/session-save.test.ts`. Source of truth: `getWindowSnapshot` / `seedWindowSession` / `installWindowSessionWriter` / `flushWindowSession` in `lib/src/lib/window-session-aggregator.ts`; `SaveSink` / `SaveOptions` in `lib/src/lib/session-save.ts`; `loadWindowState` / `saveWindowState` in `lib/src/lib/window-persistence.ts`; `restoreWindowOrFresh` in `standalone/src/window-restore.ts`. @@ -266,12 +241,12 @@ Source of truth: `getWindowSnapshot` / `seedWindowSession` / `installWindowSessi *One writer per host, exactly one read.* The writer is that host's teardown — the VS Code extension host's `deactivate()` (`docs/specs/vscode.md` → "Capturing agent recovery") and the Tauri sidecar's quit capture (`docs/specs/standalone.md` → "Agent recovery"); the renderer save path never derives one. Both run the same detection machine, `captureAgentRecovery` in `lib/src/host/recovery-capture.ts`, over their own PTY buffers, and keep the record through the same `createRecoveryStore`. **Cold restore is the one reader — resume never reads it**, the agent there still being Live. The record is read and unlinked on the first claim of a host process, **destructively even on a parse failure**, so the durable copy is gone before any webview is served; within that process **each container claims only the entries matching its own saved pane ids** (rationale). **A record older than 7 days is discarded unread.** -*Detection.* Executable-string constraints: +*Detection.* Executable-string constraints; the grammars that implement them, and their edge cases, are in the comments at the two modules below. -- **Only a known invocation plus an opaque id.** The command is *rebuilt* as label + captured id, never sliced from the buffer; the id grammar is alphanumeric/hyphen/underscore only, so shell punctuation cannot enter executable state. Anything trailing the id is dropped. The invocation must be followed by a word break (`claude --continuex` is not an offer to continue) but nothing stronger (rationale). -- **The scan window is stripped as a whole, in one pass, and an unterminated control swallows the rest of it** — the string controls (OSC, DCS, SOS, PM, APC) **in either introducer form, `ESC` or bare C1**, and equally a CSI the window was cut off *inside* (rationale). "Terminated" tracks what the renderer honours rather than ECMA-48 alone: ST in both forms (`\x1b\\`, `\x9c`), BEL for OSC, plus CAN/SUB and a bare ESC. **Match every escape by its full ECMA-48 shape** — ESC, intermediates, one final byte — never by the Fe range (rationale). **One implementation**: `stripTerminalControls` removes string controls by running `TerminalControlStreamFilter`, so the batch and streaming readers cannot disagree. -- **Stripping runs in boundary mode, whose rule is inverted**: *every* control becomes a newline rather than vanishing, except SGR and charset designators, the two classes that neither move the cursor nor erase. Cursor moves outside CSI count — `ESC M`, `ESC 7`/`ESC 8`, `ESC c`, VT/FF/backspace (rationale). -- **Rightmost match in the last 50 lines wins.** No pattern spans the newline a boundary leaves, so the stripped window is scanned whole; rightmost is newest *by position*, never by pattern order (rationale). **Restore revalidates through `normalizeResumeCommand` before typing**, against a snapshot written by an older detector. +- **Only a known invocation plus an opaque id.** The command is *rebuilt* as label + captured id, never sliced from the buffer; the id grammar is alphanumeric/hyphen/underscore only, so shell punctuation cannot enter executable state. The invocation must end on a word break but nothing stronger (rationale). +- **The scan window is stripped as a whole, in one pass, and an unterminated control swallows the rest of it** — the string controls (OSC, DCS, SOS, PM, APC) **in either introducer form, `ESC` or bare C1**, and equally a CSI the window was cut off *inside* (rationale). **Match every escape by its full ECMA-48 shape**, never by the Fe range (rationale). **One implementation**: `stripTerminalControls` removes string controls by running `TerminalControlStreamFilter`, so the batch and streaming readers cannot disagree. +- **Stripping runs in boundary mode, whose rule is inverted**: *every* control becomes a newline rather than vanishing, except SGR and charset designators, the two classes that neither move the cursor nor erase (rationale). +- **Rightmost match in the last 50 lines wins**, newest *by position* and never by pattern order (rationale). **Restore revalidates through `normalizeResumeCommand` before typing**, against a snapshot written by an older detector. Source of truth: `PersistedSession` in `lib/src/lib/session-types.ts`; `surfaceRefs` in `lib/src/components/Wall.tsx`; `saveSession` in `lib/src/lib/session-save.ts`; `restoreSession` in `lib/src/lib/session-restore.ts`; the resume plan in `lib/src/lib/reconnect.ts`; `captureAgentRecovery` in `lib/src/host/recovery-capture.ts`; `createRecoveryStore` in `lib/src/host/recovery-store.ts`; `takeRecoveryCommands` in `vscode-ext/src/session-state.ts`; `getRecoveryCommands` in `lib/src/lib/platform/vscode-adapter.ts`; `detectResumeCommand` / `normalizeResumeCommand` in `lib/src/lib/resume-patterns.ts`; `stripTerminalControls` in `lib/src/lib/terminal-controls.ts`. @@ -307,7 +282,7 @@ something ends it: | VS Code Reload Window | No — an editor operation, not an ending | Restore structure + auto-resume agents | | VS Code window close / application quit | No — window state is the host's contract | Restore structure + auto-resume agents | | VS Code editor-tab close (`killOnDispose: true`) | Yes | Fresh for that panel | -| VS Code extension-host crash | No, but `deactivate()` never ran | Fresh | +| VS Code extension-host crash | No, and the last periodic save stands | Restore structure, no agent resume — `deactivate()` never ran | Ending something deliberately is also what *keeps* its notes: **a deliberate closure archives the Surface's notepad before teardown** (`docs/specs/notepad.md` → "Closure"), so the one thing a user asked to hold on to survives the boundary that discards everything else. @@ -341,7 +316,7 @@ prompt** (rationale). ## Universal invariants -- **Must preserve VS Code scrollback across PTY exit.** In `pty-manager.ts` only `kill`/`killAll` (or host-process exit) clears it; natural exit, signal-driven exit, and `gracefulKillAll` leave it readable via `getScrollback` (rationale). +- **Must preserve VS Code scrollback across PTY exit.** In `pty-manager.ts` only `kill`/`killAll`, a `spawn` reusing the id, or host-process exit clears it; natural exit, signal-driven exit, and `gracefulKillAll` leave it readable via `getScrollback` (rationale). - **A position in a pane's output is a received count, not a buffer length.** The capped host-side buffer evicts from the front, so `scrollbackChars` goes flat while output keeps flowing (rationale). Anything marking a point in the stream, or watching a pane for growth, reads the monotonic `getScrollbackReceived` and slices with `getScrollbackSince`, which joins only the chunks spanning the mark and clamps to what the buffer still holds. - **A spawn that fails still reports an exit.** `pty-core.spawn` answers a node-pty failure with `error` *and* `exit`; `error` reaches no webview (rationale). - **Teardown acks are correlated by request id, never by message type alone.** For `interrupt` and the graceful kill (`gracefulKillAll` in VS Code, `gracefulKill(ids)` in the sidecar, since one standalone window tears down alone) the pty-host echoes `requestId` on `interruptDone` / `gracefulKillDone` and the caller compares it — a timed-out teardown call's ack still arrives afterwards (rationale). @@ -375,13 +350,13 @@ Source of truth: `openPortRequestTimeoutMs` in `lib/src/lib/platform/types.ts`; **Must carry helper parent identity and captured autorun command in live PTY metadata**, validating that the parent is owned and is not itself a helper. Promotion clears that association without restarting the PTY. Reconnect restores helper entries before reconciling the primary layout, excluding them from ordinary orphan-pane recovery. A missing parent recovers its helper as an ordinary Pane. Recovered helpers conservatively disable automatic refresh. -**Must retain Standalone replay only in memory**, bounded to the latest 200,000 UTF-16 code units per live PTY and sent after its live listing. Closing a PTY releases its buffer. Cold starts retain the existing host policy; helper scrollback and editor buffers are never written to Session snapshots. +**Must retain Standalone replay only in memory**, bounded to the latest 200,000 UTF-16 code units per spawned, unkilled PTY — naturally exited ones included, which is what lets a transfer replay their tail ("Transferring a Workspace") — and sent after its live listing. An explicit kill releases the buffer. Cold starts retain the existing host policy; helper scrollback and editor buffers are never written to Session snapshots. -**Must expose terminal context operations through correlated host requests**, reporting errors and timeouts. The VS Code router checks Workspace ownership for per-terminal operations and helper parent metadata. +**Must expose terminal context operations through correlated host requests**, reporting errors and timeouts: each adapter forwards every `TerminalContextRequest` to its PTY host and answers on the same correlation. The VS Code router checks Workspace ownership for per-terminal operations and helper parent metadata. Directory opening follows `docs/specs/security-local.md` → Terminal context directory actions, and inspection failure `docs/specs/terminal-context.md` → Helper lifecycle. **Must acknowledge Windows directory opening when Explorer starts**, reporting process-launch errors without waiting for its exit. macOS and Linux retain opener exit-error reporting. -Source of truth: `TerminalContextRequest` in `lib/src/lib/terminal-context-types.ts`; `PtyInfo` in `lib/src/lib/platform/types.ts`; `resumeOrRestore` in `lib/src/lib/reconnect.ts`; `context` in `standalone/sidecar/pty-core.js`; `attachRouter` in `vscode-ext/src/message-router.ts`. +Source of truth: `TerminalContextRequest` in `lib/src/lib/terminal-context-types.ts`; `PtyInfo` in `lib/src/lib/platform/types.ts`; `resumeOrRestore` in `lib/src/lib/reconnect.ts`; `context` in `standalone/sidecar/pty-core.js`; `attachRouter` in `vscode-ext/src/message-router.ts`. Per host: `terminalContext` in `lib/src/lib/platform/vscode-adapter.ts` and `vscode-ext/src/pty-manager.ts`; `terminalContext` in `standalone/src/tauri-adapter.ts` and `pty_context` in `standalone/src-tauri/src/lib.rs`. ## Tool transport diff --git a/docs/specs/transport.rationale.md b/docs/specs/transport.rationale.md index 94fec9af4..8266716c2 100644 --- a/docs/specs/transport.rationale.md +++ b/docs/specs/transport.rationale.md @@ -6,26 +6,6 @@ **What `persistsSession: false` actually saves.** The expensive half of a save is the record build, not the write: one `getCwd` round trip per pane (`docs/specs/standalone.md` → "Persistence", whose rationale prices it). No shipped adapter answers `false` today; the gate stays because a host that persists nothing must not pay for a record it discards. -## Standalone browser-dev harness - -**Why the bridge needs authentication at all.** Loopback is not an access control: any web page open in the developer's own browser reaches `127.0.0.1` as readily as the dev page does, and an unauthenticated bridge hands it arbitrary command execution as the developer (`docs/specs/security-local.md` → "Loopback Listeners"). - -**Why the token is digested before comparison.** `timingSafeEqual` throws on unequal-length inputs, so raw-string comparison turns a wrong-length guess into an exception rather than a refusal; hashing both sides to SHA-256 makes every comparison equal-length. - -**Why the bridge token is not the `dor` control token.** The `dor` control-API `controlToken` is handed to every shell Dormouse spawns; the bridge's circle is smaller than "every terminal on the machine", so it mints its own per-run credential. - -**How DNS rebinding defeats a loopback bind.** A hostile domain re-resolved to `127.0.0.1` arrives with its own name in `Host`; the browser treats the result as same-origin, so CORS never applies. Pinning `Host` is the check that survives it. - -**What a CORS-*simple* endpoint costs.** A foreign page can POST with `mode: 'no-cors'` and, though it cannot read the reply, the request still executes — and executing is the whole risk here. `application/json` forces a preflight it cannot pass. - -**Why the CORS origin is never `*`.** It was `*` once: the bridge's clipboard invokes were readable cross-origin under it — a foreign page could POST an invoke and read the reply. - -**Why both loopback spellings are echoed.** `127.0.0.1:` and `localhost:` are the same dev page, and pinning one rejects a developer who typed the other with symptoms — blank terminal, console CORS errors — that do not point at the token gate. - -**Agent workflows were unaffected by the gate.** The token reaches the page through the `VITE_DORMOUSE_BROWSER_DEV_HOST` env var the harness already sets, and `agent-browser` drives the Vite origin, never the bridge. - -**Why the harness does not persist.** Persisting would restore panes across a reload the real app drops, so the harness would stop reproducing the cold-start behavior it exists to exercise — and would run the record build ("Adapter model") on a path production never takes. - ## Paced input Measured on macOS 27 with Claude Code 2.1.274 and Codex 0.154.0, 2026-09 (issue #679). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 475140ceb..5508664f0 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -39,29 +39,16 @@ The webview is the shared `lib/` frontend, unmodified for this host (`docs/specs ### Extension manifest -**Must activate on the contributed view, restored editor panels, or an invoked contributed command.** Command activation is implicit on the supported VS Code versions ([activation events](https://code.visualstudio.com/api/references/activation-events#oncommand)). The manifest owns contributed commands, views, and title actions: ids, titles, icons, and ordering. +**Must activate on the contributed view, restored editor panels, or an invoked contributed command.** Command activation is implicit on the supported VS Code versions ([activation events](https://code.visualstudio.com/api/references/activation-events#oncommand)). The manifest owns contributed commands, views, and title actions: ids, titles, icons, and ordering. The shipped commands are `dormouse.focus`, `dormouse.open`, `dormouse.debugTheme`, `dormouse.newTerminal`, and `dormouse.selectShell`. **No `configuration`, no `keybindings`, no context key**: settings live in the in-webview Settings dialog rather than `settings.json`, chords are handled inside the webview, and nothing is `when`-gated on Dormouse state. Context keys are [Future](#context-keys). Source of truth: `vscode-ext/package.json`. ### Webview hosting -VS Code-specific layout of the transport model: - -``` -Extension Host (always running while extension is active) -├── pty-manager.ts (forks pty-host.js child process) -│ ├── pty-1 (Process: Live) -│ ├── pty-2 (Process: Live) -│ └── pty-3 (Process: Exited) -│ -├── WebviewView "Dormouse" (bottom panel) -│ └── message-router: owns pty-1, pty-2 -│ -└── WebviewPanel "Dormouse" (editor tab, optional) - └── message-router: owns pty-3 -``` - -Consequences: +The extension host is the platform host of `docs/specs/transport.md` → "PTY +lifecycle": `pty-manager.ts` forks the pty-host child, the bottom-panel +`WebviewView` and each editor-tab `WebviewPanel` are the webviews, and each +router owns its own PTY ids. Consequences: - Hiding or toggling the Dormouse panel neither kills its PTYs nor destroys sessions. - **Closing an editor-tab `WebviewPanel` is not hiding it.** `setupPanel` attaches @@ -97,10 +84,9 @@ Reflection updates on every owned-PTY `AlertManager.onStateChange` and on `claim WATCHING rules and the alarm settings (`docs/specs/alert.md` → Alarm settings) are app-global rather than per-Workspace, so both ride the host-authoritative seed / mutate / broadcast channel `docs/specs/transport.md` → Message protocol -specifies. Two things are this host's: **the seeding offer is the first one -after extension-host startup**, and the host installs only -`inactivityTimeoutMs` on the shared `AlertManager` while relaying the whole -settings blob. +specifies. One thing is this host's: **the seeding offer is the first one +after extension-host startup**. The settings a shared `AlertManager` consumes +are `AlertManager.applySettings`'s own list, not this host's choice. Source of truth: `WatchedCommandHost` in `lib/src/lib/watched-command-host.ts`, `AlertSettingsHost` in `lib/src/lib/alert-settings-host.ts`, the alert cases in @@ -165,58 +151,40 @@ needs no host-side per-panel store. #### Capturing agent recovery -**Write `^C` into the pty; never signal it** — the agents print their resume -invocation when interrupted, not when signalled, and the tty line discipline -delivers the SIGINT to the foreground process group, so the hint comes back as -ordinary `pty:data` (rationale). -**Interrupt every live PTY, not just recognized agents** — `detectResumeCommand` -filters the recovery invocations (rationale) — but -**exclude exited PTYs**, which can yield no hint and would permanently defeat the -"nothing left to wait for" early exit. - -**Press-wait-press, gated per pane.** claude prints its hint only on a *second* -press (after `Press Ctrl-C again to exit`); codex prints after the first, at a -latency that is not a constant, and a second press arriving mid-print destroys -its hint entirely. So: one `^C` to every live PTY, then poll on a 40 ms tick, -sending one more `^C` to a pane that has yielded nothing either the moment it -asks (`Press Ctrl-C again`) or once ~600 ms have passed with ~200 ms of silence -— quiet is evidence that a print is not in flight, **not** that the pane is -finished. **Both clocks start when the first press is *acked***, not at step -entry (rationale); the poll's ~1.2 s wall-clock ceiling is the one timing -anchored to entry, being a shutdown budget rather than an agent timing. **Never -finish early on quiet** — the only sound early exit is having nothing left to -wait for (rationale). **The ask gate keys on an English UI string on purpose** -(rationale). - -**Never simplify to a single gesture**: a blanket second press destroys codex's -idle case, an ask-gated one never fires for codex at all, and the constants are -sized against real-pty measurements so codex's idle case leaves the retry set -before the ~600 ms fallback (rationale). - -**Only post-interrupt bytes count, and never widen that scan.** Each pane's -received-count mark (`getScrollbackReceived`, `docs/specs/transport.md` → -Universal invariants) is taken before the first `^C`, and detection reads only -what arrived after it — a correctness boundary, since the command auto-runs on -the next restore without confirmation (`docs/specs/transport.md` → "Consuming -it") and only bytes produced in response to Dormouse's own interrupt may become -executable state (rationale). - -**Clear any previous record before the first early return** — consumption happens -only when a container actually resolves, so a session where the Dormouse view is -never opened would otherwise auto-run a week-old invocation on some much later -restore. A missing hint is ordinary: `CLAUDE_CODE_CHILD_SESSION` in a pane's env -disables transcript saving in claude, which then prints none (rationale). - -**Both halves are shared with the Tauri sidecar.** Every detection rule above -lives in `captureAgentRecovery` (`lib/src/host/recovery-capture.ts`), over four +**Both halves are shared with the Tauri sidecar.** The press-wait-press machine +is `captureAgentRecovery` (`lib/src/host/recovery-capture.ts`), over four primitives each host supplies — the live id set, interrupt, a monotonic received count, and the output since a mark — and the record itself is `createRecoveryStore` (`docs/specs/standalone.md` → "Agent recovery"), so one -format, one destructive read, and one set of file modes serve both. What stays -here is which PTYs the extension host offers them and which directory the store -writes into: `storageUri`, falling back to `globalStorageUri`. It is a plain file -written synchronously because `workspaceState` batches its flush and -`deactivate()` outruns it (rationale). +format, one destructive read, and one set of file modes serve both. Its timings, +its gates and the arguments for each are in that module's comments; the rules +that constrain a host are: + +- **Write `^C` into the pty; never signal it** — the agents print their resume + invocation when interrupted, not when signalled (rationale). +- **Interrupt every live PTY, not just recognized agents** — `detectResumeCommand` + filters the recovery invocations (rationale) — but **exclude exited PTYs**, + which can yield no hint and would permanently defeat the "nothing left to wait + for" early exit. +- **Never finish early on quiet**, and **never simplify to a single gesture**: + claude prints only on a second press, codex only on the first and at no + constant latency, so a blanket second press destroys codex's idle case and an + ask-gated one never fires for codex at all (rationale). +- **Only post-interrupt bytes count, and never widen that scan.** Each pane's + received-count mark (`getScrollbackReceived`, `docs/specs/transport.md` → + Universal invariants) is taken before the first `^C` — a correctness boundary, + since the command auto-runs on the next restore without confirmation + (`docs/specs/transport.md` → "Consuming it") and only bytes produced in + response to Dormouse's own interrupt may become executable state (rationale). +- **Clear any previous record before the first early return** — consumption + happens only when a container actually resolves, so a session where the + Dormouse view is never opened would otherwise auto-run a week-old invocation on + some much later restore. A missing hint is ordinary (rationale). + +What stays this host's is which PTYs the extension host offers the machine and +which directory the store writes into: `storageUri`, falling back to +`globalStorageUri`. It is a plain file written synchronously because +`workspaceState` batches its flush and `deactivate()` outruns it (rationale). Source of truth: `captureAgentRecoveryCommands` and `takeRecoveryCommands` in `vscode-ext/src/session-state.ts`, `captureAgentRecovery` in @@ -290,7 +258,7 @@ Source of truth: `getWebviewHtml` in `vscode-ext/src/webview-html.ts`, `CSP_NONC **Never swap the token for an `event.source` / `event.origin` check, and never reuse the CSP nonce as the token** (rationale). **The guard fails closed both ways**: a webview served without the global accepts nothing, a host send without a token delivers nothing, and framed content cannot read the parent's globals cross-origin. -Messages from proxied iframes are guarded by origin instead (`docs/specs/dor-browser.md`) and are unaffected by the token, which covers only the adapter's host channel. **Scope is VS Code**: standalone receives equivalent events over Tauri's `listen()` IPC or the browser-dev HTTP/SSE bridge (`docs/specs/transport.md` → "Standalone browser-dev harness"), never `window.postMessage`. +Messages from proxied iframes are guarded by origin instead (`docs/specs/dor-browser.md`) and are unaffected by the token, which covers only the adapter's host channel. **Scope is VS Code**: standalone receives equivalent events over Tauri's `listen()` IPC or the browser-dev HTTP/SSE bridge (`docs/specs/standalone.md` → "Standalone browser-dev harness"), never `window.postMessage`. Source of truth: `isHostMessage` in `lib/src/lib/vscode-message-token.ts`, `WebviewChannel` / `serveWebview` in `vscode-ext/src/webview-messaging.ts`, `getWebviewHtml` in `vscode-ext/src/webview-html.ts`, `VSCodeAdapter` in `lib/src/lib/platform/vscode-adapter.ts`; pinned by the `host message authentication` block in `lib/src/lib/platform/vscode-adapter.test.ts`. @@ -308,22 +276,20 @@ The service reads both **in-process**, through the async `BurrowStateStore`. **T Source of truth: `VsCodeBurrowStateStore` in `vscode-ext/src/burrow-store.ts`, `BurrowStateStore` in `lib/src/host/remote/burrow-state-store.ts`. -**Which window: bind-as-lease.** One extension host runs per window, so unarbitrated they would all start a Burrow against the same enrollment and fight endlessly over the one `/ws/burrow` socket (rationale). Arbitration is the socket itself: **the bind is the lease**. Every contending window tries to bind one fixed path — `.sock` inside a per-user `dormouse-peer-` directory in the temp dir, or `\\.\pipe\dormouse-peer-` on Windows — the hash derived from `context.globalStorageUri.fsPath`: **derived rather than random** because it must be *the same* in every window, **hashed rather than joined** because of the platform path cap (rationale). The winner is the broker and runs the service; everyone else connects to it as a client. - -The invariants: +**Which window: bind-as-lease.** One extension host runs per window, so unarbitrated they would all start a Burrow against the same enrollment and fight endlessly over the one `/ws/burrow` socket (rationale). Arbitration is the socket itself: **the bind is the lease**. Every contending window tries to bind one fixed path — `.sock` inside a per-user `dormouse-peer-` directory in the temp dir, or `\\.\pipe\dormouse-peer-` on Windows — the hash derived from `context.globalStorageUri.fsPath`: **derived rather than random** because it must be *the same* in every window, **hashed rather than joined** because of the platform path cap (rationale). The winner is the broker and runs the service; everyone else connects to it as a client. The invariants, each with its mechanism at the code: - **Roles never flip downward.** There is no `onRole(false)` after a `true`, and a client only ever changes role *upward*, which makes a TTL lease's mid-transition races unrepresentable rather than handled (rationale). - **Contend on broker death, not on a timer.** When the broker exits, every client's socket closes and they all race to bind; exactly one wins, because `bind` is the arbiter. No TTL, no heartbeat file, no filesystem watcher. -- **A corpse is cleared, then the bind is re-checked.** `EADDRINUSE` → dial it → `ECONNREFUSED`/`ENOENT` means the path exists but nothing listens. **Never unlink on the first refusal:** the unlink is jittered by up to `RECLAIM_JITTER_MS` and the path dialled **again** afterwards (rationale). **`stillOurs` then re-stats the path after `RECLAIM_VERIFY_MS` and compares full filesystem identity — device, inode, and nanosecond change timestamp, never inode alone** (rationale). A window whose socket identity was replaced, **or whose path has gone entirely**, stands down and the loop re-runs (per-platform reasoning at `stillOurs`). +- **A corpse is cleared, then the bind is re-checked.** **Never unlink on the first refusal**, and **`stillOurs` compares full filesystem identity — device, inode, and nanosecond change timestamp, never inode alone**. A window whose socket identity was replaced, **or whose path has gone entirely**, stands down and the loop re-runs (jitter, re-dial and per-platform reasoning at `attempt` and `stillOurs`; rationale). - **A bind is not a role until it is believed.** Everything that answers "is this window the broker" — `ensurePeerNet`'s shortcut, `isPeerBroker`, `isPeerLinkSettled`, `remoteNotifyPeerChange` — reads `brokerConfirmed`, set only where `settle(true)` runs and cleared by `closeServer`. **Unverified reads as unsettled**, so a command landing in the `RECLAIM_VERIFY_MS` window (an `enroll`, a `secrets.onDidChange`) is held for the verdict rather than told "broker" (rationale). - **Attempts are spaced.** The loop waits `RETRY_MS` between rounds so a refused hello cannot spin, and a bind or connect landing after disposal is undone rather than left to outlive its window. -- **Errors after `listen` are logged, not thrown** — a listening `net.Server` emits `'error'` for accept-time failures (EMFILE, a broken pipe), and an `EventEmitter` with no `'error'` listener rethrows out of a libuv callback and takes the extension host down, so `listenServer` installs a permanent logging listener the moment the bind succeeds (rationale). +- **Errors after `listen` are logged, not thrown** — an `EventEmitter` with no `'error'` listener rethrows out of a libuv callback and takes the extension host down, so `listenServer` installs a permanent logging listener the moment the bind succeeds (rationale). **Trust.** The socket path is derived, not secret — any user on the machine can compute it — so two layers stand between it and this installation's terminals. *The directory.* On unix the sockets live in a `dormouse-peer-` directory created 0700 and held before every bind and every connect to `peerDirIsSafe()`, the same predicate the `dor` control socket uses (`docs/specs/dor-cli.md` → Control-channel security, which states its four checks). A loose directory we own is tightened; anything else is somebody else's and **the peer link stands down for good** rather than spinning against it, releasing the callers waiting on the contention. Windows named pipes carry their own ACL and skip this layer. -*The handshake.* The shared secret is a mode-0600 `burrow.peer-token` in `globalStorageUri`, **created once with an exclusive `wx` write rather than a rename** so two windows starting together agree on one token. `wx` creates the file before it writes the bytes, so the loser's read can land on a zero-length file: **treat an empty read as *not yet written*, never as the token**, waiting it out (`TOKEN_WRITE_ATTEMPTS` × `TOKEN_WRITE_POLL_MS`) (rationale). **Exhausting the wait latches the same permanent stand-down as an unsafe socket directory**, neither remaining case being fixed by retrying; the throw re-derives which it was, that log line being the only diagnosis (rationale). The token itself **never crosses the socket**; instead three frames prove mutual knowledge of it: +*The handshake.* The shared secret is a mode-0600 `burrow.peer-token` in `globalStorageUri`, **created once with an exclusive `wx` write rather than a rename** so two windows starting together agree on one token. **Treat an empty read as *not yet written*, never as the token**, waiting it out (`ensureToken`'s bounded retry; rationale). **Exhausting the wait latches the same permanent stand-down as an unsafe socket directory**, neither remaining case being fixed by retrying. The token itself **never crosses the socket**; instead three frames prove mutual knowledge of it: 1. `challenge { nonce }` — the *server* speaks first, on accept. A client that has not yet seen proof of the token must not volunteer one into whatever bound the path. 2. `hello { nonce, proof }` — the client answers with `HMAC-SHA256(token, "client:" + relayNonce)` and a fresh nonce of its own. @@ -337,7 +303,7 @@ The invariants: Source of truth: `vscode-ext/src/burrow.ts` (service glue, provider, command routing), `ensurePeerNet` / `attempt` / `stillOurs` in `vscode-ext/src/peer-link.ts`; pinned by `vscode-ext/test/burrow.test.ts` and `vscode-ext/test/peer-link.test.ts`. -**The webview bridge.** A webview reaches the service over `BurrowLink` (`lib/src/lib/platform/types.ts`), implemented in `vscode-adapter.ts` on three messages: `burrow:command { burrowRequestId, cmd, params }` out, `burrow:result { burrowRequestId, result | error }` and `burrow:event { name, … }` back. **Everything but those three `postMessage` shapes is the shared client** in `lib/src/host/remote/link-client.ts` (`docs/specs/transport.md` → Message protocol). +**The webview bridge.** A webview reaches the service over `BurrowLink` (`lib/src/lib/platform/types.ts`), implemented in `vscode-adapter.ts` on three messages, each wrapping the shared client's shape in a `payload`: `burrow:command { payload: BurrowCommand }` out, `burrow:result { payload: BurrowResult }` and `burrow:event { payload }` back. **Everything but those three `postMessage` shapes is the shared client** in `lib/src/host/remote/link-client.ts` (`docs/specs/transport.md` → Message protocol). Results are **broadcast to every webview in the window** rather than replied to one (`docs/specs/transport.md`), and one correlation id serves both the in-window fan-out and the cross-window forward below. @@ -412,7 +378,7 @@ One UI event *is* addressed: **when a window completes the handshake the broker **Socket bind errors reject startup** and are handled as an unavailable peer link; they never leave the listen promise pending or surface as an uncaught extension host error. -Source of truth: `vscode-ext/src/peer-link.ts` (sockets, arbitration); `vscode-ext/src/peer-link-protocol.ts` (frame shapes, framing, handshake, budget, PTY routing table), pinned by `vscode-ext/test/peer-link-protocol.test.ts`; `askBothTiers` in `vscode-ext/src/burrow.ts`; `brokerRequest` and the `peer:*` / `burrow:command` cases in `vscode-ext/src/message-router.ts`; `lib/src/remote/burrow/remote-api.ts`. +Source of truth: `vscode-ext/src/peer-link.ts` (sockets, arbitration, `HANDSHAKE_BUDGET_MS`, and the `routes` / `routePtyIds` routing table); `vscode-ext/src/peer-link-protocol.ts` (frame shapes, framing, handshake helpers, `PEER_REPLY_BUDGET_MS`), pinned by `vscode-ext/test/peer-link-protocol.test.ts`; `askBothTiers` in `vscode-ext/src/burrow.ts`; `brokerRequest` and the `peer:*` / `burrow:command` cases in `vscode-ext/src/message-router.ts`; `lib/src/remote/burrow/remote-api.ts`. ### Testing the extension host @@ -452,12 +418,6 @@ debugger can attach to. `vscode-ext/vite.config.ts` sets `root: ../lib` and `outDir: ./media`, building the shared React frontend directly into the extension's media folder. -## Terminal context host operations - -The adapter forwards every `TerminalContextRequest` to the PTY host as a correlated request (`docs/specs/transport.md` → Auxiliary helper metadata); directory opening follows `docs/specs/security-local.md` → Terminal context directory actions, and inspection failure follows `docs/specs/terminal-context.md` → Helper lifecycle. - -Source of truth: `terminalContext` in `lib/src/lib/platform/vscode-adapter.ts`; `terminalContext` in `vscode-ext/src/pty-manager.ts`; `context` in `standalone/sidecar/pty-core.js`. - ## Future ### Webview→host Surface-state channel @@ -481,7 +441,7 @@ vscode.commands.executeCommand('setContext', 'dormouse.mode', 'command'); ### Commands -Palette/keybinding entry points for what today is webview-only. Shipped commands are in the manifest table above. +Palette/keybinding entry points for what today is webview-only; the shipped set is in "Extension manifest". | Command | Description | |---------|-------------| @@ -491,11 +451,10 @@ Palette/keybinding entry points for what today is webview-only. Shipped commands | `dormouse.prevPane` | Focus previous pane | | `dormouse.enterPassthroughMode` | Switch to passthrough mode | | `dormouse.enterCommandMode` | Switch to command mode | -| `dormouse.listSessions` | Show QuickPick of all live PTY sessions | +| `dormouse.listSessions` | QuickPick of all live PTY sessions | | `dormouse.reattach` | Reattach a minimized PTY to a pane | ### Other host integrations - `TerminalProfileProvider` registration, so Dormouse appears in the terminal `+` dropdown - A status bar item showing active session count -- A QuickPick for listing/reattaching PTY sessions diff --git a/lib/src/host/recovery-capture.ts b/lib/src/host/recovery-capture.ts index 753e2aeaa..b64c91987 100644 --- a/lib/src/host/recovery-capture.ts +++ b/lib/src/host/recovery-capture.ts @@ -8,6 +8,10 @@ * all the detection ever needed (docs/specs/vscode.md -> "Capturing agent * recovery", docs/specs/standalone.md -> "Agent recovery"). * + * The gesture is always `^C` written *into* the pty, never a signal: the tty + * line discipline delivers the SIGINT to the foreground process group, so the + * hint comes back as ordinary PTY output on the path the host already reads. + * * The scrollback read here never leaves this module: only the detected * invocation reaches `onCommand`, so no transcript can reach persisted state. */ @@ -17,6 +21,10 @@ import { stripTerminalControls } from '../lib/terminal-controls'; // Claude's explicit request permits an immediate second press. Other panes // without a recovery hint must pass both fallback clocks below before retrying. +// +// Keying on an English UI string is deliberate, not an oversight: claude could +// reword it, and that failure is visible and costs one shutdown's claude +// recovery, where a mistimed second press destroys codex's hint every time. const ASKS_FOR_SECOND_PRESS = /Press Ctrl-C again/i; // When to press a silent pane again without having been asked. diff --git a/lib/src/host/remote/burrow-state-store.ts b/lib/src/host/remote/burrow-state-store.ts index 82a33982d..8051c7a8d 100644 --- a/lib/src/host/remote/burrow-state-store.ts +++ b/lib/src/host/remote/burrow-state-store.ts @@ -202,7 +202,9 @@ export class FileBurrowStateStore implements BurrowStateStore { } /** - * The store for a run with no state directory (the browser dev harness). + * The store for a run whose host could not create a state directory — the + * browser dev harness is *not* this case, since it passes a per-run temp + * `DORMOUSE_STATE_DIR` (docs/specs/standalone.md -> "Burrow service"). * * Held in memory rather than dropped: a Burrow enrolled here has to keep working * for the rest of the session — its ACL is what authorizes every pairing it diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index a4a1b7519..46cc9359d 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -442,8 +442,9 @@ export interface PlatformAdapter { /** Ask for the live PTY list and each one's replay. `requestId` is the asking * collector's token: a host serving several windows echoes it on the answer * so two collections in one webview cannot finish on each other's list - * (docs/specs/transport.md -> "Reconnection"). A host with one webview may - * ignore it, and its answers then carry none. */ + * (docs/specs/transport.md -> "Reconnection"). The hosts that do not echo it + * (VS Code, Pocket, the website) run one collector per JS realm, so their + * answers carry none and the collector takes them. */ requestInit(requestId?: string): void; onPtyList(handler: (detail: PtyListDetail) => void): void; offPtyList(handler: (detail: PtyListDetail) => void): void; diff --git a/lib/src/lib/terminal-controls.ts b/lib/src/lib/terminal-controls.ts index fadb3daf9..896b24514 100644 --- a/lib/src/lib/terminal-controls.ts +++ b/lib/src/lib/terminal-controls.ts @@ -32,6 +32,7 @@ export const STRING_CONTROL_INTRODUCER_SCAN = new RegExp( STRING_CONTROL_INTRODUCER.source, 'g', ); +// "Terminated" here tracks what the renderer honours rather than ECMA-48 alone. // BEL ends an OSC but is payload inside DCS/SOS/PM/APC, so the two kinds scan // for different sets; CAN, SUB and the C1 ST end either, and so does a bare ESC // — whose next byte decides between an ST terminator and a cancel. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 5708c5379..112bbdc36 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -3,7 +3,7 @@ "SECURITY.md": 200, "SELF_HOST.md": 6200, "docs/specs/alert.md": 7250, - "docs/specs/auto-update.md": 1200, + "docs/specs/auto-update.md": 1150, "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4700, "docs/specs/dor-cli.md": 6250, @@ -26,15 +26,15 @@ "docs/specs/security-supply-chain.md": 1250, "docs/specs/security.md": 1900, "docs/specs/shortcuts.md": 1150, - "docs/specs/standalone.md": 10700, + "docs/specs/standalone.md": 11500, "docs/specs/terminal-context.md": 1050, "docs/specs/terminal-escapes.md": 3850, "docs/specs/terminal-state.md": 2400, "docs/specs/theme.md": 2200, "docs/specs/tiling-engine.md": 4500, - "docs/specs/transport.md": 6250, + "docs/specs/transport.md": 5550, "docs/specs/tutorial.md": 1900, - "docs/specs/vscode.md": 7550, + "docs/specs/vscode.md": 7200, "docs/specs/webgl-text.md": 1200, "docs/specs/website-docs.md": 5050 } diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 3f49aa08c..713700551 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -1670,7 +1670,7 @@ fn read_update_log() -> Result { // session. A plain file we overwrite atomically has no WAL and cannot grow. // // Window identity is implicit: each command keys by the invoking window's label, -// so the frontend stays window-agnostic and a second window (`win-2`, …) persists +// so the frontend stays window-agnostic and a second window (`ws-2`, …) persists // to its own file without ever rewriting the first window's blob. fn app_data_dir(app: &AppHandle) -> Result { diff --git a/standalone/src/browser-sidecar-host.test.ts b/standalone/src/browser-sidecar-host.test.ts index c593aa8d1..80bb1a72c 100644 --- a/standalone/src/browser-sidecar-host.test.ts +++ b/standalone/src/browser-sidecar-host.test.ts @@ -48,7 +48,7 @@ class FakeEventSource extends EventTarget { // The bridge only fans a broadcast out to streams it has already registered, // and the app POSTs its two alert seeds the moment `init()` resolves — replies // that ride the stream. So `init()` settles on the stream being *open*, never -// on the `EventSource` merely existing (docs/specs/transport.md -> +// on the `EventSource` merely existing (docs/specs/standalone.md -> // "Standalone browser-dev harness"). describe("BrowserSidecarHost.init", () => { beforeEach(() => { diff --git a/standalone/src/browser-sidecar-host.ts b/standalone/src/browser-sidecar-host.ts index 15e5cd22f..a3f742159 100644 --- a/standalone/src/browser-sidecar-host.ts +++ b/standalone/src/browser-sidecar-host.ts @@ -34,7 +34,7 @@ export class BrowserSidecarHost { * constructed. The bridge registers a stream in `sseClients` when the GET * arrives, and the app's first act after `init()` is to POST the two alert * seeds whose replies come back only over that stream — a POST that beat - * the GET would be answered to nobody (docs/specs/transport.md -> + * the GET would be answered to nobody (docs/specs/standalone.md -> * "Standalone browser-dev harness"). Tauri awaits its listener * registration for the same reason. */ From 55fd0f17ebb418ad938eccf8bcc986f717c1e391 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 00:49:31 -0700 Subject: [PATCH 2/3] Retire persistsSession, cover the sidecar's optional addon packages, and name the harness key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `persistsSession` was a gate no shipping adapter ever opened: both standalone adapters answered `true`, which is what absent already reads as, and the only `false` in the tree was a test fixture. The member, the gate above the record build, both adapters' declarations, the two tests that pinned `true`, and the transport.md row go; the doc comment that had the rule backwards goes with them. A future non-persisting host reintroduces the gate in one line. The sidecar's `external` list read only `dependencies`, so the six `@node-datachannel/` packages — `optionalDependencies`, because only one installs on any machine — were neither external nor covered by `assertNothingInlined`, which claimed to assert the addon was not inlined and did not. Both keys are read now. `PEER_REPLY_BUDGET_MS` is defined as `ASK_BUDGET_MS + 2_000`, so the test asserting it is the larger could never fail; the argument moves to the constant's own comment and the test goes. The harness printed `dormouse.1.` for a run that launched `dor ab --key`, whose session the holding Workspace namespaces — a name nothing ever created. It prints the key it passed instead, and the test pins that rather than the mismatch. Co-Authored-By: Claude Fable 5.1 --- docs/specs/standalone.md | 8 +++++--- docs/specs/transport.md | 1 - docs/specs/transport.rationale.md | 4 ---- docs/specs/vscode.md | 2 +- lib/src/lib/platform/types.ts | 12 ------------ lib/src/lib/session-save.test.ts | 15 +-------------- lib/src/lib/session-save.ts | 5 ----- scripts/spec-word-budgets.json | 2 +- standalone/scripts/build-sidecar-proxy.mjs | 13 ++++++++++--- standalone/scripts/dev-agent-browser.mjs | 6 +++++- standalone/scripts/dev-agent-browser.test.mjs | 17 ++++++++++++++--- standalone/src/browser-sidecar-adapter.test.ts | 9 --------- standalone/src/browser-sidecar-adapter.ts | 8 +++----- standalone/src/tauri-adapter.test.ts | 1 - standalone/src/tauri-adapter.ts | 8 -------- vscode-ext/src/peer-link-protocol.ts | 8 +++++++- vscode-ext/test/peer-link-protocol.test.ts | 13 ------------- 17 files changed, 47 insertions(+), 85 deletions(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 055c0345f..fd211d77c 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -159,9 +159,11 @@ realm**. Against the shared store contract (`docs/specs/relay.md` → "Burrow si `node-datachannel`'s W3C polyfill. **A sidecar package's transitive dependencies do not ship** — the Tauri bundle copies `standalone/sidecar/node_modules` and nothing else — so the addon's platform package and `detect-libc` are declared in -`standalone/sidecar/package.json` directly. **Every `dependencies` entry that -manifest declares stays `external` to `burrow.cjs`**, the `external` list being -derived from that key rather than listed beside it. The build fails if the +`standalone/sidecar/package.json` directly — the addon's six platform packages +under `optionalDependencies`, since only one installs anywhere. **Every entry +that manifest declares, under either key, stays `external` to `burrow.cjs`**, +the `external` list being derived from those keys rather than listed beside +them. The build fails if the manifest stops declaring the addon, and asserts from esbuild's metafile that none of those packages was inlined: the addon resolves its `.node` relative to its own `__dirname`, and inlining would move that out of the diff --git a/docs/specs/transport.md b/docs/specs/transport.md index 82ef1b6ef..957a5e2cb 100644 --- a/docs/specs/transport.md +++ b/docs/specs/transport.md @@ -20,7 +20,6 @@ Optional booleans: | Member | Absent reads | Set by | Effect when set | |---|---|---|---| -| `persistsSession?` | `true` | The two standalone adapters → `true` | `saveSession` skips the whole record build, not just the write, on a host that answers `false` (rationale) | | `hostOwnsTheme?` | `false` | `VSCodeAdapter` → `true` | Settings hides its theme picker (`docs/specs/theme.md` → "Where the user picks a theme") | | `hostOwnsShells?` | `false` | `VSCodeAdapter` → `true` | Settings hides its Shell row for the native QuickPick (`docs/specs/vscode.md` → "Shell selection") | diff --git a/docs/specs/transport.rationale.md b/docs/specs/transport.rationale.md index 8266716c2..d996b2356 100644 --- a/docs/specs/transport.rationale.md +++ b/docs/specs/transport.rationale.md @@ -2,10 +2,6 @@ > Informative companion to [transport.md](transport.md): the evidence, symptoms, and dead-approach history behind its rules, keyed by that spec's headings (AGENTS.md → "What, not why"). Nothing here is normative. -## Adapter model - -**What `persistsSession: false` actually saves.** The expensive half of a save is the record build, not the write: one `getCwd` round trip per pane (`docs/specs/standalone.md` → "Persistence", whose rationale prices it). No shipped adapter answers `false` today; the gate stays because a host that persists nothing must not pay for a record it discards. - ## Paced input Measured on macOS 27 with Claude Code 2.1.274 and Codex 0.154.0, 2026-09 (issue #679). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 5508664f0..eef3b2e28 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -325,7 +325,7 @@ The service owns the PTYs but not the *view* of them: each webview is its own JS | Operation schema | `(op, params) → zero or more results`; `op` opaque to the transport, the typed map only in `peer-surfaces.ts`. | Same seam; only a reserved `ptyId` is interpreted, for routing. | | Ownership / miss | Presence is ownership; every webview answers, including with no results. | Every peer answers; disconnect settles its pending asks empty. | | Fan-out order | All webviews in parallel. | `askBothTiers` runs local and all peers in parallel, local concatenated first. | -| Budget | `ASK_BUDGET_MS` (1s); disposal removes that webview from the outstanding set. | `PEER_REPLY_BUDGET_MS` covers the inner ask plus socket hops and **must remain larger** (pinned by `peer-link-protocol.test.ts`). | +| Budget | `ASK_BUDGET_MS` (1s); disposal removes that webview from the outstanding set. | `PEER_REPLY_BUDGET_MS` covers the inner ask plus socket hops, and **is defined as `ASK_BUDGET_MS + 2_000`** so it cannot fall under it. | | Invalidation | `peer:notify` carries no subject; pane/activity/focus bursts coalesce before crossing. | `notify`, webview membership, and peer membership each trigger a fresh directory collect. | | PTY stream | One window-wide keyed registry distributes already-processed data/exit. | Opaque routed handles select one peer; `subscribe` is reference-counted, streaming the same processed data/exit. | | Burrow command | This window calls its service, broadcasting the uniquely correlated result to its webviews. | `command` goes to the broker, `commandResult` returns only to its origin window, `uiEvent` broadcasts. | diff --git a/lib/src/lib/platform/types.ts b/lib/src/lib/platform/types.ts index 46cc9359d..eb9d9d1c2 100644 --- a/lib/src/lib/platform/types.ts +++ b/lib/src/lib/platform/types.ts @@ -253,18 +253,6 @@ export interface PlatformAdapter { resizePty(id: string, cols: number, rows: number): void; killPty(id: string): void; - /** - * Whether this host keeps a Session snapshot across a restart. `false` means - * `saveSession` does no work at all rather than building a record for a - * `saveState` that discards it — the gate belongs above the per-pane `getCwd` - * round trips, not below them. - * - * Absent reads as `true`. Standalone sets it `false`: quitting is a deliberate - * ending and a crash captured nothing, so every launch starts fresh - * (docs/specs/transport.md -> "The governing rule"). - */ - persistsSession?: boolean; - /** * Whether the host owns the color theme, so Dormouse must not offer a theme * picker of its own. Absent reads as `false`. diff --git a/lib/src/lib/session-save.test.ts b/lib/src/lib/session-save.test.ts index e2df85599..c96fd15e3 100644 --- a/lib/src/lib/session-save.test.ts +++ b/lib/src/lib/session-save.test.ts @@ -369,20 +369,7 @@ describe('saveSession', () => { }); }); - it('does no work at all for a host that persists nothing', async () => { - // The gate is above the record build, not at the write: `getCwd` is a - // per-pane round trip that lands on a synchronous `lsof` in the standalone - // sidecar, and it would otherwise run on every debounced save, every 30s - // heartbeat, and twice more per quit, for a blob that is then dropped. - const platform = { ...createPlatform(null), persistsSession: false }; - - await saveSession(platform, [{ id: 'pane-a', title: 'Pane A' }]); - - expect(platform.getCwd).not.toHaveBeenCalled(); - expect(platform.saveState).not.toHaveBeenCalled(); - }); - - it('still saves for a host that does not declare the flag', async () => { + it('saves for a host that declares nothing beyond the state slot', async () => { const platform = createPlatform(null); await saveSession(platform, [{ id: 'pane-a', title: 'Pane A' }]); diff --git a/lib/src/lib/session-save.ts b/lib/src/lib/session-save.ts index 429fb7f0c..accb3146f 100644 --- a/lib/src/lib/session-save.ts +++ b/lib/src/lib/session-save.ts @@ -162,11 +162,6 @@ export async function saveSession( sink?: SaveSink, options: SaveOptions = {}, ): Promise { - // Gate the work, not just the write. Building the record costs a cwd probe — - // on standalone a synchronous process scan in the sidecar — and a host that - // persists nothing would spend it on every debounced save, every 30s - // heartbeat, and twice more per quit, only for `saveState` to drop the result. - if (platform.persistsSession === false) return; const previous = sink ? sink.previous() : readPersistedSession(platform.getState()); const session = await buildPersistedSession(platform, panes, doors, lathLayout, surfaceRefs, surfaceRefsNext, previous, options); if (sink) sink.publish(session); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 112bbdc36..f6201684e 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -32,7 +32,7 @@ "docs/specs/terminal-state.md": 2400, "docs/specs/theme.md": 2200, "docs/specs/tiling-engine.md": 4500, - "docs/specs/transport.md": 5550, + "docs/specs/transport.md": 5500, "docs/specs/tutorial.md": 1900, "docs/specs/vscode.md": 7200, "docs/specs/webgl-text.md": 1200, diff --git a/standalone/scripts/build-sidecar-proxy.mjs b/standalone/scripts/build-sidecar-proxy.mjs index 318c582ab..d52073550 100644 --- a/standalone/scripts/build-sidecar-proxy.mjs +++ b/standalone/scripts/build-sidecar-proxy.mjs @@ -34,9 +34,16 @@ const remoteSrc = resolveRemoteConnectSrc(process.env, 'sidecar'); // installed package under `sidecar/node_modules` and be required by name — // inlining one here would leave that loader looking beside `burrow.cjs`. // Derived rather than listed, so declaring a dependency is what keeps it out. -const SIDECAR_RUNTIME_DEPS = Object.keys( - JSON.parse(readFileSync(path.resolve(sidecar, 'package.json'), 'utf8')).dependencies ?? {}, -); +// +// Both keys: the addon's six `@node-datachannel/` packages are +// `optionalDependencies` because only one of them installs on any given +// machine, and reading `dependencies` alone left them neither `external` nor +// covered by `assertNothingInlined`. +const sidecarManifest = JSON.parse(readFileSync(path.resolve(sidecar, 'package.json'), 'utf8')); +const SIDECAR_RUNTIME_DEPS = Object.keys({ + ...sidecarManifest.dependencies, + ...sidecarManifest.optionalDependencies, +}); // Each package by name, plus every subpath export of it (`node-datachannel/polyfill`). const NATIVE_DIRECT = SIDECAR_RUNTIME_DEPS.flatMap((name) => [name, `${name}/*`]); // The list `assertNothingInlined` checks is this same one, so a manifest that diff --git a/standalone/scripts/dev-agent-browser.mjs b/standalone/scripts/dev-agent-browser.mjs index 778bc1125..73e7e42bd 100644 --- a/standalone/scripts/dev-agent-browser.mjs +++ b/standalone/scripts/dev-agent-browser.mjs @@ -365,7 +365,11 @@ async function openAgentBrowser() { ? resolve() : reject(new Error(`${binary} exited code=${code} signal=${signal}`))); }); - log(`agent-browser session: ${browserSession}`); + // Name what was actually passed. `dor ab --key` is namespaced by the Workspace + // that will hold the browser, which only the host can resolve + // (docs/specs/dor-browser.md -> "Managed identity"), so printing a + // `sessionForKey` guess here would name a bare-Wall session nothing created. + log(`agent-browser ${identity[0] === '--key' ? 'key' : 'session'}: ${identity[1]}`); log(`try: ${command} snapshot -i`); } diff --git a/standalone/scripts/dev-agent-browser.test.mjs b/standalone/scripts/dev-agent-browser.test.mjs index f64074e9d..279c0681f 100644 --- a/standalone/scripts/dev-agent-browser.test.mjs +++ b/standalone/scripts/dev-agent-browser.test.mjs @@ -59,7 +59,9 @@ async function fixture(t) { this.app = (await this.wait(/app URL: (http:\/\/localhost:\d+)/))[1]; this.bridge = (await this.wait(/starting browser dev host on (http:\/\/127.0.0.1:\d+)/))[1]; this.token = (await this.wait(/bridge token: ([a-f0-9]+)/))[1]; - this.session = (await this.wait(/agent-browser session: (\S+)/))[1]; + const identity = await this.wait(/agent-browser (session|key): (\S+)/); + this.identityKind = identity[1]; + this.session = identity[2]; this.args = JSON.parse((await this.wait(/BROWSER_ARGS (.+)/))[1]); return this; }, @@ -100,7 +102,13 @@ test('parallel worktrees own ports, browser identities and bridges; stopping one const key = one.args[2]; assert.match(key, /^innerdogfood-[a-f0-9]{16}$/); assert.deepEqual(one.args, ['ab', '--key', key, 'open', one.app]); - assert.equal(one.session, sessionForKey(key)); + // Inside Dormouse the harness names the key, not a session: the Workspace that + // takes the browser is what namespaces it, so `sessionForKey`'s bare-Wall scope + // would be a session nothing ever created. + assert.equal(one.identityKind, 'key'); + assert.equal(one.session, key); + assert.notEqual(one.session, sessionForKey(key)); + assert.equal(two.identityKind, 'session'); assert.deepEqual(two.args, ['--session', two.session, 'open', two.app]); for (const [run, dir, other] of [[one, a.root, two], [two, b.root, one]]) { const js = await (await fetch(`${run.app}/app.js`)).text(); @@ -139,8 +147,11 @@ test('parallel worktrees own ports, browser identities and bridges; stopping one assert.equal((await one.stop()).code, 0); await assertClosed(one); assert.equal((await invoke(two)).status, 200); + // Stable across restarts: the identity is derived from the canonical worktree + // path. This run is outside Dormouse, so it resolves the same key and prints + // it namespaced. const restarted = await a.start().ready(); - assert.equal(restarted.session, one.session); + assert.equal(restarted.session, sessionForKey(one.session)); }); test('explicit ports and raw browser sessions are honored; occupied ports fail without adopting a peer', { timeout: 60000 }, async t => { diff --git a/standalone/src/browser-sidecar-adapter.test.ts b/standalone/src/browser-sidecar-adapter.test.ts index c1069d6c6..26237bb35 100644 --- a/standalone/src/browser-sidecar-adapter.test.ts +++ b/standalone/src/browser-sidecar-adapter.test.ts @@ -54,15 +54,6 @@ describe("BrowserSidecarAdapter session persistence", () => { activeWorkspaceId: "ws-1", }; - it("reports the same persistsSession as TauriAdapter", () => { - const harness: PlatformAdapter = new BrowserSidecarAdapter( - new BrowserSidecarHost("http://localhost:1234"), - ); - const tauri: PlatformAdapter = new TauriAdapter(); - expect(harness.persistsSession).toBe(tauri.persistsSession); - expect(harness.persistsSession).toBe(true); - }); - it("round-trips a Window through localStorage", () => { localStorage.removeItem(KEY); const adapter = new BrowserSidecarAdapter(new BrowserSidecarHost("http://localhost:1234")); diff --git a/standalone/src/browser-sidecar-adapter.ts b/standalone/src/browser-sidecar-adapter.ts index 1b382b918..176274309 100644 --- a/standalone/src/browser-sidecar-adapter.ts +++ b/standalone/src/browser-sidecar-adapter.ts @@ -380,11 +380,9 @@ export class BrowserSidecarAdapter implements PlatformAdapter { onWatchedCommands(handler: (names: string[]) => void): void { this.watchedCommandHandlers.add(handler); } onAlertSettings(handler: (settings: AlertSettings) => void): void { this.alertSettingsHandlers.add(handler); } - // The harness mirrors the shipped persistence answer, so a reload here - // exercises what the app does (docs/specs/transport.md -> "The governing rule"). - readonly persistsSession = true; - - // See TauriAdapter: no bare-Session slot on this host. + // See TauriAdapter: no bare-Session slot on this host. The harness mirrors the + // shipped persistence answer, so a reload here exercises what the app does + // (docs/specs/transport.md -> "The governing rule"). saveState(_state: unknown): void {} getState(): unknown { return null; } diff --git a/standalone/src/tauri-adapter.test.ts b/standalone/src/tauri-adapter.test.ts index 49bfa00d3..fffa3f80a 100644 --- a/standalone/src/tauri-adapter.test.ts +++ b/standalone/src/tauri-adapter.test.ts @@ -163,7 +163,6 @@ describe("TauriAdapter window persistence", () => { it("persists, and never clears the snapshot at boot", async () => { const { adapter, invoke } = await booted((cmd) => (cmd === "load_session" ? JSON.stringify(windowBlob) : undefined)); - expect(adapter.persistsSession).toBe(true); expect(adapter.getWindowState()).toEqual(windowBlob); expect(invoke.mock.calls.map(([cmd]) => cmd)).not.toContain("clear_session"); adapter.shutdown(); diff --git a/standalone/src/tauri-adapter.ts b/standalone/src/tauri-adapter.ts index d74eb7991..fa50c1ed8 100644 --- a/standalone/src/tauri-adapter.ts +++ b/standalone/src/tauri-adapter.ts @@ -773,14 +773,6 @@ export class TauriAdapter implements PlatformAdapter { // --- State persistence --- - /** - * Read by `saveSession`, which skips the whole record build — not just the - * write — when a host persists nothing (`PlatformAdapter.persistsSession`). - * Standalone persists window state (`docs/specs/transport.md` -> - * "The governing rule"). - */ - readonly persistsSession = true; - // No bare-Session slot on this host: the stored blob is a Window, and // standalone boots per Workspace so nothing shared ever reaches these // (`standalone/src/main.tsx`). The pair below is the real one. diff --git a/vscode-ext/src/peer-link-protocol.ts b/vscode-ext/src/peer-link-protocol.ts index d37915cc7..44972a67d 100644 --- a/vscode-ext/src/peer-link-protocol.ts +++ b/vscode-ext/src/peer-link-protocol.ts @@ -8,7 +8,13 @@ import { type BurrowResult, } from '../../lib/src/host/remote/service-protocol'; -/** Must exceed the nested webview fan-out budget plus its two socket hops. */ +/** + * The broker's wait for one peer window. Derived, not chosen: that wait strictly + * contains the peer's own full-budget fan-out to its webviews plus two socket + * hops, so it must exceed `ASK_BUDGET_MS` and the margin is what covers the + * hops. Equal budgets would make a slow sibling look like a timeout on the + * broker's side and throw away results that were on their way. + */ export const PEER_REPLY_BUDGET_MS = ASK_BUDGET_MS + 2_000; /** Broker → peer window. `op` is intentionally opaque to this transport. */ diff --git a/vscode-ext/test/peer-link-protocol.test.ts b/vscode-ext/test/peer-link-protocol.test.ts index c28736fda..221655aa1 100644 --- a/vscode-ext/test/peer-link-protocol.test.ts +++ b/vscode-ext/test/peer-link-protocol.test.ts @@ -5,11 +5,9 @@ */ import { describe, expect, it } from 'vitest'; -import { ASK_BUDGET_MS } from '../../lib/src/host/remote/service-protocol'; import { FrameDecoder, PEER_CLIENT_PROOF_DOMAIN, - PEER_REPLY_BUDGET_MS, PEER_SERVER_PROOF_DOMAIN, encodeFrame, forgetPeerRoutes, @@ -19,17 +17,6 @@ import { routedPtyId, } from '../src/peer-link-protocol'; -describe('reply budgets', () => { - it('gives the cross-window wait more room than the fan-out it contains', () => { - // Not a tidiness assertion: the broker's wait for a peer window strictly - // contains that window's own full-budget fan-out to its webviews plus two - // socket hops. Equal budgets make a slow sibling look like a timeout on the - // broker's side and throw away results that were on their way, so unifying - // these two constants is a regression, not a simplification. - expect(PEER_REPLY_BUDGET_MS).toBeGreaterThan(ASK_BUDGET_MS); - }); -}); - describe('FrameDecoder', () => { it('reads one frame per line', () => { const decoder = new FrameDecoder(); From 256d7a525775b689be20955ed5503ae2bf441983 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sun, 20 Sep 2026 01:51:55 -0700 Subject: [PATCH 3/3] docs(standalone): state the browser-dev port default the way native dev actually binds it `pnpm tauri dev` routes through `tauri.mjs` to `runDev`, which binds Vite on port 0 and overlays `devUrl`, so an OS-assigned port is the native default too. `tauri.conf.json`'s `1420` is reached only by a direct `pnpm exec tauri dev`. Co-Authored-By: Claude Fable 5.1 --- docs/specs/standalone.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/standalone.md b/docs/specs/standalone.md index 7443bbf16..63ac590b3 100644 --- a/docs/specs/standalone.md +++ b/docs/specs/standalone.md @@ -1276,7 +1276,7 @@ Source of truth: `standalone/package.json` (package scripts), `pnpm innerdogfood` starts the standalone sidecar directly, a localhost-only HTTP bridge, and Vite with `VITE_DORMOUSE_BROWSER_DEV_HOST`, then opens the app URL in an `agent-browser` session. The browser build uses `BrowserSidecarAdapter` instead of `TauriAdapter` whenever that env var is present. -- **Must bind OS-assigned ports for Vite and the HTTP bridge by default** — native dev keeps `tauri.conf.json`'s `1420` unless `runDev` overrides it (above). +- **Must bind OS-assigned ports for Vite and the HTTP bridge by default**, as native dev does; only a direct `pnpm exec tauri dev` keeps `tauri.conf.json`'s `1420` (above). - **Must derive the default browser key from the canonical worktree path**, stable across restarts. **Must open through `dor ab` when `DORMOUSE_SURFACE_ID` is set**, otherwise through `agent-browser`; print the actual app URL, the browser identity it passed, and the command to drive it. **Must print a `--key` as a key, never as a session**: only the Workspace that will hold the browser can namespace one (`docs/specs/dor-browser.md` → "Managed identity"). Inside Dormouse, `dor ensure -- pnpm innerdogfood` starts and opens the harness. - **May pin ports with `DORMOUSE_BROWSER_DEV_VITE_PORT` / `DORMOUSE_BROWSER_DEV_HOST_PORT` and the session with `DORMOUSE_BROWSER_DEV_AB_SESSION`.** An occupied pinned port fails startup; `0` requests an OS-assigned port. Explicit overrides are the caller's isolation responsibility. - **Must await Vite's own listener before opening the browser and use the actual ports for bridge authentication and CORS.** **Must close the bridge and Vite and terminate owned sidecar and browser-launch children on startup failure or shutdown**, escalating to SIGKILL after three seconds. Pinned by `standalone/scripts/dev-agent-browser.test.mjs`.