From 476dd317b33c86ad17eb1ad09795e94004ec37ae Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Tue, 19 May 2026 19:20:53 -0500 Subject: [PATCH 1/5] feat(S-101): Extend types.ts with ExecResult metadata, timeout config shape, and debu --- tools/ai-cli-dispatch/src/types.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tools/ai-cli-dispatch/src/types.ts b/tools/ai-cli-dispatch/src/types.ts index 3338814..166e651 100644 --- a/tools/ai-cli-dispatch/src/types.ts +++ b/tools/ai-cli-dispatch/src/types.ts @@ -11,6 +11,33 @@ export interface ExecResult { stdout: string; stderr: string; exitCode: number; + client: ClientName; + durationMs: number; +} + +export interface DebugInfo { + command: string; + args: string[]; + pid?: number; + exitCode: number | null; + exitSignal: NodeJS.Signals | null; + durationMs: number; + stderrLength: number; + stdoutLength: number; +} + +export interface DispatchConfigFile { + paths?: Partial>; + defaultClient?: ClientName; + timeout?: number; +} + +export interface DispatchEnv { + AI_CLI_DEFAULT_CLIENT?: string; + AI_CLI_TIMEOUT?: string; + AI_CLI_CODEX_PATH?: string; + AI_CLI_CLAUDE_PATH?: string; + AI_CLI_OPENCODE_PATH?: string; } export interface ToolConfig { From dc3fe8d6eb2326c84fb3122a024f42a31698346f Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Tue, 19 May 2026 19:39:46 -0500 Subject: [PATCH 2/5] feat(S-102): Test-drive and implement --timeout flag, config layering, and default in --- tools/ai-cli-dispatch/src/cli.ts | 15 +++-- tools/ai-cli-dispatch/src/config.ts | 18 ++++++ tools/ai-cli-dispatch/src/execute.ts | 25 ++++++-- tools/ai-cli-dispatch/tests/cli.test.ts | 18 ++++-- tools/ai-cli-dispatch/tests/config.test.ts | 67 +++++++++++++++++++++ tools/ai-cli-dispatch/tests/execute.test.ts | 54 +++++++++++++++++ 6 files changed, 182 insertions(+), 15 deletions(-) diff --git a/tools/ai-cli-dispatch/src/cli.ts b/tools/ai-cli-dispatch/src/cli.ts index 32ec4cd..3e5ab9a 100644 --- a/tools/ai-cli-dispatch/src/cli.ts +++ b/tools/ai-cli-dispatch/src/cli.ts @@ -21,7 +21,7 @@ export interface CliDeps { 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,7 +54,7 @@ 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" }, }); @@ -114,8 +114,12 @@ export async function main( return 1; } + const config = resolveConfig(); + const timeoutMs = + typeof args.timeout === "string" ? Number(args.timeout) : config.timeout; + try { - const result = await executePrompt(client, prompt); + const result = await executePrompt(client, prompt, { timeoutMs }); if (jsonMode) { console.log(JSON.stringify(result, null, 2)); } else { @@ -167,8 +171,11 @@ export async function main( return 1; } + const timeoutMs = + typeof args.timeout === "string" ? Number(args.timeout) : config.timeout; + try { - const result = await executePrompt(client, prompt); + const result = await executePrompt(client, prompt, { timeoutMs }); 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/execute.ts b/tools/ai-cli-dispatch/src/execute.ts index 010fcf8..e07fcb7 100644 --- a/tools/ai-cli-dispatch/src/execute.ts +++ b/tools/ai-cli-dispatch/src/execute.ts @@ -28,12 +28,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 +48,8 @@ export async function executePrompt( stdout: "", stderr: "", exitCode: -1, + client, + durationMs: 0, }); } const args = argBuilder(prompt); @@ -55,6 +59,7 @@ export async function executePrompt( let timedOut = false; let stdout = ""; let stderr = ""; + const startMs = Date.now(); const child = spawnImpl(command, args, { shell: false, @@ -80,8 +85,16 @@ export async function executePrompt( if (settled) return; settled = true; clearTimeout(timeout); - if (err) reject(err); - else resolve(result!); + const durationMs = Date.now() - startMs; + 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,7 +102,7 @@ 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 }) ); } }); @@ -101,10 +114,12 @@ export async function executePrompt( 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/tests/cli.test.ts b/tools/ai-cli-dispatch/tests/cli.test.ts index ec5c80d..4d2bc24 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), } ); 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..6bdeac6 100644 --- a/tools/ai-cli-dispatch/tests/execute.test.ts +++ b/tools/ai-cli-dispatch/tests/execute.test.ts @@ -203,4 +203,58 @@ 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; + } + }); }); From 94389df6f1e44084f64a0c8dfe6d6402e777deec Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Tue, 19 May 2026 19:48:40 -0500 Subject: [PATCH 3/5] feat(S-103): Test-drive and implement --debug diagnostic mode --- tools/ai-cli-dispatch/src/cli.ts | 17 +++- tools/ai-cli-dispatch/src/execute.ts | 21 ++++- tools/ai-cli-dispatch/tests/cli.test.ts | 87 +++++++++++++++++++++ tools/ai-cli-dispatch/tests/execute.test.ts | 63 +++++++++++++++ 4 files changed, 183 insertions(+), 5 deletions(-) diff --git a/tools/ai-cli-dispatch/src/cli.ts b/tools/ai-cli-dispatch/src/cli.ts index 3e5ab9a..f619b09 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,7 +16,8 @@ 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, @@ -60,6 +62,7 @@ export async function main( }); const jsonMode = !args.text; + const debug = !!args.debug; if (args.help) { printHelp(); @@ -119,7 +122,11 @@ export async function main( typeof args.timeout === "string" ? Number(args.timeout) : config.timeout; try { - const result = await executePrompt(client, prompt, { timeoutMs }); + 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 { @@ -175,7 +182,11 @@ export async function main( typeof args.timeout === "string" ? Number(args.timeout) : config.timeout; try { - const result = await executePrompt(client, prompt, { timeoutMs }); + 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/execute.ts b/tools/ai-cli-dispatch/src/execute.ts index e07fcb7..cf39ba3 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; } @@ -59,6 +61,7 @@ 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, { @@ -86,6 +89,19 @@ export async function executePrompt( settled = true; clearTimeout(timeout); const durationMs = Date.now() - startMs; + if (options.debug || options.onDebug) { + const debugInfo: DebugInfo = { + command, + args, + pid: child.pid ?? undefined, + exitCode: result?.exitCode ?? (err instanceof ExecError ? err.result.exitCode : null), + exitSignal, + durationMs, + stderrLength: stderr.length, + stdoutLength: stdout.length, + }; + options.onDebug?.(debugInfo); + } if (err) { if (err instanceof ExecError) { err.result.client = client; @@ -107,7 +123,8 @@ export async function executePrompt( } }); - 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`, { diff --git a/tools/ai-cli-dispatch/tests/cli.test.ts b/tools/ai-cli-dispatch/tests/cli.test.ts index 4d2bc24..03b412b 100644 --- a/tools/ai-cli-dispatch/tests/cli.test.ts +++ b/tools/ai-cli-dispatch/tests/cli.test.ts @@ -406,4 +406,91 @@ 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("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/execute.test.ts b/tools/ai-cli-dispatch/tests/execute.test.ts index 6bdeac6..e8410e8 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] : [] ); @@ -257,4 +258,66 @@ describe("executePrompt", () => { 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); + }); }); From 1983dd82e7614198ff90b537583bb51a837bf0f7 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Tue, 19 May 2026 19:51:10 -0500 Subject: [PATCH 4/5] feat(S-104): Add stderr-length and exit-code correlation diagnostics --- tools/ai-cli-dispatch/src/execute.ts | 4 +- tools/ai-cli-dispatch/src/types.ts | 1 + tools/ai-cli-dispatch/tests/execute.test.ts | 42 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/tools/ai-cli-dispatch/src/execute.ts b/tools/ai-cli-dispatch/src/execute.ts index cf39ba3..b443e6a 100644 --- a/tools/ai-cli-dispatch/src/execute.ts +++ b/tools/ai-cli-dispatch/src/execute.ts @@ -90,15 +90,17 @@ export async function executePrompt( clearTimeout(timeout); 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: result?.exitCode ?? (err instanceof ExecError ? err.result.exitCode : null), + exitCode: effectiveExitCode, exitSignal, durationMs, stderrLength: stderr.length, stdoutLength: stdout.length, + noisySuccess: effectiveExitCode === 0 && stderr.length > 0, }; options.onDebug?.(debugInfo); } diff --git a/tools/ai-cli-dispatch/src/types.ts b/tools/ai-cli-dispatch/src/types.ts index 166e651..82008ce 100644 --- a/tools/ai-cli-dispatch/src/types.ts +++ b/tools/ai-cli-dispatch/src/types.ts @@ -24,6 +24,7 @@ export interface DebugInfo { durationMs: number; stderrLength: number; stdoutLength: number; + noisySuccess: boolean; } export interface DispatchConfigFile { diff --git a/tools/ai-cli-dispatch/tests/execute.test.ts b/tools/ai-cli-dispatch/tests/execute.test.ts index e8410e8..c2ea7df 100644 --- a/tools/ai-cli-dispatch/tests/execute.test.ts +++ b/tools/ai-cli-dispatch/tests/execute.test.ts @@ -320,4 +320,46 @@ describe("executePrompt", () => { 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); + }); }); From bcddb4260827c9e3f430631a06336825f0526450 Mon Sep 17 00:00:00 2001 From: Stefano Fiorini Date: Tue, 19 May 2026 19:54:27 -0500 Subject: [PATCH 5/5] feat(M1): Codex Reliability Fix --- tools/ai-cli-dispatch/src/cli.ts | 8 +++-- tools/ai-cli-dispatch/src/dispatch.ts | 3 +- tools/ai-cli-dispatch/src/types.ts | 19 ----------- tools/ai-cli-dispatch/tests/cli.test.ts | 45 +++++++++++++++++++++++++ 4 files changed, 52 insertions(+), 23 deletions(-) diff --git a/tools/ai-cli-dispatch/src/cli.ts b/tools/ai-cli-dispatch/src/cli.ts index f619b09..5be1ef3 100644 --- a/tools/ai-cli-dispatch/src/cli.ts +++ b/tools/ai-cli-dispatch/src/cli.ts @@ -118,8 +118,10 @@ export async function main( } const config = resolveConfig(); + const parsedTimeout = + typeof args.timeout === "string" ? Number(args.timeout) : undefined; const timeoutMs = - typeof args.timeout === "string" ? Number(args.timeout) : config.timeout; + Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout; try { const result = await executePrompt(client, prompt, { @@ -178,8 +180,10 @@ export async function main( return 1; } + const parsedTimeout = + typeof args.timeout === "string" ? Number(args.timeout) : undefined; const timeoutMs = - typeof args.timeout === "string" ? Number(args.timeout) : config.timeout; + Number.isFinite(parsedTimeout) ? parsedTimeout : config.timeout; try { const result = await executePrompt(client, prompt, { 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/types.ts b/tools/ai-cli-dispatch/src/types.ts index 82008ce..0bf167d 100644 --- a/tools/ai-cli-dispatch/src/types.ts +++ b/tools/ai-cli-dispatch/src/types.ts @@ -27,25 +27,6 @@ export interface DebugInfo { noisySuccess: boolean; } -export interface DispatchConfigFile { - paths?: Partial>; - defaultClient?: ClientName; - timeout?: number; -} - -export interface DispatchEnv { - AI_CLI_DEFAULT_CLIENT?: string; - AI_CLI_TIMEOUT?: string; - AI_CLI_CODEX_PATH?: string; - AI_CLI_CLAUDE_PATH?: string; - AI_CLI_OPENCODE_PATH?: string; -} - -export interface ToolConfig { - clients: ClientName[]; - defaultClient?: ClientName; -} - export class ClientNotFoundError extends Error { constructor(client: string) { super(`Client "${client}" not found or not installed.`); diff --git a/tools/ai-cli-dispatch/tests/cli.test.ts b/tools/ai-cli-dispatch/tests/cli.test.ts index 03b412b..5d0d846 100644 --- a/tools/ai-cli-dispatch/tests/cli.test.ts +++ b/tools/ai-cli-dispatch/tests/cli.test.ts @@ -452,6 +452,51 @@ describe("main", () => { } }); + 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[] = [];