Skip to content

Latest commit

 

History

History
750 lines (634 loc) · 47.3 KB

File metadata and controls

750 lines (634 loc) · 47.3 KB

Dor CLI

See docs/specs/glossary.md for canonical Surface / Session / Pane vocabulary. A Surface is dor's user-facing handle; Pane stays layout vocabulary, out of the public target grammar.

Owns the bundled CLI: staging, the PTY env contract, external-binary spawning, control plumbing, handles, the shipped command set, and the bundled agent skill. The CLI is the public API; any socket under it is private host plumbing.

Defers to docs/specs/dor-browser.md for what a browser Surface renders and to docs/specs/alert.md for dor await's wake conditions. Evidence: dor-cli.rationale.md.

Bundling And PATH

dor must work without npm i -g. Both hosts stage dor (scripts/stage-dor-cli.mjs) before build and prepend its bin directory to every spawned PTY's PATH. Staged: bin/dor + bin/dor.cmd, dist/dor.js (esbuild), and a generated package.json declaring "type": "module" so Node runs ESM independently of parent package metadata.

Both launchers must set ELECTRON_RUN_AS_NODE=1 themselves before exec "$DORMOUSE_NODE" "$DORMOUSE_CLI_JS", or under VS Code dor silently does nothing and exits 0 (rationale). Dormouse-launched terminals must rely on injected env, never on a globally installed Node; each launcher's PATH-node fallback is for developer/manual use.

Both launchers must return the CLI's exit status. dor/test/launcher.test.mjs pins both, including dor.cmd on Windows.

Public PTY env:

  • DORMOUSE_NODE — Node runtime the launcher execs; process.execPath under VS Code. On Windows the standalone host must point this at a console-subsystem node, never its GUI-subsystem bundled node, which drops all stdout/stderr under a shell's ConPTY (rationale; docs/specs/standalone.md, Windows node subsystem).
  • DORMOUSE_CLI_JS — absolute path to staged dist/dor.js.
  • DORMOUSE_SURFACE_ID — stable invoking Session/surface id.
  • DORMOUSE_HOST — hosting app kind: vscode or standalone.
  • DORMOUSE_HOST_WORKSPACE — VS Code only: the loaded on-disk .code-workspace file, else the first workspace folder (an untitled workspace has no file and falls through). Unset under standalone and for an empty VS Code window.
  • DORMOUSE_CONTROL_SOCKET and DORMOUSE_CONTROL_TOKEN — private control endpoint credentials, set together or not at all (Control-channel security). The token is the shared secret both ends prove knowledge of, so it must be a CSPRNG value (24 random bytes, hex — randomBytes in the VS Code host, getrandom in the standalone host) and never goes on the wire.

The CLI also reads DORMOUSE_AGENT_BROWSER_BIN and DORMOUSE_PLAYWRIGHT_BIN, the user's own binary overrides that no host sets (docs/specs/dor-browser.md). An empty override must read as unset, in dor and the hosts alike.

DORMOUSE_CLI_BIN is host-internal spawn configuration, never terminal-facing: pty-core prepends its value to the child's PATH, then deletes the variable (with DORMOUSE_SHELL_INTEGRATION_DIR) from the child env, so a terminal sees dor on PATH and nothing else.

On Windows, DORMOUSE_CLI_BIN and DORMOUSE_CLI_JS must be plain paths, never \\?\ verbatim paths — cmd.exe cannot execute dor.cmd through one, and Tauri's resource_dir() hands out a verbatim prefix (rationale).

dor.cmd (and any .cmd/.bat) must be checked out with CRLF — cmd.exe misparses LF-only batch files (rationale), and staging copies bytes verbatim. .gitattributes pins it (*.cmd text eol=crlf; the POSIX launcher eol=lf).

Must keep browser-shared CLI modules free of Node runtime dependencies, even though the CLI package uses Node types. dor/test/browser-shared.test.mjs bundles their dependency graphs for the browser. (rationale)

Git Bash PATH survival

On Windows the PATH prepend must survive Git Bash / MSYS login: the PTY core strips ORIGINAL_PATH from the child env on win32, so a login shell cannot rebuild a pre-prepend PATH from it (rationale). No-op for cmd.exe / PowerShell, which never read it.

A caller cwd must leave the MSYS drive form before it goes on the wire. Git Bash exports PWD as a POSIX path (/c/Users/…) that win32 path.resolve would mangle into C:\c\Users\… and match no Surface; msysToWindowsCwd folds it back to a native path, under every command that resolves a caller cwd (ensure, list, tool, open, skill).

Source of truth: dor/bin/dor, dor/bin/dor.cmd, scripts/stage-dor-cli.mjs, withoutInheritedMsysOriginalPath in standalone/sidecar/pty-core.js, msysToWindowsCwd in dor/src/commands/shared.ts, resolve_sidecar_path in standalone/src-tauri/src/lib.rs, vscode-ext/src/pty-manager.ts, and vscode-ext/src/pty-host.js.

Spawning External Binaries

Every spawn of an external/user-installed binary must go through spawnAndCapture from dor-lib-common, never raw node:child_process spawn — dor agent-browser driving agent-browser, the agent-browser host running tab/eval/screenshot commands, and anything added later. It owns the Windows recipe: cross-spawn rather than Node's own spawn, windowsHide, and resolution on exit with an exit-time output snapshot (rationale).

Never forward an argument containing a literal %VAR% — cmd.exe expands it through a .cmd shim, an unavoidable batch limitation; today's forwarded arguments carry none.

  • dor agent-browser, dor playwright and the Playwright host spawn the PATH-resolved absolute path, never the bare name — cross-spawn resolves a bare name through which, which searches the cwd before PATH on Windows (rationale). The agent-browser host's candidate list still ends in a bare name (## Future).

  • Within the PATH directories, and only those, the walk must select the file which would — a divergence either runs a different binary or reports a present install as missing. Never extend the search to the cwd, which is the one place which looks and the rule above exists to exclude. Inside that scope: skip a directory or a non-executable file rather than returning it, and on Windows take the extension list from PATHEXT or, when that is unset or empty, from which's own hardcoded .EXE;.CMD;.BAT;.COM — npm's order, not cmd.exe's — trying the empty extension first when the name already carries one.

  • A bare name the walk cannot resolve is a missing install, reported before the spawn — including when there is no PATH to search at all, since the spawn's own fallback is the bare name. That leaves ?? binary unreachable on the real path, and isMissingBinaryError covering only a binary that disappears between the walk and the spawn.

    Both Windows-only rules are pinned through an isWindows argument rather than a process.platform read, because CI runs this suite on Linux only and a platform-gated assertion would assert an unenforced claim. The one assertion that cannot be written that way is the POSIX executable bit, since accessSync(X_OK) reports every readable file as executable on Windows.

    Source of truth: binaryCandidateNames, isExecutableFile, resolveBinaryPath and browserBinaryIsMissing in dor-lib-common/src/resolve-binary.ts; getPathInfo in which/which.js is what they mirror; pinned in dor/test/cli-output.test.mjs.

spawnAndCapture never throws: a spawn-level failure resolves as { ok: false, error }, including synchronous argv-validation errors (dor-lib-common/test/spawn.test.mjs).

Must decode stdout/stderr as continuous UTF-8 streams, retaining partial characters across pipe chunks (rationale).

Must release captured stdout/stderr pipes when the result settles, including the exit-grace fallback, without terminating descendants (rationale). dor-lib-common/test/spawn.test.mjs pins caller exit with an inherited-pipe daemon still alive.

An optional timeoutMs must kill the child and resolve { ok: false } with SPAWN_TIMEOUT_CODE (ETIMEDOUT), without waiting for the kill. POSIX SIGKILLs the child; Windows must end the whole tree with %SystemRoot%\System32\taskkill.exe /PID <pid> /T /F, since a .cmd shim's child is cmd.exe and the real CLI is its descendant (treeKillCommand, pinned through its isWindows argument).

Resolution. dor-lib-common's exports point at its built dist (Node-type-free .d.ts, since dor's tsc avoids @types/node); every esbuild/Vite consumer inlines it. The dor and dormouse-lib prebuilds must build dor-lib-common first, or those .d.ts files are missing when either typechecks.

Source of truth: dor-lib-common/src/spawn.ts, dor-lib-common/package.json, dor/package.json, and lib/package.json.

Host Plumbing

Standalone

standalone/package.json's stage step (before build and tauri, not bare vite dev) runs stage:dor-cli. Rust resolves the staged/bundled CLI paths and starts the Node sidecar with DORMOUSE_HOST, DORMOUSE_NODE, DORMOUSE_CLI_BIN, DORMOUSE_CLI_JS, and DORMOUSE_CONTROL_TOKEN; the shared PTY core then prepends DORMOUSE_CLI_BIN and sets DORMOUSE_SURFACE_ID per PTY. Rust must not set DORMOUSE_CONTROL_SOCKET: the sidecar picks the path itself and restores both control variables to its own process.env — what pty-core merges into every spawned shell — only once the socket is bound, holding stdin commands until then so no PTY spawns with the channel's fate undecided.

Control direction: dor → sidecar JSON-lines net socket → Rust command/event bridge → TauriAdapter CustomEvent("dormouse:control-request") → Wall handler, and back along the same hops.

Routing precedence, the refusal for a Surface no window owns, and a cancel following its own request belong to docs/specs/standalone.md → Routing. A target the registry cannot place — an unknown ref, or a name two windows use — reaches the caller's own window, which refuses it by name.

VS Code

vscode-ext/package.json runs pnpm stage:dor-cli before bundling the extension host and pty-host.js. The extension host computes staged CLI paths under context.extensionPath/dor-cli, forks pty-host.js, and sends the same dor env on each PTY spawn. getDorRuntimeEnv must omit both control variables: the token reaches pty-host.js through the fork env alone, and the host folds it with a bound socket path onto each spawn's env itself. The ready message that releases the extension host's queued messages is held until the channel settles, so no spawn can race the bind.

Control direction: dor → pty-host JSON-lines net socket → extension-host child-process IPC → message-router → VSCodeAdapter CustomEvent("dormouse:control-request") → Wall handler, and back.

