diff --git a/.gitignore b/.gitignore index fbc49a524..082f78933 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ standalone/src-tauri/gen/ standalone/dist/ standalone/sidecar/dor-cli/ standalone/sidecar/iframe-proxy.cjs +standalone/sidecar/recovery.cjs standalone/sidecar/agent-browser-host.cjs standalone/sidecar/burrow.cjs # Kept beside it: a checkout that built before the Burrow rename still holds diff --git a/docs/specs/auto-update.rationale.md b/docs/specs/auto-update.rationale.md index 6f1bb963f..2fd8b4b01 100644 --- a/docs/specs/auto-update.rationale.md +++ b/docs/specs/auto-update.rationale.md @@ -4,7 +4,7 @@ ## Quit-time install -**Why install runs last.** A Windows NSIS install force-kills the app the moment it starts, so starting it early interrupts teardown. This ordering originally protected persisted scrollback; standalone now persists no Session state. The retained save/drain hooks and their completion semantics are explained in `docs/specs/standalone.rationale.md` → Quit flow. +**Why install runs last.** A Windows NSIS install force-kills the app the moment it starts, so starting it early interrupts teardown. This ordering originally protected persisted scrollback; what it protects now is the window's structure, which standalone does persist. The retained save/drain hooks and their completion semantics are explained in `docs/specs/standalone.rationale.md` → Quit flow. **Why Vite dev mode skips `install()`.** The updater resolves its replacement target from the current executable path, which in dev is the dev executable's directory, not a packaged bundle. diff --git a/docs/specs/glossary.md b/docs/specs/glossary.md index 12f94330f..2b0c788bc 100644 --- a/docs/specs/glossary.md +++ b/docs/specs/glossary.md @@ -88,7 +88,7 @@ A Workspace's **union status** is its display projection of member Surfaces' Act ### Implementation status -The Pane / Surface model, surface kinds, and the Workspace model are live; a Window still means one OS window, and `dormouse.flags.workspaces` still controls the stored Window wrapper (`docs/specs/layout.md` → Workspaces). Ledger: `docs/specs/layout.md` `## Future` (**Scope: workspaces-rollout**); this glossary does not track it. +The Pane / Surface model, surface kinds, the Workspace model, and per-Workspace persistence are live; a Window still means one OS window (`docs/specs/layout.md` → Workspaces). Ledger: `docs/specs/layout.md` `## Future` (**Scope: workspaces-rollout**); this glossary does not track it. ## Roles diff --git a/docs/specs/layout.md b/docs/specs/layout.md index db58e6223..79b4e377e 100644 --- a/docs/specs/layout.md +++ b/docs/specs/layout.md @@ -155,9 +155,9 @@ Each Wall renders one Workspace's Content (Lath layout) and Baseboard (doors). S **Create** adds a Workspace named `Workspace N`, makes it active, and gives its Wall no restored record, so Lath's fresh branch spawns one default-shell pane. **Close** confirms first when the Workspace holds touched Surfaces or running work, reusing the kill-confirm letter and key rule over the Window's content area (**a bare `Shift` or `Meta` is not an answer**, as for a pane kill), then routes every member Surface through the closure coordinator; **the last remaining Workspace cannot be closed** — there is always one active Workspace, as there is always one visible pane (corner case #5). **One close runs at a time for the whole Window**, with the count re-checked after the confirmation, so two of them cannot empty two Walls between them; **a close the store then refuses hands the Wall back its auto-spawn** rather than leaving it mounted and empty. **Rename** edits the Workspace `name` only — no Surface title, and not the per-pane inline rename. **Reorder** moves a tab in the strip and renumbers the positional `workspace:` refs with it; **a press inside the open rename editor never starts a reorder**. **Must drop only the closing Workspace’s rename editor and pending confirmation**, or a stale `renamingId` holds the chrome keyboard lease for the session (`WorkspaceStrip.test.tsx`). **Every Workspace verb runs outside the strip**, which renders the rename editor and confirmation from a store, so a tab gesture and a command-mode key take one path. -The union projection and its indicators are owned by `docs/specs/alert.md` → Workspace union; the strip that renders them by `docs/specs/standalone.md` → AppBar. Persisted containers are owned by `docs/specs/transport.md`; `dormouse.flags.workspaces` still selects the bare `PersistedSession` versus `PersistedWindow` stored format, and **both standalone adapters still disable session persistence**, so a relaunch restores one Workspace. +The union projection and its indicators are owned by `docs/specs/alert.md` → Workspace union; the strip that renders them by `docs/specs/standalone.md` → AppBar. Persisted containers are owned by `docs/specs/transport.md`: standalone stores one `PersistedWindow` per window, so a relaunch restores every Workspace ([Session persistence](#session-persistence)). -Source of truth: `WorkspaceWindow` in `lib/src/components/WorkspaceWindow.tsx`; `registerWallHandle` in `lib/src/components/wall/wall-handles.ts`; `closeAll` in `lib/src/components/Wall.tsx`; `requestWorkspaceClose` in `lib/src/components/wall/workspace-lifecycle.ts`; `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `getWorkspaceUiSnapshot` in `lib/src/lib/workspace-ui-store.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`; `PERSIST_SESSION` in `standalone/src/tauri-adapter.ts` and `standalone/src/browser-sidecar-adapter.ts`. +Source of truth: `WorkspaceWindow` in `lib/src/components/WorkspaceWindow.tsx`; `registerWallHandle` in `lib/src/components/wall/wall-handles.ts`; `closeAll` in `lib/src/components/Wall.tsx`; `requestWorkspaceClose` in `lib/src/components/wall/workspace-lifecycle.ts`; `createWorkspace` / `closeWorkspace` / `renameWorkspace` / `moveWorkspace` / `setActiveWorkspace` in `lib/src/lib/workspace-store.ts`; `getWorkspaceUiSnapshot` in `lib/src/lib/workspace-ui-store.ts`; `setWorkspaceSurfaces` in `lib/src/lib/workspace-surfaces.ts`. What multi-window, per-Workspace persistence, and the `dor workspace` verbs still owe is staged in [Future](#future) — this spec's `## Future` is the single rollout ledger; other specs link here. @@ -359,19 +359,21 @@ Three save triggers, in ascending urgency: `docs/specs/standalone.md` §Persistence owns the dirty-gating mechanism and the store-level identical-value backstop. -Container shapes and the `dormouse.flags.workspaces` wrapping are `docs/specs/transport.md` → "Persisted session types" ([Workspaces](#workspaces)); VS Code persists one Workspace per webview (`WebviewView` / `WebviewPanel`). +**Under a Workspace, a Wall publishes its record to the Window aggregator instead of the platform slot**, and compares each save against its own Workspace's previous record — container shapes and the aggregator's rules are `docs/specs/transport.md` → "Persisted session types" ([Workspaces](#workspaces)). **A Wall marks itself dirty only for Surfaces it owns**: both content stores are Window-global and name the Surface that changed, so an idle Workspace does not rebuild its record — a `getCwd` per pane — whenever another Workspace moves. VS Code persists one Workspace per webview (`WebviewView` / `WebviewPanel`). Snapshots are read through `readPersistedSession()`, which tolerates a stringified blob and logs-and-discards an unreadable one so malformed storage starts fresh rather than blocking startup (`docs/specs/transport.md` → "Persisted session types"). Startup recovery is priority-based: +**A Window plans once per Workspace off one live-PTY list**: `collectLivePtys` runs the single PTY-list round trip for the whole webview, and each Workspace takes the slice its own saved panes name, so one host answer restores N Workspaces (`docs/specs/standalone.md` → Persistence). A single-Wall host reaches the same behavior through `resumeOrRestore`. + 1. **Resume** (webview recreated, retained Live or Exited PTYs): request PTY list + replay data from the platform, `resumeTerminal()` each (500ms timeout). **Saved pane and door titles are seeded back via `setTerminalUserTitle()`** (`docs/specs/transport.md`), so persisted placeholder labels never replay as user pins. If the saved session covers every retained PTY, restore the saved Lath layout when its leaf set matches and reattach saved minimized items as doors. **Never fall through to cold restore just because the visible `paneIds` list is empty** — a wall whose retained sessions are all minimized is still a resume. -2. **Restore** (app restart, cold start): the Wall's `seed` hydrates from the restored Lath layout, else falls to (3); `restoreTerminal()` per pane with its saved cwd and title. Browser surfaces are rebuilt from their persisted params instead. +2. **Restore** (app restart, cold start): the Wall's `seed` hydrates from the restored Lath layout, else falls to (3); `restoreTerminal()` per pane with its saved cwd and title, plus the single-use agent resume invocation the host captured (`docs/specs/transport.md` → "Consuming it") and, on a host whose AlertManager lives in the webview, the pane's persisted TODO through `PlatformAdapter.alertSeed`. Browser surfaces are rebuilt from their persisted params instead. 3. **Fallback/manual pane creation**: with no saved layout safely applicable, add panes as splits from the previous pane. 4. **Empty state**: one new pane. Every PTY spawned by (2)–(4) uses the current default shell selection. -Source of truth: `lib/src/components/wall/use-session-persistence.ts` (save triggers and flushes), `lib/src/lib/session-save.ts` (serialization), `lib/src/lib/reconnect.ts` (recovery priority). +Source of truth: `lib/src/components/wall/use-session-persistence.ts` (save triggers and flushes), `lib/src/lib/session-save.ts` (serialization), `collectLivePtys` / `resumeOrRestoreFrom` in `lib/src/lib/reconnect.ts` (recovery priority), `restoreWindowOrFresh` in `standalone/src/window-restore.ts` (the per-Workspace boot). ### Activity state @@ -430,7 +432,6 @@ A store commit that empties the tree (last pane killed or minimized) triggers th **Scope: workspaces-rollout** — what the multi-Workspace feature still owes. Current implementation: [Workspaces](#workspaces). Persisted containers are owned by `docs/specs/transport.md`; union projection by `docs/specs/alert.md`. This ledger is the single home for what remains; other specs link here rather than restating it. -- **Standalone persistence and agent recovery.** Every Workspace's record already reaches the Window collector, which has no writer, so nothing is stored and a relaunch restores one Workspace. Turning it on means seeding the collector at boot, debouncing and flushing its writes, adopting a restored `PersistedWindow` into the Workspace store, and lifting VS Code's agent-recovery capture into a host-agnostic module the sidecar bundles. - **Multiple OS windows.** PTY ownership routing in Rust, window lifecycle, tearing a Workspace out into its own window, dropping one onto another window, and restoring N windows. `WorkspaceStrip`'s `onDragOutsideWindow` / `onDropOnOtherWindow` and the router's `window:` rejection are the seams; `WINDOW_REF` names the only Window this build addresses. - **`dor workspace` verbs.** `new` / `rename` / `close` / `switch`, plus `dor list --all` for cross-Workspace targeting and `workspace:` as the stable handle beside today's positional `workspace:`. diff --git a/docs/specs/notepad.md b/docs/specs/notepad.md index b035a4410..ae0ce3758 100644 --- a/docs/specs/notepad.md +++ b/docs/specs/notepad.md @@ -140,12 +140,14 @@ Source of truth: `archiveSurfaceNotes` in `lib/src/lib/notepad/close-coordinator **Archiving is a gate step before teardown**: after the running-work confirmation, or immediately on an all-idle quit, and **before the first `quit_progress`** (`docs/specs/standalone.md` → "Quit flow"; rationale). **It is bounded at 3 s.** +**Standalone still archives at quit even though it now restores its windows** (`docs/specs/transport.md` → "The governing rule"): VS Code's live notes survive a Reload only through the extension host's in-memory mirror ([Live resume](#live-resume)), and quitting standalone leaves no such survivor. + - **A failure or timeout leaves the quit pending in Rust**, whose phase-2 wait is unbounded for exactly this (`docs/specs/standalone.md` → "Quit flow"), and the dialog shows the error with **Cancel** (default) and **Quit anyway**, which discards the notes. **Only Cancel calls `quit_cancel`**: Quit anyway must reach teardown with the watchdog still armed. - **A timeout aborts the archive it stopped waiting for** ([Closure](#closure)). - **Must include Surfaces with pending batch IDs even after their last note is deleted**, both when archiving and discarding on Quit anyway; pinned by `standalone/src/quit-notepad.test.ts`. - **Teardown's own rule is untouched**: once teardown begins, no failing step prevents exit. -The store is `/notepad-archive-v1.json`, **a sibling of `sessions/`, never inside it** — a Surface's notes outlive the window whose closure archived them, so they must not ride the per-window session blob or be swept by `clear_session`. **It is written owner-only and atomically through the same `write_file_atomically` the session snapshot uses** (`docs/specs/security-local.md` → "Persisted state"). **The revision is a hash of the stored bytes, and every load, save and reset holds an exclusive lock on the sidecar `notepad-archive-v1.lock`** — a second Dormouse sharing `app_data_dir()`, a dev build beside the installed app, then conflicts instead of overwriting batches it never read. **Recovery renames it to `notepad-archive-v1.unreadable-.json` beside the original**, disambiguating rather than overwriting an earlier quarantine; only a temp file a crash left behind is dropped. +The store is `/notepad-archive-v1.json`, **outside `sessions/` and outside the state root**, so every build shares one — a Surface's notes outlive the window whose closure archived them, so they must not ride the per-window session blob or the sweep over its directory. **It is written owner-only and atomically through the same `write_file_atomically` the session snapshot uses** (`docs/specs/security-local.md` → "Persisted state"). **The revision is a hash of the stored bytes, and every load, save and reset holds an exclusive lock on the sidecar `notepad-archive-v1.lock`** — a second Dormouse sharing `app_data_dir()`, a dev build beside the installed app, then conflicts instead of overwriting batches it never read. **Recovery renames it to `notepad-archive-v1.unreadable-.json` beside the original**, disambiguating rather than overwriting an earlier quarantine; only a temp file a crash left behind is dropped. Source of truth: `archiveNotesBeforeQuit` in `standalone/src/quit.ts`, the `'archive-failed'` phase in `standalone/src/quit-confirm-store.ts`; `write_notepad_archive_to`, `lock_notepad_archive` and `reset_notepad_archive_at` in `standalone/src-tauri/src/lib.rs`; the port in `standalone/src/tauri-adapter.ts`. diff --git a/docs/specs/notepad.rationale.md b/docs/specs/notepad.rationale.md index a1debda8c..c3b2271d7 100644 --- a/docs/specs/notepad.rationale.md +++ b/docs/specs/notepad.rationale.md @@ -203,7 +203,7 @@ who had just been told the notes were not stored and had chosen Cancel. quit they already asked for; a slower answer is a failure worth surfacing. The file is a sibling of `sessions/` rather than a member of it because the two have -different lifetimes: session snapshots are per window and swept by `clear_session`, +different lifetimes: session snapshots are per window and swept with it, while archived notes outlive the window that produced them and must survive that sweep. They share `write_file_atomically` because both carry user text and both must survive a crash mid-write; that is one implementation, not two. diff --git a/docs/specs/security-local.md b/docs/specs/security-local.md index 58407e0bd..4b0b0ba71 100644 --- a/docs/specs/security-local.md +++ b/docs/specs/security-local.md @@ -135,31 +135,39 @@ Source of truth: the shared rule and predicates — `isLoopbackHost`, `isOwnOrig The attacker is another local account reading disk; what the remote stack leaves behind is `docs/specs/security-remote.md` -> "Credentials at rest". -**Session snapshots are owner-only before any bytes are written.** -`restrict_to_owner` locks `/sessions/` and, *first*, the temp file -renamed into it, applying a protected single-ACE DACL on Windows where a unix -mode is a silent no-op (`docs/specs/standalone.md` -> "Persistence"). The same -helper locks the whole standalone app-data directory before the sidecar spawns. +**Session snapshots are owner-only before any bytes are written.** Standalone +persists one window's structure per file as `/sessions/