diff --git a/docs/specs/dor-tool.md b/docs/specs/dor-tool.md index d88485e0..af8717d1 100644 --- a/docs/specs/dor-tool.md +++ b/docs/specs/dor-tool.md @@ -124,6 +124,32 @@ Source of truth: `ToolPanel` in `lib/src/components/wall/ToolPanel.tsx`; `ToolPa Source of truth: `toolCommand` in `dor/src/commands/tool.ts`; `dor/test/snapshots/help/tool.md`; `ToolSurfaceResponse` in `dor/src/commands/types.ts`. +## Take-over + +**Must run a standalone `dor tool` invocation in its calling pane when every takeover condition holds.** Otherwise use the ordinary split path. Trust approval and keyed reuse take precedence. (rationale) + +| Condition | Required state | +| --- | --- | +| Caller | Visible, integrated plain terminal; not closing or dying | +| Command line | OSC 633 reports `dor tool` alone; compound shell syntax rejects takeover | +| Directory | Resolved Tool CWD equals the caller's reported CWD | +| Placement | Neither `--surface` nor `--minimize` supplied | +| Helper | No existing auxiliary helper; preserve it by splitting | +| Trust | Already approved; pending approval always uses its own pane | + +**Must answer `takeover` before waiting for the calling shell's prompt**, then transform and type the command. The answer promises placement, not successful command startup. + +- **Must leave the caller unchanged on prompt timeout or cancellation**, and recheck visibility, closing state, CWD, kind, and helper presence after the wait. A helper opened during the handshake prevents transformation. +- **Must change components and params in one metadata commit**, retaining the Session id, Surface ref, scrollback, notes, source pins, and any user rename. +- **Must clear previous OSC 367 hints before typing the new command.** +- **Must retain the spawn lock until the typed command is observed running or a new completed run is observed**, or the wait ends. A command that starts and exits between samples releases the lock too. +- **Must rerun a keyed match in the caller through the same answer/prompt handshake**, reporting `adopted`, when its line is standalone and integrated. Never interrupt the waiting `dor` process. Placement flags do not relocate an existing match; run in its current directory. +- **Must report an error when the caller is the keyed match but its command line cannot be typed behind**, instead of reporting a misleading `existing` result. +- **May interleave user keystrokes arriving between the prompt and command injection.** Takeover does not reserve the shell input buffer. +- **Must include already-owned background listeners in the usual process-tree scan.** Under `auto`, they can become the sole candidate or cause a conflict. + +Source of truth: `toolTakesOverCaller` / `toolRerunsInCaller` in `lib/src/components/wall/tool-takeover.ts`; `runToolInCallerPane` in `lib/src/components/wall/use-dor-control.ts`; `setMeta` in `lib/src/components/wall/lath-wall-store.ts`. Tests: `lib/src/components/wall/tool-takeover.test.ts`, `lib/src/components/Wall.test.tsx`. + ## OSC 367 **Must consume OSC 367 at the PTY owner's parser**, including malformed and unknown verbs, and emit no reply. `serve` is the only implemented verb. The escape registry is `docs/specs/terminal-escapes.md`. @@ -166,12 +192,6 @@ Source of truth: `PersistedToolMetadata` in `lib/src/lib/session-types.ts`; `sav `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 diff --git a/docs/specs/dor-tool.rationale.md b/docs/specs/dor-tool.rationale.md index 0dc54308..5ec5d52d 100644 --- a/docs/specs/dor-tool.rationale.md +++ b/docs/specs/dor-tool.rationale.md @@ -34,6 +34,12 @@ A hardcoded Storybook port can disagree with the port it obtains under contentio The September 2026 integration reuses Terminal Context for the Tool's primary terminal. The auxiliary helper's automatic refresh, Reset, and Promote semantics do not describe a serving command, whose Session also owns the browser and remote terminal identity. Sharing the presentation avoids introducing a second navigation mechanism or a second shell. +## 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 Hostile text printed by the designated command can contain an announcement. The current process-tree check limits port selection to that Session's discovered listeners; browser content still executes under the existing renderer boundaries. Earlier text describing arbitrary local-port selection did not match the scan implementation. diff --git a/docs/specs/tiling-engine.md b/docs/specs/tiling-engine.md index df2b6a41..079a36d4 100644 --- a/docs/specs/tiling-engine.md +++ b/docs/specs/tiling-engine.md @@ -133,7 +133,7 @@ A **parked** leaf is mounted by the adapter but absent from the split tree: its | `forgetLeaf(id)` | — | destroyed | unmounted if parked — destroys a Door | - **Parking must be one commit** — an id absent from both the tree and `parked` for even one render unmounts the leaf and loses its DOM state. Every re-admitting op (`addLeaf`, `restoreLeaf`, `insertLeaf`, `replaceLeaf`, `seed`) unparks in that same commit through the one shared `admit` helper, which also seeds the enter hint. **`seed` admits by tree membership**, never by the metadata it is handed (rationale). Dormant while `seed` runs once at startup; live in the workspaces-rollout switch. -- **One `leafMeta` map holds every leaf the Wall owns**, laid out or Doored; `parked` is pure render state (`Map`) 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 goes through `lath.getMeta(id)` (rationale). `serializeLayout` filters `leafMeta` to the tree's own leaves; a Door persists as its own row. +- **One `leafMeta` map holds every leaf the Wall owns**, laid out or Doored; `parked` is pure render state (`Map`) 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` reach a Doored leaf by the same single path as a visible one, and every reader goes through `lath.getMeta(id)` (rationale). `serializeLayout` filters `leafMeta` to the tree's own leaves; a Door persists as its own row. - **The store holds a parked leaf's last rect, never the adapter** — `registerEl(null)` is a ref detach, not an unmount (rationale). `doorLeaf({ park: true })` captures the rect in the commit that removes the leaf from the tree, `admit` replays it into the animator on re-admission (Animation → Enter), and LathHost renders parked ids there behind `visibility: hidden; pointer-events: none` and `data-lath-parked`, so the guest never sees a zero-extent viewport (rationale). A leaf parked before the Wall reports geometry falls back to the whole wall rect. - **Parked is a visibility signal, not just a layout fact** — it reaches the body as `PaneProps.parked` (Pane props contract), so a minimized `ab-screencast` stays mounted, releases viewer resources, and retains its daemon session. - **Who parks**: `shouldParkOnMinimize` — browser Surfaces, not terminals, whose persistent xterm instance remounts without replay ([glossary.md → View](glossary.md#view)). diff --git a/dor/src/commands/tool.ts b/dor/src/commands/tool.ts index ff250f73..8ce62764 100644 --- a/dor/src/commands/tool.ts +++ b/dor/src/commands/tool.ts @@ -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 visible plain terminal pane without an auxiliary helper, 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: { diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index 571a95e4..2658186d 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -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. */ diff --git a/dor/test/snapshots/help/tool.md b/dor/test/snapshots/help/tool.md index 0ceb96d8..eb0c4dfa 100644 --- a/dor/test/snapshots/help/tool.md +++ b/dor/test/snapshots/help/tool.md @@ -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 visible plain terminal pane without an auxiliary helper, 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: { diff --git a/lib/src/components/Wall.test.tsx b/lib/src/components/Wall.test.tsx index 4f041ffd..20052331 100644 --- a/lib/src/components/Wall.test.tsx +++ b/lib/src/components/Wall.test.tsx @@ -95,6 +95,15 @@ async function flush(): Promise { await act(async () => { await new Promise((r) => setTimeout(r, 0)); }); } +/** Poll until `ready()` — for the host's own 100ms state waits (a tool taking + * over a pane, a split waiting on OSC 633), which no event can flush. */ +async function settle(ready: () => boolean, timeoutMs = 2_000): Promise { + const deadline = Date.now() + timeoutMs; + while (!ready() && Date.now() < deadline) { + await act(async () => { await new Promise((r) => setTimeout(r, 25)); }); + } +} + async function flushFrame(): Promise { await act(async () => { await new Promise((r) => requestAnimationFrame(() => r(undefined))); }); } @@ -1776,6 +1785,278 @@ describe('Wall on the Lath engine', () => { } }); + // Pane take-over: `dor tool` typed alone at a prompt runs in that pane rather + // than splitting (docs/specs/dor-tool.md -> Take-over). The handshake is the + // point — `dor` is the pane's foreground process when the host answers, so the + // command may only be typed once its own shell is back at a prompt. + it.each(['cancelled', 'helper opened', 'cwd changed', 'closing'] as const)('abandons takeover if the caller becomes %s while returning to its prompt', async (change) => { + setToolsEnabled(true); + const controller = new AbortController(); + const typed: string[] = []; + let releaseClosing: (() => void) | undefined; + try { + await act(async () => root.render()); + await flush(); + act(() => { fake.spawnPty('pane-a'); addPlainNote('pane-a', 'Preserve me'); }); + fake.setInputHandler('pane-a', data => typed.push(data)); + terminalRegistry.seedTerminalManualCwd('pane-a', '/repo'); + terminalRegistry.applyTerminalSemanticEvents('pane-a', [ + { type: 'commandLine', commandLine: 'dor tool -- pnpm dev' }, + { type: 'commandStart', source: 'osc633_boundaries' }, + ]); + const respond = vi.fn(); + await act(async () => window.dispatchEvent(new CustomEvent('dormouse:control-request', { detail: { + method: SURFACE_CONTROL_METHODS.tool, surfaceId: 'pane-a', + params: { command: ['pnpm', 'dev'], cwd: '/repo' }, signal: controller.signal, respond, + } }))); + await settle(() => respond.mock.calls.length > 0); + expect(respond).toHaveBeenCalledWith(expect.objectContaining({ result: expect.objectContaining({ status: 'takeover' }) })); + if (change === 'cancelled') controller.abort(); + if (change === 'helper opened') vi.spyOn(helpers, 'getHelper').mockImplementation(id => id === 'pane-a' ? { id: 'helper-a', parentId: 'pane-a', command: '', status: 'off' } : undefined); + if (change === 'cwd changed') terminalRegistry.applyTerminalSemanticEvents('pane-a', [{ type: 'cwd', cwd: terminalRegistry.cwdFromOsc633('/elsewhere')! }]); + if (change === 'closing') releaseClosing = beginClosing(['pane-a']); + act(() => terminalRegistry.applyTerminalSemanticEvents('pane-a', [{ type: 'promptStart' }])); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 150)); }); + expect(typed).toEqual([]); + expect(leafCount()).toBe(1); + expect(getNotes('pane-a')).toHaveLength(1); + await act(async () => window.dispatchEvent(new Event('pagehide'))); + await flush(); + expect((fake.getState() as { panes: Array<{ surfaceType?: string }> }).panes[0]?.surfaceType).not.toBe('tool'); + } finally { + controller.abort(); releaseClosing?.(); fake.clearInputHandler('pane-a'); + act(() => terminalRegistry.removeTerminalPaneState('pane-a')); + setToolsEnabled(false); + } + }); + + it('takes over the calling pane when `dor tool` is typed alone at a prompt', async () => { + setToolsEnabled(true); + const typed: string[] = []; + (fake as FakePtyAdapter & Pick).toolControl = vi.fn(async () => ({ + status: 'ok' as const, + projectRoot: '/repo', + path: '/repo/dormouse.yml', + name: 'storybook', + run: 'pnpm storybook', + render: 'iframe' as const, + port: 'announced' as const, + key: ['/repo'], + warnings: [], + })); + + try { + await act(async () => { + root.render(); + }); + await flush(); + act(() => { fake.spawnPty('pane-a'); addPlainNote('pane-a', 'Keep my takeover notes'); }); + fake.setInputHandler('pane-a', (data) => typed.push(data)); + terminalRegistry.seedTerminalManualCwd('pane-a', '/repo'); + terminalRegistry.applyTerminalSemanticEvents('pane-a', [ + { type: 'commandLine', commandLine: 'dor tool storybook' }, + { type: 'commandStart', source: 'osc633_boundaries' }, + ]); + + let response: { ok: boolean; result?: { status: string; surfaceId: string; minimized: boolean } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + surfaceId: 'pane-a', + params: { name: 'storybook', cwd: '/repo', minimized: false, fresh: false }, + respond: (result: typeof response) => { response = result; }, + }, + })); + }); + await flush(); + + // Answered before the tool starts, and nothing typed while `dor` still owns + // the shell: waiting for the prompt first would deadlock. + expect(response).toMatchObject({ + ok: true, + result: { status: 'takeover', surfaceId: 'pane-a', minimized: false }, + }); + expect(leafCount()).toBe(1); + expect(typed).toEqual([]); + + // `dor` exits; the shell reports its prompt back and the command lands. + act(() => { + terminalRegistry.applyTerminalSemanticEvents('pane-a', [{ type: 'promptStart' }]); + }); + await settle(() => typed.length > 0); + expect(typed).toEqual(['pnpm storybook\r']); + expect(leafCount()).toBe(1); + expect(getNotes('pane-a').some(note => note.content.kind === 'plain' && note.content.text === 'Keep my takeover notes')).toBe(true); + + // The tool goes live, which releases the spawn lock, and then exits. The + // host learns that from its own 100ms state poll, so the live state has to + // outlast one tick. + act(() => { + terminalRegistry.applyTerminalSemanticEvents('pane-a', [ + { type: 'commandLine', commandLine: 'pnpm storybook' }, + { type: 'commandStart', source: 'osc633_boundaries' }, + ]); + }); + await act(async () => { await new Promise((r) => setTimeout(r, 150)); }); + act(() => { + terminalRegistry.applyTerminalSemanticEvents('pane-a', [{ type: 'promptStart' }]); + }); + + // Retyped in the tool's own pane: a key match on the caller re-runs there + // through the same handshake, never an interrupt — Ctrl+C would kill the + // `dor` still waiting for the answer. + act(() => { + terminalRegistry.applyTerminalSemanticEvents('pane-a', [ + { type: 'commandLine', commandLine: 'dor tool storybook' }, + { type: 'commandStart', source: 'osc633_boundaries' }, + ]); + }); + let rerun: { ok: boolean; result?: { status: string; surfaceId: string } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + surfaceId: 'pane-a', + params: { name: 'storybook', cwd: '/repo', minimized: false, fresh: false }, + respond: (result: typeof rerun) => { rerun = result; }, + }, + })); + }); + await settle(() => rerun !== undefined); + expect(rerun).toMatchObject({ ok: true, result: { status: 'adopted', surfaceId: 'pane-a' } }); + act(() => { + terminalRegistry.applyTerminalSemanticEvents('pane-a', [{ type: 'promptStart' }]); + }); + await settle(() => typed.length > 1); + expect(typed).toEqual(['pnpm storybook\r', 'pnpm storybook\r']); + expect(leafCount()).toBe(1); + + // The re-run starts and dies inside one 100ms sample, so no poll ever sees + // it live: the lock has to release on the finished run instead. Without + // that, the request below waits out the 15s timeout and `settle` gives up. + act(() => { + terminalRegistry.applyTerminalSemanticEvents('pane-a', [ + { type: 'commandLine', commandLine: 'pnpm storybook' }, + { type: 'commandStart', source: 'osc633_boundaries' }, + { type: 'commandFinish', exitCode: 1 }, + { type: 'promptStart' }, + ]); + }); + + // A line the host cannot type behind says so, rather than reporting a tool + // that is not running as `existing` back into the pane it is sitting in. + act(() => { + terminalRegistry.applyTerminalSemanticEvents('pane-a', [ + { type: 'commandLine', commandLine: 'dor tool storybook && open http://localhost:6006' }, + { type: 'commandStart', source: 'osc633_boundaries' }, + ]); + }); + let compound: { ok: boolean; error?: string } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + surfaceId: 'pane-a', + params: { name: 'storybook', cwd: '/repo', minimized: false, fresh: false }, + respond: (result: typeof compound) => { compound = result; }, + }, + })); + }); + await settle(() => compound !== undefined); + expect(compound?.ok).toBe(false); + expect(compound?.error).toContain("is this tool's own pane"); + expect(typed).toHaveLength(2); + act(() => { + terminalRegistry.applyTerminalSemanticEvents('pane-a', [{ type: 'promptStart' }]); + }); + + // Same Surface throughout: the leaf changed kind without changing id, so + // the session persists as one. + await act(async () => { window.dispatchEvent(new Event('pagehide')); }); + await flush(); + await flush(); + const saved = fake.getState() as { + panes?: Array<{ id: string; surfaceType?: string; command?: string }>; + } | null; + expect(saved?.panes?.find((pane) => pane.id === 'pane-a')).toMatchObject({ + surfaceType: 'tool', + command: 'pnpm storybook', + }); + } finally { + fake.clearInputHandler('pane-a'); + act(() => terminalRegistry.removeTerminalPaneState('pane-a')); + setToolsEnabled(false); + } + }); + + it.each(['agent', 'helper'] as const)('splits instead of taking over a caller with an existing %s', async (reason) => { + if (reason === 'helper') vi.spyOn(helpers, 'getHelper').mockImplementation(id => id === 'pane-a' ? { id: 'helper-a', parentId: 'pane-a', command: '', status: 'off' } : undefined); + setToolsEnabled(true); + const typed: string[] = []; + (fake as FakePtyAdapter & Pick).toolControl = vi.fn(async () => ({ + status: 'ok' as const, + projectRoot: '/repo', + path: '/repo/dormouse.yml', + name: 'storybook', + run: 'pnpm storybook', + render: 'iframe' as const, + port: 'announced' as const, + key: null, + warnings: [], + })); + let splitId: string | undefined; + + try { + await act(async () => { + root.render(); + }); + await flush(); + act(() => fake.spawnPty('pane-a')); + fake.setInputHandler('pane-a', (data) => typed.push(data)); + terminalRegistry.seedTerminalManualCwd('pane-a', '/repo'); + // An agent's `dor tool` runs under the agent, so the pane reports that line. + terminalRegistry.applyTerminalSemanticEvents('pane-a', [ + { type: 'commandLine', commandLine: reason === 'agent' ? 'claude' : 'dor tool storybook' }, + { type: 'commandStart', source: 'osc633_boundaries' }, + ]); + + let response: { ok: boolean; result?: { status: string; surfaceId: string } } | undefined; + await act(async () => { + window.dispatchEvent(new CustomEvent('dormouse:control-request', { + detail: { + method: SURFACE_CONTROL_METHODS.tool, + surfaceId: 'pane-a', + params: { name: 'storybook', cwd: '/repo', minimized: false, fresh: false }, + respond: (result: typeof response) => { response = result; }, + }, + })); + }); + await flush(); + // The split exists before its handle is reported: a created tool answers + // only once the new shell reports OSC 633. + expect(leafCount()).toBe(2); + splitId = Array.from(container.querySelectorAll('[data-lath-leaf]')) + .map((leaf) => leaf.getAttribute('data-lath-leaf')!) + .find((id) => id !== 'pane-a'); + act(() => { + terminalRegistry.applyTerminalSemanticEvents(splitId!, [{ type: 'promptStart' }]); + }); + await settle(() => response !== undefined); + + expect(response?.result).toMatchObject({ status: 'created', surfaceId: splitId }); + expect(typed).toEqual([]); + } finally { + if (splitId) { + pendingShellOpts.delete(splitId); + act(() => terminalRegistry.removeTerminalPaneState(splitId!)); + } + fake.clearInputHandler('pane-a'); + act(() => terminalRegistry.removeTerminalPaneState('pane-a')); + setToolsEnabled(false); + } + }); + it('rejects a non-integrated shell before offering tool approval', async () => { setToolsEnabled(true); terminalRegistry.setDefaultShellOpts({ shell: 'C:\\Windows\\System32\\cmd.exe' }); diff --git a/lib/src/components/wall/lath-wall-store.test.ts b/lib/src/components/wall/lath-wall-store.test.ts index 1c8513b2..a0ab0e89 100644 --- a/lib/src/components/wall/lath-wall-store.test.ts +++ b/lib/src/components/wall/lath-wall-store.test.ts @@ -364,6 +364,31 @@ describe('meta writes', () => { expect(listener).not.toHaveBeenCalled(); }); + // The in-place kind change behind the `dor tool` take-over: one commit, so a + // body never renders against the previous kind's params. + it('setMeta replaces a leaf\'s whole meta, dropping what it is not handed', () => { + const store = seeded(); + store.updateParams('a', { stale: true }); + const before = store.getSnapshot(); + store.setMeta('a', { component: 'tool', tabComponent: 'tool', title: 'storybook', params: { surfaceType: 'tool' } }); + const after = store.getSnapshot(); + expect(after.leafMeta.get('a')).toEqual({ + component: 'tool', + tabComponent: 'tool', + title: 'storybook', + params: { surfaceType: 'tool' }, + }); + expect(before.leafMeta.get('a')?.component).toBe('terminal'); + expect(after.leafMeta).not.toBe(before.leafMeta); + }); + + it('setMeta is a no-op on an absent id', () => { + const store = seeded(); + const before = store.getSnapshot(); + store.setMeta('missing', { component: 'tool', tabComponent: 'tool', title: 'x' }); + expect(store.getSnapshot()).toBe(before); + }); + it('updateParams merges a patch into params', () => { const store = seeded(); store.updateParams('b', { url: 'https://example.com' }); diff --git a/lib/src/components/wall/lath-wall-store.ts b/lib/src/components/wall/lath-wall-store.ts index 7826bf09..78e73bf1 100644 --- a/lib/src/components/wall/lath-wall-store.ts +++ b/lib/src/components/wall/lath-wall-store.ts @@ -137,6 +137,11 @@ export type LathWallStore = { /** Meta write: merge `patch` into a leaf's params. No-op if the leaf is absent. * Reaches parked and cap-evicted leaves too. */ updateParams(id: LeafId, patch: Record): void; + /** Meta write: **replace** a leaf's whole meta in one commit, dropping + * anything the caller does not hand back — component pair included, which is + * what lets a leaf change kind without changing id (`docs/specs/dor-tool.md` + * -> Take-over). No-op if the leaf is absent. */ + setMeta(id: LeafId, meta: LeafMeta): void; /** Presentation-only zoom target (the tree is untouched). No-op if unchanged. */ setZoomed(id: LeafId | null): void; @@ -462,6 +467,11 @@ export function createLathWallStore(): LathWallStore { commit({ leafMeta: new Map(snapshot.leafMeta).set(id, { ...cur, params }) }); }, + setMeta(id, meta) { + if (!snapshot.leafMeta.has(id)) return; + commit({ leafMeta: new Map(snapshot.leafMeta).set(id, meta) }); + }, + setZoomed(id) { if (snapshot.zoomedId === id) return; commit({ zoomedId: id }); diff --git a/lib/src/components/wall/tool-takeover.test.ts b/lib/src/components/wall/tool-takeover.test.ts new file mode 100644 index 00000000..5203e070 --- /dev/null +++ b/lib/src/components/wall/tool-takeover.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import { + isNakedToolInvocation, + toolRerunsInCaller, + toolTakesOverCaller, + type ToolTakeoverGate, +} from './tool-takeover'; + +describe('isNakedToolInvocation', () => { + it('accepts a `dor tool` line typed on its own', () => { + expect(isNakedToolInvocation('dor tool storybook')).toBe(true); + expect(isNakedToolInvocation(' dor tool storybook ')).toBe(true); + expect(isNakedToolInvocation('dor tool -- pnpm storybook')).toBe(true); + expect(isNakedToolInvocation('dor tool --fresh storybook')).toBe(true); + expect(isNakedToolInvocation('/usr/local/bin/dor tool storybook')).toBe(true); + expect(isNakedToolInvocation('dor.cmd tool storybook')).toBe(true); + // The shared tokenizer skips a leading assignment, as `commandArgv0` does. + expect(isNakedToolInvocation('DEBUG=1 dor tool storybook')).toBe(true); + }); + + it('rejects a line that is not a bare `dor tool`', () => { + expect(isNakedToolInvocation(null)).toBe(false); + expect(isNakedToolInvocation('')).toBe(false); + expect(isNakedToolInvocation('dor')).toBe(false); + expect(isNakedToolInvocation('dor split')).toBe(false); + expect(isNakedToolInvocation('dortool storybook')).toBe(false); + // The agent case: `dor tool` runs under whatever the pane is running. + expect(isNakedToolInvocation('claude')).toBe(false); + expect(isNakedToolInvocation('bash deploy.sh')).toBe(false); + }); + + it('rejects anything that could be more than one command', () => { + expect(isNakedToolInvocation('dor tool storybook && pnpm build')).toBe(false); + expect(isNakedToolInvocation('dor tool storybook; echo done')).toBe(false); + expect(isNakedToolInvocation('dor tool storybook | tee log')).toBe(false); + expect(isNakedToolInvocation('dor tool storybook &')).toBe(false); + expect(isNakedToolInvocation('dor tool storybook > log')).toBe(false); + expect(isNakedToolInvocation('echo $(dor tool storybook)')).toBe(false); + // Quoting is not unpicked: a conservative split beats parsing for intent. + expect(isNakedToolInvocation('dor tool -- sh -c "a && b"')).toBe(false); + }); +}); + +describe('toolTakesOverCaller', () => { + const passing: ToolTakeoverGate = { + explicitSurface: false, + minimized: false, + visible: true, + kind: 'terminal', + oscDriven: true, + rawCommandLine: 'dor tool storybook', + cwdMatches: true, + helperPresent: false, + }; + + it('takes over the pane the invocation was typed in', () => { + expect(toolTakesOverCaller(passing)).toBe(true); + }); + + it('splits when any condition fails', () => { + const splits: Array<[string, Partial]> = [ + ['--surface named a reference', { explicitSurface: true }], + ['--minimize asked for a background surface', { minimized: true }], + ['the caller is minimized', { visible: false }], + ['the caller has an auxiliary helper', { helperPresent: true }], + ['the caller is already a tool', { kind: 'tool' }], + ['the caller is a browser', { kind: 'browser' }], + ['the shell reports no OSC 633', { oscDriven: false }], + ['the line is not naked', { rawCommandLine: 'claude' }], + ['--cwd named another directory', { cwdMatches: false }], + ]; + for (const [why, override] of splits) { + expect(toolTakesOverCaller({ ...passing, ...override }), why).toBe(false); + } + }); + + // The two placements are the same conditions over different caller kinds: a + // plain terminal becomes the tool, the tool's own pane re-runs it. + it('re-runs in the caller only when the caller is that tool', () => { + expect(toolRerunsInCaller({ ...passing, kind: 'tool' })).toBe(true); + expect(toolRerunsInCaller(passing)).toBe(false); + expect(toolRerunsInCaller({ ...passing, kind: 'tool', rawCommandLine: 'claude' })).toBe(false); + expect(toolRerunsInCaller({ ...passing, kind: 'tool', oscDriven: false })).toBe(false); + }); + + // The pane already is the tool, so there is nothing to place and the tool + // re-runs in its own directory — as an `adopted` match from any pane does. + it('re-runs regardless of the conditions that only govern placement', () => { + for (const override of [{ cwdMatches: false }, { explicitSurface: true }, { minimized: true }, { visible: false }]) { + expect(toolRerunsInCaller({ ...passing, kind: 'tool', ...override })).toBe(true); + expect(toolTakesOverCaller({ ...passing, ...override })).toBe(false); + } + }); +}); diff --git a/lib/src/components/wall/tool-takeover.ts b/lib/src/components/wall/tool-takeover.ts new file mode 100644 index 00000000..e81c57e4 --- /dev/null +++ b/lib/src/components/wall/tool-takeover.ts @@ -0,0 +1,88 @@ +/** + * The take-over gate: `dor tool` typed alone at a prompt runs the tool in that + * pane instead of splitting (`docs/specs/dor-tool.md` -> Take-over). + * + * Pure predicates over facts the host has already read, so the placement rule is + * testable without a Wall: the handler in `use-dor-control.ts` gathers the + * facts, this decides, and the handshake that follows is the handler's. + */ +import type { SurfaceKind } from 'dor/commands/types'; +import { commandArgv0, primaryCommandTokens } from '../../lib/terminal-state'; + +/** The launcher names `dor/bin/` ships, lowercased. */ +const DOR_ARGV0 = new Set(['dor', 'dor.cmd']); + +/** Shell syntax that can make one line more than one command: separators, + * pipelines, backgrounding, redirection, substitution. Tested against the raw + * line, so quoting is not unpicked (rationale). */ +const COMPOUND_SYNTAX = /[;&|<>()`\n\r]/; + +/** + * Whether the shell reported running exactly one command and that command is + * `dor tool` — the human-intent signal, not a security boundary + * (`docs/specs/dor-tool.md` -> Take-over). Case folds on the launcher, which is + * a filename, and not on the verb, which stricli parses case-sensitively. + */ +export function isNakedToolInvocation(rawCommandLine: string | null | undefined): boolean { + const line = rawCommandLine?.trim(); + if (!line || COMPOUND_SYNTAX.test(line)) return false; + const argv0 = commandArgv0(line)?.toLowerCase(); + return !!argv0 && DOR_ARGV0.has(argv0) && primaryCommandTokens(line)[1] === 'tool'; +} + +/** What the placement rule reads. Every field is already known to the handler. */ +export interface ToolTakeoverGate { + /** `--surface`: an explicit placement, which take-over must not override. */ + explicitSurface: boolean; + /** `--minimize`: a request for a background Surface, which the caller is not. */ + minimized: boolean; + /** Whether the caller is a visible pane of the active Workspace — a Door is + * not a pane a human is typing in. */ + visible: boolean; + /** The caller's Surface kind; only a plain terminal may transform. */ + kind: SurfaceKind; + /** Whether the caller's shell reports OSC 633 — both the naked test and the + * prompt-return handshake read integration-driven state. */ + oscDriven: boolean; + /** The command line the caller's shell reports running, or null. */ + rawCommandLine: string | null; + /** Whether the tool's resolved cwd is the caller pane's own directory: the + * command is typed into that shell, so it runs where the shell already is. */ + cwdMatches: boolean; + /** An existing auxiliary helper belongs to the plain terminal. Preserve it. */ + helperPresent: boolean; +} + +/** + * Whether the caller's own shell is one the host may type into: an integrated + * pane whose reported line is this invocation and nothing else. Both placements + * need it, and neither can proceed without it. + */ +function callerTypedTool(gate: ToolTakeoverGate): boolean { + return gate.oscDriven && isNakedToolInvocation(gate.rawCommandLine); +} + +/** + * Whether this `dor tool` transforms its calling pane into the tool. Every + * condition is conservative — failing one is a split, which is never wrong + * (rationale). + */ +export function toolTakesOverCaller(gate: ToolTakeoverGate): boolean { + return gate.kind === 'terminal' + && !gate.helperPresent + && !gate.explicitSurface + && !gate.minimized + && gate.visible + && gate.cwdMatches + && callerTypedTool(gate); +} + +/** + * Whether a keyed match on the calling pane re-runs there. The caller is then + * the tool's own Surface, so the placement conditions above are moot — there is + * nothing to place, and the tool re-runs in its own directory, exactly as an + * `adopted` match from any other pane does. + */ +export function toolRerunsInCaller(gate: ToolTakeoverGate): boolean { + return gate.kind === 'tool' && callerTypedTool(gate); +} diff --git a/lib/src/components/wall/use-dor-control.ts b/lib/src/components/wall/use-dor-control.ts index 358c1ef9..0ca190ed 100644 --- a/lib/src/components/wall/use-dor-control.ts +++ b/lib/src/components/wall/use-dor-control.ts @@ -20,18 +20,22 @@ import { getTerminalPaneState, isPaneOscDriven, } from '../../lib/terminal-registry'; -import { surfaceRunsCommand, type TerminalPaneState } from '../../lib/terminal-state'; +import { cwdPathsEqual, surfaceRunsCommand, UNNAMED_PANEL_TITLE, type TerminalPaneState } from '../../lib/terminal-state'; import { isAllowedAgentBrowserBinary } from '../../lib/agent-browser-binary'; import { browserSurfaceUrl, hostPathDisplay } from './browser-url'; import { agentBrowserSessionFromParams, namespacedToolKey, + surfaceKindFromParams, toolKeysEqual, toolPendingFromParams, type ToolPending, } from './browser-surface'; - +import { toolRerunsInCaller, toolTakesOverCaller, type ToolTakeoverGate } from './tool-takeover'; +import { getHelper } from '../../lib/helper-terminal'; +import { isSurfaceClosing } from '../../lib/notepad/notepad-store'; import { listenerUrlsByPort } from './port-url'; +import { clearToolAnnounce } from '../../lib/tool-announce-store'; import { dorDirectionForEdge, toolLeafMeta, type LathWallEngine } from './lath-wall-engine'; import type { WallNav } from './keyboard/types'; import type { LeafMeta } from '../../lib/lath/persistence'; @@ -245,7 +249,9 @@ function readSurfaceText(surfaceId: string, lines: number | undefined, scrollbac // terminal state: a command is gone once `currentCommand` clears (commandFinish // → prompt) and back once the surface reports the same command live again. const RESTART_POLL_INTERVAL_MS = 100; -const RESTART_INTERRUPT_TIMEOUT_MS = 15_000; +// How long a shell gets to come back to its prompt — after `dor ensure +// --restart` interrupts a command, or after a taken-over pane's `dor` exits. +const PROMPT_RETURN_TIMEOUT_MS = 15_000; const RESTART_START_TIMEOUT_MS = 15_000; /** @@ -323,7 +329,7 @@ async function restartSurfaceInPlace(id: string, command: string, cwd: string, s const interrupted = await waitForTerminalState( id, (state) => state.currentCommand === null, - RESTART_INTERRUPT_TIMEOUT_MS, + PROMPT_RETURN_TIMEOUT_MS, signal, ); // Re-check the signal itself, not only the outcome: an already-satisfied wait @@ -343,6 +349,64 @@ async function restartSurfaceInPlace(id: string, command: string, cwd: string, s return { ok: true, value: undefined }; } +/** + * The take-over handshake (docs/specs/dor-tool.md -> Take-over): `dor` is the + * pane's foreground process until the host answers it, so the command can only + * be typed once its own shell is back at a prompt. A shell that never comes back + * — or a pane killed while we wait — is left exactly as it was. Shared by the + * take-over, which transforms the pane on the way in, and a keyed re-run in the + * tool's own pane, which does not. + */ +async function runToolInCallerPane( + lath: LathWallEngine, + id: string, + tool: { + command: string; + cwd: string; + /** The tool leaf to become — omitted when the pane already is this tool and + * is only re-running it. */ + become?: { title: string; params: Record }; + }, + signal?: AbortSignal, +): Promise { + const backAtPrompt = await waitForTerminalState( + id, + (state) => state.currentCommand === null, + PROMPT_RETURN_TIMEOUT_MS, + signal, + ); + const meta = lath.getMeta(id); + // Re-checked after the wait, not only before it: the pane can be killed or + // minimized while `dor` exits, and a Door keeps its meta — `store.has` is + // membership of the tree, so it answers both. + if (signal?.aborted || backAtPrompt !== 'ready' || !meta || !lath.store.has(id) || lath.isDying(id) || isSurfaceClosing(id)) return; + if (!cwdPathsEqual(getTerminalPaneState(id).cwd?.path, tool.cwd)) return; + if (tool.become && (surfaceKindFromParams(meta.params) !== 'terminal' || getHelper(id))) return; + // Whatever this Session announced under its previous command is not this run's: + // a stale OSC 367 would hand the tool that port, or re-key it. + clearToolAnnounce(id); + if (tool.become) { + // A rename the user made outlives the transformation; an untouched fallback + // title becomes the tool's, as a spawned one would be. + const title = meta.title === UNNAMED_PANEL_TITLE ? tool.become.title : meta.title; + lath.store.setMeta(id, toolLeafMeta(title, tool.become.params)); + } + const previousRun = getTerminalPaneState(id).lastCommand?.id ?? null; + getPlatform().writePty(id, `${tool.command}\r`); + // The caller holds the spawn lock until this resolves: a pane typed into but + // not yet reporting reads as an idle tool, which a queued invocation of the + // same key would interrupt and retype. It ends on either outcome — a command + // that dies on boot (a typo, a missing `pnpm`) can start and finish between two + // samples, and waiting out the timeout for it would pin the lock for 15s. + await waitForTerminalState( + id, + (state) => surfaceRunsCommand(state, tool.command, tool.cwd) + || (state.lastCommand !== null && state.lastCommand.id !== previousRun), + RESTART_START_TIMEOUT_MS, + signal, + ); +} + // A `dor ensure -- ` command is typed into the shell programmatically, // which bypasses the keystroke heuristic — so only a shell whose integration // emits OSC 633 boundaries ever reports the command back, which is what makes the @@ -893,6 +957,33 @@ export function useDorControl({ } } + const toolParams = { + surfaceType: 'tool', + command, + cwd, + toolRender: render, + toolPort: port, + ...(key ? { toolKey: key } : {}), + ...(toolName ? { toolName } : {}), + }; + + // What both placements below read of the pane `dor` ran in + // (docs/specs/dor-tool.md -> Take-over). + const callerId = detail.surfaceId; + const callerGate = callerId === undefined ? null : ((): ToolTakeoverGate => { + const state = getTerminalPaneState(callerId); + return { + explicitSurface: stringParam(params.surface) !== undefined, + minimized: booleanParam(params.minimized), + visible: nav.hasPane(callerId) && !lath.isDying(callerId) && !isSurfaceClosing(callerId), + kind: surfaceKindFromParams(lath.getMeta(callerId)?.params), + oscDriven: isPaneOscDriven(callerId), + rawCommandLine: state.currentCommand?.rawCommandLine ?? null, + cwdMatches: cwdPathsEqual(state.cwd?.path, cwd), + helperPresent: !!getHelper(callerId), + }; + })(); + // Spawn-time dedupe, and only for a tool that was given an identity // (docs/specs/dor-tool.md -> Identity and dedupe). if (key && !booleanParam(params.fresh)) { @@ -901,16 +992,54 @@ export function useDorControl({ const match = findSurfaceByParams(matchesToolKey); if (match) { const matchedCommand = toolCommandFromParams(lath.getMeta(match.id)?.params) || command; + // A match that is the calling pane is the tool's own Surface — the + // place take-over makes normal to retype in. Its command is live + // only when the tool itself spawned this `dor`; otherwise `dor` is + // what its shell is running, so the tool is idle however its pane + // reads, and it re-runs in its own directory like any `adopted` + // match. Through the handshake, never `restartSurfaceInPlace`, + // whose Ctrl+C would kill the `dor` awaiting this answer. + const matchedCwd = getTerminalPaneState(match.id).cwd?.path ?? cwd; + if (match.id === callerId + && !surfaceRunsCommand(getTerminalPaneState(match.id), matchedCommand, matchedCwd)) { + if (!callerGate || !toolRerunsInCaller(callerGate)) { + // Nothing can be typed behind a line that is not this + // invocation alone, and there is no survivor to reveal — the + // user is sitting in it. Say so instead of reporting a tool + // that is not running as `existing`. + detail.respond({ + ok: false, + error: `surface '${surfaceRefForId(match.id)}' is this tool's own pane and its command is not running; re-run it by typing the invocation alone at its prompt`, + }); + return; + } + revealSurface(match.id); + detail.respond({ + ok: true, + result: { + status: 'adopted', + surfaceId: match.id, + surfaceRef: surfaceRefForId(match.id), + command: matchedCommand, + cwd: matchedCwd, + minimized: false, + key, + ...(warnings.length > 0 ? { warnings } : {}), + }, + }); + await runToolInCallerPane(lath, match.id, { command: matchedCommand, cwd: matchedCwd }, detail.signal); + return; + } // A dedicated Surface whose command exited is unambiguously free, // so re-run in place rather than splitting — where `dor ensure`, // aimed at arbitrary shells, would stop matching. const idle = getTerminalPaneState(match.id).currentCommand === null; if (idle) { - // The tool's own cwd, not the caller's: `surfaceRunsCommand` - // compares against the matched Surface's `cwdAtStart`, so waiting - // on the caller's would never resolve when `dor tool` is run from - // a subdirectory — the command restarts and we report failure. - const matchedCwd = getTerminalPaneState(match.id).cwd?.path ?? cwd; + // `matchedCwd` above is the tool's own, not the caller's: + // `surfaceRunsCommand` compares against the matched Surface's + // `cwdAtStart`, so waiting on the caller's would never resolve + // when `dor tool` is run from a subdirectory — the command + // restarts and we report failure. const restarted = await restartSurfaceInPlace(match.id, matchedCommand, matchedCwd); if (!restarted.ok) { detail.respond({ @@ -942,6 +1071,36 @@ export function useDorControl({ } } + // Take-over: typed alone at a prompt, the tool runs in the calling pane + // rather than splitting (docs/specs/dor-tool.md -> Take-over). Must stay + // below the pending-approval and key-match returns above: both of those + // placements win over this one. + if (callerId && callerGate && toolTakesOverCaller(callerGate)) { + // Answered before the tool starts, because answering is what frees + // the shell to run it. + detail.respond({ + ok: true, + result: { + status: 'takeover', + surfaceId: callerId, + surfaceRef: surfaceRefForId(callerId), + command, + cwd, + minimized: false, + key, + ...(warnings.length > 0 ? { warnings } : {}), + }, + }); + // Awaited inside the spawn lock: the key reaches the leaf's params in + // there, and a queued invocation of it must find a running tool. + await runToolInCallerPane(lath, callerId, { + command, + cwd, + become: { title: toolName ?? command, params: toolParams }, + }, detail.signal); + return; + } + // A tool is a shell-hosted PTY with the command typed into it, exactly // as `dor ensure` spawns one — but with no command+cwd matching, and a // leaf that renders both capabilities. @@ -961,15 +1120,7 @@ export function useDorControl({ // Focus-neutral like `dor ensure`: a tool spawned by a script or an // agent must not steal the caller's selection. focusNeutral: true, - leafMeta: toolLeafMeta(toolName ?? command, { - surfaceType: 'tool', - command, - cwd, - toolRender: render, - toolPort: port, - ...(key ? { toolKey: key } : {}), - ...(toolName ? { toolName } : {}), - }), + leafMeta: toolLeafMeta(toolName ?? command, toolParams), }); if (!created.ok) { detail.respond({ ok: false, error: created.message }); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index db2e7f7a..f6adaffc 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -404,12 +404,22 @@ export function summarizeCommandLine(raw: string): string { * This is the key WATCHING rules are stored under — see `docs/specs/alert.md`. */ export function commandArgv0(raw: string): string | null { - const commandTokens = takePrimaryCommandTokens(tokenizeCommand(raw.trim())); + const commandTokens = primaryCommandTokens(raw); const command = commandTokens[0]; if (!command) return null; return commandProgramName(command) || null; } +/** + * The tokens of the first command on a line: quote- and escape-aware, truncated + * at the first pipeline/compound boundary, with leading `VAR=value` assignments + * and a leading `env` skipped. `commandArgv0` is this reduced to a program name; + * `dor tool`'s take-over gate reads the verb after it. + */ +export function primaryCommandTokens(raw: string): string[] { + return takePrimaryCommandTokens(tokenizeCommand(raw.trim())); +} + export interface ResolvedCommandStart { rawCommandLine: string | null; displayCommand: string; @@ -461,6 +471,19 @@ function canonicalizeCwdForMatch(path: string): string { return unified.charAt(0).toUpperCase() + unified.slice(1); } +/** + * Whether two reported paths name the same directory. The CLI sends a + * path.resolve'd cwd (trailing slashes, `..`, `.` collapsed), so the only + * remaining divergence to bridge is the Windows/MSYS dialect split (see + * canonicalizeCwdForMatch). Symlinks and true case differences are still + * treated as distinct, matching the exact-key intent. A missing path on either + * side never matches. + */ +export function cwdPathsEqual(a: string | null | undefined, b: string | null | undefined): boolean { + if (!a || !b) return false; + return canonicalizeCwdForMatch(a) === canonicalizeCwdForMatch(b); +} + /** * The idempotency predicate for `dor ensure`: true when the pane is *currently * running* `command` in `cwdPath`. It matches only while the command is live @@ -477,13 +500,7 @@ export function surfaceRunsCommand( const run = state.currentCommand; if (!run || run.rawCommandLine === null) return false; if (run.rawCommandLine !== command) return false; - // The CLI sends a path.resolve'd cwd (trailing slashes, `..`, `.` collapsed), - // so the only remaining divergence to bridge is the Windows/MSYS dialect split - // (see canonicalizeCwdForMatch). Symlinks and true case differences are still - // treated as distinct, matching the exact-key intent. - const runCwd = run.cwdAtStart?.path ?? state.cwd?.path; - if (runCwd === undefined) return false; - return canonicalizeCwdForMatch(runCwd) === canonicalizeCwdForMatch(cwdPath); + return cwdPathsEqual(run.cwdAtStart?.path ?? state.cwd?.path, cwdPath); } export function deriveFallbackCommandTitle( diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index b1ccbf7e..b3c9c0f3 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -7,7 +7,7 @@ "docs/specs/deploy.md": 1900, "docs/specs/dor-browser.md": 4500, "docs/specs/dor-cli.md": 4900, - "docs/specs/dor-tool.md": 2300, + "docs/specs/dor-tool.md": 2600, "docs/specs/glossary.md": 2850, "docs/specs/layout.md": 8000, "docs/specs/mobile-terminal-ui.md": 1950,