Because one extension host can hold multiple Dormouse webviews, the request carries DORMOUSE_SURFACE_ID and message-router.ts routes it to the webview that owns that surface. A named surface no active webview owns must fail (No Dormouse webview owns surface '<id>') rather than fall back to a sibling; a request with no surface id goes to the first active router.

Control-channel security

The shared control server protects the Surface API against local interposition (rationale). docs/specs/security-local.md → "The dor control socket" owns the unguessable path and its four-part 0700 directory predicate, the never-from-a-PID rule, the token that never crosses the wire, the constant-time proof compare, and the stand-down that keeps both control variables out of every spawned shell when a bind is lost — two of them as audited FAIL IF lines. What only this spec carries:

  • The POSIX socket name is 8 random bytes rather than 16, so the path clears macOS's sun_path cap (rationale).
  • A connection that says nothing at all is dropped after 10s.
  • The two proof domains are mirrored between client and server, pinned by lib/src/lib/mirrored-constants.test.ts.
  • Both hosts must hold their spawn path until ready settles (2s ceiling), so the first terminal cannot race the bind.

Deadlines And Cancellation

Every valid request must preserve host ceiling < client socket < server reaper. The client's own timeoutMs travels on the wire as a hint and the server timer sits 10s above it. dor await's host ceiling is at most 24h, its socket deadline 5s later, the maximum server deadline therefore 24h + 15s; absent or nonsense hints (non-finite, ≤ 0, or above 24h + 5s) fall back to the server default of 65s, which clears the longest fixed client deadline (dor ensure --restart at 60s).

Some requests outlive their client. When a socket closes with entries still pending, or the server's own timer fires, the server drops the entry and emits dor:controlCancel { requestId } — the cancellation counterpart of dor:controlRequest / dor:controlResponse. Standalone carries it over the request's own sidecar → Rust → adapter hop, which requires that dor-* request ids never collide with Rust's own req-* invoke ids, so the forwarder's pending-invoke lookup misses and the event reaches the webview; VS Code carries it over child-process IPC to ptyManager.onDorControlCancel, broadcast to every active router since only the webview holding that id has anything to abort. Each adapter keeps one requestId → AbortController map: the handler receives that controller's signal, a cancel aborts it, responding forgets it. A handler that parks must release whatever it armed when the signal fires — nothing it responds with afterwards can reach the client. A late response for a reaped id is a silent no-op on the server.

Must cancel ensure's polling when the client disconnects. Cancellation before an interrupted command returns to its prompt prevents relaunch; cancellation during initial integration detection closes the temporary Surface. lib/src/components/Wall.test.tsx pins both paths.

Must exclude Surfaces with a Wall closure in progress from reuse.

Source of truth: standalone/sidecar/dor-control-server.js, dor/src/control-client.ts, dor/src/protocol.ts, peerDirIsSafe in vscode-ext/src/peer-link.ts, lib/src/lib/platform/dor-control-dispatch.ts, and each host's hop in standalone/src/tauri-adapter.ts, standalone/src/browser-sidecar-adapter.ts, lib/src/lib/platform/vscode-adapter.ts, vscode-ext/src/pty-manager.ts, and vscode-ext/src/message-router.ts.

Handle Model

Window ⊃ Workspace ⊃ Pane ⊃ Surface (docs/specs/glossary.md). A command's positional target is always a Surface; a container is named by --workspace <ref> (dor workspace). Reserved: no command targets another Window; a request reaches the window that owns its Surface instead (§Standalone), and the ref grammar for one is Future.

