017eb1b410
When codex exec receives a prompt as a positional argument, it still tries to read additional input from stdin (prints 'Reading additional input from stdin...'). With stdio stdin set to 'ignore' or default, codex blocks indefinitely waiting for stdin that never comes. Fix: use stdio ['pipe', 'pipe', 'pipe'] and immediately close stdin via child.stdin.end() in both execute.ts (sync) and jobs.ts (async). This signals EOF to codex so it proceeds with the positional prompt.
165 lines
5.1 KiB
TypeScript
165 lines
5.1 KiB
TypeScript
import { spawn as defaultSpawn } from "node:child_process";
|
|
import { existsSync as defaultExistsSync } from "node:fs";
|
|
import type { ClientName, ExecResult, DebugInfo, ExecuteOptions } from "./types.js";
|
|
import { ClientNotFoundError, ExecError } from "./types.js";
|
|
|
|
export const CLIENT_ARGS: Record<ClientName, (prompt: string) => string[]> = {
|
|
codex: (p) => ["exec", "--yolo", p],
|
|
claude: (p) => ["-p", p, "--dangerously-skip-permissions"],
|
|
opencode: (p) => ["run", "--dangerously-skip-permissions", p],
|
|
};
|
|
|
|
/**
|
|
* Known stderr noise patterns per client.
|
|
* When exit code is 0, lines matching these patterns are stripped from the
|
|
* returned stderr to prevent agents from misinterpreting informational
|
|
* diagnostics as errors. The raw (unfiltered) stderr is preserved in
|
|
* DebugInfo.rawStderr when --debug is active.
|
|
*/
|
|
const STDERR_NOISE_PATTERNS: Partial<Record<ClientName, RegExp[]>> = {
|
|
codex: [
|
|
/^\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+ERROR\s+codex_core::util:\s+ReasoningSummary\w*\s+/,
|
|
],
|
|
};
|
|
|
|
function filterStderrNoise(client: ClientName, stderr: string, exitCode: number): string {
|
|
if (exitCode !== 0) return stderr;
|
|
const patterns = STDERR_NOISE_PATTERNS[client];
|
|
if (!patterns) return stderr;
|
|
const lines = stderr.split("\n");
|
|
const filtered = lines.filter((line) => !patterns.some((p) => p.test(line)));
|
|
return filtered.join("\n").replace(/\n+$/, "");
|
|
}
|
|
|
|
export async function executePrompt(
|
|
client: ClientName,
|
|
prompt: string,
|
|
options: ExecuteOptions = {}
|
|
): Promise<ExecResult> {
|
|
if (prompt.trim() === "") {
|
|
throw new ExecError("Prompt cannot be empty", {
|
|
stdout: "",
|
|
stderr: "",
|
|
exitCode: -1,
|
|
client,
|
|
durationMs: 0,
|
|
});
|
|
}
|
|
|
|
const spawnImpl = options.spawn ?? defaultSpawn;
|
|
const existsSyncImpl = options.existsSync ?? defaultExistsSync;
|
|
const timeoutMs = options.timeoutMs ?? 600_000;
|
|
|
|
const command = options.clientPath ?? client;
|
|
if (options.clientPath && !existsSyncImpl(options.clientPath)) {
|
|
throw new ClientNotFoundError(client);
|
|
}
|
|
|
|
const argBuilder = (CLIENT_ARGS as Record<string, (prompt: string) => string[]>)[client];
|
|
if (!argBuilder) {
|
|
throw new ExecError(`Unknown client: ${client}`, {
|
|
stdout: "",
|
|
stderr: "",
|
|
exitCode: -1,
|
|
client,
|
|
durationMs: 0,
|
|
});
|
|
}
|
|
const args = argBuilder(prompt);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let timedOut = false;
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let exitSignal: NodeJS.Signals | null = null;
|
|
const startMs = Date.now();
|
|
|
|
const child = spawnImpl(command, args, {
|
|
shell: false,
|
|
stdio: ["pipe", "pipe", "pipe"],
|
|
});
|
|
|
|
// Close stdin immediately so clients like codex don't hang waiting for input
|
|
child.stdin?.end();
|
|
|
|
child.stdout?.on("data", (chunk: Buffer | string) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
|
|
child.stderr?.on("data", (chunk: Buffer | string) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
|
|
const timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
child.kill();
|
|
}, timeoutMs);
|
|
|
|
function settle(
|
|
err?: Error | undefined,
|
|
result?: ExecResult | undefined
|
|
): void {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timeout);
|
|
const durationMs = Date.now() - startMs;
|
|
const rawStderr = stderr;
|
|
const cleanedStderr = filterStderrNoise(client, rawStderr, result?.exitCode ?? -1);
|
|
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: rawStderr.length,
|
|
stdoutLength: stdout.length,
|
|
noisySuccess: effectiveExitCode === 0 && rawStderr.length > 0,
|
|
rawStderr: rawStderr !== cleanedStderr ? rawStderr : undefined,
|
|
};
|
|
options.onDebug?.(debugInfo);
|
|
}
|
|
if (err) {
|
|
if (err instanceof ExecError) {
|
|
err.result.stderr = cleanedStderr;
|
|
err.result.client = client;
|
|
err.result.durationMs = durationMs;
|
|
}
|
|
reject(err);
|
|
} else {
|
|
resolve({ ...result!, client, durationMs, stderr: cleanedStderr });
|
|
}
|
|
}
|
|
|
|
child.on("error", (err: NodeJS.ErrnoException) => {
|
|
if (err.code === "ENOENT") {
|
|
settle(new ClientNotFoundError(client));
|
|
} else {
|
|
settle(
|
|
new ExecError(err.message, { stdout, stderr, exitCode: -1, client, durationMs: 0 })
|
|
);
|
|
}
|
|
});
|
|
|
|
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, client, durationMs: 0 });
|
|
}
|
|
});
|
|
});
|
|
}
|