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..c822dd0f 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,26 @@ 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) { + 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, + }, + }); + return; + } detail.respond({ ok: true, result: { @@ -1163,7 +1239,7 @@ export function Wall({ surfaceRef: surfaceRefForId(existingId), command, cwd, - minimized: doorsRef.current.some((door) => door.id === existingId), + minimized, }, }); return;