Invariants:

  • A target may be surface:N, a stable Surface id, or surface:<stable-id>. surface:focused selects the focused Surface in the current Workspace; surface:self the invoking Surface from DORMOUSE_SURFACE_ID. An omitted target falls back to the caller, then to the focused Surface.
  • Short refs (surface:1, surface:2, …) are Workspace-scoped stable refs, not layout/list positions: each Workspace starts at surface:1 and assigns the next number when a Surface is created/restored. The map and its counter persist in the session snapshot, which is what keeps a retired number from being reused (docs/specs/transport.md → "Persisted session types"). Only creation assigns a ref — layout churn (reorder, minimize/reattach, zoom, focus), replacing an untouched terminal with a browser Surface, and browser render-mode swaps all leave it unchanged. Killing a Surface retires its ref; a later target that names it must fail rather than silently retarget.
  • Surface targets also accept title:<exact display title>, for human recovery; a title can drift, so automation should prefer refs from command responses or dor list. Action commands (read, send, await, kill, dor agent-browser --surface) resolve against listed Surfaces, minimized ones included — a minimized Surface is still a live target, and a parked agent-browser surface still holds its daemon session. split and ensure --surface resolve their reference target the same way so minimized peers participate in ambiguity checks; when that reference is minimized, the new terminal is created minimized too and its Door inserted immediately right of the reference Door. Browser placement commands (iframe, browser creation) resolve against visible Surfaces. If multiple Surfaces in the relevant scope match, the command fails and lists the matching refs.
  • Bare numeric targets and pane:N are not Surface handles. Pane refs stay reserved for future layout-only commands.
  • Text list output defaults to refs; commands that list handles accept --id-format refs|ids|both. JSON list output always includes both refs and stable ids.
  • workspace:<n> selects a container and is stable: n is the number of the Workspace's registry-minted id (docs/specs/standalone.md → "Workspace registry"), so a strip reorder and a move between Windows rename nothing. Must use positional refs only on hosts without a registry (VS Code). Must address unnumbered registry Workspaces as workspace:<id>, resolving exact ids before names, so legacy snapshots, duplicate names, and numeric names cannot redirect a ref. workspace:<name> resolves only when exactly one Workspace carries that name, else the error lists the candidates. All three are accepted bare (2, ws-a, build), and a ref that reads as a number is a ref, never a name. A Window is window:<label> — its host's own name for it (window:main, window:ws-2), and a host with one Window answers window:1; each accepts its own ref bare. Every Workspace has a surface:1, so a Surface ref alone never identifies a Workspace.
  • One Wall answers each request, resolved in order: the Window's own verbs (dor workspace, and dor list --all, which fans out to every Wall), an explicit --workspace, the Workspace holding the target Surface when it is named by its stable id — unique Window-wide, unlike surface:N — else the Workspace owning the calling Surface, else the active one; nothing mounted answers workspace '<ref>' is still mounting for the active Workspace, after a bounded retry that covers the tick between a Workspace being created and its Wall registering — never left to the caller's deadline, which every managed dor agent-browser would pay (docs/specs/dor-browser.md → "Managed identity"). A --workspace the store resolves but whose Wall has not registered waits out that same retry, then answers the same refusal for that ref — it is not the unknown-Workspace answer, the Workspace being there. Every request is answered, including a container ref of the wrong type and a handler that throws — an unanswered one blocks its caller to the deadline. A Workspace being closed refuses the Surface-creating verbs (docs/specs/layout.md → "Workspaces"). Surface targets resolve within the answering Workspace — refs are Workspace-scoped — so a dor split from a background Workspace lands beside its caller rather than wherever the user is looking, and a caller the answering Workspace does not hold is dropped by the router, leaving that Workspace's focused Surface as the fallback rather than a failure, which is what gives --workspace a reference to place against. Cross-window duplicate ids follow docs/specs/vscode.md → "Peer surfaces across windows".

Source of truth: dor/src/commands/shared.ts, dor/src/commands/types.ts, parseWorkspaceRef in dor/src/protocol.ts, surfaceRefForId / transferSurfaceRef in lib/src/components/Wall.tsx, classifySurfaceTarget in lib/src/components/wall/use-dor-control.ts, resolveWorkspaceRef in lib/src/lib/workspace-store.ts, and resolveDorControlRoute in lib/src/components/wall/dor-control-router.ts.

Current Implemented Commands

Implemented commands call private surface.* control methods, enumerated once in dor/src/protocol.ts (SURFACE_CONTROL_METHODS, with WORKSPACE_CONTROL_METHODS and APP_CONTROL_METHODS beside it) so the emitting client and the dispatching webview cannot drift. surface.list joins one Workspace's Surfaces — visible panes plus minimized (doored) ones, each tagged view (paned / zoomed / minimized) — with terminal state and activity snapshots, and reports the answering Workspace's own workspace:<n> alongside the answering Window's window:<label> (Handle Model). Its scope: 'all' spans every Workspace of the Window, tagging each row with the Workspace it came from and carrying the Workspace directory beside them; one Workspace that cannot answer fails the whole listing — including one whose Wall never registers, after the same registration-gap wait routing gives (Handle Model) — since a Workspace missing from the answer reads as a Workspace holding nothing. Only the active Workspace's selection is focused in such a listing: each Wall marks its own, and the Window has one focus. Per the visible-vs-listed split Handle Model states, a visible split reference adds a pane in Lath, a minimized one a sibling Door in the baseboard. dor list rows sort by the Workspace-stable surface:N ref, a registry Wall owns and persists with the session, independent of Lath layout order.

Port enumeration is opt-in. With includePorts set (dor list --ports / --port) the host scans each terminal Surface's process tree (docs/specs/dor-browser.md → Dev-Server Chip), shelling out (lsof / Get-NetTCPConnection). One listing costs one scan where the adapter can batch it (PlatformAdapter.getOpenPortsMany). A --all listing never forwards includePorts to a Wall, scanning once for every Workspace's terminals instead. A remote paired session reports none, and any error degrades to an empty list rather than failing the call. Source of truth: attachSurfacePorts in lib/src/components/wall/surface-ports.ts, getOpenPortsForPids in standalone/sidecar/pty-core.js.

dor forwards command tails as raw argv; the host quotes them — dor cannot know the configured default shell used for creation, so tails after -- travel as command: string[] and the host renders one command string, used for output, JSON responses, default ensure titles, and the launched command alike. It picks the style (cmd / posix / powershell) with the same classifier clipboard/drop path escaping uses (mouse-and-clipboard.md §8.6).

Every first-party command except the dor agent-browser and dor playwright passthrough accepts --json, emitting a stable object with the same handles as its text output; single-Surface responses always carry both surface_id (stable) and surface_ref (Workspace-stable short ref). Text output carries the same refs and is the primary interface, for agents as much as humans. Any JSON mode under a native browser command belongs to its delegated CLI; dor-embed-size owns its JSON output.

