Skip to content

Dor Tools: pane take-over — dor tool typed at a prompt runs in that pane - #514

Open
nedtwigg wants to merge 5 commits into
phase-bfrom
tool-takeover
Open

Dor Tools: pane take-over — dor tool typed at a prompt runs in that pane#514
nedtwigg wants to merge 5 commits into
phase-bfrom
tool-takeover

Conversation

@nedtwigg

@nedtwigg nedtwigg commented Sep 1, 2026

Copy link
Copy Markdown
Member

Stacked on #493 (Dor Tools Phase B) — review that first; this diff is only the take-over.

dor tool typed alone at a prompt now runs in that pane instead of splitting: same Surface, same id, same scrollback. Typing a command at a prompt is how a terminal works. Everything else about the invocation is unchanged — same trust gate, same dedupe, same serving trigger — and every other caller still splits.

The gate

All of these hold or it splits, and a split is never wrong, only one pane more than asked for:

  • the caller's OSC 633 command line is one command and it is dor tool (an agent's invocation runs under claude / bash script.sh, so the pane reports that line instead);
  • the caller is a visible pane whose leaf is a plain terminal;
  • the tool's directory is that pane's own (the command is typed into the caller's shell, so it runs where that shell already is);
  • no --surface and no --minimize — both name a placement;
  • no pending approval and no key match — the first needs a pane that has spawned nothing, the second reveals its survivor.

The naked-line test is the human-intent signal, not a security boundary: dor send can type bytes identical to a human's. Trust is still the gate that carries the weight.

The handshake

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. Consequences, all specced:

  • the response promises the placement, not the run;
  • a shell that never comes back to its prompt is left alone — nothing typed, leaf still a terminal;
  • the spawn lock is held past the response until the command is live, so a same-key invocation racing it dedupes against a running tool;
  • the transformation is one meta write (setMeta), so component pair and params commit together and the leaf id — the SessionId — never moves.

Changes

  • lib/src/components/wall/tool-takeover.ts — the gate, as pure predicates over facts the handler already has.
  • surface.tool in use-dor-control.ts — the take-over arm and the handshake.
  • setMeta on the Lath wall store — the first in-place leaf-kind change; documented in tiling-engine.md alongside setTitle / updateParams.
  • cwdPathsEqual lifted out of surfaceRunsCommand so the gate and dor ensure bridge the Windows/MSYS dialect split the same way.
  • dor tool gains a takeover status (help text + snapshot).
  • dor-tool.md: the design moves out of ## Future into ## Take-over, with rationale. Word budget raised 425 words for it.

Tests

  • tool-takeover.test.ts — the naked-line test and every gate condition.
  • Wall.test.tsx — take-over end to end (answered before the tool starts, nothing typed while dor owns the shell, command lands after the prompt returns, one leaf throughout, persists as a tool on the same id) and the split fallback when the caller is running something else.
  • pnpm exec vitest run in lib: 2010 passed. pnpm --filter dor test: 136 passed. spec-lint: OK.

🤖 Generated with Claude Code

https://claude.ai/code/session_0166PG9g7V3kZ6Uo9EHrpoTD

`dor tool` typed alone at a prompt now runs in that pane instead of
splitting — same Surface, same id, same scrollback. Typing a command at a
prompt is how a terminal works; the split placement stays for every other
caller (an agent, a script, `--surface`, `--minimize`, a `--cwd`
elsewhere).

The handshake is the part that needed building: `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 only then types the command. Waiting first deadlocks. The spawn lock
is held past the response until the command is live, so a second
invocation of the same key dedupes against a running tool rather than
racing this one.

The leaf changes kind through one meta write (`setMeta`), so the
component pair and params commit together and the leaf id — the
SessionId — never moves.

dor-tool.md's word budget goes up 425 words: the take-over design moves
out of `## Future` into a `## Take-over` section, plus its rationale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166PG9g7V3kZ6Uo9EHrpoTD
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 1, 2026

Copy link
Copy Markdown

Deploying mouseterm with  Cloudflare Pages  Cloudflare Pages

Latest commit: bdbee6d
Status: ✅  Deploy successful!
Preview URL: https://25249b46.mouseterm.pages.dev
Branch Preview URL: https://tool-takeover.mouseterm.pages.dev

View logs

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One interaction worth a look before this lands, plus a smaller note.

A taken-over pane can become its own key match, and the adopted re-run is unreachable there. The spawn-time dedupe branch in surface.tool runs before the new take-over arm and probes const idle = getTerminalPaneState(match.id).currentCommand === null;. Before this PR the matched surface was always a pane other than the caller, so that read the tool's own state. After a take-over the caller is the tool: dor tool storybook typed again in that pane — the natural "the dev server exited, restart it" gesture, and now the pane the user is sitting in — matches itself through findSurfaceByParams(matchesToolKey) (lath.listPanes() includes the caller), and currentCommand is the in-flight dor tool storybook line, so idle is false. Output is existing surface:1 "pnpm storybook" and nothing re-runs. Only a keyed tool reaches this; an unkeyed one falls through to the gate, fails component !== 'terminal', and splits, which is fine.

Two shapes of fix, both one condition. Skip the key match when match.id === detail.surfaceId and let the take-over arm handle it — it re-writes the meta and types the command, which is an in-place adopt. Or treat the caller as idle by construction, since dor running from that pane's own shell means the tool command has already exited. The first is the safer of the two: restartSurfaceInPlace opens with platform.writePty(id, '\x03'), and firing that into the pane where dor is the foreground process would interrupt dor before it is answered — which is what the second shape would do on any caller whose currentCommand happens to read null.

Happy to push either one as a commit if you want it in this PR rather than a follow-up.

Smaller: of the five handshake invariants under Take-over, "a shell that never comes back to its prompt is left alone: nothing typed, leaf still a terminal" is the only one with no test behind it — TAKEOVER_PROMPT_TIMEOUT_MS makes it awkward to pin, but it is the handshake's equivalent of the gate conditions tool-takeover.test.ts enumerates one by one.

Reuse the shared command parser (`commandArgv0` + a new
`primaryCommandTokens` export) instead of a second whitespace tokenizer
that did not know about quoting, and classify the caller with
`surfaceKindFromParams` rather than reading `leafMeta.component` — the
codebase's one params-level kind switch.

Extract the handshake as `takeOverPaneWithTool` next to its sibling
`restartSurfaceInPlace`, read the caller's terminal state once, and drop
the gate's `callerId` field (the call site proves it).

The spawn lock now releases once the pane is the tool rather than once
the command is live: the key reaches the leaf's params in that write, so
the extra 15s wait only pinned a module-global lock — every other
`dor tool` in the app queued behind it. `RESTART_INTERRUPT_TIMEOUT_MS`
and the take-over's own prompt timeout were the same 15s wait on the same
predicate, now one `PROMPT_RETURN_TIMEOUT_MS`.

Trim the restatements: the handshake and one-meta-write rationale live in
dor-tool.md, with pointers at the code; the help text keeps only what a
user can act on. Tests poll instead of sleeping a fixed 250ms.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166PG9g7V3kZ6Uo9EHrpoTD

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The /simplify pass took two things out that were load-bearing. Both are in the new commit, both are narrow, and I'm happy to push either fix as a commit if you'd rather not hand-roll them.

Comment thread lib/src/components/wall/use-dor-control.ts
Comment thread lib/src/components/wall/tool-takeover.ts
Four real ones, all created or sharpened by take-over:

- A Session that announced an OSC 367 port under an earlier command handed
  that port — and that key — to the tool taking its pane over. The
  announcement is cleared as the leaf changes kind.
- A keyed re-invocation from the tool's own pane, which take-over makes
  the normal place to retype, matched itself and reported `existing`
  forever: its `dor` is what the shell is running, so the tool read as
  live. It is idle by construction there, and now re-runs through the
  same handshake, reported `adopted` — never through
  `restartSurfaceInPlace`, whose Ctrl+C would kill the `dor` still
  waiting for the answer.
- The gate's facts were read before a wait of up to 15s and never
  re-checked, so a pane minimized in that window became a Doored tool
  with a command typed into it.
- Releasing the spawn lock at the meta write (the previous /simplify
  pass) was too early: a pane typed into but not yet reporting reads as
  an idle tool, which a queued same-key invocation interrupts and
  retypes. Held until the command is live again, with the reason
  recorded so it does not get re-simplified.

Also documented as an accepted limit: a listener the taken-over shell
already owned is in the port scan's process tree, so `port: auto` can
frame it or refuse the pair. A split-spawned tool cannot hit it.

Skipped: `cwdPathsEqual` rejecting an empty-string cwd where the old
inline compare accepted it (no CwdState can hold one — `cwdFrom*` returns
null), and folding case on the `tool` verb to match the launcher (stricli
parses verbs case-sensitively; the asymmetry is correct, now stated).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166PG9g7V3kZ6Uo9EHrpoTD

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The re-run arm closes the case it targets. Two things the new commit leaves open, both in the fall-through around it.

Comment thread lib/src/components/wall/use-dor-control.ts Outdated
Comment thread lib/src/components/wall/use-dor-control.ts Outdated
…a dead tool

Both from review of the last commit.

A keyed match on the calling pane that failed the gate fell through to a
branch that could only report `existing` and do nothing: with the match
being the caller, `idle` reads the live `dor tool` line. Reachable by
`cd`-ing inside the tool's pane before retyping, or a compound line. The
re-run gate now drops the conditions that only govern placement — the
pane already is the tool, so it re-runs in its own directory like an
`adopted` match from anywhere else — and a caller that genuinely cannot
be typed behind gets an error naming why, not a survivor it is sitting
in. A self-match whose command really is live (the tool spawned this
`dor`) still reports `existing`.

The lock's tail waited for the command to be *live*, which a tool that
dies on boot never is between two 100ms samples — a typo'd `dor tool --`
pinned the module-global lock for the full 15s with every other `dor
tool` queued behind it. It now ends on either outcome, watching for a
newly finished run by id rather than by command line, so a previous run
of the same command cannot satisfy it early.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166PG9g7V3kZ6Uo9EHrpoTD

@dormouse-bot dormouse-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both fixes read correctly to me — the re-run gate dropping only the placement conditions is the right cut, and snapshotting lastCommand?.id rather than matching on the command line is a better version of what I suggested. One gap in what pins the second one, plus a contract note. The Windows-path decision on the other thread is fine by me: commandArgv0 already mangles those paths for the WATCHING keys and the header, so a second parser here would be the disagreement, not the fix.

Comment thread lib/src/components/Wall.test.tsx Outdated
Comment thread dor/src/commands/types.ts Outdated
The disjunct added last commit had no coverage: nothing in `Wall.test.tsx`
emits `commandFinish`, so `lastCommand` stayed null and only
`surfaceRunsCommand` ever satisfied the wait — the fixed sleeps were what
released the lock. The re-run now starts and dies inside one sample, the
shape the disjunct exists for, and the request after it fails on
`settle`'s deadline if the lock is not released on the finished run
(verified by removing the disjunct). Two 150ms sleeps go with it.

`adopted` also means two things about timing now, so the CLI contract
says which: off the caller's pane the restart is awaited before the
status goes out, on its own pane the answer precedes the re-run, for the
same reason `takeover` does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166PG9g7V3kZ6Uo9EHrpoTD
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants