See
docs/specs/glossary.mdfor Session / Surface / Pane / Door vocabulary. Owns the standalone-specific layer: the Tauri windows, the Rust ↔ sidecar bridge, the AppBar, persistence at the adapter boundary, shutdown ordering, logging, and the build/dev workflow. Defers the protocol it speaks — PTY lifecycle, message contracts, persisted-session types, adapter-agnostic invariants — todocs/specs/transport.md. Evidence and dead approaches: standalone.rationale.md.
flowchart LR
W[Webview per window: TauriAdapter] -- invoke --> R[Rust]
R -- "emit_to (§Routing)" --> W
R -- JSON lines on stdin --> P[Node sidecar]
P -- JSON lines on stdout --> R
P -. stderr .-> R
R --> L[(dormouse.log)]
D[dor in a pane] -- control socket --> P
P -- /ws/burrow --> X[(Relay)]
Rust stays thin: besides the bridge, only the OS-integration edges (window
events, menu, file drop, dock icon, logging) and the session file store. All
real logic runs in the sidecar, on the same lib/src/host/ modules the VS Code host runs — build-sidecar-proxy.mjs
bundles them into the sidecar's .cjs copies, so the two hosts cannot drift.
Source of truth: bootstrap in standalone/src/main.tsx, whose comments carry
the step order. The ordering invariants:
- The window label is resolved first: every window-keyed Rust command and
isMainWindow()read it. platform.updatesis set inmainonly, beforesetPlatform(docs/specs/auto-update.md).await platform.init()precedes the restore andinstallPeerSurfaceResponder(): init registers the listeners resume replay and the responder's seedingstatusanswer arrive on, and hydrates the session cache (§Persistence; rationale).- Tauri only, the quit flow and the per-window close listener are installed.
- The shell store is seeded, awaited, before the restore and the Wall
mount, so the first restored pane spawns with the persisted shell
(
docs/specs/layout.md). - Tauri only, a tear-out boot is tried; otherwise, in both hosts,
restoreWindowOrFresh.armWorkspaceMoves()runs strictly after that restore, since arming drains whatever was dropped on this window while it booted (§Arrival queue). startUpdateCheck()runs inmainonly (§Windows); the app renders withmultiWorkspaceandenableBurrow, which gates only the Burrow UI chunk (§Burrow service).
Must display fatal bootstrap errors with a reload action, for both Tauri and the browser harness, instead of leaving a blank window.
Source of truth: standalone/src-tauri/src/lib.rs (SidecarState, the
#[tauri::command] set) and standalone/sidecar/main.js (the dispatch table);
TauriAdapter in standalone/src/tauri-adapter.ts.
stdout is the protocol: sidecar diagnostics go to stderr. Most invokes are
thin sidecar forwarders; two carve-outs are handled in Rust: load_session / save_session (§Persistence)
and the Windows clipboard readers (clipboard_win.rs;
docs/specs/mouse-and-clipboard.md §8.6).
Managed-voice audio is the one byte payload that rides the pipe, as base64 in its voice:result line (rationale; docs/specs/transport.md → "Managed voice").
OPEN_PORT_TIMEOUT_MS and OPEN_PORT_TIMEOUT_PER_ID_MS in lib.rs mirror
lib/src/lib/platform/types.ts and standalone/sidecar/pty-core.js;
lib/src/lib/mirrored-constants.test.ts pins the copies together.
A blocking command must be async — #[tauri::command(async)] or a plain
#[tauri::command] over an async fn. Tauri runs a sync command on the main
thread, where waiting on the sidecar or the arrival-journal lock stops the webview painting for the whole
round trip (rationale). The three clipboard readers included: their
non-Windows branches round-trip through the sidecar. A source-scanning test in
lib.rs enforces it.
pty_graceful_kill SIGTERMs the calling window's live PTYs (§Routing) and
resolves one grace tick after the last exits, or at its timeout for
SIGTERM-ignoring programs. Must forward final output during that grace
period. Under ConPTY the SIGTERM is an immediate kill. The sidecar
keeps each PTY's latest 200,000 UTF-16 code units for replay. Pinned by standalone/sidecar/pty-core.test.js.
Sidecar events reach the webview, where TauriAdapter converts dor control
requests into the dormouse:control-request CustomEvent that Wall handles
(docs/specs/dor-cli.md → Host Plumbing, including the sidecar env).
The Burrow — relay socket, enrollment, ACL, pairing ceremony, remote-api v1
— runs in the sidecar, never the webview (docs/specs/relay.md → "Burrow
side"): the same BurrowService the VS Code extension host runs, bundled to
sidecar/burrow.cjs. Nothing the webview says can widen access (docs/specs/remote-security-model.md).
State. Rust creates the app-data directory owner-only and passes it as
DORMOUSE_STATE_DIR (§Persistence); FileBurrowStateStore keeps enrollment and
ACL there as one burrow.json, so a write is one atomic rename (rationale),
with the network policy beside it (docs/specs/remote-network.md → "Policy").
burrowToken is a bearer credential and never enters a webview realm.
Against the shared store contract (docs/specs/relay.md → "Burrow side"):
- Reads fail closed. Only
ENOENTand an unparseable file answer empty — but the network policy's unparseable file reads as Nothing (docs/specs/remote-network.md→ "Policy"). Any other read error is neither answered nor memoized: the load rejects, taking the save behind it (rationale). - The in-memory view advances only after the rename succeeds.
persistentis declared, never inferred. With no state directory (Rust passes an empty value) the store holds both values in memory, warns once, and reportspersistent: false. The browser harness is not this case: its per-run temp directory makes a dev enrollment live and die with the run.
The direct path. The sidecar answers a direct-offer
(docs/specs/remote-api.md → Transport → "Direct path") over
node-datachannel. A sidecar package's transitive dependencies do not ship
— the bundle copies standalone/sidecar/node_modules alone — so the addon, its
platform packages (optionalDependencies) and detect-libc are declared in
standalone/sidecar/package.json directly, and every entry it declares stays
external to burrow.cjs, since the addon resolves its .node beside its
own __dirname. Source of truth: assertNothingInlined in
standalone/scripts/build-sidecar-proxy.mjs.
The bridge. Webview → sidecar is one passthrough invoke,
burrow_command(payload); sidecar → webview is burrow:result, burrow:ask
and burrow:event. The correlation field is burrowRequestId, never
requestId: Rust swallows any sidecar line whose data.requestId matches a
pending invoke (rationale).
Asks and answers. What the sidecar cannot know — a pane's name, its focus,
its xterm size — it asks over burrow:ask, and
lib/src/remote/burrow/peer-surfaces.ts answers naming the ask's
burrowRequestId.
- An ask collects one answer per window and concatenates them in label
order, keyed by which window answered, never by how many have: Rust stamps the sending
window's label on every
burrow:command, so a window answering twice contributes once. - Rust pushes the live window labels (
burrow:windows) at setup and on every window create and destroy; dropping one settles the asks that window can no longer answer. An ask in flight is only ever narrowed. - An ask naming a
surfaceIdgoes to that Surface's owner alone —attach,resizeandreleaseact on the pane they reach — and Rust names the window it delivered to (burrow:askDelivered) so the collector settles on that one answer.ASK_BUDGET_MSbounds the whole fan-out; whatever answered is the best available snapshot. - A late answer:
docs/specs/remote-api.md-> "Directory".
The sidecar owns the parse, standalone's only one
(docs/specs/terminal-escapes.md → Parsing location): a pty-core data event
reaches the webview as pty:data, terminal:semanticEvents and
terminal:toolEvents, never raw, and every attached Client and the
AlertManager read the same parse. The webview pushes its resolved terminal
colours (pty_theme_colors → pty:themeColors) because the sidecar has no
DOM; null before the first push falls a colour query through to xterm.js,
and a malformed push is ignored, never half-applied.
A remote sink must never break the local pipe: a throw in the tap is logged
and every pty:* event still goes out. A spawn or an exit retires
that PTY generation's parser, so a half-read sequence cannot splice onto the
next one.
Source of truth: createSidecarHost in lib/src/host/remote/sidecar-entry.ts;
standalone/sidecar/main.js (the tap); burrow_command / burrow_state_dir in
standalone/src-tauri/src/lib.rs.
The sidecar runs the alerts' host role (docs/specs/alert.md), the same
createAlertHost VS Code's extension host runs: one AlertManager, every
window a realm under its label (rationale).
- Must offer every stdin line to
createSidecarHost'shandleCommandbeforemain.jsdispatches it: it owns the PTY commands the alerts must see, every alert, Burrow and managed-voice command, and the theme push. - Webview → sidecar is one passthrough,
alert_command(payload), and Rust stamps the invoking window's label on it aswindow, over any the payload claimed. An unstampedalert:commandis ignored. Human input ridespty_write'suserInputinstead (docs/specs/alert.md→ Engagement). helloat adapter init ends the label's previous realm, a reload keeping the label; a label gone fromburrow:windowsends its realm too.- An await's answer carries
forWindow, the label that parked it — never a Sessionid, which would route it to that Session's owner, nor arequestId, which Rust swallows (rationale). alert:speakcarries its Session'sid, which Rust routes it by.- Must re-send each listed Session's
alert:statebehind the answer topty:requestInit: a reloaded window and an arriving Workspace learn their rings and TODOs nowhere else. Asyncre-sends only the Sessions its window names, each to its owner, and both stores' snapshots withforWindow. - Rust passes a spawn's
options.alertthrough opaque; the sidecar seeds from it (docs/specs/alert.md→ Public State) and strips it beforepty-core. pty-coreis the one source of helper status, reporting each spawn's validation and each successful promotion throughonHelper.
Source of truth: createSidecarHost in lib/src/host/remote/sidecar-entry.ts;
alert_command / forward_stamped in standalone/src-tauri/src/lib.rs.
On Windows the app carries two subsystem variants of the same node.exe:
- The sidecar must run under a GUI-subsystem node, or Win11's DefTerm handoff
flashes a stray Windows Terminal window behind Dormouse (rationale).
build.rspatches the bundlednode.exe(force_windows_gui_subsystem). dormust run under a console-subsystem copy, or it silently drops everything it prints inside a shell's ConPTY (rationale).start_sidecarderives the copy (resolve_dor_node_path) and pointsDORMOUSE_NODEat it.- Never leave the GUI node's directory on a pane's PATH: a bare
nodein a dev pane would be console-less (rationale).start_sidecarpasses it asDORMOUSE_GUI_NODE_DIR; the sidecar strips it from each pane's PATH.
The PE offsets live once, in standalone/src-tauri/src/pe_subsystem.rs, shared
with build.rs. Source of truth: withoutGuiNodeDir in
standalone/sidecar/pty-core.js.
Source of truth: standalone/sidecar/main.js. Browser cleanup is pinned by standalone/sidecar/shutdown.test.js.
Shutdown (sidecar:shutdown, stdin EOF, or SIGTERM) is idempotent and
ordered: browser-host cleanup first, awaited under one 1.5 s deadline that
must stay inside Rust's shutdown_sidecar_and_wait grace (~2.5 s) before it
kills the process group (docs/specs/dor-browser.md owns the teardown contract); then the dor control
socket; then host.dispose(), the alerts then the Burrow, settling every
outstanding ask; then every PTY; then exit.
A parent-PID watchdog self-triggers shutdown if the Tauri process
disappears: stdin EOF is not always delivered on a force-kill, and an orphaned
sidecar keeps conpty.node/conpty.dll loaded and blocks the NSIS installer
(docs/specs/auto-update.md, Sidecar teardown on Windows).
Every quit trigger is driven through the webview quit orchestrator (§Quit flow);
Tauri's RunEvent::Exit then runs shutdown_sidecar_and_wait as a final
backstop.
Source of truth: standalone/src/AppBar.tsx.
The AppBar is the draggable titlebar region: the Workspace strip, then —
Windows/Linux only, since macOS gets native traffic lights from
titleBarStyle: "Overlay" — the window controls. Neither a theme picker nor a
shell picker belongs here: both live in the Settings dialog
(docs/specs/theme.md). The strip's gestures and appearance are
docs/specs/layout.md → Workspace tabs; its indicators are
docs/specs/alert.md → Workspace union.
Picking a shell in the Settings dialog's Shell row
(lib/src/components/ShellPicker.tsx) persists it; what a changed pick spawns:
docs/specs/layout.md -> "Session lifecycle and terminal registry".
Source of truth: the .menu(...) builder in standalone/src-tauri/src/lib.rs.
The app replaces Tauri's default menu with macOS-only App, File, and Edit
submenus and a Window submenu; File holds Reopen Closed (docs/specs/reopen.md
→ "Reopen verb"). Must keep the macOS fullscreen item: it and its
Ctrl+Cmd+F are the only exit from native fullscreen when AppKit does not reveal
the traffic lights.
- Must keep the Edit submenu on macOS, Tool iframes' only clipboard path (rationale).
- Must cancel the keydown of any chord the page handles, so the menu item
skips it (rationale):
docs/specs/mouse-and-clipboard.md§3.9, §8.2, §8.9. - Never add an Edit submenu on Windows or Linux (rationale).
Never show macOS's Siri affordance in a Dormouse webview (rationale).
install answers NO to allowsWritingToolsAffordance on wry's WKWebView
subclass, never on WKWebView itself, once at RunEvent::Ready; the override
is class-wide, so it covers every window, text field, and browser pane.
Source of truth: install in standalone/src-tauri/src/macos_siri_affordance.rs.
Several windows, each with several Workspaces, over one sidecar
(docs/specs/glossary.md). The sidecar has no window concept, so Rust owns
the map from PTY to window and every stdout line passes through it.
The label is the window's persistence identity: main for the first window
(fixed in tauri.conf.json), ws-<n> for every later one, numbered above every
live label, every sessions/ws-*.json on disk, and every retained
arrival-journal endpoint, so a new window cannot claim a saved or pending
identity. Journal-only labels reserve numbers without opening windows.
Every new window is cloned from app.windows[0], so its window settings
and CSP carry across with no second copy.
Never let a window's webview throttle or suspend in the background:
app.windows[0] sets "backgroundThrottling": "disabled" (macOS 14+; a no-op
on Windows and Linux; rationale).
Capabilities are split: default.json covers main and the ws-* glob,
and main-only.json scopes updater:default and core:app:allow-version to
main, which structurally enforces that the install runs in the window the walk
tears down last (docs/specs/auto-update.md). standalone/scripts/tauri-conf.test.mjs
pins the label, the throttling, and both capabilities.
Rust holds the union of every window's Workspaces, since each webview's
store (lib/src/lib/workspace-store.ts) sees only its own. Each window reports
its list on every change, and the union is broadcast as dormouse://workspaces
with a monotonic revision; a webview drops a snapshot behind the one it holds.
- Must mint numbered ids only in Rust,
workspace-<n>off one counter, handed to a webview in blocks (workspace_reserve_ids) so a create mints synchronously. The refworkspace:<n>is the id's number, so it never renumbers and never collides across windows. - Must allow boot and creation when reservation fails, using opaque UUID
ids, and retain those ids and refs for their lifetime, even after
reservation recovers (
docs/specs/dor-cli.md→ "Handle Model"). - Must seed the counter above every id named by a snapshot, a retained
arrival-journal record, or a window's report, and never below 2:
workspace-1is a bare Wall's only Workspace. - A
dorrequest naming a Workspace or Window routes to the window holding it (§Routing). A target the registry cannot place — one no window reports, or a name two windows carry — falls through to the caller's window, which refuses a name duplicated there and otherwise resolves its own. A target routes as a number only when it reads asNUMERIC_WORKSPACE_REF(dor/src/protocol.ts);007and0are names. - Must keep numbered and opaque refs consistent across Rust, the webview, and
the browser harness:
standalone/scripts/workspace-ref-cases.jsonholds the shared cases. Destroyedforgets the window's entries and broadcasts.
Source of truth: standalone/src-tauri/src/workspaces.rs;
installWorkspaceRegistry in standalone/src/workspace-registry.ts.
Showing an id is the source still consuming it until its transfer mark, otherwise its owner.
| Sidecar event | Key | Goes to |
|---|---|---|
pty:data, terminal:semanticEvents, terminal:toolEvents |
data.id |
the window showing it; after the mark, dropped until its replay, which carries the bytes and from which the receiving window re-derives the events (rationale) |
pty:exit |
data.id |
the window showing it, never suppressed |
pty:replay |
data.forWindow, then data.id |
the requesting window, including exited buffers; without an address, its owner; never suppressed |
pty:marked |
data.id |
the window showing it; the id then falls silent until its replay |
pty:list |
data.forWindow |
the window that asked |
alert:* |
data.forWindow, then data.id |
that window; else the window showing the Session; neither → every window |
dor:controlRequest |
params.workspace, params.window, data.surfaceId |
in that precedence: the window holding the named Workspace (§Workspace registry), the named window, the caller's Surface's owner; none → the most recently focused window (a never-focused one last), never all |
dor:controlCancel |
data.requestId |
the window its request went to; unknown → every window |
burrow:ask |
data.params.surfaceId |
its owner; a Surface with no PTY here, or an ask naming none, → every window (§Burrow service) |
voice:result |
— | nowhere: only one that outlived its invoke gets here |
| everything else | — | every window |
- Ownership is minted only in
pty_spawn, dropped bypty_killor the window going away, and reassigned by a transfer. Never dropped by an exit, nor does an exit end a transfer's marking phase (rationale). Minting clears any suppression left under that id.pty_killdrops the owner before the sidecar removes the Session's alert entry, so the removal's state reaches no window. - A PTY event no window owns is dropped, and the shell is reaped (rationale).
Destroyedsendspty:reapfor whatever the departing window still owned; the sidecar removes those Sessions' alert entries and SIGTERMs them. The quit teardown'spty:gracefulKillremoves no entry: windows still show those PTYs. - A
dorrequest naming a Surface no window owns is answered with an error, never handed to a sibling. pty_request_init,pty_graceful_killandcapture_agent_recoverytarget the invoking window's own PTYs and take no ids: a window tearing down must never touch a sibling's terminals. Each also excludes what an arrival claims (§Arrival queue).- A
dorcancel follows its request: Rust remembers which window took eachrequestIdand forgets it on the response. - Every webview listener names its own window: Tauri delivers an
emit_toevent to any listener with the defaultAnytarget (rationale).listenToWindowinstandalone/src/window-label.tsis the only caller of the event API, pinned bystandalone/scripts/window-listeners.test.mjs. - The focus order is the fallback owner for a
dorrequest naming no Surface, and the drag hit test's stand-in for a z-order the OS does not expose.
Source of truth: route in standalone/src-tauri/src/routing.rs;
dispatch_sidecar_event in standalone/src-tauri/src/lib.rs.
Boot reopens every window sessions/ names, main first then each ws-<n>
in numeric order, capped at MAX_RESTORED_WINDOWS with the excess logged and
left on disk (rationale). An unreadable snapshot still opens its window,
booting fresh. main is focused last, so it comes up in front; a session
naming no main relaunches with a fresh, empty one.
Geometry is a sibling of the snapshot, sessions/<label>.geometry.json,
written through the same atomic writer, debounced, and re-applied at boot. No
tauri-plugin-window-state (rationale). The live box is cached from the
Moved / Resized events, which both the write and the cross-window drag hit
test read; the cache's lock rules are at GeometryState (rationale).
Source of truth: note_geometry / restore_windows in
standalone/src-tauri/src/lib.rs.
Must clear label-keyed ownership, registry, geometry, and close state in the
Destroyed arm, when Tauri has removed the window from webview_windows()
(rationale), and send the remaining live labels to the sidecar.
Must remove incoming arrivals from the reap before killing orphaned PTYs;
the source still holds those Sessions. Must defer every approved exit until
every hand-back worker has completed, on every exit path, and force it after
QUIT_PHASE_TIMEOUT_MS, logging the timeout, so a stalled disk operation
cannot trap the app.
Source of truth: CleanupGate and WindowEvent::Destroyed in
standalone/src-tauri/src/lib.rs.
Closing a window with siblings alive ends that window alone; only the last
window's close is the quit, and on macOS too. Rust prevents the close and emits
dormouse://window-close-requested; the webview acks (a watchdog closes the
window anyway if that listener is dead), asks when any of its own Workspaces'
closes would (docs/specs/reopen.md → "Workspaces and windows"), hands Rust a
reopen record when it asked nothing, removes its snapshot, kills its PTYs, and
calls back close_window.
- Must attempt to remove the blob, geometry and temp sibling included, before
killing this Window's PTYs (
docs/specs/transport.md→ "The governing rule"). A removal failure is logged and the close proceeds. - It runs no agent-recovery capture: nothing resumes; Reopen rebuilds.
- It confirms on a pending download as well: the
download lives in this webview, so nothing else can install it
(
docs/specs/auto-update.md). - Must refuse every later save for a closing label, geometry included, for the
process lifetime. Both the webview's
remove_window_sessionand the ack-timeout path'sfinish_window_closeset it; no label is reused within a process. close_windowis the one Rust half both endings share — a deliberate close and a window whose last Workspace moved away (§Transfer).
The ack and confirm gates are one shared flow with the quit
(createTeardownFlow in standalone/src/teardown-flow.ts): a quit votes and
waits its turn in the walk, a close tears down at once.
Arbitration. A second flow is never refused in silence: an unsettled context parks its flow and leaves its host waiting on a decision that cannot come.
| Arriving | Holder | Outcome |
|---|---|---|
| quit | a close still on its dialog | the close is cancelled (window_close_cancel); the quit takes over |
| quit | any committed flow | the quit acks and votes, since this window is ending anyway |
| close | a quit, in any state | refused at once with window_close_cancel; the window stays |
A quit cancelled elsewhere drops only a quit's dialog, never this window's
own close question. Pinned by standalone/src/teardown-arbiter.test.ts.
Source of truth: standalone/src/window-close.ts; request_window_close /
finish_window_close in standalone/src-tauri/src/lib.rs.
Dirty Tool consent: docs/specs/dor-tool.md → Closing unsaved Tools.
A Workspace moves between windows without ending anything. No process is killed: a move is not a closure.
The protocol and every failure path are §Arrival queue; what a move is:
- A window whose last Workspace left closes itself, with no confirmation and no kill.
- Must collapse the source only after
workspace-departedconfirms adoption, before committing its release and removing its tab or closing its Window.standalone/src/workspace-move.test.tspins this order. Presentation isdocs/specs/layout.md→ Workspace motion. - A pane's helper Session travels with it, directly after its source, which lets the target's resume re-parent it; nothing else in the payload names it.
- An arrival whose PTYs never answer is refused, never cold-restored: those
shells are still running (
docs/specs/transport.md→ "Reconnection protocol"). - A Workspace that comes back must mount from the record it brought, never the plan it first booted with.
Source of truth: prepareWorkspaceTransfer in
lib/src/components/wall/workspace-transfer.ts, standalone/src/workspace-move.ts,
transfer_workspace in standalone/src-tauri/src/lib.rs.
Tool transfer follows docs/specs/dor-tool.md → Persistence and hosts.
Alert state and alarm delivery follow docs/specs/alert.md → Live Workspace transfer.
A tear-out opens the window positioned so the dragged tab lands under the
cursor, at the source window's size. Its first flush writes
sessions/ws-<n>.json; everything else is the transfer above.
An arrival is one transaction keyed by workspaceId, carrying the source's
Workspace, its terminalIds, and allIds naming every member Surface, 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 reads that record
rather than inferring it from the suppression map (rationale). The mark-and-replay
split is docs/specs/transport.md → "Transferring a Workspace". Sidecar lines
reach a webview through Rust (§Routing).
sequenceDiagram
participant S as Source webview
participant R as Rust
participant P as Sidecar
participant T as Target webview
S->>R: transfer_workspace / open_workspace_window
Note over R: begin_arrival: journal, then owner := target
R->>P: pty:mark {ids}
P-->>S: pty:marked {id, mark}
Note over R: drop id's output until target replay
S->>R: transfer_workspace_content (buffers at marks)
R-->>T: workspace-arriving (tear-out: build window)
T->>R: take_arrivals, then adopt_ready
R->>P: pty:requestInit {ids, marks, requestId}
P-->>T: pty:list, pty:replay since mark, alert:state
alt adopted
T->>R: adopt_done
R-->>S: workspace-departed
else adopt_failed, Destroyed, window not built, or ARRIVAL_MAX
R-->>S: workspace-arrival-failed {replayIds}
R->>P: pty:requestInit {forWindow: source, marked ids}
P-->>S: pty:replay since mark
end
- Must return preparation refusals as
{ moved: false, reason }without changing ownership. OnOkthe source marks the Workspace transferring: still mounted, nothing released, omitted fromgetWindowSnapshot. - An arrival without content is not drainable.
- The target arms its collector before
adopt_ready(rationale), andpty:requestInitnames that arrival's ids and no others. The target mounts the Workspace at the payload's index, else the drop index. It never spawns or kills; the replayedalert:stateis §Alerts. workspace-departednames that Workspace alone. The source then releases every Session (never kills one), drops the helper, and closes the Workspace, and its window if it was the last.- Nothing is released before the target has adopted it.
- A transferring Workspace is in no snapshot its source writes, and neither
end's teardown kills or interrupts its shells: the target's
pty_graceful_killandcapture_agent_recoveryexclude every id an arrival claims (boot_list_ids), and so does a boot'spty_request_init. - Must await
adopt_donebefore installing a torn-out Window; refusal releases its resumed Sessions and boots fresh. A refusedadopt_doneunwinds the mount from the received payload, releasing (never killing) and closing the Workspace, without preparing another move (rationale). - A refused arrival hands the shells back: Rust drops the record and the source clears transferring. With both ends gone the shells are reaped.
- An arrival unadopted after
ARRIVAL_MAXis handed back by a watchdog armed atbegin_arrival, retiring only the record it was armed for (queued_at). - A hand-back replays what the marked ids missed (rationale): each marked id
stays suppressed until its since-mark replay lands in the source's existing
xterm; an id never stamped goes straight back. Must retain source cuts from
pty:markedthrough target replay and natural exit until settlement, and discard them on explicit kill. Must apply a handed-back PTY's exit status after its replay, leaving its pane dead with no running command. take_arrivalsdoes not consume; the record settles atadopt_doneand the webview dedupes by id (rationale).AWAITING_REPLAY_MAXfails open only for suppressions no arrival claims (rationale).begin_arrivaljournals the arrival insessions/arrivals.jsonbefore ownership moves, never in either window's snapshot (rationale); a failed write refuses the move with nothing changed. Must retain the record until both snapshots reflect the outcome — marked settled at adoption, its durable destination reversed on hand-back — and tombstone settled arrivals into a deliberately closed Window until both snapshots omit them; failed settlement or hand-back writes are logged and block nothing live. A record left at boot is merged into its recorded destination beforerestore_windows, so the Workspace restores once, with fresh shells; must retain failed records for retry, roll back the target if trimming the source fails, and preserve a settled arrival's newer target record.
Source of truth: Arrival in standalone/src-tauri/src/routing.rs;
begin_arrival / restore_arrivals in standalone/src-tauri/src/lib.rs;
standalone/src/workspace-move.ts; markWorkspaceTransferring in
lib/src/lib/window-session-aggregator.ts.
A pointer captured on a strip tab keeps delivering pointermove and
pointerup outside the window, so the gesture stays the webview's and the
host is only asked where the cursor is (rationale). Past the strip edge a
throttled window_at_cursor probe lights a drop caret in whichever window is
under it; the release transfers there, or tears out when the cursor is over no
window or over this window outside its own strip.
Among windows containing the cursor the most recently focused wins — the OS exposes no z-order. The target decides the drop index. Never assume in-range pointer coordinates: a captured pointer reports them past the window's edges and negative (rationale).
Source of truth: window_at in standalone/src-tauri/src/routing.rs;
standalone/src/workspace-drag.ts.
One PersistedWindow per window, every Workspace in it, restored on the
next launch (docs/specs/transport.md → "The governing rule" and "Persisted session
types"). Each Workspace's Wall publishes to the Window aggregator, whose one
debounced writer is TauriAdapter.saveWindowState; getWindowState is the boot
reader and parses the blob once. Source of truth: windowStateSlot in
standalone/src/window-recovery.ts.
Boot restores per Workspace off one live-PTY list. Reload and relaunch are
the same path: nothing wires shutdown() to beforeunload, so a reload's PTYs
partition by saved pane id, while a relaunch's list is empty and every Workspace
cold-restores at its saved cwds. A live PTY no saved Workspace names goes to
the active Workspace, except a helper, which goes to the Workspace holding its
source.
Source of truth: restoreWindowOrFresh in standalone/src/window-restore.ts.
Every Workspace saving at the same moment costs one pty_get_cwds: both
adapters fold the calls of one microtask into a single invoke
(standalone/src/coalesce-cwds.ts). A listing's one scan
(docs/specs/dor-cli.md -> "Current Implemented Commands") is one
pty_get_open_ports_many, which the sidecar answers from one process-table
read and one socket scan; its deadline is
docs/specs/transport.md -> "Port scan deadlines".
Nothing is deleted at boot but orphaned session temp files and what the
arrival merge settles (docs/specs/transport.md → "Retiring the transcripts already on disk").
Never back the session blob with WebKit localStorage — a WAL that grows
without bound (rationale). The blob rides the SessionKeyValueStore seam over
the Rust-backed standalone/src/tauri-session-store.ts. Theme selection still
persists on localStorage (docs/specs/theme.md).
Rust file store. save_session / load_session persist the blob as one
atomic file per Tauri window, <state root>/sessions/<label>.json:
- The label is sanitized so it cannot escape the directory.
- Temp-then-rename, the temp file fsynced first and, on unix, the directory after, best-effort (rationale). The writer removes its own temp file on every error path, so only a crash leaves one.
- Window identity is implicit: each command keys by the invoking window's label, so the frontend stays window-agnostic.
- Only §Per-window close and the boot merge (§Arrival queue) delete a snapshot.
Must use <app_data_dir>/dev as the debug state root and <app_data_dir> for
release builds (rationale), and keep Burrow state directly under the
identifier's app_data_dir. Rust passes the sidecar its two directories by
environment, each created owner-only first and empty when it could not be:
DORMOUSE_STATE_DIR (the Burrow store, app_data_dir) and
DORMOUSE_RECOVERY_DIR (the state root, so a dev run's record cannot reach the
installed app). The browser-dev harness sets both to its per-run temp directory.
Source of truth: state_root_from / prepare_owner_only_dir in
standalone/src-tauri/src/lib.rs.
The session store is owner-only before any bytes are written
(docs/specs/security-local.md -> "Persisted state"; rationale). Must abort a
snapshot save if either permission change fails, preserving the previous
snapshot. Must withhold a state directory whose restriction fails, logging a
WARNING naming the path; the Burrow store and recovery record then stay in
memory.
getState() is synchronous; a Tauri invoke is not. TauriSessionStore
keeps a write-through cache that TauriAdapter.init() hydrates from
load_session (§Boot sequence), read synchronously and forwarded to
save_session with at most one write in flight, latest value winning.
Must skip an unchanged store write only when that value is queued or saved,
so an idle failed write stays retryable; dirty tracking above it is
docs/specs/layout.md → Session persistence.
Must await the store pipeline before exiting, under the quit timeout
(§Quit flow; rationale): drainSessionSaves resolves when it goes idle, a
rejected write included. Drain is a completion barrier, not a guarantee of
successful persistence.
Source of truth: TauriSessionStore in standalone/src/tauri-session-store.ts.
Must run shared capture and record ownership in the sidecar, under docs/compatible-agents.md. Must answer capture_agent_recovery and take_recovery_commands through respondAsync, returning { error } on throws rather than stranding the invoke.
- Must store the record at
<state root>/recovery.json. - Must claim the saved Window's pane ids across all its Workspaces from
TauriAdapter.init(); boot awaitsrecoveryReadyonly on the branch that can cold-restore. - Must restrict capture to the closing Window's eligible PTYs (§Teardown ordering, §Arrival queue).
- Never capture in the browser-dev harness; it claims records but reloads resume live PTYs.
Source of truth: pty:captureRecovery / recovery:take in standalone/sidecar/main.js.
Tool close consent: docs/specs/dor-tool.md → Closing unsaved Tools.
Source of truth: QuitMachine in standalone/src-tauri/src/quit_state.rs;
standalone/src/quit.ts (the webview orchestrator).
Must intercept every quit trigger in Rust and run the webview teardown before exiting (rationale).
Every window votes before any window is torn down (rationale).
stateDiagram-v2
[*] --> Idle
Idle --> Voting: request_quit → quit-requested
Idle --> Exit: request_quit, no windows
Voting --> Idle: quit_cancel → quit-cancelled
Voting --> Walking: last quit_vote, or last unvoted window forgotten
Voting --> Exit: last window forgotten
Walking --> Walking: quit_window_done or torn-down window forgotten → next quit-teardown
Walking --> Exit: quit_proceed, or no window left
Exit --> [*]: app.exit(0) past the cleanup gate
- A cancel is refused once the walk starts, and the walk tears
maindown last. - A window that leaves outside the flow is forgotten, its vote never waited on. A flow that runs out of windows exits.
- A quit keeps every window's snapshot on disk — the whole difference from a per-window close. A quit mid-transfer restores the Workspace at most once: from the target once it has published it, or from a source handed it back; a source torn down before its target adopts leaves it in no snapshot (§Arrival queue).
Every trigger funnels into request_quit(app):
| Arm | Fired by | Guard |
|---|---|---|
WindowEvent::CloseRequested |
the window close button | api.prevent_close() unless approved; refused outright while the walk runs. Only the last window's close is a quit (§Per-window close) |
RunEvent::ExitRequested |
a window-level exit request | api.prevent_exit() unless approved and cleared by the cleanup gate (§What a window's Destroyed settles); its code is ignored |
| the app menu's Quit item | the menu and its Cmd+Q |
a custom MenuItem, never PredefinedMenuItem::quit (macOS; rationale) |
applicationShouldTerminate: |
the Dock's Quit, osascript, logout, restart |
spliced onto tao's live delegate class at Ready, answering NSTerminateCancel and starting the flow; a re-sent terminate after approval gets NSTerminateNow once the cleanup gate clears (macOS; rationale) |
the quit_restart command |
the update notice's "Restart now", dor app restart |
request_quit with the restart intent (§Restart) |
Source of truth: standalone/src-tauri/src/macos_terminate.rs.
request_quit clears every window's acked, bumps seq, and broadcasts
dormouse://quit-requested; it must leave a walk in flight alone and keep
every vote already cast. Each window's orchestrator (initQuitFlow):
- always
quit_acks first, even if it then dedupes the event out; quit_votes when ready — at once when all-idle, else after the user confirms; a vote is not a teardown;quit_progresses when its ownquit-teardownarrives and at the install phase boundary;- tears down (§Teardown ordering), then
quit_window_done, orquit_proceedin the last window, which approves and callsapp.exit(0); - on a dialog cancel, calls
quit_cancel, which bumpsseqand drops every window's dialog. Nothing else cancels.
A watchdog thread keeps quit bounded against a dead or wedged webview:
| Phase | State | Budget |
|---|---|---|
| 1 — ack | some window has not acked | short; a listener is dead ⇒ log and app.exit(0) |
| 2 — voting | acked, no window walking yet | none — a window may be waiting on a human, who must never be force-quit; only approval or a seq bump ends it |
| 3 — walking | one window tearing down | QUIT_PHASE_TIMEOUT_MS (14 s) per phase, refreshed by quit_progress and by the walk advancing; no progress ⇒ log and exit |
Phase 3's budget must exceed the webview's teardown ceiling and the install
phase's sidecar-kill cap (docs/specs/auto-update.md → "Sidecar teardown on
Windows"); lib/src/lib/mirrored-constants.test.ts pins the ceiling, nothing
pins the install phase. Watchdog exits also pass the cleanup
gate. Each watchdog captures its seq, so a repeated trigger leaves the stale
one to exit without acting.
Must use the Workspace typed-letter confirmation for window close and quit,
naming every Workspace in the window, hidden ones included. It opens for running
Sessions; an all-idle quit proceeds without a prompt. A quit's prompt says
supported agent sessions resume when Dormouse reopens (§Agent recovery); a
window close's never does. The letter rule is docs/specs/layout.md →
Workspaces.
- Must cancel an unconfirmed request if Workspace membership changes; switching and reordering preserve it.
- Must exclude transfers from host teardown. An app quit queues while any
arrival is pending, a native window close while that window is an arrival
endpoint, and each retries through normal confirmation once the transfer
settles; quit supersedes queued closes. Must refuse new transfers while app
quit or either endpoint's close is queued, confirming, or tearing down, both
decided under one lock (
ArrivalQueue).
Source of truth: openQuitConfirm in standalone/src/quit-confirm-store.ts;
WorkspaceTeardownModalHost in standalone/src/WorkspaceTeardownModal.tsx.
Cross-window voting is pinned by standalone/src/quit.test.ts.
Every step of runQuitTeardown is individually bounded, and the whole sits
under a ceiling derived from the sum of those bounds, never a literal,
counting Rust's round-trip margin for the two steps that reach the sidecar
(QUIT_TEARDOWN_CEILING_MS in standalone/src/quit.ts; pinned by
lib/src/lib/mirrored-constants.test.ts). The steps:
captureAgentRecovery— first, since an agent's resume invocation exists only between the interrupt and the kill (§Agent recovery; rationale). A failed capture must not abort the steps behind it.requestSessionFlush— while PTYs are alive, so CWDs are fresh.gracefulKillPtys— this window's PTYs (§Rust ↔ sidecar bridge).requestSessionFlush({ probeCwd: false })— the post-exit state (docs/specs/transport.md→ "Persisted session types"; rationale).flushWindowSession— the one Window blob, bounded like the rest.drainSessionSaves(§Persistence).- In the last window only, if an update is pending, a fresh
quit_progresstheninstallPendingUpdate(), strictly after the save (docs/specs/auto-update.md); the phase-3 watchdog backstops a hung installer. - Always
quit_window_done, orquit_proceedin the last window, infinally.
A restart is a quit that relaunches: the same vote, confirmation and teardown, with only the exit changed.
- The trigger that leaves
Idleunapproved fixes the intent — whether to relaunch, and the requesting Surface. A repeat trigger keeps it, a cancel clears it, and a quit queued behind a transfer carries it.quit_restartanswers whether the quit it landed in relaunches —falsewhen it joined a plain quit. dormouse://quit-requestedcarries only{ requester }: a restart asks exactly what a quit asks.- The requester never counts as running work in the restart's confirmation
(
quitRunningWork); every other running Session still asks, and Workspace and window closes count everything. - Every approved exit stays
app.exit(0); the relaunch runs inRunEvent::Exit, aftershutdown_sidecar_and_wait, viatauri::process::restart, which on macOS re-readsInfo.plist, so a bundle replaced in place starts as the new version. NeverAppHandle::request_restart(rationale). - Must clear the restart intent when macOS re-sends an OS terminate after approval, so logout never relaunches.
quit_restartrefuses a debug build and an executabletauri::process::current_binarycannot resolve (rationale).- A Windows quit holding an update relaunches by its installer instead
(
docs/specs/auto-update.md→ "Platform behavior at quit").
Source of truth: quit_restart in standalone/src-tauri/src/lib.rs;
QuitIntent in standalone/src-tauri/src/quit_state.rs.
The WindowEvent::DragDrop handler emits the dropped paths as
dormouse://files-dropped, which TauriAdapter fans out to onFilesDropped.
The path is inert today: tauri.conf.json sets dragDropEnabled: false.
Behavior: docs/specs/mouse-and-clipboard.md (§8.7 Drag-to-Paste).
Windows release builds use the GUI subsystem, so nothing streams to a launching
terminal. Rust appends sidecar stderr, malformed stdout, and its own diagnostics
to %LOCALAPPDATA%\Dormouse Terminal\dormouse.log on Windows,
$TMPDIR/dormouse.log elsewhere, overridable via DORMOUSE_LOG_FILE. The log
resets at app startup.
Source of truth: init_log / read_update_log in standalone/src-tauri/src/lib.rs.
Must let an Objective-C exception that AppKit raises beneath a tao callback,
such as the sendEvent: override, unwind to AppKit, whose event loop reports
it and keeps running. Both halves are required (rationale):
- Must build release with
panic = "unwind". - Must take tao from the
diffplug/taofork through[patch.crates-io], whose Apple callbacks areextern "C-unwind": one fork branch per patched release (dormouse-0.35istao-v0.35.2plus that commit),tao-macrosfrom the same rev. Must rebase the commit onto the new release when tauri moves tao, since an unused[patch]only warns; drop the patch once tauri depends on a tao carrying tauri-apps/tao#1354.
Rust panics still abort (abort_on_panic, installed first in run). An
exception raised inside the Tauri event handler still aborts (rationale).
Source of truth: abort_on_panic in standalone/src-tauri/src/panic_policy.rs;
[patch.crates-io] in standalone/src-tauri/Cargo.toml.
Pinned by standalone/src-tauri/tests/objc_exception_unwinds.rs.
Source of truth: standalone/package.json, standalone/src-tauri/tauri.conf.json,
and the root package.json for dev:standalone and innerdogfood;
runDev in standalone/scripts/dev-standalone.mjs.
stagestages the dor CLI (docs/specs/dor-cli.md) and the sidecar'slib/src/host/bundles.- The sidecar bundle and the webview bake
DORMOUSE_RELAY_ORIGIN(docs/specs/relay.md→ "Relay origin"); the webview CSP has no relay sources (standalone/scripts/tauri-conf.test.mjs). - A self-host
tauri buildoverlays no updater endpoint and no updater artifacts, so the binary cannot reachdormouse.shand needs no signing key. Pinned bystandalone/scripts/dev-standalone.test.mjs. - The bundle ships the whole sidecar via the
../sidecar/**/*resources glob, including node-pty's prebuilds + bundled ConPTY and the shell-integration scripts (docs/specs/theme.md-> "OSC color queries on Windows require the bundled ConPTY",docs/specs/terminal-state.md-> "Shell-integration injection"). - Must start native dev with Vite on an OS-assigned loopback port and pass its
bound URL to Tauri; a direct
pnpm exec tauri devkeepstauri.conf.json's defaults. Inherited browser-dev settings never enable browser mode. - May pin Vite with
DORMOUSE_BROWSER_DEV_VITE_PORT; an occupied port must fail without stopping its owner. - Must close Vite and the owned Tauri process tree on startup failure, exit, SIGINT, SIGTERM or SIGHUP.
- Must key the native dev Tauri identifier to the canonical worktree path, so
parallel worktrees and the installed app never share app data. The default
log is
<worktree>/standalone/src-tauri/target/dormouse-dev.log. - Must limit Windows pre-dev cleanup to sidecars executing from this worktree's default debug directory; never kill a listener by port.
- Must re-stage and restart after changing sidecar, staged CLI, or bundled host sources. Frontend edits hot-reload; Tauri watches Rust.
- May point a dev build at a local
pnpm dev:hosted— the origin it prints, withDORMOUSE_RELAY_IS_HOSTED=1— to speak managed voice through it; it answers no otherHost(docs/specs/security-local.md-> "Persisted state").
pnpm innerdogfood starts the standalone sidecar directly, a localhost-only HTTP bridge, and Vite with VITE_DORMOUSE_BROWSER_DEV_HOST, then opens the app in an agent-browser session; that env var selects BrowserSidecarAdapter. Inside Dormouse, dor tool innerdogfood starts and shows it.
- Must bind OS-assigned ports for Vite and the HTTP bridge by default, and derive the default browser key from the canonical worktree path, so parallel worktrees are isolated.
- May pin ports with
DORMOUSE_BROWSER_DEV_VITE_PORT/DORMOUSE_BROWSER_DEV_HOST_PORTand the session withDORMOUSE_BROWSER_DEV_AB_SESSION. An occupied pinned port fails startup;0requests an OS-assigned port. Explicit overrides are the caller's isolation responsibility. - Must open through
dor agent-browserwhenDORMOUSE_SURFACE_IDis set, otherwiseagent-browser, and print the app URL, the bridge token, the browser identity, and the command to drive it. Must print a--keyas a key, never as a session (docs/specs/dor-browser.md→ "Managed identity"). Must leave the browser to the Tool when its own terminal is a Tool and no session is pinned, printing the Tool's handle. - Must give its sidecar a private
AGENT_BROWSER_SOCKET_DIR, since the inner app names managed sessions as the installed app does; the harness's own browser keeps the caller's. - Must await Vite's own listener before opening the browser and use the actual ports for bridge authentication and CORS, and close the bridge and Vite and terminate owned children on startup failure or shutdown. Pinned by
standalone/scripts/dev-agent-browser.test.mjs. - Must keep logging the Burrow state directory in the form the pairing walkthrough parses; pinned by
lib/src/lib/mirrored-constants.test.ts.
The bridge is a transport shim over the same sidecar protocol, not a second PTY implementation: POST /__dormouse_dev_host/send and /invoke, host→webview events as SSE on GET /__dormouse_dev_host/events, and browser console output mirrored to POST /__dormouse_dev_host/console. The Burrow rides it too, against a per-run temp state directory.
The bridge is authenticated: its gates are docs/specs/security-local.md -> "Loopback Listeners". The token is per-run, attached by BrowserSidecarHost.url() alone, and never the dor control-API controlToken (rationale); the CORS origin is never * (rationale). A CORS preflight is the one carve-out: OPTIONS answers 204 with the CORS headers before the token check.
The harness may omit native-only desktop chrome but must preserve every PlatformAdapter contract the app uses, the sidecar's alerts included (stamped with the one label it simulates, main), so a rule that holds only across the host boundary is exercised. BrowserSidecarHost.init() resolves on the SSE stream being open, so a seed cannot precede the stream that carries its reply; retryable connection failures reconnect within the open timeout, and after a reconnect the adapter sends sync. It must mirror standalone's Session-persistence answer — one PersistedWindow per window in localStorage, and the same agent-recovery claim against a per-run temp state directory — and never captures (§Agent recovery). Tauri APIs must not be required at static module-evaluation time when VITE_DORMOUSE_BROWSER_DEV_HOST is set.
Source of truth: standalone/scripts/dev-agent-browser.mjs; standalone/src/browser-sidecar-adapter.ts.
- A setting to allow the Siri affordance, for users who want Siri in
Dormouse. The override stays installed and
disallowreads the setting, answering fromWKWebView's own implementation when it allows Siri: the runtime cannot remove a method, so toggling is a flag, not a second swap.