A command that operates on one existing Surface takes the target as a required positional handle (read / send / await / kill); a command that creates or places a Surface keeps --surface as an optional reference Surface (split, ensure, iframe, browser creation). So --surface means "place near this" everywhere except dor agent-browser and dor playwright, whose whole positional space belongs to the provider's CLI, leaving --surface its only room for a real target.

The generated help snapshots own command names, syntax, flags, and defaults. Where stricli cannot express a shape, a command may declare narrow, snapshot-tested findReplace / remove help patches (root / command-usage / command-detail scope, HELP_PATTERN_TOKENS syntax) — not a general docs renderer. stricli's default --help-all/-H integration must stay unregistered, leaving --help/-h the single documented help surface. dor --version/-v (sole argument only) is rewritten to dor version before parsing. Must accept only the full browser command names, with no ab or pw compatibility aliases.

The spec keeps the behavior help cannot express:

Command Behavioral contract
split Only a bare split focuses the new Surface. A -- marker or command tail leaves the caller focused; pre-parse preserves the marker stricli discards.
ensure Must have a -- command tail. Matching uses the exact OSC 633 command plus resolved CWD; cmd.exe without integration fails immediately, other unintegrated shells time out after 8s and close the temporary Surface. --restart drives the live PTY in place, preserving layout and minimized/visible state, so it works on Doors too.
send Must select exactly one input mode. Text then key is the only mixed order; duplicate flags require the explicit sequence form. Input is paced, sent meaning queued (transport.md → Paced input).
read Clean, ANSI-free rendered lines; line limits count rendered lines.
await Must name --until quiet|exit; never infer it. Timeout 1–86400 whole seconds, default 600; alert.md owns wake semantics.
kill Must select exactly one confirmation mode. Conditional text needs four non-whitespace characters and must match read; browser Surfaces are killable.
iframe, agent-browser, playwright dor-browser.md owns the renderers; see target resolution and addressing. The passthrough is intercepted before stricli parses it.
list Filters are ANDed client-side; --port filters terminals (browser Surfaces never match) and implies the opt-in detail scan, --ports only requests it. Owns every Workspace read: --workspace narrows to one, --all groups every Workspace's rows under its header — every Workspace keeps its header, including one a filter emptied, so the text listing and the JSON workspaces array name the same Workspaces — --workspaces is the overview, and the three cannot be combined. --all --json adds caller_workspace_ref / focused_workspace_ref beside the _surface_ref pair, which under --all names a surface:N every Workspace has; the _surface_id halves stay unique. --workspaces takes --json and --window and nothing else, by an allowlist, so a flag added to list is refused there until it is named.
workspace Mutation only (dor workspace).
app Standalone only (dor app).
skill Prints the bundled skill or installs its bootstrap stub; Agent Skill owns the contract.

await never prints terminal text. Stdout is only the resolution cause, the narrative goes to stderr on every outcome, and JSON appears only on a resolution (rationale). Exit codes: 0 resolution, 1 usage/target error, 2 timeout, 3 Surface death; other commands use only 0/1.

list --json includes stable ids and refs, capability booleans, caller/focus identity, singleton Workspace/Window refs, and Host identity/runtime paths — but never the control socket. Consumers must gate on has_terminal / has_browser, not kind, the vocabulary commands also use in target errors. Activity/state filters are staged (see Future).

Every command that acts on a Surface accepts --workspace <ref> — split, ensure, read, send, await, kill, iframe, tool, open, and the dor agent-browser and dor playwright passthroughs, which intercept it beside their identity flags — naming the Workspace its targets resolve in and, for a creating verb, the Workspace the new Surface joins.

Source of truth: dor/src/commands/, HELP_PATTERN_TOKENS and pre-parsing in dor/src/cli.ts, shellCommandKind / buildShellCommandForKind in dor/src/commands/shell-quote.ts, buildDorSurfaces / buildDorSurfaceList in lib/src/components/Wall.tsx, dorCommandString and host dispatch in lib/src/components/wall/use-dor-control.ts; help snapshots in dor/test/snapshots/help/, pinned exhaustive by dor/test/cli-help.test.mjs.

dor workspace

dor workspace mutates and dor list enumerates, so the overview has one home. Its verbs are container verbs, answered by the Window rather than by a Wall (Handle Model), and each takes a workspace:<n|name> target except new:

