/** * Inference dispatcher. * * Public entry: `inference(opts)`. Internally routes to the best available path * based on the active agent, claude-binary availability, and recursion depth. * * Routing order (first match wins): * 1. depth >= MAX_DEPTH → refuse (prevents recursion if env leaks) * 2. isClaude() + claude on PATH → inferenceViaClaudeSpawn (subscription-billed) * 3. hasApiKey() → inferenceViaApi (current Anthropic API path) * 4. otherwise → { success: false } * * The claude-spawn path mirrors PAI/TOOLS/Inference.ts in spawn args: * --print --tools '' --setting-sources '' --output-format text --system-prompt … * These flags prevent the spawned subprocess from loading PAL hooks or making * tool calls — the primary recursion defense. PAL's spawn-guard env sentinel * is the secondary belt-and-suspenders layer (see lib/spawn-guard.ts). * * Other-agent dispatchers (codex exec, copilot -p, cursor-agent -p) are not * yet wired and currently fall through to the API path. */ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { basename, join } from "node:path"; import { getActiveAgent, isAntigravity, isClaude, isCodex, isCopilot, isCursor, isOpencode, } from "./agent"; import { extractAntigravityText, INFERENCE_AGENT, removeSpawnedConversation, streamJsonPrompt, writeInferenceAgent, } from "./antigravity-inference"; import { logDebug, logError } from "./log"; import { type FixedModelRoute, type InferenceTier, inferenceModel, isFixedModelRoute, } from "./models"; import { opencodeTierModel, writeInstructionFreeConfig } from "./opencode-config"; import { withPalEnv } from "./pal-env"; import { buildSpawnGuardEnv, getInferenceDepth, SPAWN_GUARD_ENV } from "./spawn-guard"; import { findBinaryOnPath } from "./which"; export function hasApiKey(): boolean { return !!withPalEnv().PAL_ANTHROPIC_API_KEY; } export function hasOpenAiKey(): boolean { return !!withPalEnv().PAL_OPENAI_API_KEY; } type InferenceRoute = | FixedModelRoute | "opencode-spawn" | "copilot-spawn" | "cursor-spawn" | "disabled" | "none"; interface RoutePreview { agent: string; route: InferenceRoute; reason: string; } function modelFor(route: FixedModelRoute, opts: InferenceOptions): string { return inferenceModel(route, opts.tier, opts.caller); } function smallModelOf(route: InferenceRoute): string | undefined { if (route === "opencode-spawn") return opencodeTierModel("small") ?? undefined; return isFixedModelRoute(route) ? inferenceModel(route, "small") : undefined; } /** * Preview what `inference()` would do RIGHT NOW given current env + binaries. * Pure diagnostic — never spawns or fetches. Used by `pal cli doctor`. */ export function previewInferenceRoute(): RoutePreview & { model?: string } { const preview = previewRoute(); return { ...preview, model: smallModelOf(preview.route) }; } function previewRoute(): RoutePreview { const agent = getActiveAgent(); if (process.env.PAL_INFERENCE_DISABLED === "1") { return { agent, route: "disabled", reason: "PAL_INFERENCE_DISABLED=1 (test kill-switch)", }; } if (isClaude() && hasClaudeBinary()) return { agent, route: "claude-spawn", reason: "claude binary on PATH" }; if (isCodex() && hasCodexBinary()) return { agent, route: "codex-spawn", reason: "codex binary on PATH" }; if (isCodex() && hasOpenAiKey()) return { agent, route: "openai-api", reason: "codex agent without codex binary; PAL_OPENAI_API_KEY set", }; if (isOpencode() && hasOpencodeBinary()) return { agent, route: "opencode-spawn", reason: "opencode binary on PATH" }; if (isCopilot() && hasCopilotBinary()) return { agent, route: "copilot-spawn", reason: "copilot binary on PATH" }; if (isCursor() && hasCursorBinary()) return { agent, route: "cursor-spawn", reason: "cursor-agent binary on PATH" }; if (isAntigravity() && hasAntigravityBinary()) return { agent, route: "antigravity-spawn", reason: "agy binary on PATH" }; if (hasApiKey()) return { agent, route: "anthropic-api", reason: "fallback — PAL_ANTHROPIC_API_KEY set", }; return { agent, route: "none", reason: "no native CLI binary for active agent and no PAL_ANTHROPIC_API_KEY/PAL_OPENAI_API_KEY", }; } /** True if any inference path is currently usable (subscription CLI OR API key). */ export function canInfer(): boolean { if (isClaude() && hasClaudeBinary()) return true; if (isCodex() && hasCodexBinary()) return true; if (isCodex() && hasOpenAiKey()) return true; if (isOpencode() && hasOpencodeBinary()) return true; if (isCopilot() && hasCopilotBinary()) return true; if (isCursor() && hasCursorBinary()) return true; if (isAntigravity() && hasAntigravityBinary()) return true; return hasApiKey(); } interface InferenceOptions { system?: string; user: string; /** Model size; the active route maps it to that provider's model. Default "small". */ tier?: InferenceTier; maxTokens?: number; timeout?: number; /** JSON schema for structured output — guarantees valid JSON matching the schema */ jsonSchema?: Record; /** Opaque label identifying the calling handler — appears in debug logs as caller=X */ caller?: string; /** Session ID the call is associated with — appears in debug logs as sessionId=X */ sessionId?: string; } interface InferenceResult { success: boolean; output?: string; /** First line of a failed CLI's error output. */ error?: string; usage?: { inputTokens: number; outputTokens: number }; } export async function inference(opts: InferenceOptions): Promise { // Hard kill-switch — set by the test suite to guarantee no real inference // ever fires from tests (no spawn, no API call). Production code never sets it. if (process.env.PAL_INFERENCE_DISABLED === "1") { return { success: false }; } const depth = getInferenceDepth(); if (depth >= SPAWN_GUARD_ENV.MAX_DEPTH) { logDebug("inference", `refuse: depth=${depth} >= max=${SPAWN_GUARD_ENV.MAX_DEPTH}`); return { success: false }; } const agent = getActiveAgent(); const caller = opts.caller ?? "anonymous"; const session = opts.sessionId ?? "-"; const tag = `caller=${caller} sessionId=${session}`; if (isClaude()) { const bin = getClaudeBinary(); if (bin) { logDebug( "inference", `${tag} route=claude-spawn agent=${agent} model=${modelFor("claude-spawn", opts)}` ); return inferenceViaClaudeSpawn(bin, opts); } } if (isCodex()) { const bin = getCodexBinary(); if (bin) { logDebug("inference", `${tag} route=codex-spawn agent=${agent}`); return inferenceViaCliSpawn(bin, buildCodexArgs(opts), buildCliPrompt(opts), opts); } } if (isCodex() && hasOpenAiKey()) { logDebug("inference", `${tag} route=openai-api agent=${agent}`); return inferenceViaOpenAiApi(opts); } if (isOpencode()) { const bin = getOpencodeBinary(); if (bin) { logDebug("inference", `${tag} route=opencode-spawn agent=${agent}`); return inferenceViaOpencodeSpawn(bin, opts); } } if (isCopilot()) { const bin = getCopilotBinary(); if (bin) { logDebug("inference", `${tag} route=copilot-spawn agent=${agent}`); return inferenceViaCliSpawn( bin, buildCopilotArgs(opts), buildCliPrompt(opts), opts ); } } if (isCursor()) { const bin = getCursorBinary(); if (bin) { logDebug("inference", `${tag} route=cursor-spawn agent=${agent}`); return inferenceViaCliSpawn(bin, buildCursorArgs(opts), buildCliPrompt(opts), opts); } } if (isAntigravity()) { const bin = getAntigravityBinary(); if (bin) { logDebug( "inference", `${tag} route=antigravity-spawn agent=${agent} model=${modelFor("antigravity-spawn", opts)}` ); return inferenceViaAntigravitySpawn(bin, opts); } } if (hasApiKey()) { logDebug("inference", `${tag} route=anthropic-api agent=${agent}`); return inferenceViaApi(opts); } logDebug( "inference", `${tag} route=none agent=${agent} hasApiKey=false hasOpenAiKey=${hasOpenAiKey()} hasClaude=${hasClaudeBinary()} hasCodex=${hasCodexBinary()} hasOpencode=${hasOpencodeBinary()} hasCopilot=${hasCopilotBinary()} hasCursor=${hasCursorBinary()} hasAgy=${hasAntigravityBinary()}` ); return { success: false }; } // ───────────────────────────────────────────────────────────────────────────── // Per-agent CLI metadata — binary presence + argv builders // ───────────────────────────────────────────────────────────────────────────── const binaryCache = new Map(); function cachedBinary(name: string): string | null { const key = `${name}\0${process.env.PATH ?? ""}`; if (!binaryCache.has(key)) binaryCache.set(key, findBinaryOnPath(name)); return binaryCache.get(key) ?? null; } function getClaudeBinary(): string | null { return cachedBinary("claude"); } function getCodexBinary(): string | null { return cachedBinary("codex"); } function getOpencodeBinary(): string | null { return cachedBinary("opencode"); } function getCopilotBinary(): string | null { return cachedBinary("copilot"); } function getCursorBinary(): string | null { return cachedBinary("cursor-agent"); } function getAntigravityBinary(): string | null { return cachedBinary("agy"); } function hasClaudeBinary(): boolean { return getClaudeBinary() !== null; } function hasCodexBinary(): boolean { return getCodexBinary() !== null; } function hasOpencodeBinary(): boolean { return getOpencodeBinary() !== null; } function hasCopilotBinary(): boolean { return getCopilotBinary() !== null; } function hasCursorBinary(): boolean { return getCursorBinary() !== null; } function hasAntigravityBinary(): boolean { return getAntigravityBinary() !== null; } /** * Build the argv for `claude --print …`. Pure. * * `--system-prompt` is deliberately absent: PAL's system prompts run to several * paragraphs, and an argv element cannot carry a newline on Windows once * Bun.spawn resolves claude to its .cmd shim and cmd.exe re-parses the command * line. System, user and any JSON-schema instruction all travel together on * stdin instead, the same way every other agent receives them. */ export function buildClaudeArgs( opts: InferenceOptions, systemPromptFile?: string ): string[] { const args = [ "--print", "--model", modelFor("claude-spawn", opts), "--tools", "", "--output-format", "text", "--setting-sources", "", ]; if (systemPromptFile) args.push("--system-prompt-file", systemPromptFile); return args; } type ClaudeAuthMode = "token" | "native"; function claudeAuthMode(): ClaudeAuthMode { return withPalEnv().CLAUDE_CODE_OAUTH_TOKEN ? "token" : "native"; } /** Lines logged before spawns were tagged came from the native login. */ export function loggedClaudeAuthMode(message: string): ClaudeAuthMode { return /\bauth=token\b/.test(message) ? "token" : "native"; } /** https://code.claude.com/docs/en/authentication#authentication-precedence */ const WITHOUT_KEYS_OUTRANKING_TOKEN = { ANTHROPIC_API_KEY: undefined, ANTHROPIC_AUTH_TOKEN: undefined, }; function credentialOverridesFor(mode: ClaudeAuthMode) { return mode === "token" ? WITHOUT_KEYS_OUTRANKING_TOKEN : {}; } /** * Claude keeps a real system prompt, unlike the other agents, so the system text * reaches it through --system-prompt-file rather than --system-prompt. Only the * path travels in argv, which is what makes this work on Windows: an argv * element cannot carry a newline once Bun.spawn resolves claude to its .cmd * shim, and PAL's system prompts run to several paragraphs. Folding the system * text into the user message instead is not an option — Claude treats * instructions embedded in message content as an injection attempt and refuses. */ async function inferenceViaClaudeSpawn( bin: string, opts: InferenceOptions ): Promise { const mode = claudeAuthMode(); const spawnClaude = (args: string[]) => inferenceViaCliSpawn(bin, args, opts.user, opts, undefined, { env: credentialOverridesFor(mode), logTag: `auth=${mode}`, }); const system = opts.jsonSchema ? injectJsonSchemaInstruction(opts.system ?? "", opts.jsonSchema) : opts.system; if (!system) return spawnClaude(buildClaudeArgs(opts)); const dir = await mkdtemp(join(tmpdir(), "pal-system-")); try { const file = join(dir, "system-prompt.md"); await writeFile(file, system, "utf-8"); return await spawnClaude(buildClaudeArgs(opts, file)); } finally { await rm(dir, { recursive: true, force: true }); } } /** * Build the argv for `codex exec …` from inference options. Pure. * * Recursion + tool-use defense (mirrors claude's `--setting-sources '' --tools ''`): * --ignore-user-config → no ~/.codex/config.toml → no hooks load in the child * --ignore-rules → no execpolicy .rules files load * --sandbox read-only → child cannot execute shell commands even if it tries * --ephemeral → no session persistence; one-shot only * * Codex has no --system-prompt equivalent, so system + user + JSON-schema become * one prompt. That prompt goes in on stdin, not as a positional argument, because * a PAL prompt spans several paragraphs and an argv element cannot carry a * newline on Windows: Bun.spawn resolves the CLI to its .cmd shim, cmd.exe * re-parses the command line, and the child exits non-zero having written * nothing. Codex reads its instructions from stdin when no positional prompt is * given, so this costs nothing on POSIX and is the only thing that works on * Windows. Do not move the prompt back into argv. */ export function buildCodexArgs(opts: InferenceOptions): string[] { return [ "exec", "-m", modelFor("codex-spawn", opts), "--color", "never", "--skip-git-repo-check", "--ignore-user-config", "--ignore-rules", "--sandbox", "read-only", "--ephemeral", ]; } /** * Build the argv for `opencode run …` from inference options. Pure. * * Recursion defense: * --pure → run WITHOUT external plugins → PAL's own opencode plugin * doesn't load in the spawned child → no hook recursion. * --format json → emits NDJSON events on stdout; we extract the agent's * text via extractOpencodeText() rather than wading through * decoration ("> build · provider/model" banner etc). * * opencode (like codex) has no --system-prompt equivalent, so system + user + * JSON-schema are concatenated and delivered on stdin rather than as the * positional message, for the same reason as codex: a multi-paragraph argv * element does not survive cmd.exe on Windows. Provider/model is left unset so * opencode uses the user's configured default. */ export function buildOpencodeArgs(opts: InferenceOptions): string[] { const model = opencodeTierModel(opts.tier ?? "small"); return ["run", "--pure", "--format", "json", ...(model ? ["-m", model] : [])]; } // Without its own AGENTS.md, opencode falls back to ~/.claude/CLAUDE.md, and it // lists every Claude and external skill in its tool description. const OPENCODE_WITHOUT_EXTERNAL_CONTEXT = { OPENCODE_DISABLE_CLAUDE_CODE: "1", OPENCODE_DISABLE_EXTERNAL_SKILLS: "1", }; /** Runs from an empty directory so no project AGENTS.md is in reach either. */ async function inferenceViaOpencodeSpawn( bin: string, opts: InferenceOptions ): Promise { const dir = await mkdtemp(join(tmpdir(), "pal-opencode-")); try { writeInstructionFreeConfig(dir); return await inferenceViaCliSpawn( bin, buildOpencodeArgs(opts), buildCliPrompt(opts), opts, extractOpencodeText, { cwd: dir, env: { XDG_CONFIG_HOME: dir, ...OPENCODE_WITHOUT_EXTERNAL_CONTEXT } } ); } finally { await rm(dir, { recursive: true, force: true }); } } /** * Build the argv for `cursor-agent -p …` from inference options. Pure. * * Recursion + tool-use defense: * --mode ask → read-only Q&A; the agent cannot edit files or run * shell commands, eliminating any path back into our * hooks. Cursor's equivalent of claude's `--tools ''` * and codex's `--sandbox read-only`. * --output-format text → clean stdout (default but explicit) * --trust → required for headless mode; without it, cursor-agent * exits 0 with a "trust this directory" hint instead * of running inference. Safe to pair with --mode ask * because that mode disallows tool calls anyway. * * cursor-agent has no --system-prompt flag, so system + user + JSON-schema are * concatenated into one prompt delivered on stdin rather than as the trailing * positional argument: a multi-paragraph argv element cannot survive cmd.exe on * Windows. `-p` stays because for cursor-agent it means --print, not --prompt. * * Auth note: cursor-agent picks up either `cursor-agent login` credentials or * `CURSOR_API_KEY` env var. PAL doesn't manage these — that's the user's setup. */ export function buildCursorArgs(_opts: InferenceOptions): string[] { return ["-p", "--mode", "ask", "--output-format", "text", "--trust"]; } /** * Build the argv for a one-turn `agy` stream-json session. Pure. * * The prompt goes in as one NDJSON `user` event on stdin (streamJsonPrompt), not * through `-p`: a multi-paragraph argv element cannot survive cmd.exe on Windows, * and stream-json input drops a `-p` prompt anyway. Closing stdin ends the * session once the turn's `result` event is out. * * `--json-schema` is deliberately absent: under the tool-less agent it looped * through several turns, so the schema rides in the prompt as for every other CLI. */ export function buildAntigravityArgs(opts: InferenceOptions): string[] { return [ "--input-format", "stream-json", "--output-format", "stream-json", "--model", modelFor("antigravity-spawn", opts), "--agent", INFERENCE_AGENT, ]; } /** Runs from an empty workspace that holds only the tool-less inference agent. */ async function inferenceViaAntigravitySpawn( bin: string, opts: InferenceOptions ): Promise { const dir = await mkdtemp(join(tmpdir(), "pal-antigravity-")); try { writeInferenceAgent(dir); return await inferenceViaCliSpawn( bin, buildAntigravityArgs(opts), streamJsonPrompt(buildCliPrompt(opts)), opts, extractAntigravityText, { cwd: dir, afterRun: removeSpawnedConversation } ); } finally { await rm(dir, { recursive: true, force: true }); } } /** * Build the argv for `copilot -p …` from inference options. Pure. * * Recursion defense: * --no-custom-instructions → don't load PAL's copilot custom instructions * in the spawned child (equivalent to claude's * `--setting-sources ''` and opencode's `--pure`) * --disable-builtin-mcps → no MCP servers in the child (extra safety) * --no-auto-update → prevent CLI self-update from delaying the spawn * --no-color → clean stdout for capture * --allow-all-tools → REQUIRED for non-interactive mode (without it, * copilot prompts for tool-use confirmation) * * Copilot has no --system-prompt flag, so system + user + JSON-schema are * concatenated into one prompt delivered on stdin. `-p/--prompt` is deliberately * absent: it takes the prompt inline, and a multi-paragraph argv element cannot * survive cmd.exe when Bun.spawn resolves copilot to its Windows .cmd shim. * Piping stdin keeps copilot non-interactive, so dropping -p costs nothing. */ export function buildCopilotArgs(_opts: InferenceOptions): string[] { return [ "--no-custom-instructions", "--disable-builtin-mcps", "--no-auto-update", "--no-color", "--allow-all-tools", ]; } /** * Extract the agent's text reply from opencode --format json NDJSON output. * Concatenates all `type:"text"` event payloads in order. Returns empty * string on parse failure or no text events. */ export function extractOpencodeText(rawStdout: string): string { const texts: string[] = []; for (const line of rawStdout.split("\n")) { if (!line.trim()) continue; try { const event = JSON.parse(line) as { type?: string; part?: { type?: string; text?: string }; }; if (event.type === "text" && event.part?.type === "text" && event.part.text) { texts.push(event.part.text); } } catch { /* not a JSON line — opencode also emits non-JSON lines, skip them */ } } return texts.join("").trim(); } /** * Render a JSON schema for a prompt without double quotes. * * Bun.spawn resolves an agent CLI on Windows to its `.cmd` shim, which npm * installs it as, and cmd.exe re-parses the command line it is handed. A double * quote inside an argv element does not survive that round trip: the child exits * non-zero having written nothing, so the dispatcher sees an empty abort and * gives up. Single quotes carry the same shape to a model and are inert to * cmd.exe, so the schema travels intact on every platform. */ function schemaForPrompt(schema: Record): string { return JSON.stringify(schema).replaceAll('"', "'"); } /** The one instruction line that asks a CLI agent for schema-shaped JSON. */ export function schemaInstruction(schema: Record): string { return `Respond with ONLY a JSON value matching this schema (no prose, no markdown): ${schemaForPrompt(schema)}`; } /** system + user + schema instruction, the single prompt a CLI agent receives. */ export function buildCliPrompt(opts: InferenceOptions): string { const parts: string[] = []; if (opts.system) parts.push(opts.system); parts.push(opts.user); if (opts.jsonSchema) parts.push(schemaInstruction(opts.jsonSchema)); return parts.join("\n\n"); } /** Append a JSON-schema instruction to the system prompt (PAI pattern). */ export function injectJsonSchemaInstruction( systemPrompt: string, schema: Record ): string { const schemaLine = schemaInstruction(schema); return systemPrompt ? `${systemPrompt}\n\n${schemaLine}` : schemaLine; } /** Extract a JSON object or array from raw text output. Returns null on failure. */ export function parseJsonFromOutput(output: string): unknown | null { const objectMatch = /\{[\s\S]*\}/.exec(output); const arrayMatch = /\[[\s\S]*\]/.exec(output); for (const candidate of [objectMatch?.[0], arrayMatch?.[0]]) { if (!candidate) continue; try { return JSON.parse(candidate); } catch { /* try next */ } } return null; } function firstLine(text: string): string | undefined { return text .split("\n") .find((line) => line.trim()) ?.trim(); } interface RawSpawnResult { code: number | null; stdout: string; stderr: string; timedOut: boolean; } /** * One CLI invocation. Returns raw streams + exit info, no parsing. * Used by every per-agent dispatcher (claude --print, codex exec, etc). */ async function singleCliAttempt( binary: string, args: string[], stdinInput: string, env: NodeJS.ProcessEnv, timeout: number, cwd?: string ): Promise { return new Promise((resolve) => { let stdout = ""; let stderr = ""; let timedOut = false; let settled = false; const finish = (r: RawSpawnResult) => { if (settled) return; settled = true; resolve(r); }; let proc: ReturnType; try { proc = Bun.spawn([binary, ...args], { env, cwd, stdin: "pipe", stdout: "pipe", stderr: "pipe", windowsHide: true, }); } catch (err) { logError("inference:spawn", err); finish({ code: null, stdout: "", stderr: "", timedOut: false }); return; } const timer = setTimeout(() => { timedOut = true; try { proc.kill(); } catch { /* ignore */ } }, timeout); const stdinWriter = proc.stdin && typeof proc.stdin !== "number" ? (proc.stdin as { write: (s: string) => void; end: () => void; close?: () => void; }) : null; if (stdinWriter) { try { if (stdinInput) stdinWriter.write(stdinInput); stdinWriter.end(); } catch (err) { logError("inference:stdin", err); } } void (async () => { const stdoutStream = proc.stdout && typeof proc.stdout !== "number" ? (proc.stdout as ReadableStream) : null; const stderrStream = proc.stderr && typeof proc.stderr !== "number" ? (proc.stderr as ReadableStream) : null; try { if (stdoutStream) stdout = await new Response(stdoutStream).text(); } catch { /* ignore */ } try { if (stderrStream) stderr = await new Response(stderrStream).text(); } catch { /* ignore */ } await proc.exited; clearTimeout(timer); finish({ code: proc.exitCode, stdout, stderr, timedOut }); })(); }); } interface SpawnPlace { cwd?: string; env?: Record; logTag?: string; /** Sees the final attempt's stdout whatever the outcome, timeouts included. */ afterRun?: (rawStdout: string) => void; } /** * Generic CLI dispatcher: spawn `binary args`, write stdinInput to stdin (may be * empty for argv-only CLIs like codex), capture stdout, retry once on empty-abort. * Mirrors PAI's universal pattern across all supported subscription CLIs. */ async function inferenceViaCliSpawn( binary: string, args: string[], stdinInput: string, opts: InferenceOptions, extractText?: (rawStdout: string) => string, place: SpawnPlace = {} ): Promise { const timeout = opts.timeout ?? 15000; const env = { ...buildSpawnGuardEnv(withPalEnv(process.env)), ...place.env }; const started = Date.now(); const caller = opts.caller ?? "anonymous"; const session = opts.sessionId ?? "-"; const tag = [`caller=${caller}`, `sessionId=${session}`, place.logTag] .filter(Boolean) .join(" "); // Friendly name for logs — strip path + extension so cross-platform diffs // (e.g. C:\…\claude.cmd vs /usr/local/bin/claude) read the same in debug.log. const binaryName = basename(binary).replace(/\.(cmd|bat|exe|com)$/i, ""); // Attempt 1 let attempt = await singleCliAttempt(binary, args, stdinInput, env, timeout, place.cwd); // Universal retry on empty-output exit≠0 (correlates strongly with burst- // concurrency races — the binary silently aborts without writing to either // stream). One retry only, 500-1500ms jitter so the burst settles. const isEmptyAbort = attempt.code !== 0 && !attempt.timedOut && attempt.stdout.length === 0 && attempt.stderr.length === 0; if (isEmptyAbort) { const jitterMs = 500 + Math.floor(Math.random() * 1000); logDebug( "inference:spawn", `${tag} retry: empty-abort binary=${binaryName} exit=${attempt.code} after ${Date.now() - started}ms, jitter=${jitterMs}ms` ); await new Promise((r) => setTimeout(r, jitterMs)); attempt = await singleCliAttempt(binary, args, stdinInput, env, timeout, place.cwd); } place.afterRun?.(attempt.stdout); const elapsedMs = Date.now() - started; const finish = (result: InferenceResult): InferenceResult => { logDebug( "inference:spawn", `${tag} done binary=${binaryName} success=${result.success} bytes=${result.output?.length ?? 0} elapsedMs=${elapsedMs}` ); return result; }; if (attempt.timedOut) { logError("inference:spawn", `${tag} timeout binary=${binaryName} after ${timeout}ms`); return finish({ success: false }); } if (attempt.code !== 0) { logError( "inference:spawn", `${tag} exited=${attempt.code} binary=${binaryName} argv=${JSON.stringify(args)} stderr(${attempt.stderr.length})=${attempt.stderr.slice(0, 300)} stdout(${attempt.stdout.length})=${attempt.stdout.slice(0, 300)}` ); return finish({ success: false, error: firstLine(attempt.stderr || attempt.stdout) }); } const rawText = attempt.stdout.trim(); if (!rawText) return finish({ success: false }); const text = extractText ? extractText(rawText) : rawText; if (!text) { // Extraction returned empty — the binary succeeded but our extractor found // no usable text. Log the raw stdout so we can see what was actually emitted. logError( "inference:spawn", `${tag} extract-empty binary=${binaryName} rawStdout(${rawText.length})=${rawText.slice(0, 500)}` ); return finish({ success: false }); } if (opts.jsonSchema) { const parsed = parseJsonFromOutput(text); if (parsed === null) return finish({ success: false, output: text }); return finish({ success: true, output: JSON.stringify(parsed) }); } return finish({ success: true, output: text }); } // ───────────────────────────────────────────────────────────────────────────── // Anthropic API path — used when no claude binary is available, or when the // active agent is not claude. Preserves the original PAL inference behavior. // ───────────────────────────────────────────────────────────────────────────── async function inferenceViaApi(opts: InferenceOptions): Promise { const apiKey = withPalEnv().PAL_ANTHROPIC_API_KEY; if (!apiKey) return { success: false }; const { system, user, maxTokens = 200, timeout = 5000, jsonSchema } = opts; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); const body: Record = { model: modelFor("anthropic-api", opts), max_tokens: maxTokens, messages: [{ role: "user", content: user }], }; if (system) body.system = system; if (jsonSchema) { body.output_config = { format: { type: "json_schema", schema: jsonSchema }, }; } const response = await fetch("https://api.anthropic.com/v1/messages", { method: "POST", headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01", "content-type": "application/json", }, body: JSON.stringify(body), signal: controller.signal, }); clearTimeout(timer); if (!response.ok) { const errBody = await response.text().catch(() => ""); logError("inference", `HTTP ${response.status}: ${errBody.slice(0, 200)}`); return { success: false }; } const data = (await response.json()) as Record; const rawUsage = data?.usage as | { input_tokens?: number; output_tokens?: number } | undefined; const usage = rawUsage?.input_tokens != null && rawUsage?.output_tokens != null ? { inputTokens: rawUsage.input_tokens, outputTokens: rawUsage.output_tokens } : undefined; const content = data?.content as Array<{ text?: string }> | undefined; const text = content?.[0]?.text?.trim(); if (!text) return { success: false, usage }; return { success: true, output: text, usage }; } catch (err) { logError("inference", err); return { success: false }; } } // ───────────────────────────────────────────────────────────────────────────── // OpenAI API path — fallback for codex users without a codex binary on PATH. // Codex users almost always have an OpenAI key already; falling back to // Anthropic for them would be backwards. Uses chat/completions with the // structured-output schema for JSON-mode callers. // ───────────────────────────────────────────────────────────────────────────── async function inferenceViaOpenAiApi(opts: InferenceOptions): Promise { const apiKey = withPalEnv().PAL_OPENAI_API_KEY; if (!apiKey) return { success: false }; const { system, user, maxTokens = 500, timeout = 15000, jsonSchema } = opts; try { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeout); const messages: Array<{ role: string; content: string }> = []; if (system) messages.push({ role: "system", content: system }); messages.push({ role: "user", content: user }); const body: Record = { model: modelFor("openai-api", opts), max_tokens: maxTokens, messages, }; if (jsonSchema) { body.response_format = { type: "json_schema", json_schema: { name: "structured_response", strict: true, schema: jsonSchema }, }; } const response = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "content-type": "application/json", }, body: JSON.stringify(body), signal: controller.signal, }); clearTimeout(timer); if (!response.ok) { const errBody = await response.text().catch(() => ""); logError("inference:openai", `HTTP ${response.status}: ${errBody.slice(0, 200)}`); return { success: false }; } const data = (await response.json()) as Record; const rawUsage = data?.usage as | { prompt_tokens?: number; completion_tokens?: number } | undefined; const usage = rawUsage?.prompt_tokens != null && rawUsage?.completion_tokens != null ? { inputTokens: rawUsage.prompt_tokens, outputTokens: rawUsage.completion_tokens, } : undefined; const choices = data?.choices as | Array<{ message?: { content?: string } }> | undefined; const text = choices?.[0]?.message?.content?.trim(); if (!text) return { success: false, usage }; return { success: true, output: text, usage }; } catch (err) { logError("inference:openai", err); return { success: false }; } }