From ccb0696e6fd5bcf064bbd397e6087f516d66663f Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 19 Jun 2026 12:49:54 -0700 Subject: [PATCH 1/7] dor ensure: add --restart to interrupt and re-run a matching surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dor ensure --restart -- ` now restarts a surface that is already running the command instead of no-opping. The host interrupts the live command (Ctrl+C), waits for the shell to return to its prompt, types the command again, and waits for it to go live — driving the PTY directly so it works for minimized doors too. The CLI blocks until the restart completes (with a bumped 60s request timeout for that one command). With no matching surface, --restart behaves like a plain ensure and creates one. Co-Authored-By: Claude Opus 4.8 (1M context) --- dor/src/cli.ts | 2 +- dor/src/commands/ensure.ts | 21 ++++++-- dor/src/commands/shared.ts | 10 ++-- dor/src/commands/types.ts | 4 +- dor/test/cli-output.test.mjs | 16 +++++- dor/test/snapshots/ensure-restart.snap | 5 ++ dor/test/snapshots/help/dor.md | 2 +- dor/test/snapshots/help/ensure.md | 6 ++- lib/src/components/Wall.tsx | 75 ++++++++++++++++++++++++++ 9 files changed, 128 insertions(+), 13 deletions(-) create mode 100644 dor/test/snapshots/ensure-restart.snap diff --git a/dor/src/cli.ts b/dor/src/cli.ts index 9a0c3516..4b67251b 100644 --- a/dor/src/cli.ts +++ b/dor/src/cli.ts @@ -319,7 +319,7 @@ function validateEnsureDelimiter(args: string[]): ParseResult { for (let index = 0; index < delimiterIndex; index += 1) { const arg = args[index]; - if (arg === '--json' || arg === '--minimize') { + if (arg === '--json' || arg === '--minimize' || arg === '--restart') { continue; } if (arg === '--cwd' || arg === '--surface') { diff --git a/dor/src/commands/ensure.ts b/dor/src/commands/ensure.ts index 5b3915cb..1ccb0d95 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -19,25 +19,31 @@ import { interface EnsureFlags { readonly json?: boolean; readonly minimize?: boolean; + readonly restart?: boolean; readonly surface?: string; readonly cwd?: string; } +// `--restart` makes the host block until a server is interrupted and respawned, +// which can outlast the client's default 5s request timeout. Give that one +// command plenty of headroom; everything else keeps the snappy default. +const RESTART_TIMEOUT_MS = 60_000; + export const ensureCommand: Command = { name: 'ensure', helpPatches: [ { scope: 'root', findReplace: [ - ' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path]', - ' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- ...\n', + ' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path]', + ' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- ...\n', ], }, { scope: 'command-usage', findReplace: [ - ' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path]', - ' dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- ...\n', + ' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path]', + ' dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- ...\n', ], }, { @@ -60,11 +66,14 @@ Two surfaces running the same command in different working directories are disti --minimize applies only when creating a new surface; it does not minimize an existing match. +--restart applies only to an already-running match: it interrupts the live command (Ctrl+C), waits for the shell to return to its prompt, then re-runs the command in place and blocks until the command is live again. A restarted surface keeps its minimized/visible state. If no surface is running the command, --restart behaves like a plain ensure and creates one. + --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 run dev" existing surface:3 "npm run dev" + restarted surface:3 "npm run dev" JSON output: { @@ -80,6 +89,7 @@ JSON output: flags: { json: { kind: 'boolean', brief: 'Print JSON output.', optional: true, withNegated: false }, minimize: { kind: 'boolean', brief: 'Create the surface minimized.', optional: true, withNegated: false }, + restart: { kind: 'boolean', brief: 'Restart a matching surface in place.', optional: true, withNegated: false }, surface: { kind: 'parsed', parse: stringParser, brief: 'Surface to split when creating.', optional: true, placeholder: 'id|ref|index' }, cwd: { kind: 'parsed', parse: stringParser, brief: 'Working directory for matching and for the new command.', optional: true, placeholder: 'path' }, }, @@ -98,13 +108,14 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... return new Error('dor ensure requires a command after --'); } - const client = requireControlClient(this.options); + const client = requireControlClient(this.options, flags.restart === true ? RESTART_TIMEOUT_MS : undefined); if (client instanceof Error) return client; try { const response = await client.ensureSurface({ command: commandArgs, minimized: flags.minimize === true, + restart: flags.restart === true, surface: flags.surface, cwd: callerWorkingDirectory(flags.cwd, this.options.env), }); diff --git a/dor/src/commands/shared.ts b/dor/src/commands/shared.ts index 04b0cd15..97947b1c 100644 --- a/dor/src/commands/shared.ts +++ b/dor/src/commands/shared.ts @@ -31,7 +31,7 @@ export function parseIdFormat(value: string): IdFormat { throw new SyntaxError(`invalid --id-format '${value}'`); } -function resolveControlClient(options: CliOptions): ParseResult { +function resolveControlClient(options: CliOptions, timeoutMs?: number): ParseResult { if (options.client) return { ok: true, value: options.client }; const env = options.env ?? {}; @@ -47,12 +47,16 @@ function resolveControlClient(options: CliOptions): ParseResult { socketPath, token, surfaceId: env.DORMOUSE_SURFACE_ID, + ...(timeoutMs === undefined ? {} : { timeoutMs }), }), }; } -export function requireControlClient(options: CliOptions): ControlClient | Error { - const result = resolveControlClient(options); +// `timeoutMs` overrides the client's default request timeout for commands that +// intentionally block the host (e.g. `dor ensure --restart` waits for a server +// to die and respawn). Ignored when a client is injected (tests). +export function requireControlClient(options: CliOptions, timeoutMs?: number): ControlClient | Error { + const result = resolveControlClient(options, timeoutMs); return result.ok ? result.value : new Error(result.message); } diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index b0a4e0fc..9ee78d29 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -53,13 +53,15 @@ export interface EnsureSurfaceRequest { /** Raw argv for the command; the host quotes it for the target shell. */ command: string[]; minimized: boolean; + /** Interrupt and re-run a matching surface in place instead of reusing it. */ + restart: boolean; surface?: string; /** Working directory for matching and for the new command; part of the idempotency key. */ cwd: string; } export interface EnsureSurfaceResponse { - status: 'created' | 'existing'; + status: 'created' | 'existing' | 'restarted'; surfaceId?: string; surfaceRef: string; command: string; diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index bf7bc886..ae4dae42 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -73,8 +73,9 @@ function fixtureClient(surfacesFixture = fixtureSurfaces) { // 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 isExisting = command === 'pnpm dev:workspace'; return { - status: command === 'pnpm dev:workspace' ? 'existing' : 'created', + status: isExisting ? (request.restart ? 'restarted' : 'existing') : 'created', surfaceId: '33333333-3333-4333-8333-333333333333', surfaceRef: 'surface:3', command, @@ -239,12 +240,25 @@ test('ensure sends command argv and caller cwd to the host', async () => { request: { command: ['pnpm', 'dev'], minimized: false, + restart: false, surface: undefined, cwd: '/work/site', }, }]); }); +test('ensure --restart restarts a matching surface in place', async () => { + const client = fixtureClient(); + await snapshot( + 'ensure-restart', + await runCli(['ensure', '--restart', '--', 'pnpm', 'dev:workspace'], { + client, + env: { PWD: '/work/site' }, + }), + ); + assert.equal(client.requests[0].request.restart, true); +}); + test('ensure json output', async () => { await snapshot( 'ensure-json', diff --git a/dor/test/snapshots/ensure-restart.snap b/dor/test/snapshots/ensure-restart.snap new file mode 100644 index 00000000..80aea84f --- /dev/null +++ b/dor/test/snapshots/ensure-restart.snap @@ -0,0 +1,5 @@ +exitCode: 0 +stdout: +restarted surface:3 "pnpm dev:workspace" + +stderr: diff --git a/dor/test/snapshots/help/dor.md b/dor/test/snapshots/help/dor.md index 44a91e2d..f2ce37da 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] [--cwd path] -- ... + dor ensure [--json] [--minimize] [--restart] [--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] diff --git a/dor/test/snapshots/help/ensure.md b/dor/test/snapshots/help/ensure.md index abb57175..ac5b8950 100644 --- a/dor/test/snapshots/help/ensure.md +++ b/dor/test/snapshots/help/ensure.md @@ -4,7 +4,7 @@ Invocation: `dor ensure --help` ```text USAGE - dor ensure [--json] [--minimize] [--surface id|ref|index] [--cwd path] -- ... + dor ensure [--json] [--minimize] [--restart] [--surface id|ref|index] [--cwd path] -- ... dor ensure --help 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. @@ -19,11 +19,14 @@ Two surfaces running the same command in different working directories are disti --minimize applies only when creating a new surface; it does not minimize an existing match. +--restart applies only to an already-running match: it interrupts the live command (Ctrl+C), waits for the shell to return to its prompt, then re-runs the command in place and blocks until the command is live again. A restarted surface keeps its minimized/visible state. If no surface is running the command, --restart behaves like a plain ensure and creates one. + --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 run dev" existing surface:3 "npm run dev" + restarted surface:3 "npm run dev" JSON output: { @@ -38,6 +41,7 @@ JSON output: FLAGS [--json] Print JSON output. [--minimize] Create the surface minimized. + [--restart] Restart a matching surface in place. [--surface] Surface to split when creating. [--cwd] Working directory for matching and for the new command. -h --help Print help information and exit diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 503798a6..6e87a3d6 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -35,6 +35,7 @@ import { createTerminalPaneState, deriveSurfaceLabel, surfaceRunsCommand, + type TerminalPaneState, } from '../lib/terminal-state'; import { orchestrateKill } from '../lib/kill-animation'; import { getPlatform, PLATFORM_STRING } from '../lib/platform'; @@ -103,6 +104,7 @@ type DorControlParams = { key?: unknown; lines?: unknown; minimized?: unknown; + restart?: unknown; binaryPath?: unknown; pane?: string; session?: unknown; @@ -247,6 +249,60 @@ function readSurfaceText(surfaceId: string, lines: number | undefined, scrollbac return limitLines(collected.join('\n').replace(/\n+$/, ''), lines); } +// `dor ensure --restart` blocks the CLI while we interrupt a live command and +// re-run it. Rather than guess at timings, poll the integration-derived +// 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; +const RESTART_START_TIMEOUT_MS = 15_000; + +/** Resolve true once `predicate` holds for the surface's live state, false on timeout. */ +function waitForTerminalState( + id: string, + predicate: (state: TerminalPaneState) => boolean, + timeoutMs: number, +): Promise { + if (predicate(getTerminalPaneState(id))) return Promise.resolve(true); + return new Promise((resolve) => { + let elapsed = 0; + const timer = setInterval(() => { + if (predicate(getTerminalPaneState(id))) { + clearInterval(timer); + resolve(true); + } else if ((elapsed += RESTART_POLL_INTERVAL_MS) >= timeoutMs) { + clearInterval(timer); + resolve(false); + } + }, RESTART_POLL_INTERVAL_MS); + }); +} + +/** + * Restart a surface already running `command` in `cwd`: interrupt it (Ctrl+C), + * wait for the shell to return to its prompt, type the command again, and wait + * for it to go live. Drives the live PTY directly, so it works for minimized + * doors too (their PTY keeps running). Returns a message on failure. + */ +async function restartSurfaceInPlace(id: string, command: string, cwd: string): Promise> { + const platform = getPlatform(); + platform.writePty(id, '\x03'); + const interrupted = await waitForTerminalState( + id, + (state) => state.currentCommand === null, + RESTART_INTERRUPT_TIMEOUT_MS, + ); + if (!interrupted) return { ok: false, message: 'did not return to a prompt after interrupt' }; + platform.writePty(id, `${command}\r`); + const restarted = await waitForTerminalState( + id, + (state) => surfaceRunsCommand(state, command, cwd), + RESTART_START_TIMEOUT_MS, + ); + if (!restarted) return { ok: false, message: 'command did not restart' }; + return { ok: true, value: undefined }; +} + function killConfirmationParam(value: unknown): { mode: 'if-read'; text: string } | { mode: 'dangerously' } | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const confirmation = value as { mode?: unknown; text?: unknown }; @@ -1155,6 +1211,25 @@ export function Wall({ } const existingId = findSurfaceIdRunningCommand(command, cwd); if (existingId) { + if (booleanParam(params.restart)) { + const restarted = await restartSurfaceInPlace(existingId, command, cwd); + if (!restarted.ok) { + detail.respond({ ok: false, error: `surface '${surfaceRefForId(existingId)}' ${restarted.message}` }); + return; + } + detail.respond({ + ok: true, + result: { + status: 'restarted', + surfaceId: existingId, + surfaceRef: surfaceRefForId(existingId), + command, + cwd, + minimized: doorsRef.current.some((door) => door.id === existingId), + }, + }); + return; + } detail.respond({ ok: true, result: { From c9fdaad0eab2cced9dd2ccf38d138f375b4d4620 Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 19 Jun 2026 13:03:20 -0700 Subject: [PATCH 2/7] dor ensure: hoist shared minimized computation in restart branch Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/components/Wall.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 6e87a3d6..c822dd0f 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -1211,6 +1211,7 @@ export function Wall({ } const existingId = findSurfaceIdRunningCommand(command, cwd); if (existingId) { + const minimized = doorsRef.current.some((door) => door.id === existingId); if (booleanParam(params.restart)) { const restarted = await restartSurfaceInPlace(existingId, command, cwd); if (!restarted.ok) { @@ -1225,7 +1226,7 @@ export function Wall({ surfaceRef: surfaceRefForId(existingId), command, cwd, - minimized: doorsRef.current.some((door) => door.id === existingId), + minimized, }, }); return; @@ -1238,7 +1239,7 @@ export function Wall({ surfaceRef: surfaceRefForId(existingId), command, cwd, - minimized: doorsRef.current.some((door) => door.id === existingId), + minimized, }, }); return; From 380296b59e08f092be7ce38ce5c237bcf0a775c3 Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 19 Jun 2026 15:58:23 -0700 Subject: [PATCH 3/7] dor ensure: type command into a real shell instead of `shell -c` Spawn a real interactive shell for `dor split/ensure -- ` and type the command into it once it reaches a prompt, rather than launching `shell -c command`. A `-c` invocation has no prompt behind it, so `dor ensure --restart`'s Ctrl+C would take the shell down with the command instead of returning to a re-runnable prompt. typeCommandWhenPromptReady waits for the seeded currentCommand to clear (the first-prompt signal) before writing the keystrokes, falling back to a best-effort send on timeout. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/components/Wall.tsx | 20 +++++++------------ lib/src/lib/terminal-lifecycle.ts | 30 +++++++++++++++++++++++++++++ lib/src/lib/terminal-state-store.ts | 20 ++++++++++++------- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index c822dd0f..9acb2ad3 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -351,18 +351,6 @@ function dorCommandString(args: string[] | undefined): string | undefined { 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[] { - switch (shellCommandKind(shell, PLATFORM_STRING)) { - case 'cmd': - return ['/d', '/s', '/c', command]; - case 'powershell': - return ['-NoLogo', '-NoProfile', '-Command', command]; - case 'posix': - return ['-lc', command]; - } -} - function ShellSpawnNotice({ notice, paneElements, @@ -910,9 +898,15 @@ export function Wall({ const inheritedCwd = cwd ?? (sourceCwd && !sourceCwd.isRemote ? sourceCwd.path : undefined); if (command) { + // Spawn a real interactive shell and type the command into it once it + // reaches a prompt (see typeCommandWhenPromptReady in the lifecycle), rather + // than launching `shell -c command`. A `-c` invocation has no prompt behind + // it: the command *is* the shell's whole job, so `dor ensure --restart`'s + // Ctrl+C would interrupt the command and take the shell down with it (the + // pty exits) instead of returning to a prompt the command can be re-run at. setPendingShellOpts(newId, { shell: defaults?.shell, - args: commandShellArgs(defaults?.shell, command), + args: defaults?.args, cwd: inheritedCwd, untouched: false, command, diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index 3714182a..966cad33 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -33,6 +33,7 @@ import { ensureTerminalPaneState, fillTerminalProcessCwdByPtyId, finishLaunchedCommandByPtyId, + getTerminalPaneState, recordTerminalOutputByPtyId, recordTerminalUserInputByPtyId, removeTerminalPaneState, @@ -307,6 +308,34 @@ export function setPendingShellOpts(id: string, opts: PendingShellOpts): void { pendingShellOpts.set(id, opts); } +const LAUNCH_PROMPT_POLL_MS = 100; +const LAUNCH_PROMPT_TIMEOUT_MS = 15_000; + +// `dor split/ensure -- ` spawns a real interactive shell (see +// createSplitSurface) and types the command into it, rather than running +// `shell -c command`. That leaves a live shell behind the command, so +// `dor ensure --restart` can Ctrl+C the command and have the shell survive and +// return to a prompt instead of the whole pty exiting. But we must wait for the +// shell to actually reach a prompt first, or the keystrokes land in shell +// startup. seedLaunchedCommand primed currentCommand with the command; it clears +// the moment the shell draws its first prompt (integration promptStart, or the +// keystroke heuristic for shells without it) — that's the signal it can take +// input. On timeout we send it anyway as a best effort rather than drop it. +function typeCommandWhenPromptReady(id: string, command: string): void { + let elapsed = 0; + const timer = setInterval(() => { + if (!registry.has(id)) { + clearInterval(timer); + return; + } + const ready = getTerminalPaneState(id).currentCommand === null; + if (ready || (elapsed += LAUNCH_PROMPT_POLL_MS) >= LAUNCH_PROMPT_TIMEOUT_MS) { + clearInterval(timer); + getPlatform().writePty(id, `${command}\r`); + } + }, LAUNCH_PROMPT_POLL_MS); +} + export function getOrCreateTerminal(id: string): TerminalEntry { const existing = registry.get(id); if (existing) return existing; @@ -329,6 +358,7 @@ export function getOrCreateTerminal(id: string): TerminalEntry { }); if (shellOpts?.command) { seedLaunchedCommand(id, shellOpts.command, shellOpts.cwd); + typeCommandWhenPromptReady(id, shellOpts.command); } seedProcessCwdAfterSpawn(id); diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index d8d1bdfe..d1fdeafe 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -161,13 +161,19 @@ 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. +// `dor split/ensure -- ` spawns a real interactive shell and types the +// command into it once it reaches a prompt (see typeCommandWhenPromptReady), +// rather than running `shell -c command`. We seed that command here at spawn, +// before it is typed, for two reasons. First, it is the readiness sentinel: +// typeCommandWhenPromptReady waits for this currentCommand to clear, which +// happens when the shell draws its first prompt (OSC promptStart, or the +// keystroke heuristic's prompt detector for shells without integration) — the +// signal the shell can take input. Second, it bridges the matching window until +// the command is typed and the integration re-reports it via OSC 633, so +// `dor ensure` can match a surface it (or a prior ensure) created. Sourced as +// `user_input` so it does not mark the pane OSC-driven and so the first-prompt +// detector treats it as a finished command and clears it. `finishLaunchedCommand` +// (on pty exit) also 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; From ee3eaa5488e8d02946a8bc2d73d53c984fa4d042 Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 19 Jun 2026 16:14:11 -0700 Subject: [PATCH 4/7] dor ensure: warn when a created surface lacks OSC 633 integration `dor ensure -- ` types the command into a real interactive shell rather than running `shell -c command`. That injection bypasses the keystroke heuristic, so only a shell whose integration emits OSC 633 boundaries ever reports the command back. Without it the surface can never be matched, so each ensure silently spawns a duplicate (and --restart can never reuse it). When ensure creates a surface, the host now waits for the new shell to report OSC 633 and, if it never does, returns a `warning` the CLI prints to stderr (stdout stays clean). Matched/existing surfaces still respond instantly; only a non-integration shell waits out the detection window, so the plain-ensure client timeout is raised to outlast it. Co-Authored-By: Claude Opus 4.8 --- dor/src/commands/ensure.ts | 16 +++++++++++++--- dor/src/commands/shared.ts | 4 ++++ dor/src/commands/types.ts | 3 +++ dor/test/cli-output.test.mjs | 23 +++++++++++++++++++++++ dor/test/snapshots/help/ensure.md | 2 +- lib/src/components/Wall.tsx | 20 ++++++++++++++++++++ lib/src/lib/terminal-registry.ts | 1 + lib/src/lib/terminal-state-store.ts | 9 +++++++++ 8 files changed, 74 insertions(+), 4 deletions(-) diff --git a/dor/src/commands/ensure.ts b/dor/src/commands/ensure.ts index 1ccb0d95..2704878e 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -13,6 +13,7 @@ import { renderJson, requireControlClient, stringParser, + writeStderr, writeStdout, } from './shared.js'; @@ -26,9 +27,15 @@ interface EnsureFlags { // `--restart` makes the host block until a server is interrupted and respawned, // which can outlast the client's default 5s request timeout. Give that one -// command plenty of headroom; everything else keeps the snappy default. +// command plenty of headroom. const RESTART_TIMEOUT_MS = 60_000; +// When ensure *creates* a surface, the host waits for the new shell to report OSC +// 633 integration so it can warn if the command won't be trackable. That wait can +// run to ~15s on a shell that never integrates, outlasting the default 5s. Matched +// surfaces still respond instantly; this only raises the ceiling for the slow case. +const ENSURE_TIMEOUT_MS = 20_000; + export const ensureCommand: Command = { name: 'ensure', helpPatches: [ @@ -56,7 +63,7 @@ export const ensureCommand: Command = { 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. -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. +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. When ensure creates a surface whose shell turns out to lack integration, it prints a warning to stderr — that surface can never be matched or restarted on a later call, so repeated ensures will keep spawning duplicates. 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. @@ -108,7 +115,7 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... return new Error('dor ensure requires a command after --'); } - const client = requireControlClient(this.options, flags.restart === true ? RESTART_TIMEOUT_MS : undefined); + const client = requireControlClient(this.options, flags.restart === true ? RESTART_TIMEOUT_MS : ENSURE_TIMEOUT_MS); if (client instanceof Error) return client; try { @@ -119,6 +126,9 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... surface: flags.surface, cwd: callerWorkingDirectory(flags.cwd, this.options.env), }); + if (response.warning) { + writeStderr(this, `warning: ${response.warning}\n`); + } writeStdout(this, renderEnsureResponse(response, flags.json === true)); return undefined; } catch (error) { diff --git a/dor/src/commands/shared.ts b/dor/src/commands/shared.ts index 97947b1c..072d5d01 100644 --- a/dor/src/commands/shared.ts +++ b/dor/src/commands/shared.ts @@ -82,3 +82,7 @@ export function wantsIds(idFormat: IdFormat): boolean { export function writeStdout(context: DorCommandContext, stdout: string): void { context.process.stdout.write(stdout); } + +export function writeStderr(context: DorCommandContext, stderr: string): void { + context.process.stderr.write(stderr); +} diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index 9ee78d29..0c14b36e 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -67,6 +67,9 @@ export interface EnsureSurfaceResponse { command: string; cwd: string; minimized: boolean; + /** Advisory the host attaches when the surface can't be tracked (e.g. its shell + * has no OSC 633 integration). Printed to stderr; never affects stdout. */ + warning?: string; } export interface SendSurfaceRequest { diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index ae4dae42..3e7d57b2 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -259,6 +259,29 @@ test('ensure --restart restarts a matching surface in place', async () => { assert.equal(client.requests[0].request.restart, true); }); +test('ensure prints a host warning to stderr, leaving stdout clean', async () => { + const client = { + requests: [], + async ensureSurface(request) { + this.requests.push({ method: 'ensureSurface', request }); + return { + status: 'created', + surfaceId: '33333333-3333-4333-8333-333333333333', + surfaceRef: 'surface:3', + command: 'pnpm dev', + cwd: request.cwd, + minimized: false, + warning: 'surface:3 has no Dormouse shell integration (OSC 633), so dor ensure can\'t detect its command.', + }; + }, + }; + const result = await runCli(['ensure', '--', 'pnpm', 'dev'], { client, env: { PWD: '/work/site' } }); + assert.equal(result.exitCode, 0); + assert.match(result.stderr, /^warning: surface:3 has no Dormouse shell integration/); + assert.equal(result.stdout, 'created surface:3 "pnpm dev"\n'); + assert.doesNotMatch(result.stdout, /warning/); +}); + test('ensure json output', async () => { await snapshot( 'ensure-json', diff --git a/dor/test/snapshots/help/ensure.md b/dor/test/snapshots/help/ensure.md index ac5b8950..3262057a 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 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. +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. When ensure creates a surface whose shell turns out to lack integration, it prints a warning to stderr — that surface can never be matched or restarted on a later call, so repeated ensures will keep spawning duplicates. 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. diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 3d28a110..36d276db 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -24,6 +24,7 @@ import { getDefaultShellOpts, getTerminalPaneState, getTerminalPaneStateSnapshot, + isPaneOscDriven, getActivitySnapshot, isUntouched, getOrCreateTerminal, @@ -329,6 +330,20 @@ async function restartSurfaceInPlace(id: string, command: string, cwd: string): return { ok: true, value: undefined }; } +// 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. Wait for that signal so +// the CLI can warn when it's absent. Resolves the moment integration is detected; +// only a non-integration shell waits out the timeout, which the CLI's create-path +// request budget (ENSURE_TIMEOUT_MS / RESTART_TIMEOUT_MS) is sized to outlast. +const INTEGRATION_DETECT_TIMEOUT_MS = 15_000; + +async function missingIntegrationWarning(id: string, ref: string): Promise { + const integrated = await waitForTerminalState(id, () => isPaneOscDriven(id), INTEGRATION_DETECT_TIMEOUT_MS); + if (integrated) return undefined; + return `${ref} has no Dormouse shell integration (OSC 633), so dor ensure can't detect its command; future ensure/--restart calls will spawn a new surface instead of matching or restarting this one.`; +} + function killConfirmationParam(value: unknown): { mode: 'if-read'; text: string } | { mode: 'dangerously' } | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; const confirmation = value as { mode?: unknown; text?: unknown }; @@ -1309,6 +1324,10 @@ export function Wall({ detail.respond({ ok: false, error: result.message }); return; } + // ensure's idempotency only holds if the new shell reports OSC 633 + // boundaries — otherwise it can never be matched and the next ensure spawns + // a duplicate. Wait for integration and warn the CLI if it never arrives. + const warning = await missingIntegrationWarning(result.value.id, result.value.ref); detail.respond({ ok: true, result: { @@ -1318,6 +1337,7 @@ export function Wall({ command, cwd, minimized: booleanParam(params.minimized), + ...(warning ? { warning } : {}), }, }); return; diff --git a/lib/src/lib/terminal-registry.ts b/lib/src/lib/terminal-registry.ts index a0b8faca..55dcf33f 100644 --- a/lib/src/lib/terminal-registry.ts +++ b/lib/src/lib/terminal-registry.ts @@ -65,6 +65,7 @@ export { fillTerminalProcessCwdByPtyId, getTerminalPaneState, getTerminalPaneStateSnapshot, + isPaneOscDriven, isReservedUserTitle, removeTerminalPaneState, resetTerminalPaneState, diff --git a/lib/src/lib/terminal-state-store.ts b/lib/src/lib/terminal-state-store.ts index d1fdeafe..931fbd96 100644 --- a/lib/src/lib/terminal-state-store.ts +++ b/lib/src/lib/terminal-state-store.ts @@ -63,6 +63,15 @@ export function getTerminalPaneState(id: string): TerminalPaneState { return paneStates.get(id) ?? createTerminalPaneState(); } +// True once the pane's shell has emitted real OSC 633/133 boundaries — i.e. its +// shell integration injection took. `dor ensure -- ` types the command +// into the shell programmatically (bypassing the keystroke heuristic), so only an +// OSC-driven shell re-reports the running command. Without integration the +// surface can't be matched or `--restart`ed; callers use this to warn. +export function isPaneOscDriven(id: string): boolean { + return oscDrivenPanes.has(id); +} + export function ensureTerminalPaneState(id: string, initial?: Partial): TerminalPaneState { const existing = paneStates.get(id); if (existing) return existing; From c4d87ca14307f06e5df6c54ac0213492256dbf69 Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 19 Jun 2026 17:05:09 -0700 Subject: [PATCH 5/7] Fix Windows/Git-Bash cwd dialect mismatches (dor ensure dupes, shell cwd, casing) On Windows + Git Bash the shell integration reports a pane's cwd as a POSIX path (`/c/Users/...`) while the rest of the system speaks native Windows (`C:\Users\...`). Three symptoms, all from that one dialect split, all verified by measurement: 1. `dor ensure -- cmd` twice spawned two surfaces: surfaceRunsCommand compared the bash POSIX cwd against the CLI's Windows cwd by exact string and never matched. Canonicalize both sides (MSYS `/c/`->`C:\`, slashes, drive case) before the compare. Anchored so genuine POSIX paths stay untouched off-Windows. 2. The `dor` CLI mangled a pure-POSIX `$PWD`: `path.resolve('/c/Users/x')` on win32 yields `C:\c\Users\x`. Fold the MSYS drive form to a native drive before resolving. 3. The pty spawn passed the cwd through verbatim: a POSIX `/c/...` failed the directory check and silently fell back to the home dir, and a lowercase drive (e.g. VS Code's workspaceFolder.fsPath) propagated into process.cwd() of the shell and everything launched in it. Measured: the spawn cwd's drive-letter case is preserved verbatim into a child's process.cwd(), so this is what fragments Claude Code's per-directory state (`c--` vs `C--`). Canonicalize the spawn cwd to one Windows form in the shared resolveSpawnConfig (covers both the Tauri sidecar and the VS Code host via lib/pty-core.cjs). Co-Authored-By: Claude Opus 4.8 --- dor/src/commands/ensure.ts | 15 +++++++- dor/test/cli-output.test.mjs | 10 +++++ lib/src/lib/terminal-state.test.ts | 12 ++++++ lib/src/lib/terminal-state.ts | 28 +++++++++++--- standalone/sidecar/pty-core.js | 24 +++++++++++- standalone/sidecar/pty-core.test.js | 57 +++++++++++++++++++++++++++++ 6 files changed, 138 insertions(+), 8 deletions(-) diff --git a/dor/src/commands/ensure.ts b/dor/src/commands/ensure.ts index 2704878e..ed78e9ff 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -136,13 +136,24 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... } } +// Git Bash exports PWD as a POSIX path (`/c/Users/...`). On Windows, resolvePath +// reads the leading `/c` as a folder under the current drive's root and mangles it +// to `C:\c\Users\...`, which then matches no surface. Fold the MSYS drive form to a +// native Windows drive first. No-op off win32 and for paths that already carry a +// drive letter (e.g. `C:/Users/...`, which some MSYS builds export instead). +export function msysToWindowsCwd(pwd: string, platform: string): string { + if (platform !== 'win32') return pwd; + const match = pwd.match(/^\/([A-Za-z])\/(.*)$/); + return match ? `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}` : pwd; +} + // 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. resolvePath canonicalizes both // the default and a relative/absolute --cwd into one absolute path the host can -// key on with an exact compare. +// key on. function callerWorkingDirectory(flag: string | undefined, env: CliEnv | undefined): string { - const base = env?.PWD ?? process.cwd(); + const base = msysToWindowsCwd(env?.PWD ?? process.cwd(), process.platform); return resolvePath(base, flag ?? '.'); } diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 3e7d57b2..9cd12462 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -5,6 +5,7 @@ import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { runCli } from '../dist/cli.js'; import { buildShellCommandForKind, shellCommandKind } from '../dist/commands/shell-quote.js'; +import { msysToWindowsCwd } from '../dist/commands/ensure.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); const snapshotsDir = join(__dirname, 'snapshots'); @@ -282,6 +283,15 @@ test('ensure prints a host warning to stderr, leaving stdout clean', async () => assert.doesNotMatch(result.stdout, /warning/); }); +test('msysToWindowsCwd folds a Git Bash POSIX PWD to a Windows drive on win32', () => { + assert.equal(msysToWindowsCwd('/c/Users/me/site', 'win32'), 'C:\\Users\\me\\site'); + assert.equal(msysToWindowsCwd('/d/work', 'win32'), 'D:\\work'); + // Already-native paths (some MSYS builds export `C:/...`) and non-win32 + // platforms are left for resolvePath to handle. + assert.equal(msysToWindowsCwd('C:/Users/me/site', 'win32'), 'C:/Users/me/site'); + assert.equal(msysToWindowsCwd('/c/Users/me/site', 'linux'), '/c/Users/me/site'); +}); + test('ensure json output', async () => { await snapshot( 'ensure-json', diff --git a/lib/src/lib/terminal-state.test.ts b/lib/src/lib/terminal-state.test.ts index 29043c18..d59572ad 100644 --- a/lib/src/lib/terminal-state.test.ts +++ b/lib/src/lib/terminal-state.test.ts @@ -542,6 +542,18 @@ describe('surfaceRunsCommand (dor ensure matching)', () => { expect(surfaceRunsCommand(pane, 'pnpm dev:website', '/repo/app')).toBe(true); expect(surfaceRunsCommand(pane, 'pnpm dev:website', '/repo/app/')).toBe(false); }); + + it('matches across the Windows/Git-Bash cwd dialect split', () => { + // Git Bash reports its cwd as POSIX (`/c/Users/...`) via OSC, while the dor + // CLI sends the native Windows path (`C:\Users\...`) for the same directory. + const bashPane = runningPane('/c/Users/me/app', 'pnpm dev:website'); + expect(surfaceRunsCommand(bashPane, 'pnpm dev:website', 'C:\\Users\\me\\app')).toBe(true); + // Drive-letter case and slash direction are folded too. + const winPane = runningPane('c:/Users/me/app', 'pnpm dev:website'); + expect(surfaceRunsCommand(winPane, 'pnpm dev:website', 'C:\\Users\\me\\app')).toBe(true); + // Genuinely different directories still do not match. + expect(surfaceRunsCommand(bashPane, 'pnpm dev:website', 'C:\\Users\\me\\other')).toBe(false); + }); }); function cwd(path: string, host?: string): CwdState { diff --git a/lib/src/lib/terminal-state.ts b/lib/src/lib/terminal-state.ts index 4377814c..d9661f4d 100644 --- a/lib/src/lib/terminal-state.ts +++ b/lib/src/lib/terminal-state.ts @@ -376,6 +376,23 @@ export function summarizeCommandLine(raw: string): string { return truncateCommandTitle(`${visibleTokens.join(' ')}${suffix}`); } +// Fold the Windows spellings of one directory to a single key so `dor ensure` +// can match a surface across the dialect split. On Windows + Git Bash the shell +// integration reports its cwd as a POSIX path (`/c/Users/...`) while the `dor` +// CLI sends a native Windows path (`C:\Users\...`) for the very same folder, so +// an exact compare never matches and every ensure spawns a duplicate. This +// normalizes the MSYS drive form (`/c/` -> `C:\`), slash direction, and +// drive-letter case. It is anchored to leave genuine POSIX paths (`/Users/...`, +// `/home/...`) untouched — only a single-letter root segment, i.e. an MSYS drive, +// is rewritten — so it is a no-op on macOS/Linux. Applied symmetrically, so +// already-equal paths stay equal. +function canonicalizeCwdForMatch(path: string): string { + const withDrive = path.replace(/^\/([A-Za-z])\//, (_match, drive: string) => `${drive}:/`); + if (!/^[A-Za-z]:[\\/]/.test(withDrive)) return path; + const unified = withDrive.replace(/\//g, '\\'); + return unified.charAt(0).toUpperCase() + unified.slice(1); +} + /** * The idempotency predicate for `dor ensure`: true when the pane is *currently * running* `command` in `cwdPath`. It matches only while the command is live @@ -392,12 +409,13 @@ 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. + // 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; - return runCwd === cwdPath; + if (runCwd === undefined) return false; + return canonicalizeCwdForMatch(runCwd) === canonicalizeCwdForMatch(cwdPath); } export function deriveFallbackCommandTitle( diff --git a/standalone/sidecar/pty-core.js b/standalone/sidecar/pty-core.js index 9ff300f4..0bda0bed 100644 --- a/standalone/sidecar/pty-core.js +++ b/standalone/sidecar/pty-core.js @@ -100,6 +100,23 @@ function directoryExists(cwd, fsModule = fs) { } } +// Win32 only. The cwd we're handed can be in a non-native spelling: Git Bash +// inherits a POSIX path (`/c/Users/...`) that `statSync` can't resolve — so the +// directory check below fails and the shell silently falls back to the home dir — +// and VS Code's `workspaceFolder.fsPath` often carries a lowercase drive +// (`c:\...`). Whatever spelling we pass propagates verbatim into `process.cwd()` +// of the shell and of anything launched in it (e.g. Claude Code, which keys +// per-directory state case-sensitively, so `c:\` vs `C:\` fragments its history). +// Fold to one canonical Windows form: MSYS drive -> `X:\`, slashes unified, +// drive letter upper-cased. Non-drive paths (UNC, already-relative) pass through. +function canonicalizeWindowsCwd(cwd) { + if (!cwd) return cwd; + const withDrive = cwd.replace(/^\/([A-Za-z])\//, (_match, drive) => `${drive}:/`); + if (!/^[A-Za-z]:[\\/]/.test(withDrive)) return cwd; + const unified = withDrive.replace(/\//g, '\\'); + return unified.charAt(0).toUpperCase() + unified.slice(1); +} + function pathEnvKey(env) { return Object.prototype.hasOwnProperty.call(env, 'Path') ? 'Path' : 'PATH'; } @@ -232,7 +249,7 @@ function applyShellIntegration(shell, env, shellArgs, integrationDir, runtime = } function resolveSpawnConfig(options, runtime = {}) { - const { cols = 80, rows = 30, cwd, shell: explicitShell, args: explicitArgs, surfaceId } = options || {}; + const { cols = 80, rows = 30, cwd: requestedCwd, shell: explicitShell, args: explicitArgs, surfaceId } = options || {}; const env = { ...(runtime.env || process.env), ...(options?.env || {}), @@ -240,6 +257,10 @@ function resolveSpawnConfig(options, runtime = {}) { const platform = runtime.platform || process.platform; const osModule = runtime.osModule || os; const fsModule = runtime.fsModule || fs; + // Normalize the requested cwd into a native spelling before it reaches the OS, + // so the directory check resolves and the casing the shell (and its children) + // perceives is stable. See canonicalizeWindowsCwd. + const cwd = platform === 'win32' ? canonicalizeWindowsCwd(requestedCwd) : requestedCwd; const defaultCwd = resolveDefaultCwd(platform, env, osModule); const missingExplicitCwd = Boolean(cwd) && !directoryExists(cwd, fsModule); const shell = explicitShell || resolveDefaultShell(platform, env); @@ -276,6 +297,7 @@ function resolveSpawnConfig(options, runtime = {}) { module.exports.resolveSpawnConfig = resolveSpawnConfig; module.exports.withPrependedPath = withPrependedPath; +module.exports.canonicalizeWindowsCwd = canonicalizeWindowsCwd; // ── Shell detection ──────────────────────────────────────────────────────── diff --git a/standalone/sidecar/pty-core.test.js b/standalone/sidecar/pty-core.test.js index 8b0a5b2c..44df738b 100644 --- a/standalone/sidecar/pty-core.test.js +++ b/standalone/sidecar/pty-core.test.js @@ -9,6 +9,7 @@ const { parseCwdFromLsof, resolveSpawnConfig, withPrependedPath, + canonicalizeWindowsCwd, buildDescendantSet, parseProcStatPpid, parsePsPairs, @@ -155,6 +156,62 @@ test('resolveSpawnConfig preserves explicit cwd', () => { assert.deepEqual(config.shellArgs, ['-l']); }); +test('canonicalizeWindowsCwd folds MSYS drive, slashes, and drive-letter case', () => { + assert.equal(canonicalizeWindowsCwd('/c/Users/me/app'), 'C:\\Users\\me\\app'); + assert.equal(canonicalizeWindowsCwd('c:/Users/me/app'), 'C:\\Users\\me\\app'); + assert.equal(canonicalizeWindowsCwd('c:\\Users\\me\\app'), 'C:\\Users\\me\\app'); + assert.equal(canonicalizeWindowsCwd('C:\\Users\\me\\app'), 'C:\\Users\\me\\app'); + // Non-drive paths (POSIX dirs, UNC, empty) pass through unchanged. + assert.equal(canonicalizeWindowsCwd('/home/me/app'), '/home/me/app'); + assert.equal(canonicalizeWindowsCwd('\\\\server\\share'), '\\\\server\\share'); + assert.equal(canonicalizeWindowsCwd(undefined), undefined); +}); + +test('resolveSpawnConfig canonicalizes a Git Bash POSIX cwd on Windows', () => { + let checkedPath = null; + const config = resolveSpawnConfig( + { cwd: '/c/Users/me/app', cols: 80, rows: 24 }, + { + platform: 'win32', + env: { COMSPEC: 'C:\\Windows\\System32\\cmd.exe' }, + fsModule: { statSync: (p) => { checkedPath = p; return { isDirectory: () => true }; } }, + osModule: { homedir: () => 'C:\\Users\\me', tmpdir: () => 'C:\\Temp' }, + }, + ); + // The POSIX form is resolved to a real Windows path before the directory check, + // so the shell starts in the requested dir instead of silently falling back. + assert.equal(checkedPath, 'C:\\Users\\me\\app'); + assert.equal(config.cwd, 'C:\\Users\\me\\app'); + assert.equal(config.cwdWarning, null); +}); + +test('resolveSpawnConfig upper-cases a lowercase Windows drive so child cwd casing is stable', () => { + const config = resolveSpawnConfig( + { cwd: 'c:\\Users\\me\\app' }, + { + platform: 'win32', + env: {}, + fsModule: { statSync: () => ({ isDirectory: () => true }) }, + osModule: { homedir: () => 'C:\\Users\\me', tmpdir: () => 'C:\\Temp' }, + }, + ); + assert.equal(config.cwd, 'C:\\Users\\me\\app'); +}); + +test('resolveSpawnConfig leaves a POSIX cwd untouched off Windows', () => { + const config = resolveSpawnConfig( + { cwd: '/c/Users/me/app' }, + { + platform: 'linux', + env: { SHELL: '/bin/bash' }, + fsModule: { statSync: () => ({ isDirectory: () => true }) }, + osModule: { homedir: () => '/home/me', tmpdir: () => '/tmp' }, + }, + ); + // `/c/Users/me/app` is a legitimate path on Linux; never rewrite it. + assert.equal(config.cwd, '/c/Users/me/app'); +}); + test('resolveSpawnConfig skips -l for csh-style shells that reject it', () => { const config = resolveSpawnConfig(undefined, { platform: 'linux', From 74deb1eddb02f66c332d9d75ad552e9ef44a4d3a Mon Sep 17 00:00:00 2001 From: Ned Date: Fri, 19 Jun 2026 23:31:13 -0700 Subject: [PATCH 6/7] dor ensure: fail fast and clean on shells without OSC 633 (cmd.exe) `dor ensure` needs OSC 633 shell integration to track a command. cmd.exe has none, and the old behavior was a slow, confusing half-failure: the webview blocked 15s waiting for OSC while the sidecar control server timed out at 10s (printing "timed out waiting for surface.ensure"), and the command got typed into the new split anyway on a separate best-effort timer. --restart additionally fired Ctrl+C into cmd.exe, leaving a stuck "Terminate batch job (Y/N)?". Now: - cmd.exe (explicitly configured) errors instantly with a clear message and no split: "dor ensure requires OSC 633 shell integration, which cmd.exe does not provide. Run it from a shell with Dormouse integration, such as Git Bash or PowerShell." - Any other shell that lacks integration: create the split, wait up to 8s for OSC 633, then kill the throwaway split and return the same error. - The command is only typed once OSC 633 is confirmed (requireIntegration gate in typeCommandWhenPromptReady); it is dropped, never best-effort typed, when integration is absent, so nothing half-runs. dor split keeps its best-effort typing into any shell. - restartSurfaceInPlace guards on isPaneOscDriven so Ctrl+C never reaches a non-integration shell. - The sidecar control-server timeout (10s) was preempting the CLI's 20s/60s budgets and is raised to 65s so the CLI deadline always governs. - The earlier soft stderr "warning" plumbing is removed in favor of the hard error. Co-Authored-By: Claude Opus 4.8 --- dor/src/commands/ensure.ts | 8 +-- dor/src/commands/shared.ts | 4 -- dor/src/commands/types.ts | 3 - dor/test/cli-output.test.mjs | 19 ++----- dor/test/snapshots/help/ensure.md | 4 +- lib/src/components/Wall.tsx | 61 ++++++++++++++++----- lib/src/lib/terminal-lifecycle.ts | 36 +++++++++--- lib/src/lib/terminal-registry.alert.test.ts | 54 ++++++++++++++++++ lib/src/lib/terminal-store.ts | 7 +++ standalone/sidecar/dor-control-server.js | 12 ++-- 10 files changed, 154 insertions(+), 54 deletions(-) diff --git a/dor/src/commands/ensure.ts b/dor/src/commands/ensure.ts index ed78e9ff..006a5fc5 100644 --- a/dor/src/commands/ensure.ts +++ b/dor/src/commands/ensure.ts @@ -13,7 +13,6 @@ import { renderJson, requireControlClient, stringParser, - writeStderr, writeStdout, } from './shared.js'; @@ -63,7 +62,9 @@ export const ensureCommand: Command = { 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. -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. When ensure creates a surface whose shell turns out to lack integration, it prints a warning to stderr — that surface can never be matched or restarted on a later call, so repeated ensures will keep spawning duplicates. +Matching uses the command each shell reports it is running via Dormouse shell integration (OSC 633), 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. + +ensure requires that integration: a surface can only be matched, reused, or restarted if its shell reports its command. So if the shell has no OSC 633 integration (e.g. cmd.exe), ensure fails with an error rather than starting an untrackable surface — run it from a shell with integration, such as Git Bash or PowerShell. 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. @@ -126,9 +127,6 @@ async function runEnsureCommand(this: DorCommandContext, flags: EnsureFlags, ... surface: flags.surface, cwd: callerWorkingDirectory(flags.cwd, this.options.env), }); - if (response.warning) { - writeStderr(this, `warning: ${response.warning}\n`); - } writeStdout(this, renderEnsureResponse(response, flags.json === true)); return undefined; } catch (error) { diff --git a/dor/src/commands/shared.ts b/dor/src/commands/shared.ts index 072d5d01..97947b1c 100644 --- a/dor/src/commands/shared.ts +++ b/dor/src/commands/shared.ts @@ -82,7 +82,3 @@ export function wantsIds(idFormat: IdFormat): boolean { export function writeStdout(context: DorCommandContext, stdout: string): void { context.process.stdout.write(stdout); } - -export function writeStderr(context: DorCommandContext, stderr: string): void { - context.process.stderr.write(stderr); -} diff --git a/dor/src/commands/types.ts b/dor/src/commands/types.ts index 0c14b36e..9ee78d29 100644 --- a/dor/src/commands/types.ts +++ b/dor/src/commands/types.ts @@ -67,9 +67,6 @@ export interface EnsureSurfaceResponse { command: string; cwd: string; minimized: boolean; - /** Advisory the host attaches when the surface can't be tracked (e.g. its shell - * has no OSC 633 integration). Printed to stderr; never affects stdout. */ - warning?: string; } export interface SendSurfaceRequest { diff --git a/dor/test/cli-output.test.mjs b/dor/test/cli-output.test.mjs index 9cd12462..7393ae6d 100644 --- a/dor/test/cli-output.test.mjs +++ b/dor/test/cli-output.test.mjs @@ -260,27 +260,18 @@ test('ensure --restart restarts a matching surface in place', async () => { assert.equal(client.requests[0].request.restart, true); }); -test('ensure prints a host warning to stderr, leaving stdout clean', async () => { +test('ensure surfaces a host error (no integration) to stderr with exit 1', async () => { const client = { requests: [], async ensureSurface(request) { this.requests.push({ method: 'ensureSurface', request }); - return { - status: 'created', - surfaceId: '33333333-3333-4333-8333-333333333333', - surfaceRef: 'surface:3', - command: 'pnpm dev', - cwd: request.cwd, - minimized: false, - warning: 'surface:3 has no Dormouse shell integration (OSC 633), so dor ensure can\'t detect its command.', - }; + throw new Error('dor ensure requires OSC 633 shell integration, which cmd.exe does not provide. Run it from a shell with Dormouse integration, such as Git Bash or PowerShell.'); }, }; const result = await runCli(['ensure', '--', 'pnpm', 'dev'], { client, env: { PWD: '/work/site' } }); - assert.equal(result.exitCode, 0); - assert.match(result.stderr, /^warning: surface:3 has no Dormouse shell integration/); - assert.equal(result.stdout, 'created surface:3 "pnpm dev"\n'); - assert.doesNotMatch(result.stdout, /warning/); + assert.equal(result.exitCode, 1); + assert.match(result.stderr, /^Error: dor ensure requires OSC 633 shell integration, which cmd\.exe does not provide/); + assert.equal(result.stdout, ''); }); test('msysToWindowsCwd folds a Git Bash POSIX PWD to a Windows drive on win32', () => { diff --git a/dor/test/snapshots/help/ensure.md b/dor/test/snapshots/help/ensure.md index 3262057a..0f45203b 100644 --- a/dor/test/snapshots/help/ensure.md +++ b/dor/test/snapshots/help/ensure.md @@ -9,7 +9,9 @@ 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 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. When ensure creates a surface whose shell turns out to lack integration, it prints a warning to stderr — that surface can never be matched or restarted on a later call, so repeated ensures will keep spawning duplicates. +Matching uses the command each shell reports it is running via Dormouse shell integration (OSC 633), 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. + +ensure requires that integration: a surface can only be matched, reused, or restarted if its shell reports its command. So if the shell has no OSC 633 integration (e.g. cmd.exe), ensure fails with an error rather than starting an untrackable surface — run it from a shell with integration, such as Git Bash or PowerShell. 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. diff --git a/lib/src/components/Wall.tsx b/lib/src/components/Wall.tsx index 36d276db..dae5932a 100644 --- a/lib/src/components/Wall.tsx +++ b/lib/src/components/Wall.tsx @@ -312,6 +312,11 @@ function waitForTerminalState( * doors too (their PTY keeps running). Returns a message on failure. */ async function restartSurfaceInPlace(id: string, command: string, cwd: string): Promise> { + // A match is by construction OSC-driven (surfaceRunsCommand only matches a + // shell that reports its command), so this never fires on the real path — but + // it guarantees we never fire Ctrl+C into a non-integration shell (e.g. cmd.exe + // popping `Terminate batch job (Y/N)?`). + if (!isPaneOscDriven(id)) return { ok: false, message: 'has no Dormouse shell integration to restart' }; const platform = getPlatform(); platform.writePty(id, '\x03'); const interrupted = await waitForTerminalState( @@ -332,16 +337,17 @@ async function restartSurfaceInPlace(id: string, command: string, cwd: string): // 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. Wait for that signal so -// the CLI can warn when it's absent. Resolves the moment integration is detected; -// only a non-integration shell waits out the timeout, which the CLI's create-path -// request budget (ENSURE_TIMEOUT_MS / RESTART_TIMEOUT_MS) is sized to outlast. -const INTEGRATION_DETECT_TIMEOUT_MS = 15_000; - -async function missingIntegrationWarning(id: string, ref: string): Promise { - const integrated = await waitForTerminalState(id, () => isPaneOscDriven(id), INTEGRATION_DETECT_TIMEOUT_MS); - if (integrated) return undefined; - return `${ref} has no Dormouse shell integration (OSC 633), so dor ensure can't detect its command; future ensure/--restart calls will spawn a new surface instead of matching or restarting this one.`; +// emits OSC 633 boundaries ever reports the command back, which is what makes the +// surface matchable/restartable. `dor ensure` requires it. We give the shell this +// long to draw its first integrated prompt (headroom for a cold-start shell +// loading a profile / under AV) before concluding it has no integration. +const INTEGRATION_DETECT_TIMEOUT_MS = 8_000; + +// Shown to the user (via the CLI's stderr) when `dor ensure` can't run because the +// target shell has no OSC 633 integration. `shell` is a display name when known. +function missingIntegrationError(shell?: string): string { + const name = (shell ?? '').replace(/\\/g, '/').split('/').pop() || 'this shell'; + return `dor ensure requires OSC 633 shell integration, which ${name} does not provide. Run it from a shell with Dormouse integration, such as Git Bash or PowerShell.`; } function killConfirmationParam(value: unknown): { mode: 'if-read'; text: string } | { mode: 'dangerously' } | null { @@ -913,12 +919,14 @@ export function Wall({ minimized, referenceId, cwd, + requireIntegration, }: { command?: string; direction: DorResolvedSplitDirection; minimized: boolean; referenceId: string; cwd?: string; + requireIntegration?: boolean; }): ParseResult<{ id: string; ref: string; @@ -948,6 +956,7 @@ export function Wall({ cwd: inheritedCwd, untouched: false, command, + ...(requireIntegration ? { requireIntegration: true } : {}), }); } else if (defaults?.shell || inheritedCwd) { setPendingShellOpts(newId, { @@ -1310,6 +1319,16 @@ export function Wall({ }); return; } + // ensure needs OSC 633 to track the command. cmd.exe provably has none, + // so when the configured shell is explicitly cmd, fail immediately without + // even spawning a split. Only short-circuit on an explicit shell — an + // unset shell classifies as 'cmd' on Windows but the sidecar may actually + // spawn PowerShell, so let those fall through to the generic OSC wait. + const ensureShell = getDefaultShellOpts()?.shell; + if (ensureShell && shellCommandKind(ensureShell, PLATFORM_STRING) === 'cmd') { + detail.respond({ ok: false, error: missingIntegrationError(ensureShell) }); + return; + } const resolved = resolveSplitTarget(); if (!resolved) return; const direction = dorDirectionForDockview(pickSplitDirection(resolved.panel)); @@ -1319,15 +1338,28 @@ export function Wall({ minimized: booleanParam(params.minimized), referenceId: resolved.target.id, cwd, + requireIntegration: true, }); if (!result.ok) { detail.respond({ ok: false, error: result.message }); return; } - // ensure's idempotency only holds if the new shell reports OSC 633 - // boundaries — otherwise it can never be matched and the next ensure spawns - // a duplicate. Wait for integration and warn the CLI if it never arrives. - const warning = await missingIntegrationWarning(result.value.id, result.value.ref); + // ensure is only useful if the new shell reports OSC 633 — otherwise it + // can never be matched or restarted. A non-cmd shell can still lack + // integration (misconfigured, exotic); wait for the signal, and if it + // never arrives kill the throwaway split and fail cleanly rather than + // half-run an untrackable command. typeCommandWhenPromptReady drops the + // command in the same case, so nothing executes. + const integrated = await waitForTerminalState( + result.value.id, + () => isPaneOscDriven(result.value.id), + INTEGRATION_DETECT_TIMEOUT_MS, + ); + if (!integrated) { + killPaneImmediately(result.value.id); + detail.respond({ ok: false, error: missingIntegrationError(ensureShell) }); + return; + } detail.respond({ ok: true, result: { @@ -1337,7 +1369,6 @@ export function Wall({ command, cwd, minimized: booleanParam(params.minimized), - ...(warning ? { warning } : {}), }, }); return; diff --git a/lib/src/lib/terminal-lifecycle.ts b/lib/src/lib/terminal-lifecycle.ts index f716b600..01f13c18 100644 --- a/lib/src/lib/terminal-lifecycle.ts +++ b/lib/src/lib/terminal-lifecycle.ts @@ -34,6 +34,7 @@ import { fillTerminalProcessCwdByPtyId, finishLaunchedCommandByPtyId, getTerminalPaneState, + isPaneOscDriven, recordTerminalOutputByPtyId, recordTerminalUserInputByPtyId, removeTerminalPaneState, @@ -310,6 +311,10 @@ export function setPendingShellOpts(id: string, opts: PendingShellOpts): void { const LAUNCH_PROMPT_POLL_MS = 100; const LAUNCH_PROMPT_TIMEOUT_MS = 15_000; +// `dor ensure` only types into a shell once OSC 633 integration is confirmed, on +// a tighter budget — and drops the command rather than blindly typing it into a +// shell that can never be tracked (see the requireIntegration branch below). +const INTEGRATION_TYPE_TIMEOUT_MS = 8_000; // `dor split/ensure -- ` spawns a real interactive shell (see // createSplitSurface) and types the command into it, rather than running @@ -317,21 +322,36 @@ const LAUNCH_PROMPT_TIMEOUT_MS = 15_000; // `dor ensure --restart` can Ctrl+C the command and have the shell survive and // return to a prompt instead of the whole pty exiting. But we must wait for the // shell to actually reach a prompt first, or the keystrokes land in shell -// startup. seedLaunchedCommand primed currentCommand with the command; it clears -// the moment the shell draws its first prompt (integration promptStart, or the -// keystroke heuristic for shells without it) — that's the signal it can take -// input. On timeout we send it anyway as a best effort rather than drop it. -function typeCommandWhenPromptReady(id: string, command: string): void { +// startup. +// +// `dor split` (requireIntegration=false): wait for any first prompt — +// seedLaunchedCommand primed currentCommand, which clears the moment the shell +// draws its first prompt (integration promptStart, or the keystroke heuristic +// for shells without it). On timeout we type anyway as best effort. +// +// `dor ensure` (requireIntegration=true): wait specifically for OSC 633 (the +// only signal that makes the surface trackable), and on timeout DROP the +// command. The ensure handler kills the surface and errors in that case, so a +// shell with no integration (e.g. cmd.exe) never half-runs an untrackable +// command. +function typeCommandWhenPromptReady(id: string, command: string, requireIntegration: boolean): void { + const timeoutMs = requireIntegration ? INTEGRATION_TYPE_TIMEOUT_MS : LAUNCH_PROMPT_TIMEOUT_MS; let elapsed = 0; const timer = setInterval(() => { if (!registry.has(id)) { clearInterval(timer); return; } - const ready = getTerminalPaneState(id).currentCommand === null; - if (ready || (elapsed += LAUNCH_PROMPT_POLL_MS) >= LAUNCH_PROMPT_TIMEOUT_MS) { + const ready = requireIntegration + ? isPaneOscDriven(id) + : getTerminalPaneState(id).currentCommand === null; + if (ready) { clearInterval(timer); getPlatform().writePty(id, `${command}\r`); + } else if ((elapsed += LAUNCH_PROMPT_POLL_MS) >= timeoutMs) { + clearInterval(timer); + // Best effort for split; drop for ensure (the handler kills + errors). + if (!requireIntegration) getPlatform().writePty(id, `${command}\r`); } }, LAUNCH_PROMPT_POLL_MS); } @@ -358,7 +378,7 @@ export function getOrCreateTerminal(id: string): TerminalEntry { }); if (shellOpts?.command) { seedLaunchedCommand(id, shellOpts.command, shellOpts.cwd); - typeCommandWhenPromptReady(id, shellOpts.command); + typeCommandWhenPromptReady(id, shellOpts.command, shellOpts.requireIntegration === true); } seedProcessCwdAfterSpawn(id); diff --git a/lib/src/lib/terminal-registry.alert.test.ts b/lib/src/lib/terminal-registry.alert.test.ts index 8422d851..a12aeedc 100644 --- a/lib/src/lib/terminal-registry.alert.test.ts +++ b/lib/src/lib/terminal-registry.alert.test.ts @@ -94,6 +94,8 @@ import * as platformModule from './platform'; import { makeAlertScenario, type FakePtyAdapter, type FakeScenario } from './platform'; import { DEFAULT_ACTIVITY_STATE, + applyTerminalSemanticEvents, + isPaneOscDriven, mountElement, clearSessionAttention, clearSessionTodo, @@ -1007,3 +1009,55 @@ describe('pending shell opts → spawnPty', () => { expect(options?.cwd).toBeUndefined(); }); }); + +describe('typeCommandWhenPromptReady — dor ensure requires OSC 633', () => { + beforeEach(installRegistryTestGlobals); + afterEach(uninstallRegistryTestGlobals); + + // Capture what gets typed into the pty (the command injection routes through + // writePty → the input handler). + function captureTypedInput(id: string): string[] { + const typed: string[] = []; + fakePlatform.setInputHandler(id, (data) => typed.push(data)); + return typed; + } + + it('drops the command when OSC 633 never arrives (no half-run on cmd.exe-like shells)', () => { + const id = 'ensure-no-osc'; + setPendingShellOpts(id, { command: 'pnpm dev', requireIntegration: true }); + getOrCreateTerminal(id); + const typed = captureTypedInput(id); + + vi.advanceTimersByTime(8_100); // past the 8s integration window, no OSC applied + + expect(typed).not.toContain('pnpm dev\r'); + }); + + it('types the command once OSC 633 is detected', () => { + const id = 'ensure-osc'; + setPendingShellOpts(id, { command: 'pnpm dev', requireIntegration: true }); + getOrCreateTerminal(id); + const typed = captureTypedInput(id); + + vi.advanceTimersByTime(2_000); + expect(typed).not.toContain('pnpm dev\r'); // not yet integrated + + // A real OSC 633 prompt boundary marks the pane OSC-driven. + applyTerminalSemanticEvents(id, [{ type: 'promptStart' }]); + expect(isPaneOscDriven(id)).toBe(true); + vi.advanceTimersByTime(200); // let the next poll fire + + expect(typed.filter((data) => data === 'pnpm dev\r')).toHaveLength(1); + }); + + it('split (no requireIntegration) still types best-effort without OSC', () => { + const id = 'split-no-osc'; + setPendingShellOpts(id, { command: 'pnpm dev' }); + getOrCreateTerminal(id); + const typed = captureTypedInput(id); + + vi.advanceTimersByTime(15_100); // split's best-effort timeout + + expect(typed).toContain('pnpm dev\r'); + }); +}); diff --git a/lib/src/lib/terminal-store.ts b/lib/src/lib/terminal-store.ts index ce36ae7a..b6b31519 100644 --- a/lib/src/lib/terminal-store.ts +++ b/lib/src/lib/terminal-store.ts @@ -46,6 +46,13 @@ export interface PendingShellOpts { untouched?: boolean; /** Raw command string launched via `-lc`/`/c`, seeded as the pane's command run. */ command?: string; + /** + * `dor ensure` surface: the command must only be typed once OSC 633 shell + * integration is confirmed, and dropped (never typed) otherwise — so a shell + * with no integration (e.g. cmd.exe) can't half-run an untrackable command. + * `dor split` leaves this unset and types best-effort into any shell. + */ + requireIntegration?: boolean; } export const registry = new Map(); diff --git a/standalone/sidecar/dor-control-server.js b/standalone/sidecar/dor-control-server.js index bc9f4b64..88c968dd 100644 --- a/standalone/sidecar/dor-control-server.js +++ b/standalone/sidecar/dor-control-server.js @@ -1,10 +1,14 @@ const fs = require('node:fs'); const net = require('node:net'); -// The server timeout outlasts the dor client's own 5s deadline so the client -// always controls the outcome; this timer only exists to release a pending -// entry when the webview never answers at all. -function createDorControlServer({ socketPath, token, send, timeoutMs = 10000 }) { +// The server timeout must outlast the dor client's own deadline so the client +// always controls the outcome — its longest is `dor ensure --restart` at 60s, so +// 65s clears it. (A shorter server timeout would fire first and send the client a +// spurious "timed out waiting for surface.ensure" while the webview was still +// legitimately working, e.g. waiting on shell integration or a server restart.) +// In practice socket close reaps pending entries the instant the client gives up; +// this timer only releases a pending entry if the webview never answers at all. +function createDorControlServer({ socketPath, token, send, timeoutMs = 65000 }) { if (!socketPath || !token) return null; const pending = new Map(); From aee4ac0449f584434195c4cea46fffc3fca3ae12 Mon Sep 17 00:00:00 2001 From: Ned Date: Sat, 20 Jun 2026 08:05:59 -0700 Subject: [PATCH 7/7] dogfood:vscode: graceful message when VS Code locks the extension dir `code --install-extension` installs by renaming the extension folder; on Windows a running VS Code keeps the old Dormouse extension's node-pty native modules loaded and locks that folder, so the rename fails with a cryptic EPERM retry stack. Capture the install output and, on that lock signature, print a plain "quit all VS Code windows and re-run" banner and leave the built .vsix in place for a retry. Other failures still surface non-zero. Co-Authored-By: Claude Opus 4.8 --- scripts/dogfood-vscode.mjs | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/scripts/dogfood-vscode.mjs b/scripts/dogfood-vscode.mjs index 58430cca..7bcbf152 100644 --- a/scripts/dogfood-vscode.mjs +++ b/scripts/dogfood-vscode.mjs @@ -21,11 +21,39 @@ function run(command, { ignoreFailure = false, stdio = 'inherit', env = process. return result; } +// Install the packaged VSIX. VS Code installs by renaming the extension folder, +// and on Windows a running VS Code instance keeps the current extension's native +// modules (node-pty) loaded, locking that folder — so the rename fails with EPERM +// and a cryptic retry/stack trace. Capture the output and, on that lock, print a +// plain "close VS Code and retry" instead of the raw error. +function installVsix() { + const result = spawnSync('code --install-extension dormouse.vsix --force', { + cwd: extDir, + shell: true, + env: codeEnv, + encoding: 'utf8', + }); + process.stdout.write(result.stdout ?? ''); + process.stderr.write(result.stderr ?? ''); + if (result.status === 0) return; + + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + if (/EPERM|operation not permitted|restart VS ?Code/i.test(output)) { + console.error('\n──────────────────────────────────────────────────────────────'); + console.error("Couldn't install: VS Code is holding the old Dormouse extension"); + console.error('files open (its node-pty native modules lock the extension dir).'); + console.error('→ Quit ALL VS Code windows, then re-run `pnpm dogfood:vscode`.'); + console.error(' The .vsix is already built at vscode-ext/dormouse.vsix.'); + console.error('──────────────────────────────────────────────────────────────'); + } + process.exit(result.status ?? 1); +} + // Remove the legacy extension if present; ignore failure when it isn't installed. run('code --uninstall-extension diffplug.mouseterm', { ignoreFailure: true, stdio: 'ignore', env: codeEnv }); run('pnpm package'); -run('code --install-extension dormouse.vsix --force', { env: codeEnv }); +installVsix(); rmSync(vsix, { force: true });