Verb Contract
new [name] Creates in the background, never activating: moving the user to another Workspace is a larger theft than the focus a bare dor split takes. Answers with the new ref. Without a name it is auto-named (docs/specs/layout.md → "Workspace names").
rename <ref> <name>|--auto Renames the Workspace only — no Surface title (docs/specs/layout.md → "Workspaces"); --auto hands the name back to auto-naming and answers with the outgoing name, since the derived one is computed afterwards. An auto-name follows the terminals, so a workspace:<name> target can stop resolving; dor list --workspaces --json reports auto.
close <ref> [--force] Refuses, raising no confirmation, when the Workspace holds a touched or running Surface unless --force — the caller is a command, not someone watching the Wall, exactly as dor kill refuses silently. A Workspace whose close meets another already in flight and one whose Wall never registers (still mounting, after the routing retry — closing past it would leave its Sessions running with nothing holding them) refuse too. Member Surfaces close in sequence, and a refusal leaves the Workspace open and the user where they were.
switch <ref> Activates it.
move <ref> [--window <label|new>] [--index <n>] [--dangerously-destroy-iframe-page-state] The Window handling it runs the same transfer the strip's drag does (docs/specs/standalone.md → Transfer), with no pointer to place the tab by. Answers moved only once the target Window has adopted the Workspace. Refuses a move between Windows while the Workspace holds a plain iframe Surface unless the flag is passed, naming them: the page state is lost (docs/specs/layout.md → Workspaces). A host with one Window reorders only.

Each verb ships as one action of one command, not a route map: the published CLI reference renders one help page per top-level command (docs/specs/website-docs.md → /docs/dor), and a nested command would have none.

VS Code refuses every Workspace-spanning request at the extension host — dor workspace, dor list --workspaces, dor list --all, and any --workspace but this webview's own — because each Workspace there is a separate webview (docs/specs/vscode.md → "Workspaces"), so a listing would report one webview's Workspace as the whole Window. Aggregating them at the extension host stays in Future.

Source of truth: dor/src/commands/workspace.ts, WORKSPACE_CONTROL_METHODS / spansWorkspaces in dor/src/protocol.ts, handleWorkspaceControl / listAllWorkspaceSurfaces in lib/src/components/wall/workspace-control.ts, closeWorkspaceWithSurfaces in lib/src/components/wall/workspace-lifecycle.ts, and dorWorkspaceRefusal in vscode-ext/src/dor-workspace-guard.ts.

dor app

dor app verbs act on the running app, so the webview's control router answers them before resolving any Workspace, Surface, or Window param; Rust still delivers them by the caller's Surface (Standalone). restart is the only one: it asks the host for the quit that relaunches (docs/specs/standalone.md → "Restart"), so the running-work confirmation still applies and the relaunch restores what any quit restores (docs/specs/transport.md → "The governing rule").

  • Must request the restart before answering, so the caller sees a host refusal (a dev build) and whether the request joined a quit already in progress, which exits without relaunching. The CLI fails on the latter.
  • The caller's DORMOUSE_SURFACE_ID is the restart's requester (docs/specs/standalone.md → "Restart").
  • A host without PlatformAdapter.requestAppRestart refuses with dor app restart is available only in Dormouse Standalone — VS Code and the browser-dev harness.
  • A Dormouse older than the verb answers unsupported Dormouse control method 'app.restart' (unsupportedControlMethodMessage, whose text is frozen), which a newer dor meets once the bundle is replaced under the running app. The CLI turns it into a quit-and-reopen hint only when DORMOUSE_HOST is standalone; VS Code answers with the same text.

Source of truth: dor/src/commands/app.ts, APP_CONTROL_METHODS in dor/src/protocol.ts, handleAppControl in lib/src/components/wall/app-control.ts. Pinned by dor app verbs in lib/src/components/wall/dor-control-router.test.ts.

Browser Open Target Resolution

dor agent-browser open <target> and dor iframe <target> accept, wherever they take an absolute URL:

  • a terminal Surface handle (Handle Model) — resolved to the dev server that terminal owns; and
  • a schemeless host:port — defaulted to http:// (box.ts.net:3000 → http://box.ts.net:3000/), including the bare :port localhost shorthand (:5173 → http://localhost:5173/). Purely a string rewrite, so it needs no host and works outside Dormouse.

The explicit port, never the hostname, is the signal for the http default (rationale). An explicit scheme is always honored — a public HTTPS service on a nonstandard port is the one case needing the scheme typed. This overrides agent-browser's own https-default for a bare host:port, since a local dev server on https just SSL-errors. Reject an input that is neither a URL nor a host:port, including a purely numeric "host" like 800:600 (rationale).

Resolution is CLI-side, so dor agent-browser hands agent-browser a real URL rather than a handle a binary would resolve differently. Only the open / goto / navigate verbs resolve, matching the target by shape, not position since dor can't know agent-browser's flag arity (open --headed surface:3 resolves); only the first special-shaped argument is rewritten, these verbs taking a single target. A Surface handle requires a live control endpoint (failing clearly outside Dormouse); the host:port inference does not.

A Surface handle resolves through the surface.resolveOpen control method, which runs the same host port scan as dor list --ports (visible panes and minimized doors). V1 groups all TCP listening records by distinct port, so multiple bindings for one dev server remain one candidate:

Candidates Outcome
zero fails — surface:N is not serving any port
one http://localhost:<port>/ when a loopback or any-interface bind exists, otherwise the specific bound LAN/Tailnet address
multiple distinct ports fails and lists the choices, until an explicit port selector exists

Only terminal Surfaces own ports, so a browser-Surface handle is rejected.

Source of truth: dor/src/commands/open-target.ts, dor/src/commands/iframe.ts, resolveOpenTargetArgs in dor/src/commands/browser-cli.ts, resolveOpen in dor/src/protocol.ts, the surface.resolveOpen handler in lib/src/components/wall/use-dor-control.ts, listenerUrlsByPort in lib/src/components/wall/port-url.ts.

Browser viewport control

Must intercept dor-embed-size under either browser command before native passthrough. Identity flags select one existing bound Surface; raw --session must match uniquely. No selection creates or restarts a browser. A provider mismatch fails with the matching full command name; sizing mutations require a screencast.

Must query without dimensions or a preset; otherwise apply the requested sizing and await its measurement. JSON and text report Surface identity, provider, render mode, resolved intent, readiness, and actual width, height and DPR when available. A disconnected browser reports no invented dimensions. Dimensions and preset selection are mutually exclusive; pane-sync rejects a DPR override. Fail invalid settings and unsupported DPR before changing size.

Must prepare a new managed browser's viewport before destination navigation, keeping preparatory output out of native stdout. Live reuse and explicit native device/attachment choices keep their sizing. Configuration and provider semantics belong to docs/specs/dor-browser.md → Viewport presets; native playwright open still restarts its browser.

Source of truth: runBrowserCli in dor/src/commands/browser-cli.ts; BrowserViewportRequest / BrowserViewportResponse in dor/src/commands/types.ts; useDorControl in lib/src/components/wall/use-dor-control.ts.

Browser Surface Addressing

Must intercept dor agent-browser and dor playwright before stricli parses provider arguments. One runner drives both; what differs is each provider's descriptor:

dor agent-browser dor playwright
Session flag forwarded --session <s> --session=<s>; native -s read as --session
Targets resolved in open, goto, navigate open, goto
Never binds a Surface close close, detach, close-all, kill-all, delete-data, list, show, install, install-browser
Informational flags --help, -h --help, -h, --version, -v
Runs in / with the caller's cwd and executable the binding's project cwd and pinned executable
  • Exactly one identity flag: --key (default default), --session, or --surface, plus --workspace; any two fail (--key and --surface are mutually exclusive). Except the Dormouse-owned dor-embed-size, arguments are forwarded to the provider; stdout, stderr and exit status pass through. Target resolution: Browser Open Target Resolution.
  • Resolution is host-side, mirroring surface.resolveOpen: a --key or --surface takes one surface.resolveBrowser { provider, key | surface, proposed? } round trip before the binary runs, answered with a binding { session, cwd?, binaryPath? } (docs/specs/dor-browser.md → Managed identity). proposed — the caller's cwd and executable — rides only a command that may bind. --session and an informational command ask nothing. Outside Dormouse a key names its unscoped session itself; a --surface fails.
  • After a command that may bind succeeds, surface.browser { provider, key?, session, cwd, binaryPath, wsPort? } opens or reuses its Surface: dor agent-browser first reads the stream port itself (docs/specs/dor-browser.md → agent-browser), and the host reports Playwright's stream. The call must wait past BROWSER_REQUEST_TIMEOUT_MS, since the host's answer can queue behind a launch or close of the browser (rationale). A failure there adds a stderr warning without changing the command's success.

A --surface handle resolves against listed Surfaces (Handle Model), and the host applies two gates in order:

  • Browser-gated (docs/specs/glossary.md → Panes and Surfaces). A target with no browser fails with the shared capability wording under dor list.
  • Render-mode-gated. A browser Surface on the iframe renderer has nothing to drive, and one the other provider renders is driven by the other CLI. The refusal must name the command that works: surface 'surface:2' is not agent-browser rendered (render_mode: playwright-screencast) — drive it with dor playwright --surface surface:2, or for an iframe … open its page with dor agent-browser open <its url>.

Neither gate covers a Surface whose launch has not yet named its session (dor-browser.md → Browser Connection): it fails with surface 'surface:2' has no agent-browser session yet.

For a project-scoped provider: must pass the bound cwd through spawnAndCapture's optional cwd argument; never spawn a bound executable the provider's allowlist refuses, since the binding comes back off persisted params — run the caller's own resolution instead, whose DORMOUSE_PLAYWRIGHT_BIN is the exact-match override; must run the caller's own executable, with a stderr warning, when the bound one is gone, and must fail naming a bound cwd that no longer exists rather than report the CLI missing.

Source of truth: runBrowserCli, resolveBinding and extractSessionFlags in dor/src/commands/browser-cli.ts; runAgentBrowserCli in dor/src/commands/agent-browser.ts; runPlaywrightCli in dor/src/commands/playwright.ts; SURFACE_CONTROL_METHODS in dor/src/protocol.ts; ResolveBrowserRequest, BrowserSurfaceRequest and BrowserBinding in dor/src/commands/types.ts; requireBrowserSurface and requireAutomationSession in lib/src/components/wall/use-dor-control.ts. Pinned by dor/test/cli-output.test.mjs and dor/test/playwright.test.mjs.

Agent Workflows

These acceptance workflows discover the target Surface with dor list (filtered), then act on it with a handle-taking command. Matching lives in dor list alone; read / send / await / kill must not grow their own match syntax, and a bare dor kill "npm dev" stays unsupported.

