diff --git a/cli/src/claude/claudeLocal.ts b/cli/src/claude/claudeLocal.ts index 2c860081..0524b0b9 100644 --- a/cli/src/claude/claudeLocal.ts +++ b/cli/src/claude/claudeLocal.ts @@ -1,4 +1,3 @@ -import { spawn } from "node:child_process"; import { mkdirSync } from "node:fs"; import { logger } from "@/ui/logger"; import { restoreTerminalState } from "@/ui/terminalState"; @@ -6,14 +5,7 @@ import { claudeCheckSession } from "./utils/claudeCheckSession"; import { getProjectPath } from "./utils/path"; import { systemPrompt } from "./utils/systemPrompt"; import { withBunRuntimeEnv } from "@/utils/bunRuntime"; - -const isAbortError = (error: unknown): boolean => { - if (!error || typeof error !== 'object') { - return false; - } - const maybeError = error as { name?: string; code?: string }; - return maybeError.name === 'AbortError' || maybeError.code === 'ABORT_ERR'; -}; +import { spawnWithAbort } from "@/utils/spawnWithAbort"; export async function claudeLocal(opts: { abort: AbortSignal, @@ -87,118 +79,21 @@ export async function claudeLocal(opts: { ...opts.claudeEnvVars } - logger.debug('[ClaudeLocal] Spawning claude'); - logger.debug(`[ClaudeLocal] Args: ${JSON.stringify(args)}`); + logger.debug(`[ClaudeLocal] Spawning claude with args: ${JSON.stringify(args)}`); - const child = spawn('claude', args, { - stdio: ['inherit', 'inherit', 'inherit'], - signal: opts.abort, - killSignal: 'SIGINT', + spawnWithAbort({ + command: 'claude', + args, cwd: opts.path, env: withBunRuntimeEnv(env, { allowBunBeBun: false }), + signal: opts.abort, + logLabel: 'ClaudeLocal', + spawnName: 'claude', + installHint: 'Claude CLI', + includeCause: true, + logExit: true, shell: process.platform === 'win32' - }); - let settled = false; - const abortTimeoutMs = { - term: 1000, - kill: 3000 - }; - let abortTermTimeout: NodeJS.Timeout | null = null; - let abortKillTimeout: NodeJS.Timeout | null = null; - let forcedTermination = false; - let abortStartedAt: number | null = null; - - const isAlive = () => child.exitCode === null && !child.killed; - const formatAbortElapsed = () => { - if (abortStartedAt === null) { - return 'n/a'; - } - return `${Date.now() - abortStartedAt}ms`; - }; - - const abortHandler = () => { - if (abortTermTimeout || abortKillTimeout) { - logger.debug('[ClaudeLocal] Abort already in progress'); - return; - } - abortStartedAt = Date.now(); - logger.debug('[ClaudeLocal] Abort signaled, waiting for SIGINT to exit'); - abortTermTimeout = setTimeout(() => { - if (isAlive()) { - forcedTermination = true; - logger.debug(`[ClaudeLocal] Abort timeout reached (${formatAbortElapsed()}), sending SIGTERM`); - try { - child.kill('SIGTERM'); - } catch (error) { - logger.debug('[ClaudeLocal] Failed to send SIGTERM', error); - } - } - - abortKillTimeout = setTimeout(() => { - if (isAlive()) { - forcedTermination = true; - logger.debug(`[ClaudeLocal] Abort timeout reached (${formatAbortElapsed()}), sending SIGKILL`); - try { - child.kill('SIGKILL'); - } catch (error) { - logger.debug('[ClaudeLocal] Failed to send SIGKILL', error); - } - } - }, abortTimeoutMs.kill); - }, abortTimeoutMs.term); - }; - - if (opts.abort.aborted) { - abortHandler(); - } else { - opts.abort.addEventListener('abort', abortHandler); - } - - const cleanupAbortHandler = () => { - if (abortTermTimeout) { - clearTimeout(abortTermTimeout); - abortTermTimeout = null; - } - if (abortKillTimeout) { - clearTimeout(abortKillTimeout); - abortKillTimeout = null; - } - opts.abort.removeEventListener('abort', abortHandler); - }; - const finalize = (error?: Error) => { - if (settled) { - return; - } - settled = true; - cleanupAbortHandler(); - if (error) { - reject(error); - } else { - r(); - } - }; - child.on('error', (error) => { - if (opts.abort.aborted || isAbortError(error)) { - logger.debug('[ClaudeLocal] Spawn aborted while switching'); - if (!child.pid) { - finalize(); - } - return; - } - const message = error instanceof Error ? error.message : String(error); - finalize(new Error(`Failed to spawn claude: ${message}. Is Claude installed and on PATH?`)); - }); - child.on('exit', (code, signal) => { - logger.debug(`[ClaudeLocal] Child exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}, aborted=${opts.abort.aborted}, forced=${forcedTermination}, elapsed=${formatAbortElapsed()})`); - if ((signal === 'SIGTERM' || signal === 'SIGINT' || signal === 'SIGKILL') && opts.abort.aborted) { - // Normal termination due to abort signal - finalize(); - } else if (signal) { - finalize(new Error(`Process terminated with signal: ${signal}`)); - } else { - finalize(); - } - }); + }).then(r).catch(reject); }); } finally { process.stdin.resume(); diff --git a/cli/src/codex/codexLocal.ts b/cli/src/codex/codexLocal.ts index baa33595..dafc6ecb 100644 --- a/cli/src/codex/codexLocal.ts +++ b/cli/src/codex/codexLocal.ts @@ -1,7 +1,6 @@ -import { spawn } from 'node:child_process'; import { logger } from '@/ui/logger'; import { restoreTerminalState } from '@/ui/terminalState'; -import { killProcessByChildProcess } from '@/utils/process'; +import { spawnWithAbort } from '@/utils/spawnWithAbort'; /** * Filter out 'resume' subcommand which is managed internally by hapi. @@ -60,71 +59,17 @@ export async function codexLocal(opts: { process.stdin.pause(); try { - await new Promise((resolve, reject) => { - const child = spawn('codex', args, { - stdio: ['inherit', 'inherit', 'inherit'], - signal: opts.abort, - cwd: opts.path, - env: process.env - }); - - let abortKillTimeout: NodeJS.Timeout | null = null; - const abortHandler = () => { - if (abortKillTimeout) { - return; - } - abortKillTimeout = setTimeout(() => { - if (child.exitCode === null && !child.killed) { - logger.debug('[CodexLocal] Abort timeout reached, sending SIGKILL'); - try { - void killProcessByChildProcess(child, true); - } catch (error) { - logger.debug('[CodexLocal] Failed to send SIGKILL:', error); - } - } - }, 5000); - }; - - if (opts.abort.aborted) { - abortHandler(); - } else { - opts.abort.addEventListener('abort', abortHandler); - } - - const cleanupAbortHandler = () => { - if (abortKillTimeout) { - clearTimeout(abortKillTimeout); - abortKillTimeout = null; - } - opts.abort.removeEventListener('abort', abortHandler); - }; - - child.on('error', (error) => { - cleanupAbortHandler(); - if (opts.abort.aborted) { - resolve(); - return; - } - const message = error instanceof Error ? error.message : String(error); - reject(new Error(`Failed to spawn codex: ${message}. Is Codex CLI installed and on PATH?`, { cause: error })); - }); - - child.on('exit', (code, signal) => { - cleanupAbortHandler(); - if (signal === 'SIGTERM' && opts.abort.aborted) { - resolve(); - return; - } - if (signal) { - reject(new Error(`Process terminated with signal: ${signal}`)); - return; - } - if (typeof code === 'number' && code !== 0) { - reject(new Error(`Process exited with code: ${code}`)); - return; - } - resolve(); - }); + await spawnWithAbort({ + command: 'codex', + args, + cwd: opts.path, + env: process.env, + signal: opts.abort, + logLabel: 'CodexLocal', + spawnName: 'codex', + installHint: 'Codex CLI', + includeCause: true, + logExit: true }); } finally { process.stdin.resume(); diff --git a/cli/src/ui/terminalState.ts b/cli/src/ui/terminalState.ts index 4a0c93ed..54160dab 100644 --- a/cli/src/ui/terminalState.ts +++ b/cli/src/ui/terminalState.ts @@ -2,6 +2,8 @@ export function restoreTerminalState(): void { if (process.stdout.isTTY) { // Disable kitty keyboard protocol / CSI u key release reporting if enabled. process.stdout.write('\x1b[>4;0m'); + // Disable focus reporting to avoid stray ^[[I on mode switches. + process.stdout.write('\x1b[?1004l'); process.stdout.write('\x1b[?2004l'); } if (process.stdin.isTTY) { diff --git a/cli/src/utils/spawnWithAbort.ts b/cli/src/utils/spawnWithAbort.ts new file mode 100644 index 00000000..0b4462ff --- /dev/null +++ b/cli/src/utils/spawnWithAbort.ts @@ -0,0 +1,133 @@ +import { spawn, type SpawnOptions, type StdioOptions } from 'node:child_process'; +import { logger } from '@/ui/logger'; +import { killProcessByChildProcess } from '@/utils/process'; + +const DEFAULT_ABORT_EXIT_CODES = [130, 137, 143]; +const DEFAULT_ABORT_SIGNALS: NodeJS.Signals[] = ['SIGTERM']; + +const isAbortError = (error: unknown): boolean => { + if (!error || typeof error !== 'object') { + return false; + } + const maybeError = error as { name?: string; code?: string }; + return maybeError.name === 'AbortError' || maybeError.code === 'ABORT_ERR'; +}; + +export type SpawnWithAbortOptions = { + command: string; + args: string[]; + cwd: string; + env: NodeJS.ProcessEnv; + signal: AbortSignal; + logLabel: string; + spawnName: string; + installHint: string; + abortKillTimeoutMs?: number; + abortExitCodes?: number[]; + abortSignals?: NodeJS.Signals[]; + includeCause?: boolean; + logExit?: boolean; + shell?: SpawnOptions['shell']; + stdio?: StdioOptions; +}; + +export async function spawnWithAbort(options: SpawnWithAbortOptions): Promise { + const abortKillTimeoutMs = options.abortKillTimeoutMs ?? 5000; + const abortExitCodes = options.abortExitCodes ?? DEFAULT_ABORT_EXIT_CODES; + const abortSignals = options.abortSignals ?? DEFAULT_ABORT_SIGNALS; + const stdio = options.stdio ?? ['inherit', 'inherit', 'inherit']; + const logPrefix = options.logLabel ? `[${options.logLabel}] ` : ''; + + const logDebug = (message: string, ...args: unknown[]) => { + logger.debug(`${logPrefix}${message}`, ...args); + }; + + await new Promise((resolve, reject) => { + const child = spawn(options.command, options.args, { + stdio, + signal: options.signal, + cwd: options.cwd, + env: options.env, + shell: options.shell + }); + + let abortKillTimeout: NodeJS.Timeout | null = null; + + const abortHandler = () => { + if (abortKillTimeout) { + return; + } + abortKillTimeout = setTimeout(() => { + if (child.exitCode === null && !child.killed) { + logDebug('Abort timeout reached, sending SIGKILL'); + try { + void killProcessByChildProcess(child, true); + } catch (error) { + logDebug('Failed to send SIGKILL', error); + } + } + }, abortKillTimeoutMs); + }; + + if (options.signal.aborted) { + abortHandler(); + } else { + options.signal.addEventListener('abort', abortHandler); + } + + const cleanupAbortHandler = () => { + if (abortKillTimeout) { + clearTimeout(abortKillTimeout); + abortKillTimeout = null; + } + options.signal.removeEventListener('abort', abortHandler); + }; + + child.on('error', (error) => { + cleanupAbortHandler(); + if (options.signal.aborted && isAbortError(error)) { + logDebug('Spawn aborted while switching'); + if (!child.pid) { + resolve(); + } + return; + } + if (options.signal.aborted) { + resolve(); + return; + } + const message = error instanceof Error ? error.message : String(error); + const errorMessage = `Failed to spawn ${options.spawnName}: ${message}. ` + + `Is ${options.installHint} installed and on PATH?`; + if (options.includeCause) { + reject(new Error(errorMessage, { cause: error })); + } else { + reject(new Error(errorMessage)); + } + }); + + child.on('exit', (code, signal) => { + cleanupAbortHandler(); + if (options.logExit) { + logDebug(`Child exited (code=${code ?? 'null'}, signal=${signal ?? 'null'}, aborted=${options.signal.aborted})`); + } + if (options.signal.aborted && signal && abortSignals.includes(signal)) { + resolve(); + return; + } + if (options.signal.aborted && typeof code === 'number' && abortExitCodes.includes(code)) { + resolve(); + return; + } + if (signal) { + reject(new Error(`Process terminated with signal: ${signal}`)); + return; + } + if (typeof code === 'number' && code !== 0) { + reject(new Error(`Process exited with code: ${code}`)); + return; + } + resolve(); + }); + }); +}