Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 75 additions & 9 deletions docs/specs/dor-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,9 +259,8 @@ header), `isToolParams` / `toolFace` in `browser-surface.ts`,
[serving](#serving) trigger.
- **`dor tool <name>`** — run a `dormouse.yml` entry with whatever
`prespawn_dedupe` it declares.
- **Always splits focus-neutrally** and returns a handle. Taking over the
calling pane when a human types the invocation alone at a prompt is designed
but not built — see [Future](#future).
- **Splits focus-neutrally** and returns a handle, except when it
[takes over the calling pane](#take-over).
- **A keyed invocation that matches reveals and reports**, in both placements,
so the calling pane never appears to do nothing.
- `dor list`: rows report `kind: tool` with `render_mode`; JSON carries command
Expand All @@ -270,6 +269,79 @@ header), `isToolParams` / `toolFace` in `browser-surface.ts`,
Source of truth: `dor/src/commands/tool.ts` and its help snapshot
`dor/test/snapshots/help/tool.md`; `surface.tool` in `dor/src/protocol.ts`.

## Take-over

**`dor tool` typed alone at a prompt runs in that pane** rather than splitting —
same Surface, same id, same scrollback. Typing a command at a prompt is how a
terminal works. Nothing else about the invocation changes: same trust gate, same
dedupe, same serving trigger.

Every condition holds or it splits, and a split is never wrong — only more panes
than were asked for (rationale):

- **The line is naked**: the caller's OSC 633 command line is one command and
that command is `dor tool`. An agent's invocation runs under whatever it
launched, so the pane reports *that* line instead. Human intent, never a
security boundary — see [Trust](#trust) rule 2.
- **The caller is a visible pane whose leaf is a plain terminal.** A Door is not
a pane a human is typing in; a tool or browser leaf is not one to transform.
- **The tool's directory is that pane's own.** The command is typed into the
caller's shell and runs where that shell already is, so a `--cwd` naming
anywhere else has to spawn its own.
- **The placement was not asked for.** `--surface` names a split reference and
`--minimize` asks for a background Surface.
- **A pending approval never takes over** — it needs a pane that has spawned
nothing ([Trust](#trust) rule 3).
- **A key match reveals its survivor** ([CLI](#cli)) — unless the survivor *is*
the calling pane, the place take-over makes normal to retype in. Its command
is live only when the tool spawned this `dor` itself; otherwise `dor` is what
its shell is running, so the tool is idle however the pane reads, and it
**re-runs there through the same handshake**, reported `adopted`. Never
through the interrupt-and-retype restart: Ctrl+C would kill the `dor` still
waiting for the answer.
- **A re-run is a placement of nothing**, so only the two conditions that govern
typing apply — the naked line and integration. It runs in the tool's own
directory, like an `adopted` match from any other pane, and `--surface` /
`--minimize` / a `--cwd` elsewhere do not change that.
- **A caller that is the match but cannot be typed behind fails loudly.** There
is no survivor to reveal — the user is sitting in it — so reporting `existing`
would be a silent no-op. It says so instead.

**Respond, then wait for the prompt.** `dor` is the caller's foreground process
when the host answers it, so the host answers `takeover` first, waits for the
shell to report itself back at a prompt, and types the command only then.
Waiting first deadlocks: the prompt cannot return until `dor` exits, and `dor`
cannot exit until it is answered.

- **The response promises the placement, not the run** — it is sent before the
command is typed, because the caller is gone by the time it runs. Failure
after it shows in the pane.
- **A shell that never comes back to its prompt is left alone**: nothing typed,
leaf still a terminal. The transformation happens on the way *in* to typing,
so a timeout costs nothing.
- **The spawn lock is held past the response** until the shell has processed the
line — the command live, or already finished. The key reaches the leaf's params
at the meta write, but a pane typed into and not yet reporting reads as an idle
tool, which a queued invocation of the same key would interrupt and retype.
Waiting for *live* alone would pin the lock for the full timeout on every tool
that dies on boot: it can start and finish between two samples.
- **The transformation is one meta write**, so the component pair and the params
commit together, and the leaf id — the SessionId — never changes. That is what
keeps the terminal, its buffer, and its PTY untouched.
- **The Session's [OSC 367](#osc-367) hint is cleared as it transforms.** What
the pane announced under an earlier command is not this tool's, and would
otherwise name its port or re-key it.
- Accepted: **keystrokes in the window between `dor` exiting and the command
landing** interleave with it. The window is one control round trip.
- Accepted: **a listener the taken-over shell already owned** (a backgrounded
server, an `ssh -L`) is in the [scan](#serving)'s process tree, so `port: auto`
can frame it or refuse the pair as a conflict. A shell that never ran a server
before — every split-spawned tool — cannot hit this.

Source of truth: `lib/src/components/wall/tool-takeover.ts` (the gate), the
take-over arm of `surface.tool` in `lib/src/components/wall/use-dor-control.ts`
(the handshake), `setMeta` in `lib/src/components/wall/lath-wall-store.ts`.

## OSC 367

`DOR` on a phone keypad. Verb-multiplexed (the OSC 633 pattern): one registry
Expand Down Expand Up @@ -376,12 +448,6 @@ Source of truth: `PersistedSurfaceType` in `lib/src/lib/session-types.ts`;
`DORMOUSE_DEHYDRATE`; the `dehydrate` flag is reserved in the serve payload
from the shipped `serve` payload. The Windows graceful-stop is needed here
only.
- **Pane take-over.** `dor tool` typed alone at a prompt should run in that
pane rather than splitting — typing a command at a prompt is how a terminal
works. The gate is three conditions the host can already read (sole command on
the OSC 633 line, pane at a prompt, pane not already a tool); what it needs is
the handshake, since `dor` is itself the foreground process when it answers,
so the command can only be typed once its own shell returns to a prompt.
- **The announced `name`.** Wire the reserved [OSC 367](#osc-367) `name` into
the title-candidates channel and `dor list`'s location column.
- **Later** — `prespawn_*` beyond the dedupe literal: a computed key, and
Expand Down
6 changes: 6 additions & 0 deletions docs/specs/dor-tool.rationale.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@

**What the announcement is still needed for.** Multi-port tools. `pnpm dev:standalone:ab` binds vite, the dev bridge, and the sidecar's control socket, and no scan can guess which one to frame. ssh is the other case: the control socket does not exist across it, and neither does the host's view of the remote process tree.

## Take-over

**Why the gate is conservative in the split direction.** Every condition can be read wrong in two directions, and the two costs are nowhere near equal. Declining a take-over that should have happened costs a pane the user closes — the tool still runs, in the placement `dor tool` has always used. Taking over a pane that should have split types a command into a shell that belongs to something else: an agent's session, a line with work queued behind `dor`, a directory the tool was not asked to run in. So each condition is written to fail closed, and quoting is not unpicked — a line carrying `&&` inside quotes splits rather than being parsed for whether that `&&` is real.

**Why the naked test is worth having at all, given `dor send`.** It answers "did a human ask for this *here*", not "is this trustworthy". The discrimination it actually makes is placement: an agent's `dor tool` runs under the agent's own command line, so the pane reports `claude` (or `bash script.sh`) and never matches — which is the whole point, since an agent's tool must not commandeer the pane the human is watching the agent in. Trust is a separate gate with a separate ceremony, and it is the one that carries the security weight.

## Security

**Why the content-driven announce risk is accepted.** The blast radius is the containment rule applied to ports: an announce reveals and frames, never transferring input authority, grants, or state. The iframe proxy dials upstream as a fresh client with no browser cookie authority, and the link-local/cloud-metadata SSRF guard stands regardless. Two properties of this design narrow it further than an announce-triggered one: the scan supplies the port, so an announced port that nothing bound frames nothing at all, and a runtime re-key cannot dedupe, so it cannot reach another pane.
Expand Down
2 changes: 1 addition & 1 deletion docs/specs/tiling-engine.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ A **parked** leaf is mounted by the adapter but absent from the split tree: its

- **Detaching and parking are separate things.** `doorLeaf` takes a leaf out of the tree and *keeps its meta* — what every minimize does, terminal or browser, because the store stays the authority for a Doored Surface's live title/params. `{ park: true }` additionally keeps the leaf **mounted** (the browser-only part). `removeLeaf` destroys a leaf and its meta (a kill); `forgetLeaf` destroys a Door, unmounting it if parked. A Surface **born minimized** — `dor split` / `dor ensure` targeting another Door, with no pane to detach — registers its meta through `addDoor`, so the "one map holds every leaf" invariant has no exception for creation path.
- **Parking must be one commit.** An id absent from both the tree and `parked` for even one render would make React unmount the leaf and lose the DOM state, so every op that re-admits a leaf (`addLeaf`, `restoreLeaf`, `insertLeaf`, `replaceLeaf`, `seed`) unparks it in that same commit through the one shared `admit` helper — which also seeds the enter hint, so an op added later cannot honor half the contract. **`seed` admits by tree membership**, not by the metadata it is handed: hydration passes Door rows alongside the tree's leaves, and a parked id appearing only as a Door row is still a Door — unparking it would unmount the very DOM the park preserves. Dormant while `seed` runs once at startup; live in the workspaces-rollout switch.
- **`leafMeta` covers Doors.** One map holds every leaf the Wall owns, laid out or Doored; `parked` is pure render state (`Map<id, Rect | null>`) naming the subset that keeps its DOM. Detachment is a fact about the *tree*, so **no Door record carries a metadata copy that can go stale**: `setTitle` / `updateParams` reach a Doored leaf by the same single path as a visible one, and every reader — reattach, `dor` param lookup, kill/session teardown, `buildDorSurfaces`, `dor list`, the dev-server port scan, the session save — goes through `lath.getMeta(id)`. `serializeLayout` filters `leafMeta` down to the tree's own leaves, because the persisted *layout* is the tree — a Door persists as its own row.
- **`leafMeta` covers Doors.** One map holds every leaf the Wall owns, laid out or Doored; `parked` is pure render state (`Map<id, Rect | null>`) naming the subset that keeps its DOM. Detachment is a fact about the *tree*, so **no Door record carries a metadata copy that can go stale**: `setTitle` / `updateParams` / `setMeta` — the last one replacing a leaf's whole meta in one commit, so a leaf can change kind in place without changing id (`docs/specs/dor-tool.md` → Take-over) — reach a Doored leaf by the same single path as a visible one, and every reader — reattach, `dor` param lookup, kill/session teardown, `buildDorSurfaces`, `dor list`, the dev-server port scan, the session save — goes through `lath.getMeta(id)`. `serializeLayout` filters `leafMeta` down to the tree's own leaves, because the persisted *layout* is the tree — a Door persists as its own row.
- **The store holds the last rect.** `doorLeaf({ park: true })` captures the leaf's current layout rect into `parked` in the same commit that removes it from the tree; LathHost unions parked ids into the same sorted leaf list and renders them at that rect with `visibility: hidden; pointer-events: none` and `data-lath-parked`, so the guest document never sees a zero-extent viewport and reattach is pixel-identical (rationale). A leaf parked before the Wall reports geometry falls back to the whole wall rect. **Keep the rect in the store, never in the adapter**: `registerEl(null)` is a ref detach, not an unmount, and React detaches whenever a callback identity changes and on every StrictMode commit — adapter-local pruning on detach silently lost every parked rect. On re-admission `admit` replays the held rect into the animator, for every admitting op and every drop target (Animation → Enter).
- **Visibility is a real signal.** A parked leaf is on screen only in the DOM sense, so `PaneProps.parked` carries it to the body and `useSurfaceVisibility(parked)` folds it together with document visibility: a minimized `ab-screencast` stays mounted and connected but stops pulling frames.
- **Who parks**: `shouldParkOnMinimize` — browser Surfaces, not terminals (a terminal's state is in the PTY and the registry replays it, so parking one would only cost memory). Both still door through `doorLeaf`.
Expand Down
3 changes: 2 additions & 1 deletion dor/src/commands/tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,13 +117,14 @@ A dormouse.yml is repo-controlled and its entries execute, so it is inert until

Approving an upstream covers every worktree and clone of that repo. Approving a folder covers that checkout only, which is what you want for a branch you have not read.

Where the tool lands: it always splits without taking focus and prints the new surface's handle, whether a human typed it or a script did. Taking over the calling pane when the invocation is typed alone at a prompt is designed but not built.
Where the tool lands: typed alone at a prompt, it takes over the pane you typed it in — no split, same surface, same scrollback — and reports "takeover". Anything else splits without taking focus and prints the new surface's handle. The take-over needs an integrated shell running \`dor tool\` as the whole command line, a plain terminal pane you can see, and the tool's directory to be that pane's own, so an agent's invocation, a compound line, --minimize, --surface, and --cwd elsewhere all split instead. The handle prints before the command starts, since dor has to exit before its own shell is free to run it.

--cwd sets the working directory used to find dormouse.yml and to run the command; it defaults to the directory dor was invoked from.

Text output:
created surface:3 "pnpm storybook"
existing surface:3 "pnpm storybook"
takeover surface:1 "pnpm storybook"

JSON output:
{
Expand Down
6 changes: 5 additions & 1 deletion dor/src/commands/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,8 +172,12 @@ export interface ToolSurfaceResponse {
* `existing` is a key match on a live tool: the redundant spawn never
* started. `adopted` is a key match whose command had exited — the Surface is
* reused and the command re-run in place, keeping its position and scrollback.
* On the calling pane's own match it is answered before the re-run is typed,
* for the same reason `takeover` is. `takeover` is the calling pane itself
* becoming the tool, answered before the command is typed — `dor` has to exit
* before its own shell is free to run it.
*/
status: 'created' | 'existing' | 'adopted' | 'pending';
status: 'created' | 'existing' | 'adopted' | 'pending' | 'takeover';
surfaceId: string;
surfaceRef: string;
/** The rendered command, as typed into the shell. */
Expand Down
3 changes: 2 additions & 1 deletion dor/test/snapshots/help/tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,14 @@ A dormouse.yml is repo-controlled and its entries execute, so it is inert until

Approving an upstream covers every worktree and clone of that repo. Approving a folder covers that checkout only, which is what you want for a branch you have not read.

Where the tool lands: it always splits without taking focus and prints the new surface's handle, whether a human typed it or a script did. Taking over the calling pane when the invocation is typed alone at a prompt is designed but not built.
Where the tool lands: typed alone at a prompt, it takes over the pane you typed it in — no split, same surface, same scrollback — and reports "takeover". Anything else splits without taking focus and prints the new surface's handle. The take-over needs an integrated shell running `dor tool` as the whole command line, a plain terminal pane you can see, and the tool's directory to be that pane's own, so an agent's invocation, a compound line, --minimize, --surface, and --cwd elsewhere all split instead. The handle prints before the command starts, since dor has to exit before its own shell is free to run it.

--cwd sets the working directory used to find dormouse.yml and to run the command; it defaults to the directory dor was invoked from.

Text output:
created surface:3 "pnpm storybook"
existing surface:3 "pnpm storybook"
takeover surface:1 "pnpm storybook"

JSON output:
{
Expand Down
Loading