Identity follows the Surface, not a user-supplied key: a terminal Surface is named by its Workspace-stable surface:N ref, or rediscovered after layout churn by --command / --cwd / --port, and dor ensure's command+cwd match is an implicit key that also lets an agent adopt a command the user started by hand. Only browser Surfaces carry an explicit join key (dor agent-browser --key <name>, dor playwright --key <name>), because their session is held externally by the browser CLI.

The worked examples — dev-server sharing, sub-agent launch and await, paired browser keys, multi-worktree, minimized watchers, port-owner handoff, safe cleanup — are dor/skill.md's "## Recipes", which ships with the CLI (Agent Skill).

Agent Skill

dor/skill.md is the agent skill: instructions teaching a coding agent inside a Dormouse terminal to drive it through dor — a targeting model plus the recipes Agent Workflows points at. Distribution splits into content and bootstrap so each is exactly as stable as it needs to be:

  • Content ships with the CLI. scripts/generate-dor-skill.mjs (prebuild, like the version metadata) inlines the markdown into the bundle as the gitignored generated-skill.ts, so dor skill prints text version-locked to the CLI that staged it and the staged package stays launchers + bundle. The skill body must carry no environment detection: if dor skill ran, dor is available — detection lives only in the stub.
  • Bootstrap is a loud stub that barely drifts. dor skill --install writes a marker-delimited block (<!-- dor-skill:begin … dor-skill:end -->) into the project's agent instructions file, resolved against the invoking shell's PWD like dor ensure --cwd. Its core is the detection rule — if DORMOUSE_SURFACE_ID is set, run dor skill and follow it; otherwise ignore this section — plus two mandatory directives a pointer-only stub proved too soft to enforce (rationale): never background a long-running process (use dor ensure), never use a native browser tool (use dor agent-browser). Nothing else may join them, and dor/skill.md must lead with the same two (rationale). The env guard keeps the block inert for collaborators who don't run Dormouse, and committing it is the point — one teammate's install covers every agent and every clone.
  • File selection. An existing block in AGENTS.md or CLAUDE.md (checked in that order) is rewritten in place; everything outside the markers is untouched, so re-running is idempotent. Otherwise: append to AGENTS.md when it exists; else to CLAUDE.md when it exists and does not already import @AGENTS.md; else create AGENTS.md. A begin marker without a well-ordered end marker fails (malformed dor-skill block) rather than guessing. Output reports the bare file name only (created AGENTS.md / updated CLAUDE.md), never an absolute path.

Source of truth: dor/src/commands/skill.ts, scripts/generate-dor-skill.mjs, dor/skill.md, whose byte-identity with dor skill output is pinned by dor/test/cli-output.test.mjs.

Helper exclusion

Must exclude unpromoted helpers from discovery and control, including direct internal-id targets and helper-origin requests. Promotion assigns the ordinary public Surface ref without changing Session identity; subsequent CLI operations use ordinary Surface semantics.

Source of truth: buildDorSurfacesInternal in lib/src/components/Wall.tsx; dispatchDorControlRequest in lib/src/lib/platform/dor-control-dispatch.ts.

Dor Tools

Must route dor tool and dor open through the Tool launch contract, including approval, explicit-key reuse, and placement (docs/specs/dor-tool.md → CLI). Generated help owns syntax.

Source of truth: toolCommand in dor/src/commands/tool.ts; openCommand in dor/src/commands/open.ts; ToolSurfaceResponse in dor/src/commands/types.ts.

Future

  • Resolve the host's agent-browser candidates on PATH too. runWithBinaryFallback still ends its list with the bare DEFAULT_AGENT_BROWSER_BIN, so on Windows the extension-host or Tauri-app working directory is searched first — narrower than dor agent-browser's case, since a user does not clone into it. resolvePlaywrightInstall already walks its candidates through dor-lib-common's resolveBinaryPath.

  • Surface a dead control channel in the UI. A lost bind leaves one [dor-control] line on the host's stderr, and all a user sees is dor reporting "Dormouse control endpoint is not available in this terminal yet" — which reads like a startup race rather than a channel that will never come up. Open design question: where the visible notice goes, given that the Baseboard carrying the standalone update notice (docs/specs/auto-update.md) has no VS Code counterpart. The plumbing exists — both hosts already know the outcome at ready (see Control-channel security).

  • dor skill follow-ons — skill-ecosystem publication (plugin marketplaces, npm) distributes the bootstrap stub, never a copy of the content. A user-level --global install variant waits until a story needs it.

  • Cross-Window --all. dor list --window <label> lists one other Window and --workspace reaches a sibling's Workspace (Standalone), but dor list --all still lists the answering Window alone; a union would read the registry (docs/specs/standalone.md → "Workspace registry").

  • Cross-Workspace listing in VS Code. Each Workspace is its own webview there, so dor list --all would have to aggregate at the extension host rather than in a per-webview control handler; until it does, VS Code refuses the Workspace-spanning requests (dor workspace).

  • Additional dor list filters — activity/state filters are deliberately deferred: --running as shorthand for --activity running, full --activity unknown|prompt|editing|running|finished, and possible alert filters such as --alert / --todo. Add only once a story needs them, each with snapshot-tested help.