diff --git a/tools/ai-cli-dispatch/src/cli.ts b/tools/ai-cli-dispatch/src/cli.ts index 32ec4cd..5be1ef3 100644 --- a/tools/ai-cli-dispatch/src/cli.ts +++ b/tools/ai-cli-dispatch/src/cli.ts @@ -8,6 +8,7 @@ import { type ClientName, type ClientInfo, type ExecResult, + type DebugInfo, ClientNotFoundError, } from "./types.js"; @@ -15,13 +16,14 @@ export interface CliDeps { detectClients?: () => ClientInfo[]; executePrompt?: ( client: ClientName, - prompt: string + prompt: string, + options?: { timeoutMs?: number; debug?: boolean; onDebug?: (info: DebugInfo) => void } ) => Promise; resolveClient?: ( prompt: string, config?: { client?: ClientName; defaultClient?: ClientName } ) => ClientName | null; - resolveConfig?: () => { paths: Partial>; defaultClient?: ClientName }; + resolveConfig?: () => { paths: Partial>; defaultClient?: ClientName; timeout?: number }; stdoutWrite?: (chunk: string) => void; stderrWrite?: (chunk: string) => void; } @@ -54,12 +56,13 @@ export async function main( rawArgs[0]?.includes("cli.ts") ? rawArgs.slice(1) : rawArgs; const args = minimist(parseArgs, { - string: ["client", "prompt"], + string: ["client", "prompt", "timeout"], boolean: ["json", "text", "help", "debug"], alias: { h: "help" }, }); const jsonMode = !args.text; + const debug = !!args.debug; if (args.help) { printHelp(); @@ -114,8 +117,18 @@ export async function main( return 1; } + const config = resolveConfig(); + const parsedTimeout = + typeof args.timeout === "string" ? Number(args.timeout) : undefined; + const timeoutMs = + Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout; + try { - const result = await executePrompt(client, prompt); + const result = await executePrompt(client, prompt, { + timeoutMs, + debug, + onDebug: debug ? (info) => stderrWrite(JSON.stringify(info) + "\n") : undefined, + }); if (jsonMode) { console.log(JSON.stringify(result, null, 2)); } else { @@ -167,8 +180,17 @@ export async function main( return 1; } + const parsedTimeout = + typeof args.timeout === "string" ? Number(args.timeout) : undefined; + const timeoutMs = + Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout; + try { - const result = await executePrompt(client, prompt); + const result = await executePrompt(client, prompt, { + timeoutMs, + debug, + onDebug: debug ? (info) => stderrWrite(JSON.stringify(info) + "\n") : undefined, + }); if (jsonMode) { console.log(JSON.stringify(result, null, 2)); } else { diff --git a/tools/ai-cli-dispatch/src/config.ts b/tools/ai-cli-dispatch/src/config.ts index 6fd1c15..8c0365c 100644 --- a/tools/ai-cli-dispatch/src/config.ts +++ b/tools/ai-cli-dispatch/src/config.ts @@ -10,6 +10,7 @@ import { CLIENT_NAMES, isWindows } from "./constants.js"; export interface ResolvedConfig { paths: Partial>; defaultClient?: ClientName; + timeout?: number; } export interface ResolveConfigOptions { @@ -89,5 +90,22 @@ export function resolveConfig( ) { result.defaultClient = defaultClient as ClientName; } + + const flagTimeout = + typeof flags.timeout === "string" ? Number(flags.timeout) : undefined; + const envTimeout = + typeof env.AI_CLI_TIMEOUT === "string" + ? Number(env.AI_CLI_TIMEOUT) + : undefined; + const fileTimeout = + typeof fileConfig.timeout === "number" ? fileConfig.timeout : undefined; + + const resolvedTimeout = + (Number.isFinite(flagTimeout) ? flagTimeout : undefined) ?? + (Number.isFinite(envTimeout) ? envTimeout : undefined) ?? + (Number.isFinite(fileTimeout) ? fileTimeout : undefined) ?? + 600_000; + + result.timeout = resolvedTimeout; return result; } diff --git a/tools/ai-cli-dispatch/src/dispatch.ts b/tools/ai-cli-dispatch/src/dispatch.ts index 9bc2a4c..3c2c6a2 100644 --- a/tools/ai-cli-dispatch/src/dispatch.ts +++ b/tools/ai-cli-dispatch/src/dispatch.ts @@ -1,3 +1,4 @@ +import { CLIENT_NAMES } from "./constants.js"; import type { ClientName } from "./types.js"; export interface DispatchConfig { @@ -5,8 +6,6 @@ export interface DispatchConfig { client?: ClientName; } -const CLIENT_NAMES: ClientName[] = ["codex", "claude", "opencode"]; - export function resolveClient( prompt: string, config?: DispatchConfig diff --git a/tools/ai-cli-dispatch/src/execute.ts b/tools/ai-cli-dispatch/src/execute.ts index 010fcf8..b443e6a 100644 --- a/tools/ai-cli-dispatch/src/execute.ts +++ b/tools/ai-cli-dispatch/src/execute.ts @@ -2,7 +2,7 @@ import type { ChildProcess } from "node:child_process"; import { spawn as defaultSpawn } from "node:child_process"; import type { PathLike } from "node:fs"; import { existsSync as defaultExistsSync } from "node:fs"; -import type { ClientName, ExecResult } from "./types.js"; +import type { ClientName, ExecResult, DebugInfo } from "./types.js"; import { ClientNotFoundError, ExecError } from "./types.js"; const CLIENT_ARGS: Record string[]> = { @@ -14,6 +14,8 @@ const CLIENT_ARGS: Record string[]> = { export interface ExecuteOptions { clientPath?: string; timeoutMs?: number; + debug?: boolean; + onDebug?: (info: DebugInfo) => void; spawn?: (command: string, args: string[], options?: { shell?: boolean }) => ChildProcess; existsSync?: (path: PathLike) => boolean; } @@ -28,12 +30,14 @@ export async function executePrompt( stdout: "", stderr: "", exitCode: -1, + client, + durationMs: 0, }); } const spawnImpl = options.spawn ?? defaultSpawn; const existsSyncImpl = options.existsSync ?? defaultExistsSync; - const timeoutMs = options.timeoutMs ?? 300_000; + const timeoutMs = options.timeoutMs ?? 600_000; const command = options.clientPath ?? client; if (options.clientPath && !existsSyncImpl(options.clientPath)) { @@ -46,6 +50,8 @@ export async function executePrompt( stdout: "", stderr: "", exitCode: -1, + client, + durationMs: 0, }); } const args = argBuilder(prompt); @@ -55,6 +61,8 @@ export async function executePrompt( let timedOut = false; let stdout = ""; let stderr = ""; + let exitSignal: NodeJS.Signals | null = null; + const startMs = Date.now(); const child = spawnImpl(command, args, { shell: false, @@ -80,8 +88,31 @@ export async function executePrompt( if (settled) return; settled = true; clearTimeout(timeout); - if (err) reject(err); - else resolve(result!); + const durationMs = Date.now() - startMs; + if (options.debug || options.onDebug) { + const effectiveExitCode = result?.exitCode ?? (err instanceof ExecError ? err.result.exitCode : null); + const debugInfo: DebugInfo = { + command, + args, + pid: child.pid ?? undefined, + exitCode: effectiveExitCode, + exitSignal, + durationMs, + stderrLength: stderr.length, + stdoutLength: stdout.length, + noisySuccess: effectiveExitCode === 0 && stderr.length > 0, + }; + options.onDebug?.(debugInfo); + } + if (err) { + if (err instanceof ExecError) { + err.result.client = client; + err.result.durationMs = durationMs; + } + reject(err); + } else { + resolve({ ...result!, client, durationMs }); + } } child.on("error", (err: NodeJS.ErrnoException) => { @@ -89,22 +120,25 @@ export async function executePrompt( settle(new ClientNotFoundError(client)); } else { settle( - new ExecError(err.message, { stdout, stderr, exitCode: -1 }) + new ExecError(err.message, { stdout, stderr, exitCode: -1, client, durationMs: 0 }) ); } }); - child.on("close", (code: number | null) => { + child.on("close", (code: number | null, signal: NodeJS.Signals | null) => { + exitSignal = signal; if (timedOut) { settle( new ExecError(`Execution timed out after ${timeoutMs}ms`, { stdout, stderr, exitCode: -1, + client, + durationMs: 0, }) ); } else { - settle(undefined, { stdout, stderr, exitCode: code ?? -1 }); + settle(undefined, { stdout, stderr, exitCode: code ?? -1, client, durationMs: 0 }); } }); }); diff --git a/tools/ai-cli-dispatch/src/types.ts b/tools/ai-cli-dispatch/src/types.ts index 3338814..0bf167d 100644 --- a/tools/ai-cli-dispatch/src/types.ts +++ b/tools/ai-cli-dispatch/src/types.ts @@ -11,11 +11,20 @@ export interface ExecResult { stdout: string; stderr: string; exitCode: number; + client: ClientName; + durationMs: number; } -export interface ToolConfig { - clients: ClientName[]; - defaultClient?: ClientName; +export interface DebugInfo { + command: string; + args: string[]; + pid?: number; + exitCode: number | null; + exitSignal: NodeJS.Signals | null; + durationMs: number; + stderrLength: number; + stdoutLength: number; + noisySuccess: boolean; } export class ClientNotFoundError extends Error { diff --git a/tools/ai-cli-dispatch/tests/cli.test.ts b/tools/ai-cli-dispatch/tests/cli.test.ts index ec5c80d..5d0d846 100644 --- a/tools/ai-cli-dispatch/tests/cli.test.ts +++ b/tools/ai-cli-dispatch/tests/cli.test.ts @@ -39,6 +39,8 @@ describe("main", () => { stdout: "output", stderr: "", exitCode: 0, + client: "codex", + durationMs: 42, }; it("returns 0 for --help and prints usage", async () => { @@ -152,6 +154,8 @@ describe("main", () => { stdout: "hello-out", stderr: "hello-err", exitCode: 0, + client: "codex", + durationMs: 42, }), stdoutWrite: (chunk) => stdoutChunks.push(chunk), stderrWrite: (chunk) => stderrChunks.push(chunk), @@ -259,7 +263,7 @@ describe("main", () => { return mockResult; }, resolveClient: () => "claude", - resolveConfig: () => ({ paths: {} }), + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), } ); assert.strictEqual(code, 0); @@ -286,7 +290,7 @@ describe("main", () => { return mockResult; }, resolveClient: () => "codex", - resolveConfig: () => ({ paths: {} }), + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), } ); assert.strictEqual(code, 0); @@ -310,7 +314,7 @@ describe("main", () => { return mockResult; }, resolveClient: (_p, cfg) => cfg?.client ?? null, - resolveConfig: () => ({ paths: {}, defaultClient: "claude" }), + resolveConfig: () => ({ paths: {}, defaultClient: "claude", timeout: 600_000 }), } ); assert.strictEqual(code, 0); @@ -329,7 +333,7 @@ describe("main", () => { detectClients: () => mockClients, executePrompt: async () => mockResult, resolveClient: () => null, - resolveConfig: () => ({ paths: {} }), + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), } ); assert.strictEqual(code, 1); @@ -351,7 +355,7 @@ describe("main", () => { throw new ClientNotFoundError("claude"); }, resolveClient: () => "claude", - resolveConfig: () => ({ paths: {} }), + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), } ); assert.strictEqual(code, 1); @@ -388,9 +392,11 @@ describe("main", () => { stdout: "done", stderr: "", exitCode: 0, + client: "codex", + durationMs: 42, }), resolveClient: () => "codex", - resolveConfig: () => ({ paths: {} }), + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), stdoutWrite: (chunk) => stdoutChunks.push(chunk), } ); @@ -400,4 +406,136 @@ describe("main", () => { out.restore(); } }); + + it("run prints debug diagnostic JSON to stderr with --debug", async () => { + const out = captureOutput(); + const stderrChunks: string[] = []; + try { + const code = await main( + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--debug"], + { + detectClients: () => mockClients, + executePrompt: async (_client, _prompt, options?) => { + options?.onDebug?.({ + command: "codex", + args: ["exec", "--yolo", "hello"], + pid: 12345, + exitCode: 0, + exitSignal: null, + durationMs: 42, + stderrLength: 0, + stdoutLength: 6, + } as any); + return { + stdout: "output", + stderr: "", + exitCode: 0, + client: "codex", + durationMs: 42, + }; + }, + stderrWrite: (chunk) => stderrChunks.push(chunk), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(stderrChunks.length, 1); + const diag = JSON.parse(stderrChunks[0]); + assert.strictEqual(diag.command, "codex"); + assert.deepStrictEqual(diag.args, ["exec", "--yolo", "hello"]); + assert.strictEqual(diag.pid, 12345); + assert.strictEqual(diag.exitCode, 0); + assert.strictEqual(diag.exitSignal, null); + assert.strictEqual(diag.durationMs, 42); + assert.strictEqual(diag.stderrLength, 0); + } finally { + out.restore(); + } + }); + + it("run ignores non-numeric --timeout and falls back to config timeout", async () => { + const out = captureOutput(); + try { + let receivedTimeout: number | undefined; + const code = await main( + ["node", "cli.ts", "run", "--client", "codex", "--prompt", "hello", "--timeout", "not-a-number"], + { + detectClients: () => mockClients, + executePrompt: async (_client, _prompt, options?) => { + receivedTimeout = options?.timeoutMs; + return mockResult; + }, + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedTimeout, 600_000); + } finally { + out.restore(); + } + }); + + it("dispatch ignores non-numeric --timeout and falls back to config timeout", async () => { + const out = captureOutput(); + try { + let receivedTimeout: number | undefined; + const code = await main( + ["node", "cli.ts", "dispatch", "do something", "--timeout", "bad"], + { + detectClients: () => mockClients, + executePrompt: async (_client, _prompt, options?) => { + receivedTimeout = options?.timeoutMs; + return mockResult; + }, + resolveClient: () => "codex", + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(receivedTimeout, 600_000); + } finally { + out.restore(); + } + }); + + it("dispatch prints debug diagnostic JSON to stderr with --debug", async () => { + const out = captureOutput(); + const stderrChunks: string[] = []; + try { + const code = await main( + ["node", "cli.ts", "dispatch", "do something", "--debug"], + { + detectClients: () => mockClients, + executePrompt: async (_client, _prompt, options?) => { + options?.onDebug?.({ + command: "codex", + args: ["exec", "--yolo", "do something"], + pid: 12345, + exitCode: 0, + exitSignal: null, + durationMs: 42, + stderrLength: 0, + stdoutLength: 6, + } as any); + return { + stdout: "output", + stderr: "", + exitCode: 0, + client: "codex", + durationMs: 42, + }; + }, + resolveClient: () => "codex", + resolveConfig: () => ({ paths: {}, timeout: 600_000 }), + stderrWrite: (chunk) => stderrChunks.push(chunk), + } + ); + assert.strictEqual(code, 0); + assert.strictEqual(stderrChunks.length, 1); + const diag = JSON.parse(stderrChunks[0]); + assert.strictEqual(diag.command, "codex"); + assert.strictEqual(diag.durationMs, 42); + } finally { + out.restore(); + } + }); }); diff --git a/tools/ai-cli-dispatch/tests/config.test.ts b/tools/ai-cli-dispatch/tests/config.test.ts index 99d505c..1598dd9 100644 --- a/tools/ai-cli-dispatch/tests/config.test.ts +++ b/tools/ai-cli-dispatch/tests/config.test.ts @@ -135,4 +135,71 @@ describe("resolveConfig", () => { }); assert.strictEqual(config.defaultClient, undefined); }); + + it("returns default timeout of 600000 when no sources are present", () => { + const config = resolveConfig({ + existsSync: () => false, + whichSync: () => undefined, + }); + assert.strictEqual(config.timeout, 600_000); + }); + + it("loads timeout from file config", () => { + const config = resolveConfig({ + existsSync: () => true, + readFileSync: () => JSON.stringify({ timeout: 120_000 }), + whichSync: () => undefined, + }); + assert.strictEqual(config.timeout, 120_000); + }); + + it("overrides file timeout with env var", () => { + const config = resolveConfig({ + env: { AI_CLI_TIMEOUT: "240000" }, + existsSync: () => true, + readFileSync: () => JSON.stringify({ timeout: 120_000 }), + whichSync: () => undefined, + }); + assert.strictEqual(config.timeout, 240_000); + }); + + it("overrides env timeout with CLI flag", () => { + const config = resolveConfig({ + flags: { timeout: "480000" }, + env: { AI_CLI_TIMEOUT: "240000" }, + existsSync: () => true, + readFileSync: () => JSON.stringify({ timeout: 120_000 }), + whichSync: () => undefined, + }); + assert.strictEqual(config.timeout, 480_000); + }); + + it("respects full priority ordering for timeout: flag > env > file > default", () => { + const config = resolveConfig({ + flags: { timeout: "480000" }, + env: { AI_CLI_TIMEOUT: "240000" }, + existsSync: () => true, + readFileSync: () => JSON.stringify({ timeout: 120_000 }), + whichSync: () => undefined, + }); + assert.strictEqual(config.timeout, 480_000); + }); + + it("ignores invalid timeout from env var and falls back to default", () => { + const config = resolveConfig({ + env: { AI_CLI_TIMEOUT: "not-a-number" }, + existsSync: () => false, + whichSync: () => undefined, + }); + assert.strictEqual(config.timeout, 600_000); + }); + + it("ignores invalid timeout from file and falls back to default", () => { + const config = resolveConfig({ + existsSync: () => true, + readFileSync: () => JSON.stringify({ timeout: "not-a-number" }), + whichSync: () => undefined, + }); + assert.strictEqual(config.timeout, 600_000); + }); }); diff --git a/tools/ai-cli-dispatch/tests/execute.test.ts b/tools/ai-cli-dispatch/tests/execute.test.ts index f50225f..c2ea7df 100644 --- a/tools/ai-cli-dispatch/tests/execute.test.ts +++ b/tools/ai-cli-dispatch/tests/execute.test.ts @@ -16,6 +16,7 @@ interface MockScenario { function createMockChildProcess(scenario: MockScenario): any { const child = new EventEmitter() as any; + child.pid = 12345; child.stdout = Readable.from( scenario.stdout !== undefined ? [scenario.stdout] : [] ); @@ -203,4 +204,162 @@ describe("executePrompt", () => { err instanceof ExecError && err.message.includes("Unknown client") ); }); + + it("includes client and durationMs in result", async () => { + const scenarios = new Map([ + ["codex exec --yolo hello", { stdout: "ok", exitCode: 0 }], + ]); + const result = await executePrompt("codex", "hello", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + }); + assert.strictEqual(result.client, "codex"); + assert.strictEqual(typeof result.durationMs, "number"); + assert.ok(result.durationMs >= 0); + }); + + it("rejects with ExecError containing custom timeout value", async () => { + const scenarios = new Map([ + ["codex exec --yolo slow", { hang: true }], + ]); + await assert.rejects( + executePrompt("codex", "slow", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + timeoutMs: 50, + }), + (err: unknown) => + err instanceof ExecError && + err.message === "Execution timed out after 50ms" && + err.result.exitCode === -1 && + err.result.client === "codex" && + typeof err.result.durationMs === "number" + ); + }); + + it("uses default timeout of 600000 when timeoutMs is not provided", async () => { + const delays: number[] = []; + const origSetTimeout = global.setTimeout; + (global as any).setTimeout = function(callback: any, delay: number) { + delays.push(delay); + return origSetTimeout(callback, delay); + }; + + const scenarios = new Map([ + ["codex exec --yolo hello", { stdout: "ok", exitCode: 0 }], + ]); + try { + await executePrompt("codex", "hello", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + }); + assert.strictEqual(delays[0], 600_000); + } finally { + global.setTimeout = origSetTimeout; + } + }); + + it("emits debug info via onDebug when debug is true for successful execution", async () => { + const scenarios = new Map([ + ["codex exec --yolo hello", { stdout: "ok", stderr: "warn", exitCode: 0 }], + ]); + const debugInfos: any[] = []; + const result = await executePrompt("codex", "hello", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + debug: true, + onDebug: (info) => debugInfos.push(info), + }); + assert.strictEqual(result.exitCode, 0); + assert.strictEqual(debugInfos.length, 1); + const info = debugInfos[0]; + assert.strictEqual(info.command, "codex"); + assert.deepStrictEqual(info.args, ["exec", "--yolo", "hello"]); + assert.strictEqual(info.pid, 12345); + assert.strictEqual(info.exitCode, 0); + assert.strictEqual(info.exitSignal, null); + assert.strictEqual(info.stderrLength, 4); + assert.strictEqual(info.stdoutLength, 2); + assert.strictEqual(typeof info.durationMs, "number"); + assert.ok(info.durationMs >= 0); + }); + + it("emits debug info via onDebug when debug is true for failed execution", async () => { + const scenarios = new Map([ + ["codex exec --yolo fail", { stdout: "", stderr: "error", exitCode: 1 }], + ]); + const debugInfos: any[] = []; + const result = await executePrompt("codex", "fail", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + debug: true, + onDebug: (info) => debugInfos.push(info), + }); + assert.strictEqual(result.exitCode, 1); + assert.strictEqual(debugInfos.length, 1); + assert.strictEqual(debugInfos[0].exitCode, 1); + assert.strictEqual(debugInfos[0].stderrLength, 5); + assert.strictEqual(debugInfos[0].stdoutLength, 0); + }); + + it("emits debug info via onDebug for spawn errors", async () => { + const scenarios = new Map(); + const debugInfos: any[] = []; + await assert.rejects( + executePrompt("codex", "hello", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + debug: true, + onDebug: (info) => debugInfos.push(info), + }), + (err: unknown) => err instanceof ClientNotFoundError + ); + assert.strictEqual(debugInfos.length, 1); + assert.strictEqual(debugInfos[0].command, "codex"); + assert.deepStrictEqual(debugInfos[0].args, ["exec", "--yolo", "hello"]); + assert.strictEqual(debugInfos[0].exitCode, null); + assert.strictEqual(debugInfos[0].exitSignal, null); + }); + + it("reports noisySuccess=true when stderr is non-empty and exitCode is 0", async () => { + const scenarios = new Map([ + ["codex exec --yolo hello", { stdout: "ok", stderr: "warn", exitCode: 0 }], + ]); + const debugInfos: any[] = []; + await executePrompt("codex", "hello", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + debug: true, + onDebug: (info) => debugInfos.push(info), + }); + assert.strictEqual(debugInfos[0].noisySuccess, true); + }); + + it("reports noisySuccess=false when stderr is empty and exitCode is 0", async () => { + const scenarios = new Map([ + ["codex exec --yolo hello", { stdout: "ok", stderr: "", exitCode: 0 }], + ]); + const debugInfos: any[] = []; + await executePrompt("codex", "hello", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + debug: true, + onDebug: (info) => debugInfos.push(info), + }); + assert.strictEqual(debugInfos[0].noisySuccess, false); + }); + + it("reports noisySuccess=false when exitCode is non-zero even if stderr is non-empty", async () => { + const scenarios = new Map([ + ["codex exec --yolo fail", { stdout: "", stderr: "error", exitCode: 1 }], + ]); + const debugInfos: any[] = []; + await executePrompt("codex", "fail", { + spawn: mockSpawn(scenarios), + existsSync: () => true, + debug: true, + onDebug: (info) => debugInfos.push(info), + }); + assert.strictEqual(debugInfos[0].noisySuccess, false); + }); });