From 70bee0ed8dfa341c91d96927ecd805137cee3fee Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 30 May 2026 14:39:53 -0700 Subject: [PATCH 1/8] Simplify dor CLI: dedupe errorMessage, drop unused Surface field Hoist the repeated `error instanceof Error ? error.message : String(error)` coercion into a shared errorMessage() helper, and remove the requestedWorkingDirectory field from Surface which was computed but never read by any renderer. Co-Authored-By: Claude Opus 4.8 (1M context) --- dor/src/cli.ts | 6 +----- dor/src/commands/ensure.ts | 3 ++- dor/src/commands/list-surfaces.ts | 3 ++- dor/src/commands/shared.ts | 4 ++++ dor/src/commands/split.ts | 3 ++- dor/src/commands/types.ts | 1 - dor/test/cli-output.test.mjs | 2 -- lib/src/components/Wall.tsx | 1 - 8 files changed, 11 insertions(+), 12 deletions(-) diff --git a/dor/src/cli.ts b/dor/src/cli.ts index 26d49da1..c56bfda3 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -10,7 +10,7 @@ import { ensureCommand } from './commands/ensure.js'; import { listPaneSurfacesCommand } from './commands/list-pane-surfaces.js'; import { listPanesCommand } from './commands/list-panes.js'; import { splitCommand } from './commands/split.js'; -import { fail } from './commands/shared.js'; +import { errorMessage, fail } from './commands/shared.js'; import type { CliEnv, CliOptions, @@ -325,7 +325,3 @@ function normalizeExitCode(exitCode: number | string | null | undefined): number : 0; return numeric === 0 || Number.isNaN(numeric) ? 0 : 1; } - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} diff --git a/dor/src/commands/ensure.ts b/dor/src/commands/ensure.ts index 4dc40dd0..e5fdd1fa 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -7,6 +7,7 @@ import type { EnsureSurfaceResponse, } from './types.js'; import { + errorMessage, resolveControlClient, stringParser, writeStdout, @@ -121,7 +122,7 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... writeStdout(this, renderEnsureResponse(response, flags.json === true)); return undefined; } catch (error) { - return new Error(error instanceof Error ? error.message : String(error)); + return new Error(errorMessage(error)); } } diff --git a/dor/src/commands/list-surfaces.ts b/dor/src/commands/list-surfaces.ts index 846da0a0..3a586708 100644 --- a/dor/src/commands/list-surfaces.ts +++ b/dor/src/commands/list-surfaces.ts @@ -10,6 +10,7 @@ import type { Surface, } from './types.js'; import { + errorMessage, parseIdFormat, renderHandle, resolveControlClient, @@ -59,7 +60,7 @@ export async function runListCommand( writeStdout(context, stdout); return undefined; } catch (error) { - return new Error(error instanceof Error ? error.message : String(error)); + return new Error(errorMessage(error)); } } diff --git a/dor/src/commands/shared.ts b/dor/src/commands/shared.ts index db491cb6..274574b0 100644 --- a/dor/src/commands/shared.ts +++ b/dor/src/commands/shared.ts @@ -10,6 +10,10 @@ import type { export const stringParser = (input: string): string => input; +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export function fail(message: string): CliResult { return { exitCode: 1, stdout: '', stderr: `Error: ${message}\n` }; } diff --git a/dor/src/commands/split.ts b/dor/src/commands/split.ts index b3816648..e1d472fe 100644 --- a/dor/src/commands/split.ts +++ b/dor/src/commands/split.ts @@ -9,6 +9,7 @@ import type { SplitSurfaceResponse, } from './types.js'; import { + errorMessage, resolveControlClient, stringParser, writeStdout, @@ -140,7 +141,7 @@ async function runSplitCommand(this: DorCommandContext, flags: SplitFlags, ...co writeStdout(this, renderSplitResponse(response, flags.json === true)); return undefined; } catch (error) { - return new Error(error instanceof Error ? error.message : String(error)); + return new Error(errorMessage(error)); } } diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index e6ea567e..5066717a 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -16,7 +16,6 @@ export interface Surface { focused: boolean; index: number; indexInPane: number; - requestedWorkingDirectory: string | null; selectedInPane: boolean; } diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 8f4b3372..0f46806c 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -20,7 +20,6 @@ const fixtureSurfaces = [ focused: true, index: 0, indexInPane: 0, - requestedWorkingDirectory: '/Users/example/.codex/worktrees/0cbc/mouseterm', selectedInPane: true, }, { @@ -32,7 +31,6 @@ const fixtureSurfaces = [ focused: false, index: 1, indexInPane: 0, - requestedWorkingDirectory: '/workspace/app/packages/repo', selectedInPane: true, }, ]; diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 41e163ff..dd7c342c 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -670,7 +670,6 @@ export function Wall({ focused: panel.id === activeId, index, indexInPane: 0, - requestedWorkingDirectory: state.cwd?.path ?? null, selectedInPane: true, }; }); From 6fe270e996551cd4bb911ea430bbc8d66a67090f Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 11 Jun 2026 18:08:05 -0700 Subject: [PATCH 2/8] Carry command argv across the control protocol, quote in the host The protocol command field now carries raw string[] argv instead of a pre-quoted string. The CLI sends argv verbatim; the host (Wall) does the single shell-correct quote for the target pane's shell. This removes the double-quoting that happened when the CLI quoted for the caller shell and the host re-quoted for the target shell. Quoting moves into a node-free shell-quote.ts module so it can bundle into both the CLI and the browser webview. shell-command.ts (which pulled in node:child_process for caller-shell detection) is deleted along with the now-dead detectCallerShellKind/parentProcessName. Co-Authored-By: Claude Opus 4.8 (1M context) --- dor/src/commands/ensure.ts | 5 +- .../{shell-command.ts => shell-quote.ts} | 51 +++++-------------- dor/src/commands/split.ts | 3 +- dor/src/commands/types.ts | 6 ++- dor/test/cli-output.test.mjs | 19 ++++--- lib/src/components/Wall.tsx | 40 +++++++++------ 6 files changed, 57 insertions(+), 67 deletions(-) rename dor/src/commands/{shell-command.ts => shell-quote.ts} (68%) diff --git a/dor/src/commands/ensure.ts b/dor/src/commands/ensure.ts index e5fdd1fa..219cc02d 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -12,7 +12,6 @@ import { stringParser, writeStdout, } from './shared.js'; -import { buildShellCommand } from './shell-command.js'; interface EnsureFlags { readonly json?: boolean; @@ -96,10 +95,10 @@ JSON output: }; async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ...commandArgs: string[]): Promise { - const command = buildShellCommand(commandArgs, this.options.env); - if (!command) { + if (commandArgs.length === 0) { return new Error('dor ensure requires a command after --'); } + const command = commandArgs; let title = flags.title; if (title !== undefined) { diff --git a/dor/src/commands/shell-command.ts b/dor/src/commands/shell-quote.ts similarity index 68% rename from dor/src/commands/shell-command.ts rename to dor/src/commands/shell-quote.ts index b7137146..4a58e2a3 100644 --- a/dor/src/commands/shell-command.ts +++ b/dor/src/commands/shell-quote.ts @@ -1,36 +1,15 @@ -import { execFileSync } from 'node:child_process'; +/** + * Pure, dependency-free shell quoting. The webview (the only layer that knows + * the target pane's shell) turns a raw argv array into a single command string + * for that shell. Keep this module free of Node imports so it can be bundled + * into the browser-side webview as well as the CLI. + */ export type ShellCommandKind = 'cmd' | 'posix' | 'powershell'; const POSIX_SAFE_ARG = /^[A-Za-z0-9_@%+=:,./-]+$/; const WINDOWS_SAFE_ARG = /^[A-Za-z0-9_@+=:,./\\-]+$/; -export function buildShellCommand(args: readonly string[], env: Readonly>> = {}): string | undefined { - if (args.join('').trim() === '') return undefined; - return buildShellCommandForKind(detectCallerShellKind(env), args); -} - -export function buildShellCommandForKind(kind: ShellCommandKind, args: readonly string[]): string { - switch (kind) { - case 'cmd': - return args.map(quoteCmdArg).join(' '); - case 'powershell': - return quotePowerShellCommand(args); - case 'posix': - return args.map(quotePosixArg).join(' '); - } -} - -export function detectCallerShellKind(env: Readonly>> = {}): ShellCommandKind { - return shellCommandKind( - parentProcessName() || - env.SHELL || - env.ComSpec || - env.COMSPEC, - process.platform, - ); -} - export function shellCommandKind(shell: string | undefined, platformString: string): ShellCommandKind { const normalizedShell = (shell ?? '').replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? ''; if (!normalizedShell && /win/i.test(platformString)) return 'cmd'; @@ -41,16 +20,14 @@ export function shellCommandKind(shell: string | undefined, platformString: stri return 'posix'; } -function parentProcessName(): string | undefined { - if (process.platform === 'win32') return undefined; - try { - const output = execFileSync('ps', ['-p', String(process.ppid), '-o', 'comm='], { - encoding: 'utf8', - timeout: 1000, - }); - return output.trim() || undefined; - } catch { - return undefined; +export function buildShellCommandForKind(kind: ShellCommandKind, args: readonly string[]): string { + switch (kind) { + case 'cmd': + return args.map(quoteCmdArg).join(' '); + case 'powershell': + return quotePowerShellCommand(args); + case 'posix': + return args.map(quotePosixArg).join(' '); } } diff --git a/dor/src/commands/split.ts b/dor/src/commands/split.ts index e1d472fe..41a91426 100644 --- a/dor/src/commands/split.ts +++ b/dor/src/commands/split.ts @@ -14,7 +14,6 @@ import { stringParser, writeStdout, } from './shared.js'; -import { buildShellCommand } from './shell-command.js'; interface SplitFlags { readonly auto?: boolean; @@ -126,7 +125,7 @@ JSON output: async function runSplitCommand(this: DorCommandContext, flags: SplitFlags, ...commandArgs: string[]): Promise { const direction = parseSplitDirection(flags); if (!direction.ok) return new Error(direction.message); - const command = buildShellCommand(commandArgs, this.options.env); + const command = commandArgs.length > 0 ? commandArgs : undefined; const clientResult = resolveControlClient(this.options); if (!clientResult.ok) return new Error(clientResult.message); diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index 5066717a..855189db 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -32,7 +32,8 @@ export interface ListSurfacesResponse { } export interface SplitSurfaceRequest { - command?: string; + /** Raw argv for the initial command; the host quotes it for the target shell. */ + command?: string[]; direction: SplitDirection; minimized: boolean; surface?: string; @@ -48,7 +49,8 @@ export interface SplitSurfaceResponse { } export interface EnsureSurfaceRequest { - command: string; + /** Raw argv for the command; the host quotes it for the target shell. */ + command: string[]; minimized: boolean; surface?: string; title?: string; diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 0f46806c..c38b0e14 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -4,7 +4,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runCli } from '../dist/cli.js'; -import { buildShellCommandForKind, shellCommandKind } from '../dist/commands/shell-command.js'; +import { buildShellCommandForKind, shellCommandKind } from '../dist/commands/shell-quote.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const snapshotsDir = join(__dirname, 'snapshots'); @@ -57,24 +57,27 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { }, async splitSurface(request) { this.requests.push({ method: 'splitSurface', request }); + // Mirror the real host: quote the argv for the target (here, posix) shell. + const command = request.command ? buildShellCommandForKind('posix', request.command) : undefined; return { status: 'created', surfaceId: '33333333-3333-4333-8333-333333333333', surfaceRef: 'surface:3', direction: request.direction === 'auto' ? 'right' : request.direction, minimized: request.minimized, - ...(request.command ? { command: request.command } : {}), + ...(command ? { command } : {}), }; }, async ensureSurface(request) { this.requests.push({ method: 'ensureSurface', request }); - const title = request.title ?? request.command; + const command = buildShellCommandForKind('posix', request.command); + const title = request.title ?? command; return { status: title === 'dev server' ? 'existing' : 'created', surfaceId: '33333333-3333-4333-8333-333333333333', surfaceRef: 'surface:3', title, - command: request.command, + command, minimized: request.minimized, }; }, @@ -131,13 +134,13 @@ test('shell command quoting supports shell families', () => { ); }); -test('split sends command string to the host', async () => { +test('split sends command argv to the host', async () => { const client = fixtureClient(); await runCli(['split', '--', 'pnpm', 'dev'], { client }); assert.deepEqual(client.requests, [{ method: 'splitSurface', request: { - command: 'pnpm dev', + command: ['pnpm', 'dev'], direction: 'auto', minimized: false, surface: undefined, @@ -152,13 +155,13 @@ test('ensure text output', async () => { ); }); -test('ensure sends command string to the host', async () => { +test('ensure sends command argv to the host', async () => { const client = fixtureClient(); await runCli(['ensure', '--title', 'worker', '--', 'pnpm', 'dev'], { client }); assert.deepEqual(client.requests, [{ method: 'ensureSurface', request: { - command: 'pnpm dev', + command: ['pnpm', 'dev'], minimized: false, surface: undefined, title: 'worker', diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index dd7c342c..9c729bcd 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -44,6 +44,7 @@ import type { ResolvedSplitDirection as DorResolvedSplitDirection, ParseResult, } from 'dor/commands/types'; +import { buildShellCommandForKind, shellCommandKind } from 'dor/commands/shell-quote'; import { findReattachNeighbor } from '../lib/spatial-nav'; import { cloneLayout, getLayoutStructureSignature } from '../lib/layout-snapshot'; import type { PersistedDoor } from '../lib/session-types'; @@ -171,6 +172,11 @@ function booleanParam(value: unknown): boolean { return value === true; } +function stringArrayParam(value: unknown): string[] | undefined { + if (!Array.isArray(value) || !value.every((item) => typeof item === 'string')) return undefined; + return value; +} + function parseDorSplitDirection(value: unknown): DorSplitDirection | null { if (value === undefined || value === null) return 'auto'; if (value === 'left' || value === 'right' || value === 'up' || value === 'down' || value === 'auto') return value; @@ -209,22 +215,27 @@ function validateUserTitle(title: string): string | null { return null; } -function hostLooksWindows(): boolean { - return /win/i.test(PLATFORM_STRING); +/** + * Quote a raw argv into a single command string for the target pane's shell. + * This is the one place the command is quoted; the CLI sends argv unquoted + * precisely because only the webview knows which shell will run it. + */ +function dorCommandString(args: string[] | undefined): string | undefined { + if (!args || args.join('').trim() === '') return undefined; + const shell = getDefaultShellOpts()?.shell; + return buildShellCommandForKind(shellCommandKind(shell, PLATFORM_STRING), args); } +/** Wrap an already-quoted command string in the target shell's launch flags. */ function commandShellArgs(shell: string | undefined, command: string): string[] { - const normalizedShell = (shell ?? '').replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? ''; - if (!normalizedShell && hostLooksWindows()) { - return ['/d', '/s', '/c', command]; - } - if (normalizedShell === 'cmd.exe' || normalizedShell === 'cmd') { - return ['/d', '/s', '/c', command]; - } - if (normalizedShell === 'powershell.exe' || normalizedShell === 'powershell' || normalizedShell === 'pwsh.exe' || normalizedShell === 'pwsh') { - return ['-NoLogo', '-NoProfile', '-Command', command]; + switch (shellCommandKind(shell, PLATFORM_STRING)) { + case 'cmd': + return ['/d', '/s', '/c', command]; + case 'powershell': + return ['-NoLogo', '-NoProfile', '-Command', command]; + case 'posix': + return ['-lc', command]; } - return ['-lc', command]; } function ShellSpawnNotice({ @@ -913,8 +924,7 @@ export function Wall({ const direction = directionParam === 'auto' ? dorDirectionForDockview(pickSplitDirection(resolved.panel)) : directionParam; - const rawCommand = stringParam(params.command); - const command = rawCommand?.trim() || undefined; + const command = dorCommandString(stringArrayParam(params.command)); if (params.command !== undefined && !command) { detail.respond({ ok: false, error: 'command cannot be empty' }); return; @@ -944,7 +954,7 @@ export function Wall({ } if (detail.method === 'surface.ensure') { - const command = stringParam(params.command)?.trim(); + const command = dorCommandString(stringArrayParam(params.command)); if (!command) { detail.respond({ ok: false, error: 'command cannot be empty' }); return; From 5ae0e77f01a68cdb803c016b9c0f8f499e6c73c4 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 11 Jun 2026 18:08:11 -0700 Subject: [PATCH 3/8] wip: ensure --cwd spec rewrite and planning TODO ensure.md snapshot rewrites the ensure semantics from --title idempotency to --cwd + live-command matching. This is aspirational and does NOT match the current code (still --title), so the cli-help snapshot test fails until the code follows. docs/specs/TODO.md is a planning scratch doc. Co-Authored-By: Claude Opus 4.8 (1M context) --- dor/test/snapshots/help/ensure.md | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/dor/test/snapshots/help/ensure.md b/dor/test/snapshots/help/ensure.md index 46d29db6..a66e1ca0 100644 --- a/dor/test/snapshots/help/ensure.md +++ b/dor/test/snapshots/help/ensure.md @@ -4,38 +4,34 @@ Invocation: `dor ensure --help` ```text USAGE - dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value] -- ... + dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- ... dor ensure --help -Ensures one surface exists in the current workspace for a user-enforced title. The idempotency key is always the user-enforced title. +Ensures one surface in the current workspace is running the given command at the given path. If it's already running, no-op. If it isn't, then it creates a split and runs the command. -If --title is omitted, Dormouse derives the title from the command after --. +Matching uses the command each shell reports it is running via Dormouse shell integration, not process inspection. This captures the typed command (`npm dev`), not the forked child process (`node .../vite`), and works for shells the user started by hand as well as shells Dormouse started. -If a surface in the current workspace already has the enforced title, Dormouse returns that surface and does not start another command. +A surface matches only while the command is live. Once the command exits and the shell returns to its prompt, the surface no longer matches; the next ensure causes a fresh split rather than reusing the idle shell. Minimized surfaces participate in matching. Closed/killed surfaces do not. -If no surface has that enforced title, Dormouse creates a split, starts the command, marks the surface title as user-enforced, and returns the new surface. +Two surfaces running the same command in different working directories are distinct (e.g. the same dev server in two worktrees). Both keep running; ensure never collapses them. -A user-enforced title is visible in the UI and must not be overwritten by terminal title escape sequences from the running process. - -Matching uses Dormouse metadata, not process inspection. Minimized surfaces participate in matching. Closed/killed surfaces do not participate in matching. +--cwd sets the working directory used both for matching and for the new command. If omitted, Dormouse uses the directory dor was invoked from. The path is normalized (symlinks resolved) before it becomes part of the key. --minimize applies only when creating a new surface; it does not minimize an existing match. --surface selects the surface to split only when creating a new surface. If omitted, Dormouse uses the same caller/focused fallback as dor split. -No workspace argument exists until Dormouse supports multiple workspaces. - Text output: - created surface:3 "dev server" - existing surface:3 "dev server" + created surface:3 "npm dev" + existing surface:3 "npm dev" JSON output: { "status": "created", "surface_id": "pane-def", "surface_ref": "surface:3", - "title": "dev server", - "command": "pnpm dev:workspace", + "command": "npm dev", + "cwd": "/Users/me/projects/site", "minimized": false } @@ -43,7 +39,7 @@ FLAGS [--json] Print JSON output. [--minimize] Create the surface minimized. [--surface] Surface to split when creating. - [--title] User-enforced surface title. + [--cwd] Working directory for matching and for the new command. -h --help Print help information and exit -- All subsequent inputs should be interpreted as arguments From 632e3a76e5b6d234f6dc01c7cdc031ee13cf6227 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 11 Jun 2026 20:32:36 -0700 Subject: [PATCH 4/8] Simplify dor CLI: share renderJson and requireControlClient helpers Dedupe two patterns repeated across the command runners: - renderJson(payload) replaces the `JSON.stringify(..., null, 2) + "\n"` idiom in the split, ensure, and list-surfaces renderers. - requireControlClient(options) collapses the resolve/check/new-Error boilerplate into one call returning ControlClient | Error, dropping the .value indirection at each call site. resolveControlClient is now internal to shared.ts. Co-Authored-By: Claude Opus 4.8 (1M context) --- dor/src/commands/ensure.ts | 13 +++++++------ dor/src/commands/list-surfaces.ts | 13 +++++++------ dor/src/commands/shared.ts | 11 ++++++++++- dor/src/commands/split.ts | 13 +++++++------ 4 files changed, 31 insertions(+), 19 deletions(-) diff --git a/dor/src/commands/ensure.ts b/dor/src/commands/ensure.ts index 219cc02d..49dfbf0a 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -8,7 +8,8 @@ import type { } from './types.js'; import { errorMessage, - resolveControlClient, + renderJson, + requireControlClient, stringParser, writeStdout, } from './shared.js'; @@ -108,11 +109,11 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... } } - const clientResult = resolveControlClient(this.options); - if (!clientResult.ok) return new Error(clientResult.message); + const client = requireControlClient(this.options); + if (client instanceof Error) return client; try { - const response = await clientResult.value.ensureSurface({ + const response = await client.ensureSurface({ command, minimized: flags.minimize === true, surface: flags.surface, @@ -127,14 +128,14 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... function renderEnsureResponse(response: EnsureSurfaceResponse, json: boolean): string { if (json) { - return `${JSON.stringify({ + return renderJson({ status: response.status, ...(response.surfaceId ? { surface_id: response.surfaceId } : {}), surface_ref: response.surfaceRef, title: response.title, command: response.command, minimized: response.minimized, - }, null, 2)}\n`; + }); } return `${response.status} ${response.surfaceRef} ${JSON.stringify(response.title)}\n`; diff --git a/dor/src/commands/list-surfaces.ts b/dor/src/commands/list-surfaces.ts index 3a586708..c5030317 100644 --- a/dor/src/commands/list-surfaces.ts +++ b/dor/src/commands/list-surfaces.ts @@ -13,7 +13,8 @@ import { errorMessage, parseIdFormat, renderHandle, - resolveControlClient, + renderJson, + requireControlClient, stringParser, wantsIds, wantsRefs, @@ -49,12 +50,12 @@ export async function runListCommand( flags: ListFlags, context: DorCommandContext, ): Promise { - const clientResult = resolveControlClient(context.options); - if (!clientResult.ok) return new Error(clientResult.message); + const client = requireControlClient(context.options); + if (client instanceof Error) return client; try { const pane = mode === 'pane-surfaces' ? (flags.pane ?? 'focused') : undefined; - const response = await clientResult.value.listSurfaces({ pane }); + const response = await client.listSurfaces({ pane }); const idFormat = flags.idFormat ?? 'refs'; const stdout = renderListResponse(response, mode, idFormat, flags.json === true); writeStdout(context, stdout); @@ -171,7 +172,7 @@ function renderPanesJson(response: ListSurfacesResponse, panes: Pane[], idFormat panes: panes.map((pane) => renderPaneJson(pane, idFormat)), ...(wantsRefs(idFormat) ? { window_ref: response.windowRef, workspace_ref: response.workspaceRef } : {}), }; - return `${JSON.stringify(payload, null, 2)}\n`; + return renderJson(payload); } function renderPaneJson(pane: Pane, idFormat: IdFormat): Record { @@ -196,7 +197,7 @@ function renderPaneSurfacesJson(response: ListSurfacesResponse, idFormat: IdForm surfaces: response.surfaces.map((surface) => renderPaneSurfaceJson(surface, idFormat)), ...(wantsRefs(idFormat) ? { window_ref: response.windowRef, workspace_ref: response.workspaceRef } : {}), }; - return `${JSON.stringify(payload, null, 2)}\n`; + return renderJson(payload); } function renderPaneSurfaceJson(surface: Surface, idFormat: IdFormat): Record { diff --git a/dor/src/commands/shared.ts b/dor/src/commands/shared.ts index 274574b0..04b0cd15 100644 --- a/dor/src/commands/shared.ts +++ b/dor/src/commands/shared.ts @@ -18,6 +18,10 @@ export function fail(message: string): CliResult { return { exitCode: 1, stdout: '', stderr: `Error: ${message}\n` }; } +export function renderJson(payload: unknown): string { + return `${JSON.stringify(payload, null, 2)}\n`; +} + export function isIdFormat(value: string): value is IdFormat { return value === 'refs' || value === 'uuids' || value === 'both'; } @@ -27,7 +31,7 @@ export function parseIdFormat(value: string): IdFormat { throw new SyntaxError(`invalid --id-format '${value}'`); } -export function resolveControlClient(options: CliOptions): ParseResult { +function resolveControlClient(options: CliOptions): ParseResult { if (options.client) return { ok: true, value: options.client }; const env = options.env ?? {}; @@ -47,6 +51,11 @@ export function resolveControlClient(options: CliOptions): ParseResult 0 ? commandArgs : undefined; - const clientResult = resolveControlClient(this.options); - if (!clientResult.ok) return new Error(clientResult.message); + const client = requireControlClient(this.options); + if (client instanceof Error) return client; try { - const response = await clientResult.value.splitSurface({ + const response = await client.splitSurface({ ...(command ? { command } : {}), direction: direction.value, minimized: flags.minimize === true, @@ -161,14 +162,14 @@ function parseSplitDirection(flags: SplitFlags): ParseResult { function renderSplitResponse(response: SplitSurfaceResponse, json: boolean): string { if (json) { - return `${JSON.stringify({ + return renderJson({ status: response.status, ...(response.surfaceId ? { surface_id: response.surfaceId } : {}), surface_ref: response.surfaceRef, direction: response.direction, minimized: response.minimized, ...(response.command ? { command: response.command } : {}), - }, null, 2)}\n`; + }); } const minimized = response.minimized ? ' [minimized]' : ''; From 6e788ba019a6c23a5f7e784d9023379e3b659c9e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 12 Jun 2026 14:46:36 -0700 Subject: [PATCH 5/8] Switch dor ensure idempotency from title to command+cwd The enforced-title key meant a server the human started by hand never matched the agent's `dor ensure`, so both ran their own copy. Key on the shell-integration-reported running command plus working directory instead: drop --title, add --cwd (defaults to the caller's directory, which now travels in the request since the host can't know it), and render the command rather than the title. Also reconcile the cli-expanded merge: the new iframe/kill/read/send commands used the old resolveControlClient API that this branch had replaced with requireControlClient. Co-Authored-By: Claude Opus 4.8 (1M context) --- dor/src/cli.ts | 2 +- dor/src/commands/ensure.ts | 66 ++++++++++++++--------------- dor/src/commands/iframe.ts | 8 ++-- dor/src/commands/kill.ts | 8 ++-- dor/src/commands/read.ts | 8 ++-- dor/src/commands/send.ts | 8 ++-- dor/src/commands/types.ts | 5 ++- dor/src/node-runtime.d.ts | 5 +++ dor/test/cli-output.test.mjs | 22 ++++++---- dor/test/snapshots/ensure-json.snap | 4 +- dor/test/snapshots/ensure-text.snap | 2 +- dor/test/snapshots/help/dor.md | 4 +- dor/test/snapshots/help/ensure.md | 8 ++-- 13 files changed, 80 insertions(+), 70 deletions(-) diff --git a/dor/src/cli.ts b/dor/src/cli.ts index 421a5738..f4a59fa8 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -295,7 +295,7 @@ function validateEnsureDelimiter(args: string[]): ParseResult { if (arg === '--json' || arg === '--minimize') { continue; } - if (arg === '--title' || arg === '--surface') { + if (arg === '--cwd' || arg === '--surface') { const value = args[index + 1]; if (!value || value.startsWith('-') || index + 1 >= delimiterIndex) { return { ok: false, message: `${arg} requires a value` }; diff --git a/dor/src/commands/ensure.ts b/dor/src/commands/ensure.ts index 49dfbf0a..6b82a585 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -1,7 +1,9 @@ -/** Private `surface.ensure` wiring for title-based idempotency. */ +/** Private `surface.ensure` wiring for command+cwd idempotency. */ +import { resolve as resolvePath } from 'node:path'; import { buildCommand } from '@stricli/core'; import type { + CliEnv, Command, DorCommandContext, EnsureSurfaceResponse, @@ -18,7 +20,7 @@ interface EnsureFlags { readonly json?: boolean; readonly minimize?: boolean; readonly surface?: string; - readonly title?: string; + readonly cwd?: string; } export const ensureCommand: Command = { @@ -27,15 +29,15 @@ export const ensureCommand: Command = { { scope: 'root', findReplace: [ - ' dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value]', - ' dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value] -- ...\n', + ' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path]', + ' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- ...\n', ], }, { scope: 'command-usage', findReplace: [ - ' dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value]', - ' dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value] -- ...\n', + ' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path]', + ' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- ...\n', ], }, { @@ -45,36 +47,32 @@ export const ensureCommand: Command = { ], command: buildCommand({ docs: { - brief: 'Ensure one surface exists for a user-enforced title.', - fullDescription: `Ensures one surface exists in the current workspace for a user-enforced title. The idempotency key is always the user-enforced title. + brief: 'Ensure one surface is running a command.', + fullDescription: `Ensures one surface in the current workspace is running the given command at the given path. If it's already running, no-op. If it isn't, then it creates a split and runs the command. -If --title is omitted, Dormouse derives the title from the command after --. +Matching uses the command each shell reports it is running via Dormouse shell integration, not process inspection. This captures the typed command (\`npm run dev\`), not the forked child process (\`node .../vite\`), and works for shells the user started by hand as well as shells Dormouse started. The match is exact: \`npm run dev\` and \`npm run dev --host\` are different commands and get separate surfaces. Shells without the integration don't report their command, so ensure can't match them and starts a new surface every time. -If a surface in the current workspace already has the enforced title, Dormouse returns that surface and does not start another command. +A surface matches only while the command is live. Once the command exits and the shell returns to its prompt, the surface no longer matches; the next ensure causes a fresh split rather than reusing the idle shell. Minimized surfaces participate in matching. Closed/killed surfaces do not. -If no surface has that enforced title, Dormouse creates a split, starts the command, marks the surface title as user-enforced, and returns the new surface. +Two surfaces running the same command in different working directories are distinct (e.g. the same dev server in two worktrees). Both keep running; ensure never collapses them. -A user-enforced title is visible in the UI and must not be overwritten by terminal title escape sequences from the running process. - -Matching uses Dormouse metadata, not process inspection. Minimized surfaces participate in matching. Closed/killed surfaces do not participate in matching. +--cwd sets the working directory used both for matching and for the new command. If omitted, Dormouse uses the directory dor was invoked from. The path is normalized (symlinks resolved) before it becomes part of the key. --minimize applies only when creating a new surface; it does not minimize an existing match. --surface selects the surface to split only when creating a new surface. If omitted, Dormouse uses the same caller/focused fallback as dor split. -No workspace argument exists until Dormouse supports multiple workspaces. - Text output: - created surface:3 "dev server" - existing surface:3 "dev server" + created surface:3 "npm run dev" + existing surface:3 "npm run dev" JSON output: { "status": "created", "surface_id": "pane-def", "surface_ref": "surface:3", - "title": "dev server", - "command": "pnpm dev:workspace", + "command": "npm run dev", + "cwd": "/Users/me/projects/site", "minimized": false }`, }, @@ -83,7 +81,7 @@ JSON output: json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, minimize: { kind: 'boolean', brief: 'Create the surface minimized.', optional: true, withNegated: false }, surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split when creating.', optional: true, placeholder: 'id|ref|index' }, - title: { kind: 'parsed', parse: stringParser, brief: 'User-enforced surface title.', optional: true }, + cwd: { kind: 'parsed', parse: stringParser, brief: 'Working directory for matching and for the new command.', optional: true, placeholder: 'path' }, }, positional: { kind: 'array', @@ -99,25 +97,16 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... if (commandArgs.length === 0) { return new Error('dor ensure requires a command after --'); } - const command = commandArgs; - - let title = flags.title; - if (title !== undefined) { - title = title.trim(); - if (title === '') { - return new Error('dor ensure --title must not be empty'); - } - } const client = requireControlClient(this.options); if (client instanceof Error) return client; try { const response = await client.ensureSurface({ - command, + command: commandArgs, minimized: flags.minimize === true, surface: flags.surface, - title, + cwd: callerWorkingDirectory(flags.cwd, this.options.env), }); writeStdout(this, renderEnsureResponse(response, flags.json === true)); return undefined; @@ -126,17 +115,26 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... } } +// The host has no idea where `dor` was launched, so the caller's directory must +// travel in the request. Prefer the shell's PWD (injectable, matches what the +// user sees) and fall back to the process cwd. A relative --cwd resolves against +// that base; the host normalizes symlinks before keying. +function callerWorkingDirectory(flag: string | undefined, env: CliEnv | undefined): string { + const base = env?.PWD ?? process.cwd(); + return flag === undefined ? base : resolvePath(base, flag); +} + function renderEnsureResponse(response: EnsureSurfaceResponse, json: boolean): string { if (json) { return renderJson({ status: response.status, ...(response.surfaceId ? { surface_id: response.surfaceId } : {}), surface_ref: response.surfaceRef, - title: response.title, command: response.command, + cwd: response.cwd, minimized: response.minimized, }); } - return `${response.status} ${response.surfaceRef} ${JSON.stringify(response.title)}\n`; + return `${response.status} ${response.surfaceRef} ${JSON.stringify(response.command)}\n`; } diff --git a/dor/src/commands/iframe.ts b/dor/src/commands/iframe.ts index ac7b7e17..06ada46e 100644 --- a/dor/src/commands/iframe.ts +++ b/dor/src/commands/iframe.ts @@ -7,7 +7,7 @@ import type { IframeSurfaceResponse, } from './types.js'; import { - resolveControlClient, + requireControlClient, stringParser, writeStdout, } from './shared.js'; @@ -64,11 +64,11 @@ JSON output: }; async function runIframeCommand(this: DorCommandContext, flags: IframeFlags, url: string): Promise { - const clientResult = resolveControlClient(this.options); - if (!clientResult.ok) return new Error(clientResult.message); + const client = requireControlClient(this.options); + if (client instanceof Error) return client; try { - const response = await clientResult.value.iframeSurface({ + const response = await client.iframeSurface({ minimized: flags.minimize === true, surface: flags.surface, url, diff --git a/dor/src/commands/kill.ts b/dor/src/commands/kill.ts index f6b0af35..5276ab02 100644 --- a/dor/src/commands/kill.ts +++ b/dor/src/commands/kill.ts @@ -9,7 +9,7 @@ import type { ParseResult, } from './types.js'; import { - resolveControlClient, + requireControlClient, stringParser, writeStdout, } from './shared.js'; @@ -65,11 +65,11 @@ async function runKillCommand(this: DorCommandContext, flags: KillFlags): Promis const confirmation = parseConfirmation(flags); if (!confirmation.ok) return new Error(confirmation.message); - const clientResult = resolveControlClient(this.options); - if (!clientResult.ok) return new Error(clientResult.message); + const client = requireControlClient(this.options); + if (client instanceof Error) return client; try { - const response = await clientResult.value.killSurface({ + const response = await client.killSurface({ confirmation: confirmation.value, surface: flags.surface, }); diff --git a/dor/src/commands/read.ts b/dor/src/commands/read.ts index 5de929fd..34bd8030 100644 --- a/dor/src/commands/read.ts +++ b/dor/src/commands/read.ts @@ -7,7 +7,7 @@ import type { ReadSurfaceResponse, } from './types.js'; import { - resolveControlClient, + requireControlClient, stringParser, writeStdout, } from './shared.js'; @@ -51,11 +51,11 @@ JSON output: }; async function runReadCommand(this: DorCommandContext, flags: ReadFlags): Promise { - const clientResult = resolveControlClient(this.options); - if (!clientResult.ok) return new Error(clientResult.message); + const client = requireControlClient(this.options); + if (client instanceof Error) return client; try { - const response = await clientResult.value.readSurface({ + const response = await client.readSurface({ ...(flags.lines !== undefined ? { lines: flags.lines } : {}), scrollback: flags.scrollback === true, surface: flags.surface, diff --git a/dor/src/commands/send.ts b/dor/src/commands/send.ts index 2081bd5b..0ec9be93 100644 --- a/dor/src/commands/send.ts +++ b/dor/src/commands/send.ts @@ -8,7 +8,7 @@ import type { SendSurfaceResponse, } from './types.js'; import { - resolveControlClient, + requireControlClient, stringParser, writeStdout, } from './shared.js'; @@ -78,11 +78,11 @@ async function runSendCommand(this: DorCommandContext, flags: SendFlags, text?: if (!encoded.ok) return new Error(encoded.message); if (encoded.value.input.length === 0) return new Error('input cannot be empty'); - const clientResult = resolveControlClient(this.options); - if (!clientResult.ok) return new Error(clientResult.message); + const client = requireControlClient(this.options); + if (client instanceof Error) return client; try { - const response = await clientResult.value.sendSurface({ + const response = await client.sendSurface({ surface: flags.surface, input: encoded.value.input, inputCount: encoded.value.inputCount, diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index 1d600974..8d985f28 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -54,15 +54,16 @@ export interface EnsureSurfaceRequest { command: string[]; minimized: boolean; surface?: string; - title?: string; + /** Working directory for matching and for the new command; part of the idempotency key. */ + cwd: string; } export interface EnsureSurfaceResponse { status: 'created' | 'existing'; surfaceId?: string; surfaceRef: string; - title: string; command: string; + cwd: string; minimized: boolean; } diff --git a/dor/src/node-runtime.d.ts b/dor/src/node-runtime.d.ts index e0b668d3..dfc232fd 100644 --- a/dor/src/node-runtime.d.ts +++ b/dor/src/node-runtime.d.ts @@ -19,9 +19,14 @@ declare module 'node:child_process' { }): string; } +declare module 'node:path' { + export function resolve(...segments: string[]): string; +} + declare const process: { platform: string; ppid: number; + cwd(): string; }; declare function setTimeout(callback: () => void, ms?: number): number; diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index c736f1fe..80a95998 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -70,14 +70,15 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { }, async ensureSurface(request) { this.requests.push({ method: 'ensureSurface', request }); + // Mirror the host: quote the argv for the target shell, and key on the + // command so the fixture can exercise both the created and existing paths. const command = buildShellCommandForKind('posix', request.command); - const title = request.title ?? command; return { - status: title === 'dev server' ? 'existing' : 'created', + status: command === 'pnpm dev:workspace' ? 'existing' : 'created', surfaceId: '33333333-3333-4333-8333-333333333333', surfaceRef: 'surface:3', - title, command, + cwd: request.cwd, minimized: request.minimized, }; }, @@ -199,20 +200,23 @@ test('split sends command argv to the host', async () => { test('ensure text output', async () => { await snapshot( 'ensure-text', - await runCli(['ensure', '--title', 'dev server', '--', 'pnpm', 'dev:workspace'], { client: fixtureClient() }), + await runCli(['ensure', '--', 'pnpm', 'dev:workspace'], { + client: fixtureClient(), + env: { PWD: '/Users/me/projects/site' }, + }), ); }); -test('ensure sends command argv to the host', async () => { +test('ensure sends command argv and caller cwd to the host', async () => { const client = fixtureClient(); - await runCli(['ensure', '--title', 'worker', '--', 'pnpm', 'dev'], { client }); + await runCli(['ensure', '--', 'pnpm', 'dev'], { client, env: { PWD: '/work/site' } }); assert.deepEqual(client.requests, [{ method: 'ensureSurface', request: { command: ['pnpm', 'dev'], minimized: false, surface: undefined, - title: 'worker', + cwd: '/work/site', }, }]); }); @@ -220,7 +224,9 @@ test('ensure sends command argv to the host', async () => { test('ensure json output', async () => { await snapshot( 'ensure-json', - await runCli(['ensure', '--json', '--minimize', '--', 'pnpm', 'dev:workspace'], { client: fixtureClient() }), + await runCli(['ensure', '--json', '--minimize', '--cwd', '/Users/me/projects/site', '--', 'pnpm', 'dev'], { + client: fixtureClient(), + }), ); }); diff --git a/dor/test/snapshots/ensure-json.snap b/dor/test/snapshots/ensure-json.snap index 7d2e304f..6bc5959c 100644 --- a/dor/test/snapshots/ensure-json.snap +++ b/dor/test/snapshots/ensure-json.snap @@ -4,8 +4,8 @@ stdout: "status": "created", "surface_id": "33333333-3333-4333-8333-333333333333", "surface_ref": "surface:3", - "title": "pnpm dev:workspace", - "command": "pnpm dev:workspace", + "command": "pnpm dev", + "cwd": "/Users/me/projects/site", "minimized": true } diff --git a/dor/test/snapshots/ensure-text.snap b/dor/test/snapshots/ensure-text.snap index e18a2089..cb7fd75c 100644 --- a/dor/test/snapshots/ensure-text.snap +++ b/dor/test/snapshots/ensure-text.snap @@ -1,5 +1,5 @@ exitCode: 0 stdout: -existing surface:3 "dev server" +existing surface:3 "pnpm dev:workspace" stderr: diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index 1fa268da..6590a5c0 100644 --- a/dor/test/snapshots/help/dor.md +++ b/dor/test/snapshots/help/dor.md @@ -5,7 +5,7 @@ Invocation: `dor --help` ```text USAGE dor split [--left|--right|--up|--down|--auto] [--json] [--minimize] [--surface id|ref|index] [-- ...] - dor ensure [--json] [--minimize] [--surface id|ref|index] [--title value] -- ... + dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- ... dor version dor send [--key value] [--raw] [--sequence json] [--stdin] [--surface id|ref|index] [--text value] [] dor read [--json] [--lines count] [--scrollback] [--surface id|ref|index] @@ -23,7 +23,7 @@ FLAGS COMMANDS split Create a new terminal surface by splitting an existing surface. - ensure Ensure one surface exists for a user-enforced title. + ensure Ensure one surface is running a command. version Print the dor CLI version. send Send text or key input to a terminal surface. read Read terminal text from a surface. diff --git a/dor/test/snapshots/help/ensure.md b/dor/test/snapshots/help/ensure.md index a66e1ca0..2fe2b12a 100644 --- a/dor/test/snapshots/help/ensure.md +++ b/dor/test/snapshots/help/ensure.md @@ -9,7 +9,7 @@ USAGE Ensures one surface in the current workspace is running the given command at the given path. If it's already running, no-op. If it isn't, then it creates a split and runs the command. -Matching uses the command each shell reports it is running via Dormouse shell integration, not process inspection. This captures the typed command (`npm dev`), not the forked child process (`node .../vite`), and works for shells the user started by hand as well as shells Dormouse started. +Matching uses the command each shell reports it is running via Dormouse shell integration, not process inspection. This captures the typed command (`npm run dev`), not the forked child process (`node .../vite`), and works for shells the user started by hand as well as shells Dormouse started. The match is exact: `npm run dev` and `npm run dev --host` are different commands and get separate surfaces. Shells without the integration don't report their command, so ensure can't match them and starts a new surface every time. A surface matches only while the command is live. Once the command exits and the shell returns to its prompt, the surface no longer matches; the next ensure causes a fresh split rather than reusing the idle shell. Minimized surfaces participate in matching. Closed/killed surfaces do not. @@ -22,15 +22,15 @@ Two surfaces running the same command in different working directories are disti --surface selects the surface to split only when creating a new surface. If omitted, Dormouse uses the same caller/focused fallback as dor split. Text output: - created surface:3 "npm dev" - existing surface:3 "npm dev" + created surface:3 "npm run dev" + existing surface:3 "npm run dev" JSON output: { "status": "created", "surface_id": "pane-def", "surface_ref": "surface:3", - "command": "npm dev", + "command": "npm run dev", "cwd": "/Users/me/projects/site", "minimized": false } From 7f6ea505e353cfdc5f426e443d297c953e9fa0b2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 12 Jun 2026 14:59:38 -0700 Subject: [PATCH 6/8] Implement command+cwd matching for surface.ensure (host) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite the host-side surface.ensure handler to match the dor CLI's new contract: a surface matches when it is currently running the requested command in the requested cwd, instead of carrying a user-enforced title. - surfaceRunsCommand(): the match predicate — live currentCommand only, exact rawCommandLine, normalized cwd. Pure and unit-tested. - Thread --cwd through createSplitSurface so an explicit cwd (defaulting to the caller's directory) sets where the new command runs and is the cwd half of the key; drop the title path. - Seed launched commands: a `-lc`/`/c` command never loads the OSC 633 integration, so without help its pane reports nothing and ensure could not match a surface it just created. seedLaunchedCommand stamps the command run at spawn; clearing it on pty exit preserves liveness. Hand-started interactive shells already report via the injected OSC 633 integration, so they match without seeding. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/components/Wall.tsx | 35 ++++++++-------- lib/src/lib/terminal-lifecycle.ts | 11 ++++- lib/src/lib/terminal-state-store.test.ts | 22 +++++++++- lib/src/lib/terminal-state-store.ts | 20 ++++++++++ lib/src/lib/terminal-state.test.ts | 51 ++++++++++++++++++++++++ lib/src/lib/terminal-state.ts | 33 +++++++++++++++ lib/src/lib/terminal-store.ts | 2 + 7 files changed, 155 insertions(+), 19 deletions(-) diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 15191cab..d5fb5798 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -35,6 +35,7 @@ import { createTerminalPaneState, deriveHeader, resolveDisplayPrimary, + surfaceRunsCommand, } from '../lib/terminal-state'; import { orchestrateKill } from '../lib/kill-animation'; import { getPlatform, PLATFORM_STRING } from '../lib/platform'; @@ -94,6 +95,7 @@ type ShellSpawnNoticeState = { type DorControlParams = { command?: unknown; confirmation?: unknown; + cwd?: unknown; direction?: unknown; input?: unknown; inputCount?: unknown; @@ -101,7 +103,6 @@ type DorControlParams = { minimized?: unknown; pane?: string; surface?: unknown; - title?: unknown; url?: unknown; workspace?: string; window?: string; @@ -288,10 +289,6 @@ function spawnDirectionForDockview(direction: DockviewSplitDirection): SpawnDire return direction === 'above' || direction === 'below' ? 'top' : 'left'; } -function titleFromCommand(command: string): string { - return command.trim().replace(/\s+/g, ' '); -} - function validateUserTitle(title: string): string | null { const trimmed = title.trim(); if (!trimmed) return 'title cannot be empty'; @@ -816,12 +813,12 @@ export function Wall({ return { ok: false, message: `surface '${resolvedTarget}' was not found` }; }, [buildDorSurfaces]); - const findSurfaceIdByUserTitle = useCallback((title: string): string | null => { + const findSurfaceIdRunningCommand = useCallback((command: string, cwdPath: string): string | null => { const ids = [ ...(apiRef.current?.panels.map((panel) => panel.id) ?? []), ...doorsRef.current.map((door) => door.id), ]; - return ids.find((id) => getTerminalPaneState(id).titleCandidates.user?.title.trim() === title) ?? null; + return ids.find((id) => surfaceRunsCommand(getTerminalPaneState(id), command, cwdPath)) ?? null; }, []); const createSplitSurface = useCallback(({ @@ -830,12 +827,14 @@ export function Wall({ minimized, referenceId, title, + cwd, }: { command?: string; direction: DorResolvedSplitDirection; minimized: boolean; referenceId: string; title?: string; + cwd?: string; }): ParseResult<{ id: string; ref: string; @@ -852,8 +851,10 @@ export function Wall({ const newId = generatePaneId(); const defaults = getDefaultShellOpts(); + // An explicit cwd (dor ensure --cwd, defaulting to the caller's directory) + // wins; otherwise inherit the reference pane's local cwd as dor split does. const sourceCwd = getTerminalPaneState(referencePanel.id).cwd; - const inheritedCwd = sourceCwd && !sourceCwd.isRemote ? sourceCwd.path : undefined; + const inheritedCwd = cwd ?? (sourceCwd && !sourceCwd.isRemote ? sourceCwd.path : undefined); if (command) { setPendingShellOpts(newId, { @@ -862,6 +863,7 @@ export function Wall({ cwd: inheritedCwd, title, untouched: false, + command, }); } else if (defaults?.shell || inheritedCwd || title) { setPendingShellOpts(newId, { @@ -1127,13 +1129,12 @@ export function Wall({ detail.respond({ ok: false, error: 'command cannot be empty' }); return; } - const title = (stringParam(params.title)?.trim() || titleFromCommand(command)); - const titleError = validateUserTitle(title); - if (titleError) { - detail.respond({ ok: false, error: titleError }); + const cwd = stringParam(params.cwd)?.trim(); + if (!cwd) { + detail.respond({ ok: false, error: 'cwd is required' }); return; } - const existingId = findSurfaceIdByUserTitle(title); + const existingId = findSurfaceIdRunningCommand(command, cwd); if (existingId) { detail.respond({ ok: true, @@ -1141,8 +1142,8 @@ export function Wall({ status: 'existing', surfaceId: existingId, surfaceRef: surfaceRefForId(existingId), - title, command, + cwd, minimized: doorsRef.current.some((door) => door.id === existingId), }, }); @@ -1156,7 +1157,7 @@ export function Wall({ direction, minimized: booleanParam(params.minimized), referenceId: resolved.target.id, - title, + cwd, }); if (!result.ok) { detail.respond({ ok: false, error: result.message }); @@ -1168,8 +1169,8 @@ export function Wall({ status: 'created', surfaceId: result.value.id, surfaceRef: result.value.ref, - title, command, + cwd, minimized: booleanParam(params.minimized), }, }); @@ -1302,7 +1303,7 @@ export function Wall({ window.addEventListener('dormouse:control-request', handler); return () => window.removeEventListener('dormouse:control-request', handler); - }, [buildDorSurfaces, createIframeSurface, createSplitSurface, findSurfaceIdByUserTitle, killPaneImmediately, resolveVisibleSurface, surfaceRefForId]); + }, [buildDorSurfaces, createIframeSurface, createSplitSurface, findSurfaceIdRunningCommand, killPaneImmediately, resolveVisibleSurface, surfaceRefForId]); const addSplitPanel = useCallback(( id: string | null, diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 89b1a2b0..c609cb0f 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -32,10 +32,12 @@ import { getTerminalTheme, paintTerminalHost, startThemeObserver } from './termi import { ensureTerminalPaneState, fillTerminalProcessCwdByPtyId, + finishLaunchedCommandByPtyId, recordTerminalOutputByPtyId, recordTerminalUserInputByPtyId, removeTerminalPaneState, resetTerminalPaneState, + seedLaunchedCommand, seedPromptShapeFromScrollback, seedTerminalManualCwd, setTerminalUserTitle, @@ -161,7 +163,11 @@ function wirePtyEvents(id: string, terminal: Terminal): () => void { } }; const handleExit = (detail: { id: string; exitCode: number }) => { - if (detail.id === id) terminal.write(`\r\n[Process exited with code ${detail.exitCode}]\r\n`); + if (detail.id !== id) return; + terminal.write(`\r\n[Process exited with code ${detail.exitCode}]\r\n`); + // The process is gone, so any command we seeded for this pane is no longer + // live; clear it so `dor ensure` stops matching a dead surface. + finishLaunchedCommandByPtyId(id, detail.exitCode); }; platform.onPtyData(handleData); platform.onPtyExit(handleExit); @@ -321,6 +327,9 @@ export function getOrCreateTerminal(id: string): TerminalEntry { args: shellOpts?.args, cwd: shellOpts?.cwd, }); + if (shellOpts?.command) { + seedLaunchedCommand(id, shellOpts.command, shellOpts.cwd); + } seedProcessCwdAfterSpawn(id); return entry; diff --git a/lib/src/lib/terminal-state-store.test.ts b/lib/src/lib/terminal-state-store.test.ts index 5d2a1127..ba28c7d6 100644 --- a/lib/src/lib/terminal-state-store.test.ts +++ b/lib/src/lib/terminal-state-store.test.ts @@ -11,12 +11,13 @@ import { recordTerminalUserInputByPtyId, removeTerminalPaneState, resetTerminalPaneState, + seedLaunchedCommand, seedPromptShapeFromScrollback, seedTerminalManualCwd, setTerminalUserTitle, } from './terminal-state-store'; import { registry, type TerminalEntry } from './terminal-store'; -import { DEFAULT_IDLE_TITLE, UNNAMED_PANEL_TITLE } from './terminal-state'; +import { DEFAULT_IDLE_TITLE, surfaceRunsCommand, UNNAMED_PANEL_TITLE } from './terminal-state'; const PROMPT = 'user@host repo % '; @@ -276,3 +277,22 @@ describe('terminal command input via rendered buffer', () => { expect(getTerminalPaneState('pane').currentCommand).toBeNull(); }); }); + +describe('seedLaunchedCommand (dor split/ensure -lc launches)', () => { + afterEach(() => removeTerminalPaneState('launch')); + + it('reports a launched command so ensure can match it, then clears it on finish', () => { + seedLaunchedCommand('launch', 'pnpm dev:website', '/repo/app'); + + const live = getTerminalPaneState('launch'); + expect(live.currentCommand?.rawCommandLine).toBe('pnpm dev:website'); + expect(live.currentCommand?.cwdAtStart?.path).toBe('/repo/app'); + expect(surfaceRunsCommand(live, 'pnpm dev:website', '/repo/app')).toBe(true); + + applyTerminalSemanticEvents('launch', [{ type: 'commandFinish', exitCode: 0 }]); + + const done = getTerminalPaneState('launch'); + expect(done.currentCommand).toBeNull(); + expect(surfaceRunsCommand(done, 'pnpm dev:website', '/repo/app')).toBe(false); + }); +}); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index 809a5dc5..d8d1bdfe 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -161,6 +161,26 @@ export function recordTerminalUserInputByPtyId(ptyId: string, input: string, rea recordTerminalUserInput(resolvePaneStateIdByPtyId(ptyId), input, reader); } +// A command Dormouse launched into a pane (dor split/ensure `-- `) runs +// under a non-interactive `-lc` shell, so the OSC 633 integration never loads +// and neither the OSC path nor the keystroke heuristic ever reports it. Seed the +// command run ourselves at spawn so the pane reports what it's running — needed +// for `dor ensure` to match a surface it (or a prior ensure) created. Sourced as +// `user_input` so it does not mark the pane OSC-driven. `finishLaunchedCommand` +// (on pty exit) clears it, preserving the liveness half of the match. +export function seedLaunchedCommand(id: string, command: string, cwdPath?: string): void { + const events: TerminalSemanticEvent[] = []; + const cwd = cwdPath ? cwdFromManualPath(cwdPath) : null; + if (cwd) events.push({ type: 'cwd', cwd }); + events.push({ type: 'commandLine', commandLine: command }); + events.push({ type: 'commandStart', source: 'user_input' }); + applyTerminalSemanticEvents(id, events); +} + +export function finishLaunchedCommandByPtyId(ptyId: string, exitCode: number): void { + applyTerminalSemanticEventsByPtyId(ptyId, [{ type: 'commandFinish', exitCode }]); +} + export function recordTerminalOutput(id: string, output: string): void { if (!output) return; diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 583eb7b1..2048fd11 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -13,8 +13,10 @@ import { groupTerminalPanes, notificationDisplayTitle, reduceTerminalState, + sameCwdPath, shortestUniqueCwdLabels, summarizeCommandLine, + surfaceRunsCommand, terminalTitleFromNotification, titleCandidatesForDisplay, type CwdState, @@ -485,6 +487,55 @@ describe('header and grouping derivation', () => { }); }); +describe('surfaceRunsCommand (dor ensure matching)', () => { + it('matches a pane currently running the exact command in the same cwd', () => { + const pane = runningPane('/repo/app', 'pnpm dev:website'); + expect(surfaceRunsCommand(pane, 'pnpm dev:website', '/repo/app')).toBe(true); + }); + + it('is exact: a different command line does not match', () => { + const pane = runningPane('/repo/app', 'pnpm dev:website'); + expect(surfaceRunsCommand(pane, 'pnpm dev:website --host', '/repo/app')).toBe(false); + expect(surfaceRunsCommand(pane, 'pnpm dev', '/repo/app')).toBe(false); + }); + + it('does not match the same command in a different working directory', () => { + const pane = runningPane('/repo/app', 'pnpm dev:website'); + expect(surfaceRunsCommand(pane, 'pnpm dev:website', '/repo/other')).toBe(false); + }); + + it('only matches while the command is live', () => { + let pane = runningPane('/repo/app', 'pnpm dev:website'); + pane = reduceTerminalState(pane, { type: 'commandFinish', exitCode: 0 }); + expect(pane.currentCommand).toBeNull(); + expect(surfaceRunsCommand(pane, 'pnpm dev:website', '/repo/app')).toBe(false); + }); + + it('never matches a pane with no reported command line (no shell integration)', () => { + const pane = createTerminalPaneState({ + cwd: cwd('/repo/app'), + activity: { kind: 'running' }, + currentCommand: { + id: 'run-x', + rawCommandLine: null, + displayCommand: 'shell', + cwdAtStart: cwd('/repo/app'), + startedAt: 1, + source: 'osc133_boundaries', + }, + }); + expect(surfaceRunsCommand(pane, 'pnpm dev:website', '/repo/app')).toBe(false); + }); + + it('ignores a trailing separator on either side of the cwd', () => { + const pane = runningPane('/repo/app', 'pnpm dev:website'); + expect(surfaceRunsCommand(pane, 'pnpm dev:website', '/repo/app/')).toBe(true); + expect(sameCwdPath('/repo/app/', '/repo/app')).toBe(true); + expect(sameCwdPath('/', '/')).toBe(true); + expect(sameCwdPath('/a', '/b')).toBe(false); + }); +}); + function cwd(path: string, host?: string): CwdState { return { path, diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index b15cefa3..0d74a5f5 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -376,6 +376,39 @@ export function summarizeCommandLine(raw: string): string { return truncateCommandTitle(`${visibleTokens.join(' ')}${suffix}`); } +/** + * The idempotency predicate for `dor ensure`: true when the pane is *currently + * running* `command` in `cwdPath`. It matches only while the command is live + * (`currentCommand` is set between commandStart and commandFinish) and only on + * the exact command line the shell reported via integration — never the + * summarized display label, and never a forked child. Panes with no reported + * command line (no shell integration) never match. + */ +export function surfaceRunsCommand( + state: TerminalPaneState, + command: string, + cwdPath: string, +): boolean { + const run = state.currentCommand; + if (!run || run.rawCommandLine === null) return false; + if (run.rawCommandLine !== command) return false; + const runCwd = run.cwdAtStart?.path ?? state.cwd?.path; + return runCwd !== undefined && sameCwdPath(runCwd, cwdPath); +} + +/** Compare two absolute working-directory paths, ignoring a trailing separator. */ +export function sameCwdPath(a: string, b: string): boolean { + return normalizeCwdPath(a) === normalizeCwdPath(b); +} + +function normalizeCwdPath(path: string): string { + const trimmed = path.trim(); + if (trimmed.length > 1 && (trimmed.endsWith('/') || trimmed.endsWith('\\'))) { + return trimmed.slice(0, -1); + } + return trimmed; +} + export function deriveFallbackCommandTitle( state?: TerminalPaneState | null, options: { shellName?: string } = {}, diff --git a/lib/src/lib/terminal-store.ts b/lib/src/lib/terminal-store.ts index 383373bc..ce36ae7a 100644 --- a/lib/src/lib/terminal-store.ts +++ b/lib/src/lib/terminal-store.ts @@ -44,6 +44,8 @@ export interface PendingShellOpts { cwd?: string; title?: string; untouched?: boolean; + /** Raw command string launched via `-lc`/`/c`, seeded as the pane's command run. */ + command?: string; } export const registry = new Map(); From 42902f4df24a88d47ca51e1aa09da2cfe7477472 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 12 Jun 2026 15:21:59 -0700 Subject: [PATCH 7/8] Simplify: drop dead title path left by the ensure rewrite createSplitSurface's title parameter is no longer passed by any caller now that ensure keys on command+cwd, so remove it along with the orphaned validateUserTitle helper and isReservedUserTitle import. Correct the callerWorkingDirectory comment, which claimed symlink resolution that neither the CLI nor the host actually performs. Co-Authored-By: Claude Opus 4.8 (1M context) --- dor/src/commands/ensure.ts | 2 +- lib/src/components/Wall.tsx | 27 ++------------------------- 2 files changed, 3 insertions(+), 26 deletions(-) diff --git a/dor/src/commands/ensure.ts b/dor/src/commands/ensure.ts index 6b82a585..25fba9c5 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -118,7 +118,7 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... // The host has no idea where `dor` was launched, so the caller's directory must // travel in the request. Prefer the shell's PWD (injectable, matches what the // user sees) and fall back to the process cwd. A relative --cwd resolves against -// that base; the host normalizes symlinks before keying. +// that base into an absolute path the host can key on. function callerWorkingDirectory(flag: string | undefined, env: CliEnv | undefined): string { const base = env?.PWD ?? process.cwd(); return flag === undefined ? base : resolvePath(base, flag); diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index d5fb5798..d58556ab 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -25,7 +25,6 @@ import { isUntouched, getOrCreateTerminal, getTerminalInstance, - isReservedUserTitle, setTerminalUserTitle, UNNAMED_PANEL_TITLE, type SessionStatus, @@ -289,13 +288,6 @@ function spawnDirectionForDockview(direction: DockviewSplitDirection): SpawnDire return direction === 'above' || direction === 'below' ? 'top' : 'left'; } -function validateUserTitle(title: string): string | null { - const trimmed = title.trim(); - if (!trimmed) return 'title cannot be empty'; - if (isReservedUserTitle(trimmed)) return 'title is reserved'; - return null; -} - /** * Quote a raw argv into a single command string for the target pane's shell. * This is the one place the command is quoted; the CLI sends argv unquoted @@ -826,14 +818,12 @@ export function Wall({ direction, minimized, referenceId, - title, cwd, }: { command?: string; direction: DorResolvedSplitDirection; minimized: boolean; referenceId: string; - title?: string; cwd?: string; }): ParseResult<{ id: string; @@ -844,11 +834,6 @@ export function Wall({ const referencePanel = api.getPanel(referenceId); if (!referencePanel) return { ok: false, message: `surface '${referenceId}' is not visible` }; - if (title) { - const titleError = validateUserTitle(title); - if (titleError) return { ok: false, message: titleError }; - } - const newId = generatePaneId(); const defaults = getDefaultShellOpts(); // An explicit cwd (dor ensure --cwd, defaulting to the caller's directory) @@ -861,35 +846,27 @@ export function Wall({ shell: defaults?.shell, args: commandShellArgs(defaults?.shell, command), cwd: inheritedCwd, - title, untouched: false, command, }); - } else if (defaults?.shell || inheritedCwd || title) { + } else if (defaults?.shell || inheritedCwd) { setPendingShellOpts(newId, { shell: defaults?.shell, args: defaults?.args, cwd: inheritedCwd, - title, }); } - if (title) { - const result = setTerminalUserTitle(newId, title); - if (!result.accepted) return { ok: false, message: `title is ${result.reason}` }; - } - const dockDirection = dockviewDirectionForDor(direction); freshlySpawnedRef.current.set(newId, spawnDirectionForDockview(dockDirection)); api.addPanel({ id: newId, component: 'terminal', tabComponent: 'terminal', - title: title ?? UNNAMED_PANEL_TITLE, + title: UNNAMED_PANEL_TITLE, position: { referencePanel: referencePanel.id, direction: dockDirection }, }); selectPane(newId); - if (title) api.getPanel(newId)?.api.setTitle(title); onEventRef.current?.({ type: 'split', direction: direction === 'left' || direction === 'right' ? 'horizontal' : 'vertical', From 52b414be269d07fdb69fc873d7f22903c169c5dc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 12 Jun 2026 15:37:43 -0700 Subject: [PATCH 8/8] Match cwd exactly instead of trimming trailing slashes Trailing-slash normalization was a half-measure: path.resolve in the CLI already collapses trailing slashes (and `.`/`..`), and it never addressed the real divergences (symlinks, case). Drop sameCwdPath/normalizeCwdPath and compare cwd exactly, matching the command half. Canonicalize the default cwd through resolvePath too so what the CLI sends is always absolute, and correct the help to say symlinks are not resolved. Co-Authored-By: Claude Opus 4.8 (1M context) --- dor/src/commands/ensure.ts | 9 +++++---- dor/test/snapshots/help/ensure.md | 2 +- lib/src/lib/terminal-state.test.ts | 9 +++------ lib/src/lib/terminal-state.ts | 19 +++++-------------- 4 files changed, 14 insertions(+), 25 deletions(-) diff --git a/dor/src/commands/ensure.ts b/dor/src/commands/ensure.ts index 25fba9c5..5b3915cb 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -56,7 +56,7 @@ A surface matches only while the command is live. Once the command exits and the Two surfaces running the same command in different working directories are distinct (e.g. the same dev server in two worktrees). Both keep running; ensure never collapses them. ---cwd sets the working directory used both for matching and for the new command. If omitted, Dormouse uses the directory dor was invoked from. The path is normalized (symlinks resolved) before it becomes part of the key. +--cwd sets the working directory used both for matching and for the new command. If omitted, Dormouse uses the directory dor was invoked from. The path is resolved to an absolute path and matched exactly; symlinks are not resolved, so two routes to the same directory are treated as distinct. --minimize applies only when creating a new surface; it does not minimize an existing match. @@ -117,11 +117,12 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... // The host has no idea where `dor` was launched, so the caller's directory must // travel in the request. Prefer the shell's PWD (injectable, matches what the -// user sees) and fall back to the process cwd. A relative --cwd resolves against -// that base into an absolute path the host can key on. +// user sees) and fall back to the process cwd. resolvePath canonicalizes both +// the default and a relative/absolute --cwd into one absolute path the host can +// key on with an exact compare. function callerWorkingDirectory(flag: string | undefined, env: CliEnv | undefined): string { const base = env?.PWD ?? process.cwd(); - return flag === undefined ? base : resolvePath(base, flag); + return resolvePath(base, flag ?? '.'); } function renderEnsureResponse(response: EnsureSurfaceResponse, json: boolean): string { diff --git a/dor/test/snapshots/help/ensure.md b/dor/test/snapshots/help/ensure.md index 2fe2b12a..abb57175 100644 --- a/dor/test/snapshots/help/ensure.md +++ b/dor/test/snapshots/help/ensure.md @@ -15,7 +15,7 @@ A surface matches only while the command is live. Once the command exits and the Two surfaces running the same command in different working directories are distinct (e.g. the same dev server in two worktrees). Both keep running; ensure never collapses them. ---cwd sets the working directory used both for matching and for the new command. If omitted, Dormouse uses the directory dor was invoked from. The path is normalized (symlinks resolved) before it becomes part of the key. +--cwd sets the working directory used both for matching and for the new command. If omitted, Dormouse uses the directory dor was invoked from. The path is resolved to an absolute path and matched exactly; symlinks are not resolved, so two routes to the same directory are treated as distinct. --minimize applies only when creating a new surface; it does not minimize an existing match. diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 2048fd11..fc10f8fe 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -13,7 +13,6 @@ import { groupTerminalPanes, notificationDisplayTitle, reduceTerminalState, - sameCwdPath, shortestUniqueCwdLabels, summarizeCommandLine, surfaceRunsCommand, @@ -527,12 +526,10 @@ describe('surfaceRunsCommand (dor ensure matching)', () => { expect(surfaceRunsCommand(pane, 'pnpm dev:website', '/repo/app')).toBe(false); }); - it('ignores a trailing separator on either side of the cwd', () => { + it('compares the cwd exactly (the CLI sends a canonicalized path)', () => { const pane = runningPane('/repo/app', 'pnpm dev:website'); - expect(surfaceRunsCommand(pane, 'pnpm dev:website', '/repo/app/')).toBe(true); - expect(sameCwdPath('/repo/app/', '/repo/app')).toBe(true); - expect(sameCwdPath('/', '/')).toBe(true); - expect(sameCwdPath('/a', '/b')).toBe(false); + expect(surfaceRunsCommand(pane, 'pnpm dev:website', '/repo/app')).toBe(true); + expect(surfaceRunsCommand(pane, 'pnpm dev:website', '/repo/app/')).toBe(false); }); }); diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 0d74a5f5..2e5d7e5f 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -392,21 +392,12 @@ export function surfaceRunsCommand( const run = state.currentCommand; if (!run || run.rawCommandLine === null) return false; if (run.rawCommandLine !== command) return false; + // Exact compare, matching the command half. The CLI sends a path.resolve'd cwd + // (trailing slashes, `..`, `.` already collapsed), so there's nothing further + // to normalize here — and trailing-slash trimming wouldn't address the real + // divergences (symlinks, case) anyway. const runCwd = run.cwdAtStart?.path ?? state.cwd?.path; - return runCwd !== undefined && sameCwdPath(runCwd, cwdPath); -} - -/** Compare two absolute working-directory paths, ignoring a trailing separator. */ -export function sameCwdPath(a: string, b: string): boolean { - return normalizeCwdPath(a) === normalizeCwdPath(b); -} - -function normalizeCwdPath(path: string): string { - const trimmed = path.trim(); - if (trimmed.length > 1 && (trimmed.endsWith('/') || trimmed.endsWith('\\'))) { - return trimmed.slice(0, -1); - } - return trimmed; + return runCwd === cwdPath; } export function deriveFallbackCommandTitle(