diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index fdd114c..4afdebb 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Polycli host adapters for Claude Code and related agent CLIs", - "version": "0.6.30" + "version": "0.6.31" }, "plugins": [ { "name": "polycli", "description": "Claude Code adapter for the shared polycli companion", - "version": "0.6.30", + "version": "0.6.31", "source": "./plugins/polycli" } ] diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index b6b7c23..fcd2c2d 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -5,13 +5,13 @@ }, "metadata": { "description": "Polycli marketplace for GitHub Copilot CLI", - "version": "0.6.30" + "version": "0.6.31" }, "plugins": [ { "name": "polycli-copilot", "description": "Run the shared polycli companion from GitHub Copilot CLI", - "version": "0.6.30", + "version": "0.6.31", "source": "./plugins/polycli-copilot" } ] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index deb90b4..5b86d7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,12 +32,12 @@ jobs: - name: Audit dependencies run: npm audit --audit-level=moderate - - name: Run tests - run: npm test - - name: Validate generated bundles run: npm run validate:bundles + - name: Run tests + run: npm test + - name: Validate fixture metadata run: npm run validate:fixtures diff --git a/CHANGELOG.md b/CHANGELOG.md index 509382b..37d214e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Separate from `docs/release.md` (release-focused) and `docs/archive/session-memo --- +## 2026-07-15 — Codex — release: v0.6.31 candidate (review remediation) + +- Closed all 14 confirmed findings from the v0.6.30 comprehensive review: truthful no-diff/background JSON v2 results, strict provider target disambiguation, accurate TUI effects, active-job visibility, ledger preview redaction, safe prompt transport, structured session identity, and canonical typed failures. +- Hardened provider process lifecycle with bounded stdout/stderr capture, POSIX process-group termination and escalation, simulated deadline-aware Windows `taskkill` coverage, and exactly-once settlement even when `close` is missing or termination fails. Native Windows execution was not available; streaming paths retain a direct-child fallback there. +- Made background cancellation and SessionEnd recoverable: cancellation intent remains active until a verified stop, start failures use a private recovery sidecar, terminal ledger/state publication is ordered, and worker/cancel races cannot publish late provider material. +- Added a source-derived, read-only generated-artifact freshness gate that runs before any in-place bundle rebuild in CI and `release:check`; rebuilt all five companion bundles from current source. +- Prepared host/OpenCode/terminal `0.6.31` and `@bbingz/polycli-utils` `1.0.5`; `@bbingz/polycli-timing` remains `1.0.2` and `@bbingz/polycli-runtime` remains private. + ## 2026-07-15 — Codex — release: v0.6.30 published - Published and registry-verified `@bbingz/polycli@0.6.30` (`latest`, registry time `2026-07-15T08:55:13.583Z`, shasum `882e134363d70545c15e060a8da6c1274a2aa1e7`), `@bbingz/polycli-utils@1.0.4` (`2026-07-15T08:51:16.560Z`, `f89c94947199f4d9d61ec6eddca889bb83a95ec4`), and `@bbingz/polycli-opencode@0.6.30` (`2026-07-15T08:55:44.359Z`, `9f71767156d2278f3f9bbe0cadc3fd1c90ae289f`). `@bbingz/polycli-timing@1.0.2` was unchanged and not republished. diff --git a/docs/release-notes-v0.6.31.md b/docs/release-notes-v0.6.31.md new file mode 100644 index 0000000..5c2e680 --- /dev/null +++ b/docs/release-notes-v0.6.31.md @@ -0,0 +1,55 @@ +# polycli v0.6.31 + +Review-remediation patch on top of `v0.6.30`. This release closes every confirmed issue from the post-release comprehensive review while preserving the Path B boundary: provider adapters remain flat and explicit, the runtime package remains private, and no provider protocol framework is introduced. + +## What changed + +### Truthful CLI and observation contracts + +- A no-change background review now returns the real skipped result instead of a synthetic `job.started` record with no job. +- `setup` and `health` reject ambiguous positional-plus-flag provider targets before provider, auth, or state access. +- TUI agent-context effects now disclose local recovery-state writes; default status keeps all active jobs visible while bounding terminal history. +- Every persisted ledger preview is sanitized at the storage boundary. + +### Bounded provider execution and safe prompt transport + +- On POSIX, timeout, abort, decoder overflow, termination failure, and missing-close paths now terminate or escalate the provider process group and settle exactly once with a canonical typed error. Windows streaming paths retain a direct-child termination fallback. +- Aggregate stdout and stderr capture are independently bounded while total byte counts remain available for diagnostics. +- Claude and Gemini move oversized prompts to verified stdin transport. Argv-only providers reject unsafe command lines before spawn with typed `argument_list_too_long` guidance; review input is still unlimited unless the caller explicitly chooses `--max-diff-bytes`. +- Claude, Copilot, OpenCode, and Qwen no longer promote arbitrary UUIDs from answer prose into provider session identity. + +### Recoverable background lifecycle + +- Cancellation persists a non-terminal intent and publishes `cancelled` only after worker identity is verified and the worker is stopped. +- SessionEnd delegates to the authoritative cancel path under one deadline that also bounds state/ledger locks, process identity probes, and Windows `taskkill` calls. +- Config, log, open, and spawn failures use a private recovery sidecar so a transient pre-envelope failure cannot leave a permanent pidless queued job. +- Worker, cancellation, and terminal-ledger races preserve one complete terminal pair and clean owned runtime/config/recovery artifacts before terminal state becomes observable. + +### Reproducible generated artifacts + +- `validate:bundles` now renders expected bundles and terminal metadata from source with esbuild `write:false`, then compares every tracked artifact byte-for-byte without overwriting it. +- GitHub CI and `release:check` run that freshness gate before `npm test`, which performs the in-place build. +- A regression test changes source while all five tracked bundles remain mutually identical and proves the pre-build validator rejects them. + +## Compatibility + +- Existing public `--json` payloads remain compatible; JSON v2 stays opt-in. +- Default review collection remains unbounded; only explicit `--max-diff-bytes` truncates input. +- `@bbingz/polycli-runtime` remains private and provider modules remain flat. +- Host plugins, OpenCode, and terminal CLI move to `0.6.31`; `@bbingz/polycli-utils` moves to `1.0.5`; `@bbingz/polycli-timing` remains `1.0.2`. + +## Verification + +- Five scoped implementation groups each passed independent spec-compliance and code-quality review. +- The local full suite passed: 906 tests, 906 passed, 0 failed. +- `npm run release:check` passed source-derived bundle freshness, strict fixture freshness, manifests, host maps, Codex guidance, installed-CLI review flag drift, both Claude plugin validations, and all npm package dry-runs. +- Native Windows execution was not available. Windows argv budgeting and `taskkill`/deadline branches were covered by deterministic simulation; only POSIX process-group and live process-tree behavior received native execution coverage. +- PR CI and publication evidence are recorded before the GitHub release is created. + +## Release artifacts + +- GitHub release: `v0.6.31` +- npm: `@bbingz/polycli@0.6.31` +- npm: `@bbingz/polycli-opencode@0.6.31` +- npm: `@bbingz/polycli-utils@1.0.5` +- unchanged npm package: `@bbingz/polycli-timing@1.0.2` diff --git a/docs/release.md b/docs/release.md index 68c929c..4c6e1e3 100644 --- a/docs/release.md +++ b/docs/release.md @@ -82,8 +82,8 @@ npm run release:check `release:check` includes: -- `npm test`, which rebuilds plugin bundles before running package, runtime, plugin, and release-script tests -- `npm run validate:bundles` +- `npm run validate:bundles`, a read-only source-derived freshness gate that runs before any in-place build +- `npm test`, which then rebuilds plugin bundles before running package, runtime, plugin, and release-script tests - `npm run validate:fixtures` - `npm run check:fixture-freshness -- --strict` - `npm run validate:manifests` @@ -175,4 +175,4 @@ npm install -g @bbingz/polycli polycli health --json ``` -The terminal package re-uses the same `polycli-companion.bundle.mjs` that every host adapter ships, so `npm run build:plugins` must succeed before publishing — `validate:bundles` confirms the five companion bundle copies are byte-identical. +The terminal package re-uses the same `polycli-companion.bundle.mjs` that every host adapter ships. Before publishing, `validate:bundles` independently renders the expected outputs from source without writing them and compares all five bundle copies plus generated terminal metadata byte-for-byte; `npm run build:plugins` is the only in-place writer. diff --git a/docs/roadmap.md b/docs/roadmap.md index 90921b7..418b593 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -42,7 +42,7 @@ Status: closed as an active guardrail. CI, release publication, npm registry sta Current guardrails: -- `npm run validate:bundles` checks that all five generated companion bundles are byte-identical after `npm test` rebuilds them. +- `npm run validate:bundles` independently renders source-derived expected bytes and checks all five companion bundles plus generated terminal metadata before `npm test` can rebuild them in place. - `npm run validate:fixtures` checks real runtime fixture metadata has provider/name/capturedAt/version/argv/expected response fields, matching stream captures, and required success fixtures for every parser-backed provider. - `npm run validate:manifests` keeps host plugin versions and marketplace entries aligned. - `npm run validate:host-map` keeps host command docs and registered command surfaces aligned. diff --git a/package-lock.json b/package-lock.json index 3245738..8255c1f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -533,15 +533,15 @@ "version": "1.0.0", "dependencies": { "@bbingz/polycli-timing": "1.0.2", - "@bbingz/polycli-utils": "1.0.4" + "@bbingz/polycli-utils": "1.0.5" } }, "packages/polycli-terminal": { "name": "@bbingz/polycli", - "version": "0.6.30", + "version": "0.6.31", "license": "MIT", "dependencies": { - "@bbingz/polycli-utils": "1.0.4" + "@bbingz/polycli-utils": "1.0.5" }, "bin": { "polycli": "bin/polycli.mjs" @@ -560,7 +560,7 @@ }, "packages/polycli-utils": { "name": "@bbingz/polycli-utils", - "version": "1.0.4", + "version": "1.0.5", "license": "MIT", "engines": { "node": ">=20" diff --git a/packages/polycli-runtime/package.json b/packages/polycli-runtime/package.json index 157ede3..ea107d0 100644 --- a/packages/polycli-runtime/package.json +++ b/packages/polycli-runtime/package.json @@ -5,7 +5,7 @@ "type": "module", "dependencies": { "@bbingz/polycli-timing": "1.0.2", - "@bbingz/polycli-utils": "1.0.4" + "@bbingz/polycli-utils": "1.0.5" }, "exports": { ".": "./src/index.js" diff --git a/packages/polycli-runtime/src/agy.js b/packages/polycli-runtime/src/agy.js index f3621aa..711af87 100644 --- a/packages/polycli-runtime/src/agy.js +++ b/packages/polycli-runtime/src/agy.js @@ -219,9 +219,11 @@ export function runAgyPromptStreaming({ const parsed = parseAgyTextResult(result.stdout); const filteredStderr = stripAgyBenignStderr(result.stderr); const hasVisibleText = Boolean(parsed.response.trim()); - const error = result.ok - ? (hasVisibleText ? null : "agy produced no visible text") - : (filteredStderr.trim() || result.error); + const error = !result.ok && result.errorCode + ? result.error + : (result.ok + ? (hasVisibleText ? null : "agy produced no visible text") + : (filteredStderr.trim() || result.error)); return { ...result, ...parsed, @@ -230,7 +232,7 @@ export function runAgyPromptStreaming({ model: model ?? defaultModel ?? DEFAULT_AGY_MODEL, ok: result.ok && hasVisibleText, error, - errorCode: classifyProviderFailure(error, { provider: "agy" }), + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "agy" }), }; }); } diff --git a/packages/polycli-runtime/src/claude.js b/packages/polycli-runtime/src/claude.js index 46ca929..1babb3f 100644 --- a/packages/polycli-runtime/src/claude.js +++ b/packages/polycli-runtime/src/claude.js @@ -1,9 +1,14 @@ import { parseStreamJsonLine } from "@bbingz/polycli-utils/parse-stream-json"; -import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; -import { resolveSessionId } from "@bbingz/polycli-utils/session-id"; +import { + binaryAvailable, + getSafeArgvBudgetBytes, + preflightArgv, + runCommand, +} from "@bbingz/polycli-utils/process"; +import { matchResumeSessionIdLine } from "@bbingz/polycli-utils/session-id"; import { randomUUID } from "node:crypto"; -import { formatProviderExitError } from "./errors.js"; +import { classifyProviderFailure, formatProviderExitError } from "./errors.js"; import { spawnStreamingCommand } from "./spawn.js"; const CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; @@ -12,6 +17,8 @@ const DEFAULT_TIMEOUT_MS = 900_000; const TMUX_START_TIMEOUT_MS = 30_000; const AUTH_CHECK_TIMEOUT_MS = 30_000; const PROMPT_STDIN_THRESHOLD = 100_000; +const SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +const SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; const CLAUDE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; const CLAUDE_TMUX_ENV_EXACT = new Set([ "ALL_PROXY", @@ -143,34 +150,26 @@ export function buildClaudeInvocation({ resumeSessionId = null, extraArgs = [], bin = CLAUDE_BIN, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const promptText = String(prompt ?? ""); - const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD; - const args = ["-p"]; - - if (!useStdin) { - args.push(promptText); - } - - args.push("--output-format", outputFormat); - if (outputFormat === "stream-json") { - args.push("--verbose"); - } - if (permissionMode) { - args.push("--permission-mode", permissionMode); - } - if (Number.isFinite(maxTurns) && maxTurns > 0) { - args.push("--max-turns", String(maxTurns)); - } - if (model) { - args.push("--model", model); - } - if (resumeSessionId) { - args.push("--resume", resumeSessionId); - } - if (extraArgs.length > 0) { - args.push(...extraArgs); - } + const buildArgs = (includePrompt) => { + const nextArgs = ["-p"]; + if (includePrompt) nextArgs.push(promptText); + nextArgs.push("--output-format", outputFormat); + if (outputFormat === "stream-json") nextArgs.push("--verbose"); + if (permissionMode) nextArgs.push("--permission-mode", permissionMode); + if (Number.isFinite(maxTurns) && maxTurns > 0) nextArgs.push("--max-turns", String(maxTurns)); + if (model) nextArgs.push("--model", model); + if (resumeSessionId) nextArgs.push("--resume", resumeSessionId); + if (extraArgs.length > 0) nextArgs.push(...extraArgs); + return nextArgs; + }; + const inlineArgs = buildArgs(true); + const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD + || !preflightArgv(bin, inlineArgs, { env, argvBudgetBytes }).ok; + const args = useStdin ? buildArgs(false) : inlineArgs; return { bin, @@ -603,13 +602,8 @@ export function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = n try { const parsed = JSON.parse(text.slice(jsonStart)); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"], - }); const response = typeof parsed.result === "string" ? parsed.result : ""; - const sessionId = parsed.session_id ?? parsed.sessionId ?? resolvedSession.sessionId ?? null; + const sessionId = parsed.session_id ?? parsed.sessionId ?? matchResumeSessionIdLine(stderr) ?? null; const errorText = isClaudeErrorResultEvent(parsed) ? getClaudeErrorText(parsed) : null; const processError = status === 0 ? null @@ -706,6 +700,9 @@ export function runClaudePrompt({ resumeSessionId = null, defaultModel = null, bin = CLAUDE_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildClaudeInvocation({ prompt, @@ -716,12 +713,18 @@ export function runClaudePrompt({ resumeSessionId, extraArgs, bin, + env, + argvBudgetBytes, }); const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, input: invocation.input, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, }); if (result.error) { @@ -730,6 +733,8 @@ export function runClaudePrompt({ error: result.error.code === "ETIMEDOUT" ? `claude timed out after ${Math.round(timeout / 1000)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error), + spawnErrorCode: result.spawnErrorCode ?? null, }; } @@ -745,6 +750,7 @@ export function runClaudePromptStreaming({ maxTurns = 10, cwd, timeout = DEFAULT_TIMEOUT_MS, + killGraceMs, extraArgs = [], resumeSessionId = null, defaultModel = null, @@ -756,6 +762,7 @@ export function runClaudePromptStreaming({ env = process.env, signalEmitter = process, spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { if (executionMode === "tmux-tui") { return Promise.resolve(runClaudeTuiPrompt({ @@ -784,6 +791,8 @@ export function runClaudePromptStreaming({ resumeSessionId, extraArgs, bin, + env, + argvBudgetBytes, }); return spawnStreamingCommand({ @@ -793,7 +802,10 @@ export function runClaudePromptStreaming({ env, input: invocation.input, timeout, + killGraceMs, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const parsed = parseStreamJsonLine(line, { allowPrefix: true }); if (parsed.ok) { @@ -802,11 +814,6 @@ export function runClaudePromptStreaming({ }, }).then((result) => { const parsed = parseClaudeStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"], - }); const hasVisibleText = Boolean(parsed.response.trim()); const resultError = isClaudeErrorResultEvent(parsed.resultEvent) ? getClaudeErrorText(parsed.resultEvent) @@ -816,18 +823,26 @@ export function runClaudePromptStreaming({ && parsed.resultEvent.type === "result" && !isClaudeErrorResultEvent(parsed.resultEvent) ); - const completed = result.ok || (result.timedOut && hasSuccessfulResult); + const recoveredTimeout = result.timedOut + && hasSuccessfulResult + && !result.closeTimedOut + && !result.terminationFailure; + const completed = result.ok || recoveredTimeout; return { ...result, ...parsed, timedOut: completed ? false : result.timedOut, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), model: parsed.model ?? model ?? defaultModel, ok: completed && !resultError && hasVisibleText, error: completed ? (resultError || (hasVisibleText ? null : "claude produced no visible text")) : result.error, + errorCode: completed && !resultError + ? (hasVisibleText ? null : "no_visible_text") + : result.errorCode, + terminationReason: completed ? null : result.terminationReason, }; }); } diff --git a/packages/polycli-runtime/src/cmd.js b/packages/polycli-runtime/src/cmd.js index 0197fa2..54c68ef 100644 --- a/packages/polycli-runtime/src/cmd.js +++ b/packages/polycli-runtime/src/cmd.js @@ -1,4 +1,4 @@ -import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; +import { binaryAvailable, getSafeArgvBudgetBytes, runCommand } from "@bbingz/polycli-utils/process"; import { classifyProviderFailure, formatProviderExitError } from "./errors.js"; import { spawnStreamingCommand } from "./spawn.js"; @@ -7,6 +7,8 @@ const CMD_BIN = process.env.CMD_CLI_BIN || "cmd"; const DEFAULT_CMD_MODEL = "deepseek"; const DEFAULT_TIMEOUT_MS = 900_000; const AUTH_CHECK_TIMEOUT_MS = 30_000; +const SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +const SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; const CMD_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; export const TRANSIENT_PROBE_ERROR_PATTERNS = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i, @@ -102,6 +104,8 @@ export function runCmdPrompt({ yolo = true, defaultModel = null, bin = CMD_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildCmdInvocation({ prompt, @@ -110,7 +114,14 @@ export function runCmdPrompt({ bin, }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, env }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `cmd timed out after ${Math.round(timeout / 1000)}s` @@ -118,7 +129,8 @@ export function runCmdPrompt({ return { ok: false, error, - errorCode: classifyProviderFailure(error, { provider: "cmd" }), + errorCode: classifyProviderFailure(result.error, { provider: "cmd" }), + spawnErrorCode: result.spawnErrorCode ?? null, }; } @@ -154,6 +166,7 @@ export function runCmdPromptStreaming({ onEvent = () => {}, bin = CMD_BIN, spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildCmdInvocation({ prompt, @@ -169,6 +182,8 @@ export function runCmdPromptStreaming({ env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const trimmed = line.trimEnd(); if (!trimmed.trim()) return; @@ -189,7 +204,7 @@ export function runCmdPromptStreaming({ model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, ok: result.ok && hasVisibleText, error, - errorCode: classifyProviderFailure(error, { provider: "cmd" }), + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "cmd" }), }; }); } diff --git a/packages/polycli-runtime/src/copilot.js b/packages/polycli-runtime/src/copilot.js index 584ad31..1adc74a 100644 --- a/packages/polycli-runtime/src/copilot.js +++ b/packages/polycli-runtime/src/copilot.js @@ -1,12 +1,14 @@ -import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; -import { resolveSessionId } from "@bbingz/polycli-utils/session-id"; +import { binaryAvailable, getSafeArgvBudgetBytes, runCommand } from "@bbingz/polycli-utils/process"; +import { matchResumeSessionIdLine } from "@bbingz/polycli-utils/session-id"; -import { formatProviderExitError } from "./errors.js"; +import { classifyProviderFailure, formatProviderExitError } from "./errors.js"; import { spawnStreamingCommand } from "./spawn.js"; const COPILOT_BIN = process.env.COPILOT_CLI_BIN || "copilot"; const DEFAULT_TIMEOUT_MS = 900_000; const AUTH_CHECK_TIMEOUT_MS = 30_000; +const SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +const SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; const COPILOT_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; export const TRANSIENT_PROBE_ERROR_PATTERNS = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i, @@ -236,6 +238,9 @@ export function runCopilotPrompt({ allowAllUrls = true, noAskUser = true, bin = COPILOT_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildCopilotInvocation({ prompt, @@ -252,7 +257,14 @@ export function runCopilotPrompt({ bin, }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, + }); if (result.error) { return { ok: false, @@ -260,18 +272,15 @@ export function runCopilotPrompt({ error: result.error.code === "ETIMEDOUT" ? `copilot timed out after ${Math.round(timeout / 1000)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error, { provider: "copilot" }), + spawnErrorCode: result.spawnErrorCode ?? null, }; } const parsed = parseCopilotStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"], - }); const resultError = getCopilotResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); - const sessionId = parsed.sessionId ?? resolvedSession.sessionId; + const sessionId = parsed.sessionId ?? matchResumeSessionIdLine(result.stderr); return { ok: result.status === 0 && !resultError && hasVisibleText, @@ -302,6 +311,8 @@ export function runCopilotPromptStreaming({ onEvent = () => {}, bin = COPILOT_BIN, spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildCopilotInvocation({ prompt, @@ -322,9 +333,11 @@ export function runCopilotPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -334,14 +347,9 @@ export function runCopilotPromptStreaming({ }, }).then((result) => { const parsed = parseCopilotStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"], - }); const resultError = getCopilotResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); - const sessionId = parsed.sessionId ?? resolvedSession.sessionId; + const sessionId = parsed.sessionId ?? matchResumeSessionIdLine(result.stderr); return { ...result, ...parsed, diff --git a/packages/polycli-runtime/src/errors.js b/packages/polycli-runtime/src/errors.js index 6360cfe..77a57e7 100644 --- a/packages/polycli-runtime/src/errors.js +++ b/packages/polycli-runtime/src/errors.js @@ -12,6 +12,16 @@ export function formatProviderExitError(provider, status) { } export function classifyProviderFailure(error, { provider = null } = {}) { + const structuredCode = typeof error?.code === "string" ? error.code.toUpperCase() : null; + if (structuredCode === "E2BIG") { + return "argument_list_too_long"; + } + if (structuredCode === "ENOENT") { + return "binary_missing"; + } + if (structuredCode === "ETIMEDOUT") { + return "timeout"; + } const text = typeof error === "string" ? error : String(error?.message ?? error ?? ""); @@ -22,6 +32,12 @@ export function classifyProviderFailure(error, { provider = null } = {}) { if (/\bspawn\b.*\bENOENT\b|\bENOENT\b|\bnot found\b/i.test(text)) { return "binary_missing"; } + if (/\bE2BIG\b|\bargument list too long\b/i.test(text)) { + return "argument_list_too_long"; + } + if (/\b(output|capture|line buffer)\b.*\b(exceeded|overflow)\b/i.test(text)) { + return "output_overflow"; + } if (/\b(timed out|timeout)\b/i.test(text)) { return "timeout"; } diff --git a/packages/polycli-runtime/src/gemini.js b/packages/polycli-runtime/src/gemini.js index 319e518..c81b5c2 100644 --- a/packages/polycli-runtime/src/gemini.js +++ b/packages/polycli-runtime/src/gemini.js @@ -1,14 +1,21 @@ import { parseStreamJsonLine } from "@bbingz/polycli-utils/parse-stream-json"; -import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; +import { + binaryAvailable, + getSafeArgvBudgetBytes, + preflightArgv, + runCommand, +} from "@bbingz/polycli-utils/process"; import { resolveSessionId } from "@bbingz/polycli-utils/session-id"; -import { formatProviderExitError } from "./errors.js"; +import { classifyProviderFailure, formatProviderExitError } from "./errors.js"; import { spawnStreamingCommand } from "./spawn.js"; const GEMINI_BIN = process.env.GEMINI_CLI_BIN || "gemini"; const DEFAULT_TIMEOUT_MS = 300_000; const AUTH_CHECK_TIMEOUT_MS = 30_000; const PROMPT_STDIN_THRESHOLD = 100_000; +const SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +const SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; const GEMINI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; const VALID_GEMINI_EFFORTS = new Set(["low", "medium", "high"]); export const TRANSIENT_PROBE_ERROR_PATTERNS = [ @@ -45,16 +52,23 @@ export function buildGeminiInvocation({ resumeSessionId = null, extraArgs = [], bin = GEMINI_BIN, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const promptText = applyGeminiEffort(prompt, effort); - const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD; - const args = ["-p", useStdin ? "" : promptText, "-o", outputFormat]; const resolvedApprovalMode = write ? "auto_edit" : approvalMode; - - if (model) args.push("-m", model); - args.push("--approval-mode", resolvedApprovalMode); - if (resumeSessionId) args.push("--resume", resumeSessionId); - if (extraArgs.length > 0) args.push(...extraArgs); + const buildArgs = (inlinePrompt) => { + const nextArgs = ["-p", inlinePrompt ? promptText : "", "-o", outputFormat]; + if (model) nextArgs.push("-m", model); + nextArgs.push("--approval-mode", resolvedApprovalMode); + if (resumeSessionId) nextArgs.push("--resume", resumeSessionId); + if (extraArgs.length > 0) nextArgs.push(...extraArgs); + return nextArgs; + }; + const inlineArgs = buildArgs(true); + const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD + || !preflightArgv(bin, inlineArgs, { env, argvBudgetBytes }).ok; + const args = useStdin ? buildArgs(false) : inlineArgs; return { bin, @@ -202,7 +216,11 @@ export function runGeminiPrompt({ resumeSessionId = null, defaultModel = null, bin = GEMINI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { + const childEnv = buildGeminiEnv(env); const invocation = buildGeminiInvocation({ prompt, model, @@ -213,13 +231,18 @@ export function runGeminiPrompt({ resumeSessionId, extraArgs, bin, + env: childEnv, + argvBudgetBytes, }); const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, input: invocation.input, - env: buildGeminiEnv(), + env: childEnv, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, }); if (result.error) { @@ -228,6 +251,8 @@ export function runGeminiPrompt({ error: result.error.code === "ETIMEDOUT" ? `gemini timed out after ${Math.round(timeout / 1000)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error), + spawnErrorCode: result.spawnErrorCode ?? null, }; } @@ -250,7 +275,10 @@ export function runGeminiPromptStreaming({ onEvent = () => {}, bin = GEMINI_BIN, spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { + const childEnv = buildGeminiEnv(env); const invocation = buildGeminiInvocation({ prompt, model, @@ -261,16 +289,20 @@ export function runGeminiPromptStreaming({ resumeSessionId, extraArgs, bin, + env: childEnv, + argvBudgetBytes, }); return spawnStreamingCommand({ bin: invocation.bin, args: invocation.args, cwd, - env: buildGeminiEnv(), + env: childEnv, input: invocation.input, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const parsed = parseStreamJsonLine(line, { allowPrefix: true }); if (parsed.ok) { diff --git a/packages/polycli-runtime/src/grok.js b/packages/polycli-runtime/src/grok.js index 325eabe..c1e7b0e 100644 --- a/packages/polycli-runtime/src/grok.js +++ b/packages/polycli-runtime/src/grok.js @@ -1,4 +1,4 @@ -import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; +import { binaryAvailable, getSafeArgvBudgetBytes, runCommand } from "@bbingz/polycli-utils/process"; import { classifyProviderFailure, formatProviderExitError } from "./errors.js"; import { spawnStreamingCommand } from "./spawn.js"; @@ -6,6 +6,8 @@ import { spawnStreamingCommand } from "./spawn.js"; const GROK_BIN = process.env.GROK_CLI_BIN || "grok"; const DEFAULT_TIMEOUT_MS = 900_000; const AUTH_CHECK_TIMEOUT_MS = 30_000; +const SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +const SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; // `grok models` reports `Default model: grok-4.5`; callers pass `-m ` to switch. const DEFAULT_GROK_MODEL = "grok-4.5"; const GROK_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|not logged in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; @@ -205,6 +207,9 @@ export function runGrokPrompt({ extraArgs = [], defaultModel = null, bin = GROK_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildGrokInvocation({ prompt, @@ -219,12 +224,24 @@ export function runGrokPrompt({ bin, }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `grok timed out after ${Math.round(timeout / 1000)}s` : result.error.message; - return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "grok" }) }; + return { + ok: false, + error, + errorCode: classifyProviderFailure(result.error, { provider: "grok" }), + spawnErrorCode: result.spawnErrorCode ?? null, + }; } // grok prints transient "ERROR worker quit ... UnexpectedContentType" lines to STDERR even on a @@ -251,6 +268,8 @@ export function runGrokPromptStreaming({ onEvent = () => {}, bin = GROK_BIN, spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildGrokInvocation({ prompt, @@ -269,9 +288,11 @@ export function runGrokPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -288,14 +309,17 @@ export function runGrokPromptStreaming({ const ok = result.ok && hasVisibleText && !parsed.providerError && !stopReasonError; const error = ok ? null - : (parsed.providerError || stopReasonError || (result.ok ? "grok produced no visible text" : result.error)); + : ((!result.ok && result.errorCode ? result.error : null) + || parsed.providerError + || stopReasonError + || (result.ok ? "grok produced no visible text" : result.error)); return { ...result, ...parsed, model: model ?? defaultModel ?? DEFAULT_GROK_MODEL, ok, error, - errorCode: classifyProviderFailure(error, { provider: "grok" }), + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "grok" }), }; }); } diff --git a/packages/polycli-runtime/src/kimi.js b/packages/polycli-runtime/src/kimi.js index bde6fa1..28902c8 100644 --- a/packages/polycli-runtime/src/kimi.js +++ b/packages/polycli-runtime/src/kimi.js @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; +import { binaryAvailable, getSafeArgvBudgetBytes, runCommand } from "@bbingz/polycli-utils/process"; import { classifyProviderFailure, formatProviderExitError } from "./errors.js"; import { spawnStreamingCommand } from "./spawn.js"; @@ -10,6 +10,8 @@ import { spawnStreamingCommand } from "./spawn.js"; const KIMI_BIN = process.env.KIMI_CLI_BIN || "kimi"; const DEFAULT_TIMEOUT_MS = 900_000; const AUTH_CHECK_TIMEOUT_MS = 30_000; +const SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +const SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; const KIMI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; export const TRANSIENT_PROBE_ERROR_PATTERNS = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i, @@ -155,16 +157,31 @@ export function runKimiPrompt({ resumeLast = false, defaultModel = null, bin = KIMI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildKimiInvocation({ prompt, model, resumeSessionId, resumeLast, extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `kimi timed out after ${Math.round(timeout / 1000)}s` : result.error.message; - return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "kimi" }) }; + return { + ok: false, + error, + errorCode: classifyProviderFailure(result.error, { provider: "kimi" }), + spawnErrorCode: result.spawnErrorCode ?? null, + }; } if (result.status !== 0) { const error = result.stderr.trim() || formatProviderExitError("kimi", result.status); @@ -203,6 +220,8 @@ export function runKimiPromptStreaming({ onEvent = () => {}, bin = KIMI_BIN, spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildKimiInvocation({ prompt, model, resumeSessionId, resumeLast, extraArgs, bin }); @@ -210,9 +229,11 @@ export function runKimiPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const event = parseKimiEventLine(line); if (event) { @@ -233,7 +254,7 @@ export function runKimiPromptStreaming({ model: parsed.model ?? model ?? defaultModel ?? readKimiDefaultModel(), ok, error, - errorCode: classifyProviderFailure(error, { provider: "kimi" }), + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "kimi" }), }; }); } diff --git a/packages/polycli-runtime/src/minimax.js b/packages/polycli-runtime/src/minimax.js index f3a6b85..bacb801 100644 --- a/packages/polycli-runtime/src/minimax.js +++ b/packages/polycli-runtime/src/minimax.js @@ -1,10 +1,12 @@ -import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; +import { binaryAvailable, getSafeArgvBudgetBytes, runCommand } from "@bbingz/polycli-utils/process"; import { spawnStreamingCommand } from "./spawn.js"; const MMX_BIN = process.env.MMX_CLI_BIN || process.env.MINIMAX_CLI_BIN || "mmx"; const DEFAULT_TIMEOUT_MS = 120_000; const AUTH_CHECK_TIMEOUT_MS = 30_000; +const SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +const SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; const MINIMAX_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; export const TRANSIENT_PROBE_ERROR_PATTERNS = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i, @@ -237,6 +239,7 @@ export function runMiniMaxPrompt({ env = process.env, bin = MMX_BIN, spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { return new Promise((resolve) => { const invocation = buildMiniMaxInvocation({ prompt, model, extraArgs, bin }); @@ -249,6 +252,8 @@ export function runMiniMaxPrompt({ timeout, stdio: ["ignore", "pipe", "pipe"], spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine() {}, }).then((result) => { try { @@ -311,6 +316,7 @@ export async function runMiniMaxPromptStreaming({ onEvent = () => {}, bin = MMX_BIN, spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const events = []; return runMiniMaxPrompt({ @@ -323,6 +329,7 @@ export async function runMiniMaxPromptStreaming({ env, bin, spawnImpl, + argvBudgetBytes, }).then((result) => { try { const event = { diff --git a/packages/polycli-runtime/src/opencode.js b/packages/polycli-runtime/src/opencode.js index 220447e..77bbd65 100644 --- a/packages/polycli-runtime/src/opencode.js +++ b/packages/polycli-runtime/src/opencode.js @@ -1,5 +1,5 @@ -import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; -import { resolveSessionId } from "@bbingz/polycli-utils/session-id"; +import { binaryAvailable, getSafeArgvBudgetBytes, runCommand } from "@bbingz/polycli-utils/process"; +import { matchResumeSessionIdLine } from "@bbingz/polycli-utils/session-id"; import { classifyProviderFailure, formatProviderExitError } from "./errors.js"; import { spawnStreamingCommand } from "./spawn.js"; @@ -8,6 +8,8 @@ const OPENCODE_BIN = process.env.OPENCODE_CLI_BIN || "opencode"; const DEFAULT_TIMEOUT_MS = 900_000; const AUTH_CHECK_TIMEOUT_MS = 30_000; const SESSION_EXPORT_TIMEOUT_MS = 30_000; +const SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +const SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; const OPENCODE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; export const TRANSIENT_PROBE_ERROR_PATTERNS = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i, @@ -223,11 +225,6 @@ export function parseOpenCodeStreamText(text) { export function parseOpenCodeJsonResult(stdout, stderr, status, { defaultModel = null } = {}) { const parsed = parseOpenCodeStreamText(stdout); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"], - }); const resultError = getOpenCodeResultError(parsed.resultEvent); const sessionErrorMessage = getOpenCodeSessionErrorDataMessage(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); @@ -239,7 +236,7 @@ export function parseOpenCodeJsonResult(stdout, stderr, status, { defaultModel = ok: status === 0 && !resultError && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(stderr), model: parsed.model ?? defaultModel, status, error, @@ -293,6 +290,8 @@ export function runOpenCodePrompt({ skipPermissions = true, defaultModel = null, bin = OPENCODE_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildOpenCodeInvocation({ prompt, @@ -307,7 +306,14 @@ export function runOpenCodePrompt({ bin, }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, env }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `opencode timed out after ${Math.round(timeout / 1000)}s` @@ -315,7 +321,8 @@ export function runOpenCodePrompt({ return { ok: false, error, - errorCode: classifyProviderFailure(error, { provider: "opencode" }), + errorCode: classifyProviderFailure(result.error, { provider: "opencode" }), + spawnErrorCode: result.spawnErrorCode ?? null, }; } @@ -345,6 +352,7 @@ export function runOpenCodePromptStreaming({ onEvent = () => {}, bin = OPENCODE_BIN, spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildOpenCodeInvocation({ prompt, @@ -366,6 +374,8 @@ export function runOpenCodePromptStreaming({ env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -375,30 +385,28 @@ export function runOpenCodePromptStreaming({ }, }).then((result) => { const parsed = parseOpenCodeStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"], - }); + const fallbackSessionId = matchResumeSessionIdLine(result.stderr); const resultError = getOpenCodeResultError(parsed.resultEvent); const sessionErrorMessage = getOpenCodeSessionErrorDataMessage(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); let resolvedModel = parsed.model ?? model ?? defaultModel; const ok = result.ok && !resultError && hasVisibleText; if (ok && !resolvedModel) { - resolvedModel = resolveOpenCodeSessionModel(parsed.sessionId ?? resolvedSession.sessionId, { cwd, env, bin }); + resolvedModel = resolveOpenCodeSessionModel(parsed.sessionId ?? fallbackSessionId, { cwd, env, bin }); } - const error = sessionErrorMessage || (result.ok - ? (resultError || (hasVisibleText ? null : "opencode produced no visible text")) - : result.error); + const error = (!result.ok && result.errorCode ? result.error : null) + || sessionErrorMessage + || (result.ok + ? (resultError || (hasVisibleText ? null : "opencode produced no visible text")) + : result.error); return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? fallbackSessionId, model: resolvedModel, ok, error, - errorCode: classifyProviderFailure(error, { provider: "opencode" }), + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "opencode" }), }; }); } diff --git a/packages/polycli-runtime/src/pi.js b/packages/polycli-runtime/src/pi.js index e6950f7..39d1808 100644 --- a/packages/polycli-runtime/src/pi.js +++ b/packages/polycli-runtime/src/pi.js @@ -1,13 +1,15 @@ -import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; +import { binaryAvailable, getSafeArgvBudgetBytes, runCommand } from "@bbingz/polycli-utils/process"; import { resolveSessionId } from "@bbingz/polycli-utils/session-id"; -import { formatProviderExitError } from "./errors.js"; +import { classifyProviderFailure, formatProviderExitError } from "./errors.js"; import { spawnStreamingCommand } from "./spawn.js"; const PI_BIN = process.env.PI_CLI_BIN || "pi"; const DEFAULT_PI_MODEL = null; const DEFAULT_TIMEOUT_MS = 900_000; const AUTH_CHECK_TIMEOUT_MS = 30_000; +const SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +const SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; const PI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; export const TRANSIENT_PROBE_ERROR_PATTERNS = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i, @@ -208,6 +210,9 @@ export function runPiPrompt({ noSession = false, defaultModel = null, bin = PI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildPiInvocation({ prompt, @@ -220,13 +225,22 @@ export function runPiPrompt({ bin, }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, + }); if (result.error) { return { ok: false, error: result.error.code === "ETIMEDOUT" ? `pi timed out after ${Math.round(timeout / 1000)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error, { provider: "pi" }), + spawnErrorCode: result.spawnErrorCode ?? null, }; } @@ -270,6 +284,8 @@ export function runPiPromptStreaming({ onEvent = () => {}, bin = PI_BIN, spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildPiInvocation({ prompt, @@ -286,9 +302,11 @@ export function runPiPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; diff --git a/packages/polycli-runtime/src/qwen.js b/packages/polycli-runtime/src/qwen.js index 58073c7..853ce3e 100644 --- a/packages/polycli-runtime/src/qwen.js +++ b/packages/polycli-runtime/src/qwen.js @@ -1,5 +1,5 @@ -import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; -import { resolveSessionId } from "@bbingz/polycli-utils/session-id"; +import { binaryAvailable, getSafeArgvBudgetBytes, runCommand } from "@bbingz/polycli-utils/process"; +import { matchResumeSessionIdLine } from "@bbingz/polycli-utils/session-id"; import { classifyProviderFailure, formatProviderExitError } from "./errors.js"; import { spawnStreamingCommand } from "./spawn.js"; @@ -7,6 +7,8 @@ import { spawnStreamingCommand } from "./spawn.js"; const QWEN_BIN = process.env.QWEN_CLI_BIN || "qwen"; const DEFAULT_TIMEOUT_MS = 300_000; const AUTH_CHECK_TIMEOUT_MS = 30_000; +const SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +const SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; const PROXY_KEYS = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]; const NO_PROXY_DEFAULTS = ["localhost", "127.0.0.1"]; @@ -268,6 +270,8 @@ export function runQwenPrompt({ appendDirs, extraArgs = [], bin = QWEN_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildQwenInvocation({ prompt, @@ -289,21 +293,24 @@ export function runQwenPrompt({ cwd, env, timeout, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `qwen timed out after ${Math.round(timeout / 1000)}s` : result.error.message; - return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "qwen" }) }; + return { + ok: false, + error, + errorCode: classifyProviderFailure(result.error, { provider: "qwen" }), + spawnErrorCode: result.spawnErrorCode ?? null, + }; } const parsed = parseQwenStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"], - }); const resultEventError = extractQwenResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.status === 0 && !resultEventError && hasVisibleText @@ -319,7 +326,7 @@ export function runQwenPrompt({ status: result.status, stderr: result.stderr, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), error, errorCode, }; @@ -344,6 +351,7 @@ export function runQwenPromptStreaming({ onEvent = () => {}, bin = QWEN_BIN, spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES, } = {}) { const invocation = buildQwenInvocation({ prompt, @@ -367,10 +375,12 @@ export function runQwenPromptStreaming({ cwd, env, timeout, - detached: background, + detached: background ? true : undefined, unref: background, stdio: ["ignore", "pipe", "pipe"], spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { if (!line.trim().startsWith("{")) return; try { @@ -379,23 +389,19 @@ export function runQwenPromptStreaming({ }, }).then((result) => { const parsed = parseQwenStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"], - }); const hasVisibleText = Boolean(parsed.response.trim()); const resultEventError = extractQwenResultError(parsed.resultEvent); - const error = result.ok && !resultEventError + const commonFailure = !result.ok && result.errorCode ? result.error : null; + const error = commonFailure || (result.ok && !resultEventError ? (hasVisibleText ? null : (resultEventError || "qwen produced no visible text")) - : (resultEventError || result.error); - const errorCode = resultEventError + : (resultEventError || result.error)); + const errorCode = result.errorCode ?? (resultEventError ? (classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error") - : classifyProviderFailure(error, { provider: "qwen" }); + : classifyProviderFailure(error, { provider: "qwen" })); return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), ok: result.ok && !resultEventError && hasVisibleText, error, errorCode, diff --git a/packages/polycli-runtime/src/spawn.js b/packages/polycli-runtime/src/spawn.js index a0d6cdc..96032c4 100644 --- a/packages/polycli-runtime/src/spawn.js +++ b/packages/polycli-runtime/src/spawn.js @@ -1,7 +1,12 @@ import { spawn } from "node:child_process"; +import { preflightArgv } from "@bbingz/polycli-utils/process"; import { createLineDecoder } from "@bbingz/polycli-utils/stream"; +import { classifyProviderFailure } from "./errors.js"; + +const DEFAULT_CAPTURE_LIMIT_BYTES = 1_048_576; + function formatExitError(status, signal, { timedOut = false, aborted = false } = {}) { if (aborted) { return "process aborted"; @@ -18,6 +23,38 @@ function formatExitError(status, signal, { timedOut = false, aborted = false } = return `process exited with code ${status}`; } +function normalizeCaptureLimit(value) { + return Number.isSafeInteger(value) && value >= 0 + ? value + : DEFAULT_CAPTURE_LIMIT_BYTES; +} + +function spawnFailureResult(error, captureLimitBytes) { + const spawnErrorCode = typeof error?.code === "string" ? error.code : null; + return { + ok: false, + status: null, + signal: null, + timedOut: false, + aborted: false, + stdout: "", + stderr: "", + stdoutBytes: 0, + stderrBytes: 0, + stdoutTruncated: false, + stderrTruncated: false, + captureLimitBytes, + outputOverflowStream: null, + closeTimedOut: false, + terminationErrors: [], + terminationFailure: null, + terminationReason: null, + spawnErrorCode, + error: String(error?.message ?? error), + errorCode: classifyProviderFailure(error), + }; +} + export function spawnStreamingCommand({ bin, args = [], @@ -28,27 +65,32 @@ export function spawnStreamingCommand({ killGraceMs = 2_000, signal = null, maxBufferBytes = 1_048_576, + maxCaptureBytes = DEFAULT_CAPTURE_LIMIT_BYTES, + argvBudgetBytes = null, + argvBudgetHint = null, stdio = ["pipe", "pipe", "pipe"], - detached = false, + detached = process.platform !== "win32", unref = false, spawnImpl = spawn, onStdoutLine = () => {}, onStderrChunk = () => {}, } = {}) { + const captureLimitBytes = normalizeCaptureLimit(maxCaptureBytes); + const preflight = preflightArgv(bin, args, { + env: env ?? process.env, + argvBudgetBytes, + argvBudgetHint, + }); + if (!preflight.ok) { + return Promise.resolve(spawnFailureResult(preflight.error, captureLimitBytes)); + } + return new Promise((resolve) => { let child; try { child = spawnImpl(bin, args, { cwd, env, stdio, detached }); } catch (error) { - resolve({ - ok: false, - status: null, - signal: null, - timedOut: false, - stdout: "", - stderr: "", - error: error.message, - }); + resolve(spawnFailureResult(error, captureLimitBytes)); return; } @@ -57,37 +99,73 @@ export function spawnStreamingCommand({ } const decoder = createLineDecoder({ maxBufferBytes }); - let stdout = ""; - let stderr = ""; + const stdoutParts = []; + const stderrParts = []; + let stdoutCapturedBytes = 0; + let stderrCapturedBytes = 0; + let stdoutBytes = 0; + let stderrBytes = 0; + let stdoutTruncated = false; + let stderrTruncated = false; + let outputOverflowStream = null; let timedOut = false; let aborted = false; let settled = false; let timer = null; let forceTimer = null; + let settleTimer = null; let decoderError = null; + let lifecycleFailure = null; let terminationRequested = false; + const terminationErrors = []; + + const setLifecycleFailure = (code, message) => { + if (!lifecycleFailure) { + lifecycleFailure = { code, message }; + } + }; + + const recordTerminationError = (error, killSignal, target) => { + terminationErrors.push({ + signal: killSignal, + target, + code: typeof error?.code === "string" ? error.code : null, + message: String(error?.message ?? error).slice(0, 4_096), + }); + }; - const signalChild = (signal) => { + const trySignal = (killSignal, target, sender) => { try { - if (detached && Number.isInteger(child.pid) && child.pid > 0 && process.platform !== "win32") { - process.kill(-child.pid, signal); - return; + const delivered = sender(); + if (delivered === false) { + const error = new Error(`${target} kill returned false`); + error.code = "KILL_RETURNED_FALSE"; + recordTerminationError(error, killSignal, target); + return false; } - child.kill(signal); - } catch { - // ignore + return true; + } catch (error) { + recordTerminationError(error, killSignal, target); + return false; } }; - const terminateChild = () => { - if (terminationRequested) return; - terminationRequested = true; - signalChild("SIGTERM"); - if (killGraceMs > 0) { - forceTimer = setTimeout(() => { - signalChild("SIGKILL"); - }, killGraceMs); + const signalChild = (killSignal) => { + const canSignalGroup = detached + && Number.isInteger(child.pid) + && child.pid > 0 + && process.platform !== "win32"; + + if (canSignalGroup) { + const groupSignalled = trySignal( + killSignal, + "process_group", + () => process.kill(-child.pid, killSignal) + ); + if (groupSignalled) return; } + + trySignal(killSignal, "child", () => child.kill(killSignal)); }; const cleanup = () => { @@ -101,122 +179,235 @@ export function spawnStreamingCommand({ child.off?.("close", handleChildClose); }; + const capturedStdout = () => stdoutParts.join(""); + const capturedStderr = () => Buffer.concat(stderrParts, stderrCapturedBytes).toString("utf8"); + + const buildResult = (status, signalName, { closeTimedOut = false } = {}) => { + if (!lifecycleFailure) { + if (timedOut || status === 124) { + setLifecycleFailure("timeout", "process timed out"); + } else if (aborted || signalName === "SIGINT" || status === 130) { + setLifecycleFailure("cancelled", aborted ? "process aborted" : "process interrupted"); + } else if (signalName || status === 143) { + setLifecycleFailure("terminated", formatExitError(status, signalName)); + } + } + + const stdout = capturedStdout(); + const stderr = capturedStderr(); + const ok = status === 0 && !lifecycleFailure && !closeTimedOut; + const error = ok + ? null + : lifecycleFailure?.message + ?? (stderr.trim() || formatExitError(status, signalName, { timedOut, aborted })); + + return { + ok, + status, + signal: signalName, + timedOut, + aborted, + stdout, + stderr, + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated, + captureLimitBytes, + outputOverflowStream, + closeTimedOut, + terminationErrors: terminationErrors.map((entry) => ({ ...entry })), + terminationFailure: closeTimedOut + ? "close_timeout" + : (terminationErrors.length > 0 ? "signal_error" : null), + terminationReason: lifecycleFailure?.code ?? null, + spawnErrorCode: null, + error, + errorCode: lifecycleFailure?.code ?? classifyProviderFailure(error), + }; + }; + const finish = (result) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); if (forceTimer) clearTimeout(forceTimer); + if (settleTimer) clearTimeout(settleTimer); cleanup(); resolve(result); }; - const decoderFailureResult = (status, signalName) => ({ - ok: false, - status, - signal: signalName, - timedOut, - stdout, - stderr, - error: decoderError, - }); + const settleWithoutClose = () => { + if (settled) return; + finish(buildResult(null, null, { closeTimedOut: true })); + }; + + const forceKill = () => { + if (settled) return; + signalChild("SIGKILL"); + if (settled) return; + const closeWaitMs = Math.max(10, Math.max(0, killGraceMs)); + settleTimer = setTimeout(settleWithoutClose, closeWaitMs); + }; + + const terminateChild = (code, message) => { + setLifecycleFailure(code, message); + if (terminationRequested) return; + terminationRequested = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + signalChild("SIGTERM"); + if (settled) return; + forceTimer = setTimeout(forceKill, Math.max(0, killGraceMs)); + }; + + const handleOutputOverflow = (streamName) => { + if (!outputOverflowStream) { + outputOverflowStream = streamName; + } + terminateChild( + "output_overflow", + `${streamName} capture exceeded maxCaptureBytes (${captureLimitBytes})` + ); + }; + + const appendStdoutLine = (line, { trailingNewline = true } = {}) => { + const text = trailingNewline ? `${line}\n` : line; + const bytes = Buffer.byteLength(text); + if (stdoutCapturedBytes + bytes > captureLimitBytes) { + stdoutTruncated = true; + return false; + } + stdoutParts.push(text); + stdoutCapturedBytes += bytes; + try { onStdoutLine(line); } catch {} + return true; + }; const handleDecoderError = (error) => { if (decoderError || settled) return; decoderError = String(error?.message ?? error).slice(0, 4_096); + stdoutTruncated = true; + outputOverflowStream = outputOverflowStream || "stdout"; child.stdout?.off?.("data", handleStdoutData); - terminateChild(); + terminateChild("output_overflow", decoderError); }; const abortHandler = () => { if (settled || aborted) return; aborted = true; - terminateChild(); + terminateChild("cancelled", "process aborted"); }; if (timeout != null) { timer = setTimeout(() => { - if (settled || timedOut) return; + if (settled || timedOut || terminationRequested) return; timedOut = true; - terminateChild(); + terminateChild("timeout", "process timed out"); }, timeout); } const handleChildError = (error) => { - if (decoderError) { + if (terminationRequested) { return; } finish({ - ok: false, - status: null, - signal: null, + ...spawnFailureResult(error, captureLimitBytes), timedOut, - stdout, - stderr, - error: error.message, + aborted, + stdout: capturedStdout(), + stderr: capturedStderr(), + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated, }); }; + const captureStderr = (chunk, { emit = true } = {}) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + stderrBytes += buffer.length; + const remaining = Math.max(captureLimitBytes - stderrCapturedBytes, 0); + const accepted = buffer.subarray(0, remaining); + if (accepted.length > 0) { + stderrParts.push(Buffer.from(accepted)); + stderrCapturedBytes += accepted.length; + if (emit) { + try { onStderrChunk(accepted.toString("utf8")); } catch {} + } + } + if (accepted.length < buffer.length) { + stderrTruncated = true; + child.stderr?.off?.("data", handleStderrData); + handleOutputOverflow("stderr"); + } + }; + const handleStdinError = (error) => { if (error?.code === "EPIPE" || error?.code === "ERR_STREAM_DESTROYED") { return; } - stderr += `${error.message}\n`; + captureStderr(Buffer.from(`${error.message}\n`), { emit: false }); }; const handleStdoutData = (chunk) => { if (settled) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + const previousBytes = stdoutBytes; + stdoutBytes += buffer.length; + const remaining = Math.max(captureLimitBytes - previousBytes, 0); + const accepted = buffer.subarray(0, remaining); + let lines; try { - lines = decoder.push(chunk); + lines = accepted.length > 0 ? decoder.push(accepted) : []; } catch (error) { handleDecoderError(error); return; } + for (const line of lines) { - stdout += `${line}\n`; - try { onStdoutLine(line); } catch {} + if (!appendStdoutLine(line)) { + child.stdout?.off?.("data", handleStdoutData); + handleOutputOverflow("stdout"); + return; + } + } + + if (accepted.length < buffer.length) { + stdoutTruncated = true; + child.stdout?.off?.("data", handleStdoutData); + handleOutputOverflow("stdout"); } }; const handleStderrData = (chunk) => { if (settled) return; - const text = chunk.toString("utf8"); - stderr += text; - try { onStderrChunk(text); } catch {} + captureStderr(chunk); }; const handleChildClose = (status, signalName) => { - if (decoderError) { - finish(decoderFailureResult(status, signalName)); - return; - } - - let lines; - try { - lines = decoder.end(); - } catch (error) { - handleDecoderError(error); - finish(decoderFailureResult(status, signalName)); - return; - } + if (!decoderError && !stdoutTruncated) { + let lines; + try { + lines = decoder.end(); + } catch (error) { + handleDecoderError(error); + finish(buildResult(status, signalName)); + return; + } - for (const line of lines) { - stdout += `${line}\n`; - try { onStdoutLine(line); } catch {} + for (const line of lines) { + if (!appendStdoutLine(line, { trailingNewline: false })) { + handleOutputOverflow("stdout"); + break; + } + } } - finish({ - ok: status === 0 && !timedOut && !aborted, - status, - signal: signalName, - timedOut, - stdout, - stderr, - error: - status === 0 && !timedOut && !aborted - ? null - : stderr.trim() || formatExitError(status, signalName, { timedOut, aborted }), - }); + finish(buildResult(status, signalName)); }; child.on("error", handleChildError); diff --git a/packages/polycli-runtime/test/claude.test.js b/packages/polycli-runtime/test/claude.test.js index aeaaf5c..de67f41 100644 --- a/packages/polycli-runtime/test/claude.test.js +++ b/packages/polycli-runtime/test/claude.test.js @@ -352,9 +352,53 @@ test("runClaudePromptStreaming treats a successful final result before timeout a assert.equal(result.timedOut, false); assert.equal(result.response, "review complete"); assert.equal(result.error, null); + assert.equal(result.errorCode, null); + assert.equal(result.terminationReason, null); + assert.equal(result.status, 143); assert.equal(result.sessionId, "claude-stream-timeout"); }); +test("runClaudePromptStreaming preserves timeout failure when termination cannot be confirmed", async () => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { write() {}, end() {}, on() {} }; + child.kill = (signal) => { + const error = new Error(`kill ${signal} EPERM`); + error.code = "EPERM"; + throw error; + }; + const pending = Symbol("pending"); + + const resultPromise = runClaudePromptStreaming({ + prompt: "ping", + timeout: 5, + killGraceMs: 5, + spawnImpl() { + queueMicrotask(() => { + child.stdout.emit("data", '{"type":"content_block_delta","delta":{"type":"text_delta","text":"review complete"}}\n'); + child.stdout.emit("data", '{"type":"result","subtype":"success","is_error":false,"result":"review complete"}\n'); + }); + return child; + }, + }); + const boundedTimeout = new Promise((resolve) => { + const timer = setTimeout(() => resolve(pending), 5_000); + timer.unref?.(); + }); + const result = await Promise.race([resultPromise, boundedTimeout]); + + assert.notEqual(result, pending, "Claude streaming must settle when termination never closes"); + assert.equal(result.ok, false); + assert.equal(result.timedOut, true); + assert.equal(result.closeTimedOut, true); + assert.equal(result.terminationFailure, "close_timeout"); + assert.equal(result.errorCode, "timeout"); + assert.equal(result.terminationReason, "timeout"); + assert.equal(result.status, null); + assert.equal(result.terminationErrors.length > 0, true); +}); + test("runClaudePromptStreaming passes caller env through print mode", async () => { const child = new EventEmitter(); child.stdout = new EventEmitter(); @@ -656,6 +700,33 @@ test("runClaudePromptStreaming still fails timeout recovery when no visible text assert.equal(result.timedOut, false); assert.equal(result.response, ""); assert.equal(result.error, "claude produced no visible text"); + assert.equal(result.errorCode, "no_visible_text"); + assert.equal(result.terminationReason, null); + assert.equal(result.status, 143); +}); + +test("runClaudePromptStreaming preserves timeout diagnostics without a terminal result", async () => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { write() {}, end() {}, on() {} }; + child.kill = () => { + queueMicrotask(() => child.emit("close", 143, null)); + }; + + const result = await runClaudePromptStreaming({ + prompt: "ping", + timeout: 5, + spawnImpl() { + return child; + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.timedOut, true); + assert.equal(result.errorCode, "timeout"); + assert.equal(result.terminationReason, "timeout"); + assert.equal(result.status, 143); }); test("parseClaudeStreamText replays a captured real cli fixture", () => { diff --git a/packages/polycli-runtime/test/error-code-adapters.test.js b/packages/polycli-runtime/test/error-code-adapters.test.js new file mode 100644 index 0000000..7c6beef --- /dev/null +++ b/packages/polycli-runtime/test/error-code-adapters.test.js @@ -0,0 +1,61 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; + +import { runAgyPromptStreaming } from "../src/agy.js"; +import { runClaudePromptStreaming } from "../src/claude.js"; +import { runCmdPromptStreaming } from "../src/cmd.js"; +import { runCopilotPromptStreaming } from "../src/copilot.js"; +import { runGeminiPromptStreaming } from "../src/gemini.js"; +import { runGrokPromptStreaming } from "../src/grok.js"; +import { runKimiPromptStreaming } from "../src/kimi.js"; +import { runMiniMaxPromptStreaming } from "../src/minimax.js"; +import { runOpenCodePromptStreaming } from "../src/opencode.js"; +import { runPiPromptStreaming } from "../src/pi.js"; +import { runQwenPromptStreaming } from "../src/qwen.js"; + +function createSpawnErrorChild() { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { write() {}, end() {}, on() {} }; + child.kill = () => {}; + return child; +} + +const streamingAdapters = [ + ["agy", runAgyPromptStreaming, {}], + ["claude", runClaudePromptStreaming, {}], + ["cmd", runCmdPromptStreaming, {}], + ["copilot", runCopilotPromptStreaming, {}], + ["gemini", runGeminiPromptStreaming, {}], + ["grok", runGrokPromptStreaming, {}], + ["kimi", runKimiPromptStreaming, { defaultModel: "test-model" }], + ["minimax", runMiniMaxPromptStreaming, {}], + ["opencode", runOpenCodePromptStreaming, {}], + ["pi", runPiPromptStreaming, {}], + ["qwen", runQwenPromptStreaming, {}], +]; + +for (const [provider, runner, providerOptions] of streamingAdapters) { + test(`${provider} streaming preserves a structured common runtime errorCode`, async () => { + const child = createSpawnErrorChild(); + const result = await runner({ + prompt: "ping", + ...providerOptions, + spawnImpl() { + queueMicrotask(() => { + const error = new Error("opaque spawn failure"); + error.code = "E2BIG"; + child.emit("error", error); + }); + return child; + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.error, "opaque spawn failure"); + assert.equal(result.spawnErrorCode, "E2BIG"); + assert.equal(result.errorCode, "argument_list_too_long"); + }); +} diff --git a/packages/polycli-runtime/test/errors.test.js b/packages/polycli-runtime/test/errors.test.js index d55dc42..db29e44 100644 --- a/packages/polycli-runtime/test/errors.test.js +++ b/packages/polycli-runtime/test/errors.test.js @@ -34,6 +34,10 @@ test("classifyProviderFailure recognizes qwen max-session-turns only for qwen", test("classifyProviderFailure accepts an Error object and orders binary_missing before timeout", () => { assert.equal(classifyProviderFailure(new Error("spawn ENOENT")), "binary_missing"); + const oversizedArgv = new Error("spawn failed"); + oversizedArgv.code = "E2BIG"; + assert.equal(classifyProviderFailure(oversizedArgv), "argument_list_too_long"); + assert.equal(classifyProviderFailure("stdout capture exceeded maxCaptureBytes (16)"), "output_overflow"); // 'not found' is checked before 'timed out', so binary_missing wins when both appear. assert.equal(classifyProviderFailure("binary not found; also timed out"), "binary_missing"); }); diff --git a/packages/polycli-runtime/test/gemini.test.js b/packages/polycli-runtime/test/gemini.test.js index f9de673..1cd1ea3 100644 --- a/packages/polycli-runtime/test/gemini.test.js +++ b/packages/polycli-runtime/test/gemini.test.js @@ -94,7 +94,7 @@ test("buildGeminiInvocation switches to stdin by UTF-8 byte length", () => { test("buildGeminiInvocation keeps byte-small CJK prompts inline", () => { const prompt = "你".repeat(30_000); - const invocation = buildGeminiInvocation({ prompt }); + const invocation = buildGeminiInvocation({ prompt, env: {} }); assert.equal(Buffer.byteLength(prompt, "utf8") <= 100_000, true); assert.equal(invocation.useStdin, false); diff --git a/packages/polycli-runtime/test/prompt-transport.test.js b/packages/polycli-runtime/test/prompt-transport.test.js new file mode 100644 index 0000000..4c3971c --- /dev/null +++ b/packages/polycli-runtime/test/prompt-transport.test.js @@ -0,0 +1,215 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; + +import { + buildClaudeInvocation, + buildGeminiInvocation, + runClaudePrompt, + runClaudePromptStreaming, + runCmdPrompt, + runCmdPromptStreaming, + runCopilotPrompt, + runCopilotPromptStreaming, + runGeminiPrompt, + runGeminiPromptStreaming, + runGrokPrompt, + runGrokPromptStreaming, + runKimiPrompt, + runKimiPromptStreaming, + runMiniMaxPrompt, + runMiniMaxPromptStreaming, + runOpenCodePrompt, + runOpenCodePromptStreaming, + runPiPrompt, + runPiPromptStreaming, + runQwenPrompt, + runQwenPromptStreaming, +} from "../src/index.js"; + +const LARGE_PROMPT = `UNIQUE_LARGE_PROMPT_MARKER_${"x".repeat(220_000)}`; + +function createFakeChild({ stdout = "", stderr = "", status = 0 } = {}) { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter(); + child.stdinText = ""; + child.stdin.write = (chunk) => { + child.stdinText += String(chunk); + return true; + }; + child.stdin.end = () => {}; + child.kill = () => true; + queueMicrotask(() => { + if (stdout) child.stdout.emit("data", Buffer.from(stdout)); + if (stderr) child.stderr.emit("data", Buffer.from(stderr)); + child.emit("close", status, null); + }); + return child; +} + +test("Claude and Gemini switch sub-100KB prompts to stdin when the effective argv budget is smaller", () => { + const prompt = `WINDOWS_BUDGET_MARKER_${"x".repeat(30_000)}`; + for (const [provider, invocation] of [ + ["claude", buildClaudeInvocation({ prompt, env: { PATH: "C:\\bin" }, argvBudgetBytes: 24 * 1024 })], + ["gemini", buildGeminiInvocation({ prompt, env: { PATH: "C:\\bin" }, argvBudgetBytes: 24 * 1024 })], + ]) { + assert.equal(invocation.useStdin, true, provider); + assert.equal(invocation.input, prompt, provider); + assert.equal(invocation.args.includes(prompt), false, provider); + } +}); + +test("Claude and Gemini sync runners send >200KB prompts completely through stdin", () => { + const cases = [ + { + provider: "claude", + run: runClaudePrompt, + stdout: JSON.stringify({ type: "result", result: "ok", session_id: "claude-session" }), + }, + { + provider: "gemini", + run: runGeminiPrompt, + stdout: JSON.stringify({ response: "ok", session_id: "gemini-session", stats: { models: { test: 1 } } }), + }, + ]; + + for (const entry of cases) { + let observed = null; + const result = entry.run({ + prompt: LARGE_PROMPT, + env: { PATH: "/bin" }, + bin: `${entry.provider}-fake`, + spawnImpl(bin, args, options) { + observed = { bin, args, options }; + return { status: 0, signal: null, stdout: entry.stdout, stderr: "", error: null }; + }, + }); + + assert.equal(result.ok, true, entry.provider); + assert.equal(observed?.options.input, LARGE_PROMPT, entry.provider); + assert.equal(observed?.args.includes(LARGE_PROMPT), false, entry.provider); + assert.equal(observed?.args.join(" ").includes("UNIQUE_LARGE_PROMPT_MARKER"), false, entry.provider); + } +}); + +test("Claude and Gemini streaming runners send >200KB prompts completely through stdin", async () => { + const cases = [ + { + provider: "claude", + run: runClaudePromptStreaming, + stdout: [ + JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session" }), + JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: "ok" }] } }), + JSON.stringify({ type: "result", result: "ok", session_id: "claude-session" }), + ].join("\n") + "\n", + }, + { + provider: "gemini", + run: runGeminiPromptStreaming, + stdout: [ + JSON.stringify({ type: "init", session_id: "gemini-session" }), + JSON.stringify({ type: "message", role: "assistant", content: "ok" }), + JSON.stringify({ type: "result" }), + ].join("\n") + "\n", + }, + ]; + + for (const entry of cases) { + let observed = null; + const result = await entry.run({ + prompt: LARGE_PROMPT, + env: { PATH: "/bin" }, + bin: `${entry.provider}-fake`, + spawnImpl(bin, args, options) { + const child = createFakeChild({ stdout: entry.stdout }); + observed = { bin, args, options, child }; + return child; + }, + }); + + assert.equal(result.ok, true, entry.provider); + assert.equal(observed?.child.stdinText, LARGE_PROMPT, entry.provider); + assert.equal(observed?.args.includes(LARGE_PROMPT), false, entry.provider); + assert.equal(observed?.args.join(" ").includes("UNIQUE_LARGE_PROMPT_MARKER"), false, entry.provider); + } +}); + +const ARGV_ONLY_PROVIDERS = [ + { provider: "copilot", sync: runCopilotPrompt, streaming: runCopilotPromptStreaming }, + { provider: "opencode", sync: runOpenCodePrompt, streaming: runOpenCodePromptStreaming }, + { provider: "qwen", sync: runQwenPrompt, streaming: runQwenPromptStreaming }, + { provider: "pi", sync: runPiPrompt, streaming: runPiPromptStreaming }, + { provider: "cmd", sync: runCmdPrompt, streaming: runCmdPromptStreaming }, + { provider: "kimi", sync: runKimiPrompt, streaming: runKimiPromptStreaming }, + { provider: "minimax", sync: runMiniMaxPrompt, streaming: runMiniMaxPromptStreaming }, + { provider: "grok", sync: runGrokPrompt, streaming: runGrokPromptStreaming }, +]; + +test("all argv-only review providers reject >200KB prompts before sync spawn", async (t) => { + for (const entry of ARGV_ONLY_PROVIDERS) { + await t.test(entry.provider, async () => { + let spawnCalls = 0; + const result = await entry.sync({ + prompt: LARGE_PROMPT, + env: {}, + cwd: process.cwd(), + bin: `__${entry.provider}_must_not_spawn__`, + spawnImpl() { + spawnCalls += 1; + throw new Error("spawn must not be called"); + }, + }); + + assert.equal(spawnCalls, 0); + assert.equal(result.spawnErrorCode, "E2BIG"); + assert.equal(result.errorCode, "argument_list_too_long"); + assert.match(result.error, /--max-diff-bytes/); + assert.doesNotMatch(result.error, /UNIQUE_LARGE_PROMPT_MARKER/); + }); + } +}); + +test("all argv-only review providers reject >200KB prompts before streaming spawn", async (t) => { + for (const entry of ARGV_ONLY_PROVIDERS) { + await t.test(entry.provider, async () => { + let spawnCalls = 0; + const result = await entry.streaming({ + prompt: LARGE_PROMPT, + env: {}, + cwd: process.cwd(), + bin: `__${entry.provider}_must_not_spawn__`, + spawnImpl() { + spawnCalls += 1; + throw new Error("spawn must not be called"); + }, + }); + + assert.equal(spawnCalls, 0); + assert.equal(result.spawnErrorCode, "E2BIG"); + assert.equal(result.errorCode, "argument_list_too_long"); + assert.match(result.error, /--max-diff-bytes/); + assert.doesNotMatch(result.error, /UNIQUE_LARGE_PROMPT_MARKER/); + }); + } +}); + +test("all argv-only review providers still call streaming spawn for short prompts", async (t) => { + for (const entry of ARGV_ONLY_PROVIDERS) { + await t.test(entry.provider, async () => { + let spawnCalls = 0; + await entry.streaming({ + prompt: "short", + env: {}, + cwd: process.cwd(), + bin: `${entry.provider}-fake`, + spawnImpl() { + spawnCalls += 1; + return createFakeChild({ status: 1, stderr: "fixture failure" }); + }, + }); + assert.equal(spawnCalls, 1); + }); + } +}); diff --git a/packages/polycli-runtime/test/qwen.test.js b/packages/polycli-runtime/test/qwen.test.js index 404ce0f..950264d 100644 --- a/packages/polycli-runtime/test/qwen.test.js +++ b/packages/polycli-runtime/test/qwen.test.js @@ -505,6 +505,40 @@ test("runQwenPromptStreaming returns a structured failure on spawn error", async assert.match(result.error, /ENOENT/); }); +test("runQwenPromptStreaming preserves lifecycle failure facts and the default POSIX process group", { + skip: process.platform === "win32", +}, async () => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { write() {}, end() {}, on() {} }; + child.unref = () => {}; + child.kill = (signal) => { + queueMicrotask(() => child.emit("close", null, signal)); + }; + let spawnOptions; + + const result = await runQwenPromptStreaming({ + prompt: "ping", + timeout: 5, + spawnImpl(_bin, _args, options) { + spawnOptions = options; + queueMicrotask(() => { + child.stdout.emit("data", '{"type":"result","subtype":"error","is_error":true,"result":"provider warning"}\n'); + child.stderr.emit("data", "ordinary stderr\n"); + }); + return child; + }, + }); + + assert.equal(spawnOptions.detached, true); + assert.equal(result.ok, false); + assert.equal(result.error, "process timed out"); + assert.equal(result.errorCode, "timeout"); + assert.equal(result.timedOut, true); + assert.equal(result.stderr, "ordinary stderr\n"); +}); + test("runQwenPromptStreaming passes explicit model to the qwen invocation", async () => { const child = new EventEmitter(); child.stdout = new EventEmitter(); diff --git a/packages/polycli-runtime/test/session-identity-safety.test.js b/packages/polycli-runtime/test/session-identity-safety.test.js new file mode 100644 index 0000000..43d31cc --- /dev/null +++ b/packages/polycli-runtime/test/session-identity-safety.test.js @@ -0,0 +1,164 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; + +import { + runClaudePrompt, + runClaudePromptStreaming, + runCopilotPrompt, + runCopilotPromptStreaming, + runOpenCodePrompt, + runOpenCodePromptStreaming, + runQwenPrompt, + runQwenPromptStreaming, +} from "../src/index.js"; + +const PROSE_UUID = "123e4567-e89b-42d3-a456-426614174000"; +const STRUCTURED_UUID = "223e4567-e89b-42d3-a456-426614174000"; +const RESUME_UUID = "323e4567-e89b-42d3-a456-426614174000"; + +function createFakeChild({ stdout, stderr = "" }) { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = new EventEmitter(); + child.stdin.write = () => true; + child.stdin.end = () => {}; + child.kill = () => true; + queueMicrotask(() => { + if (stdout) child.stdout.emit("data", Buffer.from(stdout)); + if (stderr) child.stderr.emit("data", Buffer.from(stderr)); + child.emit("close", 0, null); + }); + return child; +} + +const PROVIDERS = [ + { + provider: "claude", + sync: runClaudePrompt, + streaming: runClaudePromptStreaming, + syncStdout(response, sessionId = null) { + return JSON.stringify({ type: "result", subtype: "success", result: response, ...(sessionId ? { session_id: sessionId } : {}) }); + }, + streamStdout(response, sessionId = null) { + return [ + JSON.stringify({ type: "system", subtype: "init", ...(sessionId ? { session_id: sessionId } : {}) }), + JSON.stringify({ type: "assistant", message: { role: "assistant", content: [{ type: "text", text: response }] } }), + JSON.stringify({ type: "result", subtype: "success", result: response, ...(sessionId ? { session_id: sessionId } : {}) }), + ].join("\n") + "\n"; + }, + }, + { + provider: "copilot", + sync: runCopilotPrompt, + streaming: runCopilotPromptStreaming, + syncStdout(response, sessionId = null) { + return JSON.stringify({ type: "result", result: response, ...(sessionId ? { sessionId } : {}) }) + "\n"; + }, + streamStdout(response, sessionId = null) { + return this.syncStdout(response, sessionId); + }, + }, + { + provider: "opencode", + sync: runOpenCodePrompt, + streaming: runOpenCodePromptStreaming, + syncStdout(response, sessionId = null) { + return JSON.stringify({ type: "text", part: { text: response, ...(sessionId ? { sessionID: sessionId } : {}), model: "test" } }) + "\n"; + }, + streamStdout(response, sessionId = null) { + return this.syncStdout(response, sessionId); + }, + }, + { + provider: "qwen", + sync: runQwenPrompt, + streaming: runQwenPromptStreaming, + syncStdout(response, sessionId = null) { + return [ + ...(sessionId ? [JSON.stringify({ type: "system", subtype: "init", session_id: sessionId, model: "test" })] : []), + JSON.stringify({ type: "assistant", message: { content: [{ type: "text", text: response }] } }), + JSON.stringify({ type: "result", subtype: "success", is_error: false, result: response }), + ].join("\n") + "\n"; + }, + streamStdout(response, sessionId = null) { + return this.syncStdout(response, sessionId); + }, + }, +]; + +function syncRun(entry, stdout, stderr = "") { + return entry.sync({ + prompt: "short", + env: {}, + cwd: process.cwd(), + defaultModel: "test", + bin: `${entry.provider}-fake`, + spawnImpl() { + return { status: 0, signal: null, stdout, stderr, error: null }; + }, + }); +} + +function streamingRun(entry, stdout, stderr = "") { + return entry.streaming({ + prompt: "short", + env: {}, + cwd: process.cwd(), + defaultModel: "test", + bin: `${entry.provider}-fake`, + spawnImpl() { + return createFakeChild({ stdout, stderr }); + }, + }); +} + +for (const mode of ["sync", "streaming"]) { + test(`session identity ${mode}: prose UUID stays null for all four providers`, async (t) => { + for (const entry of PROVIDERS) { + await t.test(entry.provider, async () => { + const stdout = entry[mode === "sync" ? "syncStdout" : "streamStdout"](`answer mentions ${PROSE_UUID}`); + const result = mode === "sync" ? syncRun(entry, stdout) : await streamingRun(entry, stdout); + assert.equal(result.ok, true); + assert.equal(result.sessionId, null); + }); + } + }); + + test(`session identity ${mode}: structured fields are preserved for all four providers`, async (t) => { + for (const entry of PROVIDERS) { + await t.test(entry.provider, async () => { + const stdout = entry[mode === "sync" ? "syncStdout" : "streamStdout"]("ok", STRUCTURED_UUID); + const result = mode === "sync" ? syncRun(entry, stdout) : await streamingRun(entry, stdout); + assert.equal(result.ok, true); + assert.equal(result.sessionId, STRUCTURED_UUID); + }); + } + }); + + test(`session identity ${mode}: standalone stderr resume line remains compatible`, async (t) => { + for (const entry of PROVIDERS) { + await t.test(entry.provider, async () => { + const stdout = entry[mode === "sync" ? "syncStdout" : "streamStdout"]("ok"); + const result = mode === "sync" + ? syncRun(entry, stdout, `resume ${RESUME_UUID}\n`) + : await streamingRun(entry, stdout, `resume ${RESUME_UUID}\n`); + assert.equal(result.ok, true); + assert.equal(result.sessionId, RESUME_UUID); + }); + } + }); + + test(`session identity ${mode}: UUID embedded in stderr prose stays null`, async (t) => { + for (const entry of PROVIDERS) { + await t.test(entry.provider, async () => { + const stdout = entry[mode === "sync" ? "syncStdout" : "streamStdout"]("ok"); + const stderr = `warning: resume ${RESUME_UUID} after reconnect`; + const result = mode === "sync" ? syncRun(entry, stdout, stderr) : await streamingRun(entry, stdout, stderr); + assert.equal(result.ok, true); + assert.equal(result.sessionId, null); + }); + } + }); +} diff --git a/packages/polycli-runtime/test/spawn.test.js b/packages/polycli-runtime/test/spawn.test.js index 7e71007..02cc8bc 100644 --- a/packages/polycli-runtime/test/spawn.test.js +++ b/packages/polycli-runtime/test/spawn.test.js @@ -1,5 +1,6 @@ import test from "node:test"; import assert from "node:assert/strict"; +import { spawn as nodeSpawn } from "node:child_process"; import { EventEmitter } from "node:events"; import process from "node:process"; @@ -18,6 +19,26 @@ function createFakeChild() { return child; } +function isProcessAlive(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + if (error?.code === "ESRCH") return false; + throw error; + } +} + +async function waitForProcessExit(pid, timeoutMs = 1_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) return true; + await new Promise((resolve) => setTimeout(resolve, 10)); + } + return !isProcessAlive(pid); +} + test("spawnStreamingCommand resolves a structured failure when spawn emits error", async () => { const child = createFakeChild(); const resultPromise = spawnStreamingCommand({ @@ -38,7 +59,180 @@ test("spawnStreamingCommand resolves a structured failure when spawn emits error assert.match(result.error, /ENOENT/); }); -test("spawnStreamingCommand escalates timed out detached children to SIGKILL via process group", async () => { +test("spawnStreamingCommand preserves E2BIG as a canonical structured failure", async () => { + const child = createFakeChild(); + const resultPromise = spawnStreamingCommand({ + bin: "oversized-argv-bin", + args: [], + spawnImpl() { + queueMicrotask(() => { + const error = new Error("spawn oversized-argv-bin E2BIG"); + error.code = "E2BIG"; + child.emit("error", error); + }); + return child; + }, + }); + + const result = await resultPromise; + assert.equal(result.ok, false); + assert.equal(result.spawnErrorCode, "E2BIG"); + assert.equal(result.errorCode, "argument_list_too_long"); +}); + +test("spawnStreamingCommand rejects an oversized argv footprint before calling spawn", async () => { + let spawnCalls = 0; + const marker = "STREAM_PROMPT_SECRET_" + "x".repeat(256); + const result = await spawnStreamingCommand({ + bin: "provider", + args: [marker], + env: { PRIVATE_TOKEN: "STREAM_ENV_SECRET" }, + argvBudgetBytes: 64, + argvBudgetHint: "For review, pass --max-diff-bytes explicitly.", + spawnImpl() { + spawnCalls += 1; + throw new Error("spawn must not be called"); + }, + }); + + assert.equal(spawnCalls, 0); + assert.equal(result.spawnErrorCode, "E2BIG"); + assert.equal(result.errorCode, "argument_list_too_long"); + assert.match(result.error, /--max-diff-bytes/); + assert.doesNotMatch(result.error, /STREAM_PROMPT_SECRET|STREAM_ENV_SECRET/); +}); + +test("spawnStreamingCommand includes the effective environment in its argv budget preflight", async () => { + let spawnCalls = 0; + const result = await spawnStreamingCommand({ + bin: "provider", + args: ["short"], + env: { PRIVATE_TOKEN: "STREAM_ENV_ONLY_SECRET_" + "x".repeat(256) }, + argvBudgetBytes: 64, + spawnImpl() { + spawnCalls += 1; + throw new Error("spawn must not be called"); + }, + }); + + assert.equal(spawnCalls, 0); + assert.equal(result.spawnErrorCode, "E2BIG"); + assert.equal(result.errorCode, "argument_list_too_long"); + assert.doesNotMatch(result.error, /STREAM_ENV_ONLY_SECRET/); +}); + +test("spawnStreamingCommand still calls spawn for argv inside the configured budget", async () => { + const child = createFakeChild(); + let spawnCalls = 0; + const resultPromise = spawnStreamingCommand({ + bin: "provider", + args: ["short"], + env: {}, + argvBudgetBytes: 1_024, + spawnImpl() { + spawnCalls += 1; + queueMicrotask(() => child.emit("close", 0, null)); + return child; + }, + }); + + const result = await resultPromise; + assert.equal(spawnCalls, 1); + assert.equal(result.ok, true); +}); + +test("spawnStreamingCommand defaults provider children to their own POSIX process group", { + skip: process.platform === "win32", +}, async () => { + const child = createFakeChild(); + let spawnOptions; + + const result = await spawnStreamingCommand({ + bin: "provider-bin", + args: [], + spawnImpl(_bin, _args, options) { + spawnOptions = options; + queueMicrotask(() => child.emit("close", 0, null)); + return child; + }, + }); + + assert.equal(result.ok, true); + assert.equal(spawnOptions.detached, true); +}); + +test("spawnStreamingCommand timeout removes a real local descendant process tree", { + skip: process.platform === "win32", +}, async () => { + const descendantScript = "process.on('SIGTERM', () => {}); setInterval(() => {}, 1000);"; + const providerScript = ` + const { spawn } = require("node:child_process"); + const descendant = spawn(process.execPath, ["-e", ${JSON.stringify(descendantScript)}], { + stdio: "ignore", + }); + process.stdout.write(String(descendant.pid) + "\\n"); + process.on("SIGTERM", () => {}); + setInterval(() => {}, 1000); + `; + let rootPid = null; + let descendantPid = null; + + try { + const result = await spawnStreamingCommand({ + bin: process.execPath, + args: ["-e", providerScript], + timeout: 150, + killGraceMs: 30, + spawnImpl(bin, args, options) { + const child = nodeSpawn(bin, args, options); + rootPid = child.pid; + return child; + }, + onStdoutLine(line) { + if (/^\d+$/.test(line)) { + descendantPid = Number(line); + } + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.errorCode, "timeout"); + assert.equal(Number.isInteger(descendantPid), true); + assert.equal(await waitForProcessExit(descendantPid), true, `descendant ${descendantPid} survived`); + } finally { + if (Number.isInteger(rootPid) && rootPid > 0) { + try { process.kill(-rootPid, "SIGKILL"); } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + } + if (Number.isInteger(descendantPid) && descendantPid > 0 && isProcessAlive(descendantPid)) { + try { process.kill(descendantPid, "SIGKILL"); } catch (error) { + if (error?.code !== "ESRCH") throw error; + } + await waitForProcessExit(descendantPid); + } + } +}); + +test("spawnStreamingCommand formats a nonzero exit when stderr is empty", async () => { + const child = createFakeChild(); + + const result = await spawnStreamingCommand({ + bin: "failing-bin", + args: [], + spawnImpl() { + queueMicrotask(() => child.emit("close", 2, null)); + return child; + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.error, "process exited with code 2"); +}); + +test("spawnStreamingCommand escalates timed out detached children to SIGKILL via process group", { + skip: process.platform === "win32", +}, async () => { const child = createFakeChild(); child.pid = 43210; child.unref = () => {}; @@ -132,6 +326,123 @@ test("spawnStreamingCommand supports AbortSignal-driven termination", async () = assert.deepEqual(calls, ["SIGTERM"]); }); +test("spawnStreamingCommand settles after kill errors even when close never arrives", async () => { + const child = createFakeChild(); + const calls = []; + child.kill = (signal) => { + calls.push(signal); + const error = new Error(`kill ${signal} EPERM`); + error.code = "EPERM"; + throw error; + }; + + const pending = Symbol("pending"); + const result = await Promise.race([ + spawnStreamingCommand({ + bin: "unkillable-bin", + args: [], + timeout: 5, + killGraceMs: 5, + spawnImpl() { + return child; + }, + }), + new Promise((resolve) => setTimeout(() => resolve(pending), 100)), + ]); + + assert.notEqual(result, pending, "spawn result must not depend on close after termination"); + assert.equal(result.ok, false); + assert.equal(result.errorCode, "timeout"); + assert.equal(result.closeTimedOut, true); + assert.deepEqual(calls, ["SIGTERM", "SIGKILL"]); + assert.deepEqual( + result.terminationErrors.map(({ signal, code }) => ({ signal, code })), + [ + { signal: "SIGTERM", code: "EPERM" }, + { signal: "SIGKILL", code: "EPERM" }, + ] + ); +}); + +test("spawnStreamingCommand keeps timeout classification ahead of stderr diagnostics", async () => { + const child = createFakeChild(); + child.kill = (signal) => { + queueMicrotask(() => child.emit("close", null, signal)); + }; + + const result = await spawnStreamingCommand({ + bin: "warning-before-timeout-bin", + args: [], + timeout: 5, + spawnImpl() { + queueMicrotask(() => child.stderr.emit("data", Buffer.from("ordinary warning\n"))); + return child; + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.timedOut, true); + assert.equal(result.error, "process timed out"); + assert.equal(result.errorCode, "timeout"); + assert.equal(result.stderr, "ordinary warning\n"); +}); + +test("spawnStreamingCommand bounds aggregate stdout capture across complete lines", async () => { + const child = createFakeChild(); + child.kill = (signal) => { + queueMicrotask(() => child.emit("close", null, signal)); + }; + + const result = await spawnStreamingCommand({ + bin: "chatty-stdout-bin", + args: [], + maxCaptureBytes: 16, + spawnImpl() { + queueMicrotask(() => { + for (let index = 0; index < 10_000; index += 1) { + child.stdout.emit("data", Buffer.from("x\n")); + } + child.emit("close", 0, null); + }); + return child; + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.errorCode, "output_overflow"); + assert.equal(result.stdoutTruncated, true); + assert.equal(result.stdoutBytes > 16, true); + assert.equal(Buffer.byteLength(result.stdout), 16); +}); + +test("spawnStreamingCommand bounds aggregate stderr capture independently", async () => { + const child = createFakeChild(); + child.kill = (signal) => { + queueMicrotask(() => child.emit("close", null, signal)); + }; + + const result = await spawnStreamingCommand({ + bin: "chatty-stderr-bin", + args: [], + maxCaptureBytes: 16, + spawnImpl() { + queueMicrotask(() => { + for (let index = 0; index < 10_000; index += 1) { + child.stderr.emit("data", Buffer.from("e\n")); + } + child.emit("close", 0, null); + }); + return child; + }, + }); + + assert.equal(result.ok, false); + assert.equal(result.errorCode, "output_overflow"); + assert.equal(result.stderrTruncated, true); + assert.equal(result.stderrBytes > 16, true); + assert.equal(Buffer.byteLength(result.stderr), 16); +}); + test("spawnStreamingCommand ignores stdout emitted after settle", async () => { const child = createFakeChild(); const seen = []; @@ -157,7 +468,9 @@ test("spawnStreamingCommand ignores stdout emitted after settle", async () => { assert.equal(result.stdout, "first\n"); }); -test("spawnStreamingCommand terminates detached decoder overflows before settling once", async () => { +test("spawnStreamingCommand terminates detached decoder overflows before settling once", { + skip: process.platform === "win32", +}, async () => { const child = createFakeChild(); const seen = []; const calls = []; diff --git a/packages/polycli-runtime/test/timing.test.js b/packages/polycli-runtime/test/timing.test.js index 68827fd..7aa0fbe 100644 --- a/packages/polycli-runtime/test/timing.test.js +++ b/packages/polycli-runtime/test/timing.test.js @@ -143,6 +143,26 @@ test("runProviderPrompt attaches failure diagnostics to timing", async () => { assert.equal(result.timing.terminationReason, "timeout"); }); +test("runProviderPrompt retains a canonical runtime errorCode in timing diagnostics", async () => { + const result = await runProviderPrompt({ + provider: "qwen", + prompt: "ping", + cwd: process.cwd(), + runtime: { + runPrompt: async () => ({ + ok: false, + error: "ordinary provider stderr", + timedOut: true, + errorCode: "timeout", + }), + }, + }); + + assert.equal(result.errorCode, "timeout"); + assert.equal(result.timing.outcome, "timeout"); + assert.equal(result.timing.errorCode, "timeout"); +}); + test("runProviderPrompt preserves session persistence capability when a run omits sessionId", async () => { const result = await runProviderPrompt({ provider: "gemini", diff --git a/packages/polycli-terminal/bin/polycli-companion.bundle.mjs b/packages/polycli-terminal/bin/polycli-companion.bundle.mjs index 314c4f7..ebfb554 100755 --- a/packages/polycli-terminal/bin/polycli-companion.bundle.mjs +++ b/packages/polycli-terminal/bin/polycli-companion.bundle.mjs @@ -1,11 +1,10 @@ #!/usr/bin/env node // plugins/polycli/scripts/polycli-companion.mjs -import fs9 from "node:fs"; +import fs10 from "node:fs"; import path8 from "node:path"; -import process5 from "node:process"; +import process6 from "node:process"; import { randomUUID as randomUUID3 } from "node:crypto"; -import { spawn as spawn2 } from "node:child_process"; import { fileURLToPath } from "node:url"; // packages/polycli-utils/src/args.js @@ -383,8 +382,91 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { // packages/polycli-utils/src/process.js import { spawnSync } from "node:child_process"; import process3 from "node:process"; +var CONSERVATIVE_POINTER_BYTES = 8; +var WINDOWS_SAFE_ARGV_BUDGET_BYTES = 24 * 1024; +var POSIX_SAFE_ARGV_BUDGET_BYTES = 96 * 1024; +function stringStorageBytes(value) { + return Buffer.byteLength(String(value), "utf8") + 1; +} +function getSafeArgvBudgetBytes(platform = process3.platform) { + return platform === "win32" ? WINDOWS_SAFE_ARGV_BUDGET_BYTES : POSIX_SAFE_ARGV_BUDGET_BYTES; +} +function calculateArgvFootprint({ + command, + args = [], + env = process3.env +} = {}) { + const argv = [String(command ?? ""), ...args.map((arg) => String(arg))]; + const envEntries = Object.entries(env ?? {}).filter(([, value]) => value != null).map(([key, value]) => `${key}=${String(value)}`); + const argvBytes = argv.reduce((total, value) => total + stringStorageBytes(value), 0); + const envBytes = envEntries.reduce((total, value) => total + stringStorageBytes(value), 0); + const pointerBytes = (argv.length + envEntries.length + 2) * CONSERVATIVE_POINTER_BYTES; + const stringBytes = argvBytes + envBytes; + return { + totalBytes: stringBytes + pointerBytes, + stringBytes, + pointerBytes, + argvBytes, + envBytes, + argvCount: argv.length, + envCount: envEntries.length + }; +} +function preflightArgv(command, args = [], { + env = process3.env, + argvBudgetBytes = null, + argvBudgetHint = null +} = {}) { + const footprint = calculateArgvFootprint({ command, args, env }); + if (argvBudgetBytes == null) { + return { ok: true, budgetBytes: null, footprint, error: null }; + } + if (!Number.isSafeInteger(argvBudgetBytes) || argvBudgetBytes < 0) { + throw new TypeError("argvBudgetBytes must be a non-negative safe integer or null"); + } + if (footprint.totalBytes <= argvBudgetBytes) { + return { ok: true, budgetBytes: argvBudgetBytes, footprint, error: null }; + } + const counts = [ + `footprintBytes=${footprint.totalBytes}`, + `budgetBytes=${argvBudgetBytes}`, + `argvCount=${footprint.argvCount}`, + `envCount=${footprint.envCount}` + ].join(", "); + const suffix = typeof argvBudgetHint === "string" && argvBudgetHint.trim() ? ` ${argvBudgetHint.trim()}` : ""; + const error = Object.assign( + new Error(`argument list too long for the configured safe argv budget (${counts}).${suffix}`), + { + code: "E2BIG", + footprintBytes: footprint.totalBytes, + budgetBytes: argvBudgetBytes, + argvCount: footprint.argvCount, + envCount: footprint.envCount + } + ); + return { ok: false, budgetBytes: argvBudgetBytes, footprint, error }; +} function runCommand(command, args = [], options = {}) { - const result = spawnSync(command, args, { + const effectiveEnv = options.env ?? process3.env; + const preflight = preflightArgv(command, args, { + env: effectiveEnv, + argvBudgetBytes: options.argvBudgetBytes ?? null, + argvBudgetHint: options.argvBudgetHint ?? null + }); + if (!preflight.ok) { + return { + command, + args, + status: null, + signal: null, + stdout: "", + stderr: "", + error: preflight.error, + spawnErrorCode: "E2BIG" + }; + } + const spawnImpl = options.spawnImpl ?? spawnSync; + const result = spawnImpl(command, args, { cwd: options.cwd, env: options.env, encoding: "utf8", @@ -409,7 +491,8 @@ function runCommand(command, args = [], options = {}) { signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", - error + error, + spawnErrorCode: typeof error?.code === "string" ? error.code : null }; } function firstNonEmptyLine(text) { @@ -452,17 +535,41 @@ function formatCommandFailure(result) { } return parts.join(": "); } -async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SIGKILL", forceAfterMs = 5e3, ignoreMissing = true } = {}) { +async function terminateProcessTree(pid, { + signal = "SIGTERM", + forceSignal = "SIGKILL", + forceAfterMs = 5e3, + ignoreMissing = true, + deadlineAt = null, + platform = process3.platform, + runCommandImpl = runCommand, + now = Date.now, + sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)) +} = {}) { if (!Number.isInteger(pid) || pid <= 1) { throw new Error(`Invalid pid: ${pid}`); } + if (deadlineAt != null && !Number.isFinite(deadlineAt)) { + throw new TypeError("deadlineAt must be a finite epoch millisecond value or null"); + } + const remainingDeadlineMs2 = () => { + if (deadlineAt == null) return null; + const remainingMs = Math.floor(deadlineAt - now()); + if (remainingMs <= 0) { + const error = new Error("process termination deadline exceeded"); + error.code = "EDEADLINE"; + throw error; + } + return remainingMs; + }; const killOnce = (targetSignal) => { - if (process3.platform === "win32") { + const remainingMs = remainingDeadlineMs2(); + if (platform === "win32") { const args = ["/PID", String(pid), "/T"]; if (targetSignal === "SIGKILL") { args.push("/F"); } - const result = runCommand("taskkill", args); + const result = runCommandImpl("taskkill", args, remainingMs == null ? {} : { timeout: remainingMs }); if (result.error) { if (ignoreMissing && result.error.code === "ESRCH") return false; throw result.error; @@ -501,7 +608,9 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI if (!terminated || forceAfterMs <= 0) { return terminated; } - await new Promise((resolve) => setTimeout(resolve, forceAfterMs)); + const remainingBeforeWait = deadlineAt == null ? null : remainingDeadlineMs2(); + const waitMs = remainingBeforeWait == null ? forceAfterMs : Math.min(forceAfterMs, remainingBeforeWait); + await sleep(waitMs); try { killOnce(forceSignal); } catch (error) { @@ -514,6 +623,20 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI // packages/polycli-utils/src/session-id.js var UUID_SESSION_ID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i; +var RESUME_SESSION_ID_LINE_REGEX = new RegExp( + `^\\s*resume\\s+(${UUID_SESSION_ID_REGEX.source})\\s*$`, + "i" +); +function matchResumeSessionIdLine(text) { + if (typeof text !== "string" || text.length === 0) { + return null; + } + for (const line of text.split(/\r?\n/)) { + const match = line.match(RESUME_SESSION_ID_LINE_REGEX); + if (match) return match[1]; + } + return null; +} function matchSessionId(text, { patterns = [UUID_SESSION_ID_REGEX] } = {}) { if (typeof text !== "string" || text.length === 0) { return null; @@ -566,6 +689,16 @@ function formatProviderExitError(provider, status) { return `${provider} exited with code ${status}`; } function classifyProviderFailure(error, { provider = null } = {}) { + const structuredCode = typeof error?.code === "string" ? error.code.toUpperCase() : null; + if (structuredCode === "E2BIG") { + return "argument_list_too_long"; + } + if (structuredCode === "ENOENT") { + return "binary_missing"; + } + if (structuredCode === "ETIMEDOUT") { + return "timeout"; + } const text = typeof error === "string" ? error : String(error?.message ?? error ?? ""); if (!text.trim()) return null; if (provider === "qwen" && /\bmaximum session turn\b|\bmax(?:imum)? session turns?\b/i.test(text)) { @@ -574,6 +707,12 @@ function classifyProviderFailure(error, { provider = null } = {}) { if (/\bspawn\b.*\bENOENT\b|\bENOENT\b|\bnot found\b/i.test(text)) { return "binary_missing"; } + if (/\bE2BIG\b|\bargument list too long\b/i.test(text)) { + return "argument_list_too_long"; + } + if (/\b(output|capture|line buffer)\b.*\b(exceeded|overflow)\b/i.test(text)) { + return "output_overflow"; + } if (/\b(timed out|timeout)\b/i.test(text)) { return "timeout"; } @@ -643,6 +782,7 @@ function createLineDecoder({ encoding = "utf8", stripCarriageReturn = true, maxB } // packages/polycli-runtime/src/spawn.js +var DEFAULT_CAPTURE_LIMIT_BYTES = 1048576; function formatExitError(status, signal, { timedOut = false, aborted = false } = {}) { if (aborted) { return "process aborted"; @@ -658,6 +798,34 @@ function formatExitError(status, signal, { timedOut = false, aborted = false } = } return `process exited with code ${status}`; } +function normalizeCaptureLimit(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : DEFAULT_CAPTURE_LIMIT_BYTES; +} +function spawnFailureResult(error, captureLimitBytes) { + const spawnErrorCode = typeof error?.code === "string" ? error.code : null; + return { + ok: false, + status: null, + signal: null, + timedOut: false, + aborted: false, + stdout: "", + stderr: "", + stdoutBytes: 0, + stderrBytes: 0, + stdoutTruncated: false, + stderrTruncated: false, + captureLimitBytes, + outputOverflowStream: null, + closeTimedOut: false, + terminationErrors: [], + terminationFailure: null, + terminationReason: null, + spawnErrorCode, + error: String(error?.message ?? error), + errorCode: classifyProviderFailure(error) + }; +} function spawnStreamingCommand({ bin, args = [], @@ -668,8 +836,11 @@ function spawnStreamingCommand({ killGraceMs = 2e3, signal = null, maxBufferBytes = 1048576, + maxCaptureBytes = DEFAULT_CAPTURE_LIMIT_BYTES, + argvBudgetBytes = null, + argvBudgetHint = null, stdio = ["pipe", "pipe", "pipe"], - detached = false, + detached = process.platform !== "win32", unref = false, spawnImpl = spawn, onStdoutLine = () => { @@ -677,54 +848,85 @@ function spawnStreamingCommand({ onStderrChunk = () => { } } = {}) { + const captureLimitBytes = normalizeCaptureLimit(maxCaptureBytes); + const preflight = preflightArgv(bin, args, { + env: env ?? process.env, + argvBudgetBytes, + argvBudgetHint + }); + if (!preflight.ok) { + return Promise.resolve(spawnFailureResult(preflight.error, captureLimitBytes)); + } return new Promise((resolve) => { let child; try { child = spawnImpl(bin, args, { cwd, env, stdio, detached }); } catch (error) { - resolve({ - ok: false, - status: null, - signal: null, - timedOut: false, - stdout: "", - stderr: "", - error: error.message - }); + resolve(spawnFailureResult(error, captureLimitBytes)); return; } if (unref && typeof child.unref === "function") { child.unref(); } const decoder = createLineDecoder({ maxBufferBytes }); - let stdout = ""; - let stderr = ""; + const stdoutParts = []; + const stderrParts = []; + let stdoutCapturedBytes = 0; + let stderrCapturedBytes = 0; + let stdoutBytes = 0; + let stderrBytes = 0; + let stdoutTruncated = false; + let stderrTruncated = false; + let outputOverflowStream = null; let timedOut = false; let aborted = false; let settled = false; let timer = null; let forceTimer = null; + let settleTimer = null; let decoderError = null; + let lifecycleFailure = null; let terminationRequested = false; - const signalChild = (signal2) => { + const terminationErrors = []; + const setLifecycleFailure = (code, message) => { + if (!lifecycleFailure) { + lifecycleFailure = { code, message }; + } + }; + const recordTerminationError = (error, killSignal, target) => { + terminationErrors.push({ + signal: killSignal, + target, + code: typeof error?.code === "string" ? error.code : null, + message: String(error?.message ?? error).slice(0, 4096) + }); + }; + const trySignal = (killSignal, target, sender) => { try { - if (detached && Number.isInteger(child.pid) && child.pid > 0 && process.platform !== "win32") { - process.kill(-child.pid, signal2); - return; + const delivered = sender(); + if (delivered === false) { + const error = new Error(`${target} kill returned false`); + error.code = "KILL_RETURNED_FALSE"; + recordTerminationError(error, killSignal, target); + return false; } - child.kill(signal2); - } catch { + return true; + } catch (error) { + recordTerminationError(error, killSignal, target); + return false; } }; - const terminateChild = () => { - if (terminationRequested) return; - terminationRequested = true; - signalChild("SIGTERM"); - if (killGraceMs > 0) { - forceTimer = setTimeout(() => { - signalChild("SIGKILL"); - }, killGraceMs); + const signalChild = (killSignal) => { + const canSignalGroup = detached && Number.isInteger(child.pid) && child.pid > 0 && process.platform !== "win32"; + if (canSignalGroup) { + const groupSignalled = trySignal( + killSignal, + "process_group", + () => process.kill(-child.pid, killSignal) + ); + if (groupSignalled) return; } + trySignal(killSignal, "child", () => child.kill(killSignal)); }; const cleanup = () => { if (signal && typeof signal.removeEventListener === "function") { @@ -736,119 +938,215 @@ function spawnStreamingCommand({ child.off?.("error", handleChildError); child.off?.("close", handleChildClose); }; + const capturedStdout = () => stdoutParts.join(""); + const capturedStderr = () => Buffer.concat(stderrParts, stderrCapturedBytes).toString("utf8"); + const buildResult = (status, signalName, { closeTimedOut = false } = {}) => { + if (!lifecycleFailure) { + if (timedOut || status === 124) { + setLifecycleFailure("timeout", "process timed out"); + } else if (aborted || signalName === "SIGINT" || status === 130) { + setLifecycleFailure("cancelled", aborted ? "process aborted" : "process interrupted"); + } else if (signalName || status === 143) { + setLifecycleFailure("terminated", formatExitError(status, signalName)); + } + } + const stdout = capturedStdout(); + const stderr = capturedStderr(); + const ok = status === 0 && !lifecycleFailure && !closeTimedOut; + const error = ok ? null : lifecycleFailure?.message ?? (stderr.trim() || formatExitError(status, signalName, { timedOut, aborted })); + return { + ok, + status, + signal: signalName, + timedOut, + aborted, + stdout, + stderr, + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated, + captureLimitBytes, + outputOverflowStream, + closeTimedOut, + terminationErrors: terminationErrors.map((entry) => ({ ...entry })), + terminationFailure: closeTimedOut ? "close_timeout" : terminationErrors.length > 0 ? "signal_error" : null, + terminationReason: lifecycleFailure?.code ?? null, + spawnErrorCode: null, + error, + errorCode: lifecycleFailure?.code ?? classifyProviderFailure(error) + }; + }; const finish = (result) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); if (forceTimer) clearTimeout(forceTimer); + if (settleTimer) clearTimeout(settleTimer); cleanup(); resolve(result); }; - const decoderFailureResult = (status, signalName) => ({ - ok: false, - status, - signal: signalName, - timedOut, - stdout, - stderr, - error: decoderError - }); + const settleWithoutClose = () => { + if (settled) return; + finish(buildResult(null, null, { closeTimedOut: true })); + }; + const forceKill = () => { + if (settled) return; + signalChild("SIGKILL"); + if (settled) return; + const closeWaitMs = Math.max(10, Math.max(0, killGraceMs)); + settleTimer = setTimeout(settleWithoutClose, closeWaitMs); + }; + const terminateChild = (code, message) => { + setLifecycleFailure(code, message); + if (terminationRequested) return; + terminationRequested = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + signalChild("SIGTERM"); + if (settled) return; + forceTimer = setTimeout(forceKill, Math.max(0, killGraceMs)); + }; + const handleOutputOverflow = (streamName) => { + if (!outputOverflowStream) { + outputOverflowStream = streamName; + } + terminateChild( + "output_overflow", + `${streamName} capture exceeded maxCaptureBytes (${captureLimitBytes})` + ); + }; + const appendStdoutLine = (line, { trailingNewline = true } = {}) => { + const text = trailingNewline ? `${line} +` : line; + const bytes = Buffer.byteLength(text); + if (stdoutCapturedBytes + bytes > captureLimitBytes) { + stdoutTruncated = true; + return false; + } + stdoutParts.push(text); + stdoutCapturedBytes += bytes; + try { + onStdoutLine(line); + } catch { + } + return true; + }; const handleDecoderError = (error) => { if (decoderError || settled) return; decoderError = String(error?.message ?? error).slice(0, 4096); + stdoutTruncated = true; + outputOverflowStream = outputOverflowStream || "stdout"; child.stdout?.off?.("data", handleStdoutData); - terminateChild(); + terminateChild("output_overflow", decoderError); }; const abortHandler = () => { if (settled || aborted) return; aborted = true; - terminateChild(); + terminateChild("cancelled", "process aborted"); }; if (timeout != null) { timer = setTimeout(() => { - if (settled || timedOut) return; + if (settled || timedOut || terminationRequested) return; timedOut = true; - terminateChild(); + terminateChild("timeout", "process timed out"); }, timeout); } const handleChildError = (error) => { - if (decoderError) { + if (terminationRequested) { return; } finish({ - ok: false, - status: null, - signal: null, + ...spawnFailureResult(error, captureLimitBytes), timedOut, - stdout, - stderr, - error: error.message + aborted, + stdout: capturedStdout(), + stderr: capturedStderr(), + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated }); }; + const captureStderr = (chunk, { emit = true } = {}) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + stderrBytes += buffer.length; + const remaining = Math.max(captureLimitBytes - stderrCapturedBytes, 0); + const accepted = buffer.subarray(0, remaining); + if (accepted.length > 0) { + stderrParts.push(Buffer.from(accepted)); + stderrCapturedBytes += accepted.length; + if (emit) { + try { + onStderrChunk(accepted.toString("utf8")); + } catch { + } + } + } + if (accepted.length < buffer.length) { + stderrTruncated = true; + child.stderr?.off?.("data", handleStderrData); + handleOutputOverflow("stderr"); + } + }; const handleStdinError = (error) => { if (error?.code === "EPIPE" || error?.code === "ERR_STREAM_DESTROYED") { return; } - stderr += `${error.message} -`; + captureStderr(Buffer.from(`${error.message} +`), { emit: false }); }; const handleStdoutData = (chunk) => { if (settled) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + const previousBytes = stdoutBytes; + stdoutBytes += buffer.length; + const remaining = Math.max(captureLimitBytes - previousBytes, 0); + const accepted = buffer.subarray(0, remaining); let lines; try { - lines = decoder.push(chunk); + lines = accepted.length > 0 ? decoder.push(accepted) : []; } catch (error) { handleDecoderError(error); return; } for (const line of lines) { - stdout += `${line} -`; - try { - onStdoutLine(line); - } catch { + if (!appendStdoutLine(line)) { + child.stdout?.off?.("data", handleStdoutData); + handleOutputOverflow("stdout"); + return; } } + if (accepted.length < buffer.length) { + stdoutTruncated = true; + child.stdout?.off?.("data", handleStdoutData); + handleOutputOverflow("stdout"); + } }; const handleStderrData = (chunk) => { if (settled) return; - const text = chunk.toString("utf8"); - stderr += text; - try { - onStderrChunk(text); - } catch { - } + captureStderr(chunk); }; const handleChildClose = (status, signalName) => { - if (decoderError) { - finish(decoderFailureResult(status, signalName)); - return; - } - let lines; - try { - lines = decoder.end(); - } catch (error) { - handleDecoderError(error); - finish(decoderFailureResult(status, signalName)); - return; - } - for (const line of lines) { - stdout += `${line} -`; + if (!decoderError && !stdoutTruncated) { + let lines; try { - onStdoutLine(line); - } catch { + lines = decoder.end(); + } catch (error) { + handleDecoderError(error); + finish(buildResult(status, signalName)); + return; + } + for (const line of lines) { + if (!appendStdoutLine(line, { trailingNewline: false })) { + handleOutputOverflow("stdout"); + break; + } } } - finish({ - ok: status === 0 && !timedOut && !aborted, - status, - signal: signalName, - timedOut, - stdout, - stderr, - error: status === 0 && !timedOut && !aborted ? null : stderr.trim() || formatExitError(status, signalName, { timedOut, aborted }) - }); + finish(buildResult(status, signalName)); }; child.on("error", handleChildError); child.stdin?.on?.("error", handleStdinError); @@ -888,6 +1186,8 @@ var DEFAULT_TIMEOUT_MS = 9e5; var TMUX_START_TIMEOUT_MS = 3e4; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; +var SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var CLAUDE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var CLAUDE_TMUX_ENV_EXACT = /* @__PURE__ */ new Set([ "ALL_PROXY", @@ -989,33 +1289,26 @@ function buildClaudeInvocation({ maxTurns = 10, resumeSessionId = null, extraArgs = [], - bin = CLAUDE_BIN + bin = CLAUDE_BIN, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { const promptText = String(prompt ?? ""); - const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD; - const args = ["-p"]; - if (!useStdin) { - args.push(promptText); - } - args.push("--output-format", outputFormat); - if (outputFormat === "stream-json") { - args.push("--verbose"); - } - if (permissionMode) { - args.push("--permission-mode", permissionMode); - } - if (Number.isFinite(maxTurns) && maxTurns > 0) { - args.push("--max-turns", String(maxTurns)); - } - if (model) { - args.push("--model", model); - } - if (resumeSessionId) { - args.push("--resume", resumeSessionId); - } - if (extraArgs.length > 0) { - args.push(...extraArgs); - } + const buildArgs = (includePrompt) => { + const nextArgs = ["-p"]; + if (includePrompt) nextArgs.push(promptText); + nextArgs.push("--output-format", outputFormat); + if (outputFormat === "stream-json") nextArgs.push("--verbose"); + if (permissionMode) nextArgs.push("--permission-mode", permissionMode); + if (Number.isFinite(maxTurns) && maxTurns > 0) nextArgs.push("--max-turns", String(maxTurns)); + if (model) nextArgs.push("--model", model); + if (resumeSessionId) nextArgs.push("--resume", resumeSessionId); + if (extraArgs.length > 0) nextArgs.push(...extraArgs); + return nextArgs; + }; + const inlineArgs = buildArgs(true); + const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD || !preflightArgv(bin, inlineArgs, { env, argvBudgetBytes }).ok; + const args = useStdin ? buildArgs(false) : inlineArgs; return { bin, args, @@ -1390,13 +1683,8 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = } try { const parsed = JSON.parse(text.slice(jsonStart)); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); const response = typeof parsed.result === "string" ? parsed.result : ""; - const sessionId = parsed.session_id ?? parsed.sessionId ?? resolvedSession.sessionId ?? null; + const sessionId = parsed.session_id ?? parsed.sessionId ?? matchResumeSessionIdLine(stderr) ?? null; const errorText2 = isClaudeErrorResultEvent(parsed) ? getClaudeErrorText(parsed) : null; const processError = status === 0 ? null : String(stderr ?? "").trim() || formatProviderExitError("claude", status); return { @@ -1480,7 +1768,10 @@ function runClaudePrompt({ extraArgs = [], resumeSessionId = null, defaultModel = null, - bin = CLAUDE_BIN + bin = CLAUDE_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { const invocation = buildClaudeInvocation({ prompt, @@ -1490,17 +1781,25 @@ function runClaudePrompt({ maxTurns, resumeSessionId, extraArgs, - bin + bin, + env, + argvBudgetBytes }); const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, - input: invocation.input + input: invocation.input, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `claude timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `claude timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error), + spawnErrorCode: result.spawnErrorCode ?? null }; } return parseClaudeJsonResult(result.stdout, result.stderr, result.status, { @@ -1514,6 +1813,7 @@ function runClaudePromptStreaming({ maxTurns = 10, cwd, timeout = DEFAULT_TIMEOUT_MS, + killGraceMs, extraArgs = [], resumeSessionId = null, defaultModel = null, @@ -1525,7 +1825,8 @@ function runClaudePromptStreaming({ executionMode = "print", env = process.env, signalEmitter = process, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { if (executionMode === "tmux-tui") { return Promise.resolve(runClaudeTuiPrompt({ @@ -1552,7 +1853,9 @@ function runClaudePromptStreaming({ maxTurns, resumeSessionId, extraArgs, - bin + bin, + env, + argvBudgetBytes }); return spawnStreamingCommand({ bin: invocation.bin, @@ -1561,7 +1864,10 @@ function runClaudePromptStreaming({ env, input: invocation.input, timeout, + killGraceMs, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const parsed = parseStreamJsonLine(line, { allowPrefix: true }); if (parsed.ok) { @@ -1573,25 +1879,23 @@ function runClaudePromptStreaming({ } }).then((result) => { const parsed = parseClaudeStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const resultError = isClaudeErrorResultEvent(parsed.resultEvent) ? getClaudeErrorText(parsed.resultEvent) : null; const hasSuccessfulResult = Boolean( parsed.resultEvent && parsed.resultEvent.type === "result" && !isClaudeErrorResultEvent(parsed.resultEvent) ); - const completed = result.ok || result.timedOut && hasSuccessfulResult; + const recoveredTimeout = result.timedOut && hasSuccessfulResult && !result.closeTimedOut && !result.terminationFailure; + const completed = result.ok || recoveredTimeout; return { ...result, ...parsed, timedOut: completed ? false : result.timedOut, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), model: parsed.model ?? model ?? defaultModel, ok: completed && !resultError && hasVisibleText, - error: completed ? resultError || (hasVisibleText ? null : "claude produced no visible text") : result.error + error: completed ? resultError || (hasVisibleText ? null : "claude produced no visible text") : result.error, + errorCode: completed && !resultError ? hasVisibleText ? null : "no_visible_text" : result.errorCode, + terminationReason: completed ? null : result.terminationReason }; }); } @@ -1600,6 +1904,8 @@ function runClaudePromptStreaming({ var COPILOT_BIN = process.env.COPILOT_CLI_BIN || "copilot"; var DEFAULT_TIMEOUT_MS2 = 9e5; var AUTH_CHECK_TIMEOUT_MS2 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES2 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT2 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var COPILOT_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS2 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -1792,7 +2098,10 @@ function runCopilotPrompt({ allowAllPaths = true, allowAllUrls = true, noAskUser = true, - bin = COPILOT_BIN + bin = COPILOT_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES2 } = {}) { const invocation = buildCopilotInvocation({ prompt, @@ -1808,23 +2117,27 @@ function runCopilotPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT2 + }); if (result.error) { return { ok: false, resumeStatus: getCopilotResumeStatus(resumeSessionId, null), - error: result.error.code === "ETIMEDOUT" ? `copilot timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `copilot timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error, { provider: "copilot" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseCopilotStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getCopilotResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); - const sessionId = parsed.sessionId ?? resolvedSession.sessionId; + const sessionId = parsed.sessionId ?? matchResumeSessionIdLine(result.stderr); return { ok: result.status === 0 && !resultError && hasVisibleText, response: parsed.response, @@ -1851,7 +2164,9 @@ function runCopilotPromptStreaming({ onEvent = () => { }, bin = COPILOT_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES2 } = {}) { const invocation = buildCopilotInvocation({ prompt, @@ -1871,9 +2186,11 @@ function runCopilotPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT2, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -1884,14 +2201,9 @@ function runCopilotPromptStreaming({ } }).then((result) => { const parsed = parseCopilotStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getCopilotResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); - const sessionId = parsed.sessionId ?? resolvedSession.sessionId; + const sessionId = parsed.sessionId ?? matchResumeSessionIdLine(result.stderr); return { ...result, ...parsed, @@ -1908,6 +2220,8 @@ var GEMINI_BIN = process.env.GEMINI_CLI_BIN || "gemini"; var DEFAULT_TIMEOUT_MS3 = 3e5; var AUTH_CHECK_TIMEOUT_MS3 = 3e4; var PROMPT_STDIN_THRESHOLD2 = 1e5; +var SAFE_PROMPT_ARGV_BUDGET_BYTES3 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT3 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var GEMINI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var VALID_GEMINI_EFFORTS = /* @__PURE__ */ new Set(["low", "medium", "high"]); var TRANSIENT_PROBE_ERROR_PATTERNS3 = [ @@ -1944,16 +2258,23 @@ function buildGeminiInvocation({ outputFormat = "json", resumeSessionId = null, extraArgs = [], - bin = GEMINI_BIN + bin = GEMINI_BIN, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { const promptText = applyGeminiEffort(prompt, effort); - const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD2; - const args = ["-p", useStdin ? "" : promptText, "-o", outputFormat]; const resolvedApprovalMode = write ? "auto_edit" : approvalMode; - if (model) args.push("-m", model); - args.push("--approval-mode", resolvedApprovalMode); - if (resumeSessionId) args.push("--resume", resumeSessionId); - if (extraArgs.length > 0) args.push(...extraArgs); + const buildArgs = (inlinePrompt) => { + const nextArgs = ["-p", inlinePrompt ? promptText : "", "-o", outputFormat]; + if (model) nextArgs.push("-m", model); + nextArgs.push("--approval-mode", resolvedApprovalMode); + if (resumeSessionId) nextArgs.push("--resume", resumeSessionId); + if (extraArgs.length > 0) nextArgs.push(...extraArgs); + return nextArgs; + }; + const inlineArgs = buildArgs(true); + const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD2 || !preflightArgv(bin, inlineArgs, { env, argvBudgetBytes }).ok; + const args = useStdin ? buildArgs(false) : inlineArgs; return { bin, args, @@ -2085,8 +2406,12 @@ function runGeminiPrompt({ extraArgs = [], resumeSessionId = null, defaultModel = null, - bin = GEMINI_BIN + bin = GEMINI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { + const childEnv = buildGeminiEnv(env); const invocation = buildGeminiInvocation({ prompt, model, @@ -2096,18 +2421,25 @@ function runGeminiPrompt({ outputFormat: "json", resumeSessionId, extraArgs, - bin + bin, + env: childEnv, + argvBudgetBytes }); const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, input: invocation.input, - env: buildGeminiEnv() + env: childEnv, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT3 }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `gemini timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `gemini timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error), + spawnErrorCode: result.spawnErrorCode ?? null }; } return parseGeminiJsonResult(result.stdout, result.stderr, result.status, { @@ -2128,8 +2460,11 @@ function runGeminiPromptStreaming({ onEvent = () => { }, bin = GEMINI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { + const childEnv = buildGeminiEnv(env); const invocation = buildGeminiInvocation({ prompt, model, @@ -2139,16 +2474,20 @@ function runGeminiPromptStreaming({ outputFormat: "stream-json", resumeSessionId, extraArgs, - bin + bin, + env: childEnv, + argvBudgetBytes }); return spawnStreamingCommand({ bin: invocation.bin, args: invocation.args, cwd, - env: buildGeminiEnv(), + env: childEnv, input: invocation.input, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT3, onStdoutLine(line) { const parsed = parseStreamJsonLine(line, { allowPrefix: true }); if (parsed.ok) { @@ -2186,6 +2525,8 @@ import path2 from "node:path"; var KIMI_BIN = process.env.KIMI_CLI_BIN || "kimi"; var DEFAULT_TIMEOUT_MS4 = 9e5; var AUTH_CHECK_TIMEOUT_MS4 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES4 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT4 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var KIMI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS4 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -2297,13 +2638,28 @@ function runKimiPrompt({ resumeSessionId = null, resumeLast = false, defaultModel = null, - bin = KIMI_BIN + bin = KIMI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES4 } = {}) { const invocation = buildKimiInvocation({ prompt, model, resumeSessionId, resumeLast, extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT4 + }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `kimi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error: error2, errorCode: classifyProviderFailure(error2, { provider: "kimi" }) }; + return { + ok: false, + error: error2, + errorCode: classifyProviderFailure(result.error, { provider: "kimi" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } if (result.status !== 0) { const error2 = result.stderr.trim() || formatProviderExitError("kimi", result.status); @@ -2340,16 +2696,20 @@ function runKimiPromptStreaming({ onEvent = () => { }, bin = KIMI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES4 } = {}) { const invocation = buildKimiInvocation({ prompt, model, resumeSessionId, resumeLast, extraArgs, bin }); return spawnStreamingCommand({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT4, onStdoutLine(line) { const event = parseKimiEventLine(line); if (event) { @@ -2371,7 +2731,7 @@ function runKimiPromptStreaming({ model: parsed.model ?? model ?? defaultModel ?? readKimiDefaultModel(), ok, error, - errorCode: classifyProviderFailure(error, { provider: "kimi" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "kimi" }) }; }); } @@ -2380,6 +2740,8 @@ function runKimiPromptStreaming({ var QWEN_BIN = process.env.QWEN_CLI_BIN || "qwen"; var DEFAULT_TIMEOUT_MS5 = 3e5; var AUTH_CHECK_TIMEOUT_MS5 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES5 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT5 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; var PROXY_KEYS = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]; var NO_PROXY_DEFAULTS = ["localhost", "127.0.0.1"]; @@ -2627,7 +2989,9 @@ function runQwenPrompt({ appendSystem, appendDirs, extraArgs = [], - bin = QWEN_BIN + bin = QWEN_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES5 } = {}) { const invocation = buildQwenInvocation({ prompt, @@ -2647,18 +3011,21 @@ function runQwenPrompt({ const result = runCommand(invocation.bin, invocation.args, { cwd, env, - timeout + timeout, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT5 }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `qwen timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error: error2, errorCode: classifyProviderFailure(error2, { provider: "qwen" }) }; + return { + ok: false, + error: error2, + errorCode: classifyProviderFailure(result.error, { provider: "qwen" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } const parsed = parseQwenStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultEventError = extractQwenResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.status === 0 && !resultEventError && hasVisibleText ? null : result.stderr.trim() || resultEventError || (result.status === 0 ? "qwen produced no visible text" : formatProviderExitError("qwen", result.status)); @@ -2668,7 +3035,7 @@ function runQwenPrompt({ status: result.status, stderr: result.stderr, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), error, errorCode }; @@ -2692,7 +3059,8 @@ function runQwenPromptStreaming({ onEvent = () => { }, bin = QWEN_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES5 } = {}) { const invocation = buildQwenInvocation({ prompt, @@ -2715,10 +3083,12 @@ function runQwenPromptStreaming({ cwd, env, timeout, - detached: background, + detached: background ? true : void 0, unref: background, stdio: ["ignore", "pipe", "pipe"], spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT5, onStdoutLine(line) { if (!line.trim().startsWith("{")) return; try { @@ -2728,19 +3098,15 @@ function runQwenPromptStreaming({ } }).then((result) => { const parsed = parseQwenStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const resultEventError = extractQwenResultError(parsed.resultEvent); - const error = result.ok && !resultEventError ? hasVisibleText ? null : resultEventError || "qwen produced no visible text" : resultEventError || result.error; - const errorCode = resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" }); + const commonFailure = !result.ok && result.errorCode ? result.error : null; + const error = commonFailure || (result.ok && !resultEventError ? hasVisibleText ? null : resultEventError || "qwen produced no visible text" : resultEventError || result.error); + const errorCode = result.errorCode ?? (resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" })); return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), ok: result.ok && !resultEventError && hasVisibleText, error, errorCode @@ -2752,6 +3118,8 @@ function runQwenPromptStreaming({ var MMX_BIN = process.env.MMX_CLI_BIN || process.env.MINIMAX_CLI_BIN || "mmx"; var DEFAULT_TIMEOUT_MS6 = 12e4; var AUTH_CHECK_TIMEOUT_MS6 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES6 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT6 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var MINIMAX_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS6 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -2860,7 +3228,8 @@ function runMiniMaxPrompt({ defaultModel = null, env = process.env, bin = MMX_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES6 } = {}) { return new Promise((resolve) => { const invocation = buildMiniMaxInvocation({ prompt, model, extraArgs, bin }); @@ -2872,6 +3241,8 @@ function runMiniMaxPrompt({ timeout, stdio: ["ignore", "pipe", "pipe"], spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT6, onStdoutLine() { } }).then((result) => { @@ -2932,7 +3303,8 @@ async function runMiniMaxPromptStreaming({ onEvent = () => { }, bin = MMX_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES6 } = {}) { const events = []; return runMiniMaxPrompt({ @@ -2944,7 +3316,8 @@ async function runMiniMaxPromptStreaming({ defaultModel, env, bin, - spawnImpl + spawnImpl, + argvBudgetBytes }).then((result) => { try { const event = { @@ -2967,6 +3340,8 @@ var OPENCODE_BIN = process.env.OPENCODE_CLI_BIN || "opencode"; var DEFAULT_TIMEOUT_MS7 = 9e5; var AUTH_CHECK_TIMEOUT_MS7 = 3e4; var SESSION_EXPORT_TIMEOUT_MS = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES7 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT7 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var OPENCODE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS7 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3153,11 +3528,6 @@ function parseOpenCodeStreamText(text) { } function parseOpenCodeJsonResult(stdout, stderr, status, { defaultModel = null } = {}) { const parsed = parseOpenCodeStreamText(stdout); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getOpenCodeResultError(parsed.resultEvent); const sessionErrorMessage = getOpenCodeSessionErrorDataMessage(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); @@ -3166,7 +3536,7 @@ function parseOpenCodeJsonResult(stdout, stderr, status, { defaultModel = null } ok: status === 0 && !resultError && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(stderr), model: parsed.model ?? defaultModel, status, error, @@ -3214,7 +3584,9 @@ function runOpenCodePrompt({ variant = null, skipPermissions = true, defaultModel = null, - bin = OPENCODE_BIN + bin = OPENCODE_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES7 } = {}) { const invocation = buildOpenCodeInvocation({ prompt, @@ -3228,13 +3600,21 @@ function runOpenCodePrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, env }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT7 + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `opencode timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; return { ok: false, error, - errorCode: classifyProviderFailure(error, { provider: "opencode" }) + errorCode: classifyProviderFailure(result.error, { provider: "opencode" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseOpenCodeJsonResult(result.stdout, result.stderr, result.status, { @@ -3262,7 +3642,8 @@ function runOpenCodePromptStreaming({ onEvent = () => { }, bin = OPENCODE_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES7 } = {}) { const invocation = buildOpenCodeInvocation({ prompt, @@ -3283,6 +3664,8 @@ function runOpenCodePromptStreaming({ env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT7, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -3293,28 +3676,24 @@ function runOpenCodePromptStreaming({ } }).then((result) => { const parsed = parseOpenCodeStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); + const fallbackSessionId = matchResumeSessionIdLine(result.stderr); const resultError = getOpenCodeResultError(parsed.resultEvent); const sessionErrorMessage = getOpenCodeSessionErrorDataMessage(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); let resolvedModel = parsed.model ?? model ?? defaultModel; const ok = result.ok && !resultError && hasVisibleText; if (ok && !resolvedModel) { - resolvedModel = resolveOpenCodeSessionModel(parsed.sessionId ?? resolvedSession.sessionId, { cwd, env, bin }); + resolvedModel = resolveOpenCodeSessionModel(parsed.sessionId ?? fallbackSessionId, { cwd, env, bin }); } - const error = sessionErrorMessage || (result.ok ? resultError || (hasVisibleText ? null : "opencode produced no visible text") : result.error); + const error = (!result.ok && result.errorCode ? result.error : null) || sessionErrorMessage || (result.ok ? resultError || (hasVisibleText ? null : "opencode produced no visible text") : result.error); return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? fallbackSessionId, model: resolvedModel, ok, error, - errorCode: classifyProviderFailure(error, { provider: "opencode" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "opencode" }) }; }); } @@ -3324,6 +3703,8 @@ var PI_BIN = process.env.PI_CLI_BIN || "pi"; var DEFAULT_PI_MODEL = null; var DEFAULT_TIMEOUT_MS8 = 9e5; var AUTH_CHECK_TIMEOUT_MS8 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES8 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT8 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var PI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS8 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3489,7 +3870,10 @@ function runPiPrompt({ continueLast = false, noSession = false, defaultModel = null, - bin = PI_BIN + bin = PI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES8 } = {}) { const invocation = buildPiInvocation({ prompt, @@ -3501,11 +3885,20 @@ function runPiPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT8 + }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `pi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `pi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error, { provider: "pi" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parsePiStreamText(result.stdout); @@ -3542,7 +3935,9 @@ function runPiPromptStreaming({ onEvent = () => { }, bin = PI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES8 } = {}) { const invocation = buildPiInvocation({ prompt, @@ -3558,9 +3953,11 @@ function runPiPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT8, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -3596,6 +3993,8 @@ var CMD_BIN = process.env.CMD_CLI_BIN || "cmd"; var DEFAULT_CMD_MODEL = "deepseek"; var DEFAULT_TIMEOUT_MS9 = 9e5; var AUTH_CHECK_TIMEOUT_MS9 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES9 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT9 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var CMD_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS9 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3676,7 +4075,9 @@ function runCmdPrompt({ extraArgs = [], yolo = true, defaultModel = null, - bin = CMD_BIN + bin = CMD_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES9 } = {}) { const invocation = buildCmdInvocation({ prompt, @@ -3684,13 +4085,21 @@ function runCmdPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, env }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT9 + }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `cmd timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; return { ok: false, error: error2, - errorCode: classifyProviderFailure(error2, { provider: "cmd" }) + errorCode: classifyProviderFailure(result.error, { provider: "cmd" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseCmdTextResult(result.stdout); @@ -3721,7 +4130,8 @@ function runCmdPromptStreaming({ onEvent = () => { }, bin = CMD_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES9 } = {}) { const invocation = buildCmdInvocation({ prompt, @@ -3736,6 +4146,8 @@ function runCmdPromptStreaming({ env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT9, onStdoutLine(line) { const trimmed = line.trimEnd(); if (!trimmed.trim()) return; @@ -3754,7 +4166,7 @@ function runCmdPromptStreaming({ model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, ok: result.ok && hasVisibleText, error, - errorCode: classifyProviderFailure(error, { provider: "cmd" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "cmd" }) }; }); } @@ -3946,7 +4358,7 @@ function runAgyPromptStreaming({ const parsed = parseAgyTextResult(result.stdout); const filteredStderr = stripAgyBenignStderr(result.stderr); const hasVisibleText = Boolean(parsed.response.trim()); - const error = result.ok ? hasVisibleText ? null : "agy produced no visible text" : filteredStderr.trim() || result.error; + const error = !result.ok && result.errorCode ? result.error : result.ok ? hasVisibleText ? null : "agy produced no visible text" : filteredStderr.trim() || result.error; return { ...result, ...parsed, @@ -3955,7 +4367,7 @@ function runAgyPromptStreaming({ model: model ?? defaultModel ?? DEFAULT_AGY_MODEL, ok: result.ok && hasVisibleText, error, - errorCode: classifyProviderFailure(error, { provider: "agy" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "agy" }) }; }); } @@ -3964,6 +4376,8 @@ function runAgyPromptStreaming({ var GROK_BIN = process.env.GROK_CLI_BIN || "grok"; var DEFAULT_TIMEOUT_MS11 = 9e5; var AUTH_CHECK_TIMEOUT_MS11 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES10 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT10 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var DEFAULT_GROK_MODEL = "grok-4.5"; var GROK_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|not logged in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var SUCCESS_STOP_REASONS = /* @__PURE__ */ new Set(["endturn", "end_turn", "stop", "stop_sequence", "complete", "completed", "done", "finished", "maxtokens", "max_tokens", "length"]); @@ -4127,7 +4541,10 @@ function runGrokPrompt({ continueLast = false, extraArgs = [], defaultModel = null, - bin = GROK_BIN + bin = GROK_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES10 } = {}) { const invocation = buildGrokInvocation({ prompt, @@ -4141,10 +4558,22 @@ function runGrokPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT10 + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `grok timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "grok" }) }; + return { + ok: false, + error, + errorCode: classifyProviderFailure(result.error, { provider: "grok" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } const parsed = parseGrokJsonResult(result.stdout, result.stderr, result.status, { defaultModel: model ?? defaultModel @@ -4166,7 +4595,9 @@ function runGrokPromptStreaming({ onEvent = () => { }, bin = GROK_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES10 } = {}) { const invocation = buildGrokInvocation({ prompt, @@ -4184,9 +4615,11 @@ function runGrokPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT10, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -4200,14 +4633,14 @@ function runGrokPromptStreaming({ const hasVisibleText = Boolean(parsed.response.trim()); const stopReasonError = isNonSuccessStopReason(parsed.stopReason) ? `grok stopped with ${parsed.stopReason}` : null; const ok = result.ok && hasVisibleText && !parsed.providerError && !stopReasonError; - const error = ok ? null : parsed.providerError || stopReasonError || (result.ok ? "grok produced no visible text" : result.error); + const error = ok ? null : (!result.ok && result.errorCode ? result.error : null) || parsed.providerError || stopReasonError || (result.ok ? "grok produced no visible text" : result.error); return { ...result, ...parsed, model: model ?? defaultModel ?? DEFAULT_GROK_MODEL, ok, error, - errorCode: classifyProviderFailure(error, { provider: "grok" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "grok" }) }; }); } @@ -5653,11 +6086,11 @@ var COMMAND_DEFINITIONS = deepFreeze([ definition({ id: "tui", path: ["tui"], - summary: "Open the read-only terminal run inspector.", + summary: "Open the terminal run inspector; loading may update local recovery state.", usage: "polycli tui [--run-id ] [--history ]", surfaces: TERMINAL_SURFACE, dispatchTarget: "terminal-wrapper", - effects: { readsWorkspace: true }, + effects: { readsWorkspace: true, writesLocalState: true }, options: [ option("run-id", "string", "Select an initial run.", { valueName: "id" }), option("history", "integer", "Limit the run list.", { valueName: "n" }), @@ -6077,6 +6510,18 @@ function validateCommandPositionals(definition2, parsed, enumSources) { throw commandArgumentError(definition2, "Too many positional arguments.", { arguments: positionals }); } const positionalProvider = positionals[0] ?? null; + if (positionalProvider && explicitProvider) { + throw commandArgumentError( + definition2, + `Provider target cannot be supplied as positional provider '${positionalProvider}' together with --provider '${explicitProvider}'.`, + { + argument: positionalProvider, + positionalProvider, + explicitProvider, + conflictsWith: "--provider" + } + ); + } if (positionalProvider && !providers.includes(positionalProvider)) { throw commandArgumentError( definition2, @@ -6407,9 +6852,9 @@ function assertCommandRegistry({ assertCommandRegistry(); // plugins/polycli/scripts/lib/job-control.mjs -import fs6 from "node:fs"; +import fs7 from "node:fs"; import os4 from "node:os"; -import process4 from "node:process"; +import process5 from "node:process"; import { spawnSync as spawnSync3 } from "node:child_process"; // plugins/polycli/scripts/lib/state.mjs @@ -6550,8 +6995,13 @@ function describeStateRoot() { function stateRootDir() { return describeStateRoot().stateRoot; } -function resolveWorkspaceRoot(cwd = process.cwd()) { - const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { cwd }); +function resolveWorkspaceRoot(cwd = process.cwd(), { deadlineAt = null } = {}) { + const remainingMs = Number.isFinite(deadlineAt) ? Math.floor(deadlineAt - Date.now()) : null; + if (remainingMs != null && remainingMs <= 0) return path3.resolve(cwd); + const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { + cwd, + ...remainingMs == null ? {} : { timeout: remainingMs } + }); if (result.status === 0 && result.stdout.trim()) { return path3.resolve(result.stdout.trim()); } @@ -6575,6 +7025,9 @@ function resolveJobLogFile(workspaceRoot, jobId) { function resolveJobConfigFile(workspaceRoot, jobId) { return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); } +function resolveJobStartFailureFile(workspaceRoot, jobId) { + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.start-failure.json`); +} function chmodPrivateDir(dir) { try { fs3.chmodSync(dir, PRIVATE_DIR_MODE); @@ -6639,6 +7092,7 @@ function saveState(workspaceRoot, state, { preserveJobIds = [] } = {}) { if (job && job.jobId && !keptIds.has(job.jobId)) { removeJobFile(workspaceRoot, job.jobId); removeJobConfigFile(workspaceRoot, job.jobId); + removeJobStartFailureFile(workspaceRoot, job.jobId); removeJobLogFile(workspaceRoot, job.jobId); } } @@ -6653,7 +7107,7 @@ function updateState(workspaceRoot, mutate) { return saveState(workspaceRoot, state); }); } -function updateJobAtomically(workspaceRoot, jobId, buildNext) { +function updateJobAtomically(workspaceRoot, jobId, buildNext, { lockOptions = {} } = {}) { ensureStateDir(workspaceRoot); const lockPath = `${resolveStateFile(workspaceRoot)}.lock`; return withLockfile(lockPath, () => { @@ -6691,7 +7145,7 @@ function updateJobAtomically(workspaceRoot, jobId, buildNext) { } saveState(workspaceRoot, state, { preserveJobIds: [jobId] }); return { written: true, job, envelope }; - }); + }, lockOptions); } function upsertJob(workspaceRoot, jobPatch) { let savedJob = null; @@ -6772,182 +7226,36 @@ function removeJobConfigFile(workspaceRoot, jobId) { } catch { } } -function removeJobLogFile(workspaceRoot, jobId) { - try { - fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); - } catch { - } +function writeJobStartFailureFile(workspaceRoot, jobId, payload) { + ensureStateDir(workspaceRoot); + writeJsonAtomic(resolveJobStartFailureFile(workspaceRoot, jobId), payload, { mode: PRIVATE_FILE_MODE }); + return resolveJobStartFailureFile(workspaceRoot, jobId); } - -// plugins/polycli/scripts/lib/run-ledger.mjs -import { randomUUID as randomUUID2 } from "node:crypto"; -import { stat as fsStat } from "node:fs/promises"; -import path4 from "node:path"; - -// packages/polycli-utils/src/ndjson.js -import fs4 from "node:fs"; -function safeParseLine(line) { +function readJobStartFailureFile(workspaceRoot, jobId) { try { - return JSON.parse(line); + return JSON.parse(fs3.readFileSync(resolveJobStartFailureFile(workspaceRoot, jobId), "utf8")); } catch { return null; } } -function retainCompactedLines(lines, keepFrom, retentionGroupKey) { - const entries = []; - for (const line of lines) { - const record = safeParseLine(line); - if (record != null) { - entries.push({ line, record }); - } - } - if (typeof retentionGroupKey !== "function") { - return entries.slice(keepFrom).map((entry) => entry.line); - } - const grouped = entries.map((entry) => ({ - ...entry, - retentionGroup: retentionGroupKey(entry.record) - })); - const retainedStart = keepFrom < 0 ? Math.max(0, grouped.length + keepFrom) : Math.min(keepFrom, grouped.length); - const retainedGroups = new Set( - grouped.slice(keepFrom).map((entry) => entry.retentionGroup).filter((group) => group != null) - ); - return grouped.filter((entry, index) => index >= retainedStart || entry.retentionGroup != null && retainedGroups.has(entry.retentionGroup)).map((entry) => entry.line); -} -function chmodIfRequested(filePath, mode) { - if (mode === 438) return; +function removeJobStartFailureFile(workspaceRoot, jobId) { try { - fs4.chmodSync(filePath, mode); + fs3.unlinkSync(resolveJobStartFailureFile(workspaceRoot, jobId)); } catch { } } -function readNdjson(filePath) { - let text; +function removeJobLogFile(workspaceRoot, jobId) { try { - text = fs4.readFileSync(filePath, "utf8"); - } catch (error) { - if (error?.code === "ENOENT") { - return []; - } - throw error; - } - if (text.length === 0) { - return []; - } - const records = []; - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - const parsed = safeParseLine(trimmed); - if (parsed != null) { - records.push(parsed); - } - } - return records; -} -function appendNdjson(filePath, record, { - timeoutMs = 1e4, - staleMs = 3e4, - pollMs = 25, - maxBytes = null, - keepRatio = 0.5, - retentionGroupKey = null, - mode = 438 -} = {}) { - const lockPath = `${filePath}.lock`; - return withLockfile(lockPath, () => { - ensureParentDir(filePath); - let needsLeadingNewline = false; - try { - const stat = fs4.statSync(filePath); - if (stat.size > 0) { - const fd = fs4.openSync(filePath, "r"); - const lastByte = Buffer.alloc(1); - try { - fs4.readSync(fd, lastByte, 0, 1, stat.size - 1); - } finally { - fs4.closeSync(fd); - } - needsLeadingNewline = lastByte[0] !== 10; - } - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - const line = `${needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)} -`; - fs4.appendFileSync(filePath, line, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - if (maxBytes != null) { - const stat = fs4.statSync(filePath); - if (stat.size > maxBytes) { - const lines = fs4.readFileSync(filePath, "utf8").split("\n").filter(Boolean); - const validCount = lines.reduce((count, entry) => count + (safeParseLine(entry) != null ? 1 : 0), 0); - const keepFrom = Math.floor(validCount * (1 - keepRatio)); - const kept = retainCompactedLines(lines, keepFrom, retentionGroupKey); - writeFileAtomic(filePath, `${kept.join("\n")} -`, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - } - } - return true; - }, { timeoutMs, staleMs, pollMs }); -} -function appendNdjsonBatch(filePath, records, { - timeoutMs = 1e4, - staleMs = 3e4, - pollMs = 25, - maxBytes = null, - keepRatio = 0.5, - retentionGroupKey = null, - mode = 438 -} = {}) { - if (!Array.isArray(records)) { - throw new TypeError("records must be an array"); - } - if (records.length === 0) { - return true; + fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); + } catch { } - const serializedBatch = records.map((record) => { - const serialized = JSON.stringify(record); - if (typeof serialized !== "string") { - throw new TypeError("each record must be JSON-serializable"); - } - return serialized; - }); - const batch = `${serializedBatch.join("\n")} -`; - const lockPath = `${filePath}.lock`; - return withLockfile(lockPath, () => { - ensureParentDir(filePath); - let text = ""; - try { - text = fs4.readFileSync(filePath, "utf8"); - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - let nextText = `${text}${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}${batch}`; - if (maxBytes != null && Buffer.byteLength(nextText, "utf8") > maxBytes) { - const existing = text.split("\n").filter((entry) => safeParseLine(entry) != null); - const targetCount = Math.max( - serializedBatch.length, - Math.ceil((existing.length + serializedBatch.length) * keepRatio) - ); - const allLines = [...existing, ...serializedBatch]; - const keepFrom = Math.max(0, allLines.length - targetCount); - const kept = retainCompactedLines(allLines, keepFrom, retentionGroupKey); - nextText = `${kept.join("\n")} -`; - } - writeFileAtomic(filePath, nextText, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - return true; - }, { timeoutMs, staleMs, pollMs }); } +// plugins/polycli/scripts/lib/background-start.mjs +import fs5 from "node:fs"; +import process4 from "node:process"; +import { spawn as spawn2 } from "node:child_process"; + // plugins/polycli/scripts/lib/cli-contract.mjs var ERROR_CODES = /* @__PURE__ */ new Set([ "invalid_argument", @@ -7250,8 +7558,8 @@ function normalizeStatusListWait(wait, payload) { function normalizeSessionEntries(entries) { return clonePublicResult(Array.isArray(entries) ? entries : []); } -function serializeProviderCommand(commandId, payload, context) { - if (context.background === true || payload?.job && context.background !== false) { +function serializeProviderCommand(commandId, payload) { + if (payload?.job) { return { type: "job.started", job: normalizeV2Job(payload.job) @@ -7282,7 +7590,7 @@ function serializeV2Result(commandId, legacyPayload, context = {}) { }; } if (PROVIDER_COMMANDS.has(id)) { - return serializeProviderCommand(id, payload, context); + return serializeProviderCommand(id, payload); } if (id === "status") { if (payload.job) { @@ -7444,24 +7752,193 @@ function createV2ErrorEnvelope(error, context = {}) { } // plugins/polycli/scripts/lib/run-ledger.mjs -var MAX_LEDGER_BYTES = 2e6; -var KEEP_RATIO = 0.5; -var PRIVATE_FILE_MODE2 = 384; -var RUN_ID_RE = /^[A-Za-z0-9_.-]{1,96}$/; -var SECRET_LONG_OPT_RE = /(token|secret|password|api-?key|access-?key|credential)/i; -var SECRET_ENV_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_KEY|CREDENTIAL)/i; -var PROMPT_COMMANDS = /* @__PURE__ */ new Set(["ask", "rescue", "review", "adversarial-review"]); -var VALUE_OPTIONS = /* @__PURE__ */ new Set([ - "--provider", - "--model", - "--base", - "--scope", - "--resume", - "--effort", - "--run-id", - "--timeout-ms", - "--history" -]); +import { randomUUID as randomUUID2 } from "node:crypto"; +import { stat as fsStat } from "node:fs/promises"; +import path4 from "node:path"; + +// packages/polycli-utils/src/ndjson.js +import fs4 from "node:fs"; +function safeParseLine(line) { + try { + return JSON.parse(line); + } catch { + return null; + } +} +function retainCompactedLines(lines, keepFrom, retentionGroupKey) { + const entries = []; + for (const line of lines) { + const record = safeParseLine(line); + if (record != null) { + entries.push({ line, record }); + } + } + if (typeof retentionGroupKey !== "function") { + return entries.slice(keepFrom).map((entry) => entry.line); + } + const grouped = entries.map((entry) => ({ + ...entry, + retentionGroup: retentionGroupKey(entry.record) + })); + const retainedStart = keepFrom < 0 ? Math.max(0, grouped.length + keepFrom) : Math.min(keepFrom, grouped.length); + const retainedGroups = new Set( + grouped.slice(keepFrom).map((entry) => entry.retentionGroup).filter((group) => group != null) + ); + return grouped.filter((entry, index) => index >= retainedStart || entry.retentionGroup != null && retainedGroups.has(entry.retentionGroup)).map((entry) => entry.line); +} +function chmodIfRequested(filePath, mode) { + if (mode === 438) return; + try { + fs4.chmodSync(filePath, mode); + } catch { + } +} +function readNdjson(filePath) { + let text; + try { + text = fs4.readFileSync(filePath, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") { + return []; + } + throw error; + } + if (text.length === 0) { + return []; + } + const records = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parsed = safeParseLine(trimmed); + if (parsed != null) { + records.push(parsed); + } + } + return records; +} +function appendNdjson(filePath, record, { + timeoutMs = 1e4, + staleMs = 3e4, + pollMs = 25, + maxBytes = null, + keepRatio = 0.5, + retentionGroupKey = null, + mode = 438 +} = {}) { + const lockPath = `${filePath}.lock`; + return withLockfile(lockPath, () => { + ensureParentDir(filePath); + let needsLeadingNewline = false; + try { + const stat = fs4.statSync(filePath); + if (stat.size > 0) { + const fd = fs4.openSync(filePath, "r"); + const lastByte = Buffer.alloc(1); + try { + fs4.readSync(fd, lastByte, 0, 1, stat.size - 1); + } finally { + fs4.closeSync(fd); + } + needsLeadingNewline = lastByte[0] !== 10; + } + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + const line = `${needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)} +`; + fs4.appendFileSync(filePath, line, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + if (maxBytes != null) { + const stat = fs4.statSync(filePath); + if (stat.size > maxBytes) { + const lines = fs4.readFileSync(filePath, "utf8").split("\n").filter(Boolean); + const validCount = lines.reduce((count, entry) => count + (safeParseLine(entry) != null ? 1 : 0), 0); + const keepFrom = Math.floor(validCount * (1 - keepRatio)); + const kept = retainCompactedLines(lines, keepFrom, retentionGroupKey); + writeFileAtomic(filePath, `${kept.join("\n")} +`, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + } + } + return true; + }, { timeoutMs, staleMs, pollMs }); +} +function appendNdjsonBatch(filePath, records, { + timeoutMs = 1e4, + staleMs = 3e4, + pollMs = 25, + maxBytes = null, + keepRatio = 0.5, + retentionGroupKey = null, + mode = 438 +} = {}) { + if (!Array.isArray(records)) { + throw new TypeError("records must be an array"); + } + if (records.length === 0) { + return true; + } + const serializedBatch = records.map((record) => { + const serialized = JSON.stringify(record); + if (typeof serialized !== "string") { + throw new TypeError("each record must be JSON-serializable"); + } + return serialized; + }); + const batch = `${serializedBatch.join("\n")} +`; + const lockPath = `${filePath}.lock`; + return withLockfile(lockPath, () => { + ensureParentDir(filePath); + let text = ""; + try { + text = fs4.readFileSync(filePath, "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + let nextText = `${text}${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}${batch}`; + if (maxBytes != null && Buffer.byteLength(nextText, "utf8") > maxBytes) { + const existing = text.split("\n").filter((entry) => safeParseLine(entry) != null); + const targetCount = Math.max( + serializedBatch.length, + Math.ceil((existing.length + serializedBatch.length) * keepRatio) + ); + const allLines = [...existing, ...serializedBatch]; + const keepFrom = Math.max(0, allLines.length - targetCount); + const kept = retainCompactedLines(allLines, keepFrom, retentionGroupKey); + nextText = `${kept.join("\n")} +`; + } + writeFileAtomic(filePath, nextText, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + return true; + }, { timeoutMs, staleMs, pollMs }); +} + +// plugins/polycli/scripts/lib/run-ledger.mjs +var MAX_LEDGER_BYTES = 2e6; +var KEEP_RATIO = 0.5; +var PRIVATE_FILE_MODE2 = 384; +var RUN_ID_RE = /^[A-Za-z0-9_.-]{1,96}$/; +var SECRET_LONG_OPT_RE = /(token|secret|password|api-?key|access-?key|credential)/i; +var SECRET_ENV_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_KEY|CREDENTIAL)/i; +var PROMPT_COMMANDS = /* @__PURE__ */ new Set(["ask", "rescue", "review", "adversarial-review"]); +var VALUE_OPTIONS = /* @__PURE__ */ new Set([ + "--provider", + "--model", + "--base", + "--scope", + "--resume", + "--effort", + "--run-id", + "--timeout-ms", + "--history" +]); var SHORT_VALUE_OPTIONS = /* @__PURE__ */ new Set(["-m"]); var FOCUS_VALUE_OPTIONS = /* @__PURE__ */ new Set(["--focus"]); var VALID_HOST_SURFACES = /* @__PURE__ */ new Set([ @@ -7610,11 +8087,20 @@ function redactLedgerError(error) { if (message == null || message === "") return null; return { message: sanitizePublicErrorMessage(message, 300) }; } +function sanitizeLedgerPreview(preview) { + if (preview == null) return null; + const text = String(preview); + return sanitizePublicErrorMessage(text, text.length); +} function redactTerminalDescriptor(descriptor) { if (!descriptor || typeof descriptor !== "object") return descriptor ?? null; return { ...descriptor, - events: Array.isArray(descriptor.events) ? descriptor.events.map((event) => ({ ...event, error: redactLedgerError(event.error) })) : descriptor.events + events: Array.isArray(descriptor.events) ? descriptor.events.map((event) => ({ + ...event, + error: redactLedgerError(event.error), + ...Object.prototype.hasOwnProperty.call(event, "preview") ? { preview: sanitizeLedgerPreview(event.preview) } : {} + })) : descriptor.events }; } function createRunLedgerEvent(event = {}) { @@ -7643,7 +8129,7 @@ function createRunLedgerEvent(event = {}) { providerSessionId, timingRef: event.timingRef ?? null, error: redactLedgerError(event.error), - preview: event.preview ?? null, + preview: sanitizeLedgerPreview(event.preview), stdoutBytes: event.stdoutBytes ?? null, stderrBytes: event.stderrBytes ?? null, durationMs: event.durationMs ?? null, @@ -7677,7 +8163,7 @@ function appendRunLedgerEvent(workspaceRoot, event) { }); return full; } -function appendRunLedgerEvents(workspaceRoot, events) { +function appendRunLedgerEvents(workspaceRoot, events, lockOptions = {}) { if (!Array.isArray(events)) { throw new TypeError("events must be an array"); } @@ -7691,6 +8177,7 @@ function appendRunLedgerEvents(workspaceRoot, events) { workspaceSlug: event.workspaceSlug ?? workspaceSlug })); appendNdjsonBatch(file, full, { + ...lockOptions, maxBytes: MAX_LEDGER_BYTES, keepRatio: KEEP_RATIO, retentionGroupKey: terminalLedgerRetentionGroupKey, @@ -7880,7 +8367,7 @@ function buildExpectedTerminalPair(events) { expected: rawExpected.map((event) => ({ ...event, terminalDescriptor: descriptor })) }; } -function ensureRunLedgerTerminalPair(workspaceRoot, events) { +function ensureRunLedgerTerminalPair(workspaceRoot, events, { lockOptions = {} } = {}) { if (!Array.isArray(events) || events.length !== 2) { throw new TypeError("terminal ledger pair must contain exactly two events"); } @@ -7897,7 +8384,7 @@ function ensureRunLedgerTerminalPair(workspaceRoot, events) { if (ambiguousLegacy.length > 0) { throw new Error(`Incomplete or conflicting terminal ledger pair for ${identityLabel}`); } - return appendRunLedgerEvents(workspaceRoot, expected); + return appendRunLedgerEvents(workspaceRoot, expected, lockOptions); } if (existing.length === 1) { const [partial] = existing; @@ -7920,7 +8407,7 @@ function ensureRunLedgerTerminalPair(workspaceRoot, events) { ...missing, terminalDescriptor: repairDescriptor }; - return [...existing, ...appendRunLedgerEvents(workspaceRoot, [repair])]; + return [...existing, ...appendRunLedgerEvents(workspaceRoot, [repair], lockOptions)]; } if (!terminalPairMatches(existing, expected)) { throw new Error(`Incomplete or conflicting terminal ledger pair for ${identityLabel}`); @@ -8251,40 +8738,342 @@ function summarizeRunLedger(events) { incrementCount(failureClassCounts, classifyRunFailure(event)); } return { - runId: group.runId, - commands: group.commands, - startedAt: group.events[0]?.at || null, - updatedAt: group.events.at(-1)?.at || null, - providerCount: new Set(decisions.map((event) => event.provider)).size, - adoptedCount: decisions.filter((event) => event.status === "adopted").length, - skippedCount: decisions.filter((event) => event.status === "skipped").length, - failedCount: decisions.filter((event) => event.status === "failed").length, - failureClassCounts + runId: group.runId, + commands: group.commands, + startedAt: group.events[0]?.at || null, + updatedAt: group.events.at(-1)?.at || null, + providerCount: new Set(decisions.map((event) => event.provider)).size, + adoptedCount: decisions.filter((event) => event.status === "adopted").length, + skippedCount: decisions.filter((event) => event.status === "skipped").length, + failedCount: decisions.filter((event) => event.status === "failed").length, + failureClassCounts + }; + }); +} +function buildRunExplanation(events, runId) { + const group = groupRunLedgerEvents(events).get(runId); + if (!group) { + return { runId, found: false, text: `Run ${runId} was not found.`, events: [] }; + } + const decisions = group.projectedEvents.filter((event) => event.phase === "provider_decision"); + const lines = decisions.map( + (event) => `${event.provider || "run"} ${event.status}${event.reason ? ` (${event.reason})` : ""}` + ); + for (const event of group.projectedEvents.filter((item) => item.phase === "attempt_result" && item.status === "failed")) { + const subject = event.provider || event.jobId || "run"; + lines.push(`attempt ${subject} failed (${classifyRunFailure(event)})`); + } + const terminalProviders = new Set(group.projectedEvents.filter((event) => TERMINAL_LEDGER_PHASES.has(event.phase) && event.provider).map((event) => event.provider)); + for (const provider of new Set(group.projectedEvents.filter((event) => (event.phase === "attempt_started" || event.phase === "job_started") && event.provider).map((event) => event.provider))) { + if (!terminalProviders.has(provider)) lines.push(`${provider} unfinished`); + } + return { runId, found: true, text: lines.join("\n"), events: group.events }; +} + +// plugins/polycli/scripts/lib/background-start.mjs +var ACTIVE_JOB_STATUSES2 = /* @__PURE__ */ new Set(["queued", "running"]); +var TERMINAL_JOB_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); +function blocksFailureFinalizer(envelope) { + return TERMINAL_JOB_STATUSES2.has(envelope?.job?.status) || envelope?.cancellationIntent?.status === "requested" && ACTIVE_JOB_STATUSES2.has(envelope?.job?.status); +} +function cleanupRuntimeOptions(runtimeOptions = {}) { + const cleanupPaths = Array.isArray(runtimeOptions?.cleanupPaths) ? runtimeOptions.cleanupPaths : []; + for (const cleanupPath of cleanupPaths) { + if (typeof cleanupPath !== "string" || cleanupPath.trim() === "") continue; + try { + fs5.rmSync(cleanupPath, { recursive: true, force: true }); + } catch { + } + } +} +function buildRunEvent(runContext, base) { + if (!runContext?.runId) return null; + const command = base.command || runContext.command; + return { + runId: runContext.runId, + hostSurface: runContext.hostSurface || "unknown", + argv: runContext.argv || [], + invocationId: runContext.invocationId ?? null, + attemptId: runContext.attemptId ?? null, + jobId: runContext.jobId ?? base.jobId ?? null, + command, + commands: Array.from(new Set([ + ...runContext.commands || [], + command, + ...base.commands || [] + ].filter(Boolean))).sort(), + ...base + }; +} +function prepareFailureEvents(runContext, events) { + const material = events.map((event) => buildRunEvent(runContext, event)).filter(Boolean); + if (material.length === 0) return []; + const terminalDescriptor = createTerminalLedgerDescriptor(material); + return material.map((event) => ({ ...event, terminalDescriptor })); +} +function safeRunIdentity(jobId, execution, runContext) { + return { + runId: runContext?.runId ?? null, + invocationId: runContext?.invocationId ?? null, + attemptId: runContext?.attemptId ?? null, + command: runContext?.command || execution.kind, + commands: Array.from(new Set([ + ...runContext?.commands || [], + runContext?.command, + execution.kind + ].filter(Boolean))).sort(), + hostSurface: runContext?.hostSurface || "unknown", + jobId, + provider: execution.provider, + kind: execution.kind, + logFile: runContext?.logFile ?? null + }; +} +function buildStartFailureRecovery(jobId, execution, runContext, error, runtimeOptions) { + const safeMessage = sanitizePublicErrorMessage(error?.message || error, 300); + const identity = safeRunIdentity(jobId, execution, runContext); + const terminalReason = `${execution.kind}_failed`; + const terminalEvents = prepareFailureEvents(identity, [ + { + command: identity.command, + kind: execution.kind, + provider: execution.provider, + phase: "attempt_result", + status: "failed", + reason: terminalReason, + attempt: { ordinal: 1 }, + jobId, + error: { message: safeMessage }, + logFile: identity.logFile + }, + { + command: identity.command, + kind: execution.kind, + provider: execution.provider, + phase: "provider_decision", + status: "failed", + reason: terminalReason, + jobId + } + ]); + return { + version: 1, + jobId, + provider: execution.provider, + kind: execution.kind, + error: safeMessage, + recordedAt: (/* @__PURE__ */ new Date()).toISOString(), + identity, + terminalDescriptor: terminalEvents[0]?.terminalDescriptor ?? null, + ownedCleanupPaths: Array.isArray(runtimeOptions?.cleanupPaths) ? runtimeOptions.cleanupPaths.filter((entry) => typeof entry === "string" && entry.trim() !== "") : [] + }; +} +function materialExecution(recovery) { + return { + provider: recovery.provider, + kind: recovery.kind, + runtimeOptions: { cleanupPaths: recovery.ownedCleanupPaths || [] } + }; +} +function finalizeBackgroundStartFailure(workspaceRoot, recovery, { lockOptions = {} } = {}) { + const { jobId } = recovery; + const execution = materialExecution(recovery); + const runContext = recovery.identity; + const safeMessage = recovery.error; + return updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { + if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || blocksFailureFinalizer(storedEnvelope)) { + return null; + } + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); + const failedJob = { + ...latest, + invocationId: latest.invocationId ?? runContext?.invocationId ?? null, + attemptId: latest.attemptId ?? runContext?.attemptId ?? null, + status: "failed", + pid: null, + finishedAt, + updatedAt: finishedAt, + error: safeMessage + }; + const terminalReason = `${execution.kind}_failed`; + const terminalEvents = prepareFailureEvents(runContext, [ + { + command: runContext?.command || execution.kind, + kind: execution.kind, + provider: execution.provider, + phase: "attempt_result", + status: "failed", + reason: terminalReason, + attempt: { ordinal: 1 }, + jobId, + error: { message: safeMessage }, + logFile: failedJob.logFile || runContext?.logFile || null + }, + { + command: runContext?.command || execution.kind, + kind: execution.kind, + provider: execution.provider, + phase: "provider_decision", + status: "failed", + reason: terminalReason, + jobId + } + ]).map((event) => ({ + ...event, + terminalDescriptor: recovery.terminalDescriptor || event.terminalDescriptor + })); + const terminalDescriptor = recovery.terminalDescriptor || terminalEvents[0]?.terminalDescriptor || null; + return { + job: failedJob, + envelope: { + job: failedJob, + result: { ok: false, error: safeMessage }, + terminalReason, + terminalDescriptor + }, + beforeStateCommit() { + if (terminalEvents.length > 0) { + ensureRunLedgerTerminalPair(workspaceRoot, terminalEvents); + } + } }; - }); + }, { lockOptions }); } -function buildRunExplanation(events, runId) { - const group = groupRunLedgerEvents(events).get(runId); - if (!group) { - return { runId, found: false, text: `Run ${runId} was not found.`, events: [] }; +function persistedJobBlocksFailure(workspaceRoot, jobId) { + const job = getJob(workspaceRoot, jobId); + const envelope = readJobFile(resolveJobFile(workspaceRoot, jobId)); + return !job || !ACTIVE_JOB_STATUSES2.has(job.status) || blocksFailureFinalizer(envelope); +} +function recoverBackgroundStartFailure(workspaceRoot, jobId, options = {}) { + const recovery = readJobStartFailureFile(workspaceRoot, jobId); + if (!recovery || recovery.version !== 1 || recovery.jobId !== jobId) { + return { written: false, finalizationError: null }; } - const decisions = group.projectedEvents.filter((event) => event.phase === "provider_decision"); - const lines = decisions.map( - (event) => `${event.provider || "run"} ${event.status}${event.reason ? ` (${event.reason})` : ""}` - ); - for (const event of group.projectedEvents.filter((item) => item.phase === "attempt_result" && item.status === "failed")) { - const subject = event.provider || event.jobId || "run"; - lines.push(`attempt ${subject} failed (${classifyRunFailure(event)})`); + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; } - const terminalProviders = new Set(group.projectedEvents.filter((event) => TERMINAL_LEDGER_PHASES.has(event.phase) && event.provider).map((event) => event.provider)); - for (const provider of new Set(group.projectedEvents.filter((event) => (event.phase === "attempt_started" || event.phase === "job_started") && event.provider).map((event) => event.provider))) { - if (!terminalProviders.has(provider)) lines.push(`${provider} unfinished`); + try { + const write = finalizeBackgroundStartFailure(workspaceRoot, recovery, options); + if (write.written) { + cleanupRuntimeOptions({ cleanupPaths: recovery.ownedCleanupPaths }); + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } + return { written: write.written, finalizationError: null }; + } catch (finalizationError) { + return { written: false, finalizationError }; } - return { runId, found: true, text: lines.join("\n"), events: group.events }; +} +function recordBackgroundStartFailure(workspaceRoot, jobId, execution, runContext, error, options = {}) { + const configFile = resolveJobConfigFile(workspaceRoot, jobId); + const config = readJobConfigFile(configFile); + const runtimeOptions = config?.execution?.runtimeOptions ?? execution.runtimeOptions; + const recovery = buildStartFailureRecovery(jobId, execution, runContext, error, runtimeOptions); + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + writeJobStartFailureFile(workspaceRoot, jobId, recovery); + } catch { + } + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + const write = finalizeBackgroundStartFailure(workspaceRoot, recovery, options); + if (write.written) { + cleanupRuntimeOptions(runtimeOptions); + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } + return { written: write.written, finalizationError: null }; + } catch (finalizationError) { + cleanupRuntimeOptions(runtimeOptions); + return { written: false, finalizationError }; + } +} +async function startBackgroundWorker({ + workspaceRoot, + job, + execution, + runContext, + config, + companionPath, + env = process4.env, + failureFinalizationOptions = {} +}, { + writeConfigFile = writeJobConfigFile, + writeLogFile = fs5.writeFileSync, + openLogFile = fs5.openSync, + closeLogFile = fs5.closeSync, + spawnWorker = spawn2 +} = {}) { + let child = null; + let logFd = null; + let startFailure = null; + let startWarning = null; + let closeWarning = null; + const configFile = resolveJobConfigFile(workspaceRoot, job.jobId); + try { + writeConfigFile(workspaceRoot, job.jobId, config); + writeLogFile(job.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] started ${job.provider} ${job.kind} +`, { + encoding: "utf8", + mode: 384 + }); + logFd = openLogFile(job.logFile, "a", 384); + child = spawnWorker(process4.execPath, [companionPath, "_job-worker", configFile], { + cwd: execution.cwd, + env: { ...env }, + stdio: ["ignore", logFd, logFd], + detached: true + }); + try { + child.once("error", (asyncError) => { + recordBackgroundStartFailure( + workspaceRoot, + job.jobId, + execution, + runContext, + asyncError, + failureFinalizationOptions + ); + }); + child.unref(); + } catch (error) { + startWarning = error; + } + } catch (error) { + startFailure = error; + } finally { + if (logFd != null) { + try { + closeLogFile(logFd); + } catch (error) { + if (child) closeWarning = error; + else if (!startFailure) startFailure = error; + } + } + } + if (startFailure) { + recordBackgroundStartFailure( + workspaceRoot, + job.jobId, + execution, + runContext, + startFailure, + failureFinalizationOptions + ); + throw startFailure; + } + return { child, startWarning, closeWarning }; } // plugins/polycli/scripts/lib/sessions.mjs -import fs5 from "node:fs"; +import fs6 from "node:fs"; import os3 from "node:os"; import path5 from "node:path"; import { createHash } from "node:crypto"; @@ -8330,7 +9119,7 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho case "kimi": { let realCwd = workspaceRoot; try { - realCwd = fs5.realpathSync(workspaceRoot); + realCwd = fs6.realpathSync(workspaceRoot); } catch { } const slug = `wd_${path5.basename(realCwd)}_${createHash("sha256").update(realCwd).digest("hex").slice(0, 12)}`; @@ -8355,7 +9144,7 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho return { path: null, reason: `no artifact derivation for provider ${provider ?? "?"}` }; } } -function recordArtifactPath(candidate, { homedir, lstatFn = fs5.lstatSync, realpathFn = fs5.realpathSync, existsFn = fs5.existsSync } = {}) { +function recordArtifactPath(candidate, { homedir, lstatFn = fs6.lstatSync, realpathFn = fs6.realpathSync, existsFn = fs6.existsSync } = {}) { if (!candidate || typeof candidate.path !== "string") return null; const { path: candidatePath, provider } = candidate; if (!existsFn(candidatePath)) return null; @@ -8432,7 +9221,7 @@ function collectNonPurgeableSessions(events = [], { homedir = os3.homedir() } = } return out; } -function planPurge({ recorded = [], homedir, lstatFn = fs5.lstatSync, realpathFn = fs5.realpathSync, existsFn = fs5.existsSync, sizeFn } = {}) { +function planPurge({ recorded = [], homedir, lstatFn = fs6.lstatSync, realpathFn = fs6.realpathSync, existsFn = fs6.existsSync, sizeFn } = {}) { const deletable = []; const skipped = []; const defaultSizeFn = (p) => { @@ -8499,7 +9288,7 @@ function planPurge({ recorded = [], homedir, lstatFn = fs5.lstatSync, realpathFn } return { deletable, skipped }; } -function executePurge(plan, { confirm = false, rmFn = (p) => fs5.rmSync(p, { recursive: true, force: true }) } = {}) { +function executePurge(plan, { confirm = false, rmFn = (p) => fs6.rmSync(p, { recursive: true, force: true }) } = {}) { const deletable = plan?.deletable ?? []; const skipped = plan?.skipped ?? []; if (!confirm) { @@ -8522,27 +9311,74 @@ var TERMINAL_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancel var WAIT_TARGETS = /* @__PURE__ */ new Set(["terminal", "completed", "failed", "cancelled"]); var DEFAULT_STATUS_LIMIT = 8; var MAX_SELECTOR_CANDIDATES = 8; +function createDeadlineError() { + const error = new Error("cancellation deadline exceeded"); + error.code = "EDEADLINE"; + return error; +} +function remainingDeadlineMs(deadlineAt) { + return Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : null; +} +function deadlineLockOptions(deadlineAt) { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs == null) return {}; + if (remainingMs <= 0) throw createDeadlineError(); + return { + timeoutMs: Math.max(1, Math.ceil(remainingMs)), + pollMs: Math.max(1, Math.min(25, Math.ceil(remainingMs))) + }; +} +function isDeadlineFailure(error, deadlineAt) { + return Number.isFinite(deadlineAt) && ["EDEADLINE", "ELOCKTIMEOUT", "ETIMEDOUT"].includes(error?.code); +} +async function awaitWithinDeadline(promise, deadlineAt) { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs == null) { + await promise; + return true; + } + if (remainingMs <= 0) return false; + let timer = null; + try { + return await Promise.race([ + Promise.resolve(promise).then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), remainingMs); + }) + ]); + } finally { + if (timer) clearTimeout(timer); + } +} function isProcessAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { - process4.kill(pid, 0); + process5.kill(pid, 0); return true; } catch { return false; } } -function isExpectedWorkerProcess(pid, configFile) { +function isExpectedWorkerProcess(pid, configFile, { + deadlineAt = null, + platform = process5.platform, + spawnProcess = spawnSync3 +} = {}) { if (!Number.isInteger(pid) || pid <= 0 || !configFile) return null; + const remainingMs = Number.isFinite(deadlineAt) ? Math.floor(deadlineAt - Date.now()) : null; + if (remainingMs != null && remainingMs <= 0) return null; try { - const result = process4.platform === "win32" ? spawnSync3("powershell.exe", [ + const spawnOptions = { + encoding: "utf8", + stdio: "pipe", + ...remainingMs == null ? {} : { timeout: remainingMs } + }; + const result = platform === "win32" ? spawnProcess("powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine` - ], { encoding: "utf8", stdio: "pipe" }) : spawnSync3("ps", ["-ww", "-o", "command=", "-p", String(pid)], { - encoding: "utf8", - stdio: "pipe" - }); + ], spawnOptions) : spawnProcess("ps", ["-ww", "-o", "command=", "-p", String(pid)], spawnOptions); if (result.error) return null; if (result.status !== 0) return false; return result.stdout.includes("_job-worker") && result.stdout.includes(configFile); @@ -8556,7 +9392,7 @@ function sortJobsNewestFirst(jobs) { function readProgressPreview(logFile, maxLines = 4) { if (!logFile) return ""; try { - const lines = fs6.readFileSync(logFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const lines = fs7.readFileSync(logFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); return lines.slice(-maxLines).join("\n"); } catch { return ""; @@ -8573,6 +9409,9 @@ function enrichJob(workspaceRoot, job) { function isTerminalEnvelope(envelope) { return Boolean(envelope?.job && TERMINAL_STATUSES2.has(envelope.job.status)); } +function hasPendingCancellationIntent(envelope) { + return envelope?.cancellationIntent?.status === "requested"; +} function buildRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result = null, reason = "worker_exited", terminalDescriptor = null } = {}) { const command = runContext.command || config?.execution?.kind || job.kind || null; const provider = runContext.provider || config?.execution?.provider || job.provider || null; @@ -8671,7 +9510,18 @@ function applyTerminalDescriptor(events, terminalDescriptor) { }); } function prepareRecoveredTerminalEvents(workspaceRoot, job, config, { result = null, reason = "worker_exited", terminalDescriptor = null } = {}) { - const runContext = config?.runContext; + const descriptorEvent = terminalDescriptor?.events?.[0] ?? null; + const runContext = config?.runContext || (terminalDescriptor?.runId ? { + runId: terminalDescriptor.runId, + command: descriptorEvent?.command ?? job.kind ?? null, + kind: descriptorEvent?.kind ?? job.kind ?? null, + provider: descriptorEvent?.provider ?? job.provider ?? null, + hostSurface: descriptorEvent?.hostSurface || "unknown", + invocationId: terminalDescriptor.invocationId ?? job.invocationId ?? null, + attemptId: terminalDescriptor.attemptId ?? job.attemptId ?? null, + jobId: terminalDescriptor.jobId ?? job.jobId ?? null, + logFile: job.logFile ?? null + } : null); if (!runContext?.runId) return { events: [], terminalDescriptor }; const events = buildRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result, @@ -8684,9 +9534,9 @@ function prepareRecoveredTerminalEvents(workspaceRoot, job, config, { result = n terminalDescriptor: descriptor }; } -function ensureRecoveredTerminalEvents(workspaceRoot, prepared) { +function ensureRecoveredTerminalEvents(workspaceRoot, prepared, { lockOptions = {} } = {}) { if (prepared.events.length > 0) { - ensureRunLedgerTerminalPair(workspaceRoot, prepared.events); + ensureRunLedgerTerminalPair(workspaceRoot, prepared.events, { lockOptions }); } } function cleanupRuntimePaths(config) { @@ -8695,16 +9545,26 @@ function cleanupRuntimePaths(config) { for (const cleanupPath of cleanupPaths) { if (typeof cleanupPath !== "string" || !cleanupPath) continue; try { - fs6.rmSync(cleanupPath, { recursive: true, force: true }); + fs7.rmSync(cleanupPath, { recursive: true, force: true }); } catch { } } } function refreshJob(workspaceRoot, job) { if (!job || !ACTIVE_STATUSES2.has(job.status)) { + if (job?.jobId && TERMINAL_STATUSES2.has(job.status)) { + removeJobStartFailureFile(workspaceRoot, job.jobId); + } return job ? enrichJob(workspaceRoot, job) : null; } const storedEnvelope = readJobFile(resolveJobFile(workspaceRoot, job.jobId)); + if (hasPendingCancellationIntent(storedEnvelope)) { + return enrichJob(workspaceRoot, job); + } + const startFailureRecovery = recoverBackgroundStartFailure(workspaceRoot, job.jobId); + if (startFailureRecovery.written) { + return enrichJob(workspaceRoot, getJob(workspaceRoot, job.jobId) || job); + } if (!job.pid && !isTerminalEnvelope(storedEnvelope)) { return enrichJob(workspaceRoot, job); } @@ -8778,8 +9638,12 @@ function refreshJob(workspaceRoot, job) { const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, job.jobId)); cleanupRuntimePaths(config); removeJobConfigFile(workspaceRoot, job.jobId); + removeJobStartFailureFile(workspaceRoot, job.jobId); } const current = write.written ? write.job : getJob(workspaceRoot, job.jobId) || job; + if (TERMINAL_STATUSES2.has(current?.status)) { + removeJobStartFailureFile(workspaceRoot, job.jobId); + } return enrichJob(workspaceRoot, current); } catch { return enrichJob(workspaceRoot, getJob(workspaceRoot, job.jobId) || job); @@ -8787,11 +9651,11 @@ function refreshJob(workspaceRoot, job) { } function buildStatusSnapshot(workspaceRoot, { showAll = false } = {}) { const refreshed = sortJobsNewestFirst(listJobs(workspaceRoot)).map((job) => refreshJob(workspaceRoot, job)); - const limited = showAll ? refreshed : refreshed.slice(0, DEFAULT_STATUS_LIMIT); + const recent = refreshed.filter((job) => TERMINAL_STATUSES2.has(job.status)); return { totalJobs: refreshed.length, - running: limited.filter((job) => ACTIVE_STATUSES2.has(job.status)), - recent: limited.filter((job) => TERMINAL_STATUSES2.has(job.status)) + running: refreshed.filter((job) => ACTIVE_STATUSES2.has(job.status)), + recent: showAll ? recent : recent.slice(0, DEFAULT_STATUS_LIMIT) }; } function refreshJobsForLedgerRecovery(workspaceRoot) { @@ -8974,84 +9838,94 @@ async function waitForJob(workspaceRoot, jobId, options = {}) { async function cancelJob(workspaceRoot, jobId, { terminate = terminateProcessTree, isWorkerAlive = isProcessAlive, - isExpectedWorker = isExpectedWorkerProcess + isExpectedWorker = isExpectedWorkerProcess, + deadlineAt = null } = {}) { let pidToKill = null; let configForCleanup = null; let cancellationEnvelope = null; let reason = null; const requestedAt = (/* @__PURE__ */ new Date()).toISOString(); - const intentWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { - if (!current) { - reason = "not_found"; - return null; - } - if (!ACTIVE_STATUSES2.has(current.status)) { - reason = "not_cancellable"; - return null; - } - const resumingCancellation = storedEnvelope?.job?.status === "cancelled"; - if (isTerminalEnvelope(storedEnvelope) && !resumingCancellation) { - reason = "not_cancellable"; - return null; - } - pidToKill = current.pid ?? null; - configForCleanup = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); - const intentJob = resumingCancellation ? { - ...current, - ...storedEnvelope.job, - status: "cancelled", - pid: null, - finishedAt: storedEnvelope.job.finishedAt || requestedAt, - updatedAt: requestedAt - } : { - ...current, - status: "cancelled", - pid: null, - finishedAt: requestedAt, - updatedAt: requestedAt - }; - const cancellationResult = resumingCancellation ? storedEnvelope.result || { ok: false, error: "cancelled" } : { ok: false, error: "cancelled" }; - const terminal = prepareRecoveredTerminalEvents(workspaceRoot, intentJob, configForCleanup, { - result: cancellationResult, - reason: "cancelled", - terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null - }); - cancellationEnvelope = { - job: intentJob, - result: cancellationResult, - terminalReason: "cancelled", - terminalDescriptor: terminal.terminalDescriptor - }; - return { - // Do not make the state terminal yet. It remains the recovery point if this process exits - // after persisting the intent but before the worker receives its signal. - job: current, - envelope: cancellationEnvelope, - beforeStateCommit() { - ensureRecoveredTerminalEvents(workspaceRoot, terminal); + let intentWrite; + try { + intentWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { + if (!current) { + reason = "not_found"; + return null; } - }; - }); + if (!ACTIVE_STATUSES2.has(current.status)) { + reason = "not_cancellable"; + return null; + } + const resumingCancellation = hasPendingCancellationIntent(storedEnvelope); + if (isTerminalEnvelope(storedEnvelope) && !resumingCancellation) { + reason = "not_cancellable"; + return null; + } + pidToKill = current.pid ?? null; + configForCleanup = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); + cancellationEnvelope = resumingCancellation ? storedEnvelope : { + job: current, + cancellationIntent: { + status: "requested", + requestedAt + } + }; + return { + // Do not make the state or envelope terminal yet. Both remain recovery points if this + // process exits after persisting the intent but before the worker receives its signal. + job: current, + envelope: cancellationEnvelope + }; + }, { lockOptions: deadlineLockOptions(deadlineAt) }); + } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + throw error; + } if (!intentWrite.written) { return { cancelled: false, reason: reason || "not_cancellable", jobId }; } const configFile = resolveJobConfigFile(workspaceRoot, jobId); - if (pidToKill && isWorkerAlive(pidToKill)) { - if (!isExpectedWorker(pidToKill, configFile)) { + if (Number.isInteger(pidToKill) && pidToKill > 1 && isWorkerAlive(pidToKill)) { + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + if (!configForCleanup || isExpectedWorker(pidToKill, configFile, { deadlineAt }) !== true) { + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } return { cancelled: false, reason: "worker_identity_unverified", jobId }; } try { - await terminate(pidToKill, { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs != null && remainingMs <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + const terminatedWithinDeadline = await awaitWithinDeadline(terminate(pidToKill, { signal: "SIGINT", forceSignal: "SIGKILL", - forceAfterMs: 2e3 - }); + forceAfterMs: remainingMs == null ? 2e3 : Math.max(1, Math.min(2e3, Math.floor(remainingMs))), + deadlineAt + }), deadlineAt); + if (!terminatedWithinDeadline) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } return { cancelled: false, reason: "kill_failed", jobId, killWarning: error.message }; } if (isWorkerAlive(pidToKill)) { - const postSignalIdentity = isExpectedWorker(pidToKill, configFile); + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + const postSignalIdentity = isExpectedWorker(pidToKill, configFile, { deadlineAt }); + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } if (postSignalIdentity === true) { return { cancelled: false, reason: "worker_still_running", jobId }; } @@ -9061,55 +9935,69 @@ async function cancelJob(workspaceRoot, jobId, { } } let finalConfig = configForCleanup; - const finalWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { - if (!current) { - reason = "not_found"; - return null; - } - if (current.status === "cancelled") return null; - if (!ACTIVE_STATUSES2.has(current.status) || storedEnvelope?.job?.status !== "cancelled") { - reason = "cancellation_finalization_pending"; - return null; - } - finalConfig = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)) || finalConfig; - const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); - const finalJob = { - ...current, - ...storedEnvelope.job, - status: "cancelled", - pid: null, - finishedAt: storedEnvelope.job.finishedAt || finishedAt, - updatedAt: finishedAt - }; - const result = storedEnvelope.result || cancellationEnvelope?.result || { ok: false, error: "cancelled" }; - const terminal = prepareRecoveredTerminalEvents(workspaceRoot, finalJob, finalConfig, { - result, - reason: "cancelled", - terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null - }); - return { - job: finalJob, - envelope: { - ...storedEnvelope, - job: finalJob, - result, - terminalReason: "cancelled", - terminalDescriptor: terminal.terminalDescriptor - }, - beforeStateCommit() { - ensureRecoveredTerminalEvents(workspaceRoot, terminal); + let finalWrite; + try { + finalWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { + if (!current) { + reason = "not_found"; + return null; } - }; - }); + if (current.status === "cancelled") return null; + if (!ACTIVE_STATUSES2.has(current.status) || !hasPendingCancellationIntent(storedEnvelope)) { + reason = "cancellation_finalization_pending"; + return null; + } + finalConfig = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)) || finalConfig; + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); + const storedCancelledJob = storedEnvelope?.job?.status === "cancelled" ? storedEnvelope.job : null; + const finalJob = { + ...current, + ...storedCancelledJob || {}, + status: "cancelled", + pid: null, + finishedAt: storedCancelledJob?.finishedAt || finishedAt, + updatedAt: finishedAt + }; + const result = storedEnvelope.result || cancellationEnvelope?.result || { ok: false, error: "cancelled" }; + const terminal = prepareRecoveredTerminalEvents(workspaceRoot, finalJob, finalConfig, { + result, + reason: "cancelled", + terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null + }); + return { + job: finalJob, + envelope: { + ...storedEnvelope, + job: finalJob, + result, + terminalReason: "cancelled", + terminalDescriptor: terminal.terminalDescriptor, + cancellationIntent: storedEnvelope.cancellationIntent + }, + beforeStateCommit() { + ensureRecoveredTerminalEvents(workspaceRoot, terminal, { + lockOptions: deadlineLockOptions(deadlineAt) + }); + } + }; + }, { lockOptions: deadlineLockOptions(deadlineAt) }); + } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + throw error; + } if (!finalWrite.written) { const current = getJob(workspaceRoot, jobId); if (current?.status === "cancelled") { + removeJobStartFailureFile(workspaceRoot, jobId); return { cancelled: true, jobId }; } return { cancelled: false, reason: reason || "cancellation_finalization_pending", jobId }; } cleanupRuntimePaths(finalConfig); removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); return { cancelled: true, jobId }; } @@ -9271,7 +10159,7 @@ function resolveProvider({ provider, positionals = [] } = {}) { } // plugins/polycli/scripts/lib/review.mjs -import fs7 from "node:fs"; +import fs8 from "node:fs"; import os5 from "node:os"; import path6 from "node:path"; var DEFAULT_MAX_DIFF_BYTES = null; @@ -9331,7 +10219,7 @@ function git(cwd, args) { return runCommand("git", args, { cwd }); } function makeReviewTempDir(prefix) { - return fs7.mkdtempSync(path6.join(os5.tmpdir(), `polycli-review-${prefix}-`)); + return fs8.mkdtempSync(path6.join(os5.tmpdir(), `polycli-review-${prefix}-`)); } function assertNoReviewConstraintOverride(provider, runtimeOptions = {}) { const extraArgs = Array.isArray(runtimeOptions.extraArgs) ? runtimeOptions.extraArgs : []; @@ -9608,7 +10496,7 @@ function summarizeTimingRecords(records) { } // plugins/polycli/scripts/lib/preview.mjs -import fs8 from "node:fs"; +import fs9 from "node:fs"; var PREVIEW_MAX_LINES = 10; var PREVIEW_TAIL_CACHE = /* @__PURE__ */ new Map(); var PRIVATE_FILE_MODE4 = 384; @@ -9697,7 +10585,7 @@ function summarizeEventText(provider, event) { } return ""; } -function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PREVIEW_TAIL_CACHE } = {}) { +function appendPreview(logFile, provider, event, { fsImpl = fs9, tailCache = PREVIEW_TAIL_CACHE } = {}) { const text = summarizeEventText(provider, event); if (!text) return; const lines = String(text).split(/\r?\n/).map((line) => collapseWhitespace(line)).filter(Boolean).slice(0, PREVIEW_MAX_LINES); @@ -9708,9 +10596,9 @@ function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PRE } fsImpl.appendFileSync(logFile, `${lines.join("\n")} `, { encoding: "utf8", mode: PRIVATE_FILE_MODE4 }); - if (fsImpl === fs8) { + if (fsImpl === fs9) { try { - fs8.chmodSync(logFile, PRIVATE_FILE_MODE4); + fs9.chmodSync(logFile, PRIVATE_FILE_MODE4); } catch { } } @@ -9719,7 +10607,7 @@ function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PRE // plugins/polycli/scripts/polycli-companion.mjs var COMPANION_PATH = fileURLToPath(import.meta.url); -var BUILD_VERSION = true ? "0.6.30" : "0.0.0-dev"; +var BUILD_VERSION = true ? "0.6.31" : "0.0.0-dev"; var BUILD_VERSION_SOURCE = true ? "bundled-release" : "development"; var JOB_PREFIXES = { ask: "pa", @@ -9756,8 +10644,8 @@ function resolveTimeoutMs(provider, kind, { model = null, defaultModel = null } } var HEALTH_SENTINEL = "POLYCLI_HEALTH_OK"; var SESSION_ID_ENV = "POLYCLI_COMPANION_SESSION_ID"; -var TERMINAL_JOB_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); -var ACTIVE_JOB_STATUSES2 = /* @__PURE__ */ new Set(["queued", "running"]); +var TERMINAL_JOB_STATUSES3 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); +var ACTIVE_JOB_STATUSES3 = /* @__PURE__ */ new Set(["queued", "running"]); var RUN_CONTEXT = { invocationId: null, runId: null, @@ -9821,21 +10709,24 @@ function ensureTerminalRunEventsForContext(workspaceRoot, prepared) { return prepared.events.length > 0 ? ensureRunLedgerTerminalPair(workspaceRoot, prepared.events) : []; } function hasTerminalJobEnvelope(envelope) { - return TERMINAL_JOB_STATUSES2.has(envelope?.job?.status); + return TERMINAL_JOB_STATUSES3.has(envelope?.job?.status); +} +function blocksBackgroundWorkerCommit(envelope) { + return hasTerminalJobEnvelope(envelope) || hasPendingCancellationIntent(envelope); } function claimBackgroundWorker(workspaceRoot, jobId) { const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || !ACTIVE_JOB_STATUSES3.has(latest.status) || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } - if (latest.pid != null && latest.pid !== process5.pid) { + if (latest.pid != null && latest.pid !== process6.pid) { return null; } return { job: { ...latest, status: "running", - pid: process5.pid, + pid: process6.pid, updatedAt: (/* @__PURE__ */ new Date()).toISOString() } }; @@ -9846,68 +10737,6 @@ function shouldRetainJobConfig(workspaceRoot, jobId) { const current = getJob(workspaceRoot, jobId); return current?.status === "queued" || current?.status === "running"; } -function recordBackgroundSpawnFailure(workspaceRoot, jobId, execution, runContext, error) { - const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); - try { - const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || hasTerminalJobEnvelope(storedEnvelope)) { - return null; - } - const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); - const failedJob = { - ...latest, - ...execution.jobMeta, - status: "failed", - pid: null, - finishedAt, - updatedAt: finishedAt, - error: error.message - }; - const terminalReason = `${execution.kind}_failed`; - const terminalEvents = [ - { - command: runContext?.command || execution.kind, - kind: execution.kind, - provider: execution.provider, - phase: "attempt_result", - status: "failed", - reason: terminalReason, - attempt: { ordinal: 1 }, - jobId, - error: { message: String(error.message || error).slice(0, 300) }, - logFile: failedJob.logFile || null - }, - { - command: runContext?.command || execution.kind, - kind: execution.kind, - provider: execution.provider, - phase: "provider_decision", - status: "failed", - reason: terminalReason, - jobId - } - ]; - const terminal = prepareTerminalRunEventsForContext(runContext, terminalEvents); - return { - job: failedJob, - envelope: { - job: failedJob, - result: { ok: false, error: error.message }, - terminalReason, - terminalDescriptor: terminal.terminalDescriptor - }, - beforeStateCommit() { - ensureTerminalRunEventsForContext(workspaceRoot, terminal); - } - }; - }); - if (write.written) { - cleanupRuntimeOptions(config?.execution?.runtimeOptions); - removeJobConfigFile(workspaceRoot, jobId); - } - } catch { - } -} async function recordRunEvent(workspaceRoot, base = {}) { return recordRunEventForContext(workspaceRoot, buildCurrentRunContext(), base); } @@ -10029,9 +10858,9 @@ function assertPreDispatchReviewSafety(definition2, parsed) { function exitWithError(error) { const typed = toTypedCliError(error); if (RUN_CONTEXT.outputMode === "json-v2") { - process5.stdout.write(`${JSON.stringify(createV2ErrorEnvelope(typed, { + process6.stdout.write(`${JSON.stringify(createV2ErrorEnvelope(typed, { invocationId: RUN_CONTEXT.invocationId, - command: RUN_CONTEXT.command || process5.argv[2] || "", + command: RUN_CONTEXT.command || process6.argv[2] || "", hostSurface: RUN_CONTEXT.hostSurface, workspaceSlug: RUN_CONTEXT.workspaceSlug, runId: RUN_CONTEXT.runId, @@ -10041,19 +10870,19 @@ function exitWithError(error) { } else if (RUN_CONTEXT.outputMode === "legacy-json") { const code = error?.legacyCode || error?.code || classifyErrorCode(error?.message || ""); const legacyCode = code === "unknown_command" ? "unknown_subcommand" : code; - process5.stdout.write(`${JSON.stringify({ error: typed.message, code: legacyCode }, null, 2)} + process6.stdout.write(`${JSON.stringify({ error: typed.message, code: legacyCode }, null, 2)} `); } else { - process5.stderr.write(`Error: ${typed.message} + process6.stderr.write(`Error: ${typed.message} `); for (const suggestion of typed.data?.suggestions || []) { - process5.stderr.write(`Suggestion: ${suggestion} + process6.stderr.write(`Suggestion: ${suggestion} `); } - for (const step of typed.nextSteps) process5.stderr.write(`${step} + for (const step of typed.nextSteps) process6.stderr.write(`${step} `); } - process5.exitCode = typed.exitCode || error?.exitCode || 1; + process6.exitCode = typed.exitCode || error?.exitCode || 1; } function output(value, asJson) { if (RUN_CONTEXT.outputMode === "json-v2") { @@ -10069,18 +10898,18 @@ function output(value, asJson) { runId: RUN_CONTEXT.runId, jobId: result.job?.jobId ?? result.jobId ?? null }); - process5.stdout.write(`${JSON.stringify(envelope, null, 2)} + process6.stdout.write(`${JSON.stringify(envelope, null, 2)} `); RUN_CONTEXT.authoritativeJsonWritten = true; return; } if (asJson) { - process5.stdout.write(`${JSON.stringify(value, null, 2)} + process6.stdout.write(`${JSON.stringify(value, null, 2)} `); RUN_CONTEXT.authoritativeJsonWritten = true; return; } - process5.stdout.write(typeof value === "string" ? `${value} + process6.stdout.write(typeof value === "string" ? `${value} ` : `${JSON.stringify(value, null, 2)} `); } @@ -10149,7 +10978,7 @@ function resolveProviderModelCacheFile(workspaceRoot) { } function readProviderModelCache(workspaceRoot) { try { - const parsed = JSON.parse(fs9.readFileSync(resolveProviderModelCacheFile(workspaceRoot), "utf8")); + const parsed = JSON.parse(fs10.readFileSync(resolveProviderModelCacheFile(workspaceRoot), "utf8")); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; @@ -10162,7 +10991,7 @@ function readCachedProviderModel(workspaceRoot, provider) { function cacheProviderModel(workspaceRoot, provider, model) { if (typeof model !== "string" || !model.trim()) return; const cacheFile = resolveProviderModelCacheFile(workspaceRoot); - fs9.mkdirSync(path8.dirname(cacheFile), { recursive: true, mode: 448 }); + fs10.mkdirSync(path8.dirname(cacheFile), { recursive: true, mode: 448 }); withLockfile(`${cacheFile}.lock`, () => { writeJsonAtomic(cacheFile, { ...readProviderModelCache(workspaceRoot), [provider]: model }, { mode: 384 }); }); @@ -10188,11 +11017,11 @@ function skippedAuthDetail({ available, authProbeCost }) { } async function inspectProvider(provider, { probeAuth = false } = {}) { const runtime = getProviderRuntime(provider); - const availability = await Promise.resolve(runtime.getAvailability(process5.cwd())); + const availability = await Promise.resolve(runtime.getAvailability(process6.cwd())); const available = availability.available === true; const authProbeCost = normalizeAuthProbeCost(runtime); const authChecked = available && (probeAuth || authProbeCost === "status"); - const auth = authChecked ? await Promise.resolve(runtime.getAuthStatus(process5.cwd())) : null; + const auth = authChecked ? await Promise.resolve(runtime.getAuthStatus(process6.cwd())) : null; const row = { provider, available, @@ -10205,7 +11034,7 @@ async function inspectProvider(provider, { probeAuth = false } = {}) { model: auth?.model ?? null, capabilities: runtime.capabilities }; - cacheProviderModel(resolveWorkspaceRoot(process5.cwd()), provider, row.model); + cacheProviderModel(resolveWorkspaceRoot(process6.cwd()), provider, row.model); return row; } async function inspectSetupProvider(provider, { probeAuth, workspaceRoot }) { @@ -10277,7 +11106,7 @@ async function inspectSetupProvider(provider, { probeAuth, workspaceRoot }) { } async function inspectProviderAvailability(provider) { const runtime = getProviderRuntime(provider); - const availability = await Promise.resolve(runtime.getAvailability(process5.cwd())); + const availability = await Promise.resolve(runtime.getAvailability(process6.cwd())); const authProbeCost = normalizeAuthProbeCost(runtime); return { provider, @@ -10306,14 +11135,14 @@ function parseExecutionMode(options) { } function emitNote(line) { if (!line) return; - process5.stderr.write(`${line} + process6.stderr.write(`${line} `); } function emitRuntimeWarnings(result = {}) { if (!Array.isArray(result.warnings)) return; for (const warning of result.warnings) { if (typeof warning === "string" && warning.trim()) { - process5.stderr.write(`${warning.trim()} + process6.stderr.write(`${warning.trim()} `); } } @@ -10424,12 +11253,12 @@ function compactProviderResult(result = {}) { } return compact; } -function cleanupRuntimeOptions(runtimeOptions = {}) { +function cleanupRuntimeOptions2(runtimeOptions = {}) { const cleanupPaths = Array.isArray(runtimeOptions.cleanupPaths) ? runtimeOptions.cleanupPaths : []; for (const cleanupPath of cleanupPaths) { if (typeof cleanupPath !== "string" || cleanupPath.trim() === "") continue; try { - fs9.rmSync(cleanupPath, { recursive: true, force: true }); + fs10.rmSync(cleanupPath, { recursive: true, force: true }); } catch { } } @@ -10440,7 +11269,7 @@ function hydrateRuntimeOptions(runtimeOptions = {}) { } return { ...runtimeOptions, - env: { ...process5.env, ...runtimeOptions.env } + env: { ...process6.env, ...runtimeOptions.env } }; } async function runForegroundExecution(execution, asJson) { @@ -10494,7 +11323,7 @@ async function runForegroundExecution(execution, asJson) { } catch (error) { executionError = error; } finally { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } const resultOk = Boolean(result?.ok) && !executionError; const publicError = executionError?.message || result?.error || null; @@ -10561,9 +11390,9 @@ async function runForegroundExecution(execution, asJson) { return; } if (!result.ok) { - process5.stderr.write(`Error: ${String(result.error || `${execution.provider} ${execution.kind} failed`).slice(0, 500)} + process6.stderr.write(`Error: ${String(result.error || `${execution.provider} ${execution.kind} failed`).slice(0, 500)} `); - process5.exitCode = 1; + process6.exitCode = 1; return; } const lines = []; @@ -10590,7 +11419,7 @@ function buildQueuedJob(execution, workspaceRoot, attemptId) { updatedAt: now, invocationId: RUN_CONTEXT.invocationId, attemptId, - hostSessionId: process5.env[SESSION_ID_ENV] || null, + hostSessionId: process6.env[SESSION_ID_ENV] || null, providerSessionId: null, ...execution.jobMeta }; @@ -10736,7 +11565,7 @@ async function startBackgroundExecution(execution, asJson) { defaultModel: execution.defaultModel || null, logFile: job.logFile }); - writeJobConfigFile(workspaceRoot, job.jobId, { + const config = { workspaceRoot, hostSessionId: job.hostSessionId, execution: { @@ -10750,24 +11579,16 @@ async function startBackgroundExecution(execution, asJson) { }, jobId: job.jobId, runContext + }; + const { child } = await startBackgroundWorker({ + workspaceRoot, + job, + execution, + runContext, + config, + companionPath: COMPANION_PATH, + env: process6.env }); - fs9.writeFileSync(job.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] started ${job.provider} ${job.kind} -`, { - encoding: "utf8", - mode: 384 - }); - const logFd = fs9.openSync(job.logFile, "a", 384); - const child = spawn2(process5.execPath, [COMPANION_PATH, "_job-worker", resolveJobConfigFile(workspaceRoot, job.jobId)], { - cwd: execution.cwd, - env: { ...process5.env }, - stdio: ["ignore", logFd, logFd], - detached: true - }); - child.once("error", (error) => { - recordBackgroundSpawnFailure(workspaceRoot, job.jobId, execution, runContext, error); - }); - child.unref(); - fs9.closeSync(logFd); const runningWrite = updateJobAtomically(workspaceRoot, job.jobId, (latest) => { if (!latest || latest.status !== "queued") return null; return { @@ -10780,10 +11601,10 @@ async function startBackgroundExecution(execution, asJson) { }; }); const runningJob = runningWrite.written ? runningWrite.job : getJob(workspaceRoot, job.jobId) || job; - if (!ACTIVE_JOB_STATUSES2.has(runningJob.status)) { + if (!ACTIVE_JOB_STATUSES3.has(runningJob.status)) { removeJobConfigFile(workspaceRoot, job.jobId); } - if (runContext && ACTIVE_JOB_STATUSES2.has(runningJob.status)) { + if (runContext && ACTIVE_JOB_STATUSES3.has(runningJob.status)) { await recordRunEventForContext(workspaceRoot, runContext, { command: execution.kind, kind: execution.kind, @@ -10811,7 +11632,7 @@ async function runSetup(rawArgs) { if (options["enable-review-gate"] && options["disable-review-gate"]) { throw new Error("Choose either --enable-review-gate or --disable-review-gate, not both."); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); if (options["enable-review-gate"]) { setConfig(workspaceRoot, "stopReviewGate", true); } else if (options["disable-review-gate"]) { @@ -10899,7 +11720,7 @@ async function probeProviderHealth({ report.probe.error = inspection.availabilityDetail || "provider CLI is unavailable"; } else if (provider === "claude") { try { - const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process5.cwd())); + const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process6.cwd())); report.loggedIn = auth.loggedIn ?? false; report.authDetail = auth.detail ?? auth.reason ?? null; report.model = auth.model ?? report.model; @@ -10924,7 +11745,7 @@ async function probeProviderHealth({ prompt: `Reply with ${HEALTH_SENTINEL} only.`, model, defaultModel: model ? null : readCachedProviderModel(workspaceRoot, provider), - cwd: process5.cwd(), + cwd: process6.cwd(), timeout, kind: "health", measurementScope: "request", @@ -10996,7 +11817,7 @@ async function runHealth(rawArgs) { valueOptions: ["provider", "model", "timeout-ms"], aliasMap: { m: "model" } }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const timeoutMs = options["timeout-ms"] ? Number.parseInt(options["timeout-ms"], 10) : TIMEOUTS_MS.health; const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : TIMEOUTS_MS.health; const hasSingleProvider = Boolean(options.provider || positionals[0]); @@ -11084,7 +11905,7 @@ async function runHealth(rawArgs) { })); const payload = buildHealthPayload(results); if (!payload.anyHealthy) { - process5.exitCode = 2; + process6.exitCode = 2; } output( options.json ? payload : [ @@ -11106,7 +11927,7 @@ function parsePromptExecution(rawArgs, kind) { positionals }); validateEffort(options.effort); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const userPrompt = remainingPositionals.join(" ").trim(); if (!userPrompt) { throw new Error(`Missing prompt text for ${kind}.`); @@ -11123,7 +11944,7 @@ function parsePromptExecution(rawArgs, kind) { userPrompt, model: options.model || null, defaultModel: cachedDefaultModel, - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, kind, { model: options.model || null, defaultModel: cachedDefaultModel @@ -11173,7 +11994,7 @@ function buildStopReviewGateExecution(rawArgs) { if (!prompt) { throw new Error("Missing prompt text for stop-review-gate."); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const defaultModel = readCachedProviderModel(workspaceRoot, provider); return { options, @@ -11188,14 +12009,14 @@ function buildStopReviewGateExecution(rawArgs) { userPrompt: "stop-time review gate", model: null, defaultModel, - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, "review", { defaultModel }), meta: { stopReviewGate: true }, jobMeta: {}, measurementScope: "request", runtimeOptions: buildReviewRuntimeOptions({ provider, - cwd: process5.cwd() + cwd: process6.cwd() }) } }; @@ -11215,11 +12036,11 @@ function buildReviewExecution(rawArgs, { adversarial }) { positionals }); assertReviewProviderSupported(provider); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const focus = remainingPositionals.join(" ").trim(); const maxDiffBytes = parseMaxDiffBytes(options["max-diff-bytes"]); const reviewContext = collectReviewContext({ - cwd: process5.cwd(), + cwd: process6.cwd(), scope: options.scope, baseRef: options.base || null, maxDiffBytes @@ -11256,7 +12077,7 @@ function buildReviewExecution(rawArgs, { adversarial }) { userPrompt: focus || `${adversarial ? "adversarial " : ""}review ${reviewContext.scope}`, model: options.model || null, defaultModel: readCachedProviderModel(workspaceRoot, provider), - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, adversarial ? "adversarial-review" : "review", { model: options.model || null, defaultModel: readCachedProviderModel(workspaceRoot, provider) @@ -11277,7 +12098,7 @@ function buildReviewExecution(rawArgs, { adversarial }) { measurementScope: "request", runtimeOptions: buildReviewRuntimeOptions({ provider, - cwd: process5.cwd() + cwd: process6.cwd() }) } }; @@ -11305,7 +12126,7 @@ async function runReviewCommand(rawArgs, { adversarial }) { ); return; } finally { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } } const { background } = parseExecutionMode(options); @@ -11320,7 +12141,7 @@ async function runStatus(rawArgs) { booleanOptions: ["json", "all", "wait"], valueOptions: ["job", "for", "timeout-ms"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const reference = options.job || positionals[0] || null; const selectorOptions = options.job ? { grammar: "explicit" } : void 0; const timeoutMs = options.wait ? parseStatusTimeoutMs(options["timeout-ms"]) : void 0; @@ -11329,7 +12150,7 @@ async function runStatus(rawArgs) { timeoutMs }); if (waited.waitTimedOut) { - process5.exitCode = 2; + process6.exitCode = 2; } if (options.json) { output(waited, true); @@ -11345,7 +12166,7 @@ async function runStatus(rawArgs) { for: options.for || "terminal" }); if (waited.waitTimedOut) { - process5.exitCode = 2; + process6.exitCode = 2; } if (waited.error && RUN_CONTEXT.outputMode === "json-v2") { throw cliError("job_not_found", `Job '${target.jobId}' was not found while waiting.`, { @@ -11386,7 +12207,7 @@ async function runResult(rawArgs) { booleanOptions: ["json"], valueOptions: ["job"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const job = resolveJobSelector( workspaceRoot, options.job || positionals[0] || "latest-terminal", @@ -11414,7 +12235,7 @@ async function runCancel(rawArgs) { booleanOptions: ["json"], valueOptions: ["job"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const selector = options.job || positionals[0] || "latest-active"; let job; try { @@ -11423,11 +12244,11 @@ async function runCancel(rawArgs) { if (RUN_CONTEXT.outputMode === "json-v2" || error.code === "ambiguous_selector") throw error; if (options.json) { output({ cancelled: false, reason: "not_found", jobId: positionals[0] || null }, true); - process5.exitCode = 1; + process6.exitCode = 1; return; } output(positionals[0] ? `Job ${positionals[0]} not found.` : "No active job found to cancel.", false); - process5.exitCode = 1; + process6.exitCode = 1; return; } const report = await cancelJob(workspaceRoot, job.jobId); @@ -11438,7 +12259,7 @@ async function runCancel(rawArgs) { } if (report.reason === "not_cancellable") { output(report, true); - process5.exitCode = 4; + process6.exitCode = 4; return; } throw new PolycliCliError({ @@ -11454,10 +12275,10 @@ async function runCancel(rawArgs) { output(`Cancelled job ${report.jobId}.`, false); } else if (report.reason === "not_cancellable") { output(`Job ${report.jobId} is already ${job.status}.`, false); - process5.exitCode = 4; + process6.exitCode = 4; } else { output(`Failed to cancel ${report.jobId}: ${report.error || report.reason}`, false); - process5.exitCode = 5; + process6.exitCode = 5; } } function formatMetric(metric) { @@ -11523,7 +12344,7 @@ async function runTiming(rawArgs) { booleanOptions: ["all", "json"], valueOptions: ["provider", "history"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const provider = options.provider ? resolveProvider({ provider: options.provider }).provider : null; const limit = parseHistoryLimit(options.history, { all: options.all }); const records = listTimingRecords(workspaceRoot, { @@ -11557,6 +12378,10 @@ async function runJobWorker(rawArgs) { const { workspaceRoot, execution, jobId, runContext } = payload; const current = claimBackgroundWorker(workspaceRoot, jobId); if (!current) { + if (!shouldRetainJobConfig(workspaceRoot, jobId)) { + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } return; } if (runContext?.runId) { @@ -11590,10 +12415,6 @@ async function runJobWorker(rawArgs) { appendPreview(current.logFile, execution.provider, event); } }); - if (result.timing) { - appendTimingRecord(workspaceRoot, result.timing); - } - cacheProviderModel(workspaceRoot, execution.provider, result.model); const compactResult = compactProviderResult(result); const sessionArtifactPath = resolveSessionArtifactPath( execution.provider, @@ -11601,9 +12422,13 @@ async function runJobWorker(rawArgs) { execution.cwd ); const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || latest.status === "cancelled" || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || latest.status === "cancelled" || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } + if (result.timing) { + appendTimingRecord(workspaceRoot, result.timing); + } + cacheProviderModel(workspaceRoot, execution.provider, result.model); const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); const finishedJob = { ...latest, @@ -11669,19 +12494,22 @@ async function runJobWorker(rawArgs) { // intent instead of exposing a terminal state with only half of its ledger pair. beforeStateCommit() { ensureTerminalRunEventsForContext(workspaceRoot, terminal); + removeJobStartFailureFile(workspaceRoot, jobId); } }; }); if (!write.written) { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } return; } removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } catch (error) { const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || latest.status === "cancelled" || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || latest.status === "cancelled" || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); @@ -11730,20 +12558,23 @@ async function runJobWorker(rawArgs) { }, beforeStateCommit() { ensureTerminalRunEventsForContext(workspaceRoot, terminal); + removeJobStartFailureFile(workspaceRoot, jobId); } }; }); if (!write.written) { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } return; } removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); throw error; } finally { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } } } @@ -11761,7 +12592,7 @@ function formatDebugRunsTable(runs) { return lines.join("\n"); } async function readDebugLedger({ raw = false } = {}) { - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); refreshJobsForLedgerRecovery(workspaceRoot); const events = await readRunLedgerEvents(workspaceRoot, { raw }); return { workspaceRoot, events }; @@ -11771,7 +12602,7 @@ async function runDebugTail(rawArgs) { booleanOptions: ["json", "wait"], valueOptions: ["after", "limit", "timeout-ms"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const result = await tailRunLedgerEvents(workspaceRoot, { runId: positionals[0] || null, after: options.after || null, @@ -11779,7 +12610,7 @@ async function runDebugTail(rawArgs) { wait: Boolean(options.wait), timeoutMs: options["timeout-ms"] == null ? void 0 : Number(options["timeout-ms"]) }); - if (result.waitTimedOut) process5.exitCode = 2; + if (result.waitTimedOut) process6.exitCode = 2; if (options.json) { output(result, true); return; @@ -11833,11 +12664,11 @@ function renderSessionsList(recorded, nonPurgeable = []) { } else { lines.push("Recorded upstream sessions (this workspace):"); for (const rec of recorded) { - const exists = fs9.existsSync(rec.sessionArtifactPath); + const exists = fs10.existsSync(rec.sessionArtifactPath); let size = ""; if (exists) { try { - size = ` ${formatBytes(fs9.lstatSync(rec.sessionArtifactPath).size)}`; + size = ` ${formatBytes(fs10.lstatSync(rec.sessionArtifactPath).size)}`; } catch { size = ""; } @@ -11875,7 +12706,7 @@ function renderPurgePlan(plan, summary, nonPurgeable = []) { return lines.join("\n"); } async function readSessionLedger() { - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const events = await readRunLedgerEvents(workspaceRoot); const recorded = collectRecordedArtifacts(events); const nonPurgeable = collectNonPurgeableSessions(events); @@ -11954,10 +12785,10 @@ function commandResolutionError(fullArgs, resolution, hostSurface) { ); } async function main() { - const fullArgs = process5.argv.slice(2); + const fullArgs = process6.argv.slice(2); RUN_CONTEXT.invocationId = `inv_${randomUUID3().replaceAll("-", "").slice(0, 20)}`; RUN_CONTEXT.authoritativeJsonWritten = false; - RUN_CONTEXT.hostSurface = resolveHostSurface(process5.env, import.meta.url); + RUN_CONTEXT.hostSurface = resolveHostSurface(process6.env, import.meta.url); RUN_CONTEXT.rawArgs = fullArgs; const outputModes = scanOutputModes(fullArgs); const command = fullArgs[0]; @@ -12002,12 +12833,12 @@ async function main() { } if (definition2.runTracked) { try { - RUN_CONTEXT.runId = resolveRunId({ runId: explicitRunId }, process5.env); + RUN_CONTEXT.runId = resolveRunId({ runId: explicitRunId }, process6.env); } catch (error) { if (!/^Invalid run id:/.test(error?.message || "")) throw error; const typed = cliError("invalid_argument", error.message, { argument: "--run-id", - value: explicitRunId || process5.env.POLYCLI_RUN_ID || null + value: explicitRunId || process6.env.POLYCLI_RUN_ID || null }); typed.legacyCode = "error"; throw typed; @@ -12015,15 +12846,15 @@ async function main() { } else { RUN_CONTEXT.runId = null; } - RUN_CONTEXT.workspaceSlug = computeWorkspaceSlug(resolveWorkspaceRoot(process5.cwd())); + RUN_CONTEXT.workspaceSlug = computeWorkspaceSlug(resolveWorkspaceRoot(process6.cwd())); if (!RUN_CONTEXT.runId) { return dispatchCommand(definition2.id, rawArgs); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); await recordRunEvent(workspaceRoot, { phase: "run_started", status: "started" }); try { const result = await dispatchCommand(definition2.id, rawArgs); - const failed = process5.exitCode != null && process5.exitCode !== 0; + const failed = process6.exitCode != null && process6.exitCode !== 0; try { await recordRunEvent(workspaceRoot, { phase: "run_summary", diff --git a/packages/polycli-terminal/lib/command-surface.generated.mjs b/packages/polycli-terminal/lib/command-surface.generated.mjs index 34742fd..36f77d5 100644 --- a/packages/polycli-terminal/lib/command-surface.generated.mjs +++ b/packages/polycli-terminal/lib/command-surface.generated.mjs @@ -3847,14 +3847,14 @@ export const TERMINAL_COMMAND_DEFINITIONS = deepFreeze([ "dispatchTarget": "terminal-wrapper", "executable": true, "defaultSubcommand": null, - "summary": "Open the read-only terminal run inspector.", + "summary": "Open the terminal run inspector; loading may update local recovery state.", "usage": "polycli tui [--run-id ] [--history ]", "argumentMode": "options", "runTracked": false, "effects": { "providerInvocation": false, "readsWorkspace": true, - "writesLocalState": false, + "writesLocalState": true, "destructive": false }, "options": [ diff --git a/packages/polycli-terminal/package.json b/packages/polycli-terminal/package.json index 67db0a0..102f899 100644 --- a/packages/polycli-terminal/package.json +++ b/packages/polycli-terminal/package.json @@ -1,6 +1,6 @@ { "name": "@bbingz/polycli", - "version": "0.6.30", + "version": "0.6.31", "description": "Terminal CLI for Polycli provider diagnostics and host-compatible commands.", "type": "module", "bin": { @@ -18,7 +18,7 @@ "node": ">=20" }, "dependencies": { - "@bbingz/polycli-utils": "1.0.4" + "@bbingz/polycli-utils": "1.0.5" }, "license": "MIT" } diff --git a/packages/polycli-utils/package.json b/packages/polycli-utils/package.json index 24af2a4..32780ad 100644 --- a/packages/polycli-utils/package.json +++ b/packages/polycli-utils/package.json @@ -1,6 +1,6 @@ { "name": "@bbingz/polycli-utils", - "version": "1.0.4", + "version": "1.0.5", "description": "Low-semantic-risk utilities shared by polycli provider runtimes: args parsing, process execution, stream decoding, NDJSON I/O, atomic save with lockfile, session-id matching, and stream JSON parsing.", "license": "MIT", "homepage": "https://github.com/bbingz/polycli/tree/main/packages/polycli-utils", diff --git a/packages/polycli-utils/src/process.js b/packages/polycli-utils/src/process.js index 3478511..04db88f 100644 --- a/packages/polycli-utils/src/process.js +++ b/packages/polycli-utils/src/process.js @@ -1,8 +1,108 @@ import { spawnSync } from "node:child_process"; import process from "node:process"; +const CONSERVATIVE_POINTER_BYTES = 8; +const WINDOWS_SAFE_ARGV_BUDGET_BYTES = 24 * 1024; +const POSIX_SAFE_ARGV_BUDGET_BYTES = 96 * 1024; + +function stringStorageBytes(value) { + return Buffer.byteLength(String(value), "utf8") + 1; +} + +export function getSafeArgvBudgetBytes(platform = process.platform) { + // These are application safety gates, not claims about the operating system's ARG_MAX. + // The lower Windows budget reflects its smaller effective command-line envelope. + return platform === "win32" + ? WINDOWS_SAFE_ARGV_BUDGET_BYTES + : POSIX_SAFE_ARGV_BUDGET_BYTES; +} + +export function calculateArgvFootprint({ + command, + args = [], + env = process.env, +} = {}) { + const argv = [String(command ?? ""), ...args.map((arg) => String(arg))]; + const envEntries = Object.entries(env ?? {}) + .filter(([, value]) => value != null) + .map(([key, value]) => `${key}=${String(value)}`); + const argvBytes = argv.reduce((total, value) => total + stringStorageBytes(value), 0); + const envBytes = envEntries.reduce((total, value) => total + stringStorageBytes(value), 0); + // Account for argv/envp pointers plus each terminating null pointer. Eight bytes is + // deliberately conservative for the supported 64-bit Node.js environments. + const pointerBytes = (argv.length + envEntries.length + 2) * CONSERVATIVE_POINTER_BYTES; + const stringBytes = argvBytes + envBytes; + return { + totalBytes: stringBytes + pointerBytes, + stringBytes, + pointerBytes, + argvBytes, + envBytes, + argvCount: argv.length, + envCount: envEntries.length, + }; +} + +export function preflightArgv(command, args = [], { + env = process.env, + argvBudgetBytes = null, + argvBudgetHint = null, +} = {}) { + const footprint = calculateArgvFootprint({ command, args, env }); + if (argvBudgetBytes == null) { + return { ok: true, budgetBytes: null, footprint, error: null }; + } + if (!Number.isSafeInteger(argvBudgetBytes) || argvBudgetBytes < 0) { + throw new TypeError("argvBudgetBytes must be a non-negative safe integer or null"); + } + if (footprint.totalBytes <= argvBudgetBytes) { + return { ok: true, budgetBytes: argvBudgetBytes, footprint, error: null }; + } + + const counts = [ + `footprintBytes=${footprint.totalBytes}`, + `budgetBytes=${argvBudgetBytes}`, + `argvCount=${footprint.argvCount}`, + `envCount=${footprint.envCount}`, + ].join(", "); + const suffix = typeof argvBudgetHint === "string" && argvBudgetHint.trim() + ? ` ${argvBudgetHint.trim()}` + : ""; + const error = Object.assign( + new Error(`argument list too long for the configured safe argv budget (${counts}).${suffix}`), + { + code: "E2BIG", + footprintBytes: footprint.totalBytes, + budgetBytes: argvBudgetBytes, + argvCount: footprint.argvCount, + envCount: footprint.envCount, + } + ); + return { ok: false, budgetBytes: argvBudgetBytes, footprint, error }; +} + export function runCommand(command, args = [], options = {}) { - const result = spawnSync(command, args, { + const effectiveEnv = options.env ?? process.env; + const preflight = preflightArgv(command, args, { + env: effectiveEnv, + argvBudgetBytes: options.argvBudgetBytes ?? null, + argvBudgetHint: options.argvBudgetHint ?? null, + }); + if (!preflight.ok) { + return { + command, + args, + status: null, + signal: null, + stdout: "", + stderr: "", + error: preflight.error, + spawnErrorCode: "E2BIG", + }; + } + + const spawnImpl = options.spawnImpl ?? spawnSync; + const result = spawnImpl(command, args, { cwd: options.cwd, env: options.env, encoding: "utf8", @@ -35,6 +135,7 @@ export function runCommand(command, args = [], options = {}) { stdout: result.stdout ?? "", stderr: result.stderr ?? "", error, + spawnErrorCode: typeof error?.code === "string" ? error.code : null, }; } @@ -94,19 +195,46 @@ export function formatCommandFailure(result) { export async function terminateProcessTree( pid, - { signal = "SIGTERM", forceSignal = "SIGKILL", forceAfterMs = 5_000, ignoreMissing = true } = {} + { + signal = "SIGTERM", + forceSignal = "SIGKILL", + forceAfterMs = 5_000, + ignoreMissing = true, + deadlineAt = null, + platform = process.platform, + runCommandImpl = runCommand, + now = Date.now, + sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)), + } = {} ) { if (!Number.isInteger(pid) || pid <= 1) { throw new Error(`Invalid pid: ${pid}`); } + if (deadlineAt != null && !Number.isFinite(deadlineAt)) { + throw new TypeError("deadlineAt must be a finite epoch millisecond value or null"); + } + + const remainingDeadlineMs = () => { + if (deadlineAt == null) return null; + const remainingMs = Math.floor(deadlineAt - now()); + if (remainingMs <= 0) { + const error = new Error("process termination deadline exceeded"); + error.code = "EDEADLINE"; + throw error; + } + return remainingMs; + }; const killOnce = (targetSignal) => { - if (process.platform === "win32") { + const remainingMs = remainingDeadlineMs(); + if (platform === "win32") { const args = ["/PID", String(pid), "/T"]; if (targetSignal === "SIGKILL") { args.push("/F"); } - const result = runCommand("taskkill", args); + const result = runCommandImpl("taskkill", args, remainingMs == null + ? {} + : { timeout: remainingMs }); if (result.error) { if (ignoreMissing && result.error.code === "ESRCH") return false; throw result.error; @@ -149,7 +277,11 @@ export async function terminateProcessTree( return terminated; } - await new Promise((resolve) => setTimeout(resolve, forceAfterMs)); + const remainingBeforeWait = deadlineAt == null ? null : remainingDeadlineMs(); + const waitMs = remainingBeforeWait == null + ? forceAfterMs + : Math.min(forceAfterMs, remainingBeforeWait); + await sleep(waitMs); try { killOnce(forceSignal); } catch (error) { diff --git a/packages/polycli-utils/src/session-id.js b/packages/polycli-utils/src/session-id.js index b5e3fb4..5b6cfde 100644 --- a/packages/polycli-utils/src/session-id.js +++ b/packages/polycli-utils/src/session-id.js @@ -1,6 +1,22 @@ export const UUID_SESSION_ID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i; +const RESUME_SESSION_ID_LINE_REGEX = new RegExp( + `^\\s*resume\\s+(${UUID_SESSION_ID_REGEX.source})\\s*$`, + "i" +); + +export function matchResumeSessionIdLine(text) { + if (typeof text !== "string" || text.length === 0) { + return null; + } + for (const line of text.split(/\r?\n/)) { + const match = line.match(RESUME_SESSION_ID_LINE_REGEX); + if (match) return match[1]; + } + return null; +} + export function matchSessionId(text, { patterns = [UUID_SESSION_ID_REGEX] } = {}) { if (typeof text !== "string" || text.length === 0) { return null; diff --git a/packages/polycli-utils/test/exports.test.js b/packages/polycli-utils/test/exports.test.js index 6903ba3..44a10e5 100644 --- a/packages/polycli-utils/test/exports.test.js +++ b/packages/polycli-utils/test/exports.test.js @@ -10,13 +10,17 @@ test("utils index exports expected surface", () => { "appendNdjson", "appendNdjsonBatch", "binaryAvailable", + "calculateArgvFootprint", "createLineDecoder", "ensureParentDir", "formatCommandFailure", + "getSafeArgvBudgetBytes", + "matchResumeSessionIdLine", "matchSessionId", "parseArgs", "parseStreamJsonLine", "parseStreamJsonText", + "preflightArgv", "readNdjson", "resolveSessionId", "runCommand", diff --git a/packages/polycli-utils/test/process.test.js b/packages/polycli-utils/test/process.test.js index 775f282..a1c72ff 100644 --- a/packages/polycli-utils/test/process.test.js +++ b/packages/polycli-utils/test/process.test.js @@ -3,7 +3,81 @@ import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { binaryAvailable, formatCommandFailure, runCommand, terminateProcessTree } from "../src/process.js"; +import { + binaryAvailable, + calculateArgvFootprint, + formatCommandFailure, + getSafeArgvBudgetBytes, + runCommand, + terminateProcessTree, +} from "../src/process.js"; + +test("calculateArgvFootprint counts UTF-8 strings, NUL terminators, and conservative pointers", () => { + assert.deepEqual( + calculateArgvFootprint({ command: "x", args: ["a", "bc"], env: { A: "1" } }), + { + totalBytes: 59, + stringBytes: 11, + pointerBytes: 48, + argvBytes: 7, + envBytes: 4, + argvCount: 3, + envCount: 1, + } + ); + + assert.deepEqual( + calculateArgvFootprint({ command: "你", args: ["好"], env: { "键": "值" } }), + { + totalBytes: 56, + stringBytes: 16, + pointerBytes: 40, + argvBytes: 8, + envBytes: 8, + argvCount: 2, + envCount: 1, + } + ); +}); + +test("getSafeArgvBudgetBytes uses conservative application budgets by platform", () => { + assert.equal(getSafeArgvBudgetBytes("win32"), 24 * 1024); + assert.equal(getSafeArgvBudgetBytes("darwin"), 96 * 1024); + assert.equal(getSafeArgvBudgetBytes("linux"), 96 * 1024); +}); + +test("runCommand rejects oversized argv before resolving a missing binary without leaking values", () => { + const marker = "PROMPT_SECRET_MARKER_" + "x".repeat(256); + const result = runCommand("__polycli_missing_argv_binary__", [marker], { + env: { PRIVATE_TOKEN: "ENV_SECRET_MARKER" }, + argvBudgetBytes: 64, + argvBudgetHint: "For review, pass --max-diff-bytes explicitly.", + }); + + assert.equal(result.status, null); + assert.equal(result.spawnErrorCode, "E2BIG"); + assert.equal(result.error?.code, "E2BIG"); + assert.match(result.error?.message ?? "", /argvCount=2/); + assert.match(result.error?.message ?? "", /envCount=1/); + assert.match(result.error?.message ?? "", /--max-diff-bytes/); + assert.doesNotMatch(result.error?.message ?? "", /PROMPT_SECRET_MARKER|ENV_SECRET_MARKER/); +}); + +test("runCommand includes the effective environment in its argv budget preflight", () => { + let spawnCalls = 0; + const result = runCommand("provider", ["short"], { + env: { PRIVATE_TOKEN: "ENV_ONLY_SECRET_" + "x".repeat(256) }, + argvBudgetBytes: 64, + spawnImpl() { + spawnCalls += 1; + throw new Error("spawn must not be called"); + }, + }); + + assert.equal(spawnCalls, 0); + assert.equal(result.spawnErrorCode, "E2BIG"); + assert.doesNotMatch(result.error?.message ?? "", /ENV_ONLY_SECRET/); +}); test("runCommand captures stdout and exit status", () => { const result = runCommand(process.execPath, ["-e", "console.log('pong')"]); @@ -98,3 +172,50 @@ test("terminateProcessTree rejects pid 1 without sending any signal", async (t) ); assert.equal(kill.mock.callCount(), 0); }); + +test("terminateProcessTree gives every Windows taskkill the remaining absolute deadline", async () => { + let now = 100; + const calls = []; + const terminated = await terminateProcessTree(4242, { + platform: "win32", + deadlineAt: 1_000, + forceAfterMs: 50, + now: () => now, + sleep: async (delayMs) => { + now += delayMs; + }, + runCommandImpl(command, args, options) { + calls.push({ command, args, options }); + return { status: 0, signal: null, stdout: "", stderr: "", error: null }; + }, + }); + + assert.equal(terminated, true); + assert.deepEqual(calls.map((call) => [call.command, call.args.at(-1), call.options.timeout]), [ + ["taskkill", "/T", 900], + ["taskkill", "/F", 850], + ]); +}); + +test("terminateProcessTree stops before a post-force Windows taskkill when its deadline expires", async () => { + let now = 100; + const calls = []; + await assert.rejects( + () => terminateProcessTree(4242, { + platform: "win32", + deadlineAt: 120, + forceAfterMs: 50, + now: () => now, + sleep: async (delayMs) => { + now += delayMs; + }, + runCommandImpl(command, args, options) { + calls.push({ command, args, options }); + return { status: 0, signal: null, stdout: "", stderr: "", error: null }; + }, + }), + (error) => error?.code === "EDEADLINE", + ); + assert.equal(calls.length, 1); + assert.equal(calls[0].options.timeout, 20); +}); diff --git a/packages/polycli-utils/test/session-id.test.js b/packages/polycli-utils/test/session-id.test.js index 4cb0e8a..a9f0547 100644 --- a/packages/polycli-utils/test/session-id.test.js +++ b/packages/polycli-utils/test/session-id.test.js @@ -1,7 +1,16 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { matchSessionId, resolveSessionId } from "../src/session-id.js"; +import { matchResumeSessionIdLine, matchSessionId, resolveSessionId } from "../src/session-id.js"; + +test("matchResumeSessionIdLine accepts only a standalone resume UUID line", () => { + const sessionId = "123e4567-e89b-42d3-a456-426614174000"; + assert.equal(matchResumeSessionIdLine(`resume ${sessionId}\n`), sessionId); + assert.equal(matchResumeSessionIdLine(` RESUME ${sessionId} \n`), sessionId); + assert.equal(matchResumeSessionIdLine(`warning: resume ${sessionId}`), null); + assert.equal(matchResumeSessionIdLine(`resume ${sessionId} after reconnect`), null); + assert.equal(matchResumeSessionIdLine(`answer mentions ${sessionId}`), null); +}); test("matchSessionId detects UUIDs in arbitrary text", () => { const sessionId = matchSessionId("To resume: kimi -r 123e4567-e89b-12d3-a456-426614174000"); diff --git a/plugins/polycli-codex/.codex-plugin/plugin.json b/plugins/polycli-codex/.codex-plugin/plugin.json index 1cef7e9..985985d 100644 --- a/plugins/polycli-codex/.codex-plugin/plugin.json +++ b/plugins/polycli-codex/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "polycli-codex", - "version": "0.6.30", + "version": "0.6.31", "description": "Codex skill adapter that routes provider CLI work through the shared polycli companion.", "author": { "name": "bing" diff --git a/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs b/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs index 314c4f7..ebfb554 100755 --- a/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs @@ -1,11 +1,10 @@ #!/usr/bin/env node // plugins/polycli/scripts/polycli-companion.mjs -import fs9 from "node:fs"; +import fs10 from "node:fs"; import path8 from "node:path"; -import process5 from "node:process"; +import process6 from "node:process"; import { randomUUID as randomUUID3 } from "node:crypto"; -import { spawn as spawn2 } from "node:child_process"; import { fileURLToPath } from "node:url"; // packages/polycli-utils/src/args.js @@ -383,8 +382,91 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { // packages/polycli-utils/src/process.js import { spawnSync } from "node:child_process"; import process3 from "node:process"; +var CONSERVATIVE_POINTER_BYTES = 8; +var WINDOWS_SAFE_ARGV_BUDGET_BYTES = 24 * 1024; +var POSIX_SAFE_ARGV_BUDGET_BYTES = 96 * 1024; +function stringStorageBytes(value) { + return Buffer.byteLength(String(value), "utf8") + 1; +} +function getSafeArgvBudgetBytes(platform = process3.platform) { + return platform === "win32" ? WINDOWS_SAFE_ARGV_BUDGET_BYTES : POSIX_SAFE_ARGV_BUDGET_BYTES; +} +function calculateArgvFootprint({ + command, + args = [], + env = process3.env +} = {}) { + const argv = [String(command ?? ""), ...args.map((arg) => String(arg))]; + const envEntries = Object.entries(env ?? {}).filter(([, value]) => value != null).map(([key, value]) => `${key}=${String(value)}`); + const argvBytes = argv.reduce((total, value) => total + stringStorageBytes(value), 0); + const envBytes = envEntries.reduce((total, value) => total + stringStorageBytes(value), 0); + const pointerBytes = (argv.length + envEntries.length + 2) * CONSERVATIVE_POINTER_BYTES; + const stringBytes = argvBytes + envBytes; + return { + totalBytes: stringBytes + pointerBytes, + stringBytes, + pointerBytes, + argvBytes, + envBytes, + argvCount: argv.length, + envCount: envEntries.length + }; +} +function preflightArgv(command, args = [], { + env = process3.env, + argvBudgetBytes = null, + argvBudgetHint = null +} = {}) { + const footprint = calculateArgvFootprint({ command, args, env }); + if (argvBudgetBytes == null) { + return { ok: true, budgetBytes: null, footprint, error: null }; + } + if (!Number.isSafeInteger(argvBudgetBytes) || argvBudgetBytes < 0) { + throw new TypeError("argvBudgetBytes must be a non-negative safe integer or null"); + } + if (footprint.totalBytes <= argvBudgetBytes) { + return { ok: true, budgetBytes: argvBudgetBytes, footprint, error: null }; + } + const counts = [ + `footprintBytes=${footprint.totalBytes}`, + `budgetBytes=${argvBudgetBytes}`, + `argvCount=${footprint.argvCount}`, + `envCount=${footprint.envCount}` + ].join(", "); + const suffix = typeof argvBudgetHint === "string" && argvBudgetHint.trim() ? ` ${argvBudgetHint.trim()}` : ""; + const error = Object.assign( + new Error(`argument list too long for the configured safe argv budget (${counts}).${suffix}`), + { + code: "E2BIG", + footprintBytes: footprint.totalBytes, + budgetBytes: argvBudgetBytes, + argvCount: footprint.argvCount, + envCount: footprint.envCount + } + ); + return { ok: false, budgetBytes: argvBudgetBytes, footprint, error }; +} function runCommand(command, args = [], options = {}) { - const result = spawnSync(command, args, { + const effectiveEnv = options.env ?? process3.env; + const preflight = preflightArgv(command, args, { + env: effectiveEnv, + argvBudgetBytes: options.argvBudgetBytes ?? null, + argvBudgetHint: options.argvBudgetHint ?? null + }); + if (!preflight.ok) { + return { + command, + args, + status: null, + signal: null, + stdout: "", + stderr: "", + error: preflight.error, + spawnErrorCode: "E2BIG" + }; + } + const spawnImpl = options.spawnImpl ?? spawnSync; + const result = spawnImpl(command, args, { cwd: options.cwd, env: options.env, encoding: "utf8", @@ -409,7 +491,8 @@ function runCommand(command, args = [], options = {}) { signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", - error + error, + spawnErrorCode: typeof error?.code === "string" ? error.code : null }; } function firstNonEmptyLine(text) { @@ -452,17 +535,41 @@ function formatCommandFailure(result) { } return parts.join(": "); } -async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SIGKILL", forceAfterMs = 5e3, ignoreMissing = true } = {}) { +async function terminateProcessTree(pid, { + signal = "SIGTERM", + forceSignal = "SIGKILL", + forceAfterMs = 5e3, + ignoreMissing = true, + deadlineAt = null, + platform = process3.platform, + runCommandImpl = runCommand, + now = Date.now, + sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)) +} = {}) { if (!Number.isInteger(pid) || pid <= 1) { throw new Error(`Invalid pid: ${pid}`); } + if (deadlineAt != null && !Number.isFinite(deadlineAt)) { + throw new TypeError("deadlineAt must be a finite epoch millisecond value or null"); + } + const remainingDeadlineMs2 = () => { + if (deadlineAt == null) return null; + const remainingMs = Math.floor(deadlineAt - now()); + if (remainingMs <= 0) { + const error = new Error("process termination deadline exceeded"); + error.code = "EDEADLINE"; + throw error; + } + return remainingMs; + }; const killOnce = (targetSignal) => { - if (process3.platform === "win32") { + const remainingMs = remainingDeadlineMs2(); + if (platform === "win32") { const args = ["/PID", String(pid), "/T"]; if (targetSignal === "SIGKILL") { args.push("/F"); } - const result = runCommand("taskkill", args); + const result = runCommandImpl("taskkill", args, remainingMs == null ? {} : { timeout: remainingMs }); if (result.error) { if (ignoreMissing && result.error.code === "ESRCH") return false; throw result.error; @@ -501,7 +608,9 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI if (!terminated || forceAfterMs <= 0) { return terminated; } - await new Promise((resolve) => setTimeout(resolve, forceAfterMs)); + const remainingBeforeWait = deadlineAt == null ? null : remainingDeadlineMs2(); + const waitMs = remainingBeforeWait == null ? forceAfterMs : Math.min(forceAfterMs, remainingBeforeWait); + await sleep(waitMs); try { killOnce(forceSignal); } catch (error) { @@ -514,6 +623,20 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI // packages/polycli-utils/src/session-id.js var UUID_SESSION_ID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i; +var RESUME_SESSION_ID_LINE_REGEX = new RegExp( + `^\\s*resume\\s+(${UUID_SESSION_ID_REGEX.source})\\s*$`, + "i" +); +function matchResumeSessionIdLine(text) { + if (typeof text !== "string" || text.length === 0) { + return null; + } + for (const line of text.split(/\r?\n/)) { + const match = line.match(RESUME_SESSION_ID_LINE_REGEX); + if (match) return match[1]; + } + return null; +} function matchSessionId(text, { patterns = [UUID_SESSION_ID_REGEX] } = {}) { if (typeof text !== "string" || text.length === 0) { return null; @@ -566,6 +689,16 @@ function formatProviderExitError(provider, status) { return `${provider} exited with code ${status}`; } function classifyProviderFailure(error, { provider = null } = {}) { + const structuredCode = typeof error?.code === "string" ? error.code.toUpperCase() : null; + if (structuredCode === "E2BIG") { + return "argument_list_too_long"; + } + if (structuredCode === "ENOENT") { + return "binary_missing"; + } + if (structuredCode === "ETIMEDOUT") { + return "timeout"; + } const text = typeof error === "string" ? error : String(error?.message ?? error ?? ""); if (!text.trim()) return null; if (provider === "qwen" && /\bmaximum session turn\b|\bmax(?:imum)? session turns?\b/i.test(text)) { @@ -574,6 +707,12 @@ function classifyProviderFailure(error, { provider = null } = {}) { if (/\bspawn\b.*\bENOENT\b|\bENOENT\b|\bnot found\b/i.test(text)) { return "binary_missing"; } + if (/\bE2BIG\b|\bargument list too long\b/i.test(text)) { + return "argument_list_too_long"; + } + if (/\b(output|capture|line buffer)\b.*\b(exceeded|overflow)\b/i.test(text)) { + return "output_overflow"; + } if (/\b(timed out|timeout)\b/i.test(text)) { return "timeout"; } @@ -643,6 +782,7 @@ function createLineDecoder({ encoding = "utf8", stripCarriageReturn = true, maxB } // packages/polycli-runtime/src/spawn.js +var DEFAULT_CAPTURE_LIMIT_BYTES = 1048576; function formatExitError(status, signal, { timedOut = false, aborted = false } = {}) { if (aborted) { return "process aborted"; @@ -658,6 +798,34 @@ function formatExitError(status, signal, { timedOut = false, aborted = false } = } return `process exited with code ${status}`; } +function normalizeCaptureLimit(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : DEFAULT_CAPTURE_LIMIT_BYTES; +} +function spawnFailureResult(error, captureLimitBytes) { + const spawnErrorCode = typeof error?.code === "string" ? error.code : null; + return { + ok: false, + status: null, + signal: null, + timedOut: false, + aborted: false, + stdout: "", + stderr: "", + stdoutBytes: 0, + stderrBytes: 0, + stdoutTruncated: false, + stderrTruncated: false, + captureLimitBytes, + outputOverflowStream: null, + closeTimedOut: false, + terminationErrors: [], + terminationFailure: null, + terminationReason: null, + spawnErrorCode, + error: String(error?.message ?? error), + errorCode: classifyProviderFailure(error) + }; +} function spawnStreamingCommand({ bin, args = [], @@ -668,8 +836,11 @@ function spawnStreamingCommand({ killGraceMs = 2e3, signal = null, maxBufferBytes = 1048576, + maxCaptureBytes = DEFAULT_CAPTURE_LIMIT_BYTES, + argvBudgetBytes = null, + argvBudgetHint = null, stdio = ["pipe", "pipe", "pipe"], - detached = false, + detached = process.platform !== "win32", unref = false, spawnImpl = spawn, onStdoutLine = () => { @@ -677,54 +848,85 @@ function spawnStreamingCommand({ onStderrChunk = () => { } } = {}) { + const captureLimitBytes = normalizeCaptureLimit(maxCaptureBytes); + const preflight = preflightArgv(bin, args, { + env: env ?? process.env, + argvBudgetBytes, + argvBudgetHint + }); + if (!preflight.ok) { + return Promise.resolve(spawnFailureResult(preflight.error, captureLimitBytes)); + } return new Promise((resolve) => { let child; try { child = spawnImpl(bin, args, { cwd, env, stdio, detached }); } catch (error) { - resolve({ - ok: false, - status: null, - signal: null, - timedOut: false, - stdout: "", - stderr: "", - error: error.message - }); + resolve(spawnFailureResult(error, captureLimitBytes)); return; } if (unref && typeof child.unref === "function") { child.unref(); } const decoder = createLineDecoder({ maxBufferBytes }); - let stdout = ""; - let stderr = ""; + const stdoutParts = []; + const stderrParts = []; + let stdoutCapturedBytes = 0; + let stderrCapturedBytes = 0; + let stdoutBytes = 0; + let stderrBytes = 0; + let stdoutTruncated = false; + let stderrTruncated = false; + let outputOverflowStream = null; let timedOut = false; let aborted = false; let settled = false; let timer = null; let forceTimer = null; + let settleTimer = null; let decoderError = null; + let lifecycleFailure = null; let terminationRequested = false; - const signalChild = (signal2) => { + const terminationErrors = []; + const setLifecycleFailure = (code, message) => { + if (!lifecycleFailure) { + lifecycleFailure = { code, message }; + } + }; + const recordTerminationError = (error, killSignal, target) => { + terminationErrors.push({ + signal: killSignal, + target, + code: typeof error?.code === "string" ? error.code : null, + message: String(error?.message ?? error).slice(0, 4096) + }); + }; + const trySignal = (killSignal, target, sender) => { try { - if (detached && Number.isInteger(child.pid) && child.pid > 0 && process.platform !== "win32") { - process.kill(-child.pid, signal2); - return; + const delivered = sender(); + if (delivered === false) { + const error = new Error(`${target} kill returned false`); + error.code = "KILL_RETURNED_FALSE"; + recordTerminationError(error, killSignal, target); + return false; } - child.kill(signal2); - } catch { + return true; + } catch (error) { + recordTerminationError(error, killSignal, target); + return false; } }; - const terminateChild = () => { - if (terminationRequested) return; - terminationRequested = true; - signalChild("SIGTERM"); - if (killGraceMs > 0) { - forceTimer = setTimeout(() => { - signalChild("SIGKILL"); - }, killGraceMs); + const signalChild = (killSignal) => { + const canSignalGroup = detached && Number.isInteger(child.pid) && child.pid > 0 && process.platform !== "win32"; + if (canSignalGroup) { + const groupSignalled = trySignal( + killSignal, + "process_group", + () => process.kill(-child.pid, killSignal) + ); + if (groupSignalled) return; } + trySignal(killSignal, "child", () => child.kill(killSignal)); }; const cleanup = () => { if (signal && typeof signal.removeEventListener === "function") { @@ -736,119 +938,215 @@ function spawnStreamingCommand({ child.off?.("error", handleChildError); child.off?.("close", handleChildClose); }; + const capturedStdout = () => stdoutParts.join(""); + const capturedStderr = () => Buffer.concat(stderrParts, stderrCapturedBytes).toString("utf8"); + const buildResult = (status, signalName, { closeTimedOut = false } = {}) => { + if (!lifecycleFailure) { + if (timedOut || status === 124) { + setLifecycleFailure("timeout", "process timed out"); + } else if (aborted || signalName === "SIGINT" || status === 130) { + setLifecycleFailure("cancelled", aborted ? "process aborted" : "process interrupted"); + } else if (signalName || status === 143) { + setLifecycleFailure("terminated", formatExitError(status, signalName)); + } + } + const stdout = capturedStdout(); + const stderr = capturedStderr(); + const ok = status === 0 && !lifecycleFailure && !closeTimedOut; + const error = ok ? null : lifecycleFailure?.message ?? (stderr.trim() || formatExitError(status, signalName, { timedOut, aborted })); + return { + ok, + status, + signal: signalName, + timedOut, + aborted, + stdout, + stderr, + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated, + captureLimitBytes, + outputOverflowStream, + closeTimedOut, + terminationErrors: terminationErrors.map((entry) => ({ ...entry })), + terminationFailure: closeTimedOut ? "close_timeout" : terminationErrors.length > 0 ? "signal_error" : null, + terminationReason: lifecycleFailure?.code ?? null, + spawnErrorCode: null, + error, + errorCode: lifecycleFailure?.code ?? classifyProviderFailure(error) + }; + }; const finish = (result) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); if (forceTimer) clearTimeout(forceTimer); + if (settleTimer) clearTimeout(settleTimer); cleanup(); resolve(result); }; - const decoderFailureResult = (status, signalName) => ({ - ok: false, - status, - signal: signalName, - timedOut, - stdout, - stderr, - error: decoderError - }); + const settleWithoutClose = () => { + if (settled) return; + finish(buildResult(null, null, { closeTimedOut: true })); + }; + const forceKill = () => { + if (settled) return; + signalChild("SIGKILL"); + if (settled) return; + const closeWaitMs = Math.max(10, Math.max(0, killGraceMs)); + settleTimer = setTimeout(settleWithoutClose, closeWaitMs); + }; + const terminateChild = (code, message) => { + setLifecycleFailure(code, message); + if (terminationRequested) return; + terminationRequested = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + signalChild("SIGTERM"); + if (settled) return; + forceTimer = setTimeout(forceKill, Math.max(0, killGraceMs)); + }; + const handleOutputOverflow = (streamName) => { + if (!outputOverflowStream) { + outputOverflowStream = streamName; + } + terminateChild( + "output_overflow", + `${streamName} capture exceeded maxCaptureBytes (${captureLimitBytes})` + ); + }; + const appendStdoutLine = (line, { trailingNewline = true } = {}) => { + const text = trailingNewline ? `${line} +` : line; + const bytes = Buffer.byteLength(text); + if (stdoutCapturedBytes + bytes > captureLimitBytes) { + stdoutTruncated = true; + return false; + } + stdoutParts.push(text); + stdoutCapturedBytes += bytes; + try { + onStdoutLine(line); + } catch { + } + return true; + }; const handleDecoderError = (error) => { if (decoderError || settled) return; decoderError = String(error?.message ?? error).slice(0, 4096); + stdoutTruncated = true; + outputOverflowStream = outputOverflowStream || "stdout"; child.stdout?.off?.("data", handleStdoutData); - terminateChild(); + terminateChild("output_overflow", decoderError); }; const abortHandler = () => { if (settled || aborted) return; aborted = true; - terminateChild(); + terminateChild("cancelled", "process aborted"); }; if (timeout != null) { timer = setTimeout(() => { - if (settled || timedOut) return; + if (settled || timedOut || terminationRequested) return; timedOut = true; - terminateChild(); + terminateChild("timeout", "process timed out"); }, timeout); } const handleChildError = (error) => { - if (decoderError) { + if (terminationRequested) { return; } finish({ - ok: false, - status: null, - signal: null, + ...spawnFailureResult(error, captureLimitBytes), timedOut, - stdout, - stderr, - error: error.message + aborted, + stdout: capturedStdout(), + stderr: capturedStderr(), + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated }); }; + const captureStderr = (chunk, { emit = true } = {}) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + stderrBytes += buffer.length; + const remaining = Math.max(captureLimitBytes - stderrCapturedBytes, 0); + const accepted = buffer.subarray(0, remaining); + if (accepted.length > 0) { + stderrParts.push(Buffer.from(accepted)); + stderrCapturedBytes += accepted.length; + if (emit) { + try { + onStderrChunk(accepted.toString("utf8")); + } catch { + } + } + } + if (accepted.length < buffer.length) { + stderrTruncated = true; + child.stderr?.off?.("data", handleStderrData); + handleOutputOverflow("stderr"); + } + }; const handleStdinError = (error) => { if (error?.code === "EPIPE" || error?.code === "ERR_STREAM_DESTROYED") { return; } - stderr += `${error.message} -`; + captureStderr(Buffer.from(`${error.message} +`), { emit: false }); }; const handleStdoutData = (chunk) => { if (settled) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + const previousBytes = stdoutBytes; + stdoutBytes += buffer.length; + const remaining = Math.max(captureLimitBytes - previousBytes, 0); + const accepted = buffer.subarray(0, remaining); let lines; try { - lines = decoder.push(chunk); + lines = accepted.length > 0 ? decoder.push(accepted) : []; } catch (error) { handleDecoderError(error); return; } for (const line of lines) { - stdout += `${line} -`; - try { - onStdoutLine(line); - } catch { + if (!appendStdoutLine(line)) { + child.stdout?.off?.("data", handleStdoutData); + handleOutputOverflow("stdout"); + return; } } + if (accepted.length < buffer.length) { + stdoutTruncated = true; + child.stdout?.off?.("data", handleStdoutData); + handleOutputOverflow("stdout"); + } }; const handleStderrData = (chunk) => { if (settled) return; - const text = chunk.toString("utf8"); - stderr += text; - try { - onStderrChunk(text); - } catch { - } + captureStderr(chunk); }; const handleChildClose = (status, signalName) => { - if (decoderError) { - finish(decoderFailureResult(status, signalName)); - return; - } - let lines; - try { - lines = decoder.end(); - } catch (error) { - handleDecoderError(error); - finish(decoderFailureResult(status, signalName)); - return; - } - for (const line of lines) { - stdout += `${line} -`; + if (!decoderError && !stdoutTruncated) { + let lines; try { - onStdoutLine(line); - } catch { + lines = decoder.end(); + } catch (error) { + handleDecoderError(error); + finish(buildResult(status, signalName)); + return; + } + for (const line of lines) { + if (!appendStdoutLine(line, { trailingNewline: false })) { + handleOutputOverflow("stdout"); + break; + } } } - finish({ - ok: status === 0 && !timedOut && !aborted, - status, - signal: signalName, - timedOut, - stdout, - stderr, - error: status === 0 && !timedOut && !aborted ? null : stderr.trim() || formatExitError(status, signalName, { timedOut, aborted }) - }); + finish(buildResult(status, signalName)); }; child.on("error", handleChildError); child.stdin?.on?.("error", handleStdinError); @@ -888,6 +1186,8 @@ var DEFAULT_TIMEOUT_MS = 9e5; var TMUX_START_TIMEOUT_MS = 3e4; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; +var SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var CLAUDE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var CLAUDE_TMUX_ENV_EXACT = /* @__PURE__ */ new Set([ "ALL_PROXY", @@ -989,33 +1289,26 @@ function buildClaudeInvocation({ maxTurns = 10, resumeSessionId = null, extraArgs = [], - bin = CLAUDE_BIN + bin = CLAUDE_BIN, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { const promptText = String(prompt ?? ""); - const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD; - const args = ["-p"]; - if (!useStdin) { - args.push(promptText); - } - args.push("--output-format", outputFormat); - if (outputFormat === "stream-json") { - args.push("--verbose"); - } - if (permissionMode) { - args.push("--permission-mode", permissionMode); - } - if (Number.isFinite(maxTurns) && maxTurns > 0) { - args.push("--max-turns", String(maxTurns)); - } - if (model) { - args.push("--model", model); - } - if (resumeSessionId) { - args.push("--resume", resumeSessionId); - } - if (extraArgs.length > 0) { - args.push(...extraArgs); - } + const buildArgs = (includePrompt) => { + const nextArgs = ["-p"]; + if (includePrompt) nextArgs.push(promptText); + nextArgs.push("--output-format", outputFormat); + if (outputFormat === "stream-json") nextArgs.push("--verbose"); + if (permissionMode) nextArgs.push("--permission-mode", permissionMode); + if (Number.isFinite(maxTurns) && maxTurns > 0) nextArgs.push("--max-turns", String(maxTurns)); + if (model) nextArgs.push("--model", model); + if (resumeSessionId) nextArgs.push("--resume", resumeSessionId); + if (extraArgs.length > 0) nextArgs.push(...extraArgs); + return nextArgs; + }; + const inlineArgs = buildArgs(true); + const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD || !preflightArgv(bin, inlineArgs, { env, argvBudgetBytes }).ok; + const args = useStdin ? buildArgs(false) : inlineArgs; return { bin, args, @@ -1390,13 +1683,8 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = } try { const parsed = JSON.parse(text.slice(jsonStart)); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); const response = typeof parsed.result === "string" ? parsed.result : ""; - const sessionId = parsed.session_id ?? parsed.sessionId ?? resolvedSession.sessionId ?? null; + const sessionId = parsed.session_id ?? parsed.sessionId ?? matchResumeSessionIdLine(stderr) ?? null; const errorText2 = isClaudeErrorResultEvent(parsed) ? getClaudeErrorText(parsed) : null; const processError = status === 0 ? null : String(stderr ?? "").trim() || formatProviderExitError("claude", status); return { @@ -1480,7 +1768,10 @@ function runClaudePrompt({ extraArgs = [], resumeSessionId = null, defaultModel = null, - bin = CLAUDE_BIN + bin = CLAUDE_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { const invocation = buildClaudeInvocation({ prompt, @@ -1490,17 +1781,25 @@ function runClaudePrompt({ maxTurns, resumeSessionId, extraArgs, - bin + bin, + env, + argvBudgetBytes }); const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, - input: invocation.input + input: invocation.input, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `claude timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `claude timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error), + spawnErrorCode: result.spawnErrorCode ?? null }; } return parseClaudeJsonResult(result.stdout, result.stderr, result.status, { @@ -1514,6 +1813,7 @@ function runClaudePromptStreaming({ maxTurns = 10, cwd, timeout = DEFAULT_TIMEOUT_MS, + killGraceMs, extraArgs = [], resumeSessionId = null, defaultModel = null, @@ -1525,7 +1825,8 @@ function runClaudePromptStreaming({ executionMode = "print", env = process.env, signalEmitter = process, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { if (executionMode === "tmux-tui") { return Promise.resolve(runClaudeTuiPrompt({ @@ -1552,7 +1853,9 @@ function runClaudePromptStreaming({ maxTurns, resumeSessionId, extraArgs, - bin + bin, + env, + argvBudgetBytes }); return spawnStreamingCommand({ bin: invocation.bin, @@ -1561,7 +1864,10 @@ function runClaudePromptStreaming({ env, input: invocation.input, timeout, + killGraceMs, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const parsed = parseStreamJsonLine(line, { allowPrefix: true }); if (parsed.ok) { @@ -1573,25 +1879,23 @@ function runClaudePromptStreaming({ } }).then((result) => { const parsed = parseClaudeStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const resultError = isClaudeErrorResultEvent(parsed.resultEvent) ? getClaudeErrorText(parsed.resultEvent) : null; const hasSuccessfulResult = Boolean( parsed.resultEvent && parsed.resultEvent.type === "result" && !isClaudeErrorResultEvent(parsed.resultEvent) ); - const completed = result.ok || result.timedOut && hasSuccessfulResult; + const recoveredTimeout = result.timedOut && hasSuccessfulResult && !result.closeTimedOut && !result.terminationFailure; + const completed = result.ok || recoveredTimeout; return { ...result, ...parsed, timedOut: completed ? false : result.timedOut, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), model: parsed.model ?? model ?? defaultModel, ok: completed && !resultError && hasVisibleText, - error: completed ? resultError || (hasVisibleText ? null : "claude produced no visible text") : result.error + error: completed ? resultError || (hasVisibleText ? null : "claude produced no visible text") : result.error, + errorCode: completed && !resultError ? hasVisibleText ? null : "no_visible_text" : result.errorCode, + terminationReason: completed ? null : result.terminationReason }; }); } @@ -1600,6 +1904,8 @@ function runClaudePromptStreaming({ var COPILOT_BIN = process.env.COPILOT_CLI_BIN || "copilot"; var DEFAULT_TIMEOUT_MS2 = 9e5; var AUTH_CHECK_TIMEOUT_MS2 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES2 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT2 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var COPILOT_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS2 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -1792,7 +2098,10 @@ function runCopilotPrompt({ allowAllPaths = true, allowAllUrls = true, noAskUser = true, - bin = COPILOT_BIN + bin = COPILOT_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES2 } = {}) { const invocation = buildCopilotInvocation({ prompt, @@ -1808,23 +2117,27 @@ function runCopilotPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT2 + }); if (result.error) { return { ok: false, resumeStatus: getCopilotResumeStatus(resumeSessionId, null), - error: result.error.code === "ETIMEDOUT" ? `copilot timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `copilot timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error, { provider: "copilot" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseCopilotStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getCopilotResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); - const sessionId = parsed.sessionId ?? resolvedSession.sessionId; + const sessionId = parsed.sessionId ?? matchResumeSessionIdLine(result.stderr); return { ok: result.status === 0 && !resultError && hasVisibleText, response: parsed.response, @@ -1851,7 +2164,9 @@ function runCopilotPromptStreaming({ onEvent = () => { }, bin = COPILOT_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES2 } = {}) { const invocation = buildCopilotInvocation({ prompt, @@ -1871,9 +2186,11 @@ function runCopilotPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT2, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -1884,14 +2201,9 @@ function runCopilotPromptStreaming({ } }).then((result) => { const parsed = parseCopilotStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getCopilotResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); - const sessionId = parsed.sessionId ?? resolvedSession.sessionId; + const sessionId = parsed.sessionId ?? matchResumeSessionIdLine(result.stderr); return { ...result, ...parsed, @@ -1908,6 +2220,8 @@ var GEMINI_BIN = process.env.GEMINI_CLI_BIN || "gemini"; var DEFAULT_TIMEOUT_MS3 = 3e5; var AUTH_CHECK_TIMEOUT_MS3 = 3e4; var PROMPT_STDIN_THRESHOLD2 = 1e5; +var SAFE_PROMPT_ARGV_BUDGET_BYTES3 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT3 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var GEMINI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var VALID_GEMINI_EFFORTS = /* @__PURE__ */ new Set(["low", "medium", "high"]); var TRANSIENT_PROBE_ERROR_PATTERNS3 = [ @@ -1944,16 +2258,23 @@ function buildGeminiInvocation({ outputFormat = "json", resumeSessionId = null, extraArgs = [], - bin = GEMINI_BIN + bin = GEMINI_BIN, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { const promptText = applyGeminiEffort(prompt, effort); - const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD2; - const args = ["-p", useStdin ? "" : promptText, "-o", outputFormat]; const resolvedApprovalMode = write ? "auto_edit" : approvalMode; - if (model) args.push("-m", model); - args.push("--approval-mode", resolvedApprovalMode); - if (resumeSessionId) args.push("--resume", resumeSessionId); - if (extraArgs.length > 0) args.push(...extraArgs); + const buildArgs = (inlinePrompt) => { + const nextArgs = ["-p", inlinePrompt ? promptText : "", "-o", outputFormat]; + if (model) nextArgs.push("-m", model); + nextArgs.push("--approval-mode", resolvedApprovalMode); + if (resumeSessionId) nextArgs.push("--resume", resumeSessionId); + if (extraArgs.length > 0) nextArgs.push(...extraArgs); + return nextArgs; + }; + const inlineArgs = buildArgs(true); + const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD2 || !preflightArgv(bin, inlineArgs, { env, argvBudgetBytes }).ok; + const args = useStdin ? buildArgs(false) : inlineArgs; return { bin, args, @@ -2085,8 +2406,12 @@ function runGeminiPrompt({ extraArgs = [], resumeSessionId = null, defaultModel = null, - bin = GEMINI_BIN + bin = GEMINI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { + const childEnv = buildGeminiEnv(env); const invocation = buildGeminiInvocation({ prompt, model, @@ -2096,18 +2421,25 @@ function runGeminiPrompt({ outputFormat: "json", resumeSessionId, extraArgs, - bin + bin, + env: childEnv, + argvBudgetBytes }); const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, input: invocation.input, - env: buildGeminiEnv() + env: childEnv, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT3 }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `gemini timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `gemini timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error), + spawnErrorCode: result.spawnErrorCode ?? null }; } return parseGeminiJsonResult(result.stdout, result.stderr, result.status, { @@ -2128,8 +2460,11 @@ function runGeminiPromptStreaming({ onEvent = () => { }, bin = GEMINI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { + const childEnv = buildGeminiEnv(env); const invocation = buildGeminiInvocation({ prompt, model, @@ -2139,16 +2474,20 @@ function runGeminiPromptStreaming({ outputFormat: "stream-json", resumeSessionId, extraArgs, - bin + bin, + env: childEnv, + argvBudgetBytes }); return spawnStreamingCommand({ bin: invocation.bin, args: invocation.args, cwd, - env: buildGeminiEnv(), + env: childEnv, input: invocation.input, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT3, onStdoutLine(line) { const parsed = parseStreamJsonLine(line, { allowPrefix: true }); if (parsed.ok) { @@ -2186,6 +2525,8 @@ import path2 from "node:path"; var KIMI_BIN = process.env.KIMI_CLI_BIN || "kimi"; var DEFAULT_TIMEOUT_MS4 = 9e5; var AUTH_CHECK_TIMEOUT_MS4 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES4 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT4 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var KIMI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS4 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -2297,13 +2638,28 @@ function runKimiPrompt({ resumeSessionId = null, resumeLast = false, defaultModel = null, - bin = KIMI_BIN + bin = KIMI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES4 } = {}) { const invocation = buildKimiInvocation({ prompt, model, resumeSessionId, resumeLast, extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT4 + }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `kimi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error: error2, errorCode: classifyProviderFailure(error2, { provider: "kimi" }) }; + return { + ok: false, + error: error2, + errorCode: classifyProviderFailure(result.error, { provider: "kimi" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } if (result.status !== 0) { const error2 = result.stderr.trim() || formatProviderExitError("kimi", result.status); @@ -2340,16 +2696,20 @@ function runKimiPromptStreaming({ onEvent = () => { }, bin = KIMI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES4 } = {}) { const invocation = buildKimiInvocation({ prompt, model, resumeSessionId, resumeLast, extraArgs, bin }); return spawnStreamingCommand({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT4, onStdoutLine(line) { const event = parseKimiEventLine(line); if (event) { @@ -2371,7 +2731,7 @@ function runKimiPromptStreaming({ model: parsed.model ?? model ?? defaultModel ?? readKimiDefaultModel(), ok, error, - errorCode: classifyProviderFailure(error, { provider: "kimi" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "kimi" }) }; }); } @@ -2380,6 +2740,8 @@ function runKimiPromptStreaming({ var QWEN_BIN = process.env.QWEN_CLI_BIN || "qwen"; var DEFAULT_TIMEOUT_MS5 = 3e5; var AUTH_CHECK_TIMEOUT_MS5 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES5 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT5 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; var PROXY_KEYS = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]; var NO_PROXY_DEFAULTS = ["localhost", "127.0.0.1"]; @@ -2627,7 +2989,9 @@ function runQwenPrompt({ appendSystem, appendDirs, extraArgs = [], - bin = QWEN_BIN + bin = QWEN_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES5 } = {}) { const invocation = buildQwenInvocation({ prompt, @@ -2647,18 +3011,21 @@ function runQwenPrompt({ const result = runCommand(invocation.bin, invocation.args, { cwd, env, - timeout + timeout, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT5 }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `qwen timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error: error2, errorCode: classifyProviderFailure(error2, { provider: "qwen" }) }; + return { + ok: false, + error: error2, + errorCode: classifyProviderFailure(result.error, { provider: "qwen" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } const parsed = parseQwenStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultEventError = extractQwenResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.status === 0 && !resultEventError && hasVisibleText ? null : result.stderr.trim() || resultEventError || (result.status === 0 ? "qwen produced no visible text" : formatProviderExitError("qwen", result.status)); @@ -2668,7 +3035,7 @@ function runQwenPrompt({ status: result.status, stderr: result.stderr, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), error, errorCode }; @@ -2692,7 +3059,8 @@ function runQwenPromptStreaming({ onEvent = () => { }, bin = QWEN_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES5 } = {}) { const invocation = buildQwenInvocation({ prompt, @@ -2715,10 +3083,12 @@ function runQwenPromptStreaming({ cwd, env, timeout, - detached: background, + detached: background ? true : void 0, unref: background, stdio: ["ignore", "pipe", "pipe"], spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT5, onStdoutLine(line) { if (!line.trim().startsWith("{")) return; try { @@ -2728,19 +3098,15 @@ function runQwenPromptStreaming({ } }).then((result) => { const parsed = parseQwenStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const resultEventError = extractQwenResultError(parsed.resultEvent); - const error = result.ok && !resultEventError ? hasVisibleText ? null : resultEventError || "qwen produced no visible text" : resultEventError || result.error; - const errorCode = resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" }); + const commonFailure = !result.ok && result.errorCode ? result.error : null; + const error = commonFailure || (result.ok && !resultEventError ? hasVisibleText ? null : resultEventError || "qwen produced no visible text" : resultEventError || result.error); + const errorCode = result.errorCode ?? (resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" })); return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), ok: result.ok && !resultEventError && hasVisibleText, error, errorCode @@ -2752,6 +3118,8 @@ function runQwenPromptStreaming({ var MMX_BIN = process.env.MMX_CLI_BIN || process.env.MINIMAX_CLI_BIN || "mmx"; var DEFAULT_TIMEOUT_MS6 = 12e4; var AUTH_CHECK_TIMEOUT_MS6 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES6 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT6 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var MINIMAX_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS6 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -2860,7 +3228,8 @@ function runMiniMaxPrompt({ defaultModel = null, env = process.env, bin = MMX_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES6 } = {}) { return new Promise((resolve) => { const invocation = buildMiniMaxInvocation({ prompt, model, extraArgs, bin }); @@ -2872,6 +3241,8 @@ function runMiniMaxPrompt({ timeout, stdio: ["ignore", "pipe", "pipe"], spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT6, onStdoutLine() { } }).then((result) => { @@ -2932,7 +3303,8 @@ async function runMiniMaxPromptStreaming({ onEvent = () => { }, bin = MMX_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES6 } = {}) { const events = []; return runMiniMaxPrompt({ @@ -2944,7 +3316,8 @@ async function runMiniMaxPromptStreaming({ defaultModel, env, bin, - spawnImpl + spawnImpl, + argvBudgetBytes }).then((result) => { try { const event = { @@ -2967,6 +3340,8 @@ var OPENCODE_BIN = process.env.OPENCODE_CLI_BIN || "opencode"; var DEFAULT_TIMEOUT_MS7 = 9e5; var AUTH_CHECK_TIMEOUT_MS7 = 3e4; var SESSION_EXPORT_TIMEOUT_MS = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES7 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT7 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var OPENCODE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS7 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3153,11 +3528,6 @@ function parseOpenCodeStreamText(text) { } function parseOpenCodeJsonResult(stdout, stderr, status, { defaultModel = null } = {}) { const parsed = parseOpenCodeStreamText(stdout); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getOpenCodeResultError(parsed.resultEvent); const sessionErrorMessage = getOpenCodeSessionErrorDataMessage(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); @@ -3166,7 +3536,7 @@ function parseOpenCodeJsonResult(stdout, stderr, status, { defaultModel = null } ok: status === 0 && !resultError && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(stderr), model: parsed.model ?? defaultModel, status, error, @@ -3214,7 +3584,9 @@ function runOpenCodePrompt({ variant = null, skipPermissions = true, defaultModel = null, - bin = OPENCODE_BIN + bin = OPENCODE_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES7 } = {}) { const invocation = buildOpenCodeInvocation({ prompt, @@ -3228,13 +3600,21 @@ function runOpenCodePrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, env }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT7 + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `opencode timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; return { ok: false, error, - errorCode: classifyProviderFailure(error, { provider: "opencode" }) + errorCode: classifyProviderFailure(result.error, { provider: "opencode" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseOpenCodeJsonResult(result.stdout, result.stderr, result.status, { @@ -3262,7 +3642,8 @@ function runOpenCodePromptStreaming({ onEvent = () => { }, bin = OPENCODE_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES7 } = {}) { const invocation = buildOpenCodeInvocation({ prompt, @@ -3283,6 +3664,8 @@ function runOpenCodePromptStreaming({ env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT7, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -3293,28 +3676,24 @@ function runOpenCodePromptStreaming({ } }).then((result) => { const parsed = parseOpenCodeStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); + const fallbackSessionId = matchResumeSessionIdLine(result.stderr); const resultError = getOpenCodeResultError(parsed.resultEvent); const sessionErrorMessage = getOpenCodeSessionErrorDataMessage(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); let resolvedModel = parsed.model ?? model ?? defaultModel; const ok = result.ok && !resultError && hasVisibleText; if (ok && !resolvedModel) { - resolvedModel = resolveOpenCodeSessionModel(parsed.sessionId ?? resolvedSession.sessionId, { cwd, env, bin }); + resolvedModel = resolveOpenCodeSessionModel(parsed.sessionId ?? fallbackSessionId, { cwd, env, bin }); } - const error = sessionErrorMessage || (result.ok ? resultError || (hasVisibleText ? null : "opencode produced no visible text") : result.error); + const error = (!result.ok && result.errorCode ? result.error : null) || sessionErrorMessage || (result.ok ? resultError || (hasVisibleText ? null : "opencode produced no visible text") : result.error); return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? fallbackSessionId, model: resolvedModel, ok, error, - errorCode: classifyProviderFailure(error, { provider: "opencode" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "opencode" }) }; }); } @@ -3324,6 +3703,8 @@ var PI_BIN = process.env.PI_CLI_BIN || "pi"; var DEFAULT_PI_MODEL = null; var DEFAULT_TIMEOUT_MS8 = 9e5; var AUTH_CHECK_TIMEOUT_MS8 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES8 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT8 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var PI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS8 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3489,7 +3870,10 @@ function runPiPrompt({ continueLast = false, noSession = false, defaultModel = null, - bin = PI_BIN + bin = PI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES8 } = {}) { const invocation = buildPiInvocation({ prompt, @@ -3501,11 +3885,20 @@ function runPiPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT8 + }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `pi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `pi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error, { provider: "pi" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parsePiStreamText(result.stdout); @@ -3542,7 +3935,9 @@ function runPiPromptStreaming({ onEvent = () => { }, bin = PI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES8 } = {}) { const invocation = buildPiInvocation({ prompt, @@ -3558,9 +3953,11 @@ function runPiPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT8, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -3596,6 +3993,8 @@ var CMD_BIN = process.env.CMD_CLI_BIN || "cmd"; var DEFAULT_CMD_MODEL = "deepseek"; var DEFAULT_TIMEOUT_MS9 = 9e5; var AUTH_CHECK_TIMEOUT_MS9 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES9 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT9 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var CMD_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS9 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3676,7 +4075,9 @@ function runCmdPrompt({ extraArgs = [], yolo = true, defaultModel = null, - bin = CMD_BIN + bin = CMD_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES9 } = {}) { const invocation = buildCmdInvocation({ prompt, @@ -3684,13 +4085,21 @@ function runCmdPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, env }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT9 + }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `cmd timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; return { ok: false, error: error2, - errorCode: classifyProviderFailure(error2, { provider: "cmd" }) + errorCode: classifyProviderFailure(result.error, { provider: "cmd" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseCmdTextResult(result.stdout); @@ -3721,7 +4130,8 @@ function runCmdPromptStreaming({ onEvent = () => { }, bin = CMD_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES9 } = {}) { const invocation = buildCmdInvocation({ prompt, @@ -3736,6 +4146,8 @@ function runCmdPromptStreaming({ env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT9, onStdoutLine(line) { const trimmed = line.trimEnd(); if (!trimmed.trim()) return; @@ -3754,7 +4166,7 @@ function runCmdPromptStreaming({ model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, ok: result.ok && hasVisibleText, error, - errorCode: classifyProviderFailure(error, { provider: "cmd" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "cmd" }) }; }); } @@ -3946,7 +4358,7 @@ function runAgyPromptStreaming({ const parsed = parseAgyTextResult(result.stdout); const filteredStderr = stripAgyBenignStderr(result.stderr); const hasVisibleText = Boolean(parsed.response.trim()); - const error = result.ok ? hasVisibleText ? null : "agy produced no visible text" : filteredStderr.trim() || result.error; + const error = !result.ok && result.errorCode ? result.error : result.ok ? hasVisibleText ? null : "agy produced no visible text" : filteredStderr.trim() || result.error; return { ...result, ...parsed, @@ -3955,7 +4367,7 @@ function runAgyPromptStreaming({ model: model ?? defaultModel ?? DEFAULT_AGY_MODEL, ok: result.ok && hasVisibleText, error, - errorCode: classifyProviderFailure(error, { provider: "agy" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "agy" }) }; }); } @@ -3964,6 +4376,8 @@ function runAgyPromptStreaming({ var GROK_BIN = process.env.GROK_CLI_BIN || "grok"; var DEFAULT_TIMEOUT_MS11 = 9e5; var AUTH_CHECK_TIMEOUT_MS11 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES10 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT10 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var DEFAULT_GROK_MODEL = "grok-4.5"; var GROK_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|not logged in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var SUCCESS_STOP_REASONS = /* @__PURE__ */ new Set(["endturn", "end_turn", "stop", "stop_sequence", "complete", "completed", "done", "finished", "maxtokens", "max_tokens", "length"]); @@ -4127,7 +4541,10 @@ function runGrokPrompt({ continueLast = false, extraArgs = [], defaultModel = null, - bin = GROK_BIN + bin = GROK_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES10 } = {}) { const invocation = buildGrokInvocation({ prompt, @@ -4141,10 +4558,22 @@ function runGrokPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT10 + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `grok timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "grok" }) }; + return { + ok: false, + error, + errorCode: classifyProviderFailure(result.error, { provider: "grok" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } const parsed = parseGrokJsonResult(result.stdout, result.stderr, result.status, { defaultModel: model ?? defaultModel @@ -4166,7 +4595,9 @@ function runGrokPromptStreaming({ onEvent = () => { }, bin = GROK_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES10 } = {}) { const invocation = buildGrokInvocation({ prompt, @@ -4184,9 +4615,11 @@ function runGrokPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT10, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -4200,14 +4633,14 @@ function runGrokPromptStreaming({ const hasVisibleText = Boolean(parsed.response.trim()); const stopReasonError = isNonSuccessStopReason(parsed.stopReason) ? `grok stopped with ${parsed.stopReason}` : null; const ok = result.ok && hasVisibleText && !parsed.providerError && !stopReasonError; - const error = ok ? null : parsed.providerError || stopReasonError || (result.ok ? "grok produced no visible text" : result.error); + const error = ok ? null : (!result.ok && result.errorCode ? result.error : null) || parsed.providerError || stopReasonError || (result.ok ? "grok produced no visible text" : result.error); return { ...result, ...parsed, model: model ?? defaultModel ?? DEFAULT_GROK_MODEL, ok, error, - errorCode: classifyProviderFailure(error, { provider: "grok" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "grok" }) }; }); } @@ -5653,11 +6086,11 @@ var COMMAND_DEFINITIONS = deepFreeze([ definition({ id: "tui", path: ["tui"], - summary: "Open the read-only terminal run inspector.", + summary: "Open the terminal run inspector; loading may update local recovery state.", usage: "polycli tui [--run-id ] [--history ]", surfaces: TERMINAL_SURFACE, dispatchTarget: "terminal-wrapper", - effects: { readsWorkspace: true }, + effects: { readsWorkspace: true, writesLocalState: true }, options: [ option("run-id", "string", "Select an initial run.", { valueName: "id" }), option("history", "integer", "Limit the run list.", { valueName: "n" }), @@ -6077,6 +6510,18 @@ function validateCommandPositionals(definition2, parsed, enumSources) { throw commandArgumentError(definition2, "Too many positional arguments.", { arguments: positionals }); } const positionalProvider = positionals[0] ?? null; + if (positionalProvider && explicitProvider) { + throw commandArgumentError( + definition2, + `Provider target cannot be supplied as positional provider '${positionalProvider}' together with --provider '${explicitProvider}'.`, + { + argument: positionalProvider, + positionalProvider, + explicitProvider, + conflictsWith: "--provider" + } + ); + } if (positionalProvider && !providers.includes(positionalProvider)) { throw commandArgumentError( definition2, @@ -6407,9 +6852,9 @@ function assertCommandRegistry({ assertCommandRegistry(); // plugins/polycli/scripts/lib/job-control.mjs -import fs6 from "node:fs"; +import fs7 from "node:fs"; import os4 from "node:os"; -import process4 from "node:process"; +import process5 from "node:process"; import { spawnSync as spawnSync3 } from "node:child_process"; // plugins/polycli/scripts/lib/state.mjs @@ -6550,8 +6995,13 @@ function describeStateRoot() { function stateRootDir() { return describeStateRoot().stateRoot; } -function resolveWorkspaceRoot(cwd = process.cwd()) { - const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { cwd }); +function resolveWorkspaceRoot(cwd = process.cwd(), { deadlineAt = null } = {}) { + const remainingMs = Number.isFinite(deadlineAt) ? Math.floor(deadlineAt - Date.now()) : null; + if (remainingMs != null && remainingMs <= 0) return path3.resolve(cwd); + const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { + cwd, + ...remainingMs == null ? {} : { timeout: remainingMs } + }); if (result.status === 0 && result.stdout.trim()) { return path3.resolve(result.stdout.trim()); } @@ -6575,6 +7025,9 @@ function resolveJobLogFile(workspaceRoot, jobId) { function resolveJobConfigFile(workspaceRoot, jobId) { return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); } +function resolveJobStartFailureFile(workspaceRoot, jobId) { + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.start-failure.json`); +} function chmodPrivateDir(dir) { try { fs3.chmodSync(dir, PRIVATE_DIR_MODE); @@ -6639,6 +7092,7 @@ function saveState(workspaceRoot, state, { preserveJobIds = [] } = {}) { if (job && job.jobId && !keptIds.has(job.jobId)) { removeJobFile(workspaceRoot, job.jobId); removeJobConfigFile(workspaceRoot, job.jobId); + removeJobStartFailureFile(workspaceRoot, job.jobId); removeJobLogFile(workspaceRoot, job.jobId); } } @@ -6653,7 +7107,7 @@ function updateState(workspaceRoot, mutate) { return saveState(workspaceRoot, state); }); } -function updateJobAtomically(workspaceRoot, jobId, buildNext) { +function updateJobAtomically(workspaceRoot, jobId, buildNext, { lockOptions = {} } = {}) { ensureStateDir(workspaceRoot); const lockPath = `${resolveStateFile(workspaceRoot)}.lock`; return withLockfile(lockPath, () => { @@ -6691,7 +7145,7 @@ function updateJobAtomically(workspaceRoot, jobId, buildNext) { } saveState(workspaceRoot, state, { preserveJobIds: [jobId] }); return { written: true, job, envelope }; - }); + }, lockOptions); } function upsertJob(workspaceRoot, jobPatch) { let savedJob = null; @@ -6772,182 +7226,36 @@ function removeJobConfigFile(workspaceRoot, jobId) { } catch { } } -function removeJobLogFile(workspaceRoot, jobId) { - try { - fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); - } catch { - } +function writeJobStartFailureFile(workspaceRoot, jobId, payload) { + ensureStateDir(workspaceRoot); + writeJsonAtomic(resolveJobStartFailureFile(workspaceRoot, jobId), payload, { mode: PRIVATE_FILE_MODE }); + return resolveJobStartFailureFile(workspaceRoot, jobId); } - -// plugins/polycli/scripts/lib/run-ledger.mjs -import { randomUUID as randomUUID2 } from "node:crypto"; -import { stat as fsStat } from "node:fs/promises"; -import path4 from "node:path"; - -// packages/polycli-utils/src/ndjson.js -import fs4 from "node:fs"; -function safeParseLine(line) { +function readJobStartFailureFile(workspaceRoot, jobId) { try { - return JSON.parse(line); + return JSON.parse(fs3.readFileSync(resolveJobStartFailureFile(workspaceRoot, jobId), "utf8")); } catch { return null; } } -function retainCompactedLines(lines, keepFrom, retentionGroupKey) { - const entries = []; - for (const line of lines) { - const record = safeParseLine(line); - if (record != null) { - entries.push({ line, record }); - } - } - if (typeof retentionGroupKey !== "function") { - return entries.slice(keepFrom).map((entry) => entry.line); - } - const grouped = entries.map((entry) => ({ - ...entry, - retentionGroup: retentionGroupKey(entry.record) - })); - const retainedStart = keepFrom < 0 ? Math.max(0, grouped.length + keepFrom) : Math.min(keepFrom, grouped.length); - const retainedGroups = new Set( - grouped.slice(keepFrom).map((entry) => entry.retentionGroup).filter((group) => group != null) - ); - return grouped.filter((entry, index) => index >= retainedStart || entry.retentionGroup != null && retainedGroups.has(entry.retentionGroup)).map((entry) => entry.line); -} -function chmodIfRequested(filePath, mode) { - if (mode === 438) return; +function removeJobStartFailureFile(workspaceRoot, jobId) { try { - fs4.chmodSync(filePath, mode); + fs3.unlinkSync(resolveJobStartFailureFile(workspaceRoot, jobId)); } catch { } } -function readNdjson(filePath) { - let text; +function removeJobLogFile(workspaceRoot, jobId) { try { - text = fs4.readFileSync(filePath, "utf8"); - } catch (error) { - if (error?.code === "ENOENT") { - return []; - } - throw error; - } - if (text.length === 0) { - return []; - } - const records = []; - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - const parsed = safeParseLine(trimmed); - if (parsed != null) { - records.push(parsed); - } - } - return records; -} -function appendNdjson(filePath, record, { - timeoutMs = 1e4, - staleMs = 3e4, - pollMs = 25, - maxBytes = null, - keepRatio = 0.5, - retentionGroupKey = null, - mode = 438 -} = {}) { - const lockPath = `${filePath}.lock`; - return withLockfile(lockPath, () => { - ensureParentDir(filePath); - let needsLeadingNewline = false; - try { - const stat = fs4.statSync(filePath); - if (stat.size > 0) { - const fd = fs4.openSync(filePath, "r"); - const lastByte = Buffer.alloc(1); - try { - fs4.readSync(fd, lastByte, 0, 1, stat.size - 1); - } finally { - fs4.closeSync(fd); - } - needsLeadingNewline = lastByte[0] !== 10; - } - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - const line = `${needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)} -`; - fs4.appendFileSync(filePath, line, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - if (maxBytes != null) { - const stat = fs4.statSync(filePath); - if (stat.size > maxBytes) { - const lines = fs4.readFileSync(filePath, "utf8").split("\n").filter(Boolean); - const validCount = lines.reduce((count, entry) => count + (safeParseLine(entry) != null ? 1 : 0), 0); - const keepFrom = Math.floor(validCount * (1 - keepRatio)); - const kept = retainCompactedLines(lines, keepFrom, retentionGroupKey); - writeFileAtomic(filePath, `${kept.join("\n")} -`, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - } - } - return true; - }, { timeoutMs, staleMs, pollMs }); -} -function appendNdjsonBatch(filePath, records, { - timeoutMs = 1e4, - staleMs = 3e4, - pollMs = 25, - maxBytes = null, - keepRatio = 0.5, - retentionGroupKey = null, - mode = 438 -} = {}) { - if (!Array.isArray(records)) { - throw new TypeError("records must be an array"); - } - if (records.length === 0) { - return true; + fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); + } catch { } - const serializedBatch = records.map((record) => { - const serialized = JSON.stringify(record); - if (typeof serialized !== "string") { - throw new TypeError("each record must be JSON-serializable"); - } - return serialized; - }); - const batch = `${serializedBatch.join("\n")} -`; - const lockPath = `${filePath}.lock`; - return withLockfile(lockPath, () => { - ensureParentDir(filePath); - let text = ""; - try { - text = fs4.readFileSync(filePath, "utf8"); - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - let nextText = `${text}${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}${batch}`; - if (maxBytes != null && Buffer.byteLength(nextText, "utf8") > maxBytes) { - const existing = text.split("\n").filter((entry) => safeParseLine(entry) != null); - const targetCount = Math.max( - serializedBatch.length, - Math.ceil((existing.length + serializedBatch.length) * keepRatio) - ); - const allLines = [...existing, ...serializedBatch]; - const keepFrom = Math.max(0, allLines.length - targetCount); - const kept = retainCompactedLines(allLines, keepFrom, retentionGroupKey); - nextText = `${kept.join("\n")} -`; - } - writeFileAtomic(filePath, nextText, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - return true; - }, { timeoutMs, staleMs, pollMs }); } +// plugins/polycli/scripts/lib/background-start.mjs +import fs5 from "node:fs"; +import process4 from "node:process"; +import { spawn as spawn2 } from "node:child_process"; + // plugins/polycli/scripts/lib/cli-contract.mjs var ERROR_CODES = /* @__PURE__ */ new Set([ "invalid_argument", @@ -7250,8 +7558,8 @@ function normalizeStatusListWait(wait, payload) { function normalizeSessionEntries(entries) { return clonePublicResult(Array.isArray(entries) ? entries : []); } -function serializeProviderCommand(commandId, payload, context) { - if (context.background === true || payload?.job && context.background !== false) { +function serializeProviderCommand(commandId, payload) { + if (payload?.job) { return { type: "job.started", job: normalizeV2Job(payload.job) @@ -7282,7 +7590,7 @@ function serializeV2Result(commandId, legacyPayload, context = {}) { }; } if (PROVIDER_COMMANDS.has(id)) { - return serializeProviderCommand(id, payload, context); + return serializeProviderCommand(id, payload); } if (id === "status") { if (payload.job) { @@ -7444,24 +7752,193 @@ function createV2ErrorEnvelope(error, context = {}) { } // plugins/polycli/scripts/lib/run-ledger.mjs -var MAX_LEDGER_BYTES = 2e6; -var KEEP_RATIO = 0.5; -var PRIVATE_FILE_MODE2 = 384; -var RUN_ID_RE = /^[A-Za-z0-9_.-]{1,96}$/; -var SECRET_LONG_OPT_RE = /(token|secret|password|api-?key|access-?key|credential)/i; -var SECRET_ENV_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_KEY|CREDENTIAL)/i; -var PROMPT_COMMANDS = /* @__PURE__ */ new Set(["ask", "rescue", "review", "adversarial-review"]); -var VALUE_OPTIONS = /* @__PURE__ */ new Set([ - "--provider", - "--model", - "--base", - "--scope", - "--resume", - "--effort", - "--run-id", - "--timeout-ms", - "--history" -]); +import { randomUUID as randomUUID2 } from "node:crypto"; +import { stat as fsStat } from "node:fs/promises"; +import path4 from "node:path"; + +// packages/polycli-utils/src/ndjson.js +import fs4 from "node:fs"; +function safeParseLine(line) { + try { + return JSON.parse(line); + } catch { + return null; + } +} +function retainCompactedLines(lines, keepFrom, retentionGroupKey) { + const entries = []; + for (const line of lines) { + const record = safeParseLine(line); + if (record != null) { + entries.push({ line, record }); + } + } + if (typeof retentionGroupKey !== "function") { + return entries.slice(keepFrom).map((entry) => entry.line); + } + const grouped = entries.map((entry) => ({ + ...entry, + retentionGroup: retentionGroupKey(entry.record) + })); + const retainedStart = keepFrom < 0 ? Math.max(0, grouped.length + keepFrom) : Math.min(keepFrom, grouped.length); + const retainedGroups = new Set( + grouped.slice(keepFrom).map((entry) => entry.retentionGroup).filter((group) => group != null) + ); + return grouped.filter((entry, index) => index >= retainedStart || entry.retentionGroup != null && retainedGroups.has(entry.retentionGroup)).map((entry) => entry.line); +} +function chmodIfRequested(filePath, mode) { + if (mode === 438) return; + try { + fs4.chmodSync(filePath, mode); + } catch { + } +} +function readNdjson(filePath) { + let text; + try { + text = fs4.readFileSync(filePath, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") { + return []; + } + throw error; + } + if (text.length === 0) { + return []; + } + const records = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parsed = safeParseLine(trimmed); + if (parsed != null) { + records.push(parsed); + } + } + return records; +} +function appendNdjson(filePath, record, { + timeoutMs = 1e4, + staleMs = 3e4, + pollMs = 25, + maxBytes = null, + keepRatio = 0.5, + retentionGroupKey = null, + mode = 438 +} = {}) { + const lockPath = `${filePath}.lock`; + return withLockfile(lockPath, () => { + ensureParentDir(filePath); + let needsLeadingNewline = false; + try { + const stat = fs4.statSync(filePath); + if (stat.size > 0) { + const fd = fs4.openSync(filePath, "r"); + const lastByte = Buffer.alloc(1); + try { + fs4.readSync(fd, lastByte, 0, 1, stat.size - 1); + } finally { + fs4.closeSync(fd); + } + needsLeadingNewline = lastByte[0] !== 10; + } + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + const line = `${needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)} +`; + fs4.appendFileSync(filePath, line, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + if (maxBytes != null) { + const stat = fs4.statSync(filePath); + if (stat.size > maxBytes) { + const lines = fs4.readFileSync(filePath, "utf8").split("\n").filter(Boolean); + const validCount = lines.reduce((count, entry) => count + (safeParseLine(entry) != null ? 1 : 0), 0); + const keepFrom = Math.floor(validCount * (1 - keepRatio)); + const kept = retainCompactedLines(lines, keepFrom, retentionGroupKey); + writeFileAtomic(filePath, `${kept.join("\n")} +`, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + } + } + return true; + }, { timeoutMs, staleMs, pollMs }); +} +function appendNdjsonBatch(filePath, records, { + timeoutMs = 1e4, + staleMs = 3e4, + pollMs = 25, + maxBytes = null, + keepRatio = 0.5, + retentionGroupKey = null, + mode = 438 +} = {}) { + if (!Array.isArray(records)) { + throw new TypeError("records must be an array"); + } + if (records.length === 0) { + return true; + } + const serializedBatch = records.map((record) => { + const serialized = JSON.stringify(record); + if (typeof serialized !== "string") { + throw new TypeError("each record must be JSON-serializable"); + } + return serialized; + }); + const batch = `${serializedBatch.join("\n")} +`; + const lockPath = `${filePath}.lock`; + return withLockfile(lockPath, () => { + ensureParentDir(filePath); + let text = ""; + try { + text = fs4.readFileSync(filePath, "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + let nextText = `${text}${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}${batch}`; + if (maxBytes != null && Buffer.byteLength(nextText, "utf8") > maxBytes) { + const existing = text.split("\n").filter((entry) => safeParseLine(entry) != null); + const targetCount = Math.max( + serializedBatch.length, + Math.ceil((existing.length + serializedBatch.length) * keepRatio) + ); + const allLines = [...existing, ...serializedBatch]; + const keepFrom = Math.max(0, allLines.length - targetCount); + const kept = retainCompactedLines(allLines, keepFrom, retentionGroupKey); + nextText = `${kept.join("\n")} +`; + } + writeFileAtomic(filePath, nextText, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + return true; + }, { timeoutMs, staleMs, pollMs }); +} + +// plugins/polycli/scripts/lib/run-ledger.mjs +var MAX_LEDGER_BYTES = 2e6; +var KEEP_RATIO = 0.5; +var PRIVATE_FILE_MODE2 = 384; +var RUN_ID_RE = /^[A-Za-z0-9_.-]{1,96}$/; +var SECRET_LONG_OPT_RE = /(token|secret|password|api-?key|access-?key|credential)/i; +var SECRET_ENV_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_KEY|CREDENTIAL)/i; +var PROMPT_COMMANDS = /* @__PURE__ */ new Set(["ask", "rescue", "review", "adversarial-review"]); +var VALUE_OPTIONS = /* @__PURE__ */ new Set([ + "--provider", + "--model", + "--base", + "--scope", + "--resume", + "--effort", + "--run-id", + "--timeout-ms", + "--history" +]); var SHORT_VALUE_OPTIONS = /* @__PURE__ */ new Set(["-m"]); var FOCUS_VALUE_OPTIONS = /* @__PURE__ */ new Set(["--focus"]); var VALID_HOST_SURFACES = /* @__PURE__ */ new Set([ @@ -7610,11 +8087,20 @@ function redactLedgerError(error) { if (message == null || message === "") return null; return { message: sanitizePublicErrorMessage(message, 300) }; } +function sanitizeLedgerPreview(preview) { + if (preview == null) return null; + const text = String(preview); + return sanitizePublicErrorMessage(text, text.length); +} function redactTerminalDescriptor(descriptor) { if (!descriptor || typeof descriptor !== "object") return descriptor ?? null; return { ...descriptor, - events: Array.isArray(descriptor.events) ? descriptor.events.map((event) => ({ ...event, error: redactLedgerError(event.error) })) : descriptor.events + events: Array.isArray(descriptor.events) ? descriptor.events.map((event) => ({ + ...event, + error: redactLedgerError(event.error), + ...Object.prototype.hasOwnProperty.call(event, "preview") ? { preview: sanitizeLedgerPreview(event.preview) } : {} + })) : descriptor.events }; } function createRunLedgerEvent(event = {}) { @@ -7643,7 +8129,7 @@ function createRunLedgerEvent(event = {}) { providerSessionId, timingRef: event.timingRef ?? null, error: redactLedgerError(event.error), - preview: event.preview ?? null, + preview: sanitizeLedgerPreview(event.preview), stdoutBytes: event.stdoutBytes ?? null, stderrBytes: event.stderrBytes ?? null, durationMs: event.durationMs ?? null, @@ -7677,7 +8163,7 @@ function appendRunLedgerEvent(workspaceRoot, event) { }); return full; } -function appendRunLedgerEvents(workspaceRoot, events) { +function appendRunLedgerEvents(workspaceRoot, events, lockOptions = {}) { if (!Array.isArray(events)) { throw new TypeError("events must be an array"); } @@ -7691,6 +8177,7 @@ function appendRunLedgerEvents(workspaceRoot, events) { workspaceSlug: event.workspaceSlug ?? workspaceSlug })); appendNdjsonBatch(file, full, { + ...lockOptions, maxBytes: MAX_LEDGER_BYTES, keepRatio: KEEP_RATIO, retentionGroupKey: terminalLedgerRetentionGroupKey, @@ -7880,7 +8367,7 @@ function buildExpectedTerminalPair(events) { expected: rawExpected.map((event) => ({ ...event, terminalDescriptor: descriptor })) }; } -function ensureRunLedgerTerminalPair(workspaceRoot, events) { +function ensureRunLedgerTerminalPair(workspaceRoot, events, { lockOptions = {} } = {}) { if (!Array.isArray(events) || events.length !== 2) { throw new TypeError("terminal ledger pair must contain exactly two events"); } @@ -7897,7 +8384,7 @@ function ensureRunLedgerTerminalPair(workspaceRoot, events) { if (ambiguousLegacy.length > 0) { throw new Error(`Incomplete or conflicting terminal ledger pair for ${identityLabel}`); } - return appendRunLedgerEvents(workspaceRoot, expected); + return appendRunLedgerEvents(workspaceRoot, expected, lockOptions); } if (existing.length === 1) { const [partial] = existing; @@ -7920,7 +8407,7 @@ function ensureRunLedgerTerminalPair(workspaceRoot, events) { ...missing, terminalDescriptor: repairDescriptor }; - return [...existing, ...appendRunLedgerEvents(workspaceRoot, [repair])]; + return [...existing, ...appendRunLedgerEvents(workspaceRoot, [repair], lockOptions)]; } if (!terminalPairMatches(existing, expected)) { throw new Error(`Incomplete or conflicting terminal ledger pair for ${identityLabel}`); @@ -8251,40 +8738,342 @@ function summarizeRunLedger(events) { incrementCount(failureClassCounts, classifyRunFailure(event)); } return { - runId: group.runId, - commands: group.commands, - startedAt: group.events[0]?.at || null, - updatedAt: group.events.at(-1)?.at || null, - providerCount: new Set(decisions.map((event) => event.provider)).size, - adoptedCount: decisions.filter((event) => event.status === "adopted").length, - skippedCount: decisions.filter((event) => event.status === "skipped").length, - failedCount: decisions.filter((event) => event.status === "failed").length, - failureClassCounts + runId: group.runId, + commands: group.commands, + startedAt: group.events[0]?.at || null, + updatedAt: group.events.at(-1)?.at || null, + providerCount: new Set(decisions.map((event) => event.provider)).size, + adoptedCount: decisions.filter((event) => event.status === "adopted").length, + skippedCount: decisions.filter((event) => event.status === "skipped").length, + failedCount: decisions.filter((event) => event.status === "failed").length, + failureClassCounts + }; + }); +} +function buildRunExplanation(events, runId) { + const group = groupRunLedgerEvents(events).get(runId); + if (!group) { + return { runId, found: false, text: `Run ${runId} was not found.`, events: [] }; + } + const decisions = group.projectedEvents.filter((event) => event.phase === "provider_decision"); + const lines = decisions.map( + (event) => `${event.provider || "run"} ${event.status}${event.reason ? ` (${event.reason})` : ""}` + ); + for (const event of group.projectedEvents.filter((item) => item.phase === "attempt_result" && item.status === "failed")) { + const subject = event.provider || event.jobId || "run"; + lines.push(`attempt ${subject} failed (${classifyRunFailure(event)})`); + } + const terminalProviders = new Set(group.projectedEvents.filter((event) => TERMINAL_LEDGER_PHASES.has(event.phase) && event.provider).map((event) => event.provider)); + for (const provider of new Set(group.projectedEvents.filter((event) => (event.phase === "attempt_started" || event.phase === "job_started") && event.provider).map((event) => event.provider))) { + if (!terminalProviders.has(provider)) lines.push(`${provider} unfinished`); + } + return { runId, found: true, text: lines.join("\n"), events: group.events }; +} + +// plugins/polycli/scripts/lib/background-start.mjs +var ACTIVE_JOB_STATUSES2 = /* @__PURE__ */ new Set(["queued", "running"]); +var TERMINAL_JOB_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); +function blocksFailureFinalizer(envelope) { + return TERMINAL_JOB_STATUSES2.has(envelope?.job?.status) || envelope?.cancellationIntent?.status === "requested" && ACTIVE_JOB_STATUSES2.has(envelope?.job?.status); +} +function cleanupRuntimeOptions(runtimeOptions = {}) { + const cleanupPaths = Array.isArray(runtimeOptions?.cleanupPaths) ? runtimeOptions.cleanupPaths : []; + for (const cleanupPath of cleanupPaths) { + if (typeof cleanupPath !== "string" || cleanupPath.trim() === "") continue; + try { + fs5.rmSync(cleanupPath, { recursive: true, force: true }); + } catch { + } + } +} +function buildRunEvent(runContext, base) { + if (!runContext?.runId) return null; + const command = base.command || runContext.command; + return { + runId: runContext.runId, + hostSurface: runContext.hostSurface || "unknown", + argv: runContext.argv || [], + invocationId: runContext.invocationId ?? null, + attemptId: runContext.attemptId ?? null, + jobId: runContext.jobId ?? base.jobId ?? null, + command, + commands: Array.from(new Set([ + ...runContext.commands || [], + command, + ...base.commands || [] + ].filter(Boolean))).sort(), + ...base + }; +} +function prepareFailureEvents(runContext, events) { + const material = events.map((event) => buildRunEvent(runContext, event)).filter(Boolean); + if (material.length === 0) return []; + const terminalDescriptor = createTerminalLedgerDescriptor(material); + return material.map((event) => ({ ...event, terminalDescriptor })); +} +function safeRunIdentity(jobId, execution, runContext) { + return { + runId: runContext?.runId ?? null, + invocationId: runContext?.invocationId ?? null, + attemptId: runContext?.attemptId ?? null, + command: runContext?.command || execution.kind, + commands: Array.from(new Set([ + ...runContext?.commands || [], + runContext?.command, + execution.kind + ].filter(Boolean))).sort(), + hostSurface: runContext?.hostSurface || "unknown", + jobId, + provider: execution.provider, + kind: execution.kind, + logFile: runContext?.logFile ?? null + }; +} +function buildStartFailureRecovery(jobId, execution, runContext, error, runtimeOptions) { + const safeMessage = sanitizePublicErrorMessage(error?.message || error, 300); + const identity = safeRunIdentity(jobId, execution, runContext); + const terminalReason = `${execution.kind}_failed`; + const terminalEvents = prepareFailureEvents(identity, [ + { + command: identity.command, + kind: execution.kind, + provider: execution.provider, + phase: "attempt_result", + status: "failed", + reason: terminalReason, + attempt: { ordinal: 1 }, + jobId, + error: { message: safeMessage }, + logFile: identity.logFile + }, + { + command: identity.command, + kind: execution.kind, + provider: execution.provider, + phase: "provider_decision", + status: "failed", + reason: terminalReason, + jobId + } + ]); + return { + version: 1, + jobId, + provider: execution.provider, + kind: execution.kind, + error: safeMessage, + recordedAt: (/* @__PURE__ */ new Date()).toISOString(), + identity, + terminalDescriptor: terminalEvents[0]?.terminalDescriptor ?? null, + ownedCleanupPaths: Array.isArray(runtimeOptions?.cleanupPaths) ? runtimeOptions.cleanupPaths.filter((entry) => typeof entry === "string" && entry.trim() !== "") : [] + }; +} +function materialExecution(recovery) { + return { + provider: recovery.provider, + kind: recovery.kind, + runtimeOptions: { cleanupPaths: recovery.ownedCleanupPaths || [] } + }; +} +function finalizeBackgroundStartFailure(workspaceRoot, recovery, { lockOptions = {} } = {}) { + const { jobId } = recovery; + const execution = materialExecution(recovery); + const runContext = recovery.identity; + const safeMessage = recovery.error; + return updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { + if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || blocksFailureFinalizer(storedEnvelope)) { + return null; + } + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); + const failedJob = { + ...latest, + invocationId: latest.invocationId ?? runContext?.invocationId ?? null, + attemptId: latest.attemptId ?? runContext?.attemptId ?? null, + status: "failed", + pid: null, + finishedAt, + updatedAt: finishedAt, + error: safeMessage + }; + const terminalReason = `${execution.kind}_failed`; + const terminalEvents = prepareFailureEvents(runContext, [ + { + command: runContext?.command || execution.kind, + kind: execution.kind, + provider: execution.provider, + phase: "attempt_result", + status: "failed", + reason: terminalReason, + attempt: { ordinal: 1 }, + jobId, + error: { message: safeMessage }, + logFile: failedJob.logFile || runContext?.logFile || null + }, + { + command: runContext?.command || execution.kind, + kind: execution.kind, + provider: execution.provider, + phase: "provider_decision", + status: "failed", + reason: terminalReason, + jobId + } + ]).map((event) => ({ + ...event, + terminalDescriptor: recovery.terminalDescriptor || event.terminalDescriptor + })); + const terminalDescriptor = recovery.terminalDescriptor || terminalEvents[0]?.terminalDescriptor || null; + return { + job: failedJob, + envelope: { + job: failedJob, + result: { ok: false, error: safeMessage }, + terminalReason, + terminalDescriptor + }, + beforeStateCommit() { + if (terminalEvents.length > 0) { + ensureRunLedgerTerminalPair(workspaceRoot, terminalEvents); + } + } }; - }); + }, { lockOptions }); } -function buildRunExplanation(events, runId) { - const group = groupRunLedgerEvents(events).get(runId); - if (!group) { - return { runId, found: false, text: `Run ${runId} was not found.`, events: [] }; +function persistedJobBlocksFailure(workspaceRoot, jobId) { + const job = getJob(workspaceRoot, jobId); + const envelope = readJobFile(resolveJobFile(workspaceRoot, jobId)); + return !job || !ACTIVE_JOB_STATUSES2.has(job.status) || blocksFailureFinalizer(envelope); +} +function recoverBackgroundStartFailure(workspaceRoot, jobId, options = {}) { + const recovery = readJobStartFailureFile(workspaceRoot, jobId); + if (!recovery || recovery.version !== 1 || recovery.jobId !== jobId) { + return { written: false, finalizationError: null }; } - const decisions = group.projectedEvents.filter((event) => event.phase === "provider_decision"); - const lines = decisions.map( - (event) => `${event.provider || "run"} ${event.status}${event.reason ? ` (${event.reason})` : ""}` - ); - for (const event of group.projectedEvents.filter((item) => item.phase === "attempt_result" && item.status === "failed")) { - const subject = event.provider || event.jobId || "run"; - lines.push(`attempt ${subject} failed (${classifyRunFailure(event)})`); + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; } - const terminalProviders = new Set(group.projectedEvents.filter((event) => TERMINAL_LEDGER_PHASES.has(event.phase) && event.provider).map((event) => event.provider)); - for (const provider of new Set(group.projectedEvents.filter((event) => (event.phase === "attempt_started" || event.phase === "job_started") && event.provider).map((event) => event.provider))) { - if (!terminalProviders.has(provider)) lines.push(`${provider} unfinished`); + try { + const write = finalizeBackgroundStartFailure(workspaceRoot, recovery, options); + if (write.written) { + cleanupRuntimeOptions({ cleanupPaths: recovery.ownedCleanupPaths }); + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } + return { written: write.written, finalizationError: null }; + } catch (finalizationError) { + return { written: false, finalizationError }; } - return { runId, found: true, text: lines.join("\n"), events: group.events }; +} +function recordBackgroundStartFailure(workspaceRoot, jobId, execution, runContext, error, options = {}) { + const configFile = resolveJobConfigFile(workspaceRoot, jobId); + const config = readJobConfigFile(configFile); + const runtimeOptions = config?.execution?.runtimeOptions ?? execution.runtimeOptions; + const recovery = buildStartFailureRecovery(jobId, execution, runContext, error, runtimeOptions); + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + writeJobStartFailureFile(workspaceRoot, jobId, recovery); + } catch { + } + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + const write = finalizeBackgroundStartFailure(workspaceRoot, recovery, options); + if (write.written) { + cleanupRuntimeOptions(runtimeOptions); + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } + return { written: write.written, finalizationError: null }; + } catch (finalizationError) { + cleanupRuntimeOptions(runtimeOptions); + return { written: false, finalizationError }; + } +} +async function startBackgroundWorker({ + workspaceRoot, + job, + execution, + runContext, + config, + companionPath, + env = process4.env, + failureFinalizationOptions = {} +}, { + writeConfigFile = writeJobConfigFile, + writeLogFile = fs5.writeFileSync, + openLogFile = fs5.openSync, + closeLogFile = fs5.closeSync, + spawnWorker = spawn2 +} = {}) { + let child = null; + let logFd = null; + let startFailure = null; + let startWarning = null; + let closeWarning = null; + const configFile = resolveJobConfigFile(workspaceRoot, job.jobId); + try { + writeConfigFile(workspaceRoot, job.jobId, config); + writeLogFile(job.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] started ${job.provider} ${job.kind} +`, { + encoding: "utf8", + mode: 384 + }); + logFd = openLogFile(job.logFile, "a", 384); + child = spawnWorker(process4.execPath, [companionPath, "_job-worker", configFile], { + cwd: execution.cwd, + env: { ...env }, + stdio: ["ignore", logFd, logFd], + detached: true + }); + try { + child.once("error", (asyncError) => { + recordBackgroundStartFailure( + workspaceRoot, + job.jobId, + execution, + runContext, + asyncError, + failureFinalizationOptions + ); + }); + child.unref(); + } catch (error) { + startWarning = error; + } + } catch (error) { + startFailure = error; + } finally { + if (logFd != null) { + try { + closeLogFile(logFd); + } catch (error) { + if (child) closeWarning = error; + else if (!startFailure) startFailure = error; + } + } + } + if (startFailure) { + recordBackgroundStartFailure( + workspaceRoot, + job.jobId, + execution, + runContext, + startFailure, + failureFinalizationOptions + ); + throw startFailure; + } + return { child, startWarning, closeWarning }; } // plugins/polycli/scripts/lib/sessions.mjs -import fs5 from "node:fs"; +import fs6 from "node:fs"; import os3 from "node:os"; import path5 from "node:path"; import { createHash } from "node:crypto"; @@ -8330,7 +9119,7 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho case "kimi": { let realCwd = workspaceRoot; try { - realCwd = fs5.realpathSync(workspaceRoot); + realCwd = fs6.realpathSync(workspaceRoot); } catch { } const slug = `wd_${path5.basename(realCwd)}_${createHash("sha256").update(realCwd).digest("hex").slice(0, 12)}`; @@ -8355,7 +9144,7 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho return { path: null, reason: `no artifact derivation for provider ${provider ?? "?"}` }; } } -function recordArtifactPath(candidate, { homedir, lstatFn = fs5.lstatSync, realpathFn = fs5.realpathSync, existsFn = fs5.existsSync } = {}) { +function recordArtifactPath(candidate, { homedir, lstatFn = fs6.lstatSync, realpathFn = fs6.realpathSync, existsFn = fs6.existsSync } = {}) { if (!candidate || typeof candidate.path !== "string") return null; const { path: candidatePath, provider } = candidate; if (!existsFn(candidatePath)) return null; @@ -8432,7 +9221,7 @@ function collectNonPurgeableSessions(events = [], { homedir = os3.homedir() } = } return out; } -function planPurge({ recorded = [], homedir, lstatFn = fs5.lstatSync, realpathFn = fs5.realpathSync, existsFn = fs5.existsSync, sizeFn } = {}) { +function planPurge({ recorded = [], homedir, lstatFn = fs6.lstatSync, realpathFn = fs6.realpathSync, existsFn = fs6.existsSync, sizeFn } = {}) { const deletable = []; const skipped = []; const defaultSizeFn = (p) => { @@ -8499,7 +9288,7 @@ function planPurge({ recorded = [], homedir, lstatFn = fs5.lstatSync, realpathFn } return { deletable, skipped }; } -function executePurge(plan, { confirm = false, rmFn = (p) => fs5.rmSync(p, { recursive: true, force: true }) } = {}) { +function executePurge(plan, { confirm = false, rmFn = (p) => fs6.rmSync(p, { recursive: true, force: true }) } = {}) { const deletable = plan?.deletable ?? []; const skipped = plan?.skipped ?? []; if (!confirm) { @@ -8522,27 +9311,74 @@ var TERMINAL_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancel var WAIT_TARGETS = /* @__PURE__ */ new Set(["terminal", "completed", "failed", "cancelled"]); var DEFAULT_STATUS_LIMIT = 8; var MAX_SELECTOR_CANDIDATES = 8; +function createDeadlineError() { + const error = new Error("cancellation deadline exceeded"); + error.code = "EDEADLINE"; + return error; +} +function remainingDeadlineMs(deadlineAt) { + return Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : null; +} +function deadlineLockOptions(deadlineAt) { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs == null) return {}; + if (remainingMs <= 0) throw createDeadlineError(); + return { + timeoutMs: Math.max(1, Math.ceil(remainingMs)), + pollMs: Math.max(1, Math.min(25, Math.ceil(remainingMs))) + }; +} +function isDeadlineFailure(error, deadlineAt) { + return Number.isFinite(deadlineAt) && ["EDEADLINE", "ELOCKTIMEOUT", "ETIMEDOUT"].includes(error?.code); +} +async function awaitWithinDeadline(promise, deadlineAt) { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs == null) { + await promise; + return true; + } + if (remainingMs <= 0) return false; + let timer = null; + try { + return await Promise.race([ + Promise.resolve(promise).then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), remainingMs); + }) + ]); + } finally { + if (timer) clearTimeout(timer); + } +} function isProcessAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { - process4.kill(pid, 0); + process5.kill(pid, 0); return true; } catch { return false; } } -function isExpectedWorkerProcess(pid, configFile) { +function isExpectedWorkerProcess(pid, configFile, { + deadlineAt = null, + platform = process5.platform, + spawnProcess = spawnSync3 +} = {}) { if (!Number.isInteger(pid) || pid <= 0 || !configFile) return null; + const remainingMs = Number.isFinite(deadlineAt) ? Math.floor(deadlineAt - Date.now()) : null; + if (remainingMs != null && remainingMs <= 0) return null; try { - const result = process4.platform === "win32" ? spawnSync3("powershell.exe", [ + const spawnOptions = { + encoding: "utf8", + stdio: "pipe", + ...remainingMs == null ? {} : { timeout: remainingMs } + }; + const result = platform === "win32" ? spawnProcess("powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine` - ], { encoding: "utf8", stdio: "pipe" }) : spawnSync3("ps", ["-ww", "-o", "command=", "-p", String(pid)], { - encoding: "utf8", - stdio: "pipe" - }); + ], spawnOptions) : spawnProcess("ps", ["-ww", "-o", "command=", "-p", String(pid)], spawnOptions); if (result.error) return null; if (result.status !== 0) return false; return result.stdout.includes("_job-worker") && result.stdout.includes(configFile); @@ -8556,7 +9392,7 @@ function sortJobsNewestFirst(jobs) { function readProgressPreview(logFile, maxLines = 4) { if (!logFile) return ""; try { - const lines = fs6.readFileSync(logFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const lines = fs7.readFileSync(logFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); return lines.slice(-maxLines).join("\n"); } catch { return ""; @@ -8573,6 +9409,9 @@ function enrichJob(workspaceRoot, job) { function isTerminalEnvelope(envelope) { return Boolean(envelope?.job && TERMINAL_STATUSES2.has(envelope.job.status)); } +function hasPendingCancellationIntent(envelope) { + return envelope?.cancellationIntent?.status === "requested"; +} function buildRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result = null, reason = "worker_exited", terminalDescriptor = null } = {}) { const command = runContext.command || config?.execution?.kind || job.kind || null; const provider = runContext.provider || config?.execution?.provider || job.provider || null; @@ -8671,7 +9510,18 @@ function applyTerminalDescriptor(events, terminalDescriptor) { }); } function prepareRecoveredTerminalEvents(workspaceRoot, job, config, { result = null, reason = "worker_exited", terminalDescriptor = null } = {}) { - const runContext = config?.runContext; + const descriptorEvent = terminalDescriptor?.events?.[0] ?? null; + const runContext = config?.runContext || (terminalDescriptor?.runId ? { + runId: terminalDescriptor.runId, + command: descriptorEvent?.command ?? job.kind ?? null, + kind: descriptorEvent?.kind ?? job.kind ?? null, + provider: descriptorEvent?.provider ?? job.provider ?? null, + hostSurface: descriptorEvent?.hostSurface || "unknown", + invocationId: terminalDescriptor.invocationId ?? job.invocationId ?? null, + attemptId: terminalDescriptor.attemptId ?? job.attemptId ?? null, + jobId: terminalDescriptor.jobId ?? job.jobId ?? null, + logFile: job.logFile ?? null + } : null); if (!runContext?.runId) return { events: [], terminalDescriptor }; const events = buildRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result, @@ -8684,9 +9534,9 @@ function prepareRecoveredTerminalEvents(workspaceRoot, job, config, { result = n terminalDescriptor: descriptor }; } -function ensureRecoveredTerminalEvents(workspaceRoot, prepared) { +function ensureRecoveredTerminalEvents(workspaceRoot, prepared, { lockOptions = {} } = {}) { if (prepared.events.length > 0) { - ensureRunLedgerTerminalPair(workspaceRoot, prepared.events); + ensureRunLedgerTerminalPair(workspaceRoot, prepared.events, { lockOptions }); } } function cleanupRuntimePaths(config) { @@ -8695,16 +9545,26 @@ function cleanupRuntimePaths(config) { for (const cleanupPath of cleanupPaths) { if (typeof cleanupPath !== "string" || !cleanupPath) continue; try { - fs6.rmSync(cleanupPath, { recursive: true, force: true }); + fs7.rmSync(cleanupPath, { recursive: true, force: true }); } catch { } } } function refreshJob(workspaceRoot, job) { if (!job || !ACTIVE_STATUSES2.has(job.status)) { + if (job?.jobId && TERMINAL_STATUSES2.has(job.status)) { + removeJobStartFailureFile(workspaceRoot, job.jobId); + } return job ? enrichJob(workspaceRoot, job) : null; } const storedEnvelope = readJobFile(resolveJobFile(workspaceRoot, job.jobId)); + if (hasPendingCancellationIntent(storedEnvelope)) { + return enrichJob(workspaceRoot, job); + } + const startFailureRecovery = recoverBackgroundStartFailure(workspaceRoot, job.jobId); + if (startFailureRecovery.written) { + return enrichJob(workspaceRoot, getJob(workspaceRoot, job.jobId) || job); + } if (!job.pid && !isTerminalEnvelope(storedEnvelope)) { return enrichJob(workspaceRoot, job); } @@ -8778,8 +9638,12 @@ function refreshJob(workspaceRoot, job) { const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, job.jobId)); cleanupRuntimePaths(config); removeJobConfigFile(workspaceRoot, job.jobId); + removeJobStartFailureFile(workspaceRoot, job.jobId); } const current = write.written ? write.job : getJob(workspaceRoot, job.jobId) || job; + if (TERMINAL_STATUSES2.has(current?.status)) { + removeJobStartFailureFile(workspaceRoot, job.jobId); + } return enrichJob(workspaceRoot, current); } catch { return enrichJob(workspaceRoot, getJob(workspaceRoot, job.jobId) || job); @@ -8787,11 +9651,11 @@ function refreshJob(workspaceRoot, job) { } function buildStatusSnapshot(workspaceRoot, { showAll = false } = {}) { const refreshed = sortJobsNewestFirst(listJobs(workspaceRoot)).map((job) => refreshJob(workspaceRoot, job)); - const limited = showAll ? refreshed : refreshed.slice(0, DEFAULT_STATUS_LIMIT); + const recent = refreshed.filter((job) => TERMINAL_STATUSES2.has(job.status)); return { totalJobs: refreshed.length, - running: limited.filter((job) => ACTIVE_STATUSES2.has(job.status)), - recent: limited.filter((job) => TERMINAL_STATUSES2.has(job.status)) + running: refreshed.filter((job) => ACTIVE_STATUSES2.has(job.status)), + recent: showAll ? recent : recent.slice(0, DEFAULT_STATUS_LIMIT) }; } function refreshJobsForLedgerRecovery(workspaceRoot) { @@ -8974,84 +9838,94 @@ async function waitForJob(workspaceRoot, jobId, options = {}) { async function cancelJob(workspaceRoot, jobId, { terminate = terminateProcessTree, isWorkerAlive = isProcessAlive, - isExpectedWorker = isExpectedWorkerProcess + isExpectedWorker = isExpectedWorkerProcess, + deadlineAt = null } = {}) { let pidToKill = null; let configForCleanup = null; let cancellationEnvelope = null; let reason = null; const requestedAt = (/* @__PURE__ */ new Date()).toISOString(); - const intentWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { - if (!current) { - reason = "not_found"; - return null; - } - if (!ACTIVE_STATUSES2.has(current.status)) { - reason = "not_cancellable"; - return null; - } - const resumingCancellation = storedEnvelope?.job?.status === "cancelled"; - if (isTerminalEnvelope(storedEnvelope) && !resumingCancellation) { - reason = "not_cancellable"; - return null; - } - pidToKill = current.pid ?? null; - configForCleanup = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); - const intentJob = resumingCancellation ? { - ...current, - ...storedEnvelope.job, - status: "cancelled", - pid: null, - finishedAt: storedEnvelope.job.finishedAt || requestedAt, - updatedAt: requestedAt - } : { - ...current, - status: "cancelled", - pid: null, - finishedAt: requestedAt, - updatedAt: requestedAt - }; - const cancellationResult = resumingCancellation ? storedEnvelope.result || { ok: false, error: "cancelled" } : { ok: false, error: "cancelled" }; - const terminal = prepareRecoveredTerminalEvents(workspaceRoot, intentJob, configForCleanup, { - result: cancellationResult, - reason: "cancelled", - terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null - }); - cancellationEnvelope = { - job: intentJob, - result: cancellationResult, - terminalReason: "cancelled", - terminalDescriptor: terminal.terminalDescriptor - }; - return { - // Do not make the state terminal yet. It remains the recovery point if this process exits - // after persisting the intent but before the worker receives its signal. - job: current, - envelope: cancellationEnvelope, - beforeStateCommit() { - ensureRecoveredTerminalEvents(workspaceRoot, terminal); + let intentWrite; + try { + intentWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { + if (!current) { + reason = "not_found"; + return null; } - }; - }); + if (!ACTIVE_STATUSES2.has(current.status)) { + reason = "not_cancellable"; + return null; + } + const resumingCancellation = hasPendingCancellationIntent(storedEnvelope); + if (isTerminalEnvelope(storedEnvelope) && !resumingCancellation) { + reason = "not_cancellable"; + return null; + } + pidToKill = current.pid ?? null; + configForCleanup = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); + cancellationEnvelope = resumingCancellation ? storedEnvelope : { + job: current, + cancellationIntent: { + status: "requested", + requestedAt + } + }; + return { + // Do not make the state or envelope terminal yet. Both remain recovery points if this + // process exits after persisting the intent but before the worker receives its signal. + job: current, + envelope: cancellationEnvelope + }; + }, { lockOptions: deadlineLockOptions(deadlineAt) }); + } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + throw error; + } if (!intentWrite.written) { return { cancelled: false, reason: reason || "not_cancellable", jobId }; } const configFile = resolveJobConfigFile(workspaceRoot, jobId); - if (pidToKill && isWorkerAlive(pidToKill)) { - if (!isExpectedWorker(pidToKill, configFile)) { + if (Number.isInteger(pidToKill) && pidToKill > 1 && isWorkerAlive(pidToKill)) { + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + if (!configForCleanup || isExpectedWorker(pidToKill, configFile, { deadlineAt }) !== true) { + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } return { cancelled: false, reason: "worker_identity_unverified", jobId }; } try { - await terminate(pidToKill, { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs != null && remainingMs <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + const terminatedWithinDeadline = await awaitWithinDeadline(terminate(pidToKill, { signal: "SIGINT", forceSignal: "SIGKILL", - forceAfterMs: 2e3 - }); + forceAfterMs: remainingMs == null ? 2e3 : Math.max(1, Math.min(2e3, Math.floor(remainingMs))), + deadlineAt + }), deadlineAt); + if (!terminatedWithinDeadline) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } return { cancelled: false, reason: "kill_failed", jobId, killWarning: error.message }; } if (isWorkerAlive(pidToKill)) { - const postSignalIdentity = isExpectedWorker(pidToKill, configFile); + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + const postSignalIdentity = isExpectedWorker(pidToKill, configFile, { deadlineAt }); + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } if (postSignalIdentity === true) { return { cancelled: false, reason: "worker_still_running", jobId }; } @@ -9061,55 +9935,69 @@ async function cancelJob(workspaceRoot, jobId, { } } let finalConfig = configForCleanup; - const finalWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { - if (!current) { - reason = "not_found"; - return null; - } - if (current.status === "cancelled") return null; - if (!ACTIVE_STATUSES2.has(current.status) || storedEnvelope?.job?.status !== "cancelled") { - reason = "cancellation_finalization_pending"; - return null; - } - finalConfig = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)) || finalConfig; - const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); - const finalJob = { - ...current, - ...storedEnvelope.job, - status: "cancelled", - pid: null, - finishedAt: storedEnvelope.job.finishedAt || finishedAt, - updatedAt: finishedAt - }; - const result = storedEnvelope.result || cancellationEnvelope?.result || { ok: false, error: "cancelled" }; - const terminal = prepareRecoveredTerminalEvents(workspaceRoot, finalJob, finalConfig, { - result, - reason: "cancelled", - terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null - }); - return { - job: finalJob, - envelope: { - ...storedEnvelope, - job: finalJob, - result, - terminalReason: "cancelled", - terminalDescriptor: terminal.terminalDescriptor - }, - beforeStateCommit() { - ensureRecoveredTerminalEvents(workspaceRoot, terminal); + let finalWrite; + try { + finalWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { + if (!current) { + reason = "not_found"; + return null; } - }; - }); + if (current.status === "cancelled") return null; + if (!ACTIVE_STATUSES2.has(current.status) || !hasPendingCancellationIntent(storedEnvelope)) { + reason = "cancellation_finalization_pending"; + return null; + } + finalConfig = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)) || finalConfig; + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); + const storedCancelledJob = storedEnvelope?.job?.status === "cancelled" ? storedEnvelope.job : null; + const finalJob = { + ...current, + ...storedCancelledJob || {}, + status: "cancelled", + pid: null, + finishedAt: storedCancelledJob?.finishedAt || finishedAt, + updatedAt: finishedAt + }; + const result = storedEnvelope.result || cancellationEnvelope?.result || { ok: false, error: "cancelled" }; + const terminal = prepareRecoveredTerminalEvents(workspaceRoot, finalJob, finalConfig, { + result, + reason: "cancelled", + terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null + }); + return { + job: finalJob, + envelope: { + ...storedEnvelope, + job: finalJob, + result, + terminalReason: "cancelled", + terminalDescriptor: terminal.terminalDescriptor, + cancellationIntent: storedEnvelope.cancellationIntent + }, + beforeStateCommit() { + ensureRecoveredTerminalEvents(workspaceRoot, terminal, { + lockOptions: deadlineLockOptions(deadlineAt) + }); + } + }; + }, { lockOptions: deadlineLockOptions(deadlineAt) }); + } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + throw error; + } if (!finalWrite.written) { const current = getJob(workspaceRoot, jobId); if (current?.status === "cancelled") { + removeJobStartFailureFile(workspaceRoot, jobId); return { cancelled: true, jobId }; } return { cancelled: false, reason: reason || "cancellation_finalization_pending", jobId }; } cleanupRuntimePaths(finalConfig); removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); return { cancelled: true, jobId }; } @@ -9271,7 +10159,7 @@ function resolveProvider({ provider, positionals = [] } = {}) { } // plugins/polycli/scripts/lib/review.mjs -import fs7 from "node:fs"; +import fs8 from "node:fs"; import os5 from "node:os"; import path6 from "node:path"; var DEFAULT_MAX_DIFF_BYTES = null; @@ -9331,7 +10219,7 @@ function git(cwd, args) { return runCommand("git", args, { cwd }); } function makeReviewTempDir(prefix) { - return fs7.mkdtempSync(path6.join(os5.tmpdir(), `polycli-review-${prefix}-`)); + return fs8.mkdtempSync(path6.join(os5.tmpdir(), `polycli-review-${prefix}-`)); } function assertNoReviewConstraintOverride(provider, runtimeOptions = {}) { const extraArgs = Array.isArray(runtimeOptions.extraArgs) ? runtimeOptions.extraArgs : []; @@ -9608,7 +10496,7 @@ function summarizeTimingRecords(records) { } // plugins/polycli/scripts/lib/preview.mjs -import fs8 from "node:fs"; +import fs9 from "node:fs"; var PREVIEW_MAX_LINES = 10; var PREVIEW_TAIL_CACHE = /* @__PURE__ */ new Map(); var PRIVATE_FILE_MODE4 = 384; @@ -9697,7 +10585,7 @@ function summarizeEventText(provider, event) { } return ""; } -function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PREVIEW_TAIL_CACHE } = {}) { +function appendPreview(logFile, provider, event, { fsImpl = fs9, tailCache = PREVIEW_TAIL_CACHE } = {}) { const text = summarizeEventText(provider, event); if (!text) return; const lines = String(text).split(/\r?\n/).map((line) => collapseWhitespace(line)).filter(Boolean).slice(0, PREVIEW_MAX_LINES); @@ -9708,9 +10596,9 @@ function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PRE } fsImpl.appendFileSync(logFile, `${lines.join("\n")} `, { encoding: "utf8", mode: PRIVATE_FILE_MODE4 }); - if (fsImpl === fs8) { + if (fsImpl === fs9) { try { - fs8.chmodSync(logFile, PRIVATE_FILE_MODE4); + fs9.chmodSync(logFile, PRIVATE_FILE_MODE4); } catch { } } @@ -9719,7 +10607,7 @@ function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PRE // plugins/polycli/scripts/polycli-companion.mjs var COMPANION_PATH = fileURLToPath(import.meta.url); -var BUILD_VERSION = true ? "0.6.30" : "0.0.0-dev"; +var BUILD_VERSION = true ? "0.6.31" : "0.0.0-dev"; var BUILD_VERSION_SOURCE = true ? "bundled-release" : "development"; var JOB_PREFIXES = { ask: "pa", @@ -9756,8 +10644,8 @@ function resolveTimeoutMs(provider, kind, { model = null, defaultModel = null } } var HEALTH_SENTINEL = "POLYCLI_HEALTH_OK"; var SESSION_ID_ENV = "POLYCLI_COMPANION_SESSION_ID"; -var TERMINAL_JOB_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); -var ACTIVE_JOB_STATUSES2 = /* @__PURE__ */ new Set(["queued", "running"]); +var TERMINAL_JOB_STATUSES3 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); +var ACTIVE_JOB_STATUSES3 = /* @__PURE__ */ new Set(["queued", "running"]); var RUN_CONTEXT = { invocationId: null, runId: null, @@ -9821,21 +10709,24 @@ function ensureTerminalRunEventsForContext(workspaceRoot, prepared) { return prepared.events.length > 0 ? ensureRunLedgerTerminalPair(workspaceRoot, prepared.events) : []; } function hasTerminalJobEnvelope(envelope) { - return TERMINAL_JOB_STATUSES2.has(envelope?.job?.status); + return TERMINAL_JOB_STATUSES3.has(envelope?.job?.status); +} +function blocksBackgroundWorkerCommit(envelope) { + return hasTerminalJobEnvelope(envelope) || hasPendingCancellationIntent(envelope); } function claimBackgroundWorker(workspaceRoot, jobId) { const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || !ACTIVE_JOB_STATUSES3.has(latest.status) || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } - if (latest.pid != null && latest.pid !== process5.pid) { + if (latest.pid != null && latest.pid !== process6.pid) { return null; } return { job: { ...latest, status: "running", - pid: process5.pid, + pid: process6.pid, updatedAt: (/* @__PURE__ */ new Date()).toISOString() } }; @@ -9846,68 +10737,6 @@ function shouldRetainJobConfig(workspaceRoot, jobId) { const current = getJob(workspaceRoot, jobId); return current?.status === "queued" || current?.status === "running"; } -function recordBackgroundSpawnFailure(workspaceRoot, jobId, execution, runContext, error) { - const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); - try { - const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || hasTerminalJobEnvelope(storedEnvelope)) { - return null; - } - const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); - const failedJob = { - ...latest, - ...execution.jobMeta, - status: "failed", - pid: null, - finishedAt, - updatedAt: finishedAt, - error: error.message - }; - const terminalReason = `${execution.kind}_failed`; - const terminalEvents = [ - { - command: runContext?.command || execution.kind, - kind: execution.kind, - provider: execution.provider, - phase: "attempt_result", - status: "failed", - reason: terminalReason, - attempt: { ordinal: 1 }, - jobId, - error: { message: String(error.message || error).slice(0, 300) }, - logFile: failedJob.logFile || null - }, - { - command: runContext?.command || execution.kind, - kind: execution.kind, - provider: execution.provider, - phase: "provider_decision", - status: "failed", - reason: terminalReason, - jobId - } - ]; - const terminal = prepareTerminalRunEventsForContext(runContext, terminalEvents); - return { - job: failedJob, - envelope: { - job: failedJob, - result: { ok: false, error: error.message }, - terminalReason, - terminalDescriptor: terminal.terminalDescriptor - }, - beforeStateCommit() { - ensureTerminalRunEventsForContext(workspaceRoot, terminal); - } - }; - }); - if (write.written) { - cleanupRuntimeOptions(config?.execution?.runtimeOptions); - removeJobConfigFile(workspaceRoot, jobId); - } - } catch { - } -} async function recordRunEvent(workspaceRoot, base = {}) { return recordRunEventForContext(workspaceRoot, buildCurrentRunContext(), base); } @@ -10029,9 +10858,9 @@ function assertPreDispatchReviewSafety(definition2, parsed) { function exitWithError(error) { const typed = toTypedCliError(error); if (RUN_CONTEXT.outputMode === "json-v2") { - process5.stdout.write(`${JSON.stringify(createV2ErrorEnvelope(typed, { + process6.stdout.write(`${JSON.stringify(createV2ErrorEnvelope(typed, { invocationId: RUN_CONTEXT.invocationId, - command: RUN_CONTEXT.command || process5.argv[2] || "", + command: RUN_CONTEXT.command || process6.argv[2] || "", hostSurface: RUN_CONTEXT.hostSurface, workspaceSlug: RUN_CONTEXT.workspaceSlug, runId: RUN_CONTEXT.runId, @@ -10041,19 +10870,19 @@ function exitWithError(error) { } else if (RUN_CONTEXT.outputMode === "legacy-json") { const code = error?.legacyCode || error?.code || classifyErrorCode(error?.message || ""); const legacyCode = code === "unknown_command" ? "unknown_subcommand" : code; - process5.stdout.write(`${JSON.stringify({ error: typed.message, code: legacyCode }, null, 2)} + process6.stdout.write(`${JSON.stringify({ error: typed.message, code: legacyCode }, null, 2)} `); } else { - process5.stderr.write(`Error: ${typed.message} + process6.stderr.write(`Error: ${typed.message} `); for (const suggestion of typed.data?.suggestions || []) { - process5.stderr.write(`Suggestion: ${suggestion} + process6.stderr.write(`Suggestion: ${suggestion} `); } - for (const step of typed.nextSteps) process5.stderr.write(`${step} + for (const step of typed.nextSteps) process6.stderr.write(`${step} `); } - process5.exitCode = typed.exitCode || error?.exitCode || 1; + process6.exitCode = typed.exitCode || error?.exitCode || 1; } function output(value, asJson) { if (RUN_CONTEXT.outputMode === "json-v2") { @@ -10069,18 +10898,18 @@ function output(value, asJson) { runId: RUN_CONTEXT.runId, jobId: result.job?.jobId ?? result.jobId ?? null }); - process5.stdout.write(`${JSON.stringify(envelope, null, 2)} + process6.stdout.write(`${JSON.stringify(envelope, null, 2)} `); RUN_CONTEXT.authoritativeJsonWritten = true; return; } if (asJson) { - process5.stdout.write(`${JSON.stringify(value, null, 2)} + process6.stdout.write(`${JSON.stringify(value, null, 2)} `); RUN_CONTEXT.authoritativeJsonWritten = true; return; } - process5.stdout.write(typeof value === "string" ? `${value} + process6.stdout.write(typeof value === "string" ? `${value} ` : `${JSON.stringify(value, null, 2)} `); } @@ -10149,7 +10978,7 @@ function resolveProviderModelCacheFile(workspaceRoot) { } function readProviderModelCache(workspaceRoot) { try { - const parsed = JSON.parse(fs9.readFileSync(resolveProviderModelCacheFile(workspaceRoot), "utf8")); + const parsed = JSON.parse(fs10.readFileSync(resolveProviderModelCacheFile(workspaceRoot), "utf8")); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; @@ -10162,7 +10991,7 @@ function readCachedProviderModel(workspaceRoot, provider) { function cacheProviderModel(workspaceRoot, provider, model) { if (typeof model !== "string" || !model.trim()) return; const cacheFile = resolveProviderModelCacheFile(workspaceRoot); - fs9.mkdirSync(path8.dirname(cacheFile), { recursive: true, mode: 448 }); + fs10.mkdirSync(path8.dirname(cacheFile), { recursive: true, mode: 448 }); withLockfile(`${cacheFile}.lock`, () => { writeJsonAtomic(cacheFile, { ...readProviderModelCache(workspaceRoot), [provider]: model }, { mode: 384 }); }); @@ -10188,11 +11017,11 @@ function skippedAuthDetail({ available, authProbeCost }) { } async function inspectProvider(provider, { probeAuth = false } = {}) { const runtime = getProviderRuntime(provider); - const availability = await Promise.resolve(runtime.getAvailability(process5.cwd())); + const availability = await Promise.resolve(runtime.getAvailability(process6.cwd())); const available = availability.available === true; const authProbeCost = normalizeAuthProbeCost(runtime); const authChecked = available && (probeAuth || authProbeCost === "status"); - const auth = authChecked ? await Promise.resolve(runtime.getAuthStatus(process5.cwd())) : null; + const auth = authChecked ? await Promise.resolve(runtime.getAuthStatus(process6.cwd())) : null; const row = { provider, available, @@ -10205,7 +11034,7 @@ async function inspectProvider(provider, { probeAuth = false } = {}) { model: auth?.model ?? null, capabilities: runtime.capabilities }; - cacheProviderModel(resolveWorkspaceRoot(process5.cwd()), provider, row.model); + cacheProviderModel(resolveWorkspaceRoot(process6.cwd()), provider, row.model); return row; } async function inspectSetupProvider(provider, { probeAuth, workspaceRoot }) { @@ -10277,7 +11106,7 @@ async function inspectSetupProvider(provider, { probeAuth, workspaceRoot }) { } async function inspectProviderAvailability(provider) { const runtime = getProviderRuntime(provider); - const availability = await Promise.resolve(runtime.getAvailability(process5.cwd())); + const availability = await Promise.resolve(runtime.getAvailability(process6.cwd())); const authProbeCost = normalizeAuthProbeCost(runtime); return { provider, @@ -10306,14 +11135,14 @@ function parseExecutionMode(options) { } function emitNote(line) { if (!line) return; - process5.stderr.write(`${line} + process6.stderr.write(`${line} `); } function emitRuntimeWarnings(result = {}) { if (!Array.isArray(result.warnings)) return; for (const warning of result.warnings) { if (typeof warning === "string" && warning.trim()) { - process5.stderr.write(`${warning.trim()} + process6.stderr.write(`${warning.trim()} `); } } @@ -10424,12 +11253,12 @@ function compactProviderResult(result = {}) { } return compact; } -function cleanupRuntimeOptions(runtimeOptions = {}) { +function cleanupRuntimeOptions2(runtimeOptions = {}) { const cleanupPaths = Array.isArray(runtimeOptions.cleanupPaths) ? runtimeOptions.cleanupPaths : []; for (const cleanupPath of cleanupPaths) { if (typeof cleanupPath !== "string" || cleanupPath.trim() === "") continue; try { - fs9.rmSync(cleanupPath, { recursive: true, force: true }); + fs10.rmSync(cleanupPath, { recursive: true, force: true }); } catch { } } @@ -10440,7 +11269,7 @@ function hydrateRuntimeOptions(runtimeOptions = {}) { } return { ...runtimeOptions, - env: { ...process5.env, ...runtimeOptions.env } + env: { ...process6.env, ...runtimeOptions.env } }; } async function runForegroundExecution(execution, asJson) { @@ -10494,7 +11323,7 @@ async function runForegroundExecution(execution, asJson) { } catch (error) { executionError = error; } finally { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } const resultOk = Boolean(result?.ok) && !executionError; const publicError = executionError?.message || result?.error || null; @@ -10561,9 +11390,9 @@ async function runForegroundExecution(execution, asJson) { return; } if (!result.ok) { - process5.stderr.write(`Error: ${String(result.error || `${execution.provider} ${execution.kind} failed`).slice(0, 500)} + process6.stderr.write(`Error: ${String(result.error || `${execution.provider} ${execution.kind} failed`).slice(0, 500)} `); - process5.exitCode = 1; + process6.exitCode = 1; return; } const lines = []; @@ -10590,7 +11419,7 @@ function buildQueuedJob(execution, workspaceRoot, attemptId) { updatedAt: now, invocationId: RUN_CONTEXT.invocationId, attemptId, - hostSessionId: process5.env[SESSION_ID_ENV] || null, + hostSessionId: process6.env[SESSION_ID_ENV] || null, providerSessionId: null, ...execution.jobMeta }; @@ -10736,7 +11565,7 @@ async function startBackgroundExecution(execution, asJson) { defaultModel: execution.defaultModel || null, logFile: job.logFile }); - writeJobConfigFile(workspaceRoot, job.jobId, { + const config = { workspaceRoot, hostSessionId: job.hostSessionId, execution: { @@ -10750,24 +11579,16 @@ async function startBackgroundExecution(execution, asJson) { }, jobId: job.jobId, runContext + }; + const { child } = await startBackgroundWorker({ + workspaceRoot, + job, + execution, + runContext, + config, + companionPath: COMPANION_PATH, + env: process6.env }); - fs9.writeFileSync(job.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] started ${job.provider} ${job.kind} -`, { - encoding: "utf8", - mode: 384 - }); - const logFd = fs9.openSync(job.logFile, "a", 384); - const child = spawn2(process5.execPath, [COMPANION_PATH, "_job-worker", resolveJobConfigFile(workspaceRoot, job.jobId)], { - cwd: execution.cwd, - env: { ...process5.env }, - stdio: ["ignore", logFd, logFd], - detached: true - }); - child.once("error", (error) => { - recordBackgroundSpawnFailure(workspaceRoot, job.jobId, execution, runContext, error); - }); - child.unref(); - fs9.closeSync(logFd); const runningWrite = updateJobAtomically(workspaceRoot, job.jobId, (latest) => { if (!latest || latest.status !== "queued") return null; return { @@ -10780,10 +11601,10 @@ async function startBackgroundExecution(execution, asJson) { }; }); const runningJob = runningWrite.written ? runningWrite.job : getJob(workspaceRoot, job.jobId) || job; - if (!ACTIVE_JOB_STATUSES2.has(runningJob.status)) { + if (!ACTIVE_JOB_STATUSES3.has(runningJob.status)) { removeJobConfigFile(workspaceRoot, job.jobId); } - if (runContext && ACTIVE_JOB_STATUSES2.has(runningJob.status)) { + if (runContext && ACTIVE_JOB_STATUSES3.has(runningJob.status)) { await recordRunEventForContext(workspaceRoot, runContext, { command: execution.kind, kind: execution.kind, @@ -10811,7 +11632,7 @@ async function runSetup(rawArgs) { if (options["enable-review-gate"] && options["disable-review-gate"]) { throw new Error("Choose either --enable-review-gate or --disable-review-gate, not both."); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); if (options["enable-review-gate"]) { setConfig(workspaceRoot, "stopReviewGate", true); } else if (options["disable-review-gate"]) { @@ -10899,7 +11720,7 @@ async function probeProviderHealth({ report.probe.error = inspection.availabilityDetail || "provider CLI is unavailable"; } else if (provider === "claude") { try { - const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process5.cwd())); + const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process6.cwd())); report.loggedIn = auth.loggedIn ?? false; report.authDetail = auth.detail ?? auth.reason ?? null; report.model = auth.model ?? report.model; @@ -10924,7 +11745,7 @@ async function probeProviderHealth({ prompt: `Reply with ${HEALTH_SENTINEL} only.`, model, defaultModel: model ? null : readCachedProviderModel(workspaceRoot, provider), - cwd: process5.cwd(), + cwd: process6.cwd(), timeout, kind: "health", measurementScope: "request", @@ -10996,7 +11817,7 @@ async function runHealth(rawArgs) { valueOptions: ["provider", "model", "timeout-ms"], aliasMap: { m: "model" } }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const timeoutMs = options["timeout-ms"] ? Number.parseInt(options["timeout-ms"], 10) : TIMEOUTS_MS.health; const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : TIMEOUTS_MS.health; const hasSingleProvider = Boolean(options.provider || positionals[0]); @@ -11084,7 +11905,7 @@ async function runHealth(rawArgs) { })); const payload = buildHealthPayload(results); if (!payload.anyHealthy) { - process5.exitCode = 2; + process6.exitCode = 2; } output( options.json ? payload : [ @@ -11106,7 +11927,7 @@ function parsePromptExecution(rawArgs, kind) { positionals }); validateEffort(options.effort); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const userPrompt = remainingPositionals.join(" ").trim(); if (!userPrompt) { throw new Error(`Missing prompt text for ${kind}.`); @@ -11123,7 +11944,7 @@ function parsePromptExecution(rawArgs, kind) { userPrompt, model: options.model || null, defaultModel: cachedDefaultModel, - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, kind, { model: options.model || null, defaultModel: cachedDefaultModel @@ -11173,7 +11994,7 @@ function buildStopReviewGateExecution(rawArgs) { if (!prompt) { throw new Error("Missing prompt text for stop-review-gate."); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const defaultModel = readCachedProviderModel(workspaceRoot, provider); return { options, @@ -11188,14 +12009,14 @@ function buildStopReviewGateExecution(rawArgs) { userPrompt: "stop-time review gate", model: null, defaultModel, - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, "review", { defaultModel }), meta: { stopReviewGate: true }, jobMeta: {}, measurementScope: "request", runtimeOptions: buildReviewRuntimeOptions({ provider, - cwd: process5.cwd() + cwd: process6.cwd() }) } }; @@ -11215,11 +12036,11 @@ function buildReviewExecution(rawArgs, { adversarial }) { positionals }); assertReviewProviderSupported(provider); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const focus = remainingPositionals.join(" ").trim(); const maxDiffBytes = parseMaxDiffBytes(options["max-diff-bytes"]); const reviewContext = collectReviewContext({ - cwd: process5.cwd(), + cwd: process6.cwd(), scope: options.scope, baseRef: options.base || null, maxDiffBytes @@ -11256,7 +12077,7 @@ function buildReviewExecution(rawArgs, { adversarial }) { userPrompt: focus || `${adversarial ? "adversarial " : ""}review ${reviewContext.scope}`, model: options.model || null, defaultModel: readCachedProviderModel(workspaceRoot, provider), - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, adversarial ? "adversarial-review" : "review", { model: options.model || null, defaultModel: readCachedProviderModel(workspaceRoot, provider) @@ -11277,7 +12098,7 @@ function buildReviewExecution(rawArgs, { adversarial }) { measurementScope: "request", runtimeOptions: buildReviewRuntimeOptions({ provider, - cwd: process5.cwd() + cwd: process6.cwd() }) } }; @@ -11305,7 +12126,7 @@ async function runReviewCommand(rawArgs, { adversarial }) { ); return; } finally { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } } const { background } = parseExecutionMode(options); @@ -11320,7 +12141,7 @@ async function runStatus(rawArgs) { booleanOptions: ["json", "all", "wait"], valueOptions: ["job", "for", "timeout-ms"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const reference = options.job || positionals[0] || null; const selectorOptions = options.job ? { grammar: "explicit" } : void 0; const timeoutMs = options.wait ? parseStatusTimeoutMs(options["timeout-ms"]) : void 0; @@ -11329,7 +12150,7 @@ async function runStatus(rawArgs) { timeoutMs }); if (waited.waitTimedOut) { - process5.exitCode = 2; + process6.exitCode = 2; } if (options.json) { output(waited, true); @@ -11345,7 +12166,7 @@ async function runStatus(rawArgs) { for: options.for || "terminal" }); if (waited.waitTimedOut) { - process5.exitCode = 2; + process6.exitCode = 2; } if (waited.error && RUN_CONTEXT.outputMode === "json-v2") { throw cliError("job_not_found", `Job '${target.jobId}' was not found while waiting.`, { @@ -11386,7 +12207,7 @@ async function runResult(rawArgs) { booleanOptions: ["json"], valueOptions: ["job"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const job = resolveJobSelector( workspaceRoot, options.job || positionals[0] || "latest-terminal", @@ -11414,7 +12235,7 @@ async function runCancel(rawArgs) { booleanOptions: ["json"], valueOptions: ["job"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const selector = options.job || positionals[0] || "latest-active"; let job; try { @@ -11423,11 +12244,11 @@ async function runCancel(rawArgs) { if (RUN_CONTEXT.outputMode === "json-v2" || error.code === "ambiguous_selector") throw error; if (options.json) { output({ cancelled: false, reason: "not_found", jobId: positionals[0] || null }, true); - process5.exitCode = 1; + process6.exitCode = 1; return; } output(positionals[0] ? `Job ${positionals[0]} not found.` : "No active job found to cancel.", false); - process5.exitCode = 1; + process6.exitCode = 1; return; } const report = await cancelJob(workspaceRoot, job.jobId); @@ -11438,7 +12259,7 @@ async function runCancel(rawArgs) { } if (report.reason === "not_cancellable") { output(report, true); - process5.exitCode = 4; + process6.exitCode = 4; return; } throw new PolycliCliError({ @@ -11454,10 +12275,10 @@ async function runCancel(rawArgs) { output(`Cancelled job ${report.jobId}.`, false); } else if (report.reason === "not_cancellable") { output(`Job ${report.jobId} is already ${job.status}.`, false); - process5.exitCode = 4; + process6.exitCode = 4; } else { output(`Failed to cancel ${report.jobId}: ${report.error || report.reason}`, false); - process5.exitCode = 5; + process6.exitCode = 5; } } function formatMetric(metric) { @@ -11523,7 +12344,7 @@ async function runTiming(rawArgs) { booleanOptions: ["all", "json"], valueOptions: ["provider", "history"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const provider = options.provider ? resolveProvider({ provider: options.provider }).provider : null; const limit = parseHistoryLimit(options.history, { all: options.all }); const records = listTimingRecords(workspaceRoot, { @@ -11557,6 +12378,10 @@ async function runJobWorker(rawArgs) { const { workspaceRoot, execution, jobId, runContext } = payload; const current = claimBackgroundWorker(workspaceRoot, jobId); if (!current) { + if (!shouldRetainJobConfig(workspaceRoot, jobId)) { + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } return; } if (runContext?.runId) { @@ -11590,10 +12415,6 @@ async function runJobWorker(rawArgs) { appendPreview(current.logFile, execution.provider, event); } }); - if (result.timing) { - appendTimingRecord(workspaceRoot, result.timing); - } - cacheProviderModel(workspaceRoot, execution.provider, result.model); const compactResult = compactProviderResult(result); const sessionArtifactPath = resolveSessionArtifactPath( execution.provider, @@ -11601,9 +12422,13 @@ async function runJobWorker(rawArgs) { execution.cwd ); const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || latest.status === "cancelled" || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || latest.status === "cancelled" || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } + if (result.timing) { + appendTimingRecord(workspaceRoot, result.timing); + } + cacheProviderModel(workspaceRoot, execution.provider, result.model); const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); const finishedJob = { ...latest, @@ -11669,19 +12494,22 @@ async function runJobWorker(rawArgs) { // intent instead of exposing a terminal state with only half of its ledger pair. beforeStateCommit() { ensureTerminalRunEventsForContext(workspaceRoot, terminal); + removeJobStartFailureFile(workspaceRoot, jobId); } }; }); if (!write.written) { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } return; } removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } catch (error) { const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || latest.status === "cancelled" || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || latest.status === "cancelled" || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); @@ -11730,20 +12558,23 @@ async function runJobWorker(rawArgs) { }, beforeStateCommit() { ensureTerminalRunEventsForContext(workspaceRoot, terminal); + removeJobStartFailureFile(workspaceRoot, jobId); } }; }); if (!write.written) { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } return; } removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); throw error; } finally { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } } } @@ -11761,7 +12592,7 @@ function formatDebugRunsTable(runs) { return lines.join("\n"); } async function readDebugLedger({ raw = false } = {}) { - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); refreshJobsForLedgerRecovery(workspaceRoot); const events = await readRunLedgerEvents(workspaceRoot, { raw }); return { workspaceRoot, events }; @@ -11771,7 +12602,7 @@ async function runDebugTail(rawArgs) { booleanOptions: ["json", "wait"], valueOptions: ["after", "limit", "timeout-ms"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const result = await tailRunLedgerEvents(workspaceRoot, { runId: positionals[0] || null, after: options.after || null, @@ -11779,7 +12610,7 @@ async function runDebugTail(rawArgs) { wait: Boolean(options.wait), timeoutMs: options["timeout-ms"] == null ? void 0 : Number(options["timeout-ms"]) }); - if (result.waitTimedOut) process5.exitCode = 2; + if (result.waitTimedOut) process6.exitCode = 2; if (options.json) { output(result, true); return; @@ -11833,11 +12664,11 @@ function renderSessionsList(recorded, nonPurgeable = []) { } else { lines.push("Recorded upstream sessions (this workspace):"); for (const rec of recorded) { - const exists = fs9.existsSync(rec.sessionArtifactPath); + const exists = fs10.existsSync(rec.sessionArtifactPath); let size = ""; if (exists) { try { - size = ` ${formatBytes(fs9.lstatSync(rec.sessionArtifactPath).size)}`; + size = ` ${formatBytes(fs10.lstatSync(rec.sessionArtifactPath).size)}`; } catch { size = ""; } @@ -11875,7 +12706,7 @@ function renderPurgePlan(plan, summary, nonPurgeable = []) { return lines.join("\n"); } async function readSessionLedger() { - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const events = await readRunLedgerEvents(workspaceRoot); const recorded = collectRecordedArtifacts(events); const nonPurgeable = collectNonPurgeableSessions(events); @@ -11954,10 +12785,10 @@ function commandResolutionError(fullArgs, resolution, hostSurface) { ); } async function main() { - const fullArgs = process5.argv.slice(2); + const fullArgs = process6.argv.slice(2); RUN_CONTEXT.invocationId = `inv_${randomUUID3().replaceAll("-", "").slice(0, 20)}`; RUN_CONTEXT.authoritativeJsonWritten = false; - RUN_CONTEXT.hostSurface = resolveHostSurface(process5.env, import.meta.url); + RUN_CONTEXT.hostSurface = resolveHostSurface(process6.env, import.meta.url); RUN_CONTEXT.rawArgs = fullArgs; const outputModes = scanOutputModes(fullArgs); const command = fullArgs[0]; @@ -12002,12 +12833,12 @@ async function main() { } if (definition2.runTracked) { try { - RUN_CONTEXT.runId = resolveRunId({ runId: explicitRunId }, process5.env); + RUN_CONTEXT.runId = resolveRunId({ runId: explicitRunId }, process6.env); } catch (error) { if (!/^Invalid run id:/.test(error?.message || "")) throw error; const typed = cliError("invalid_argument", error.message, { argument: "--run-id", - value: explicitRunId || process5.env.POLYCLI_RUN_ID || null + value: explicitRunId || process6.env.POLYCLI_RUN_ID || null }); typed.legacyCode = "error"; throw typed; @@ -12015,15 +12846,15 @@ async function main() { } else { RUN_CONTEXT.runId = null; } - RUN_CONTEXT.workspaceSlug = computeWorkspaceSlug(resolveWorkspaceRoot(process5.cwd())); + RUN_CONTEXT.workspaceSlug = computeWorkspaceSlug(resolveWorkspaceRoot(process6.cwd())); if (!RUN_CONTEXT.runId) { return dispatchCommand(definition2.id, rawArgs); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); await recordRunEvent(workspaceRoot, { phase: "run_started", status: "started" }); try { const result = await dispatchCommand(definition2.id, rawArgs); - const failed = process5.exitCode != null && process5.exitCode !== 0; + const failed = process6.exitCode != null && process6.exitCode !== 0; try { await recordRunEvent(workspaceRoot, { phase: "run_summary", diff --git a/plugins/polycli-copilot/plugin.json b/plugins/polycli-copilot/plugin.json index 13c334d..65e5568 100644 --- a/plugins/polycli-copilot/plugin.json +++ b/plugins/polycli-copilot/plugin.json @@ -1,7 +1,7 @@ { "name": "polycli-copilot", "description": "GitHub Copilot CLI adapter for the shared polycli companion.", - "version": "0.6.30", + "version": "0.6.31", "author": { "name": "bing" }, diff --git a/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs b/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs index 314c4f7..ebfb554 100755 --- a/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs @@ -1,11 +1,10 @@ #!/usr/bin/env node // plugins/polycli/scripts/polycli-companion.mjs -import fs9 from "node:fs"; +import fs10 from "node:fs"; import path8 from "node:path"; -import process5 from "node:process"; +import process6 from "node:process"; import { randomUUID as randomUUID3 } from "node:crypto"; -import { spawn as spawn2 } from "node:child_process"; import { fileURLToPath } from "node:url"; // packages/polycli-utils/src/args.js @@ -383,8 +382,91 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { // packages/polycli-utils/src/process.js import { spawnSync } from "node:child_process"; import process3 from "node:process"; +var CONSERVATIVE_POINTER_BYTES = 8; +var WINDOWS_SAFE_ARGV_BUDGET_BYTES = 24 * 1024; +var POSIX_SAFE_ARGV_BUDGET_BYTES = 96 * 1024; +function stringStorageBytes(value) { + return Buffer.byteLength(String(value), "utf8") + 1; +} +function getSafeArgvBudgetBytes(platform = process3.platform) { + return platform === "win32" ? WINDOWS_SAFE_ARGV_BUDGET_BYTES : POSIX_SAFE_ARGV_BUDGET_BYTES; +} +function calculateArgvFootprint({ + command, + args = [], + env = process3.env +} = {}) { + const argv = [String(command ?? ""), ...args.map((arg) => String(arg))]; + const envEntries = Object.entries(env ?? {}).filter(([, value]) => value != null).map(([key, value]) => `${key}=${String(value)}`); + const argvBytes = argv.reduce((total, value) => total + stringStorageBytes(value), 0); + const envBytes = envEntries.reduce((total, value) => total + stringStorageBytes(value), 0); + const pointerBytes = (argv.length + envEntries.length + 2) * CONSERVATIVE_POINTER_BYTES; + const stringBytes = argvBytes + envBytes; + return { + totalBytes: stringBytes + pointerBytes, + stringBytes, + pointerBytes, + argvBytes, + envBytes, + argvCount: argv.length, + envCount: envEntries.length + }; +} +function preflightArgv(command, args = [], { + env = process3.env, + argvBudgetBytes = null, + argvBudgetHint = null +} = {}) { + const footprint = calculateArgvFootprint({ command, args, env }); + if (argvBudgetBytes == null) { + return { ok: true, budgetBytes: null, footprint, error: null }; + } + if (!Number.isSafeInteger(argvBudgetBytes) || argvBudgetBytes < 0) { + throw new TypeError("argvBudgetBytes must be a non-negative safe integer or null"); + } + if (footprint.totalBytes <= argvBudgetBytes) { + return { ok: true, budgetBytes: argvBudgetBytes, footprint, error: null }; + } + const counts = [ + `footprintBytes=${footprint.totalBytes}`, + `budgetBytes=${argvBudgetBytes}`, + `argvCount=${footprint.argvCount}`, + `envCount=${footprint.envCount}` + ].join(", "); + const suffix = typeof argvBudgetHint === "string" && argvBudgetHint.trim() ? ` ${argvBudgetHint.trim()}` : ""; + const error = Object.assign( + new Error(`argument list too long for the configured safe argv budget (${counts}).${suffix}`), + { + code: "E2BIG", + footprintBytes: footprint.totalBytes, + budgetBytes: argvBudgetBytes, + argvCount: footprint.argvCount, + envCount: footprint.envCount + } + ); + return { ok: false, budgetBytes: argvBudgetBytes, footprint, error }; +} function runCommand(command, args = [], options = {}) { - const result = spawnSync(command, args, { + const effectiveEnv = options.env ?? process3.env; + const preflight = preflightArgv(command, args, { + env: effectiveEnv, + argvBudgetBytes: options.argvBudgetBytes ?? null, + argvBudgetHint: options.argvBudgetHint ?? null + }); + if (!preflight.ok) { + return { + command, + args, + status: null, + signal: null, + stdout: "", + stderr: "", + error: preflight.error, + spawnErrorCode: "E2BIG" + }; + } + const spawnImpl = options.spawnImpl ?? spawnSync; + const result = spawnImpl(command, args, { cwd: options.cwd, env: options.env, encoding: "utf8", @@ -409,7 +491,8 @@ function runCommand(command, args = [], options = {}) { signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", - error + error, + spawnErrorCode: typeof error?.code === "string" ? error.code : null }; } function firstNonEmptyLine(text) { @@ -452,17 +535,41 @@ function formatCommandFailure(result) { } return parts.join(": "); } -async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SIGKILL", forceAfterMs = 5e3, ignoreMissing = true } = {}) { +async function terminateProcessTree(pid, { + signal = "SIGTERM", + forceSignal = "SIGKILL", + forceAfterMs = 5e3, + ignoreMissing = true, + deadlineAt = null, + platform = process3.platform, + runCommandImpl = runCommand, + now = Date.now, + sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)) +} = {}) { if (!Number.isInteger(pid) || pid <= 1) { throw new Error(`Invalid pid: ${pid}`); } + if (deadlineAt != null && !Number.isFinite(deadlineAt)) { + throw new TypeError("deadlineAt must be a finite epoch millisecond value or null"); + } + const remainingDeadlineMs2 = () => { + if (deadlineAt == null) return null; + const remainingMs = Math.floor(deadlineAt - now()); + if (remainingMs <= 0) { + const error = new Error("process termination deadline exceeded"); + error.code = "EDEADLINE"; + throw error; + } + return remainingMs; + }; const killOnce = (targetSignal) => { - if (process3.platform === "win32") { + const remainingMs = remainingDeadlineMs2(); + if (platform === "win32") { const args = ["/PID", String(pid), "/T"]; if (targetSignal === "SIGKILL") { args.push("/F"); } - const result = runCommand("taskkill", args); + const result = runCommandImpl("taskkill", args, remainingMs == null ? {} : { timeout: remainingMs }); if (result.error) { if (ignoreMissing && result.error.code === "ESRCH") return false; throw result.error; @@ -501,7 +608,9 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI if (!terminated || forceAfterMs <= 0) { return terminated; } - await new Promise((resolve) => setTimeout(resolve, forceAfterMs)); + const remainingBeforeWait = deadlineAt == null ? null : remainingDeadlineMs2(); + const waitMs = remainingBeforeWait == null ? forceAfterMs : Math.min(forceAfterMs, remainingBeforeWait); + await sleep(waitMs); try { killOnce(forceSignal); } catch (error) { @@ -514,6 +623,20 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI // packages/polycli-utils/src/session-id.js var UUID_SESSION_ID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i; +var RESUME_SESSION_ID_LINE_REGEX = new RegExp( + `^\\s*resume\\s+(${UUID_SESSION_ID_REGEX.source})\\s*$`, + "i" +); +function matchResumeSessionIdLine(text) { + if (typeof text !== "string" || text.length === 0) { + return null; + } + for (const line of text.split(/\r?\n/)) { + const match = line.match(RESUME_SESSION_ID_LINE_REGEX); + if (match) return match[1]; + } + return null; +} function matchSessionId(text, { patterns = [UUID_SESSION_ID_REGEX] } = {}) { if (typeof text !== "string" || text.length === 0) { return null; @@ -566,6 +689,16 @@ function formatProviderExitError(provider, status) { return `${provider} exited with code ${status}`; } function classifyProviderFailure(error, { provider = null } = {}) { + const structuredCode = typeof error?.code === "string" ? error.code.toUpperCase() : null; + if (structuredCode === "E2BIG") { + return "argument_list_too_long"; + } + if (structuredCode === "ENOENT") { + return "binary_missing"; + } + if (structuredCode === "ETIMEDOUT") { + return "timeout"; + } const text = typeof error === "string" ? error : String(error?.message ?? error ?? ""); if (!text.trim()) return null; if (provider === "qwen" && /\bmaximum session turn\b|\bmax(?:imum)? session turns?\b/i.test(text)) { @@ -574,6 +707,12 @@ function classifyProviderFailure(error, { provider = null } = {}) { if (/\bspawn\b.*\bENOENT\b|\bENOENT\b|\bnot found\b/i.test(text)) { return "binary_missing"; } + if (/\bE2BIG\b|\bargument list too long\b/i.test(text)) { + return "argument_list_too_long"; + } + if (/\b(output|capture|line buffer)\b.*\b(exceeded|overflow)\b/i.test(text)) { + return "output_overflow"; + } if (/\b(timed out|timeout)\b/i.test(text)) { return "timeout"; } @@ -643,6 +782,7 @@ function createLineDecoder({ encoding = "utf8", stripCarriageReturn = true, maxB } // packages/polycli-runtime/src/spawn.js +var DEFAULT_CAPTURE_LIMIT_BYTES = 1048576; function formatExitError(status, signal, { timedOut = false, aborted = false } = {}) { if (aborted) { return "process aborted"; @@ -658,6 +798,34 @@ function formatExitError(status, signal, { timedOut = false, aborted = false } = } return `process exited with code ${status}`; } +function normalizeCaptureLimit(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : DEFAULT_CAPTURE_LIMIT_BYTES; +} +function spawnFailureResult(error, captureLimitBytes) { + const spawnErrorCode = typeof error?.code === "string" ? error.code : null; + return { + ok: false, + status: null, + signal: null, + timedOut: false, + aborted: false, + stdout: "", + stderr: "", + stdoutBytes: 0, + stderrBytes: 0, + stdoutTruncated: false, + stderrTruncated: false, + captureLimitBytes, + outputOverflowStream: null, + closeTimedOut: false, + terminationErrors: [], + terminationFailure: null, + terminationReason: null, + spawnErrorCode, + error: String(error?.message ?? error), + errorCode: classifyProviderFailure(error) + }; +} function spawnStreamingCommand({ bin, args = [], @@ -668,8 +836,11 @@ function spawnStreamingCommand({ killGraceMs = 2e3, signal = null, maxBufferBytes = 1048576, + maxCaptureBytes = DEFAULT_CAPTURE_LIMIT_BYTES, + argvBudgetBytes = null, + argvBudgetHint = null, stdio = ["pipe", "pipe", "pipe"], - detached = false, + detached = process.platform !== "win32", unref = false, spawnImpl = spawn, onStdoutLine = () => { @@ -677,54 +848,85 @@ function spawnStreamingCommand({ onStderrChunk = () => { } } = {}) { + const captureLimitBytes = normalizeCaptureLimit(maxCaptureBytes); + const preflight = preflightArgv(bin, args, { + env: env ?? process.env, + argvBudgetBytes, + argvBudgetHint + }); + if (!preflight.ok) { + return Promise.resolve(spawnFailureResult(preflight.error, captureLimitBytes)); + } return new Promise((resolve) => { let child; try { child = spawnImpl(bin, args, { cwd, env, stdio, detached }); } catch (error) { - resolve({ - ok: false, - status: null, - signal: null, - timedOut: false, - stdout: "", - stderr: "", - error: error.message - }); + resolve(spawnFailureResult(error, captureLimitBytes)); return; } if (unref && typeof child.unref === "function") { child.unref(); } const decoder = createLineDecoder({ maxBufferBytes }); - let stdout = ""; - let stderr = ""; + const stdoutParts = []; + const stderrParts = []; + let stdoutCapturedBytes = 0; + let stderrCapturedBytes = 0; + let stdoutBytes = 0; + let stderrBytes = 0; + let stdoutTruncated = false; + let stderrTruncated = false; + let outputOverflowStream = null; let timedOut = false; let aborted = false; let settled = false; let timer = null; let forceTimer = null; + let settleTimer = null; let decoderError = null; + let lifecycleFailure = null; let terminationRequested = false; - const signalChild = (signal2) => { + const terminationErrors = []; + const setLifecycleFailure = (code, message) => { + if (!lifecycleFailure) { + lifecycleFailure = { code, message }; + } + }; + const recordTerminationError = (error, killSignal, target) => { + terminationErrors.push({ + signal: killSignal, + target, + code: typeof error?.code === "string" ? error.code : null, + message: String(error?.message ?? error).slice(0, 4096) + }); + }; + const trySignal = (killSignal, target, sender) => { try { - if (detached && Number.isInteger(child.pid) && child.pid > 0 && process.platform !== "win32") { - process.kill(-child.pid, signal2); - return; + const delivered = sender(); + if (delivered === false) { + const error = new Error(`${target} kill returned false`); + error.code = "KILL_RETURNED_FALSE"; + recordTerminationError(error, killSignal, target); + return false; } - child.kill(signal2); - } catch { + return true; + } catch (error) { + recordTerminationError(error, killSignal, target); + return false; } }; - const terminateChild = () => { - if (terminationRequested) return; - terminationRequested = true; - signalChild("SIGTERM"); - if (killGraceMs > 0) { - forceTimer = setTimeout(() => { - signalChild("SIGKILL"); - }, killGraceMs); + const signalChild = (killSignal) => { + const canSignalGroup = detached && Number.isInteger(child.pid) && child.pid > 0 && process.platform !== "win32"; + if (canSignalGroup) { + const groupSignalled = trySignal( + killSignal, + "process_group", + () => process.kill(-child.pid, killSignal) + ); + if (groupSignalled) return; } + trySignal(killSignal, "child", () => child.kill(killSignal)); }; const cleanup = () => { if (signal && typeof signal.removeEventListener === "function") { @@ -736,119 +938,215 @@ function spawnStreamingCommand({ child.off?.("error", handleChildError); child.off?.("close", handleChildClose); }; + const capturedStdout = () => stdoutParts.join(""); + const capturedStderr = () => Buffer.concat(stderrParts, stderrCapturedBytes).toString("utf8"); + const buildResult = (status, signalName, { closeTimedOut = false } = {}) => { + if (!lifecycleFailure) { + if (timedOut || status === 124) { + setLifecycleFailure("timeout", "process timed out"); + } else if (aborted || signalName === "SIGINT" || status === 130) { + setLifecycleFailure("cancelled", aborted ? "process aborted" : "process interrupted"); + } else if (signalName || status === 143) { + setLifecycleFailure("terminated", formatExitError(status, signalName)); + } + } + const stdout = capturedStdout(); + const stderr = capturedStderr(); + const ok = status === 0 && !lifecycleFailure && !closeTimedOut; + const error = ok ? null : lifecycleFailure?.message ?? (stderr.trim() || formatExitError(status, signalName, { timedOut, aborted })); + return { + ok, + status, + signal: signalName, + timedOut, + aborted, + stdout, + stderr, + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated, + captureLimitBytes, + outputOverflowStream, + closeTimedOut, + terminationErrors: terminationErrors.map((entry) => ({ ...entry })), + terminationFailure: closeTimedOut ? "close_timeout" : terminationErrors.length > 0 ? "signal_error" : null, + terminationReason: lifecycleFailure?.code ?? null, + spawnErrorCode: null, + error, + errorCode: lifecycleFailure?.code ?? classifyProviderFailure(error) + }; + }; const finish = (result) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); if (forceTimer) clearTimeout(forceTimer); + if (settleTimer) clearTimeout(settleTimer); cleanup(); resolve(result); }; - const decoderFailureResult = (status, signalName) => ({ - ok: false, - status, - signal: signalName, - timedOut, - stdout, - stderr, - error: decoderError - }); + const settleWithoutClose = () => { + if (settled) return; + finish(buildResult(null, null, { closeTimedOut: true })); + }; + const forceKill = () => { + if (settled) return; + signalChild("SIGKILL"); + if (settled) return; + const closeWaitMs = Math.max(10, Math.max(0, killGraceMs)); + settleTimer = setTimeout(settleWithoutClose, closeWaitMs); + }; + const terminateChild = (code, message) => { + setLifecycleFailure(code, message); + if (terminationRequested) return; + terminationRequested = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + signalChild("SIGTERM"); + if (settled) return; + forceTimer = setTimeout(forceKill, Math.max(0, killGraceMs)); + }; + const handleOutputOverflow = (streamName) => { + if (!outputOverflowStream) { + outputOverflowStream = streamName; + } + terminateChild( + "output_overflow", + `${streamName} capture exceeded maxCaptureBytes (${captureLimitBytes})` + ); + }; + const appendStdoutLine = (line, { trailingNewline = true } = {}) => { + const text = trailingNewline ? `${line} +` : line; + const bytes = Buffer.byteLength(text); + if (stdoutCapturedBytes + bytes > captureLimitBytes) { + stdoutTruncated = true; + return false; + } + stdoutParts.push(text); + stdoutCapturedBytes += bytes; + try { + onStdoutLine(line); + } catch { + } + return true; + }; const handleDecoderError = (error) => { if (decoderError || settled) return; decoderError = String(error?.message ?? error).slice(0, 4096); + stdoutTruncated = true; + outputOverflowStream = outputOverflowStream || "stdout"; child.stdout?.off?.("data", handleStdoutData); - terminateChild(); + terminateChild("output_overflow", decoderError); }; const abortHandler = () => { if (settled || aborted) return; aborted = true; - terminateChild(); + terminateChild("cancelled", "process aborted"); }; if (timeout != null) { timer = setTimeout(() => { - if (settled || timedOut) return; + if (settled || timedOut || terminationRequested) return; timedOut = true; - terminateChild(); + terminateChild("timeout", "process timed out"); }, timeout); } const handleChildError = (error) => { - if (decoderError) { + if (terminationRequested) { return; } finish({ - ok: false, - status: null, - signal: null, + ...spawnFailureResult(error, captureLimitBytes), timedOut, - stdout, - stderr, - error: error.message + aborted, + stdout: capturedStdout(), + stderr: capturedStderr(), + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated }); }; + const captureStderr = (chunk, { emit = true } = {}) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + stderrBytes += buffer.length; + const remaining = Math.max(captureLimitBytes - stderrCapturedBytes, 0); + const accepted = buffer.subarray(0, remaining); + if (accepted.length > 0) { + stderrParts.push(Buffer.from(accepted)); + stderrCapturedBytes += accepted.length; + if (emit) { + try { + onStderrChunk(accepted.toString("utf8")); + } catch { + } + } + } + if (accepted.length < buffer.length) { + stderrTruncated = true; + child.stderr?.off?.("data", handleStderrData); + handleOutputOverflow("stderr"); + } + }; const handleStdinError = (error) => { if (error?.code === "EPIPE" || error?.code === "ERR_STREAM_DESTROYED") { return; } - stderr += `${error.message} -`; + captureStderr(Buffer.from(`${error.message} +`), { emit: false }); }; const handleStdoutData = (chunk) => { if (settled) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + const previousBytes = stdoutBytes; + stdoutBytes += buffer.length; + const remaining = Math.max(captureLimitBytes - previousBytes, 0); + const accepted = buffer.subarray(0, remaining); let lines; try { - lines = decoder.push(chunk); + lines = accepted.length > 0 ? decoder.push(accepted) : []; } catch (error) { handleDecoderError(error); return; } for (const line of lines) { - stdout += `${line} -`; - try { - onStdoutLine(line); - } catch { + if (!appendStdoutLine(line)) { + child.stdout?.off?.("data", handleStdoutData); + handleOutputOverflow("stdout"); + return; } } + if (accepted.length < buffer.length) { + stdoutTruncated = true; + child.stdout?.off?.("data", handleStdoutData); + handleOutputOverflow("stdout"); + } }; const handleStderrData = (chunk) => { if (settled) return; - const text = chunk.toString("utf8"); - stderr += text; - try { - onStderrChunk(text); - } catch { - } + captureStderr(chunk); }; const handleChildClose = (status, signalName) => { - if (decoderError) { - finish(decoderFailureResult(status, signalName)); - return; - } - let lines; - try { - lines = decoder.end(); - } catch (error) { - handleDecoderError(error); - finish(decoderFailureResult(status, signalName)); - return; - } - for (const line of lines) { - stdout += `${line} -`; + if (!decoderError && !stdoutTruncated) { + let lines; try { - onStdoutLine(line); - } catch { + lines = decoder.end(); + } catch (error) { + handleDecoderError(error); + finish(buildResult(status, signalName)); + return; + } + for (const line of lines) { + if (!appendStdoutLine(line, { trailingNewline: false })) { + handleOutputOverflow("stdout"); + break; + } } } - finish({ - ok: status === 0 && !timedOut && !aborted, - status, - signal: signalName, - timedOut, - stdout, - stderr, - error: status === 0 && !timedOut && !aborted ? null : stderr.trim() || formatExitError(status, signalName, { timedOut, aborted }) - }); + finish(buildResult(status, signalName)); }; child.on("error", handleChildError); child.stdin?.on?.("error", handleStdinError); @@ -888,6 +1186,8 @@ var DEFAULT_TIMEOUT_MS = 9e5; var TMUX_START_TIMEOUT_MS = 3e4; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; +var SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var CLAUDE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var CLAUDE_TMUX_ENV_EXACT = /* @__PURE__ */ new Set([ "ALL_PROXY", @@ -989,33 +1289,26 @@ function buildClaudeInvocation({ maxTurns = 10, resumeSessionId = null, extraArgs = [], - bin = CLAUDE_BIN + bin = CLAUDE_BIN, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { const promptText = String(prompt ?? ""); - const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD; - const args = ["-p"]; - if (!useStdin) { - args.push(promptText); - } - args.push("--output-format", outputFormat); - if (outputFormat === "stream-json") { - args.push("--verbose"); - } - if (permissionMode) { - args.push("--permission-mode", permissionMode); - } - if (Number.isFinite(maxTurns) && maxTurns > 0) { - args.push("--max-turns", String(maxTurns)); - } - if (model) { - args.push("--model", model); - } - if (resumeSessionId) { - args.push("--resume", resumeSessionId); - } - if (extraArgs.length > 0) { - args.push(...extraArgs); - } + const buildArgs = (includePrompt) => { + const nextArgs = ["-p"]; + if (includePrompt) nextArgs.push(promptText); + nextArgs.push("--output-format", outputFormat); + if (outputFormat === "stream-json") nextArgs.push("--verbose"); + if (permissionMode) nextArgs.push("--permission-mode", permissionMode); + if (Number.isFinite(maxTurns) && maxTurns > 0) nextArgs.push("--max-turns", String(maxTurns)); + if (model) nextArgs.push("--model", model); + if (resumeSessionId) nextArgs.push("--resume", resumeSessionId); + if (extraArgs.length > 0) nextArgs.push(...extraArgs); + return nextArgs; + }; + const inlineArgs = buildArgs(true); + const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD || !preflightArgv(bin, inlineArgs, { env, argvBudgetBytes }).ok; + const args = useStdin ? buildArgs(false) : inlineArgs; return { bin, args, @@ -1390,13 +1683,8 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = } try { const parsed = JSON.parse(text.slice(jsonStart)); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); const response = typeof parsed.result === "string" ? parsed.result : ""; - const sessionId = parsed.session_id ?? parsed.sessionId ?? resolvedSession.sessionId ?? null; + const sessionId = parsed.session_id ?? parsed.sessionId ?? matchResumeSessionIdLine(stderr) ?? null; const errorText2 = isClaudeErrorResultEvent(parsed) ? getClaudeErrorText(parsed) : null; const processError = status === 0 ? null : String(stderr ?? "").trim() || formatProviderExitError("claude", status); return { @@ -1480,7 +1768,10 @@ function runClaudePrompt({ extraArgs = [], resumeSessionId = null, defaultModel = null, - bin = CLAUDE_BIN + bin = CLAUDE_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { const invocation = buildClaudeInvocation({ prompt, @@ -1490,17 +1781,25 @@ function runClaudePrompt({ maxTurns, resumeSessionId, extraArgs, - bin + bin, + env, + argvBudgetBytes }); const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, - input: invocation.input + input: invocation.input, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `claude timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `claude timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error), + spawnErrorCode: result.spawnErrorCode ?? null }; } return parseClaudeJsonResult(result.stdout, result.stderr, result.status, { @@ -1514,6 +1813,7 @@ function runClaudePromptStreaming({ maxTurns = 10, cwd, timeout = DEFAULT_TIMEOUT_MS, + killGraceMs, extraArgs = [], resumeSessionId = null, defaultModel = null, @@ -1525,7 +1825,8 @@ function runClaudePromptStreaming({ executionMode = "print", env = process.env, signalEmitter = process, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { if (executionMode === "tmux-tui") { return Promise.resolve(runClaudeTuiPrompt({ @@ -1552,7 +1853,9 @@ function runClaudePromptStreaming({ maxTurns, resumeSessionId, extraArgs, - bin + bin, + env, + argvBudgetBytes }); return spawnStreamingCommand({ bin: invocation.bin, @@ -1561,7 +1864,10 @@ function runClaudePromptStreaming({ env, input: invocation.input, timeout, + killGraceMs, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const parsed = parseStreamJsonLine(line, { allowPrefix: true }); if (parsed.ok) { @@ -1573,25 +1879,23 @@ function runClaudePromptStreaming({ } }).then((result) => { const parsed = parseClaudeStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const resultError = isClaudeErrorResultEvent(parsed.resultEvent) ? getClaudeErrorText(parsed.resultEvent) : null; const hasSuccessfulResult = Boolean( parsed.resultEvent && parsed.resultEvent.type === "result" && !isClaudeErrorResultEvent(parsed.resultEvent) ); - const completed = result.ok || result.timedOut && hasSuccessfulResult; + const recoveredTimeout = result.timedOut && hasSuccessfulResult && !result.closeTimedOut && !result.terminationFailure; + const completed = result.ok || recoveredTimeout; return { ...result, ...parsed, timedOut: completed ? false : result.timedOut, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), model: parsed.model ?? model ?? defaultModel, ok: completed && !resultError && hasVisibleText, - error: completed ? resultError || (hasVisibleText ? null : "claude produced no visible text") : result.error + error: completed ? resultError || (hasVisibleText ? null : "claude produced no visible text") : result.error, + errorCode: completed && !resultError ? hasVisibleText ? null : "no_visible_text" : result.errorCode, + terminationReason: completed ? null : result.terminationReason }; }); } @@ -1600,6 +1904,8 @@ function runClaudePromptStreaming({ var COPILOT_BIN = process.env.COPILOT_CLI_BIN || "copilot"; var DEFAULT_TIMEOUT_MS2 = 9e5; var AUTH_CHECK_TIMEOUT_MS2 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES2 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT2 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var COPILOT_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS2 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -1792,7 +2098,10 @@ function runCopilotPrompt({ allowAllPaths = true, allowAllUrls = true, noAskUser = true, - bin = COPILOT_BIN + bin = COPILOT_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES2 } = {}) { const invocation = buildCopilotInvocation({ prompt, @@ -1808,23 +2117,27 @@ function runCopilotPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT2 + }); if (result.error) { return { ok: false, resumeStatus: getCopilotResumeStatus(resumeSessionId, null), - error: result.error.code === "ETIMEDOUT" ? `copilot timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `copilot timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error, { provider: "copilot" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseCopilotStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getCopilotResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); - const sessionId = parsed.sessionId ?? resolvedSession.sessionId; + const sessionId = parsed.sessionId ?? matchResumeSessionIdLine(result.stderr); return { ok: result.status === 0 && !resultError && hasVisibleText, response: parsed.response, @@ -1851,7 +2164,9 @@ function runCopilotPromptStreaming({ onEvent = () => { }, bin = COPILOT_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES2 } = {}) { const invocation = buildCopilotInvocation({ prompt, @@ -1871,9 +2186,11 @@ function runCopilotPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT2, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -1884,14 +2201,9 @@ function runCopilotPromptStreaming({ } }).then((result) => { const parsed = parseCopilotStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getCopilotResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); - const sessionId = parsed.sessionId ?? resolvedSession.sessionId; + const sessionId = parsed.sessionId ?? matchResumeSessionIdLine(result.stderr); return { ...result, ...parsed, @@ -1908,6 +2220,8 @@ var GEMINI_BIN = process.env.GEMINI_CLI_BIN || "gemini"; var DEFAULT_TIMEOUT_MS3 = 3e5; var AUTH_CHECK_TIMEOUT_MS3 = 3e4; var PROMPT_STDIN_THRESHOLD2 = 1e5; +var SAFE_PROMPT_ARGV_BUDGET_BYTES3 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT3 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var GEMINI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var VALID_GEMINI_EFFORTS = /* @__PURE__ */ new Set(["low", "medium", "high"]); var TRANSIENT_PROBE_ERROR_PATTERNS3 = [ @@ -1944,16 +2258,23 @@ function buildGeminiInvocation({ outputFormat = "json", resumeSessionId = null, extraArgs = [], - bin = GEMINI_BIN + bin = GEMINI_BIN, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { const promptText = applyGeminiEffort(prompt, effort); - const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD2; - const args = ["-p", useStdin ? "" : promptText, "-o", outputFormat]; const resolvedApprovalMode = write ? "auto_edit" : approvalMode; - if (model) args.push("-m", model); - args.push("--approval-mode", resolvedApprovalMode); - if (resumeSessionId) args.push("--resume", resumeSessionId); - if (extraArgs.length > 0) args.push(...extraArgs); + const buildArgs = (inlinePrompt) => { + const nextArgs = ["-p", inlinePrompt ? promptText : "", "-o", outputFormat]; + if (model) nextArgs.push("-m", model); + nextArgs.push("--approval-mode", resolvedApprovalMode); + if (resumeSessionId) nextArgs.push("--resume", resumeSessionId); + if (extraArgs.length > 0) nextArgs.push(...extraArgs); + return nextArgs; + }; + const inlineArgs = buildArgs(true); + const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD2 || !preflightArgv(bin, inlineArgs, { env, argvBudgetBytes }).ok; + const args = useStdin ? buildArgs(false) : inlineArgs; return { bin, args, @@ -2085,8 +2406,12 @@ function runGeminiPrompt({ extraArgs = [], resumeSessionId = null, defaultModel = null, - bin = GEMINI_BIN + bin = GEMINI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { + const childEnv = buildGeminiEnv(env); const invocation = buildGeminiInvocation({ prompt, model, @@ -2096,18 +2421,25 @@ function runGeminiPrompt({ outputFormat: "json", resumeSessionId, extraArgs, - bin + bin, + env: childEnv, + argvBudgetBytes }); const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, input: invocation.input, - env: buildGeminiEnv() + env: childEnv, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT3 }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `gemini timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `gemini timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error), + spawnErrorCode: result.spawnErrorCode ?? null }; } return parseGeminiJsonResult(result.stdout, result.stderr, result.status, { @@ -2128,8 +2460,11 @@ function runGeminiPromptStreaming({ onEvent = () => { }, bin = GEMINI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { + const childEnv = buildGeminiEnv(env); const invocation = buildGeminiInvocation({ prompt, model, @@ -2139,16 +2474,20 @@ function runGeminiPromptStreaming({ outputFormat: "stream-json", resumeSessionId, extraArgs, - bin + bin, + env: childEnv, + argvBudgetBytes }); return spawnStreamingCommand({ bin: invocation.bin, args: invocation.args, cwd, - env: buildGeminiEnv(), + env: childEnv, input: invocation.input, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT3, onStdoutLine(line) { const parsed = parseStreamJsonLine(line, { allowPrefix: true }); if (parsed.ok) { @@ -2186,6 +2525,8 @@ import path2 from "node:path"; var KIMI_BIN = process.env.KIMI_CLI_BIN || "kimi"; var DEFAULT_TIMEOUT_MS4 = 9e5; var AUTH_CHECK_TIMEOUT_MS4 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES4 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT4 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var KIMI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS4 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -2297,13 +2638,28 @@ function runKimiPrompt({ resumeSessionId = null, resumeLast = false, defaultModel = null, - bin = KIMI_BIN + bin = KIMI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES4 } = {}) { const invocation = buildKimiInvocation({ prompt, model, resumeSessionId, resumeLast, extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT4 + }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `kimi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error: error2, errorCode: classifyProviderFailure(error2, { provider: "kimi" }) }; + return { + ok: false, + error: error2, + errorCode: classifyProviderFailure(result.error, { provider: "kimi" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } if (result.status !== 0) { const error2 = result.stderr.trim() || formatProviderExitError("kimi", result.status); @@ -2340,16 +2696,20 @@ function runKimiPromptStreaming({ onEvent = () => { }, bin = KIMI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES4 } = {}) { const invocation = buildKimiInvocation({ prompt, model, resumeSessionId, resumeLast, extraArgs, bin }); return spawnStreamingCommand({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT4, onStdoutLine(line) { const event = parseKimiEventLine(line); if (event) { @@ -2371,7 +2731,7 @@ function runKimiPromptStreaming({ model: parsed.model ?? model ?? defaultModel ?? readKimiDefaultModel(), ok, error, - errorCode: classifyProviderFailure(error, { provider: "kimi" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "kimi" }) }; }); } @@ -2380,6 +2740,8 @@ function runKimiPromptStreaming({ var QWEN_BIN = process.env.QWEN_CLI_BIN || "qwen"; var DEFAULT_TIMEOUT_MS5 = 3e5; var AUTH_CHECK_TIMEOUT_MS5 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES5 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT5 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; var PROXY_KEYS = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]; var NO_PROXY_DEFAULTS = ["localhost", "127.0.0.1"]; @@ -2627,7 +2989,9 @@ function runQwenPrompt({ appendSystem, appendDirs, extraArgs = [], - bin = QWEN_BIN + bin = QWEN_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES5 } = {}) { const invocation = buildQwenInvocation({ prompt, @@ -2647,18 +3011,21 @@ function runQwenPrompt({ const result = runCommand(invocation.bin, invocation.args, { cwd, env, - timeout + timeout, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT5 }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `qwen timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error: error2, errorCode: classifyProviderFailure(error2, { provider: "qwen" }) }; + return { + ok: false, + error: error2, + errorCode: classifyProviderFailure(result.error, { provider: "qwen" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } const parsed = parseQwenStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultEventError = extractQwenResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.status === 0 && !resultEventError && hasVisibleText ? null : result.stderr.trim() || resultEventError || (result.status === 0 ? "qwen produced no visible text" : formatProviderExitError("qwen", result.status)); @@ -2668,7 +3035,7 @@ function runQwenPrompt({ status: result.status, stderr: result.stderr, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), error, errorCode }; @@ -2692,7 +3059,8 @@ function runQwenPromptStreaming({ onEvent = () => { }, bin = QWEN_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES5 } = {}) { const invocation = buildQwenInvocation({ prompt, @@ -2715,10 +3083,12 @@ function runQwenPromptStreaming({ cwd, env, timeout, - detached: background, + detached: background ? true : void 0, unref: background, stdio: ["ignore", "pipe", "pipe"], spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT5, onStdoutLine(line) { if (!line.trim().startsWith("{")) return; try { @@ -2728,19 +3098,15 @@ function runQwenPromptStreaming({ } }).then((result) => { const parsed = parseQwenStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const resultEventError = extractQwenResultError(parsed.resultEvent); - const error = result.ok && !resultEventError ? hasVisibleText ? null : resultEventError || "qwen produced no visible text" : resultEventError || result.error; - const errorCode = resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" }); + const commonFailure = !result.ok && result.errorCode ? result.error : null; + const error = commonFailure || (result.ok && !resultEventError ? hasVisibleText ? null : resultEventError || "qwen produced no visible text" : resultEventError || result.error); + const errorCode = result.errorCode ?? (resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" })); return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), ok: result.ok && !resultEventError && hasVisibleText, error, errorCode @@ -2752,6 +3118,8 @@ function runQwenPromptStreaming({ var MMX_BIN = process.env.MMX_CLI_BIN || process.env.MINIMAX_CLI_BIN || "mmx"; var DEFAULT_TIMEOUT_MS6 = 12e4; var AUTH_CHECK_TIMEOUT_MS6 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES6 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT6 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var MINIMAX_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS6 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -2860,7 +3228,8 @@ function runMiniMaxPrompt({ defaultModel = null, env = process.env, bin = MMX_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES6 } = {}) { return new Promise((resolve) => { const invocation = buildMiniMaxInvocation({ prompt, model, extraArgs, bin }); @@ -2872,6 +3241,8 @@ function runMiniMaxPrompt({ timeout, stdio: ["ignore", "pipe", "pipe"], spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT6, onStdoutLine() { } }).then((result) => { @@ -2932,7 +3303,8 @@ async function runMiniMaxPromptStreaming({ onEvent = () => { }, bin = MMX_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES6 } = {}) { const events = []; return runMiniMaxPrompt({ @@ -2944,7 +3316,8 @@ async function runMiniMaxPromptStreaming({ defaultModel, env, bin, - spawnImpl + spawnImpl, + argvBudgetBytes }).then((result) => { try { const event = { @@ -2967,6 +3340,8 @@ var OPENCODE_BIN = process.env.OPENCODE_CLI_BIN || "opencode"; var DEFAULT_TIMEOUT_MS7 = 9e5; var AUTH_CHECK_TIMEOUT_MS7 = 3e4; var SESSION_EXPORT_TIMEOUT_MS = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES7 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT7 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var OPENCODE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS7 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3153,11 +3528,6 @@ function parseOpenCodeStreamText(text) { } function parseOpenCodeJsonResult(stdout, stderr, status, { defaultModel = null } = {}) { const parsed = parseOpenCodeStreamText(stdout); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getOpenCodeResultError(parsed.resultEvent); const sessionErrorMessage = getOpenCodeSessionErrorDataMessage(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); @@ -3166,7 +3536,7 @@ function parseOpenCodeJsonResult(stdout, stderr, status, { defaultModel = null } ok: status === 0 && !resultError && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(stderr), model: parsed.model ?? defaultModel, status, error, @@ -3214,7 +3584,9 @@ function runOpenCodePrompt({ variant = null, skipPermissions = true, defaultModel = null, - bin = OPENCODE_BIN + bin = OPENCODE_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES7 } = {}) { const invocation = buildOpenCodeInvocation({ prompt, @@ -3228,13 +3600,21 @@ function runOpenCodePrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, env }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT7 + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `opencode timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; return { ok: false, error, - errorCode: classifyProviderFailure(error, { provider: "opencode" }) + errorCode: classifyProviderFailure(result.error, { provider: "opencode" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseOpenCodeJsonResult(result.stdout, result.stderr, result.status, { @@ -3262,7 +3642,8 @@ function runOpenCodePromptStreaming({ onEvent = () => { }, bin = OPENCODE_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES7 } = {}) { const invocation = buildOpenCodeInvocation({ prompt, @@ -3283,6 +3664,8 @@ function runOpenCodePromptStreaming({ env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT7, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -3293,28 +3676,24 @@ function runOpenCodePromptStreaming({ } }).then((result) => { const parsed = parseOpenCodeStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); + const fallbackSessionId = matchResumeSessionIdLine(result.stderr); const resultError = getOpenCodeResultError(parsed.resultEvent); const sessionErrorMessage = getOpenCodeSessionErrorDataMessage(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); let resolvedModel = parsed.model ?? model ?? defaultModel; const ok = result.ok && !resultError && hasVisibleText; if (ok && !resolvedModel) { - resolvedModel = resolveOpenCodeSessionModel(parsed.sessionId ?? resolvedSession.sessionId, { cwd, env, bin }); + resolvedModel = resolveOpenCodeSessionModel(parsed.sessionId ?? fallbackSessionId, { cwd, env, bin }); } - const error = sessionErrorMessage || (result.ok ? resultError || (hasVisibleText ? null : "opencode produced no visible text") : result.error); + const error = (!result.ok && result.errorCode ? result.error : null) || sessionErrorMessage || (result.ok ? resultError || (hasVisibleText ? null : "opencode produced no visible text") : result.error); return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? fallbackSessionId, model: resolvedModel, ok, error, - errorCode: classifyProviderFailure(error, { provider: "opencode" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "opencode" }) }; }); } @@ -3324,6 +3703,8 @@ var PI_BIN = process.env.PI_CLI_BIN || "pi"; var DEFAULT_PI_MODEL = null; var DEFAULT_TIMEOUT_MS8 = 9e5; var AUTH_CHECK_TIMEOUT_MS8 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES8 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT8 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var PI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS8 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3489,7 +3870,10 @@ function runPiPrompt({ continueLast = false, noSession = false, defaultModel = null, - bin = PI_BIN + bin = PI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES8 } = {}) { const invocation = buildPiInvocation({ prompt, @@ -3501,11 +3885,20 @@ function runPiPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT8 + }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `pi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `pi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error, { provider: "pi" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parsePiStreamText(result.stdout); @@ -3542,7 +3935,9 @@ function runPiPromptStreaming({ onEvent = () => { }, bin = PI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES8 } = {}) { const invocation = buildPiInvocation({ prompt, @@ -3558,9 +3953,11 @@ function runPiPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT8, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -3596,6 +3993,8 @@ var CMD_BIN = process.env.CMD_CLI_BIN || "cmd"; var DEFAULT_CMD_MODEL = "deepseek"; var DEFAULT_TIMEOUT_MS9 = 9e5; var AUTH_CHECK_TIMEOUT_MS9 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES9 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT9 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var CMD_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS9 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3676,7 +4075,9 @@ function runCmdPrompt({ extraArgs = [], yolo = true, defaultModel = null, - bin = CMD_BIN + bin = CMD_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES9 } = {}) { const invocation = buildCmdInvocation({ prompt, @@ -3684,13 +4085,21 @@ function runCmdPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, env }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT9 + }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `cmd timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; return { ok: false, error: error2, - errorCode: classifyProviderFailure(error2, { provider: "cmd" }) + errorCode: classifyProviderFailure(result.error, { provider: "cmd" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseCmdTextResult(result.stdout); @@ -3721,7 +4130,8 @@ function runCmdPromptStreaming({ onEvent = () => { }, bin = CMD_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES9 } = {}) { const invocation = buildCmdInvocation({ prompt, @@ -3736,6 +4146,8 @@ function runCmdPromptStreaming({ env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT9, onStdoutLine(line) { const trimmed = line.trimEnd(); if (!trimmed.trim()) return; @@ -3754,7 +4166,7 @@ function runCmdPromptStreaming({ model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, ok: result.ok && hasVisibleText, error, - errorCode: classifyProviderFailure(error, { provider: "cmd" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "cmd" }) }; }); } @@ -3946,7 +4358,7 @@ function runAgyPromptStreaming({ const parsed = parseAgyTextResult(result.stdout); const filteredStderr = stripAgyBenignStderr(result.stderr); const hasVisibleText = Boolean(parsed.response.trim()); - const error = result.ok ? hasVisibleText ? null : "agy produced no visible text" : filteredStderr.trim() || result.error; + const error = !result.ok && result.errorCode ? result.error : result.ok ? hasVisibleText ? null : "agy produced no visible text" : filteredStderr.trim() || result.error; return { ...result, ...parsed, @@ -3955,7 +4367,7 @@ function runAgyPromptStreaming({ model: model ?? defaultModel ?? DEFAULT_AGY_MODEL, ok: result.ok && hasVisibleText, error, - errorCode: classifyProviderFailure(error, { provider: "agy" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "agy" }) }; }); } @@ -3964,6 +4376,8 @@ function runAgyPromptStreaming({ var GROK_BIN = process.env.GROK_CLI_BIN || "grok"; var DEFAULT_TIMEOUT_MS11 = 9e5; var AUTH_CHECK_TIMEOUT_MS11 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES10 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT10 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var DEFAULT_GROK_MODEL = "grok-4.5"; var GROK_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|not logged in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var SUCCESS_STOP_REASONS = /* @__PURE__ */ new Set(["endturn", "end_turn", "stop", "stop_sequence", "complete", "completed", "done", "finished", "maxtokens", "max_tokens", "length"]); @@ -4127,7 +4541,10 @@ function runGrokPrompt({ continueLast = false, extraArgs = [], defaultModel = null, - bin = GROK_BIN + bin = GROK_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES10 } = {}) { const invocation = buildGrokInvocation({ prompt, @@ -4141,10 +4558,22 @@ function runGrokPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT10 + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `grok timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "grok" }) }; + return { + ok: false, + error, + errorCode: classifyProviderFailure(result.error, { provider: "grok" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } const parsed = parseGrokJsonResult(result.stdout, result.stderr, result.status, { defaultModel: model ?? defaultModel @@ -4166,7 +4595,9 @@ function runGrokPromptStreaming({ onEvent = () => { }, bin = GROK_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES10 } = {}) { const invocation = buildGrokInvocation({ prompt, @@ -4184,9 +4615,11 @@ function runGrokPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT10, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -4200,14 +4633,14 @@ function runGrokPromptStreaming({ const hasVisibleText = Boolean(parsed.response.trim()); const stopReasonError = isNonSuccessStopReason(parsed.stopReason) ? `grok stopped with ${parsed.stopReason}` : null; const ok = result.ok && hasVisibleText && !parsed.providerError && !stopReasonError; - const error = ok ? null : parsed.providerError || stopReasonError || (result.ok ? "grok produced no visible text" : result.error); + const error = ok ? null : (!result.ok && result.errorCode ? result.error : null) || parsed.providerError || stopReasonError || (result.ok ? "grok produced no visible text" : result.error); return { ...result, ...parsed, model: model ?? defaultModel ?? DEFAULT_GROK_MODEL, ok, error, - errorCode: classifyProviderFailure(error, { provider: "grok" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "grok" }) }; }); } @@ -5653,11 +6086,11 @@ var COMMAND_DEFINITIONS = deepFreeze([ definition({ id: "tui", path: ["tui"], - summary: "Open the read-only terminal run inspector.", + summary: "Open the terminal run inspector; loading may update local recovery state.", usage: "polycli tui [--run-id ] [--history ]", surfaces: TERMINAL_SURFACE, dispatchTarget: "terminal-wrapper", - effects: { readsWorkspace: true }, + effects: { readsWorkspace: true, writesLocalState: true }, options: [ option("run-id", "string", "Select an initial run.", { valueName: "id" }), option("history", "integer", "Limit the run list.", { valueName: "n" }), @@ -6077,6 +6510,18 @@ function validateCommandPositionals(definition2, parsed, enumSources) { throw commandArgumentError(definition2, "Too many positional arguments.", { arguments: positionals }); } const positionalProvider = positionals[0] ?? null; + if (positionalProvider && explicitProvider) { + throw commandArgumentError( + definition2, + `Provider target cannot be supplied as positional provider '${positionalProvider}' together with --provider '${explicitProvider}'.`, + { + argument: positionalProvider, + positionalProvider, + explicitProvider, + conflictsWith: "--provider" + } + ); + } if (positionalProvider && !providers.includes(positionalProvider)) { throw commandArgumentError( definition2, @@ -6407,9 +6852,9 @@ function assertCommandRegistry({ assertCommandRegistry(); // plugins/polycli/scripts/lib/job-control.mjs -import fs6 from "node:fs"; +import fs7 from "node:fs"; import os4 from "node:os"; -import process4 from "node:process"; +import process5 from "node:process"; import { spawnSync as spawnSync3 } from "node:child_process"; // plugins/polycli/scripts/lib/state.mjs @@ -6550,8 +6995,13 @@ function describeStateRoot() { function stateRootDir() { return describeStateRoot().stateRoot; } -function resolveWorkspaceRoot(cwd = process.cwd()) { - const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { cwd }); +function resolveWorkspaceRoot(cwd = process.cwd(), { deadlineAt = null } = {}) { + const remainingMs = Number.isFinite(deadlineAt) ? Math.floor(deadlineAt - Date.now()) : null; + if (remainingMs != null && remainingMs <= 0) return path3.resolve(cwd); + const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { + cwd, + ...remainingMs == null ? {} : { timeout: remainingMs } + }); if (result.status === 0 && result.stdout.trim()) { return path3.resolve(result.stdout.trim()); } @@ -6575,6 +7025,9 @@ function resolveJobLogFile(workspaceRoot, jobId) { function resolveJobConfigFile(workspaceRoot, jobId) { return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); } +function resolveJobStartFailureFile(workspaceRoot, jobId) { + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.start-failure.json`); +} function chmodPrivateDir(dir) { try { fs3.chmodSync(dir, PRIVATE_DIR_MODE); @@ -6639,6 +7092,7 @@ function saveState(workspaceRoot, state, { preserveJobIds = [] } = {}) { if (job && job.jobId && !keptIds.has(job.jobId)) { removeJobFile(workspaceRoot, job.jobId); removeJobConfigFile(workspaceRoot, job.jobId); + removeJobStartFailureFile(workspaceRoot, job.jobId); removeJobLogFile(workspaceRoot, job.jobId); } } @@ -6653,7 +7107,7 @@ function updateState(workspaceRoot, mutate) { return saveState(workspaceRoot, state); }); } -function updateJobAtomically(workspaceRoot, jobId, buildNext) { +function updateJobAtomically(workspaceRoot, jobId, buildNext, { lockOptions = {} } = {}) { ensureStateDir(workspaceRoot); const lockPath = `${resolveStateFile(workspaceRoot)}.lock`; return withLockfile(lockPath, () => { @@ -6691,7 +7145,7 @@ function updateJobAtomically(workspaceRoot, jobId, buildNext) { } saveState(workspaceRoot, state, { preserveJobIds: [jobId] }); return { written: true, job, envelope }; - }); + }, lockOptions); } function upsertJob(workspaceRoot, jobPatch) { let savedJob = null; @@ -6772,182 +7226,36 @@ function removeJobConfigFile(workspaceRoot, jobId) { } catch { } } -function removeJobLogFile(workspaceRoot, jobId) { - try { - fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); - } catch { - } +function writeJobStartFailureFile(workspaceRoot, jobId, payload) { + ensureStateDir(workspaceRoot); + writeJsonAtomic(resolveJobStartFailureFile(workspaceRoot, jobId), payload, { mode: PRIVATE_FILE_MODE }); + return resolveJobStartFailureFile(workspaceRoot, jobId); } - -// plugins/polycli/scripts/lib/run-ledger.mjs -import { randomUUID as randomUUID2 } from "node:crypto"; -import { stat as fsStat } from "node:fs/promises"; -import path4 from "node:path"; - -// packages/polycli-utils/src/ndjson.js -import fs4 from "node:fs"; -function safeParseLine(line) { +function readJobStartFailureFile(workspaceRoot, jobId) { try { - return JSON.parse(line); + return JSON.parse(fs3.readFileSync(resolveJobStartFailureFile(workspaceRoot, jobId), "utf8")); } catch { return null; } } -function retainCompactedLines(lines, keepFrom, retentionGroupKey) { - const entries = []; - for (const line of lines) { - const record = safeParseLine(line); - if (record != null) { - entries.push({ line, record }); - } - } - if (typeof retentionGroupKey !== "function") { - return entries.slice(keepFrom).map((entry) => entry.line); - } - const grouped = entries.map((entry) => ({ - ...entry, - retentionGroup: retentionGroupKey(entry.record) - })); - const retainedStart = keepFrom < 0 ? Math.max(0, grouped.length + keepFrom) : Math.min(keepFrom, grouped.length); - const retainedGroups = new Set( - grouped.slice(keepFrom).map((entry) => entry.retentionGroup).filter((group) => group != null) - ); - return grouped.filter((entry, index) => index >= retainedStart || entry.retentionGroup != null && retainedGroups.has(entry.retentionGroup)).map((entry) => entry.line); -} -function chmodIfRequested(filePath, mode) { - if (mode === 438) return; +function removeJobStartFailureFile(workspaceRoot, jobId) { try { - fs4.chmodSync(filePath, mode); + fs3.unlinkSync(resolveJobStartFailureFile(workspaceRoot, jobId)); } catch { } } -function readNdjson(filePath) { - let text; +function removeJobLogFile(workspaceRoot, jobId) { try { - text = fs4.readFileSync(filePath, "utf8"); - } catch (error) { - if (error?.code === "ENOENT") { - return []; - } - throw error; - } - if (text.length === 0) { - return []; - } - const records = []; - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - const parsed = safeParseLine(trimmed); - if (parsed != null) { - records.push(parsed); - } - } - return records; -} -function appendNdjson(filePath, record, { - timeoutMs = 1e4, - staleMs = 3e4, - pollMs = 25, - maxBytes = null, - keepRatio = 0.5, - retentionGroupKey = null, - mode = 438 -} = {}) { - const lockPath = `${filePath}.lock`; - return withLockfile(lockPath, () => { - ensureParentDir(filePath); - let needsLeadingNewline = false; - try { - const stat = fs4.statSync(filePath); - if (stat.size > 0) { - const fd = fs4.openSync(filePath, "r"); - const lastByte = Buffer.alloc(1); - try { - fs4.readSync(fd, lastByte, 0, 1, stat.size - 1); - } finally { - fs4.closeSync(fd); - } - needsLeadingNewline = lastByte[0] !== 10; - } - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - const line = `${needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)} -`; - fs4.appendFileSync(filePath, line, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - if (maxBytes != null) { - const stat = fs4.statSync(filePath); - if (stat.size > maxBytes) { - const lines = fs4.readFileSync(filePath, "utf8").split("\n").filter(Boolean); - const validCount = lines.reduce((count, entry) => count + (safeParseLine(entry) != null ? 1 : 0), 0); - const keepFrom = Math.floor(validCount * (1 - keepRatio)); - const kept = retainCompactedLines(lines, keepFrom, retentionGroupKey); - writeFileAtomic(filePath, `${kept.join("\n")} -`, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - } - } - return true; - }, { timeoutMs, staleMs, pollMs }); -} -function appendNdjsonBatch(filePath, records, { - timeoutMs = 1e4, - staleMs = 3e4, - pollMs = 25, - maxBytes = null, - keepRatio = 0.5, - retentionGroupKey = null, - mode = 438 -} = {}) { - if (!Array.isArray(records)) { - throw new TypeError("records must be an array"); - } - if (records.length === 0) { - return true; + fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); + } catch { } - const serializedBatch = records.map((record) => { - const serialized = JSON.stringify(record); - if (typeof serialized !== "string") { - throw new TypeError("each record must be JSON-serializable"); - } - return serialized; - }); - const batch = `${serializedBatch.join("\n")} -`; - const lockPath = `${filePath}.lock`; - return withLockfile(lockPath, () => { - ensureParentDir(filePath); - let text = ""; - try { - text = fs4.readFileSync(filePath, "utf8"); - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - let nextText = `${text}${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}${batch}`; - if (maxBytes != null && Buffer.byteLength(nextText, "utf8") > maxBytes) { - const existing = text.split("\n").filter((entry) => safeParseLine(entry) != null); - const targetCount = Math.max( - serializedBatch.length, - Math.ceil((existing.length + serializedBatch.length) * keepRatio) - ); - const allLines = [...existing, ...serializedBatch]; - const keepFrom = Math.max(0, allLines.length - targetCount); - const kept = retainCompactedLines(allLines, keepFrom, retentionGroupKey); - nextText = `${kept.join("\n")} -`; - } - writeFileAtomic(filePath, nextText, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - return true; - }, { timeoutMs, staleMs, pollMs }); } +// plugins/polycli/scripts/lib/background-start.mjs +import fs5 from "node:fs"; +import process4 from "node:process"; +import { spawn as spawn2 } from "node:child_process"; + // plugins/polycli/scripts/lib/cli-contract.mjs var ERROR_CODES = /* @__PURE__ */ new Set([ "invalid_argument", @@ -7250,8 +7558,8 @@ function normalizeStatusListWait(wait, payload) { function normalizeSessionEntries(entries) { return clonePublicResult(Array.isArray(entries) ? entries : []); } -function serializeProviderCommand(commandId, payload, context) { - if (context.background === true || payload?.job && context.background !== false) { +function serializeProviderCommand(commandId, payload) { + if (payload?.job) { return { type: "job.started", job: normalizeV2Job(payload.job) @@ -7282,7 +7590,7 @@ function serializeV2Result(commandId, legacyPayload, context = {}) { }; } if (PROVIDER_COMMANDS.has(id)) { - return serializeProviderCommand(id, payload, context); + return serializeProviderCommand(id, payload); } if (id === "status") { if (payload.job) { @@ -7444,24 +7752,193 @@ function createV2ErrorEnvelope(error, context = {}) { } // plugins/polycli/scripts/lib/run-ledger.mjs -var MAX_LEDGER_BYTES = 2e6; -var KEEP_RATIO = 0.5; -var PRIVATE_FILE_MODE2 = 384; -var RUN_ID_RE = /^[A-Za-z0-9_.-]{1,96}$/; -var SECRET_LONG_OPT_RE = /(token|secret|password|api-?key|access-?key|credential)/i; -var SECRET_ENV_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_KEY|CREDENTIAL)/i; -var PROMPT_COMMANDS = /* @__PURE__ */ new Set(["ask", "rescue", "review", "adversarial-review"]); -var VALUE_OPTIONS = /* @__PURE__ */ new Set([ - "--provider", - "--model", - "--base", - "--scope", - "--resume", - "--effort", - "--run-id", - "--timeout-ms", - "--history" -]); +import { randomUUID as randomUUID2 } from "node:crypto"; +import { stat as fsStat } from "node:fs/promises"; +import path4 from "node:path"; + +// packages/polycli-utils/src/ndjson.js +import fs4 from "node:fs"; +function safeParseLine(line) { + try { + return JSON.parse(line); + } catch { + return null; + } +} +function retainCompactedLines(lines, keepFrom, retentionGroupKey) { + const entries = []; + for (const line of lines) { + const record = safeParseLine(line); + if (record != null) { + entries.push({ line, record }); + } + } + if (typeof retentionGroupKey !== "function") { + return entries.slice(keepFrom).map((entry) => entry.line); + } + const grouped = entries.map((entry) => ({ + ...entry, + retentionGroup: retentionGroupKey(entry.record) + })); + const retainedStart = keepFrom < 0 ? Math.max(0, grouped.length + keepFrom) : Math.min(keepFrom, grouped.length); + const retainedGroups = new Set( + grouped.slice(keepFrom).map((entry) => entry.retentionGroup).filter((group) => group != null) + ); + return grouped.filter((entry, index) => index >= retainedStart || entry.retentionGroup != null && retainedGroups.has(entry.retentionGroup)).map((entry) => entry.line); +} +function chmodIfRequested(filePath, mode) { + if (mode === 438) return; + try { + fs4.chmodSync(filePath, mode); + } catch { + } +} +function readNdjson(filePath) { + let text; + try { + text = fs4.readFileSync(filePath, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") { + return []; + } + throw error; + } + if (text.length === 0) { + return []; + } + const records = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parsed = safeParseLine(trimmed); + if (parsed != null) { + records.push(parsed); + } + } + return records; +} +function appendNdjson(filePath, record, { + timeoutMs = 1e4, + staleMs = 3e4, + pollMs = 25, + maxBytes = null, + keepRatio = 0.5, + retentionGroupKey = null, + mode = 438 +} = {}) { + const lockPath = `${filePath}.lock`; + return withLockfile(lockPath, () => { + ensureParentDir(filePath); + let needsLeadingNewline = false; + try { + const stat = fs4.statSync(filePath); + if (stat.size > 0) { + const fd = fs4.openSync(filePath, "r"); + const lastByte = Buffer.alloc(1); + try { + fs4.readSync(fd, lastByte, 0, 1, stat.size - 1); + } finally { + fs4.closeSync(fd); + } + needsLeadingNewline = lastByte[0] !== 10; + } + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + const line = `${needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)} +`; + fs4.appendFileSync(filePath, line, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + if (maxBytes != null) { + const stat = fs4.statSync(filePath); + if (stat.size > maxBytes) { + const lines = fs4.readFileSync(filePath, "utf8").split("\n").filter(Boolean); + const validCount = lines.reduce((count, entry) => count + (safeParseLine(entry) != null ? 1 : 0), 0); + const keepFrom = Math.floor(validCount * (1 - keepRatio)); + const kept = retainCompactedLines(lines, keepFrom, retentionGroupKey); + writeFileAtomic(filePath, `${kept.join("\n")} +`, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + } + } + return true; + }, { timeoutMs, staleMs, pollMs }); +} +function appendNdjsonBatch(filePath, records, { + timeoutMs = 1e4, + staleMs = 3e4, + pollMs = 25, + maxBytes = null, + keepRatio = 0.5, + retentionGroupKey = null, + mode = 438 +} = {}) { + if (!Array.isArray(records)) { + throw new TypeError("records must be an array"); + } + if (records.length === 0) { + return true; + } + const serializedBatch = records.map((record) => { + const serialized = JSON.stringify(record); + if (typeof serialized !== "string") { + throw new TypeError("each record must be JSON-serializable"); + } + return serialized; + }); + const batch = `${serializedBatch.join("\n")} +`; + const lockPath = `${filePath}.lock`; + return withLockfile(lockPath, () => { + ensureParentDir(filePath); + let text = ""; + try { + text = fs4.readFileSync(filePath, "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + let nextText = `${text}${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}${batch}`; + if (maxBytes != null && Buffer.byteLength(nextText, "utf8") > maxBytes) { + const existing = text.split("\n").filter((entry) => safeParseLine(entry) != null); + const targetCount = Math.max( + serializedBatch.length, + Math.ceil((existing.length + serializedBatch.length) * keepRatio) + ); + const allLines = [...existing, ...serializedBatch]; + const keepFrom = Math.max(0, allLines.length - targetCount); + const kept = retainCompactedLines(allLines, keepFrom, retentionGroupKey); + nextText = `${kept.join("\n")} +`; + } + writeFileAtomic(filePath, nextText, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + return true; + }, { timeoutMs, staleMs, pollMs }); +} + +// plugins/polycli/scripts/lib/run-ledger.mjs +var MAX_LEDGER_BYTES = 2e6; +var KEEP_RATIO = 0.5; +var PRIVATE_FILE_MODE2 = 384; +var RUN_ID_RE = /^[A-Za-z0-9_.-]{1,96}$/; +var SECRET_LONG_OPT_RE = /(token|secret|password|api-?key|access-?key|credential)/i; +var SECRET_ENV_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_KEY|CREDENTIAL)/i; +var PROMPT_COMMANDS = /* @__PURE__ */ new Set(["ask", "rescue", "review", "adversarial-review"]); +var VALUE_OPTIONS = /* @__PURE__ */ new Set([ + "--provider", + "--model", + "--base", + "--scope", + "--resume", + "--effort", + "--run-id", + "--timeout-ms", + "--history" +]); var SHORT_VALUE_OPTIONS = /* @__PURE__ */ new Set(["-m"]); var FOCUS_VALUE_OPTIONS = /* @__PURE__ */ new Set(["--focus"]); var VALID_HOST_SURFACES = /* @__PURE__ */ new Set([ @@ -7610,11 +8087,20 @@ function redactLedgerError(error) { if (message == null || message === "") return null; return { message: sanitizePublicErrorMessage(message, 300) }; } +function sanitizeLedgerPreview(preview) { + if (preview == null) return null; + const text = String(preview); + return sanitizePublicErrorMessage(text, text.length); +} function redactTerminalDescriptor(descriptor) { if (!descriptor || typeof descriptor !== "object") return descriptor ?? null; return { ...descriptor, - events: Array.isArray(descriptor.events) ? descriptor.events.map((event) => ({ ...event, error: redactLedgerError(event.error) })) : descriptor.events + events: Array.isArray(descriptor.events) ? descriptor.events.map((event) => ({ + ...event, + error: redactLedgerError(event.error), + ...Object.prototype.hasOwnProperty.call(event, "preview") ? { preview: sanitizeLedgerPreview(event.preview) } : {} + })) : descriptor.events }; } function createRunLedgerEvent(event = {}) { @@ -7643,7 +8129,7 @@ function createRunLedgerEvent(event = {}) { providerSessionId, timingRef: event.timingRef ?? null, error: redactLedgerError(event.error), - preview: event.preview ?? null, + preview: sanitizeLedgerPreview(event.preview), stdoutBytes: event.stdoutBytes ?? null, stderrBytes: event.stderrBytes ?? null, durationMs: event.durationMs ?? null, @@ -7677,7 +8163,7 @@ function appendRunLedgerEvent(workspaceRoot, event) { }); return full; } -function appendRunLedgerEvents(workspaceRoot, events) { +function appendRunLedgerEvents(workspaceRoot, events, lockOptions = {}) { if (!Array.isArray(events)) { throw new TypeError("events must be an array"); } @@ -7691,6 +8177,7 @@ function appendRunLedgerEvents(workspaceRoot, events) { workspaceSlug: event.workspaceSlug ?? workspaceSlug })); appendNdjsonBatch(file, full, { + ...lockOptions, maxBytes: MAX_LEDGER_BYTES, keepRatio: KEEP_RATIO, retentionGroupKey: terminalLedgerRetentionGroupKey, @@ -7880,7 +8367,7 @@ function buildExpectedTerminalPair(events) { expected: rawExpected.map((event) => ({ ...event, terminalDescriptor: descriptor })) }; } -function ensureRunLedgerTerminalPair(workspaceRoot, events) { +function ensureRunLedgerTerminalPair(workspaceRoot, events, { lockOptions = {} } = {}) { if (!Array.isArray(events) || events.length !== 2) { throw new TypeError("terminal ledger pair must contain exactly two events"); } @@ -7897,7 +8384,7 @@ function ensureRunLedgerTerminalPair(workspaceRoot, events) { if (ambiguousLegacy.length > 0) { throw new Error(`Incomplete or conflicting terminal ledger pair for ${identityLabel}`); } - return appendRunLedgerEvents(workspaceRoot, expected); + return appendRunLedgerEvents(workspaceRoot, expected, lockOptions); } if (existing.length === 1) { const [partial] = existing; @@ -7920,7 +8407,7 @@ function ensureRunLedgerTerminalPair(workspaceRoot, events) { ...missing, terminalDescriptor: repairDescriptor }; - return [...existing, ...appendRunLedgerEvents(workspaceRoot, [repair])]; + return [...existing, ...appendRunLedgerEvents(workspaceRoot, [repair], lockOptions)]; } if (!terminalPairMatches(existing, expected)) { throw new Error(`Incomplete or conflicting terminal ledger pair for ${identityLabel}`); @@ -8251,40 +8738,342 @@ function summarizeRunLedger(events) { incrementCount(failureClassCounts, classifyRunFailure(event)); } return { - runId: group.runId, - commands: group.commands, - startedAt: group.events[0]?.at || null, - updatedAt: group.events.at(-1)?.at || null, - providerCount: new Set(decisions.map((event) => event.provider)).size, - adoptedCount: decisions.filter((event) => event.status === "adopted").length, - skippedCount: decisions.filter((event) => event.status === "skipped").length, - failedCount: decisions.filter((event) => event.status === "failed").length, - failureClassCounts + runId: group.runId, + commands: group.commands, + startedAt: group.events[0]?.at || null, + updatedAt: group.events.at(-1)?.at || null, + providerCount: new Set(decisions.map((event) => event.provider)).size, + adoptedCount: decisions.filter((event) => event.status === "adopted").length, + skippedCount: decisions.filter((event) => event.status === "skipped").length, + failedCount: decisions.filter((event) => event.status === "failed").length, + failureClassCounts + }; + }); +} +function buildRunExplanation(events, runId) { + const group = groupRunLedgerEvents(events).get(runId); + if (!group) { + return { runId, found: false, text: `Run ${runId} was not found.`, events: [] }; + } + const decisions = group.projectedEvents.filter((event) => event.phase === "provider_decision"); + const lines = decisions.map( + (event) => `${event.provider || "run"} ${event.status}${event.reason ? ` (${event.reason})` : ""}` + ); + for (const event of group.projectedEvents.filter((item) => item.phase === "attempt_result" && item.status === "failed")) { + const subject = event.provider || event.jobId || "run"; + lines.push(`attempt ${subject} failed (${classifyRunFailure(event)})`); + } + const terminalProviders = new Set(group.projectedEvents.filter((event) => TERMINAL_LEDGER_PHASES.has(event.phase) && event.provider).map((event) => event.provider)); + for (const provider of new Set(group.projectedEvents.filter((event) => (event.phase === "attempt_started" || event.phase === "job_started") && event.provider).map((event) => event.provider))) { + if (!terminalProviders.has(provider)) lines.push(`${provider} unfinished`); + } + return { runId, found: true, text: lines.join("\n"), events: group.events }; +} + +// plugins/polycli/scripts/lib/background-start.mjs +var ACTIVE_JOB_STATUSES2 = /* @__PURE__ */ new Set(["queued", "running"]); +var TERMINAL_JOB_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); +function blocksFailureFinalizer(envelope) { + return TERMINAL_JOB_STATUSES2.has(envelope?.job?.status) || envelope?.cancellationIntent?.status === "requested" && ACTIVE_JOB_STATUSES2.has(envelope?.job?.status); +} +function cleanupRuntimeOptions(runtimeOptions = {}) { + const cleanupPaths = Array.isArray(runtimeOptions?.cleanupPaths) ? runtimeOptions.cleanupPaths : []; + for (const cleanupPath of cleanupPaths) { + if (typeof cleanupPath !== "string" || cleanupPath.trim() === "") continue; + try { + fs5.rmSync(cleanupPath, { recursive: true, force: true }); + } catch { + } + } +} +function buildRunEvent(runContext, base) { + if (!runContext?.runId) return null; + const command = base.command || runContext.command; + return { + runId: runContext.runId, + hostSurface: runContext.hostSurface || "unknown", + argv: runContext.argv || [], + invocationId: runContext.invocationId ?? null, + attemptId: runContext.attemptId ?? null, + jobId: runContext.jobId ?? base.jobId ?? null, + command, + commands: Array.from(new Set([ + ...runContext.commands || [], + command, + ...base.commands || [] + ].filter(Boolean))).sort(), + ...base + }; +} +function prepareFailureEvents(runContext, events) { + const material = events.map((event) => buildRunEvent(runContext, event)).filter(Boolean); + if (material.length === 0) return []; + const terminalDescriptor = createTerminalLedgerDescriptor(material); + return material.map((event) => ({ ...event, terminalDescriptor })); +} +function safeRunIdentity(jobId, execution, runContext) { + return { + runId: runContext?.runId ?? null, + invocationId: runContext?.invocationId ?? null, + attemptId: runContext?.attemptId ?? null, + command: runContext?.command || execution.kind, + commands: Array.from(new Set([ + ...runContext?.commands || [], + runContext?.command, + execution.kind + ].filter(Boolean))).sort(), + hostSurface: runContext?.hostSurface || "unknown", + jobId, + provider: execution.provider, + kind: execution.kind, + logFile: runContext?.logFile ?? null + }; +} +function buildStartFailureRecovery(jobId, execution, runContext, error, runtimeOptions) { + const safeMessage = sanitizePublicErrorMessage(error?.message || error, 300); + const identity = safeRunIdentity(jobId, execution, runContext); + const terminalReason = `${execution.kind}_failed`; + const terminalEvents = prepareFailureEvents(identity, [ + { + command: identity.command, + kind: execution.kind, + provider: execution.provider, + phase: "attempt_result", + status: "failed", + reason: terminalReason, + attempt: { ordinal: 1 }, + jobId, + error: { message: safeMessage }, + logFile: identity.logFile + }, + { + command: identity.command, + kind: execution.kind, + provider: execution.provider, + phase: "provider_decision", + status: "failed", + reason: terminalReason, + jobId + } + ]); + return { + version: 1, + jobId, + provider: execution.provider, + kind: execution.kind, + error: safeMessage, + recordedAt: (/* @__PURE__ */ new Date()).toISOString(), + identity, + terminalDescriptor: terminalEvents[0]?.terminalDescriptor ?? null, + ownedCleanupPaths: Array.isArray(runtimeOptions?.cleanupPaths) ? runtimeOptions.cleanupPaths.filter((entry) => typeof entry === "string" && entry.trim() !== "") : [] + }; +} +function materialExecution(recovery) { + return { + provider: recovery.provider, + kind: recovery.kind, + runtimeOptions: { cleanupPaths: recovery.ownedCleanupPaths || [] } + }; +} +function finalizeBackgroundStartFailure(workspaceRoot, recovery, { lockOptions = {} } = {}) { + const { jobId } = recovery; + const execution = materialExecution(recovery); + const runContext = recovery.identity; + const safeMessage = recovery.error; + return updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { + if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || blocksFailureFinalizer(storedEnvelope)) { + return null; + } + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); + const failedJob = { + ...latest, + invocationId: latest.invocationId ?? runContext?.invocationId ?? null, + attemptId: latest.attemptId ?? runContext?.attemptId ?? null, + status: "failed", + pid: null, + finishedAt, + updatedAt: finishedAt, + error: safeMessage + }; + const terminalReason = `${execution.kind}_failed`; + const terminalEvents = prepareFailureEvents(runContext, [ + { + command: runContext?.command || execution.kind, + kind: execution.kind, + provider: execution.provider, + phase: "attempt_result", + status: "failed", + reason: terminalReason, + attempt: { ordinal: 1 }, + jobId, + error: { message: safeMessage }, + logFile: failedJob.logFile || runContext?.logFile || null + }, + { + command: runContext?.command || execution.kind, + kind: execution.kind, + provider: execution.provider, + phase: "provider_decision", + status: "failed", + reason: terminalReason, + jobId + } + ]).map((event) => ({ + ...event, + terminalDescriptor: recovery.terminalDescriptor || event.terminalDescriptor + })); + const terminalDescriptor = recovery.terminalDescriptor || terminalEvents[0]?.terminalDescriptor || null; + return { + job: failedJob, + envelope: { + job: failedJob, + result: { ok: false, error: safeMessage }, + terminalReason, + terminalDescriptor + }, + beforeStateCommit() { + if (terminalEvents.length > 0) { + ensureRunLedgerTerminalPair(workspaceRoot, terminalEvents); + } + } }; - }); + }, { lockOptions }); } -function buildRunExplanation(events, runId) { - const group = groupRunLedgerEvents(events).get(runId); - if (!group) { - return { runId, found: false, text: `Run ${runId} was not found.`, events: [] }; +function persistedJobBlocksFailure(workspaceRoot, jobId) { + const job = getJob(workspaceRoot, jobId); + const envelope = readJobFile(resolveJobFile(workspaceRoot, jobId)); + return !job || !ACTIVE_JOB_STATUSES2.has(job.status) || blocksFailureFinalizer(envelope); +} +function recoverBackgroundStartFailure(workspaceRoot, jobId, options = {}) { + const recovery = readJobStartFailureFile(workspaceRoot, jobId); + if (!recovery || recovery.version !== 1 || recovery.jobId !== jobId) { + return { written: false, finalizationError: null }; } - const decisions = group.projectedEvents.filter((event) => event.phase === "provider_decision"); - const lines = decisions.map( - (event) => `${event.provider || "run"} ${event.status}${event.reason ? ` (${event.reason})` : ""}` - ); - for (const event of group.projectedEvents.filter((item) => item.phase === "attempt_result" && item.status === "failed")) { - const subject = event.provider || event.jobId || "run"; - lines.push(`attempt ${subject} failed (${classifyRunFailure(event)})`); + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; } - const terminalProviders = new Set(group.projectedEvents.filter((event) => TERMINAL_LEDGER_PHASES.has(event.phase) && event.provider).map((event) => event.provider)); - for (const provider of new Set(group.projectedEvents.filter((event) => (event.phase === "attempt_started" || event.phase === "job_started") && event.provider).map((event) => event.provider))) { - if (!terminalProviders.has(provider)) lines.push(`${provider} unfinished`); + try { + const write = finalizeBackgroundStartFailure(workspaceRoot, recovery, options); + if (write.written) { + cleanupRuntimeOptions({ cleanupPaths: recovery.ownedCleanupPaths }); + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } + return { written: write.written, finalizationError: null }; + } catch (finalizationError) { + return { written: false, finalizationError }; } - return { runId, found: true, text: lines.join("\n"), events: group.events }; +} +function recordBackgroundStartFailure(workspaceRoot, jobId, execution, runContext, error, options = {}) { + const configFile = resolveJobConfigFile(workspaceRoot, jobId); + const config = readJobConfigFile(configFile); + const runtimeOptions = config?.execution?.runtimeOptions ?? execution.runtimeOptions; + const recovery = buildStartFailureRecovery(jobId, execution, runContext, error, runtimeOptions); + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + writeJobStartFailureFile(workspaceRoot, jobId, recovery); + } catch { + } + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + const write = finalizeBackgroundStartFailure(workspaceRoot, recovery, options); + if (write.written) { + cleanupRuntimeOptions(runtimeOptions); + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } + return { written: write.written, finalizationError: null }; + } catch (finalizationError) { + cleanupRuntimeOptions(runtimeOptions); + return { written: false, finalizationError }; + } +} +async function startBackgroundWorker({ + workspaceRoot, + job, + execution, + runContext, + config, + companionPath, + env = process4.env, + failureFinalizationOptions = {} +}, { + writeConfigFile = writeJobConfigFile, + writeLogFile = fs5.writeFileSync, + openLogFile = fs5.openSync, + closeLogFile = fs5.closeSync, + spawnWorker = spawn2 +} = {}) { + let child = null; + let logFd = null; + let startFailure = null; + let startWarning = null; + let closeWarning = null; + const configFile = resolveJobConfigFile(workspaceRoot, job.jobId); + try { + writeConfigFile(workspaceRoot, job.jobId, config); + writeLogFile(job.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] started ${job.provider} ${job.kind} +`, { + encoding: "utf8", + mode: 384 + }); + logFd = openLogFile(job.logFile, "a", 384); + child = spawnWorker(process4.execPath, [companionPath, "_job-worker", configFile], { + cwd: execution.cwd, + env: { ...env }, + stdio: ["ignore", logFd, logFd], + detached: true + }); + try { + child.once("error", (asyncError) => { + recordBackgroundStartFailure( + workspaceRoot, + job.jobId, + execution, + runContext, + asyncError, + failureFinalizationOptions + ); + }); + child.unref(); + } catch (error) { + startWarning = error; + } + } catch (error) { + startFailure = error; + } finally { + if (logFd != null) { + try { + closeLogFile(logFd); + } catch (error) { + if (child) closeWarning = error; + else if (!startFailure) startFailure = error; + } + } + } + if (startFailure) { + recordBackgroundStartFailure( + workspaceRoot, + job.jobId, + execution, + runContext, + startFailure, + failureFinalizationOptions + ); + throw startFailure; + } + return { child, startWarning, closeWarning }; } // plugins/polycli/scripts/lib/sessions.mjs -import fs5 from "node:fs"; +import fs6 from "node:fs"; import os3 from "node:os"; import path5 from "node:path"; import { createHash } from "node:crypto"; @@ -8330,7 +9119,7 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho case "kimi": { let realCwd = workspaceRoot; try { - realCwd = fs5.realpathSync(workspaceRoot); + realCwd = fs6.realpathSync(workspaceRoot); } catch { } const slug = `wd_${path5.basename(realCwd)}_${createHash("sha256").update(realCwd).digest("hex").slice(0, 12)}`; @@ -8355,7 +9144,7 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho return { path: null, reason: `no artifact derivation for provider ${provider ?? "?"}` }; } } -function recordArtifactPath(candidate, { homedir, lstatFn = fs5.lstatSync, realpathFn = fs5.realpathSync, existsFn = fs5.existsSync } = {}) { +function recordArtifactPath(candidate, { homedir, lstatFn = fs6.lstatSync, realpathFn = fs6.realpathSync, existsFn = fs6.existsSync } = {}) { if (!candidate || typeof candidate.path !== "string") return null; const { path: candidatePath, provider } = candidate; if (!existsFn(candidatePath)) return null; @@ -8432,7 +9221,7 @@ function collectNonPurgeableSessions(events = [], { homedir = os3.homedir() } = } return out; } -function planPurge({ recorded = [], homedir, lstatFn = fs5.lstatSync, realpathFn = fs5.realpathSync, existsFn = fs5.existsSync, sizeFn } = {}) { +function planPurge({ recorded = [], homedir, lstatFn = fs6.lstatSync, realpathFn = fs6.realpathSync, existsFn = fs6.existsSync, sizeFn } = {}) { const deletable = []; const skipped = []; const defaultSizeFn = (p) => { @@ -8499,7 +9288,7 @@ function planPurge({ recorded = [], homedir, lstatFn = fs5.lstatSync, realpathFn } return { deletable, skipped }; } -function executePurge(plan, { confirm = false, rmFn = (p) => fs5.rmSync(p, { recursive: true, force: true }) } = {}) { +function executePurge(plan, { confirm = false, rmFn = (p) => fs6.rmSync(p, { recursive: true, force: true }) } = {}) { const deletable = plan?.deletable ?? []; const skipped = plan?.skipped ?? []; if (!confirm) { @@ -8522,27 +9311,74 @@ var TERMINAL_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancel var WAIT_TARGETS = /* @__PURE__ */ new Set(["terminal", "completed", "failed", "cancelled"]); var DEFAULT_STATUS_LIMIT = 8; var MAX_SELECTOR_CANDIDATES = 8; +function createDeadlineError() { + const error = new Error("cancellation deadline exceeded"); + error.code = "EDEADLINE"; + return error; +} +function remainingDeadlineMs(deadlineAt) { + return Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : null; +} +function deadlineLockOptions(deadlineAt) { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs == null) return {}; + if (remainingMs <= 0) throw createDeadlineError(); + return { + timeoutMs: Math.max(1, Math.ceil(remainingMs)), + pollMs: Math.max(1, Math.min(25, Math.ceil(remainingMs))) + }; +} +function isDeadlineFailure(error, deadlineAt) { + return Number.isFinite(deadlineAt) && ["EDEADLINE", "ELOCKTIMEOUT", "ETIMEDOUT"].includes(error?.code); +} +async function awaitWithinDeadline(promise, deadlineAt) { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs == null) { + await promise; + return true; + } + if (remainingMs <= 0) return false; + let timer = null; + try { + return await Promise.race([ + Promise.resolve(promise).then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), remainingMs); + }) + ]); + } finally { + if (timer) clearTimeout(timer); + } +} function isProcessAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { - process4.kill(pid, 0); + process5.kill(pid, 0); return true; } catch { return false; } } -function isExpectedWorkerProcess(pid, configFile) { +function isExpectedWorkerProcess(pid, configFile, { + deadlineAt = null, + platform = process5.platform, + spawnProcess = spawnSync3 +} = {}) { if (!Number.isInteger(pid) || pid <= 0 || !configFile) return null; + const remainingMs = Number.isFinite(deadlineAt) ? Math.floor(deadlineAt - Date.now()) : null; + if (remainingMs != null && remainingMs <= 0) return null; try { - const result = process4.platform === "win32" ? spawnSync3("powershell.exe", [ + const spawnOptions = { + encoding: "utf8", + stdio: "pipe", + ...remainingMs == null ? {} : { timeout: remainingMs } + }; + const result = platform === "win32" ? spawnProcess("powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine` - ], { encoding: "utf8", stdio: "pipe" }) : spawnSync3("ps", ["-ww", "-o", "command=", "-p", String(pid)], { - encoding: "utf8", - stdio: "pipe" - }); + ], spawnOptions) : spawnProcess("ps", ["-ww", "-o", "command=", "-p", String(pid)], spawnOptions); if (result.error) return null; if (result.status !== 0) return false; return result.stdout.includes("_job-worker") && result.stdout.includes(configFile); @@ -8556,7 +9392,7 @@ function sortJobsNewestFirst(jobs) { function readProgressPreview(logFile, maxLines = 4) { if (!logFile) return ""; try { - const lines = fs6.readFileSync(logFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const lines = fs7.readFileSync(logFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); return lines.slice(-maxLines).join("\n"); } catch { return ""; @@ -8573,6 +9409,9 @@ function enrichJob(workspaceRoot, job) { function isTerminalEnvelope(envelope) { return Boolean(envelope?.job && TERMINAL_STATUSES2.has(envelope.job.status)); } +function hasPendingCancellationIntent(envelope) { + return envelope?.cancellationIntent?.status === "requested"; +} function buildRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result = null, reason = "worker_exited", terminalDescriptor = null } = {}) { const command = runContext.command || config?.execution?.kind || job.kind || null; const provider = runContext.provider || config?.execution?.provider || job.provider || null; @@ -8671,7 +9510,18 @@ function applyTerminalDescriptor(events, terminalDescriptor) { }); } function prepareRecoveredTerminalEvents(workspaceRoot, job, config, { result = null, reason = "worker_exited", terminalDescriptor = null } = {}) { - const runContext = config?.runContext; + const descriptorEvent = terminalDescriptor?.events?.[0] ?? null; + const runContext = config?.runContext || (terminalDescriptor?.runId ? { + runId: terminalDescriptor.runId, + command: descriptorEvent?.command ?? job.kind ?? null, + kind: descriptorEvent?.kind ?? job.kind ?? null, + provider: descriptorEvent?.provider ?? job.provider ?? null, + hostSurface: descriptorEvent?.hostSurface || "unknown", + invocationId: terminalDescriptor.invocationId ?? job.invocationId ?? null, + attemptId: terminalDescriptor.attemptId ?? job.attemptId ?? null, + jobId: terminalDescriptor.jobId ?? job.jobId ?? null, + logFile: job.logFile ?? null + } : null); if (!runContext?.runId) return { events: [], terminalDescriptor }; const events = buildRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result, @@ -8684,9 +9534,9 @@ function prepareRecoveredTerminalEvents(workspaceRoot, job, config, { result = n terminalDescriptor: descriptor }; } -function ensureRecoveredTerminalEvents(workspaceRoot, prepared) { +function ensureRecoveredTerminalEvents(workspaceRoot, prepared, { lockOptions = {} } = {}) { if (prepared.events.length > 0) { - ensureRunLedgerTerminalPair(workspaceRoot, prepared.events); + ensureRunLedgerTerminalPair(workspaceRoot, prepared.events, { lockOptions }); } } function cleanupRuntimePaths(config) { @@ -8695,16 +9545,26 @@ function cleanupRuntimePaths(config) { for (const cleanupPath of cleanupPaths) { if (typeof cleanupPath !== "string" || !cleanupPath) continue; try { - fs6.rmSync(cleanupPath, { recursive: true, force: true }); + fs7.rmSync(cleanupPath, { recursive: true, force: true }); } catch { } } } function refreshJob(workspaceRoot, job) { if (!job || !ACTIVE_STATUSES2.has(job.status)) { + if (job?.jobId && TERMINAL_STATUSES2.has(job.status)) { + removeJobStartFailureFile(workspaceRoot, job.jobId); + } return job ? enrichJob(workspaceRoot, job) : null; } const storedEnvelope = readJobFile(resolveJobFile(workspaceRoot, job.jobId)); + if (hasPendingCancellationIntent(storedEnvelope)) { + return enrichJob(workspaceRoot, job); + } + const startFailureRecovery = recoverBackgroundStartFailure(workspaceRoot, job.jobId); + if (startFailureRecovery.written) { + return enrichJob(workspaceRoot, getJob(workspaceRoot, job.jobId) || job); + } if (!job.pid && !isTerminalEnvelope(storedEnvelope)) { return enrichJob(workspaceRoot, job); } @@ -8778,8 +9638,12 @@ function refreshJob(workspaceRoot, job) { const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, job.jobId)); cleanupRuntimePaths(config); removeJobConfigFile(workspaceRoot, job.jobId); + removeJobStartFailureFile(workspaceRoot, job.jobId); } const current = write.written ? write.job : getJob(workspaceRoot, job.jobId) || job; + if (TERMINAL_STATUSES2.has(current?.status)) { + removeJobStartFailureFile(workspaceRoot, job.jobId); + } return enrichJob(workspaceRoot, current); } catch { return enrichJob(workspaceRoot, getJob(workspaceRoot, job.jobId) || job); @@ -8787,11 +9651,11 @@ function refreshJob(workspaceRoot, job) { } function buildStatusSnapshot(workspaceRoot, { showAll = false } = {}) { const refreshed = sortJobsNewestFirst(listJobs(workspaceRoot)).map((job) => refreshJob(workspaceRoot, job)); - const limited = showAll ? refreshed : refreshed.slice(0, DEFAULT_STATUS_LIMIT); + const recent = refreshed.filter((job) => TERMINAL_STATUSES2.has(job.status)); return { totalJobs: refreshed.length, - running: limited.filter((job) => ACTIVE_STATUSES2.has(job.status)), - recent: limited.filter((job) => TERMINAL_STATUSES2.has(job.status)) + running: refreshed.filter((job) => ACTIVE_STATUSES2.has(job.status)), + recent: showAll ? recent : recent.slice(0, DEFAULT_STATUS_LIMIT) }; } function refreshJobsForLedgerRecovery(workspaceRoot) { @@ -8974,84 +9838,94 @@ async function waitForJob(workspaceRoot, jobId, options = {}) { async function cancelJob(workspaceRoot, jobId, { terminate = terminateProcessTree, isWorkerAlive = isProcessAlive, - isExpectedWorker = isExpectedWorkerProcess + isExpectedWorker = isExpectedWorkerProcess, + deadlineAt = null } = {}) { let pidToKill = null; let configForCleanup = null; let cancellationEnvelope = null; let reason = null; const requestedAt = (/* @__PURE__ */ new Date()).toISOString(); - const intentWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { - if (!current) { - reason = "not_found"; - return null; - } - if (!ACTIVE_STATUSES2.has(current.status)) { - reason = "not_cancellable"; - return null; - } - const resumingCancellation = storedEnvelope?.job?.status === "cancelled"; - if (isTerminalEnvelope(storedEnvelope) && !resumingCancellation) { - reason = "not_cancellable"; - return null; - } - pidToKill = current.pid ?? null; - configForCleanup = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); - const intentJob = resumingCancellation ? { - ...current, - ...storedEnvelope.job, - status: "cancelled", - pid: null, - finishedAt: storedEnvelope.job.finishedAt || requestedAt, - updatedAt: requestedAt - } : { - ...current, - status: "cancelled", - pid: null, - finishedAt: requestedAt, - updatedAt: requestedAt - }; - const cancellationResult = resumingCancellation ? storedEnvelope.result || { ok: false, error: "cancelled" } : { ok: false, error: "cancelled" }; - const terminal = prepareRecoveredTerminalEvents(workspaceRoot, intentJob, configForCleanup, { - result: cancellationResult, - reason: "cancelled", - terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null - }); - cancellationEnvelope = { - job: intentJob, - result: cancellationResult, - terminalReason: "cancelled", - terminalDescriptor: terminal.terminalDescriptor - }; - return { - // Do not make the state terminal yet. It remains the recovery point if this process exits - // after persisting the intent but before the worker receives its signal. - job: current, - envelope: cancellationEnvelope, - beforeStateCommit() { - ensureRecoveredTerminalEvents(workspaceRoot, terminal); + let intentWrite; + try { + intentWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { + if (!current) { + reason = "not_found"; + return null; } - }; - }); + if (!ACTIVE_STATUSES2.has(current.status)) { + reason = "not_cancellable"; + return null; + } + const resumingCancellation = hasPendingCancellationIntent(storedEnvelope); + if (isTerminalEnvelope(storedEnvelope) && !resumingCancellation) { + reason = "not_cancellable"; + return null; + } + pidToKill = current.pid ?? null; + configForCleanup = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); + cancellationEnvelope = resumingCancellation ? storedEnvelope : { + job: current, + cancellationIntent: { + status: "requested", + requestedAt + } + }; + return { + // Do not make the state or envelope terminal yet. Both remain recovery points if this + // process exits after persisting the intent but before the worker receives its signal. + job: current, + envelope: cancellationEnvelope + }; + }, { lockOptions: deadlineLockOptions(deadlineAt) }); + } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + throw error; + } if (!intentWrite.written) { return { cancelled: false, reason: reason || "not_cancellable", jobId }; } const configFile = resolveJobConfigFile(workspaceRoot, jobId); - if (pidToKill && isWorkerAlive(pidToKill)) { - if (!isExpectedWorker(pidToKill, configFile)) { + if (Number.isInteger(pidToKill) && pidToKill > 1 && isWorkerAlive(pidToKill)) { + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + if (!configForCleanup || isExpectedWorker(pidToKill, configFile, { deadlineAt }) !== true) { + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } return { cancelled: false, reason: "worker_identity_unverified", jobId }; } try { - await terminate(pidToKill, { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs != null && remainingMs <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + const terminatedWithinDeadline = await awaitWithinDeadline(terminate(pidToKill, { signal: "SIGINT", forceSignal: "SIGKILL", - forceAfterMs: 2e3 - }); + forceAfterMs: remainingMs == null ? 2e3 : Math.max(1, Math.min(2e3, Math.floor(remainingMs))), + deadlineAt + }), deadlineAt); + if (!terminatedWithinDeadline) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } return { cancelled: false, reason: "kill_failed", jobId, killWarning: error.message }; } if (isWorkerAlive(pidToKill)) { - const postSignalIdentity = isExpectedWorker(pidToKill, configFile); + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + const postSignalIdentity = isExpectedWorker(pidToKill, configFile, { deadlineAt }); + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } if (postSignalIdentity === true) { return { cancelled: false, reason: "worker_still_running", jobId }; } @@ -9061,55 +9935,69 @@ async function cancelJob(workspaceRoot, jobId, { } } let finalConfig = configForCleanup; - const finalWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { - if (!current) { - reason = "not_found"; - return null; - } - if (current.status === "cancelled") return null; - if (!ACTIVE_STATUSES2.has(current.status) || storedEnvelope?.job?.status !== "cancelled") { - reason = "cancellation_finalization_pending"; - return null; - } - finalConfig = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)) || finalConfig; - const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); - const finalJob = { - ...current, - ...storedEnvelope.job, - status: "cancelled", - pid: null, - finishedAt: storedEnvelope.job.finishedAt || finishedAt, - updatedAt: finishedAt - }; - const result = storedEnvelope.result || cancellationEnvelope?.result || { ok: false, error: "cancelled" }; - const terminal = prepareRecoveredTerminalEvents(workspaceRoot, finalJob, finalConfig, { - result, - reason: "cancelled", - terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null - }); - return { - job: finalJob, - envelope: { - ...storedEnvelope, - job: finalJob, - result, - terminalReason: "cancelled", - terminalDescriptor: terminal.terminalDescriptor - }, - beforeStateCommit() { - ensureRecoveredTerminalEvents(workspaceRoot, terminal); + let finalWrite; + try { + finalWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { + if (!current) { + reason = "not_found"; + return null; } - }; - }); + if (current.status === "cancelled") return null; + if (!ACTIVE_STATUSES2.has(current.status) || !hasPendingCancellationIntent(storedEnvelope)) { + reason = "cancellation_finalization_pending"; + return null; + } + finalConfig = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)) || finalConfig; + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); + const storedCancelledJob = storedEnvelope?.job?.status === "cancelled" ? storedEnvelope.job : null; + const finalJob = { + ...current, + ...storedCancelledJob || {}, + status: "cancelled", + pid: null, + finishedAt: storedCancelledJob?.finishedAt || finishedAt, + updatedAt: finishedAt + }; + const result = storedEnvelope.result || cancellationEnvelope?.result || { ok: false, error: "cancelled" }; + const terminal = prepareRecoveredTerminalEvents(workspaceRoot, finalJob, finalConfig, { + result, + reason: "cancelled", + terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null + }); + return { + job: finalJob, + envelope: { + ...storedEnvelope, + job: finalJob, + result, + terminalReason: "cancelled", + terminalDescriptor: terminal.terminalDescriptor, + cancellationIntent: storedEnvelope.cancellationIntent + }, + beforeStateCommit() { + ensureRecoveredTerminalEvents(workspaceRoot, terminal, { + lockOptions: deadlineLockOptions(deadlineAt) + }); + } + }; + }, { lockOptions: deadlineLockOptions(deadlineAt) }); + } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + throw error; + } if (!finalWrite.written) { const current = getJob(workspaceRoot, jobId); if (current?.status === "cancelled") { + removeJobStartFailureFile(workspaceRoot, jobId); return { cancelled: true, jobId }; } return { cancelled: false, reason: reason || "cancellation_finalization_pending", jobId }; } cleanupRuntimePaths(finalConfig); removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); return { cancelled: true, jobId }; } @@ -9271,7 +10159,7 @@ function resolveProvider({ provider, positionals = [] } = {}) { } // plugins/polycli/scripts/lib/review.mjs -import fs7 from "node:fs"; +import fs8 from "node:fs"; import os5 from "node:os"; import path6 from "node:path"; var DEFAULT_MAX_DIFF_BYTES = null; @@ -9331,7 +10219,7 @@ function git(cwd, args) { return runCommand("git", args, { cwd }); } function makeReviewTempDir(prefix) { - return fs7.mkdtempSync(path6.join(os5.tmpdir(), `polycli-review-${prefix}-`)); + return fs8.mkdtempSync(path6.join(os5.tmpdir(), `polycli-review-${prefix}-`)); } function assertNoReviewConstraintOverride(provider, runtimeOptions = {}) { const extraArgs = Array.isArray(runtimeOptions.extraArgs) ? runtimeOptions.extraArgs : []; @@ -9608,7 +10496,7 @@ function summarizeTimingRecords(records) { } // plugins/polycli/scripts/lib/preview.mjs -import fs8 from "node:fs"; +import fs9 from "node:fs"; var PREVIEW_MAX_LINES = 10; var PREVIEW_TAIL_CACHE = /* @__PURE__ */ new Map(); var PRIVATE_FILE_MODE4 = 384; @@ -9697,7 +10585,7 @@ function summarizeEventText(provider, event) { } return ""; } -function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PREVIEW_TAIL_CACHE } = {}) { +function appendPreview(logFile, provider, event, { fsImpl = fs9, tailCache = PREVIEW_TAIL_CACHE } = {}) { const text = summarizeEventText(provider, event); if (!text) return; const lines = String(text).split(/\r?\n/).map((line) => collapseWhitespace(line)).filter(Boolean).slice(0, PREVIEW_MAX_LINES); @@ -9708,9 +10596,9 @@ function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PRE } fsImpl.appendFileSync(logFile, `${lines.join("\n")} `, { encoding: "utf8", mode: PRIVATE_FILE_MODE4 }); - if (fsImpl === fs8) { + if (fsImpl === fs9) { try { - fs8.chmodSync(logFile, PRIVATE_FILE_MODE4); + fs9.chmodSync(logFile, PRIVATE_FILE_MODE4); } catch { } } @@ -9719,7 +10607,7 @@ function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PRE // plugins/polycli/scripts/polycli-companion.mjs var COMPANION_PATH = fileURLToPath(import.meta.url); -var BUILD_VERSION = true ? "0.6.30" : "0.0.0-dev"; +var BUILD_VERSION = true ? "0.6.31" : "0.0.0-dev"; var BUILD_VERSION_SOURCE = true ? "bundled-release" : "development"; var JOB_PREFIXES = { ask: "pa", @@ -9756,8 +10644,8 @@ function resolveTimeoutMs(provider, kind, { model = null, defaultModel = null } } var HEALTH_SENTINEL = "POLYCLI_HEALTH_OK"; var SESSION_ID_ENV = "POLYCLI_COMPANION_SESSION_ID"; -var TERMINAL_JOB_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); -var ACTIVE_JOB_STATUSES2 = /* @__PURE__ */ new Set(["queued", "running"]); +var TERMINAL_JOB_STATUSES3 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); +var ACTIVE_JOB_STATUSES3 = /* @__PURE__ */ new Set(["queued", "running"]); var RUN_CONTEXT = { invocationId: null, runId: null, @@ -9821,21 +10709,24 @@ function ensureTerminalRunEventsForContext(workspaceRoot, prepared) { return prepared.events.length > 0 ? ensureRunLedgerTerminalPair(workspaceRoot, prepared.events) : []; } function hasTerminalJobEnvelope(envelope) { - return TERMINAL_JOB_STATUSES2.has(envelope?.job?.status); + return TERMINAL_JOB_STATUSES3.has(envelope?.job?.status); +} +function blocksBackgroundWorkerCommit(envelope) { + return hasTerminalJobEnvelope(envelope) || hasPendingCancellationIntent(envelope); } function claimBackgroundWorker(workspaceRoot, jobId) { const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || !ACTIVE_JOB_STATUSES3.has(latest.status) || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } - if (latest.pid != null && latest.pid !== process5.pid) { + if (latest.pid != null && latest.pid !== process6.pid) { return null; } return { job: { ...latest, status: "running", - pid: process5.pid, + pid: process6.pid, updatedAt: (/* @__PURE__ */ new Date()).toISOString() } }; @@ -9846,68 +10737,6 @@ function shouldRetainJobConfig(workspaceRoot, jobId) { const current = getJob(workspaceRoot, jobId); return current?.status === "queued" || current?.status === "running"; } -function recordBackgroundSpawnFailure(workspaceRoot, jobId, execution, runContext, error) { - const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); - try { - const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || hasTerminalJobEnvelope(storedEnvelope)) { - return null; - } - const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); - const failedJob = { - ...latest, - ...execution.jobMeta, - status: "failed", - pid: null, - finishedAt, - updatedAt: finishedAt, - error: error.message - }; - const terminalReason = `${execution.kind}_failed`; - const terminalEvents = [ - { - command: runContext?.command || execution.kind, - kind: execution.kind, - provider: execution.provider, - phase: "attempt_result", - status: "failed", - reason: terminalReason, - attempt: { ordinal: 1 }, - jobId, - error: { message: String(error.message || error).slice(0, 300) }, - logFile: failedJob.logFile || null - }, - { - command: runContext?.command || execution.kind, - kind: execution.kind, - provider: execution.provider, - phase: "provider_decision", - status: "failed", - reason: terminalReason, - jobId - } - ]; - const terminal = prepareTerminalRunEventsForContext(runContext, terminalEvents); - return { - job: failedJob, - envelope: { - job: failedJob, - result: { ok: false, error: error.message }, - terminalReason, - terminalDescriptor: terminal.terminalDescriptor - }, - beforeStateCommit() { - ensureTerminalRunEventsForContext(workspaceRoot, terminal); - } - }; - }); - if (write.written) { - cleanupRuntimeOptions(config?.execution?.runtimeOptions); - removeJobConfigFile(workspaceRoot, jobId); - } - } catch { - } -} async function recordRunEvent(workspaceRoot, base = {}) { return recordRunEventForContext(workspaceRoot, buildCurrentRunContext(), base); } @@ -10029,9 +10858,9 @@ function assertPreDispatchReviewSafety(definition2, parsed) { function exitWithError(error) { const typed = toTypedCliError(error); if (RUN_CONTEXT.outputMode === "json-v2") { - process5.stdout.write(`${JSON.stringify(createV2ErrorEnvelope(typed, { + process6.stdout.write(`${JSON.stringify(createV2ErrorEnvelope(typed, { invocationId: RUN_CONTEXT.invocationId, - command: RUN_CONTEXT.command || process5.argv[2] || "", + command: RUN_CONTEXT.command || process6.argv[2] || "", hostSurface: RUN_CONTEXT.hostSurface, workspaceSlug: RUN_CONTEXT.workspaceSlug, runId: RUN_CONTEXT.runId, @@ -10041,19 +10870,19 @@ function exitWithError(error) { } else if (RUN_CONTEXT.outputMode === "legacy-json") { const code = error?.legacyCode || error?.code || classifyErrorCode(error?.message || ""); const legacyCode = code === "unknown_command" ? "unknown_subcommand" : code; - process5.stdout.write(`${JSON.stringify({ error: typed.message, code: legacyCode }, null, 2)} + process6.stdout.write(`${JSON.stringify({ error: typed.message, code: legacyCode }, null, 2)} `); } else { - process5.stderr.write(`Error: ${typed.message} + process6.stderr.write(`Error: ${typed.message} `); for (const suggestion of typed.data?.suggestions || []) { - process5.stderr.write(`Suggestion: ${suggestion} + process6.stderr.write(`Suggestion: ${suggestion} `); } - for (const step of typed.nextSteps) process5.stderr.write(`${step} + for (const step of typed.nextSteps) process6.stderr.write(`${step} `); } - process5.exitCode = typed.exitCode || error?.exitCode || 1; + process6.exitCode = typed.exitCode || error?.exitCode || 1; } function output(value, asJson) { if (RUN_CONTEXT.outputMode === "json-v2") { @@ -10069,18 +10898,18 @@ function output(value, asJson) { runId: RUN_CONTEXT.runId, jobId: result.job?.jobId ?? result.jobId ?? null }); - process5.stdout.write(`${JSON.stringify(envelope, null, 2)} + process6.stdout.write(`${JSON.stringify(envelope, null, 2)} `); RUN_CONTEXT.authoritativeJsonWritten = true; return; } if (asJson) { - process5.stdout.write(`${JSON.stringify(value, null, 2)} + process6.stdout.write(`${JSON.stringify(value, null, 2)} `); RUN_CONTEXT.authoritativeJsonWritten = true; return; } - process5.stdout.write(typeof value === "string" ? `${value} + process6.stdout.write(typeof value === "string" ? `${value} ` : `${JSON.stringify(value, null, 2)} `); } @@ -10149,7 +10978,7 @@ function resolveProviderModelCacheFile(workspaceRoot) { } function readProviderModelCache(workspaceRoot) { try { - const parsed = JSON.parse(fs9.readFileSync(resolveProviderModelCacheFile(workspaceRoot), "utf8")); + const parsed = JSON.parse(fs10.readFileSync(resolveProviderModelCacheFile(workspaceRoot), "utf8")); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; @@ -10162,7 +10991,7 @@ function readCachedProviderModel(workspaceRoot, provider) { function cacheProviderModel(workspaceRoot, provider, model) { if (typeof model !== "string" || !model.trim()) return; const cacheFile = resolveProviderModelCacheFile(workspaceRoot); - fs9.mkdirSync(path8.dirname(cacheFile), { recursive: true, mode: 448 }); + fs10.mkdirSync(path8.dirname(cacheFile), { recursive: true, mode: 448 }); withLockfile(`${cacheFile}.lock`, () => { writeJsonAtomic(cacheFile, { ...readProviderModelCache(workspaceRoot), [provider]: model }, { mode: 384 }); }); @@ -10188,11 +11017,11 @@ function skippedAuthDetail({ available, authProbeCost }) { } async function inspectProvider(provider, { probeAuth = false } = {}) { const runtime = getProviderRuntime(provider); - const availability = await Promise.resolve(runtime.getAvailability(process5.cwd())); + const availability = await Promise.resolve(runtime.getAvailability(process6.cwd())); const available = availability.available === true; const authProbeCost = normalizeAuthProbeCost(runtime); const authChecked = available && (probeAuth || authProbeCost === "status"); - const auth = authChecked ? await Promise.resolve(runtime.getAuthStatus(process5.cwd())) : null; + const auth = authChecked ? await Promise.resolve(runtime.getAuthStatus(process6.cwd())) : null; const row = { provider, available, @@ -10205,7 +11034,7 @@ async function inspectProvider(provider, { probeAuth = false } = {}) { model: auth?.model ?? null, capabilities: runtime.capabilities }; - cacheProviderModel(resolveWorkspaceRoot(process5.cwd()), provider, row.model); + cacheProviderModel(resolveWorkspaceRoot(process6.cwd()), provider, row.model); return row; } async function inspectSetupProvider(provider, { probeAuth, workspaceRoot }) { @@ -10277,7 +11106,7 @@ async function inspectSetupProvider(provider, { probeAuth, workspaceRoot }) { } async function inspectProviderAvailability(provider) { const runtime = getProviderRuntime(provider); - const availability = await Promise.resolve(runtime.getAvailability(process5.cwd())); + const availability = await Promise.resolve(runtime.getAvailability(process6.cwd())); const authProbeCost = normalizeAuthProbeCost(runtime); return { provider, @@ -10306,14 +11135,14 @@ function parseExecutionMode(options) { } function emitNote(line) { if (!line) return; - process5.stderr.write(`${line} + process6.stderr.write(`${line} `); } function emitRuntimeWarnings(result = {}) { if (!Array.isArray(result.warnings)) return; for (const warning of result.warnings) { if (typeof warning === "string" && warning.trim()) { - process5.stderr.write(`${warning.trim()} + process6.stderr.write(`${warning.trim()} `); } } @@ -10424,12 +11253,12 @@ function compactProviderResult(result = {}) { } return compact; } -function cleanupRuntimeOptions(runtimeOptions = {}) { +function cleanupRuntimeOptions2(runtimeOptions = {}) { const cleanupPaths = Array.isArray(runtimeOptions.cleanupPaths) ? runtimeOptions.cleanupPaths : []; for (const cleanupPath of cleanupPaths) { if (typeof cleanupPath !== "string" || cleanupPath.trim() === "") continue; try { - fs9.rmSync(cleanupPath, { recursive: true, force: true }); + fs10.rmSync(cleanupPath, { recursive: true, force: true }); } catch { } } @@ -10440,7 +11269,7 @@ function hydrateRuntimeOptions(runtimeOptions = {}) { } return { ...runtimeOptions, - env: { ...process5.env, ...runtimeOptions.env } + env: { ...process6.env, ...runtimeOptions.env } }; } async function runForegroundExecution(execution, asJson) { @@ -10494,7 +11323,7 @@ async function runForegroundExecution(execution, asJson) { } catch (error) { executionError = error; } finally { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } const resultOk = Boolean(result?.ok) && !executionError; const publicError = executionError?.message || result?.error || null; @@ -10561,9 +11390,9 @@ async function runForegroundExecution(execution, asJson) { return; } if (!result.ok) { - process5.stderr.write(`Error: ${String(result.error || `${execution.provider} ${execution.kind} failed`).slice(0, 500)} + process6.stderr.write(`Error: ${String(result.error || `${execution.provider} ${execution.kind} failed`).slice(0, 500)} `); - process5.exitCode = 1; + process6.exitCode = 1; return; } const lines = []; @@ -10590,7 +11419,7 @@ function buildQueuedJob(execution, workspaceRoot, attemptId) { updatedAt: now, invocationId: RUN_CONTEXT.invocationId, attemptId, - hostSessionId: process5.env[SESSION_ID_ENV] || null, + hostSessionId: process6.env[SESSION_ID_ENV] || null, providerSessionId: null, ...execution.jobMeta }; @@ -10736,7 +11565,7 @@ async function startBackgroundExecution(execution, asJson) { defaultModel: execution.defaultModel || null, logFile: job.logFile }); - writeJobConfigFile(workspaceRoot, job.jobId, { + const config = { workspaceRoot, hostSessionId: job.hostSessionId, execution: { @@ -10750,24 +11579,16 @@ async function startBackgroundExecution(execution, asJson) { }, jobId: job.jobId, runContext + }; + const { child } = await startBackgroundWorker({ + workspaceRoot, + job, + execution, + runContext, + config, + companionPath: COMPANION_PATH, + env: process6.env }); - fs9.writeFileSync(job.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] started ${job.provider} ${job.kind} -`, { - encoding: "utf8", - mode: 384 - }); - const logFd = fs9.openSync(job.logFile, "a", 384); - const child = spawn2(process5.execPath, [COMPANION_PATH, "_job-worker", resolveJobConfigFile(workspaceRoot, job.jobId)], { - cwd: execution.cwd, - env: { ...process5.env }, - stdio: ["ignore", logFd, logFd], - detached: true - }); - child.once("error", (error) => { - recordBackgroundSpawnFailure(workspaceRoot, job.jobId, execution, runContext, error); - }); - child.unref(); - fs9.closeSync(logFd); const runningWrite = updateJobAtomically(workspaceRoot, job.jobId, (latest) => { if (!latest || latest.status !== "queued") return null; return { @@ -10780,10 +11601,10 @@ async function startBackgroundExecution(execution, asJson) { }; }); const runningJob = runningWrite.written ? runningWrite.job : getJob(workspaceRoot, job.jobId) || job; - if (!ACTIVE_JOB_STATUSES2.has(runningJob.status)) { + if (!ACTIVE_JOB_STATUSES3.has(runningJob.status)) { removeJobConfigFile(workspaceRoot, job.jobId); } - if (runContext && ACTIVE_JOB_STATUSES2.has(runningJob.status)) { + if (runContext && ACTIVE_JOB_STATUSES3.has(runningJob.status)) { await recordRunEventForContext(workspaceRoot, runContext, { command: execution.kind, kind: execution.kind, @@ -10811,7 +11632,7 @@ async function runSetup(rawArgs) { if (options["enable-review-gate"] && options["disable-review-gate"]) { throw new Error("Choose either --enable-review-gate or --disable-review-gate, not both."); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); if (options["enable-review-gate"]) { setConfig(workspaceRoot, "stopReviewGate", true); } else if (options["disable-review-gate"]) { @@ -10899,7 +11720,7 @@ async function probeProviderHealth({ report.probe.error = inspection.availabilityDetail || "provider CLI is unavailable"; } else if (provider === "claude") { try { - const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process5.cwd())); + const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process6.cwd())); report.loggedIn = auth.loggedIn ?? false; report.authDetail = auth.detail ?? auth.reason ?? null; report.model = auth.model ?? report.model; @@ -10924,7 +11745,7 @@ async function probeProviderHealth({ prompt: `Reply with ${HEALTH_SENTINEL} only.`, model, defaultModel: model ? null : readCachedProviderModel(workspaceRoot, provider), - cwd: process5.cwd(), + cwd: process6.cwd(), timeout, kind: "health", measurementScope: "request", @@ -10996,7 +11817,7 @@ async function runHealth(rawArgs) { valueOptions: ["provider", "model", "timeout-ms"], aliasMap: { m: "model" } }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const timeoutMs = options["timeout-ms"] ? Number.parseInt(options["timeout-ms"], 10) : TIMEOUTS_MS.health; const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : TIMEOUTS_MS.health; const hasSingleProvider = Boolean(options.provider || positionals[0]); @@ -11084,7 +11905,7 @@ async function runHealth(rawArgs) { })); const payload = buildHealthPayload(results); if (!payload.anyHealthy) { - process5.exitCode = 2; + process6.exitCode = 2; } output( options.json ? payload : [ @@ -11106,7 +11927,7 @@ function parsePromptExecution(rawArgs, kind) { positionals }); validateEffort(options.effort); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const userPrompt = remainingPositionals.join(" ").trim(); if (!userPrompt) { throw new Error(`Missing prompt text for ${kind}.`); @@ -11123,7 +11944,7 @@ function parsePromptExecution(rawArgs, kind) { userPrompt, model: options.model || null, defaultModel: cachedDefaultModel, - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, kind, { model: options.model || null, defaultModel: cachedDefaultModel @@ -11173,7 +11994,7 @@ function buildStopReviewGateExecution(rawArgs) { if (!prompt) { throw new Error("Missing prompt text for stop-review-gate."); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const defaultModel = readCachedProviderModel(workspaceRoot, provider); return { options, @@ -11188,14 +12009,14 @@ function buildStopReviewGateExecution(rawArgs) { userPrompt: "stop-time review gate", model: null, defaultModel, - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, "review", { defaultModel }), meta: { stopReviewGate: true }, jobMeta: {}, measurementScope: "request", runtimeOptions: buildReviewRuntimeOptions({ provider, - cwd: process5.cwd() + cwd: process6.cwd() }) } }; @@ -11215,11 +12036,11 @@ function buildReviewExecution(rawArgs, { adversarial }) { positionals }); assertReviewProviderSupported(provider); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const focus = remainingPositionals.join(" ").trim(); const maxDiffBytes = parseMaxDiffBytes(options["max-diff-bytes"]); const reviewContext = collectReviewContext({ - cwd: process5.cwd(), + cwd: process6.cwd(), scope: options.scope, baseRef: options.base || null, maxDiffBytes @@ -11256,7 +12077,7 @@ function buildReviewExecution(rawArgs, { adversarial }) { userPrompt: focus || `${adversarial ? "adversarial " : ""}review ${reviewContext.scope}`, model: options.model || null, defaultModel: readCachedProviderModel(workspaceRoot, provider), - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, adversarial ? "adversarial-review" : "review", { model: options.model || null, defaultModel: readCachedProviderModel(workspaceRoot, provider) @@ -11277,7 +12098,7 @@ function buildReviewExecution(rawArgs, { adversarial }) { measurementScope: "request", runtimeOptions: buildReviewRuntimeOptions({ provider, - cwd: process5.cwd() + cwd: process6.cwd() }) } }; @@ -11305,7 +12126,7 @@ async function runReviewCommand(rawArgs, { adversarial }) { ); return; } finally { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } } const { background } = parseExecutionMode(options); @@ -11320,7 +12141,7 @@ async function runStatus(rawArgs) { booleanOptions: ["json", "all", "wait"], valueOptions: ["job", "for", "timeout-ms"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const reference = options.job || positionals[0] || null; const selectorOptions = options.job ? { grammar: "explicit" } : void 0; const timeoutMs = options.wait ? parseStatusTimeoutMs(options["timeout-ms"]) : void 0; @@ -11329,7 +12150,7 @@ async function runStatus(rawArgs) { timeoutMs }); if (waited.waitTimedOut) { - process5.exitCode = 2; + process6.exitCode = 2; } if (options.json) { output(waited, true); @@ -11345,7 +12166,7 @@ async function runStatus(rawArgs) { for: options.for || "terminal" }); if (waited.waitTimedOut) { - process5.exitCode = 2; + process6.exitCode = 2; } if (waited.error && RUN_CONTEXT.outputMode === "json-v2") { throw cliError("job_not_found", `Job '${target.jobId}' was not found while waiting.`, { @@ -11386,7 +12207,7 @@ async function runResult(rawArgs) { booleanOptions: ["json"], valueOptions: ["job"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const job = resolveJobSelector( workspaceRoot, options.job || positionals[0] || "latest-terminal", @@ -11414,7 +12235,7 @@ async function runCancel(rawArgs) { booleanOptions: ["json"], valueOptions: ["job"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const selector = options.job || positionals[0] || "latest-active"; let job; try { @@ -11423,11 +12244,11 @@ async function runCancel(rawArgs) { if (RUN_CONTEXT.outputMode === "json-v2" || error.code === "ambiguous_selector") throw error; if (options.json) { output({ cancelled: false, reason: "not_found", jobId: positionals[0] || null }, true); - process5.exitCode = 1; + process6.exitCode = 1; return; } output(positionals[0] ? `Job ${positionals[0]} not found.` : "No active job found to cancel.", false); - process5.exitCode = 1; + process6.exitCode = 1; return; } const report = await cancelJob(workspaceRoot, job.jobId); @@ -11438,7 +12259,7 @@ async function runCancel(rawArgs) { } if (report.reason === "not_cancellable") { output(report, true); - process5.exitCode = 4; + process6.exitCode = 4; return; } throw new PolycliCliError({ @@ -11454,10 +12275,10 @@ async function runCancel(rawArgs) { output(`Cancelled job ${report.jobId}.`, false); } else if (report.reason === "not_cancellable") { output(`Job ${report.jobId} is already ${job.status}.`, false); - process5.exitCode = 4; + process6.exitCode = 4; } else { output(`Failed to cancel ${report.jobId}: ${report.error || report.reason}`, false); - process5.exitCode = 5; + process6.exitCode = 5; } } function formatMetric(metric) { @@ -11523,7 +12344,7 @@ async function runTiming(rawArgs) { booleanOptions: ["all", "json"], valueOptions: ["provider", "history"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const provider = options.provider ? resolveProvider({ provider: options.provider }).provider : null; const limit = parseHistoryLimit(options.history, { all: options.all }); const records = listTimingRecords(workspaceRoot, { @@ -11557,6 +12378,10 @@ async function runJobWorker(rawArgs) { const { workspaceRoot, execution, jobId, runContext } = payload; const current = claimBackgroundWorker(workspaceRoot, jobId); if (!current) { + if (!shouldRetainJobConfig(workspaceRoot, jobId)) { + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } return; } if (runContext?.runId) { @@ -11590,10 +12415,6 @@ async function runJobWorker(rawArgs) { appendPreview(current.logFile, execution.provider, event); } }); - if (result.timing) { - appendTimingRecord(workspaceRoot, result.timing); - } - cacheProviderModel(workspaceRoot, execution.provider, result.model); const compactResult = compactProviderResult(result); const sessionArtifactPath = resolveSessionArtifactPath( execution.provider, @@ -11601,9 +12422,13 @@ async function runJobWorker(rawArgs) { execution.cwd ); const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || latest.status === "cancelled" || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || latest.status === "cancelled" || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } + if (result.timing) { + appendTimingRecord(workspaceRoot, result.timing); + } + cacheProviderModel(workspaceRoot, execution.provider, result.model); const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); const finishedJob = { ...latest, @@ -11669,19 +12494,22 @@ async function runJobWorker(rawArgs) { // intent instead of exposing a terminal state with only half of its ledger pair. beforeStateCommit() { ensureTerminalRunEventsForContext(workspaceRoot, terminal); + removeJobStartFailureFile(workspaceRoot, jobId); } }; }); if (!write.written) { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } return; } removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } catch (error) { const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || latest.status === "cancelled" || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || latest.status === "cancelled" || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); @@ -11730,20 +12558,23 @@ async function runJobWorker(rawArgs) { }, beforeStateCommit() { ensureTerminalRunEventsForContext(workspaceRoot, terminal); + removeJobStartFailureFile(workspaceRoot, jobId); } }; }); if (!write.written) { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } return; } removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); throw error; } finally { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } } } @@ -11761,7 +12592,7 @@ function formatDebugRunsTable(runs) { return lines.join("\n"); } async function readDebugLedger({ raw = false } = {}) { - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); refreshJobsForLedgerRecovery(workspaceRoot); const events = await readRunLedgerEvents(workspaceRoot, { raw }); return { workspaceRoot, events }; @@ -11771,7 +12602,7 @@ async function runDebugTail(rawArgs) { booleanOptions: ["json", "wait"], valueOptions: ["after", "limit", "timeout-ms"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const result = await tailRunLedgerEvents(workspaceRoot, { runId: positionals[0] || null, after: options.after || null, @@ -11779,7 +12610,7 @@ async function runDebugTail(rawArgs) { wait: Boolean(options.wait), timeoutMs: options["timeout-ms"] == null ? void 0 : Number(options["timeout-ms"]) }); - if (result.waitTimedOut) process5.exitCode = 2; + if (result.waitTimedOut) process6.exitCode = 2; if (options.json) { output(result, true); return; @@ -11833,11 +12664,11 @@ function renderSessionsList(recorded, nonPurgeable = []) { } else { lines.push("Recorded upstream sessions (this workspace):"); for (const rec of recorded) { - const exists = fs9.existsSync(rec.sessionArtifactPath); + const exists = fs10.existsSync(rec.sessionArtifactPath); let size = ""; if (exists) { try { - size = ` ${formatBytes(fs9.lstatSync(rec.sessionArtifactPath).size)}`; + size = ` ${formatBytes(fs10.lstatSync(rec.sessionArtifactPath).size)}`; } catch { size = ""; } @@ -11875,7 +12706,7 @@ function renderPurgePlan(plan, summary, nonPurgeable = []) { return lines.join("\n"); } async function readSessionLedger() { - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const events = await readRunLedgerEvents(workspaceRoot); const recorded = collectRecordedArtifacts(events); const nonPurgeable = collectNonPurgeableSessions(events); @@ -11954,10 +12785,10 @@ function commandResolutionError(fullArgs, resolution, hostSurface) { ); } async function main() { - const fullArgs = process5.argv.slice(2); + const fullArgs = process6.argv.slice(2); RUN_CONTEXT.invocationId = `inv_${randomUUID3().replaceAll("-", "").slice(0, 20)}`; RUN_CONTEXT.authoritativeJsonWritten = false; - RUN_CONTEXT.hostSurface = resolveHostSurface(process5.env, import.meta.url); + RUN_CONTEXT.hostSurface = resolveHostSurface(process6.env, import.meta.url); RUN_CONTEXT.rawArgs = fullArgs; const outputModes = scanOutputModes(fullArgs); const command = fullArgs[0]; @@ -12002,12 +12833,12 @@ async function main() { } if (definition2.runTracked) { try { - RUN_CONTEXT.runId = resolveRunId({ runId: explicitRunId }, process5.env); + RUN_CONTEXT.runId = resolveRunId({ runId: explicitRunId }, process6.env); } catch (error) { if (!/^Invalid run id:/.test(error?.message || "")) throw error; const typed = cliError("invalid_argument", error.message, { argument: "--run-id", - value: explicitRunId || process5.env.POLYCLI_RUN_ID || null + value: explicitRunId || process6.env.POLYCLI_RUN_ID || null }); typed.legacyCode = "error"; throw typed; @@ -12015,15 +12846,15 @@ async function main() { } else { RUN_CONTEXT.runId = null; } - RUN_CONTEXT.workspaceSlug = computeWorkspaceSlug(resolveWorkspaceRoot(process5.cwd())); + RUN_CONTEXT.workspaceSlug = computeWorkspaceSlug(resolveWorkspaceRoot(process6.cwd())); if (!RUN_CONTEXT.runId) { return dispatchCommand(definition2.id, rawArgs); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); await recordRunEvent(workspaceRoot, { phase: "run_started", status: "started" }); try { const result = await dispatchCommand(definition2.id, rawArgs); - const failed = process5.exitCode != null && process5.exitCode !== 0; + const failed = process6.exitCode != null && process6.exitCode !== 0; try { await recordRunEvent(workspaceRoot, { phase: "run_summary", diff --git a/plugins/polycli-opencode/package.json b/plugins/polycli-opencode/package.json index 4c99b98..c3399ae 100644 --- a/plugins/polycli-opencode/package.json +++ b/plugins/polycli-opencode/package.json @@ -1,6 +1,6 @@ { "name": "@bbingz/polycli-opencode", - "version": "0.6.30", + "version": "0.6.31", "type": "module", "main": "./index.mjs", "exports": { diff --git a/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs b/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs index 314c4f7..ebfb554 100755 --- a/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs @@ -1,11 +1,10 @@ #!/usr/bin/env node // plugins/polycli/scripts/polycli-companion.mjs -import fs9 from "node:fs"; +import fs10 from "node:fs"; import path8 from "node:path"; -import process5 from "node:process"; +import process6 from "node:process"; import { randomUUID as randomUUID3 } from "node:crypto"; -import { spawn as spawn2 } from "node:child_process"; import { fileURLToPath } from "node:url"; // packages/polycli-utils/src/args.js @@ -383,8 +382,91 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { // packages/polycli-utils/src/process.js import { spawnSync } from "node:child_process"; import process3 from "node:process"; +var CONSERVATIVE_POINTER_BYTES = 8; +var WINDOWS_SAFE_ARGV_BUDGET_BYTES = 24 * 1024; +var POSIX_SAFE_ARGV_BUDGET_BYTES = 96 * 1024; +function stringStorageBytes(value) { + return Buffer.byteLength(String(value), "utf8") + 1; +} +function getSafeArgvBudgetBytes(platform = process3.platform) { + return platform === "win32" ? WINDOWS_SAFE_ARGV_BUDGET_BYTES : POSIX_SAFE_ARGV_BUDGET_BYTES; +} +function calculateArgvFootprint({ + command, + args = [], + env = process3.env +} = {}) { + const argv = [String(command ?? ""), ...args.map((arg) => String(arg))]; + const envEntries = Object.entries(env ?? {}).filter(([, value]) => value != null).map(([key, value]) => `${key}=${String(value)}`); + const argvBytes = argv.reduce((total, value) => total + stringStorageBytes(value), 0); + const envBytes = envEntries.reduce((total, value) => total + stringStorageBytes(value), 0); + const pointerBytes = (argv.length + envEntries.length + 2) * CONSERVATIVE_POINTER_BYTES; + const stringBytes = argvBytes + envBytes; + return { + totalBytes: stringBytes + pointerBytes, + stringBytes, + pointerBytes, + argvBytes, + envBytes, + argvCount: argv.length, + envCount: envEntries.length + }; +} +function preflightArgv(command, args = [], { + env = process3.env, + argvBudgetBytes = null, + argvBudgetHint = null +} = {}) { + const footprint = calculateArgvFootprint({ command, args, env }); + if (argvBudgetBytes == null) { + return { ok: true, budgetBytes: null, footprint, error: null }; + } + if (!Number.isSafeInteger(argvBudgetBytes) || argvBudgetBytes < 0) { + throw new TypeError("argvBudgetBytes must be a non-negative safe integer or null"); + } + if (footprint.totalBytes <= argvBudgetBytes) { + return { ok: true, budgetBytes: argvBudgetBytes, footprint, error: null }; + } + const counts = [ + `footprintBytes=${footprint.totalBytes}`, + `budgetBytes=${argvBudgetBytes}`, + `argvCount=${footprint.argvCount}`, + `envCount=${footprint.envCount}` + ].join(", "); + const suffix = typeof argvBudgetHint === "string" && argvBudgetHint.trim() ? ` ${argvBudgetHint.trim()}` : ""; + const error = Object.assign( + new Error(`argument list too long for the configured safe argv budget (${counts}).${suffix}`), + { + code: "E2BIG", + footprintBytes: footprint.totalBytes, + budgetBytes: argvBudgetBytes, + argvCount: footprint.argvCount, + envCount: footprint.envCount + } + ); + return { ok: false, budgetBytes: argvBudgetBytes, footprint, error }; +} function runCommand(command, args = [], options = {}) { - const result = spawnSync(command, args, { + const effectiveEnv = options.env ?? process3.env; + const preflight = preflightArgv(command, args, { + env: effectiveEnv, + argvBudgetBytes: options.argvBudgetBytes ?? null, + argvBudgetHint: options.argvBudgetHint ?? null + }); + if (!preflight.ok) { + return { + command, + args, + status: null, + signal: null, + stdout: "", + stderr: "", + error: preflight.error, + spawnErrorCode: "E2BIG" + }; + } + const spawnImpl = options.spawnImpl ?? spawnSync; + const result = spawnImpl(command, args, { cwd: options.cwd, env: options.env, encoding: "utf8", @@ -409,7 +491,8 @@ function runCommand(command, args = [], options = {}) { signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", - error + error, + spawnErrorCode: typeof error?.code === "string" ? error.code : null }; } function firstNonEmptyLine(text) { @@ -452,17 +535,41 @@ function formatCommandFailure(result) { } return parts.join(": "); } -async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SIGKILL", forceAfterMs = 5e3, ignoreMissing = true } = {}) { +async function terminateProcessTree(pid, { + signal = "SIGTERM", + forceSignal = "SIGKILL", + forceAfterMs = 5e3, + ignoreMissing = true, + deadlineAt = null, + platform = process3.platform, + runCommandImpl = runCommand, + now = Date.now, + sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)) +} = {}) { if (!Number.isInteger(pid) || pid <= 1) { throw new Error(`Invalid pid: ${pid}`); } + if (deadlineAt != null && !Number.isFinite(deadlineAt)) { + throw new TypeError("deadlineAt must be a finite epoch millisecond value or null"); + } + const remainingDeadlineMs2 = () => { + if (deadlineAt == null) return null; + const remainingMs = Math.floor(deadlineAt - now()); + if (remainingMs <= 0) { + const error = new Error("process termination deadline exceeded"); + error.code = "EDEADLINE"; + throw error; + } + return remainingMs; + }; const killOnce = (targetSignal) => { - if (process3.platform === "win32") { + const remainingMs = remainingDeadlineMs2(); + if (platform === "win32") { const args = ["/PID", String(pid), "/T"]; if (targetSignal === "SIGKILL") { args.push("/F"); } - const result = runCommand("taskkill", args); + const result = runCommandImpl("taskkill", args, remainingMs == null ? {} : { timeout: remainingMs }); if (result.error) { if (ignoreMissing && result.error.code === "ESRCH") return false; throw result.error; @@ -501,7 +608,9 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI if (!terminated || forceAfterMs <= 0) { return terminated; } - await new Promise((resolve) => setTimeout(resolve, forceAfterMs)); + const remainingBeforeWait = deadlineAt == null ? null : remainingDeadlineMs2(); + const waitMs = remainingBeforeWait == null ? forceAfterMs : Math.min(forceAfterMs, remainingBeforeWait); + await sleep(waitMs); try { killOnce(forceSignal); } catch (error) { @@ -514,6 +623,20 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI // packages/polycli-utils/src/session-id.js var UUID_SESSION_ID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i; +var RESUME_SESSION_ID_LINE_REGEX = new RegExp( + `^\\s*resume\\s+(${UUID_SESSION_ID_REGEX.source})\\s*$`, + "i" +); +function matchResumeSessionIdLine(text) { + if (typeof text !== "string" || text.length === 0) { + return null; + } + for (const line of text.split(/\r?\n/)) { + const match = line.match(RESUME_SESSION_ID_LINE_REGEX); + if (match) return match[1]; + } + return null; +} function matchSessionId(text, { patterns = [UUID_SESSION_ID_REGEX] } = {}) { if (typeof text !== "string" || text.length === 0) { return null; @@ -566,6 +689,16 @@ function formatProviderExitError(provider, status) { return `${provider} exited with code ${status}`; } function classifyProviderFailure(error, { provider = null } = {}) { + const structuredCode = typeof error?.code === "string" ? error.code.toUpperCase() : null; + if (structuredCode === "E2BIG") { + return "argument_list_too_long"; + } + if (structuredCode === "ENOENT") { + return "binary_missing"; + } + if (structuredCode === "ETIMEDOUT") { + return "timeout"; + } const text = typeof error === "string" ? error : String(error?.message ?? error ?? ""); if (!text.trim()) return null; if (provider === "qwen" && /\bmaximum session turn\b|\bmax(?:imum)? session turns?\b/i.test(text)) { @@ -574,6 +707,12 @@ function classifyProviderFailure(error, { provider = null } = {}) { if (/\bspawn\b.*\bENOENT\b|\bENOENT\b|\bnot found\b/i.test(text)) { return "binary_missing"; } + if (/\bE2BIG\b|\bargument list too long\b/i.test(text)) { + return "argument_list_too_long"; + } + if (/\b(output|capture|line buffer)\b.*\b(exceeded|overflow)\b/i.test(text)) { + return "output_overflow"; + } if (/\b(timed out|timeout)\b/i.test(text)) { return "timeout"; } @@ -643,6 +782,7 @@ function createLineDecoder({ encoding = "utf8", stripCarriageReturn = true, maxB } // packages/polycli-runtime/src/spawn.js +var DEFAULT_CAPTURE_LIMIT_BYTES = 1048576; function formatExitError(status, signal, { timedOut = false, aborted = false } = {}) { if (aborted) { return "process aborted"; @@ -658,6 +798,34 @@ function formatExitError(status, signal, { timedOut = false, aborted = false } = } return `process exited with code ${status}`; } +function normalizeCaptureLimit(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : DEFAULT_CAPTURE_LIMIT_BYTES; +} +function spawnFailureResult(error, captureLimitBytes) { + const spawnErrorCode = typeof error?.code === "string" ? error.code : null; + return { + ok: false, + status: null, + signal: null, + timedOut: false, + aborted: false, + stdout: "", + stderr: "", + stdoutBytes: 0, + stderrBytes: 0, + stdoutTruncated: false, + stderrTruncated: false, + captureLimitBytes, + outputOverflowStream: null, + closeTimedOut: false, + terminationErrors: [], + terminationFailure: null, + terminationReason: null, + spawnErrorCode, + error: String(error?.message ?? error), + errorCode: classifyProviderFailure(error) + }; +} function spawnStreamingCommand({ bin, args = [], @@ -668,8 +836,11 @@ function spawnStreamingCommand({ killGraceMs = 2e3, signal = null, maxBufferBytes = 1048576, + maxCaptureBytes = DEFAULT_CAPTURE_LIMIT_BYTES, + argvBudgetBytes = null, + argvBudgetHint = null, stdio = ["pipe", "pipe", "pipe"], - detached = false, + detached = process.platform !== "win32", unref = false, spawnImpl = spawn, onStdoutLine = () => { @@ -677,54 +848,85 @@ function spawnStreamingCommand({ onStderrChunk = () => { } } = {}) { + const captureLimitBytes = normalizeCaptureLimit(maxCaptureBytes); + const preflight = preflightArgv(bin, args, { + env: env ?? process.env, + argvBudgetBytes, + argvBudgetHint + }); + if (!preflight.ok) { + return Promise.resolve(spawnFailureResult(preflight.error, captureLimitBytes)); + } return new Promise((resolve) => { let child; try { child = spawnImpl(bin, args, { cwd, env, stdio, detached }); } catch (error) { - resolve({ - ok: false, - status: null, - signal: null, - timedOut: false, - stdout: "", - stderr: "", - error: error.message - }); + resolve(spawnFailureResult(error, captureLimitBytes)); return; } if (unref && typeof child.unref === "function") { child.unref(); } const decoder = createLineDecoder({ maxBufferBytes }); - let stdout = ""; - let stderr = ""; + const stdoutParts = []; + const stderrParts = []; + let stdoutCapturedBytes = 0; + let stderrCapturedBytes = 0; + let stdoutBytes = 0; + let stderrBytes = 0; + let stdoutTruncated = false; + let stderrTruncated = false; + let outputOverflowStream = null; let timedOut = false; let aborted = false; let settled = false; let timer = null; let forceTimer = null; + let settleTimer = null; let decoderError = null; + let lifecycleFailure = null; let terminationRequested = false; - const signalChild = (signal2) => { + const terminationErrors = []; + const setLifecycleFailure = (code, message) => { + if (!lifecycleFailure) { + lifecycleFailure = { code, message }; + } + }; + const recordTerminationError = (error, killSignal, target) => { + terminationErrors.push({ + signal: killSignal, + target, + code: typeof error?.code === "string" ? error.code : null, + message: String(error?.message ?? error).slice(0, 4096) + }); + }; + const trySignal = (killSignal, target, sender) => { try { - if (detached && Number.isInteger(child.pid) && child.pid > 0 && process.platform !== "win32") { - process.kill(-child.pid, signal2); - return; + const delivered = sender(); + if (delivered === false) { + const error = new Error(`${target} kill returned false`); + error.code = "KILL_RETURNED_FALSE"; + recordTerminationError(error, killSignal, target); + return false; } - child.kill(signal2); - } catch { + return true; + } catch (error) { + recordTerminationError(error, killSignal, target); + return false; } }; - const terminateChild = () => { - if (terminationRequested) return; - terminationRequested = true; - signalChild("SIGTERM"); - if (killGraceMs > 0) { - forceTimer = setTimeout(() => { - signalChild("SIGKILL"); - }, killGraceMs); + const signalChild = (killSignal) => { + const canSignalGroup = detached && Number.isInteger(child.pid) && child.pid > 0 && process.platform !== "win32"; + if (canSignalGroup) { + const groupSignalled = trySignal( + killSignal, + "process_group", + () => process.kill(-child.pid, killSignal) + ); + if (groupSignalled) return; } + trySignal(killSignal, "child", () => child.kill(killSignal)); }; const cleanup = () => { if (signal && typeof signal.removeEventListener === "function") { @@ -736,119 +938,215 @@ function spawnStreamingCommand({ child.off?.("error", handleChildError); child.off?.("close", handleChildClose); }; + const capturedStdout = () => stdoutParts.join(""); + const capturedStderr = () => Buffer.concat(stderrParts, stderrCapturedBytes).toString("utf8"); + const buildResult = (status, signalName, { closeTimedOut = false } = {}) => { + if (!lifecycleFailure) { + if (timedOut || status === 124) { + setLifecycleFailure("timeout", "process timed out"); + } else if (aborted || signalName === "SIGINT" || status === 130) { + setLifecycleFailure("cancelled", aborted ? "process aborted" : "process interrupted"); + } else if (signalName || status === 143) { + setLifecycleFailure("terminated", formatExitError(status, signalName)); + } + } + const stdout = capturedStdout(); + const stderr = capturedStderr(); + const ok = status === 0 && !lifecycleFailure && !closeTimedOut; + const error = ok ? null : lifecycleFailure?.message ?? (stderr.trim() || formatExitError(status, signalName, { timedOut, aborted })); + return { + ok, + status, + signal: signalName, + timedOut, + aborted, + stdout, + stderr, + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated, + captureLimitBytes, + outputOverflowStream, + closeTimedOut, + terminationErrors: terminationErrors.map((entry) => ({ ...entry })), + terminationFailure: closeTimedOut ? "close_timeout" : terminationErrors.length > 0 ? "signal_error" : null, + terminationReason: lifecycleFailure?.code ?? null, + spawnErrorCode: null, + error, + errorCode: lifecycleFailure?.code ?? classifyProviderFailure(error) + }; + }; const finish = (result) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); if (forceTimer) clearTimeout(forceTimer); + if (settleTimer) clearTimeout(settleTimer); cleanup(); resolve(result); }; - const decoderFailureResult = (status, signalName) => ({ - ok: false, - status, - signal: signalName, - timedOut, - stdout, - stderr, - error: decoderError - }); + const settleWithoutClose = () => { + if (settled) return; + finish(buildResult(null, null, { closeTimedOut: true })); + }; + const forceKill = () => { + if (settled) return; + signalChild("SIGKILL"); + if (settled) return; + const closeWaitMs = Math.max(10, Math.max(0, killGraceMs)); + settleTimer = setTimeout(settleWithoutClose, closeWaitMs); + }; + const terminateChild = (code, message) => { + setLifecycleFailure(code, message); + if (terminationRequested) return; + terminationRequested = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + signalChild("SIGTERM"); + if (settled) return; + forceTimer = setTimeout(forceKill, Math.max(0, killGraceMs)); + }; + const handleOutputOverflow = (streamName) => { + if (!outputOverflowStream) { + outputOverflowStream = streamName; + } + terminateChild( + "output_overflow", + `${streamName} capture exceeded maxCaptureBytes (${captureLimitBytes})` + ); + }; + const appendStdoutLine = (line, { trailingNewline = true } = {}) => { + const text = trailingNewline ? `${line} +` : line; + const bytes = Buffer.byteLength(text); + if (stdoutCapturedBytes + bytes > captureLimitBytes) { + stdoutTruncated = true; + return false; + } + stdoutParts.push(text); + stdoutCapturedBytes += bytes; + try { + onStdoutLine(line); + } catch { + } + return true; + }; const handleDecoderError = (error) => { if (decoderError || settled) return; decoderError = String(error?.message ?? error).slice(0, 4096); + stdoutTruncated = true; + outputOverflowStream = outputOverflowStream || "stdout"; child.stdout?.off?.("data", handleStdoutData); - terminateChild(); + terminateChild("output_overflow", decoderError); }; const abortHandler = () => { if (settled || aborted) return; aborted = true; - terminateChild(); + terminateChild("cancelled", "process aborted"); }; if (timeout != null) { timer = setTimeout(() => { - if (settled || timedOut) return; + if (settled || timedOut || terminationRequested) return; timedOut = true; - terminateChild(); + terminateChild("timeout", "process timed out"); }, timeout); } const handleChildError = (error) => { - if (decoderError) { + if (terminationRequested) { return; } finish({ - ok: false, - status: null, - signal: null, + ...spawnFailureResult(error, captureLimitBytes), timedOut, - stdout, - stderr, - error: error.message + aborted, + stdout: capturedStdout(), + stderr: capturedStderr(), + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated }); }; + const captureStderr = (chunk, { emit = true } = {}) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + stderrBytes += buffer.length; + const remaining = Math.max(captureLimitBytes - stderrCapturedBytes, 0); + const accepted = buffer.subarray(0, remaining); + if (accepted.length > 0) { + stderrParts.push(Buffer.from(accepted)); + stderrCapturedBytes += accepted.length; + if (emit) { + try { + onStderrChunk(accepted.toString("utf8")); + } catch { + } + } + } + if (accepted.length < buffer.length) { + stderrTruncated = true; + child.stderr?.off?.("data", handleStderrData); + handleOutputOverflow("stderr"); + } + }; const handleStdinError = (error) => { if (error?.code === "EPIPE" || error?.code === "ERR_STREAM_DESTROYED") { return; } - stderr += `${error.message} -`; + captureStderr(Buffer.from(`${error.message} +`), { emit: false }); }; const handleStdoutData = (chunk) => { if (settled) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + const previousBytes = stdoutBytes; + stdoutBytes += buffer.length; + const remaining = Math.max(captureLimitBytes - previousBytes, 0); + const accepted = buffer.subarray(0, remaining); let lines; try { - lines = decoder.push(chunk); + lines = accepted.length > 0 ? decoder.push(accepted) : []; } catch (error) { handleDecoderError(error); return; } for (const line of lines) { - stdout += `${line} -`; - try { - onStdoutLine(line); - } catch { + if (!appendStdoutLine(line)) { + child.stdout?.off?.("data", handleStdoutData); + handleOutputOverflow("stdout"); + return; } } + if (accepted.length < buffer.length) { + stdoutTruncated = true; + child.stdout?.off?.("data", handleStdoutData); + handleOutputOverflow("stdout"); + } }; const handleStderrData = (chunk) => { if (settled) return; - const text = chunk.toString("utf8"); - stderr += text; - try { - onStderrChunk(text); - } catch { - } + captureStderr(chunk); }; const handleChildClose = (status, signalName) => { - if (decoderError) { - finish(decoderFailureResult(status, signalName)); - return; - } - let lines; - try { - lines = decoder.end(); - } catch (error) { - handleDecoderError(error); - finish(decoderFailureResult(status, signalName)); - return; - } - for (const line of lines) { - stdout += `${line} -`; + if (!decoderError && !stdoutTruncated) { + let lines; try { - onStdoutLine(line); - } catch { + lines = decoder.end(); + } catch (error) { + handleDecoderError(error); + finish(buildResult(status, signalName)); + return; + } + for (const line of lines) { + if (!appendStdoutLine(line, { trailingNewline: false })) { + handleOutputOverflow("stdout"); + break; + } } } - finish({ - ok: status === 0 && !timedOut && !aborted, - status, - signal: signalName, - timedOut, - stdout, - stderr, - error: status === 0 && !timedOut && !aborted ? null : stderr.trim() || formatExitError(status, signalName, { timedOut, aborted }) - }); + finish(buildResult(status, signalName)); }; child.on("error", handleChildError); child.stdin?.on?.("error", handleStdinError); @@ -888,6 +1186,8 @@ var DEFAULT_TIMEOUT_MS = 9e5; var TMUX_START_TIMEOUT_MS = 3e4; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; +var SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var CLAUDE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var CLAUDE_TMUX_ENV_EXACT = /* @__PURE__ */ new Set([ "ALL_PROXY", @@ -989,33 +1289,26 @@ function buildClaudeInvocation({ maxTurns = 10, resumeSessionId = null, extraArgs = [], - bin = CLAUDE_BIN + bin = CLAUDE_BIN, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { const promptText = String(prompt ?? ""); - const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD; - const args = ["-p"]; - if (!useStdin) { - args.push(promptText); - } - args.push("--output-format", outputFormat); - if (outputFormat === "stream-json") { - args.push("--verbose"); - } - if (permissionMode) { - args.push("--permission-mode", permissionMode); - } - if (Number.isFinite(maxTurns) && maxTurns > 0) { - args.push("--max-turns", String(maxTurns)); - } - if (model) { - args.push("--model", model); - } - if (resumeSessionId) { - args.push("--resume", resumeSessionId); - } - if (extraArgs.length > 0) { - args.push(...extraArgs); - } + const buildArgs = (includePrompt) => { + const nextArgs = ["-p"]; + if (includePrompt) nextArgs.push(promptText); + nextArgs.push("--output-format", outputFormat); + if (outputFormat === "stream-json") nextArgs.push("--verbose"); + if (permissionMode) nextArgs.push("--permission-mode", permissionMode); + if (Number.isFinite(maxTurns) && maxTurns > 0) nextArgs.push("--max-turns", String(maxTurns)); + if (model) nextArgs.push("--model", model); + if (resumeSessionId) nextArgs.push("--resume", resumeSessionId); + if (extraArgs.length > 0) nextArgs.push(...extraArgs); + return nextArgs; + }; + const inlineArgs = buildArgs(true); + const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD || !preflightArgv(bin, inlineArgs, { env, argvBudgetBytes }).ok; + const args = useStdin ? buildArgs(false) : inlineArgs; return { bin, args, @@ -1390,13 +1683,8 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = } try { const parsed = JSON.parse(text.slice(jsonStart)); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); const response = typeof parsed.result === "string" ? parsed.result : ""; - const sessionId = parsed.session_id ?? parsed.sessionId ?? resolvedSession.sessionId ?? null; + const sessionId = parsed.session_id ?? parsed.sessionId ?? matchResumeSessionIdLine(stderr) ?? null; const errorText2 = isClaudeErrorResultEvent(parsed) ? getClaudeErrorText(parsed) : null; const processError = status === 0 ? null : String(stderr ?? "").trim() || formatProviderExitError("claude", status); return { @@ -1480,7 +1768,10 @@ function runClaudePrompt({ extraArgs = [], resumeSessionId = null, defaultModel = null, - bin = CLAUDE_BIN + bin = CLAUDE_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { const invocation = buildClaudeInvocation({ prompt, @@ -1490,17 +1781,25 @@ function runClaudePrompt({ maxTurns, resumeSessionId, extraArgs, - bin + bin, + env, + argvBudgetBytes }); const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, - input: invocation.input + input: invocation.input, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `claude timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `claude timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error), + spawnErrorCode: result.spawnErrorCode ?? null }; } return parseClaudeJsonResult(result.stdout, result.stderr, result.status, { @@ -1514,6 +1813,7 @@ function runClaudePromptStreaming({ maxTurns = 10, cwd, timeout = DEFAULT_TIMEOUT_MS, + killGraceMs, extraArgs = [], resumeSessionId = null, defaultModel = null, @@ -1525,7 +1825,8 @@ function runClaudePromptStreaming({ executionMode = "print", env = process.env, signalEmitter = process, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { if (executionMode === "tmux-tui") { return Promise.resolve(runClaudeTuiPrompt({ @@ -1552,7 +1853,9 @@ function runClaudePromptStreaming({ maxTurns, resumeSessionId, extraArgs, - bin + bin, + env, + argvBudgetBytes }); return spawnStreamingCommand({ bin: invocation.bin, @@ -1561,7 +1864,10 @@ function runClaudePromptStreaming({ env, input: invocation.input, timeout, + killGraceMs, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const parsed = parseStreamJsonLine(line, { allowPrefix: true }); if (parsed.ok) { @@ -1573,25 +1879,23 @@ function runClaudePromptStreaming({ } }).then((result) => { const parsed = parseClaudeStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const resultError = isClaudeErrorResultEvent(parsed.resultEvent) ? getClaudeErrorText(parsed.resultEvent) : null; const hasSuccessfulResult = Boolean( parsed.resultEvent && parsed.resultEvent.type === "result" && !isClaudeErrorResultEvent(parsed.resultEvent) ); - const completed = result.ok || result.timedOut && hasSuccessfulResult; + const recoveredTimeout = result.timedOut && hasSuccessfulResult && !result.closeTimedOut && !result.terminationFailure; + const completed = result.ok || recoveredTimeout; return { ...result, ...parsed, timedOut: completed ? false : result.timedOut, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), model: parsed.model ?? model ?? defaultModel, ok: completed && !resultError && hasVisibleText, - error: completed ? resultError || (hasVisibleText ? null : "claude produced no visible text") : result.error + error: completed ? resultError || (hasVisibleText ? null : "claude produced no visible text") : result.error, + errorCode: completed && !resultError ? hasVisibleText ? null : "no_visible_text" : result.errorCode, + terminationReason: completed ? null : result.terminationReason }; }); } @@ -1600,6 +1904,8 @@ function runClaudePromptStreaming({ var COPILOT_BIN = process.env.COPILOT_CLI_BIN || "copilot"; var DEFAULT_TIMEOUT_MS2 = 9e5; var AUTH_CHECK_TIMEOUT_MS2 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES2 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT2 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var COPILOT_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS2 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -1792,7 +2098,10 @@ function runCopilotPrompt({ allowAllPaths = true, allowAllUrls = true, noAskUser = true, - bin = COPILOT_BIN + bin = COPILOT_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES2 } = {}) { const invocation = buildCopilotInvocation({ prompt, @@ -1808,23 +2117,27 @@ function runCopilotPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT2 + }); if (result.error) { return { ok: false, resumeStatus: getCopilotResumeStatus(resumeSessionId, null), - error: result.error.code === "ETIMEDOUT" ? `copilot timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `copilot timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error, { provider: "copilot" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseCopilotStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getCopilotResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); - const sessionId = parsed.sessionId ?? resolvedSession.sessionId; + const sessionId = parsed.sessionId ?? matchResumeSessionIdLine(result.stderr); return { ok: result.status === 0 && !resultError && hasVisibleText, response: parsed.response, @@ -1851,7 +2164,9 @@ function runCopilotPromptStreaming({ onEvent = () => { }, bin = COPILOT_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES2 } = {}) { const invocation = buildCopilotInvocation({ prompt, @@ -1871,9 +2186,11 @@ function runCopilotPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT2, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -1884,14 +2201,9 @@ function runCopilotPromptStreaming({ } }).then((result) => { const parsed = parseCopilotStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getCopilotResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); - const sessionId = parsed.sessionId ?? resolvedSession.sessionId; + const sessionId = parsed.sessionId ?? matchResumeSessionIdLine(result.stderr); return { ...result, ...parsed, @@ -1908,6 +2220,8 @@ var GEMINI_BIN = process.env.GEMINI_CLI_BIN || "gemini"; var DEFAULT_TIMEOUT_MS3 = 3e5; var AUTH_CHECK_TIMEOUT_MS3 = 3e4; var PROMPT_STDIN_THRESHOLD2 = 1e5; +var SAFE_PROMPT_ARGV_BUDGET_BYTES3 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT3 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var GEMINI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var VALID_GEMINI_EFFORTS = /* @__PURE__ */ new Set(["low", "medium", "high"]); var TRANSIENT_PROBE_ERROR_PATTERNS3 = [ @@ -1944,16 +2258,23 @@ function buildGeminiInvocation({ outputFormat = "json", resumeSessionId = null, extraArgs = [], - bin = GEMINI_BIN + bin = GEMINI_BIN, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { const promptText = applyGeminiEffort(prompt, effort); - const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD2; - const args = ["-p", useStdin ? "" : promptText, "-o", outputFormat]; const resolvedApprovalMode = write ? "auto_edit" : approvalMode; - if (model) args.push("-m", model); - args.push("--approval-mode", resolvedApprovalMode); - if (resumeSessionId) args.push("--resume", resumeSessionId); - if (extraArgs.length > 0) args.push(...extraArgs); + const buildArgs = (inlinePrompt) => { + const nextArgs = ["-p", inlinePrompt ? promptText : "", "-o", outputFormat]; + if (model) nextArgs.push("-m", model); + nextArgs.push("--approval-mode", resolvedApprovalMode); + if (resumeSessionId) nextArgs.push("--resume", resumeSessionId); + if (extraArgs.length > 0) nextArgs.push(...extraArgs); + return nextArgs; + }; + const inlineArgs = buildArgs(true); + const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD2 || !preflightArgv(bin, inlineArgs, { env, argvBudgetBytes }).ok; + const args = useStdin ? buildArgs(false) : inlineArgs; return { bin, args, @@ -2085,8 +2406,12 @@ function runGeminiPrompt({ extraArgs = [], resumeSessionId = null, defaultModel = null, - bin = GEMINI_BIN + bin = GEMINI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { + const childEnv = buildGeminiEnv(env); const invocation = buildGeminiInvocation({ prompt, model, @@ -2096,18 +2421,25 @@ function runGeminiPrompt({ outputFormat: "json", resumeSessionId, extraArgs, - bin + bin, + env: childEnv, + argvBudgetBytes }); const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, input: invocation.input, - env: buildGeminiEnv() + env: childEnv, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT3 }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `gemini timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `gemini timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error), + spawnErrorCode: result.spawnErrorCode ?? null }; } return parseGeminiJsonResult(result.stdout, result.stderr, result.status, { @@ -2128,8 +2460,11 @@ function runGeminiPromptStreaming({ onEvent = () => { }, bin = GEMINI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { + const childEnv = buildGeminiEnv(env); const invocation = buildGeminiInvocation({ prompt, model, @@ -2139,16 +2474,20 @@ function runGeminiPromptStreaming({ outputFormat: "stream-json", resumeSessionId, extraArgs, - bin + bin, + env: childEnv, + argvBudgetBytes }); return spawnStreamingCommand({ bin: invocation.bin, args: invocation.args, cwd, - env: buildGeminiEnv(), + env: childEnv, input: invocation.input, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT3, onStdoutLine(line) { const parsed = parseStreamJsonLine(line, { allowPrefix: true }); if (parsed.ok) { @@ -2186,6 +2525,8 @@ import path2 from "node:path"; var KIMI_BIN = process.env.KIMI_CLI_BIN || "kimi"; var DEFAULT_TIMEOUT_MS4 = 9e5; var AUTH_CHECK_TIMEOUT_MS4 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES4 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT4 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var KIMI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS4 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -2297,13 +2638,28 @@ function runKimiPrompt({ resumeSessionId = null, resumeLast = false, defaultModel = null, - bin = KIMI_BIN + bin = KIMI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES4 } = {}) { const invocation = buildKimiInvocation({ prompt, model, resumeSessionId, resumeLast, extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT4 + }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `kimi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error: error2, errorCode: classifyProviderFailure(error2, { provider: "kimi" }) }; + return { + ok: false, + error: error2, + errorCode: classifyProviderFailure(result.error, { provider: "kimi" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } if (result.status !== 0) { const error2 = result.stderr.trim() || formatProviderExitError("kimi", result.status); @@ -2340,16 +2696,20 @@ function runKimiPromptStreaming({ onEvent = () => { }, bin = KIMI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES4 } = {}) { const invocation = buildKimiInvocation({ prompt, model, resumeSessionId, resumeLast, extraArgs, bin }); return spawnStreamingCommand({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT4, onStdoutLine(line) { const event = parseKimiEventLine(line); if (event) { @@ -2371,7 +2731,7 @@ function runKimiPromptStreaming({ model: parsed.model ?? model ?? defaultModel ?? readKimiDefaultModel(), ok, error, - errorCode: classifyProviderFailure(error, { provider: "kimi" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "kimi" }) }; }); } @@ -2380,6 +2740,8 @@ function runKimiPromptStreaming({ var QWEN_BIN = process.env.QWEN_CLI_BIN || "qwen"; var DEFAULT_TIMEOUT_MS5 = 3e5; var AUTH_CHECK_TIMEOUT_MS5 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES5 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT5 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; var PROXY_KEYS = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]; var NO_PROXY_DEFAULTS = ["localhost", "127.0.0.1"]; @@ -2627,7 +2989,9 @@ function runQwenPrompt({ appendSystem, appendDirs, extraArgs = [], - bin = QWEN_BIN + bin = QWEN_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES5 } = {}) { const invocation = buildQwenInvocation({ prompt, @@ -2647,18 +3011,21 @@ function runQwenPrompt({ const result = runCommand(invocation.bin, invocation.args, { cwd, env, - timeout + timeout, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT5 }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `qwen timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error: error2, errorCode: classifyProviderFailure(error2, { provider: "qwen" }) }; + return { + ok: false, + error: error2, + errorCode: classifyProviderFailure(result.error, { provider: "qwen" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } const parsed = parseQwenStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultEventError = extractQwenResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.status === 0 && !resultEventError && hasVisibleText ? null : result.stderr.trim() || resultEventError || (result.status === 0 ? "qwen produced no visible text" : formatProviderExitError("qwen", result.status)); @@ -2668,7 +3035,7 @@ function runQwenPrompt({ status: result.status, stderr: result.stderr, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), error, errorCode }; @@ -2692,7 +3059,8 @@ function runQwenPromptStreaming({ onEvent = () => { }, bin = QWEN_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES5 } = {}) { const invocation = buildQwenInvocation({ prompt, @@ -2715,10 +3083,12 @@ function runQwenPromptStreaming({ cwd, env, timeout, - detached: background, + detached: background ? true : void 0, unref: background, stdio: ["ignore", "pipe", "pipe"], spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT5, onStdoutLine(line) { if (!line.trim().startsWith("{")) return; try { @@ -2728,19 +3098,15 @@ function runQwenPromptStreaming({ } }).then((result) => { const parsed = parseQwenStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const resultEventError = extractQwenResultError(parsed.resultEvent); - const error = result.ok && !resultEventError ? hasVisibleText ? null : resultEventError || "qwen produced no visible text" : resultEventError || result.error; - const errorCode = resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" }); + const commonFailure = !result.ok && result.errorCode ? result.error : null; + const error = commonFailure || (result.ok && !resultEventError ? hasVisibleText ? null : resultEventError || "qwen produced no visible text" : resultEventError || result.error); + const errorCode = result.errorCode ?? (resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" })); return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), ok: result.ok && !resultEventError && hasVisibleText, error, errorCode @@ -2752,6 +3118,8 @@ function runQwenPromptStreaming({ var MMX_BIN = process.env.MMX_CLI_BIN || process.env.MINIMAX_CLI_BIN || "mmx"; var DEFAULT_TIMEOUT_MS6 = 12e4; var AUTH_CHECK_TIMEOUT_MS6 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES6 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT6 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var MINIMAX_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS6 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -2860,7 +3228,8 @@ function runMiniMaxPrompt({ defaultModel = null, env = process.env, bin = MMX_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES6 } = {}) { return new Promise((resolve) => { const invocation = buildMiniMaxInvocation({ prompt, model, extraArgs, bin }); @@ -2872,6 +3241,8 @@ function runMiniMaxPrompt({ timeout, stdio: ["ignore", "pipe", "pipe"], spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT6, onStdoutLine() { } }).then((result) => { @@ -2932,7 +3303,8 @@ async function runMiniMaxPromptStreaming({ onEvent = () => { }, bin = MMX_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES6 } = {}) { const events = []; return runMiniMaxPrompt({ @@ -2944,7 +3316,8 @@ async function runMiniMaxPromptStreaming({ defaultModel, env, bin, - spawnImpl + spawnImpl, + argvBudgetBytes }).then((result) => { try { const event = { @@ -2967,6 +3340,8 @@ var OPENCODE_BIN = process.env.OPENCODE_CLI_BIN || "opencode"; var DEFAULT_TIMEOUT_MS7 = 9e5; var AUTH_CHECK_TIMEOUT_MS7 = 3e4; var SESSION_EXPORT_TIMEOUT_MS = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES7 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT7 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var OPENCODE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS7 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3153,11 +3528,6 @@ function parseOpenCodeStreamText(text) { } function parseOpenCodeJsonResult(stdout, stderr, status, { defaultModel = null } = {}) { const parsed = parseOpenCodeStreamText(stdout); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getOpenCodeResultError(parsed.resultEvent); const sessionErrorMessage = getOpenCodeSessionErrorDataMessage(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); @@ -3166,7 +3536,7 @@ function parseOpenCodeJsonResult(stdout, stderr, status, { defaultModel = null } ok: status === 0 && !resultError && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(stderr), model: parsed.model ?? defaultModel, status, error, @@ -3214,7 +3584,9 @@ function runOpenCodePrompt({ variant = null, skipPermissions = true, defaultModel = null, - bin = OPENCODE_BIN + bin = OPENCODE_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES7 } = {}) { const invocation = buildOpenCodeInvocation({ prompt, @@ -3228,13 +3600,21 @@ function runOpenCodePrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, env }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT7 + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `opencode timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; return { ok: false, error, - errorCode: classifyProviderFailure(error, { provider: "opencode" }) + errorCode: classifyProviderFailure(result.error, { provider: "opencode" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseOpenCodeJsonResult(result.stdout, result.stderr, result.status, { @@ -3262,7 +3642,8 @@ function runOpenCodePromptStreaming({ onEvent = () => { }, bin = OPENCODE_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES7 } = {}) { const invocation = buildOpenCodeInvocation({ prompt, @@ -3283,6 +3664,8 @@ function runOpenCodePromptStreaming({ env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT7, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -3293,28 +3676,24 @@ function runOpenCodePromptStreaming({ } }).then((result) => { const parsed = parseOpenCodeStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); + const fallbackSessionId = matchResumeSessionIdLine(result.stderr); const resultError = getOpenCodeResultError(parsed.resultEvent); const sessionErrorMessage = getOpenCodeSessionErrorDataMessage(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); let resolvedModel = parsed.model ?? model ?? defaultModel; const ok = result.ok && !resultError && hasVisibleText; if (ok && !resolvedModel) { - resolvedModel = resolveOpenCodeSessionModel(parsed.sessionId ?? resolvedSession.sessionId, { cwd, env, bin }); + resolvedModel = resolveOpenCodeSessionModel(parsed.sessionId ?? fallbackSessionId, { cwd, env, bin }); } - const error = sessionErrorMessage || (result.ok ? resultError || (hasVisibleText ? null : "opencode produced no visible text") : result.error); + const error = (!result.ok && result.errorCode ? result.error : null) || sessionErrorMessage || (result.ok ? resultError || (hasVisibleText ? null : "opencode produced no visible text") : result.error); return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? fallbackSessionId, model: resolvedModel, ok, error, - errorCode: classifyProviderFailure(error, { provider: "opencode" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "opencode" }) }; }); } @@ -3324,6 +3703,8 @@ var PI_BIN = process.env.PI_CLI_BIN || "pi"; var DEFAULT_PI_MODEL = null; var DEFAULT_TIMEOUT_MS8 = 9e5; var AUTH_CHECK_TIMEOUT_MS8 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES8 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT8 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var PI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS8 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3489,7 +3870,10 @@ function runPiPrompt({ continueLast = false, noSession = false, defaultModel = null, - bin = PI_BIN + bin = PI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES8 } = {}) { const invocation = buildPiInvocation({ prompt, @@ -3501,11 +3885,20 @@ function runPiPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT8 + }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `pi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `pi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error, { provider: "pi" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parsePiStreamText(result.stdout); @@ -3542,7 +3935,9 @@ function runPiPromptStreaming({ onEvent = () => { }, bin = PI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES8 } = {}) { const invocation = buildPiInvocation({ prompt, @@ -3558,9 +3953,11 @@ function runPiPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT8, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -3596,6 +3993,8 @@ var CMD_BIN = process.env.CMD_CLI_BIN || "cmd"; var DEFAULT_CMD_MODEL = "deepseek"; var DEFAULT_TIMEOUT_MS9 = 9e5; var AUTH_CHECK_TIMEOUT_MS9 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES9 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT9 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var CMD_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS9 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3676,7 +4075,9 @@ function runCmdPrompt({ extraArgs = [], yolo = true, defaultModel = null, - bin = CMD_BIN + bin = CMD_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES9 } = {}) { const invocation = buildCmdInvocation({ prompt, @@ -3684,13 +4085,21 @@ function runCmdPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, env }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT9 + }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `cmd timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; return { ok: false, error: error2, - errorCode: classifyProviderFailure(error2, { provider: "cmd" }) + errorCode: classifyProviderFailure(result.error, { provider: "cmd" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseCmdTextResult(result.stdout); @@ -3721,7 +4130,8 @@ function runCmdPromptStreaming({ onEvent = () => { }, bin = CMD_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES9 } = {}) { const invocation = buildCmdInvocation({ prompt, @@ -3736,6 +4146,8 @@ function runCmdPromptStreaming({ env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT9, onStdoutLine(line) { const trimmed = line.trimEnd(); if (!trimmed.trim()) return; @@ -3754,7 +4166,7 @@ function runCmdPromptStreaming({ model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, ok: result.ok && hasVisibleText, error, - errorCode: classifyProviderFailure(error, { provider: "cmd" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "cmd" }) }; }); } @@ -3946,7 +4358,7 @@ function runAgyPromptStreaming({ const parsed = parseAgyTextResult(result.stdout); const filteredStderr = stripAgyBenignStderr(result.stderr); const hasVisibleText = Boolean(parsed.response.trim()); - const error = result.ok ? hasVisibleText ? null : "agy produced no visible text" : filteredStderr.trim() || result.error; + const error = !result.ok && result.errorCode ? result.error : result.ok ? hasVisibleText ? null : "agy produced no visible text" : filteredStderr.trim() || result.error; return { ...result, ...parsed, @@ -3955,7 +4367,7 @@ function runAgyPromptStreaming({ model: model ?? defaultModel ?? DEFAULT_AGY_MODEL, ok: result.ok && hasVisibleText, error, - errorCode: classifyProviderFailure(error, { provider: "agy" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "agy" }) }; }); } @@ -3964,6 +4376,8 @@ function runAgyPromptStreaming({ var GROK_BIN = process.env.GROK_CLI_BIN || "grok"; var DEFAULT_TIMEOUT_MS11 = 9e5; var AUTH_CHECK_TIMEOUT_MS11 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES10 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT10 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var DEFAULT_GROK_MODEL = "grok-4.5"; var GROK_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|not logged in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var SUCCESS_STOP_REASONS = /* @__PURE__ */ new Set(["endturn", "end_turn", "stop", "stop_sequence", "complete", "completed", "done", "finished", "maxtokens", "max_tokens", "length"]); @@ -4127,7 +4541,10 @@ function runGrokPrompt({ continueLast = false, extraArgs = [], defaultModel = null, - bin = GROK_BIN + bin = GROK_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES10 } = {}) { const invocation = buildGrokInvocation({ prompt, @@ -4141,10 +4558,22 @@ function runGrokPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT10 + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `grok timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "grok" }) }; + return { + ok: false, + error, + errorCode: classifyProviderFailure(result.error, { provider: "grok" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } const parsed = parseGrokJsonResult(result.stdout, result.stderr, result.status, { defaultModel: model ?? defaultModel @@ -4166,7 +4595,9 @@ function runGrokPromptStreaming({ onEvent = () => { }, bin = GROK_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES10 } = {}) { const invocation = buildGrokInvocation({ prompt, @@ -4184,9 +4615,11 @@ function runGrokPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT10, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -4200,14 +4633,14 @@ function runGrokPromptStreaming({ const hasVisibleText = Boolean(parsed.response.trim()); const stopReasonError = isNonSuccessStopReason(parsed.stopReason) ? `grok stopped with ${parsed.stopReason}` : null; const ok = result.ok && hasVisibleText && !parsed.providerError && !stopReasonError; - const error = ok ? null : parsed.providerError || stopReasonError || (result.ok ? "grok produced no visible text" : result.error); + const error = ok ? null : (!result.ok && result.errorCode ? result.error : null) || parsed.providerError || stopReasonError || (result.ok ? "grok produced no visible text" : result.error); return { ...result, ...parsed, model: model ?? defaultModel ?? DEFAULT_GROK_MODEL, ok, error, - errorCode: classifyProviderFailure(error, { provider: "grok" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "grok" }) }; }); } @@ -5653,11 +6086,11 @@ var COMMAND_DEFINITIONS = deepFreeze([ definition({ id: "tui", path: ["tui"], - summary: "Open the read-only terminal run inspector.", + summary: "Open the terminal run inspector; loading may update local recovery state.", usage: "polycli tui [--run-id ] [--history ]", surfaces: TERMINAL_SURFACE, dispatchTarget: "terminal-wrapper", - effects: { readsWorkspace: true }, + effects: { readsWorkspace: true, writesLocalState: true }, options: [ option("run-id", "string", "Select an initial run.", { valueName: "id" }), option("history", "integer", "Limit the run list.", { valueName: "n" }), @@ -6077,6 +6510,18 @@ function validateCommandPositionals(definition2, parsed, enumSources) { throw commandArgumentError(definition2, "Too many positional arguments.", { arguments: positionals }); } const positionalProvider = positionals[0] ?? null; + if (positionalProvider && explicitProvider) { + throw commandArgumentError( + definition2, + `Provider target cannot be supplied as positional provider '${positionalProvider}' together with --provider '${explicitProvider}'.`, + { + argument: positionalProvider, + positionalProvider, + explicitProvider, + conflictsWith: "--provider" + } + ); + } if (positionalProvider && !providers.includes(positionalProvider)) { throw commandArgumentError( definition2, @@ -6407,9 +6852,9 @@ function assertCommandRegistry({ assertCommandRegistry(); // plugins/polycli/scripts/lib/job-control.mjs -import fs6 from "node:fs"; +import fs7 from "node:fs"; import os4 from "node:os"; -import process4 from "node:process"; +import process5 from "node:process"; import { spawnSync as spawnSync3 } from "node:child_process"; // plugins/polycli/scripts/lib/state.mjs @@ -6550,8 +6995,13 @@ function describeStateRoot() { function stateRootDir() { return describeStateRoot().stateRoot; } -function resolveWorkspaceRoot(cwd = process.cwd()) { - const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { cwd }); +function resolveWorkspaceRoot(cwd = process.cwd(), { deadlineAt = null } = {}) { + const remainingMs = Number.isFinite(deadlineAt) ? Math.floor(deadlineAt - Date.now()) : null; + if (remainingMs != null && remainingMs <= 0) return path3.resolve(cwd); + const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { + cwd, + ...remainingMs == null ? {} : { timeout: remainingMs } + }); if (result.status === 0 && result.stdout.trim()) { return path3.resolve(result.stdout.trim()); } @@ -6575,6 +7025,9 @@ function resolveJobLogFile(workspaceRoot, jobId) { function resolveJobConfigFile(workspaceRoot, jobId) { return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); } +function resolveJobStartFailureFile(workspaceRoot, jobId) { + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.start-failure.json`); +} function chmodPrivateDir(dir) { try { fs3.chmodSync(dir, PRIVATE_DIR_MODE); @@ -6639,6 +7092,7 @@ function saveState(workspaceRoot, state, { preserveJobIds = [] } = {}) { if (job && job.jobId && !keptIds.has(job.jobId)) { removeJobFile(workspaceRoot, job.jobId); removeJobConfigFile(workspaceRoot, job.jobId); + removeJobStartFailureFile(workspaceRoot, job.jobId); removeJobLogFile(workspaceRoot, job.jobId); } } @@ -6653,7 +7107,7 @@ function updateState(workspaceRoot, mutate) { return saveState(workspaceRoot, state); }); } -function updateJobAtomically(workspaceRoot, jobId, buildNext) { +function updateJobAtomically(workspaceRoot, jobId, buildNext, { lockOptions = {} } = {}) { ensureStateDir(workspaceRoot); const lockPath = `${resolveStateFile(workspaceRoot)}.lock`; return withLockfile(lockPath, () => { @@ -6691,7 +7145,7 @@ function updateJobAtomically(workspaceRoot, jobId, buildNext) { } saveState(workspaceRoot, state, { preserveJobIds: [jobId] }); return { written: true, job, envelope }; - }); + }, lockOptions); } function upsertJob(workspaceRoot, jobPatch) { let savedJob = null; @@ -6772,182 +7226,36 @@ function removeJobConfigFile(workspaceRoot, jobId) { } catch { } } -function removeJobLogFile(workspaceRoot, jobId) { - try { - fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); - } catch { - } +function writeJobStartFailureFile(workspaceRoot, jobId, payload) { + ensureStateDir(workspaceRoot); + writeJsonAtomic(resolveJobStartFailureFile(workspaceRoot, jobId), payload, { mode: PRIVATE_FILE_MODE }); + return resolveJobStartFailureFile(workspaceRoot, jobId); } - -// plugins/polycli/scripts/lib/run-ledger.mjs -import { randomUUID as randomUUID2 } from "node:crypto"; -import { stat as fsStat } from "node:fs/promises"; -import path4 from "node:path"; - -// packages/polycli-utils/src/ndjson.js -import fs4 from "node:fs"; -function safeParseLine(line) { +function readJobStartFailureFile(workspaceRoot, jobId) { try { - return JSON.parse(line); + return JSON.parse(fs3.readFileSync(resolveJobStartFailureFile(workspaceRoot, jobId), "utf8")); } catch { return null; } } -function retainCompactedLines(lines, keepFrom, retentionGroupKey) { - const entries = []; - for (const line of lines) { - const record = safeParseLine(line); - if (record != null) { - entries.push({ line, record }); - } - } - if (typeof retentionGroupKey !== "function") { - return entries.slice(keepFrom).map((entry) => entry.line); - } - const grouped = entries.map((entry) => ({ - ...entry, - retentionGroup: retentionGroupKey(entry.record) - })); - const retainedStart = keepFrom < 0 ? Math.max(0, grouped.length + keepFrom) : Math.min(keepFrom, grouped.length); - const retainedGroups = new Set( - grouped.slice(keepFrom).map((entry) => entry.retentionGroup).filter((group) => group != null) - ); - return grouped.filter((entry, index) => index >= retainedStart || entry.retentionGroup != null && retainedGroups.has(entry.retentionGroup)).map((entry) => entry.line); -} -function chmodIfRequested(filePath, mode) { - if (mode === 438) return; +function removeJobStartFailureFile(workspaceRoot, jobId) { try { - fs4.chmodSync(filePath, mode); + fs3.unlinkSync(resolveJobStartFailureFile(workspaceRoot, jobId)); } catch { } } -function readNdjson(filePath) { - let text; +function removeJobLogFile(workspaceRoot, jobId) { try { - text = fs4.readFileSync(filePath, "utf8"); - } catch (error) { - if (error?.code === "ENOENT") { - return []; - } - throw error; - } - if (text.length === 0) { - return []; - } - const records = []; - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - const parsed = safeParseLine(trimmed); - if (parsed != null) { - records.push(parsed); - } - } - return records; -} -function appendNdjson(filePath, record, { - timeoutMs = 1e4, - staleMs = 3e4, - pollMs = 25, - maxBytes = null, - keepRatio = 0.5, - retentionGroupKey = null, - mode = 438 -} = {}) { - const lockPath = `${filePath}.lock`; - return withLockfile(lockPath, () => { - ensureParentDir(filePath); - let needsLeadingNewline = false; - try { - const stat = fs4.statSync(filePath); - if (stat.size > 0) { - const fd = fs4.openSync(filePath, "r"); - const lastByte = Buffer.alloc(1); - try { - fs4.readSync(fd, lastByte, 0, 1, stat.size - 1); - } finally { - fs4.closeSync(fd); - } - needsLeadingNewline = lastByte[0] !== 10; - } - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - const line = `${needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)} -`; - fs4.appendFileSync(filePath, line, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - if (maxBytes != null) { - const stat = fs4.statSync(filePath); - if (stat.size > maxBytes) { - const lines = fs4.readFileSync(filePath, "utf8").split("\n").filter(Boolean); - const validCount = lines.reduce((count, entry) => count + (safeParseLine(entry) != null ? 1 : 0), 0); - const keepFrom = Math.floor(validCount * (1 - keepRatio)); - const kept = retainCompactedLines(lines, keepFrom, retentionGroupKey); - writeFileAtomic(filePath, `${kept.join("\n")} -`, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - } - } - return true; - }, { timeoutMs, staleMs, pollMs }); -} -function appendNdjsonBatch(filePath, records, { - timeoutMs = 1e4, - staleMs = 3e4, - pollMs = 25, - maxBytes = null, - keepRatio = 0.5, - retentionGroupKey = null, - mode = 438 -} = {}) { - if (!Array.isArray(records)) { - throw new TypeError("records must be an array"); - } - if (records.length === 0) { - return true; + fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); + } catch { } - const serializedBatch = records.map((record) => { - const serialized = JSON.stringify(record); - if (typeof serialized !== "string") { - throw new TypeError("each record must be JSON-serializable"); - } - return serialized; - }); - const batch = `${serializedBatch.join("\n")} -`; - const lockPath = `${filePath}.lock`; - return withLockfile(lockPath, () => { - ensureParentDir(filePath); - let text = ""; - try { - text = fs4.readFileSync(filePath, "utf8"); - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - let nextText = `${text}${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}${batch}`; - if (maxBytes != null && Buffer.byteLength(nextText, "utf8") > maxBytes) { - const existing = text.split("\n").filter((entry) => safeParseLine(entry) != null); - const targetCount = Math.max( - serializedBatch.length, - Math.ceil((existing.length + serializedBatch.length) * keepRatio) - ); - const allLines = [...existing, ...serializedBatch]; - const keepFrom = Math.max(0, allLines.length - targetCount); - const kept = retainCompactedLines(allLines, keepFrom, retentionGroupKey); - nextText = `${kept.join("\n")} -`; - } - writeFileAtomic(filePath, nextText, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - return true; - }, { timeoutMs, staleMs, pollMs }); } +// plugins/polycli/scripts/lib/background-start.mjs +import fs5 from "node:fs"; +import process4 from "node:process"; +import { spawn as spawn2 } from "node:child_process"; + // plugins/polycli/scripts/lib/cli-contract.mjs var ERROR_CODES = /* @__PURE__ */ new Set([ "invalid_argument", @@ -7250,8 +7558,8 @@ function normalizeStatusListWait(wait, payload) { function normalizeSessionEntries(entries) { return clonePublicResult(Array.isArray(entries) ? entries : []); } -function serializeProviderCommand(commandId, payload, context) { - if (context.background === true || payload?.job && context.background !== false) { +function serializeProviderCommand(commandId, payload) { + if (payload?.job) { return { type: "job.started", job: normalizeV2Job(payload.job) @@ -7282,7 +7590,7 @@ function serializeV2Result(commandId, legacyPayload, context = {}) { }; } if (PROVIDER_COMMANDS.has(id)) { - return serializeProviderCommand(id, payload, context); + return serializeProviderCommand(id, payload); } if (id === "status") { if (payload.job) { @@ -7444,24 +7752,193 @@ function createV2ErrorEnvelope(error, context = {}) { } // plugins/polycli/scripts/lib/run-ledger.mjs -var MAX_LEDGER_BYTES = 2e6; -var KEEP_RATIO = 0.5; -var PRIVATE_FILE_MODE2 = 384; -var RUN_ID_RE = /^[A-Za-z0-9_.-]{1,96}$/; -var SECRET_LONG_OPT_RE = /(token|secret|password|api-?key|access-?key|credential)/i; -var SECRET_ENV_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_KEY|CREDENTIAL)/i; -var PROMPT_COMMANDS = /* @__PURE__ */ new Set(["ask", "rescue", "review", "adversarial-review"]); -var VALUE_OPTIONS = /* @__PURE__ */ new Set([ - "--provider", - "--model", - "--base", - "--scope", - "--resume", - "--effort", - "--run-id", - "--timeout-ms", - "--history" -]); +import { randomUUID as randomUUID2 } from "node:crypto"; +import { stat as fsStat } from "node:fs/promises"; +import path4 from "node:path"; + +// packages/polycli-utils/src/ndjson.js +import fs4 from "node:fs"; +function safeParseLine(line) { + try { + return JSON.parse(line); + } catch { + return null; + } +} +function retainCompactedLines(lines, keepFrom, retentionGroupKey) { + const entries = []; + for (const line of lines) { + const record = safeParseLine(line); + if (record != null) { + entries.push({ line, record }); + } + } + if (typeof retentionGroupKey !== "function") { + return entries.slice(keepFrom).map((entry) => entry.line); + } + const grouped = entries.map((entry) => ({ + ...entry, + retentionGroup: retentionGroupKey(entry.record) + })); + const retainedStart = keepFrom < 0 ? Math.max(0, grouped.length + keepFrom) : Math.min(keepFrom, grouped.length); + const retainedGroups = new Set( + grouped.slice(keepFrom).map((entry) => entry.retentionGroup).filter((group) => group != null) + ); + return grouped.filter((entry, index) => index >= retainedStart || entry.retentionGroup != null && retainedGroups.has(entry.retentionGroup)).map((entry) => entry.line); +} +function chmodIfRequested(filePath, mode) { + if (mode === 438) return; + try { + fs4.chmodSync(filePath, mode); + } catch { + } +} +function readNdjson(filePath) { + let text; + try { + text = fs4.readFileSync(filePath, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") { + return []; + } + throw error; + } + if (text.length === 0) { + return []; + } + const records = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parsed = safeParseLine(trimmed); + if (parsed != null) { + records.push(parsed); + } + } + return records; +} +function appendNdjson(filePath, record, { + timeoutMs = 1e4, + staleMs = 3e4, + pollMs = 25, + maxBytes = null, + keepRatio = 0.5, + retentionGroupKey = null, + mode = 438 +} = {}) { + const lockPath = `${filePath}.lock`; + return withLockfile(lockPath, () => { + ensureParentDir(filePath); + let needsLeadingNewline = false; + try { + const stat = fs4.statSync(filePath); + if (stat.size > 0) { + const fd = fs4.openSync(filePath, "r"); + const lastByte = Buffer.alloc(1); + try { + fs4.readSync(fd, lastByte, 0, 1, stat.size - 1); + } finally { + fs4.closeSync(fd); + } + needsLeadingNewline = lastByte[0] !== 10; + } + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + const line = `${needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)} +`; + fs4.appendFileSync(filePath, line, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + if (maxBytes != null) { + const stat = fs4.statSync(filePath); + if (stat.size > maxBytes) { + const lines = fs4.readFileSync(filePath, "utf8").split("\n").filter(Boolean); + const validCount = lines.reduce((count, entry) => count + (safeParseLine(entry) != null ? 1 : 0), 0); + const keepFrom = Math.floor(validCount * (1 - keepRatio)); + const kept = retainCompactedLines(lines, keepFrom, retentionGroupKey); + writeFileAtomic(filePath, `${kept.join("\n")} +`, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + } + } + return true; + }, { timeoutMs, staleMs, pollMs }); +} +function appendNdjsonBatch(filePath, records, { + timeoutMs = 1e4, + staleMs = 3e4, + pollMs = 25, + maxBytes = null, + keepRatio = 0.5, + retentionGroupKey = null, + mode = 438 +} = {}) { + if (!Array.isArray(records)) { + throw new TypeError("records must be an array"); + } + if (records.length === 0) { + return true; + } + const serializedBatch = records.map((record) => { + const serialized = JSON.stringify(record); + if (typeof serialized !== "string") { + throw new TypeError("each record must be JSON-serializable"); + } + return serialized; + }); + const batch = `${serializedBatch.join("\n")} +`; + const lockPath = `${filePath}.lock`; + return withLockfile(lockPath, () => { + ensureParentDir(filePath); + let text = ""; + try { + text = fs4.readFileSync(filePath, "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + let nextText = `${text}${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}${batch}`; + if (maxBytes != null && Buffer.byteLength(nextText, "utf8") > maxBytes) { + const existing = text.split("\n").filter((entry) => safeParseLine(entry) != null); + const targetCount = Math.max( + serializedBatch.length, + Math.ceil((existing.length + serializedBatch.length) * keepRatio) + ); + const allLines = [...existing, ...serializedBatch]; + const keepFrom = Math.max(0, allLines.length - targetCount); + const kept = retainCompactedLines(allLines, keepFrom, retentionGroupKey); + nextText = `${kept.join("\n")} +`; + } + writeFileAtomic(filePath, nextText, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + return true; + }, { timeoutMs, staleMs, pollMs }); +} + +// plugins/polycli/scripts/lib/run-ledger.mjs +var MAX_LEDGER_BYTES = 2e6; +var KEEP_RATIO = 0.5; +var PRIVATE_FILE_MODE2 = 384; +var RUN_ID_RE = /^[A-Za-z0-9_.-]{1,96}$/; +var SECRET_LONG_OPT_RE = /(token|secret|password|api-?key|access-?key|credential)/i; +var SECRET_ENV_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_KEY|CREDENTIAL)/i; +var PROMPT_COMMANDS = /* @__PURE__ */ new Set(["ask", "rescue", "review", "adversarial-review"]); +var VALUE_OPTIONS = /* @__PURE__ */ new Set([ + "--provider", + "--model", + "--base", + "--scope", + "--resume", + "--effort", + "--run-id", + "--timeout-ms", + "--history" +]); var SHORT_VALUE_OPTIONS = /* @__PURE__ */ new Set(["-m"]); var FOCUS_VALUE_OPTIONS = /* @__PURE__ */ new Set(["--focus"]); var VALID_HOST_SURFACES = /* @__PURE__ */ new Set([ @@ -7610,11 +8087,20 @@ function redactLedgerError(error) { if (message == null || message === "") return null; return { message: sanitizePublicErrorMessage(message, 300) }; } +function sanitizeLedgerPreview(preview) { + if (preview == null) return null; + const text = String(preview); + return sanitizePublicErrorMessage(text, text.length); +} function redactTerminalDescriptor(descriptor) { if (!descriptor || typeof descriptor !== "object") return descriptor ?? null; return { ...descriptor, - events: Array.isArray(descriptor.events) ? descriptor.events.map((event) => ({ ...event, error: redactLedgerError(event.error) })) : descriptor.events + events: Array.isArray(descriptor.events) ? descriptor.events.map((event) => ({ + ...event, + error: redactLedgerError(event.error), + ...Object.prototype.hasOwnProperty.call(event, "preview") ? { preview: sanitizeLedgerPreview(event.preview) } : {} + })) : descriptor.events }; } function createRunLedgerEvent(event = {}) { @@ -7643,7 +8129,7 @@ function createRunLedgerEvent(event = {}) { providerSessionId, timingRef: event.timingRef ?? null, error: redactLedgerError(event.error), - preview: event.preview ?? null, + preview: sanitizeLedgerPreview(event.preview), stdoutBytes: event.stdoutBytes ?? null, stderrBytes: event.stderrBytes ?? null, durationMs: event.durationMs ?? null, @@ -7677,7 +8163,7 @@ function appendRunLedgerEvent(workspaceRoot, event) { }); return full; } -function appendRunLedgerEvents(workspaceRoot, events) { +function appendRunLedgerEvents(workspaceRoot, events, lockOptions = {}) { if (!Array.isArray(events)) { throw new TypeError("events must be an array"); } @@ -7691,6 +8177,7 @@ function appendRunLedgerEvents(workspaceRoot, events) { workspaceSlug: event.workspaceSlug ?? workspaceSlug })); appendNdjsonBatch(file, full, { + ...lockOptions, maxBytes: MAX_LEDGER_BYTES, keepRatio: KEEP_RATIO, retentionGroupKey: terminalLedgerRetentionGroupKey, @@ -7880,7 +8367,7 @@ function buildExpectedTerminalPair(events) { expected: rawExpected.map((event) => ({ ...event, terminalDescriptor: descriptor })) }; } -function ensureRunLedgerTerminalPair(workspaceRoot, events) { +function ensureRunLedgerTerminalPair(workspaceRoot, events, { lockOptions = {} } = {}) { if (!Array.isArray(events) || events.length !== 2) { throw new TypeError("terminal ledger pair must contain exactly two events"); } @@ -7897,7 +8384,7 @@ function ensureRunLedgerTerminalPair(workspaceRoot, events) { if (ambiguousLegacy.length > 0) { throw new Error(`Incomplete or conflicting terminal ledger pair for ${identityLabel}`); } - return appendRunLedgerEvents(workspaceRoot, expected); + return appendRunLedgerEvents(workspaceRoot, expected, lockOptions); } if (existing.length === 1) { const [partial] = existing; @@ -7920,7 +8407,7 @@ function ensureRunLedgerTerminalPair(workspaceRoot, events) { ...missing, terminalDescriptor: repairDescriptor }; - return [...existing, ...appendRunLedgerEvents(workspaceRoot, [repair])]; + return [...existing, ...appendRunLedgerEvents(workspaceRoot, [repair], lockOptions)]; } if (!terminalPairMatches(existing, expected)) { throw new Error(`Incomplete or conflicting terminal ledger pair for ${identityLabel}`); @@ -8251,40 +8738,342 @@ function summarizeRunLedger(events) { incrementCount(failureClassCounts, classifyRunFailure(event)); } return { - runId: group.runId, - commands: group.commands, - startedAt: group.events[0]?.at || null, - updatedAt: group.events.at(-1)?.at || null, - providerCount: new Set(decisions.map((event) => event.provider)).size, - adoptedCount: decisions.filter((event) => event.status === "adopted").length, - skippedCount: decisions.filter((event) => event.status === "skipped").length, - failedCount: decisions.filter((event) => event.status === "failed").length, - failureClassCounts + runId: group.runId, + commands: group.commands, + startedAt: group.events[0]?.at || null, + updatedAt: group.events.at(-1)?.at || null, + providerCount: new Set(decisions.map((event) => event.provider)).size, + adoptedCount: decisions.filter((event) => event.status === "adopted").length, + skippedCount: decisions.filter((event) => event.status === "skipped").length, + failedCount: decisions.filter((event) => event.status === "failed").length, + failureClassCounts + }; + }); +} +function buildRunExplanation(events, runId) { + const group = groupRunLedgerEvents(events).get(runId); + if (!group) { + return { runId, found: false, text: `Run ${runId} was not found.`, events: [] }; + } + const decisions = group.projectedEvents.filter((event) => event.phase === "provider_decision"); + const lines = decisions.map( + (event) => `${event.provider || "run"} ${event.status}${event.reason ? ` (${event.reason})` : ""}` + ); + for (const event of group.projectedEvents.filter((item) => item.phase === "attempt_result" && item.status === "failed")) { + const subject = event.provider || event.jobId || "run"; + lines.push(`attempt ${subject} failed (${classifyRunFailure(event)})`); + } + const terminalProviders = new Set(group.projectedEvents.filter((event) => TERMINAL_LEDGER_PHASES.has(event.phase) && event.provider).map((event) => event.provider)); + for (const provider of new Set(group.projectedEvents.filter((event) => (event.phase === "attempt_started" || event.phase === "job_started") && event.provider).map((event) => event.provider))) { + if (!terminalProviders.has(provider)) lines.push(`${provider} unfinished`); + } + return { runId, found: true, text: lines.join("\n"), events: group.events }; +} + +// plugins/polycli/scripts/lib/background-start.mjs +var ACTIVE_JOB_STATUSES2 = /* @__PURE__ */ new Set(["queued", "running"]); +var TERMINAL_JOB_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); +function blocksFailureFinalizer(envelope) { + return TERMINAL_JOB_STATUSES2.has(envelope?.job?.status) || envelope?.cancellationIntent?.status === "requested" && ACTIVE_JOB_STATUSES2.has(envelope?.job?.status); +} +function cleanupRuntimeOptions(runtimeOptions = {}) { + const cleanupPaths = Array.isArray(runtimeOptions?.cleanupPaths) ? runtimeOptions.cleanupPaths : []; + for (const cleanupPath of cleanupPaths) { + if (typeof cleanupPath !== "string" || cleanupPath.trim() === "") continue; + try { + fs5.rmSync(cleanupPath, { recursive: true, force: true }); + } catch { + } + } +} +function buildRunEvent(runContext, base) { + if (!runContext?.runId) return null; + const command = base.command || runContext.command; + return { + runId: runContext.runId, + hostSurface: runContext.hostSurface || "unknown", + argv: runContext.argv || [], + invocationId: runContext.invocationId ?? null, + attemptId: runContext.attemptId ?? null, + jobId: runContext.jobId ?? base.jobId ?? null, + command, + commands: Array.from(new Set([ + ...runContext.commands || [], + command, + ...base.commands || [] + ].filter(Boolean))).sort(), + ...base + }; +} +function prepareFailureEvents(runContext, events) { + const material = events.map((event) => buildRunEvent(runContext, event)).filter(Boolean); + if (material.length === 0) return []; + const terminalDescriptor = createTerminalLedgerDescriptor(material); + return material.map((event) => ({ ...event, terminalDescriptor })); +} +function safeRunIdentity(jobId, execution, runContext) { + return { + runId: runContext?.runId ?? null, + invocationId: runContext?.invocationId ?? null, + attemptId: runContext?.attemptId ?? null, + command: runContext?.command || execution.kind, + commands: Array.from(new Set([ + ...runContext?.commands || [], + runContext?.command, + execution.kind + ].filter(Boolean))).sort(), + hostSurface: runContext?.hostSurface || "unknown", + jobId, + provider: execution.provider, + kind: execution.kind, + logFile: runContext?.logFile ?? null + }; +} +function buildStartFailureRecovery(jobId, execution, runContext, error, runtimeOptions) { + const safeMessage = sanitizePublicErrorMessage(error?.message || error, 300); + const identity = safeRunIdentity(jobId, execution, runContext); + const terminalReason = `${execution.kind}_failed`; + const terminalEvents = prepareFailureEvents(identity, [ + { + command: identity.command, + kind: execution.kind, + provider: execution.provider, + phase: "attempt_result", + status: "failed", + reason: terminalReason, + attempt: { ordinal: 1 }, + jobId, + error: { message: safeMessage }, + logFile: identity.logFile + }, + { + command: identity.command, + kind: execution.kind, + provider: execution.provider, + phase: "provider_decision", + status: "failed", + reason: terminalReason, + jobId + } + ]); + return { + version: 1, + jobId, + provider: execution.provider, + kind: execution.kind, + error: safeMessage, + recordedAt: (/* @__PURE__ */ new Date()).toISOString(), + identity, + terminalDescriptor: terminalEvents[0]?.terminalDescriptor ?? null, + ownedCleanupPaths: Array.isArray(runtimeOptions?.cleanupPaths) ? runtimeOptions.cleanupPaths.filter((entry) => typeof entry === "string" && entry.trim() !== "") : [] + }; +} +function materialExecution(recovery) { + return { + provider: recovery.provider, + kind: recovery.kind, + runtimeOptions: { cleanupPaths: recovery.ownedCleanupPaths || [] } + }; +} +function finalizeBackgroundStartFailure(workspaceRoot, recovery, { lockOptions = {} } = {}) { + const { jobId } = recovery; + const execution = materialExecution(recovery); + const runContext = recovery.identity; + const safeMessage = recovery.error; + return updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { + if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || blocksFailureFinalizer(storedEnvelope)) { + return null; + } + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); + const failedJob = { + ...latest, + invocationId: latest.invocationId ?? runContext?.invocationId ?? null, + attemptId: latest.attemptId ?? runContext?.attemptId ?? null, + status: "failed", + pid: null, + finishedAt, + updatedAt: finishedAt, + error: safeMessage + }; + const terminalReason = `${execution.kind}_failed`; + const terminalEvents = prepareFailureEvents(runContext, [ + { + command: runContext?.command || execution.kind, + kind: execution.kind, + provider: execution.provider, + phase: "attempt_result", + status: "failed", + reason: terminalReason, + attempt: { ordinal: 1 }, + jobId, + error: { message: safeMessage }, + logFile: failedJob.logFile || runContext?.logFile || null + }, + { + command: runContext?.command || execution.kind, + kind: execution.kind, + provider: execution.provider, + phase: "provider_decision", + status: "failed", + reason: terminalReason, + jobId + } + ]).map((event) => ({ + ...event, + terminalDescriptor: recovery.terminalDescriptor || event.terminalDescriptor + })); + const terminalDescriptor = recovery.terminalDescriptor || terminalEvents[0]?.terminalDescriptor || null; + return { + job: failedJob, + envelope: { + job: failedJob, + result: { ok: false, error: safeMessage }, + terminalReason, + terminalDescriptor + }, + beforeStateCommit() { + if (terminalEvents.length > 0) { + ensureRunLedgerTerminalPair(workspaceRoot, terminalEvents); + } + } }; - }); + }, { lockOptions }); } -function buildRunExplanation(events, runId) { - const group = groupRunLedgerEvents(events).get(runId); - if (!group) { - return { runId, found: false, text: `Run ${runId} was not found.`, events: [] }; +function persistedJobBlocksFailure(workspaceRoot, jobId) { + const job = getJob(workspaceRoot, jobId); + const envelope = readJobFile(resolveJobFile(workspaceRoot, jobId)); + return !job || !ACTIVE_JOB_STATUSES2.has(job.status) || blocksFailureFinalizer(envelope); +} +function recoverBackgroundStartFailure(workspaceRoot, jobId, options = {}) { + const recovery = readJobStartFailureFile(workspaceRoot, jobId); + if (!recovery || recovery.version !== 1 || recovery.jobId !== jobId) { + return { written: false, finalizationError: null }; } - const decisions = group.projectedEvents.filter((event) => event.phase === "provider_decision"); - const lines = decisions.map( - (event) => `${event.provider || "run"} ${event.status}${event.reason ? ` (${event.reason})` : ""}` - ); - for (const event of group.projectedEvents.filter((item) => item.phase === "attempt_result" && item.status === "failed")) { - const subject = event.provider || event.jobId || "run"; - lines.push(`attempt ${subject} failed (${classifyRunFailure(event)})`); + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; } - const terminalProviders = new Set(group.projectedEvents.filter((event) => TERMINAL_LEDGER_PHASES.has(event.phase) && event.provider).map((event) => event.provider)); - for (const provider of new Set(group.projectedEvents.filter((event) => (event.phase === "attempt_started" || event.phase === "job_started") && event.provider).map((event) => event.provider))) { - if (!terminalProviders.has(provider)) lines.push(`${provider} unfinished`); + try { + const write = finalizeBackgroundStartFailure(workspaceRoot, recovery, options); + if (write.written) { + cleanupRuntimeOptions({ cleanupPaths: recovery.ownedCleanupPaths }); + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } + return { written: write.written, finalizationError: null }; + } catch (finalizationError) { + return { written: false, finalizationError }; } - return { runId, found: true, text: lines.join("\n"), events: group.events }; +} +function recordBackgroundStartFailure(workspaceRoot, jobId, execution, runContext, error, options = {}) { + const configFile = resolveJobConfigFile(workspaceRoot, jobId); + const config = readJobConfigFile(configFile); + const runtimeOptions = config?.execution?.runtimeOptions ?? execution.runtimeOptions; + const recovery = buildStartFailureRecovery(jobId, execution, runContext, error, runtimeOptions); + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + writeJobStartFailureFile(workspaceRoot, jobId, recovery); + } catch { + } + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + const write = finalizeBackgroundStartFailure(workspaceRoot, recovery, options); + if (write.written) { + cleanupRuntimeOptions(runtimeOptions); + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } + return { written: write.written, finalizationError: null }; + } catch (finalizationError) { + cleanupRuntimeOptions(runtimeOptions); + return { written: false, finalizationError }; + } +} +async function startBackgroundWorker({ + workspaceRoot, + job, + execution, + runContext, + config, + companionPath, + env = process4.env, + failureFinalizationOptions = {} +}, { + writeConfigFile = writeJobConfigFile, + writeLogFile = fs5.writeFileSync, + openLogFile = fs5.openSync, + closeLogFile = fs5.closeSync, + spawnWorker = spawn2 +} = {}) { + let child = null; + let logFd = null; + let startFailure = null; + let startWarning = null; + let closeWarning = null; + const configFile = resolveJobConfigFile(workspaceRoot, job.jobId); + try { + writeConfigFile(workspaceRoot, job.jobId, config); + writeLogFile(job.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] started ${job.provider} ${job.kind} +`, { + encoding: "utf8", + mode: 384 + }); + logFd = openLogFile(job.logFile, "a", 384); + child = spawnWorker(process4.execPath, [companionPath, "_job-worker", configFile], { + cwd: execution.cwd, + env: { ...env }, + stdio: ["ignore", logFd, logFd], + detached: true + }); + try { + child.once("error", (asyncError) => { + recordBackgroundStartFailure( + workspaceRoot, + job.jobId, + execution, + runContext, + asyncError, + failureFinalizationOptions + ); + }); + child.unref(); + } catch (error) { + startWarning = error; + } + } catch (error) { + startFailure = error; + } finally { + if (logFd != null) { + try { + closeLogFile(logFd); + } catch (error) { + if (child) closeWarning = error; + else if (!startFailure) startFailure = error; + } + } + } + if (startFailure) { + recordBackgroundStartFailure( + workspaceRoot, + job.jobId, + execution, + runContext, + startFailure, + failureFinalizationOptions + ); + throw startFailure; + } + return { child, startWarning, closeWarning }; } // plugins/polycli/scripts/lib/sessions.mjs -import fs5 from "node:fs"; +import fs6 from "node:fs"; import os3 from "node:os"; import path5 from "node:path"; import { createHash } from "node:crypto"; @@ -8330,7 +9119,7 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho case "kimi": { let realCwd = workspaceRoot; try { - realCwd = fs5.realpathSync(workspaceRoot); + realCwd = fs6.realpathSync(workspaceRoot); } catch { } const slug = `wd_${path5.basename(realCwd)}_${createHash("sha256").update(realCwd).digest("hex").slice(0, 12)}`; @@ -8355,7 +9144,7 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho return { path: null, reason: `no artifact derivation for provider ${provider ?? "?"}` }; } } -function recordArtifactPath(candidate, { homedir, lstatFn = fs5.lstatSync, realpathFn = fs5.realpathSync, existsFn = fs5.existsSync } = {}) { +function recordArtifactPath(candidate, { homedir, lstatFn = fs6.lstatSync, realpathFn = fs6.realpathSync, existsFn = fs6.existsSync } = {}) { if (!candidate || typeof candidate.path !== "string") return null; const { path: candidatePath, provider } = candidate; if (!existsFn(candidatePath)) return null; @@ -8432,7 +9221,7 @@ function collectNonPurgeableSessions(events = [], { homedir = os3.homedir() } = } return out; } -function planPurge({ recorded = [], homedir, lstatFn = fs5.lstatSync, realpathFn = fs5.realpathSync, existsFn = fs5.existsSync, sizeFn } = {}) { +function planPurge({ recorded = [], homedir, lstatFn = fs6.lstatSync, realpathFn = fs6.realpathSync, existsFn = fs6.existsSync, sizeFn } = {}) { const deletable = []; const skipped = []; const defaultSizeFn = (p) => { @@ -8499,7 +9288,7 @@ function planPurge({ recorded = [], homedir, lstatFn = fs5.lstatSync, realpathFn } return { deletable, skipped }; } -function executePurge(plan, { confirm = false, rmFn = (p) => fs5.rmSync(p, { recursive: true, force: true }) } = {}) { +function executePurge(plan, { confirm = false, rmFn = (p) => fs6.rmSync(p, { recursive: true, force: true }) } = {}) { const deletable = plan?.deletable ?? []; const skipped = plan?.skipped ?? []; if (!confirm) { @@ -8522,27 +9311,74 @@ var TERMINAL_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancel var WAIT_TARGETS = /* @__PURE__ */ new Set(["terminal", "completed", "failed", "cancelled"]); var DEFAULT_STATUS_LIMIT = 8; var MAX_SELECTOR_CANDIDATES = 8; +function createDeadlineError() { + const error = new Error("cancellation deadline exceeded"); + error.code = "EDEADLINE"; + return error; +} +function remainingDeadlineMs(deadlineAt) { + return Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : null; +} +function deadlineLockOptions(deadlineAt) { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs == null) return {}; + if (remainingMs <= 0) throw createDeadlineError(); + return { + timeoutMs: Math.max(1, Math.ceil(remainingMs)), + pollMs: Math.max(1, Math.min(25, Math.ceil(remainingMs))) + }; +} +function isDeadlineFailure(error, deadlineAt) { + return Number.isFinite(deadlineAt) && ["EDEADLINE", "ELOCKTIMEOUT", "ETIMEDOUT"].includes(error?.code); +} +async function awaitWithinDeadline(promise, deadlineAt) { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs == null) { + await promise; + return true; + } + if (remainingMs <= 0) return false; + let timer = null; + try { + return await Promise.race([ + Promise.resolve(promise).then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), remainingMs); + }) + ]); + } finally { + if (timer) clearTimeout(timer); + } +} function isProcessAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { - process4.kill(pid, 0); + process5.kill(pid, 0); return true; } catch { return false; } } -function isExpectedWorkerProcess(pid, configFile) { +function isExpectedWorkerProcess(pid, configFile, { + deadlineAt = null, + platform = process5.platform, + spawnProcess = spawnSync3 +} = {}) { if (!Number.isInteger(pid) || pid <= 0 || !configFile) return null; + const remainingMs = Number.isFinite(deadlineAt) ? Math.floor(deadlineAt - Date.now()) : null; + if (remainingMs != null && remainingMs <= 0) return null; try { - const result = process4.platform === "win32" ? spawnSync3("powershell.exe", [ + const spawnOptions = { + encoding: "utf8", + stdio: "pipe", + ...remainingMs == null ? {} : { timeout: remainingMs } + }; + const result = platform === "win32" ? spawnProcess("powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine` - ], { encoding: "utf8", stdio: "pipe" }) : spawnSync3("ps", ["-ww", "-o", "command=", "-p", String(pid)], { - encoding: "utf8", - stdio: "pipe" - }); + ], spawnOptions) : spawnProcess("ps", ["-ww", "-o", "command=", "-p", String(pid)], spawnOptions); if (result.error) return null; if (result.status !== 0) return false; return result.stdout.includes("_job-worker") && result.stdout.includes(configFile); @@ -8556,7 +9392,7 @@ function sortJobsNewestFirst(jobs) { function readProgressPreview(logFile, maxLines = 4) { if (!logFile) return ""; try { - const lines = fs6.readFileSync(logFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const lines = fs7.readFileSync(logFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); return lines.slice(-maxLines).join("\n"); } catch { return ""; @@ -8573,6 +9409,9 @@ function enrichJob(workspaceRoot, job) { function isTerminalEnvelope(envelope) { return Boolean(envelope?.job && TERMINAL_STATUSES2.has(envelope.job.status)); } +function hasPendingCancellationIntent(envelope) { + return envelope?.cancellationIntent?.status === "requested"; +} function buildRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result = null, reason = "worker_exited", terminalDescriptor = null } = {}) { const command = runContext.command || config?.execution?.kind || job.kind || null; const provider = runContext.provider || config?.execution?.provider || job.provider || null; @@ -8671,7 +9510,18 @@ function applyTerminalDescriptor(events, terminalDescriptor) { }); } function prepareRecoveredTerminalEvents(workspaceRoot, job, config, { result = null, reason = "worker_exited", terminalDescriptor = null } = {}) { - const runContext = config?.runContext; + const descriptorEvent = terminalDescriptor?.events?.[0] ?? null; + const runContext = config?.runContext || (terminalDescriptor?.runId ? { + runId: terminalDescriptor.runId, + command: descriptorEvent?.command ?? job.kind ?? null, + kind: descriptorEvent?.kind ?? job.kind ?? null, + provider: descriptorEvent?.provider ?? job.provider ?? null, + hostSurface: descriptorEvent?.hostSurface || "unknown", + invocationId: terminalDescriptor.invocationId ?? job.invocationId ?? null, + attemptId: terminalDescriptor.attemptId ?? job.attemptId ?? null, + jobId: terminalDescriptor.jobId ?? job.jobId ?? null, + logFile: job.logFile ?? null + } : null); if (!runContext?.runId) return { events: [], terminalDescriptor }; const events = buildRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result, @@ -8684,9 +9534,9 @@ function prepareRecoveredTerminalEvents(workspaceRoot, job, config, { result = n terminalDescriptor: descriptor }; } -function ensureRecoveredTerminalEvents(workspaceRoot, prepared) { +function ensureRecoveredTerminalEvents(workspaceRoot, prepared, { lockOptions = {} } = {}) { if (prepared.events.length > 0) { - ensureRunLedgerTerminalPair(workspaceRoot, prepared.events); + ensureRunLedgerTerminalPair(workspaceRoot, prepared.events, { lockOptions }); } } function cleanupRuntimePaths(config) { @@ -8695,16 +9545,26 @@ function cleanupRuntimePaths(config) { for (const cleanupPath of cleanupPaths) { if (typeof cleanupPath !== "string" || !cleanupPath) continue; try { - fs6.rmSync(cleanupPath, { recursive: true, force: true }); + fs7.rmSync(cleanupPath, { recursive: true, force: true }); } catch { } } } function refreshJob(workspaceRoot, job) { if (!job || !ACTIVE_STATUSES2.has(job.status)) { + if (job?.jobId && TERMINAL_STATUSES2.has(job.status)) { + removeJobStartFailureFile(workspaceRoot, job.jobId); + } return job ? enrichJob(workspaceRoot, job) : null; } const storedEnvelope = readJobFile(resolveJobFile(workspaceRoot, job.jobId)); + if (hasPendingCancellationIntent(storedEnvelope)) { + return enrichJob(workspaceRoot, job); + } + const startFailureRecovery = recoverBackgroundStartFailure(workspaceRoot, job.jobId); + if (startFailureRecovery.written) { + return enrichJob(workspaceRoot, getJob(workspaceRoot, job.jobId) || job); + } if (!job.pid && !isTerminalEnvelope(storedEnvelope)) { return enrichJob(workspaceRoot, job); } @@ -8778,8 +9638,12 @@ function refreshJob(workspaceRoot, job) { const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, job.jobId)); cleanupRuntimePaths(config); removeJobConfigFile(workspaceRoot, job.jobId); + removeJobStartFailureFile(workspaceRoot, job.jobId); } const current = write.written ? write.job : getJob(workspaceRoot, job.jobId) || job; + if (TERMINAL_STATUSES2.has(current?.status)) { + removeJobStartFailureFile(workspaceRoot, job.jobId); + } return enrichJob(workspaceRoot, current); } catch { return enrichJob(workspaceRoot, getJob(workspaceRoot, job.jobId) || job); @@ -8787,11 +9651,11 @@ function refreshJob(workspaceRoot, job) { } function buildStatusSnapshot(workspaceRoot, { showAll = false } = {}) { const refreshed = sortJobsNewestFirst(listJobs(workspaceRoot)).map((job) => refreshJob(workspaceRoot, job)); - const limited = showAll ? refreshed : refreshed.slice(0, DEFAULT_STATUS_LIMIT); + const recent = refreshed.filter((job) => TERMINAL_STATUSES2.has(job.status)); return { totalJobs: refreshed.length, - running: limited.filter((job) => ACTIVE_STATUSES2.has(job.status)), - recent: limited.filter((job) => TERMINAL_STATUSES2.has(job.status)) + running: refreshed.filter((job) => ACTIVE_STATUSES2.has(job.status)), + recent: showAll ? recent : recent.slice(0, DEFAULT_STATUS_LIMIT) }; } function refreshJobsForLedgerRecovery(workspaceRoot) { @@ -8974,84 +9838,94 @@ async function waitForJob(workspaceRoot, jobId, options = {}) { async function cancelJob(workspaceRoot, jobId, { terminate = terminateProcessTree, isWorkerAlive = isProcessAlive, - isExpectedWorker = isExpectedWorkerProcess + isExpectedWorker = isExpectedWorkerProcess, + deadlineAt = null } = {}) { let pidToKill = null; let configForCleanup = null; let cancellationEnvelope = null; let reason = null; const requestedAt = (/* @__PURE__ */ new Date()).toISOString(); - const intentWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { - if (!current) { - reason = "not_found"; - return null; - } - if (!ACTIVE_STATUSES2.has(current.status)) { - reason = "not_cancellable"; - return null; - } - const resumingCancellation = storedEnvelope?.job?.status === "cancelled"; - if (isTerminalEnvelope(storedEnvelope) && !resumingCancellation) { - reason = "not_cancellable"; - return null; - } - pidToKill = current.pid ?? null; - configForCleanup = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); - const intentJob = resumingCancellation ? { - ...current, - ...storedEnvelope.job, - status: "cancelled", - pid: null, - finishedAt: storedEnvelope.job.finishedAt || requestedAt, - updatedAt: requestedAt - } : { - ...current, - status: "cancelled", - pid: null, - finishedAt: requestedAt, - updatedAt: requestedAt - }; - const cancellationResult = resumingCancellation ? storedEnvelope.result || { ok: false, error: "cancelled" } : { ok: false, error: "cancelled" }; - const terminal = prepareRecoveredTerminalEvents(workspaceRoot, intentJob, configForCleanup, { - result: cancellationResult, - reason: "cancelled", - terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null - }); - cancellationEnvelope = { - job: intentJob, - result: cancellationResult, - terminalReason: "cancelled", - terminalDescriptor: terminal.terminalDescriptor - }; - return { - // Do not make the state terminal yet. It remains the recovery point if this process exits - // after persisting the intent but before the worker receives its signal. - job: current, - envelope: cancellationEnvelope, - beforeStateCommit() { - ensureRecoveredTerminalEvents(workspaceRoot, terminal); + let intentWrite; + try { + intentWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { + if (!current) { + reason = "not_found"; + return null; } - }; - }); + if (!ACTIVE_STATUSES2.has(current.status)) { + reason = "not_cancellable"; + return null; + } + const resumingCancellation = hasPendingCancellationIntent(storedEnvelope); + if (isTerminalEnvelope(storedEnvelope) && !resumingCancellation) { + reason = "not_cancellable"; + return null; + } + pidToKill = current.pid ?? null; + configForCleanup = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); + cancellationEnvelope = resumingCancellation ? storedEnvelope : { + job: current, + cancellationIntent: { + status: "requested", + requestedAt + } + }; + return { + // Do not make the state or envelope terminal yet. Both remain recovery points if this + // process exits after persisting the intent but before the worker receives its signal. + job: current, + envelope: cancellationEnvelope + }; + }, { lockOptions: deadlineLockOptions(deadlineAt) }); + } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + throw error; + } if (!intentWrite.written) { return { cancelled: false, reason: reason || "not_cancellable", jobId }; } const configFile = resolveJobConfigFile(workspaceRoot, jobId); - if (pidToKill && isWorkerAlive(pidToKill)) { - if (!isExpectedWorker(pidToKill, configFile)) { + if (Number.isInteger(pidToKill) && pidToKill > 1 && isWorkerAlive(pidToKill)) { + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + if (!configForCleanup || isExpectedWorker(pidToKill, configFile, { deadlineAt }) !== true) { + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } return { cancelled: false, reason: "worker_identity_unverified", jobId }; } try { - await terminate(pidToKill, { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs != null && remainingMs <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + const terminatedWithinDeadline = await awaitWithinDeadline(terminate(pidToKill, { signal: "SIGINT", forceSignal: "SIGKILL", - forceAfterMs: 2e3 - }); + forceAfterMs: remainingMs == null ? 2e3 : Math.max(1, Math.min(2e3, Math.floor(remainingMs))), + deadlineAt + }), deadlineAt); + if (!terminatedWithinDeadline) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } return { cancelled: false, reason: "kill_failed", jobId, killWarning: error.message }; } if (isWorkerAlive(pidToKill)) { - const postSignalIdentity = isExpectedWorker(pidToKill, configFile); + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + const postSignalIdentity = isExpectedWorker(pidToKill, configFile, { deadlineAt }); + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } if (postSignalIdentity === true) { return { cancelled: false, reason: "worker_still_running", jobId }; } @@ -9061,55 +9935,69 @@ async function cancelJob(workspaceRoot, jobId, { } } let finalConfig = configForCleanup; - const finalWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { - if (!current) { - reason = "not_found"; - return null; - } - if (current.status === "cancelled") return null; - if (!ACTIVE_STATUSES2.has(current.status) || storedEnvelope?.job?.status !== "cancelled") { - reason = "cancellation_finalization_pending"; - return null; - } - finalConfig = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)) || finalConfig; - const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); - const finalJob = { - ...current, - ...storedEnvelope.job, - status: "cancelled", - pid: null, - finishedAt: storedEnvelope.job.finishedAt || finishedAt, - updatedAt: finishedAt - }; - const result = storedEnvelope.result || cancellationEnvelope?.result || { ok: false, error: "cancelled" }; - const terminal = prepareRecoveredTerminalEvents(workspaceRoot, finalJob, finalConfig, { - result, - reason: "cancelled", - terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null - }); - return { - job: finalJob, - envelope: { - ...storedEnvelope, - job: finalJob, - result, - terminalReason: "cancelled", - terminalDescriptor: terminal.terminalDescriptor - }, - beforeStateCommit() { - ensureRecoveredTerminalEvents(workspaceRoot, terminal); + let finalWrite; + try { + finalWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { + if (!current) { + reason = "not_found"; + return null; } - }; - }); + if (current.status === "cancelled") return null; + if (!ACTIVE_STATUSES2.has(current.status) || !hasPendingCancellationIntent(storedEnvelope)) { + reason = "cancellation_finalization_pending"; + return null; + } + finalConfig = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)) || finalConfig; + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); + const storedCancelledJob = storedEnvelope?.job?.status === "cancelled" ? storedEnvelope.job : null; + const finalJob = { + ...current, + ...storedCancelledJob || {}, + status: "cancelled", + pid: null, + finishedAt: storedCancelledJob?.finishedAt || finishedAt, + updatedAt: finishedAt + }; + const result = storedEnvelope.result || cancellationEnvelope?.result || { ok: false, error: "cancelled" }; + const terminal = prepareRecoveredTerminalEvents(workspaceRoot, finalJob, finalConfig, { + result, + reason: "cancelled", + terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null + }); + return { + job: finalJob, + envelope: { + ...storedEnvelope, + job: finalJob, + result, + terminalReason: "cancelled", + terminalDescriptor: terminal.terminalDescriptor, + cancellationIntent: storedEnvelope.cancellationIntent + }, + beforeStateCommit() { + ensureRecoveredTerminalEvents(workspaceRoot, terminal, { + lockOptions: deadlineLockOptions(deadlineAt) + }); + } + }; + }, { lockOptions: deadlineLockOptions(deadlineAt) }); + } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + throw error; + } if (!finalWrite.written) { const current = getJob(workspaceRoot, jobId); if (current?.status === "cancelled") { + removeJobStartFailureFile(workspaceRoot, jobId); return { cancelled: true, jobId }; } return { cancelled: false, reason: reason || "cancellation_finalization_pending", jobId }; } cleanupRuntimePaths(finalConfig); removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); return { cancelled: true, jobId }; } @@ -9271,7 +10159,7 @@ function resolveProvider({ provider, positionals = [] } = {}) { } // plugins/polycli/scripts/lib/review.mjs -import fs7 from "node:fs"; +import fs8 from "node:fs"; import os5 from "node:os"; import path6 from "node:path"; var DEFAULT_MAX_DIFF_BYTES = null; @@ -9331,7 +10219,7 @@ function git(cwd, args) { return runCommand("git", args, { cwd }); } function makeReviewTempDir(prefix) { - return fs7.mkdtempSync(path6.join(os5.tmpdir(), `polycli-review-${prefix}-`)); + return fs8.mkdtempSync(path6.join(os5.tmpdir(), `polycli-review-${prefix}-`)); } function assertNoReviewConstraintOverride(provider, runtimeOptions = {}) { const extraArgs = Array.isArray(runtimeOptions.extraArgs) ? runtimeOptions.extraArgs : []; @@ -9608,7 +10496,7 @@ function summarizeTimingRecords(records) { } // plugins/polycli/scripts/lib/preview.mjs -import fs8 from "node:fs"; +import fs9 from "node:fs"; var PREVIEW_MAX_LINES = 10; var PREVIEW_TAIL_CACHE = /* @__PURE__ */ new Map(); var PRIVATE_FILE_MODE4 = 384; @@ -9697,7 +10585,7 @@ function summarizeEventText(provider, event) { } return ""; } -function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PREVIEW_TAIL_CACHE } = {}) { +function appendPreview(logFile, provider, event, { fsImpl = fs9, tailCache = PREVIEW_TAIL_CACHE } = {}) { const text = summarizeEventText(provider, event); if (!text) return; const lines = String(text).split(/\r?\n/).map((line) => collapseWhitespace(line)).filter(Boolean).slice(0, PREVIEW_MAX_LINES); @@ -9708,9 +10596,9 @@ function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PRE } fsImpl.appendFileSync(logFile, `${lines.join("\n")} `, { encoding: "utf8", mode: PRIVATE_FILE_MODE4 }); - if (fsImpl === fs8) { + if (fsImpl === fs9) { try { - fs8.chmodSync(logFile, PRIVATE_FILE_MODE4); + fs9.chmodSync(logFile, PRIVATE_FILE_MODE4); } catch { } } @@ -9719,7 +10607,7 @@ function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PRE // plugins/polycli/scripts/polycli-companion.mjs var COMPANION_PATH = fileURLToPath(import.meta.url); -var BUILD_VERSION = true ? "0.6.30" : "0.0.0-dev"; +var BUILD_VERSION = true ? "0.6.31" : "0.0.0-dev"; var BUILD_VERSION_SOURCE = true ? "bundled-release" : "development"; var JOB_PREFIXES = { ask: "pa", @@ -9756,8 +10644,8 @@ function resolveTimeoutMs(provider, kind, { model = null, defaultModel = null } } var HEALTH_SENTINEL = "POLYCLI_HEALTH_OK"; var SESSION_ID_ENV = "POLYCLI_COMPANION_SESSION_ID"; -var TERMINAL_JOB_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); -var ACTIVE_JOB_STATUSES2 = /* @__PURE__ */ new Set(["queued", "running"]); +var TERMINAL_JOB_STATUSES3 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); +var ACTIVE_JOB_STATUSES3 = /* @__PURE__ */ new Set(["queued", "running"]); var RUN_CONTEXT = { invocationId: null, runId: null, @@ -9821,21 +10709,24 @@ function ensureTerminalRunEventsForContext(workspaceRoot, prepared) { return prepared.events.length > 0 ? ensureRunLedgerTerminalPair(workspaceRoot, prepared.events) : []; } function hasTerminalJobEnvelope(envelope) { - return TERMINAL_JOB_STATUSES2.has(envelope?.job?.status); + return TERMINAL_JOB_STATUSES3.has(envelope?.job?.status); +} +function blocksBackgroundWorkerCommit(envelope) { + return hasTerminalJobEnvelope(envelope) || hasPendingCancellationIntent(envelope); } function claimBackgroundWorker(workspaceRoot, jobId) { const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || !ACTIVE_JOB_STATUSES3.has(latest.status) || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } - if (latest.pid != null && latest.pid !== process5.pid) { + if (latest.pid != null && latest.pid !== process6.pid) { return null; } return { job: { ...latest, status: "running", - pid: process5.pid, + pid: process6.pid, updatedAt: (/* @__PURE__ */ new Date()).toISOString() } }; @@ -9846,68 +10737,6 @@ function shouldRetainJobConfig(workspaceRoot, jobId) { const current = getJob(workspaceRoot, jobId); return current?.status === "queued" || current?.status === "running"; } -function recordBackgroundSpawnFailure(workspaceRoot, jobId, execution, runContext, error) { - const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); - try { - const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || hasTerminalJobEnvelope(storedEnvelope)) { - return null; - } - const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); - const failedJob = { - ...latest, - ...execution.jobMeta, - status: "failed", - pid: null, - finishedAt, - updatedAt: finishedAt, - error: error.message - }; - const terminalReason = `${execution.kind}_failed`; - const terminalEvents = [ - { - command: runContext?.command || execution.kind, - kind: execution.kind, - provider: execution.provider, - phase: "attempt_result", - status: "failed", - reason: terminalReason, - attempt: { ordinal: 1 }, - jobId, - error: { message: String(error.message || error).slice(0, 300) }, - logFile: failedJob.logFile || null - }, - { - command: runContext?.command || execution.kind, - kind: execution.kind, - provider: execution.provider, - phase: "provider_decision", - status: "failed", - reason: terminalReason, - jobId - } - ]; - const terminal = prepareTerminalRunEventsForContext(runContext, terminalEvents); - return { - job: failedJob, - envelope: { - job: failedJob, - result: { ok: false, error: error.message }, - terminalReason, - terminalDescriptor: terminal.terminalDescriptor - }, - beforeStateCommit() { - ensureTerminalRunEventsForContext(workspaceRoot, terminal); - } - }; - }); - if (write.written) { - cleanupRuntimeOptions(config?.execution?.runtimeOptions); - removeJobConfigFile(workspaceRoot, jobId); - } - } catch { - } -} async function recordRunEvent(workspaceRoot, base = {}) { return recordRunEventForContext(workspaceRoot, buildCurrentRunContext(), base); } @@ -10029,9 +10858,9 @@ function assertPreDispatchReviewSafety(definition2, parsed) { function exitWithError(error) { const typed = toTypedCliError(error); if (RUN_CONTEXT.outputMode === "json-v2") { - process5.stdout.write(`${JSON.stringify(createV2ErrorEnvelope(typed, { + process6.stdout.write(`${JSON.stringify(createV2ErrorEnvelope(typed, { invocationId: RUN_CONTEXT.invocationId, - command: RUN_CONTEXT.command || process5.argv[2] || "", + command: RUN_CONTEXT.command || process6.argv[2] || "", hostSurface: RUN_CONTEXT.hostSurface, workspaceSlug: RUN_CONTEXT.workspaceSlug, runId: RUN_CONTEXT.runId, @@ -10041,19 +10870,19 @@ function exitWithError(error) { } else if (RUN_CONTEXT.outputMode === "legacy-json") { const code = error?.legacyCode || error?.code || classifyErrorCode(error?.message || ""); const legacyCode = code === "unknown_command" ? "unknown_subcommand" : code; - process5.stdout.write(`${JSON.stringify({ error: typed.message, code: legacyCode }, null, 2)} + process6.stdout.write(`${JSON.stringify({ error: typed.message, code: legacyCode }, null, 2)} `); } else { - process5.stderr.write(`Error: ${typed.message} + process6.stderr.write(`Error: ${typed.message} `); for (const suggestion of typed.data?.suggestions || []) { - process5.stderr.write(`Suggestion: ${suggestion} + process6.stderr.write(`Suggestion: ${suggestion} `); } - for (const step of typed.nextSteps) process5.stderr.write(`${step} + for (const step of typed.nextSteps) process6.stderr.write(`${step} `); } - process5.exitCode = typed.exitCode || error?.exitCode || 1; + process6.exitCode = typed.exitCode || error?.exitCode || 1; } function output(value, asJson) { if (RUN_CONTEXT.outputMode === "json-v2") { @@ -10069,18 +10898,18 @@ function output(value, asJson) { runId: RUN_CONTEXT.runId, jobId: result.job?.jobId ?? result.jobId ?? null }); - process5.stdout.write(`${JSON.stringify(envelope, null, 2)} + process6.stdout.write(`${JSON.stringify(envelope, null, 2)} `); RUN_CONTEXT.authoritativeJsonWritten = true; return; } if (asJson) { - process5.stdout.write(`${JSON.stringify(value, null, 2)} + process6.stdout.write(`${JSON.stringify(value, null, 2)} `); RUN_CONTEXT.authoritativeJsonWritten = true; return; } - process5.stdout.write(typeof value === "string" ? `${value} + process6.stdout.write(typeof value === "string" ? `${value} ` : `${JSON.stringify(value, null, 2)} `); } @@ -10149,7 +10978,7 @@ function resolveProviderModelCacheFile(workspaceRoot) { } function readProviderModelCache(workspaceRoot) { try { - const parsed = JSON.parse(fs9.readFileSync(resolveProviderModelCacheFile(workspaceRoot), "utf8")); + const parsed = JSON.parse(fs10.readFileSync(resolveProviderModelCacheFile(workspaceRoot), "utf8")); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; @@ -10162,7 +10991,7 @@ function readCachedProviderModel(workspaceRoot, provider) { function cacheProviderModel(workspaceRoot, provider, model) { if (typeof model !== "string" || !model.trim()) return; const cacheFile = resolveProviderModelCacheFile(workspaceRoot); - fs9.mkdirSync(path8.dirname(cacheFile), { recursive: true, mode: 448 }); + fs10.mkdirSync(path8.dirname(cacheFile), { recursive: true, mode: 448 }); withLockfile(`${cacheFile}.lock`, () => { writeJsonAtomic(cacheFile, { ...readProviderModelCache(workspaceRoot), [provider]: model }, { mode: 384 }); }); @@ -10188,11 +11017,11 @@ function skippedAuthDetail({ available, authProbeCost }) { } async function inspectProvider(provider, { probeAuth = false } = {}) { const runtime = getProviderRuntime(provider); - const availability = await Promise.resolve(runtime.getAvailability(process5.cwd())); + const availability = await Promise.resolve(runtime.getAvailability(process6.cwd())); const available = availability.available === true; const authProbeCost = normalizeAuthProbeCost(runtime); const authChecked = available && (probeAuth || authProbeCost === "status"); - const auth = authChecked ? await Promise.resolve(runtime.getAuthStatus(process5.cwd())) : null; + const auth = authChecked ? await Promise.resolve(runtime.getAuthStatus(process6.cwd())) : null; const row = { provider, available, @@ -10205,7 +11034,7 @@ async function inspectProvider(provider, { probeAuth = false } = {}) { model: auth?.model ?? null, capabilities: runtime.capabilities }; - cacheProviderModel(resolveWorkspaceRoot(process5.cwd()), provider, row.model); + cacheProviderModel(resolveWorkspaceRoot(process6.cwd()), provider, row.model); return row; } async function inspectSetupProvider(provider, { probeAuth, workspaceRoot }) { @@ -10277,7 +11106,7 @@ async function inspectSetupProvider(provider, { probeAuth, workspaceRoot }) { } async function inspectProviderAvailability(provider) { const runtime = getProviderRuntime(provider); - const availability = await Promise.resolve(runtime.getAvailability(process5.cwd())); + const availability = await Promise.resolve(runtime.getAvailability(process6.cwd())); const authProbeCost = normalizeAuthProbeCost(runtime); return { provider, @@ -10306,14 +11135,14 @@ function parseExecutionMode(options) { } function emitNote(line) { if (!line) return; - process5.stderr.write(`${line} + process6.stderr.write(`${line} `); } function emitRuntimeWarnings(result = {}) { if (!Array.isArray(result.warnings)) return; for (const warning of result.warnings) { if (typeof warning === "string" && warning.trim()) { - process5.stderr.write(`${warning.trim()} + process6.stderr.write(`${warning.trim()} `); } } @@ -10424,12 +11253,12 @@ function compactProviderResult(result = {}) { } return compact; } -function cleanupRuntimeOptions(runtimeOptions = {}) { +function cleanupRuntimeOptions2(runtimeOptions = {}) { const cleanupPaths = Array.isArray(runtimeOptions.cleanupPaths) ? runtimeOptions.cleanupPaths : []; for (const cleanupPath of cleanupPaths) { if (typeof cleanupPath !== "string" || cleanupPath.trim() === "") continue; try { - fs9.rmSync(cleanupPath, { recursive: true, force: true }); + fs10.rmSync(cleanupPath, { recursive: true, force: true }); } catch { } } @@ -10440,7 +11269,7 @@ function hydrateRuntimeOptions(runtimeOptions = {}) { } return { ...runtimeOptions, - env: { ...process5.env, ...runtimeOptions.env } + env: { ...process6.env, ...runtimeOptions.env } }; } async function runForegroundExecution(execution, asJson) { @@ -10494,7 +11323,7 @@ async function runForegroundExecution(execution, asJson) { } catch (error) { executionError = error; } finally { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } const resultOk = Boolean(result?.ok) && !executionError; const publicError = executionError?.message || result?.error || null; @@ -10561,9 +11390,9 @@ async function runForegroundExecution(execution, asJson) { return; } if (!result.ok) { - process5.stderr.write(`Error: ${String(result.error || `${execution.provider} ${execution.kind} failed`).slice(0, 500)} + process6.stderr.write(`Error: ${String(result.error || `${execution.provider} ${execution.kind} failed`).slice(0, 500)} `); - process5.exitCode = 1; + process6.exitCode = 1; return; } const lines = []; @@ -10590,7 +11419,7 @@ function buildQueuedJob(execution, workspaceRoot, attemptId) { updatedAt: now, invocationId: RUN_CONTEXT.invocationId, attemptId, - hostSessionId: process5.env[SESSION_ID_ENV] || null, + hostSessionId: process6.env[SESSION_ID_ENV] || null, providerSessionId: null, ...execution.jobMeta }; @@ -10736,7 +11565,7 @@ async function startBackgroundExecution(execution, asJson) { defaultModel: execution.defaultModel || null, logFile: job.logFile }); - writeJobConfigFile(workspaceRoot, job.jobId, { + const config = { workspaceRoot, hostSessionId: job.hostSessionId, execution: { @@ -10750,24 +11579,16 @@ async function startBackgroundExecution(execution, asJson) { }, jobId: job.jobId, runContext + }; + const { child } = await startBackgroundWorker({ + workspaceRoot, + job, + execution, + runContext, + config, + companionPath: COMPANION_PATH, + env: process6.env }); - fs9.writeFileSync(job.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] started ${job.provider} ${job.kind} -`, { - encoding: "utf8", - mode: 384 - }); - const logFd = fs9.openSync(job.logFile, "a", 384); - const child = spawn2(process5.execPath, [COMPANION_PATH, "_job-worker", resolveJobConfigFile(workspaceRoot, job.jobId)], { - cwd: execution.cwd, - env: { ...process5.env }, - stdio: ["ignore", logFd, logFd], - detached: true - }); - child.once("error", (error) => { - recordBackgroundSpawnFailure(workspaceRoot, job.jobId, execution, runContext, error); - }); - child.unref(); - fs9.closeSync(logFd); const runningWrite = updateJobAtomically(workspaceRoot, job.jobId, (latest) => { if (!latest || latest.status !== "queued") return null; return { @@ -10780,10 +11601,10 @@ async function startBackgroundExecution(execution, asJson) { }; }); const runningJob = runningWrite.written ? runningWrite.job : getJob(workspaceRoot, job.jobId) || job; - if (!ACTIVE_JOB_STATUSES2.has(runningJob.status)) { + if (!ACTIVE_JOB_STATUSES3.has(runningJob.status)) { removeJobConfigFile(workspaceRoot, job.jobId); } - if (runContext && ACTIVE_JOB_STATUSES2.has(runningJob.status)) { + if (runContext && ACTIVE_JOB_STATUSES3.has(runningJob.status)) { await recordRunEventForContext(workspaceRoot, runContext, { command: execution.kind, kind: execution.kind, @@ -10811,7 +11632,7 @@ async function runSetup(rawArgs) { if (options["enable-review-gate"] && options["disable-review-gate"]) { throw new Error("Choose either --enable-review-gate or --disable-review-gate, not both."); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); if (options["enable-review-gate"]) { setConfig(workspaceRoot, "stopReviewGate", true); } else if (options["disable-review-gate"]) { @@ -10899,7 +11720,7 @@ async function probeProviderHealth({ report.probe.error = inspection.availabilityDetail || "provider CLI is unavailable"; } else if (provider === "claude") { try { - const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process5.cwd())); + const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process6.cwd())); report.loggedIn = auth.loggedIn ?? false; report.authDetail = auth.detail ?? auth.reason ?? null; report.model = auth.model ?? report.model; @@ -10924,7 +11745,7 @@ async function probeProviderHealth({ prompt: `Reply with ${HEALTH_SENTINEL} only.`, model, defaultModel: model ? null : readCachedProviderModel(workspaceRoot, provider), - cwd: process5.cwd(), + cwd: process6.cwd(), timeout, kind: "health", measurementScope: "request", @@ -10996,7 +11817,7 @@ async function runHealth(rawArgs) { valueOptions: ["provider", "model", "timeout-ms"], aliasMap: { m: "model" } }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const timeoutMs = options["timeout-ms"] ? Number.parseInt(options["timeout-ms"], 10) : TIMEOUTS_MS.health; const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : TIMEOUTS_MS.health; const hasSingleProvider = Boolean(options.provider || positionals[0]); @@ -11084,7 +11905,7 @@ async function runHealth(rawArgs) { })); const payload = buildHealthPayload(results); if (!payload.anyHealthy) { - process5.exitCode = 2; + process6.exitCode = 2; } output( options.json ? payload : [ @@ -11106,7 +11927,7 @@ function parsePromptExecution(rawArgs, kind) { positionals }); validateEffort(options.effort); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const userPrompt = remainingPositionals.join(" ").trim(); if (!userPrompt) { throw new Error(`Missing prompt text for ${kind}.`); @@ -11123,7 +11944,7 @@ function parsePromptExecution(rawArgs, kind) { userPrompt, model: options.model || null, defaultModel: cachedDefaultModel, - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, kind, { model: options.model || null, defaultModel: cachedDefaultModel @@ -11173,7 +11994,7 @@ function buildStopReviewGateExecution(rawArgs) { if (!prompt) { throw new Error("Missing prompt text for stop-review-gate."); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const defaultModel = readCachedProviderModel(workspaceRoot, provider); return { options, @@ -11188,14 +12009,14 @@ function buildStopReviewGateExecution(rawArgs) { userPrompt: "stop-time review gate", model: null, defaultModel, - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, "review", { defaultModel }), meta: { stopReviewGate: true }, jobMeta: {}, measurementScope: "request", runtimeOptions: buildReviewRuntimeOptions({ provider, - cwd: process5.cwd() + cwd: process6.cwd() }) } }; @@ -11215,11 +12036,11 @@ function buildReviewExecution(rawArgs, { adversarial }) { positionals }); assertReviewProviderSupported(provider); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const focus = remainingPositionals.join(" ").trim(); const maxDiffBytes = parseMaxDiffBytes(options["max-diff-bytes"]); const reviewContext = collectReviewContext({ - cwd: process5.cwd(), + cwd: process6.cwd(), scope: options.scope, baseRef: options.base || null, maxDiffBytes @@ -11256,7 +12077,7 @@ function buildReviewExecution(rawArgs, { adversarial }) { userPrompt: focus || `${adversarial ? "adversarial " : ""}review ${reviewContext.scope}`, model: options.model || null, defaultModel: readCachedProviderModel(workspaceRoot, provider), - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, adversarial ? "adversarial-review" : "review", { model: options.model || null, defaultModel: readCachedProviderModel(workspaceRoot, provider) @@ -11277,7 +12098,7 @@ function buildReviewExecution(rawArgs, { adversarial }) { measurementScope: "request", runtimeOptions: buildReviewRuntimeOptions({ provider, - cwd: process5.cwd() + cwd: process6.cwd() }) } }; @@ -11305,7 +12126,7 @@ async function runReviewCommand(rawArgs, { adversarial }) { ); return; } finally { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } } const { background } = parseExecutionMode(options); @@ -11320,7 +12141,7 @@ async function runStatus(rawArgs) { booleanOptions: ["json", "all", "wait"], valueOptions: ["job", "for", "timeout-ms"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const reference = options.job || positionals[0] || null; const selectorOptions = options.job ? { grammar: "explicit" } : void 0; const timeoutMs = options.wait ? parseStatusTimeoutMs(options["timeout-ms"]) : void 0; @@ -11329,7 +12150,7 @@ async function runStatus(rawArgs) { timeoutMs }); if (waited.waitTimedOut) { - process5.exitCode = 2; + process6.exitCode = 2; } if (options.json) { output(waited, true); @@ -11345,7 +12166,7 @@ async function runStatus(rawArgs) { for: options.for || "terminal" }); if (waited.waitTimedOut) { - process5.exitCode = 2; + process6.exitCode = 2; } if (waited.error && RUN_CONTEXT.outputMode === "json-v2") { throw cliError("job_not_found", `Job '${target.jobId}' was not found while waiting.`, { @@ -11386,7 +12207,7 @@ async function runResult(rawArgs) { booleanOptions: ["json"], valueOptions: ["job"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const job = resolveJobSelector( workspaceRoot, options.job || positionals[0] || "latest-terminal", @@ -11414,7 +12235,7 @@ async function runCancel(rawArgs) { booleanOptions: ["json"], valueOptions: ["job"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const selector = options.job || positionals[0] || "latest-active"; let job; try { @@ -11423,11 +12244,11 @@ async function runCancel(rawArgs) { if (RUN_CONTEXT.outputMode === "json-v2" || error.code === "ambiguous_selector") throw error; if (options.json) { output({ cancelled: false, reason: "not_found", jobId: positionals[0] || null }, true); - process5.exitCode = 1; + process6.exitCode = 1; return; } output(positionals[0] ? `Job ${positionals[0]} not found.` : "No active job found to cancel.", false); - process5.exitCode = 1; + process6.exitCode = 1; return; } const report = await cancelJob(workspaceRoot, job.jobId); @@ -11438,7 +12259,7 @@ async function runCancel(rawArgs) { } if (report.reason === "not_cancellable") { output(report, true); - process5.exitCode = 4; + process6.exitCode = 4; return; } throw new PolycliCliError({ @@ -11454,10 +12275,10 @@ async function runCancel(rawArgs) { output(`Cancelled job ${report.jobId}.`, false); } else if (report.reason === "not_cancellable") { output(`Job ${report.jobId} is already ${job.status}.`, false); - process5.exitCode = 4; + process6.exitCode = 4; } else { output(`Failed to cancel ${report.jobId}: ${report.error || report.reason}`, false); - process5.exitCode = 5; + process6.exitCode = 5; } } function formatMetric(metric) { @@ -11523,7 +12344,7 @@ async function runTiming(rawArgs) { booleanOptions: ["all", "json"], valueOptions: ["provider", "history"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const provider = options.provider ? resolveProvider({ provider: options.provider }).provider : null; const limit = parseHistoryLimit(options.history, { all: options.all }); const records = listTimingRecords(workspaceRoot, { @@ -11557,6 +12378,10 @@ async function runJobWorker(rawArgs) { const { workspaceRoot, execution, jobId, runContext } = payload; const current = claimBackgroundWorker(workspaceRoot, jobId); if (!current) { + if (!shouldRetainJobConfig(workspaceRoot, jobId)) { + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } return; } if (runContext?.runId) { @@ -11590,10 +12415,6 @@ async function runJobWorker(rawArgs) { appendPreview(current.logFile, execution.provider, event); } }); - if (result.timing) { - appendTimingRecord(workspaceRoot, result.timing); - } - cacheProviderModel(workspaceRoot, execution.provider, result.model); const compactResult = compactProviderResult(result); const sessionArtifactPath = resolveSessionArtifactPath( execution.provider, @@ -11601,9 +12422,13 @@ async function runJobWorker(rawArgs) { execution.cwd ); const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || latest.status === "cancelled" || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || latest.status === "cancelled" || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } + if (result.timing) { + appendTimingRecord(workspaceRoot, result.timing); + } + cacheProviderModel(workspaceRoot, execution.provider, result.model); const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); const finishedJob = { ...latest, @@ -11669,19 +12494,22 @@ async function runJobWorker(rawArgs) { // intent instead of exposing a terminal state with only half of its ledger pair. beforeStateCommit() { ensureTerminalRunEventsForContext(workspaceRoot, terminal); + removeJobStartFailureFile(workspaceRoot, jobId); } }; }); if (!write.written) { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } return; } removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } catch (error) { const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || latest.status === "cancelled" || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || latest.status === "cancelled" || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); @@ -11730,20 +12558,23 @@ async function runJobWorker(rawArgs) { }, beforeStateCommit() { ensureTerminalRunEventsForContext(workspaceRoot, terminal); + removeJobStartFailureFile(workspaceRoot, jobId); } }; }); if (!write.written) { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } return; } removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); throw error; } finally { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } } } @@ -11761,7 +12592,7 @@ function formatDebugRunsTable(runs) { return lines.join("\n"); } async function readDebugLedger({ raw = false } = {}) { - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); refreshJobsForLedgerRecovery(workspaceRoot); const events = await readRunLedgerEvents(workspaceRoot, { raw }); return { workspaceRoot, events }; @@ -11771,7 +12602,7 @@ async function runDebugTail(rawArgs) { booleanOptions: ["json", "wait"], valueOptions: ["after", "limit", "timeout-ms"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const result = await tailRunLedgerEvents(workspaceRoot, { runId: positionals[0] || null, after: options.after || null, @@ -11779,7 +12610,7 @@ async function runDebugTail(rawArgs) { wait: Boolean(options.wait), timeoutMs: options["timeout-ms"] == null ? void 0 : Number(options["timeout-ms"]) }); - if (result.waitTimedOut) process5.exitCode = 2; + if (result.waitTimedOut) process6.exitCode = 2; if (options.json) { output(result, true); return; @@ -11833,11 +12664,11 @@ function renderSessionsList(recorded, nonPurgeable = []) { } else { lines.push("Recorded upstream sessions (this workspace):"); for (const rec of recorded) { - const exists = fs9.existsSync(rec.sessionArtifactPath); + const exists = fs10.existsSync(rec.sessionArtifactPath); let size = ""; if (exists) { try { - size = ` ${formatBytes(fs9.lstatSync(rec.sessionArtifactPath).size)}`; + size = ` ${formatBytes(fs10.lstatSync(rec.sessionArtifactPath).size)}`; } catch { size = ""; } @@ -11875,7 +12706,7 @@ function renderPurgePlan(plan, summary, nonPurgeable = []) { return lines.join("\n"); } async function readSessionLedger() { - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const events = await readRunLedgerEvents(workspaceRoot); const recorded = collectRecordedArtifacts(events); const nonPurgeable = collectNonPurgeableSessions(events); @@ -11954,10 +12785,10 @@ function commandResolutionError(fullArgs, resolution, hostSurface) { ); } async function main() { - const fullArgs = process5.argv.slice(2); + const fullArgs = process6.argv.slice(2); RUN_CONTEXT.invocationId = `inv_${randomUUID3().replaceAll("-", "").slice(0, 20)}`; RUN_CONTEXT.authoritativeJsonWritten = false; - RUN_CONTEXT.hostSurface = resolveHostSurface(process5.env, import.meta.url); + RUN_CONTEXT.hostSurface = resolveHostSurface(process6.env, import.meta.url); RUN_CONTEXT.rawArgs = fullArgs; const outputModes = scanOutputModes(fullArgs); const command = fullArgs[0]; @@ -12002,12 +12833,12 @@ async function main() { } if (definition2.runTracked) { try { - RUN_CONTEXT.runId = resolveRunId({ runId: explicitRunId }, process5.env); + RUN_CONTEXT.runId = resolveRunId({ runId: explicitRunId }, process6.env); } catch (error) { if (!/^Invalid run id:/.test(error?.message || "")) throw error; const typed = cliError("invalid_argument", error.message, { argument: "--run-id", - value: explicitRunId || process5.env.POLYCLI_RUN_ID || null + value: explicitRunId || process6.env.POLYCLI_RUN_ID || null }); typed.legacyCode = "error"; throw typed; @@ -12015,15 +12846,15 @@ async function main() { } else { RUN_CONTEXT.runId = null; } - RUN_CONTEXT.workspaceSlug = computeWorkspaceSlug(resolveWorkspaceRoot(process5.cwd())); + RUN_CONTEXT.workspaceSlug = computeWorkspaceSlug(resolveWorkspaceRoot(process6.cwd())); if (!RUN_CONTEXT.runId) { return dispatchCommand(definition2.id, rawArgs); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); await recordRunEvent(workspaceRoot, { phase: "run_started", status: "started" }); try { const result = await dispatchCommand(definition2.id, rawArgs); - const failed = process5.exitCode != null && process5.exitCode !== 0; + const failed = process6.exitCode != null && process6.exitCode !== 0; try { await recordRunEvent(workspaceRoot, { phase: "run_summary", diff --git a/plugins/polycli/.claude-plugin/plugin.json b/plugins/polycli/.claude-plugin/plugin.json index f8162ee..633ca1f 100644 --- a/plugins/polycli/.claude-plugin/plugin.json +++ b/plugins/polycli/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "polycli", - "version": "0.6.30", + "version": "0.6.31", "description": "Use multiple provider CLIs from one Claude Code plugin, including background job lifecycle.", "author": { "name": "bing" diff --git a/plugins/polycli/scripts/lib/background-start.mjs b/plugins/polycli/scripts/lib/background-start.mjs new file mode 100644 index 0000000..a330b7a --- /dev/null +++ b/plugins/polycli/scripts/lib/background-start.mjs @@ -0,0 +1,366 @@ +import fs from "node:fs"; +import process from "node:process"; +import { spawn } from "node:child_process"; + +import { sanitizePublicErrorMessage } from "./cli-contract.mjs"; +import { + createTerminalLedgerDescriptor, + ensureRunLedgerTerminalPair, +} from "./run-ledger.mjs"; +import { + getJob, + readJobFile, + readJobConfigFile, + removeJobConfigFile, + readJobStartFailureFile, + removeJobStartFailureFile, + resolveJobConfigFile, + resolveJobFile, + updateJobAtomically, + writeJobConfigFile, + writeJobStartFailureFile, +} from "./state.mjs"; + +const ACTIVE_JOB_STATUSES = new Set(["queued", "running"]); +const TERMINAL_JOB_STATUSES = new Set(["completed", "failed", "cancelled"]); + +function blocksFailureFinalizer(envelope) { + return TERMINAL_JOB_STATUSES.has(envelope?.job?.status) + || (envelope?.cancellationIntent?.status === "requested" + && ACTIVE_JOB_STATUSES.has(envelope?.job?.status)); +} + +function cleanupRuntimeOptions(runtimeOptions = {}) { + const cleanupPaths = Array.isArray(runtimeOptions?.cleanupPaths) + ? runtimeOptions.cleanupPaths + : []; + for (const cleanupPath of cleanupPaths) { + if (typeof cleanupPath !== "string" || cleanupPath.trim() === "") continue; + try { + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } catch { + // A failed start has no live worker using these owned paths; cleanup remains best effort. + } + } +} + +function buildRunEvent(runContext, base) { + if (!runContext?.runId) return null; + const command = base.command || runContext.command; + return { + runId: runContext.runId, + hostSurface: runContext.hostSurface || "unknown", + argv: runContext.argv || [], + invocationId: runContext.invocationId ?? null, + attemptId: runContext.attemptId ?? null, + jobId: runContext.jobId ?? base.jobId ?? null, + command, + commands: Array.from(new Set([ + ...(runContext.commands || []), + command, + ...(base.commands || []), + ].filter(Boolean))).sort(), + ...base, + }; +} + +function prepareFailureEvents(runContext, events) { + const material = events.map((event) => buildRunEvent(runContext, event)).filter(Boolean); + if (material.length === 0) return []; + const terminalDescriptor = createTerminalLedgerDescriptor(material); + return material.map((event) => ({ ...event, terminalDescriptor })); +} + +function safeRunIdentity(jobId, execution, runContext) { + return { + runId: runContext?.runId ?? null, + invocationId: runContext?.invocationId ?? null, + attemptId: runContext?.attemptId ?? null, + command: runContext?.command || execution.kind, + commands: Array.from(new Set([ + ...(runContext?.commands || []), + runContext?.command, + execution.kind, + ].filter(Boolean))).sort(), + hostSurface: runContext?.hostSurface || "unknown", + jobId, + provider: execution.provider, + kind: execution.kind, + logFile: runContext?.logFile ?? null, + }; +} + +function buildStartFailureRecovery(jobId, execution, runContext, error, runtimeOptions) { + const safeMessage = sanitizePublicErrorMessage(error?.message || error, 300); + const identity = safeRunIdentity(jobId, execution, runContext); + const terminalReason = `${execution.kind}_failed`; + const terminalEvents = prepareFailureEvents(identity, [ + { + command: identity.command, + kind: execution.kind, + provider: execution.provider, + phase: "attempt_result", + status: "failed", + reason: terminalReason, + attempt: { ordinal: 1 }, + jobId, + error: { message: safeMessage }, + logFile: identity.logFile, + }, + { + command: identity.command, + kind: execution.kind, + provider: execution.provider, + phase: "provider_decision", + status: "failed", + reason: terminalReason, + jobId, + }, + ]); + return { + version: 1, + jobId, + provider: execution.provider, + kind: execution.kind, + error: safeMessage, + recordedAt: new Date().toISOString(), + identity, + terminalDescriptor: terminalEvents[0]?.terminalDescriptor ?? null, + ownedCleanupPaths: Array.isArray(runtimeOptions?.cleanupPaths) + ? runtimeOptions.cleanupPaths.filter((entry) => typeof entry === "string" && entry.trim() !== "") + : [], + }; +} + +function materialExecution(recovery) { + return { + provider: recovery.provider, + kind: recovery.kind, + runtimeOptions: { cleanupPaths: recovery.ownedCleanupPaths || [] }, + }; +} + +function finalizeBackgroundStartFailure(workspaceRoot, recovery, { lockOptions = {} } = {}) { + const { jobId } = recovery; + const execution = materialExecution(recovery); + const runContext = recovery.identity; + const safeMessage = recovery.error; + return updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { + if (!latest || !ACTIVE_JOB_STATUSES.has(latest.status) || blocksFailureFinalizer(storedEnvelope)) { + return null; + } + const finishedAt = new Date().toISOString(); + const failedJob = { + ...latest, + invocationId: latest.invocationId ?? runContext?.invocationId ?? null, + attemptId: latest.attemptId ?? runContext?.attemptId ?? null, + status: "failed", + pid: null, + finishedAt, + updatedAt: finishedAt, + error: safeMessage, + }; + const terminalReason = `${execution.kind}_failed`; + const terminalEvents = prepareFailureEvents(runContext, [ + { + command: runContext?.command || execution.kind, + kind: execution.kind, + provider: execution.provider, + phase: "attempt_result", + status: "failed", + reason: terminalReason, + attempt: { ordinal: 1 }, + jobId, + error: { message: safeMessage }, + logFile: failedJob.logFile || runContext?.logFile || null, + }, + { + command: runContext?.command || execution.kind, + kind: execution.kind, + provider: execution.provider, + phase: "provider_decision", + status: "failed", + reason: terminalReason, + jobId, + }, + ]).map((event) => ({ + ...event, + terminalDescriptor: recovery.terminalDescriptor || event.terminalDescriptor, + })); + const terminalDescriptor = recovery.terminalDescriptor + || terminalEvents[0]?.terminalDescriptor + || null; + return { + job: failedJob, + envelope: { + job: failedJob, + result: { ok: false, error: safeMessage }, + terminalReason, + terminalDescriptor, + }, + beforeStateCommit() { + if (terminalEvents.length > 0) { + ensureRunLedgerTerminalPair(workspaceRoot, terminalEvents); + } + }, + }; + }, { lockOptions }); +} + +function persistedJobBlocksFailure(workspaceRoot, jobId) { + const job = getJob(workspaceRoot, jobId); + const envelope = readJobFile(resolveJobFile(workspaceRoot, jobId)); + return !job || !ACTIVE_JOB_STATUSES.has(job.status) || blocksFailureFinalizer(envelope); +} + +export function recoverBackgroundStartFailure(workspaceRoot, jobId, options = {}) { + const recovery = readJobStartFailureFile(workspaceRoot, jobId); + if (!recovery || recovery.version !== 1 || recovery.jobId !== jobId) { + return { written: false, finalizationError: null }; + } + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + const write = finalizeBackgroundStartFailure(workspaceRoot, recovery, options); + if (write.written) { + cleanupRuntimeOptions({ cleanupPaths: recovery.ownedCleanupPaths }); + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } + return { written: write.written, finalizationError: null }; + } catch (finalizationError) { + return { written: false, finalizationError }; + } +} + +export function recordBackgroundStartFailure( + workspaceRoot, + jobId, + execution, + runContext, + error, + options = {}, +) { + const configFile = resolveJobConfigFile(workspaceRoot, jobId); + const config = readJobConfigFile(configFile); + const runtimeOptions = config?.execution?.runtimeOptions ?? execution.runtimeOptions; + const recovery = buildStartFailureRecovery(jobId, execution, runContext, error, runtimeOptions); + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + writeJobStartFailureFile(workspaceRoot, jobId, recovery); + } catch { + // The terminal transaction may still succeed. If both writes fail, keep the original start + // error authoritative; this secondary persistence problem must never mask it. + } + // Close a race with cancellation/worker finalization: if either won while the sidecar write was + // in flight, remove the now-stale recovery marker. Otherwise cancellation's own terminal cleanup + // removes it after this check. + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + const write = finalizeBackgroundStartFailure(workspaceRoot, recovery, options); + if (write.written) { + cleanupRuntimeOptions(runtimeOptions); + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } + return { written: write.written, finalizationError: null }; + } catch (finalizationError) { + // updateJobAtomically keeps public state active when its ledger barrier fails. The durable + // envelope (if written) is then recoverable by refreshJob, while the caller still receives + // the original config/log/open/spawn error rather than this secondary persistence failure. + // A start failure has no live worker, so reclaim caller-owned runtime paths immediately; the + // sidecar retains the same paths only so an interrupted cleanup remains safely retryable. + cleanupRuntimeOptions(runtimeOptions); + return { written: false, finalizationError }; + } +} + +export async function startBackgroundWorker({ + workspaceRoot, + job, + execution, + runContext, + config, + companionPath, + env = process.env, + failureFinalizationOptions = {}, +}, { + writeConfigFile = writeJobConfigFile, + writeLogFile = fs.writeFileSync, + openLogFile = fs.openSync, + closeLogFile = fs.closeSync, + spawnWorker = spawn, +} = {}) { + let child = null; + let logFd = null; + let startFailure = null; + let startWarning = null; + let closeWarning = null; + const configFile = resolveJobConfigFile(workspaceRoot, job.jobId); + + try { + writeConfigFile(workspaceRoot, job.jobId, config); + writeLogFile(job.logFile, `[${new Date().toISOString()}] started ${job.provider} ${job.kind}\n`, { + encoding: "utf8", + mode: 0o600, + }); + logFd = openLogFile(job.logFile, "a", 0o600); + child = spawnWorker(process.execPath, [companionPath, "_job-worker", configFile], { + cwd: execution.cwd, + env: { ...env }, + stdio: ["ignore", logFd, logFd], + detached: true, + }); + + // Once spawn returned a child, never misreport this job as a synchronous start failure: the + // process may already be live. Async spawn errors use the same authoritative finalizer. + try { + child.once("error", (asyncError) => { + recordBackgroundStartFailure( + workspaceRoot, + job.jobId, + execution, + runContext, + asyncError, + failureFinalizationOptions, + ); + }); + child.unref(); + } catch (error) { + startWarning = error; + } + } catch (error) { + startFailure = error; + } finally { + if (logFd != null) { + try { + closeLogFile(logFd); + } catch (error) { + if (child) closeWarning = error; + else if (!startFailure) startFailure = error; + } + } + } + + if (startFailure) { + recordBackgroundStartFailure( + workspaceRoot, + job.jobId, + execution, + runContext, + startFailure, + failureFinalizationOptions, + ); + throw startFailure; + } + + return { child, startWarning, closeWarning }; +} diff --git a/plugins/polycli/scripts/lib/cli-contract.mjs b/plugins/polycli/scripts/lib/cli-contract.mjs index aa1efc2..8f7baa9 100644 --- a/plugins/polycli/scripts/lib/cli-contract.mjs +++ b/plugins/polycli/scripts/lib/cli-contract.mjs @@ -375,8 +375,8 @@ function normalizeSessionEntries(entries) { return clonePublicResult(Array.isArray(entries) ? entries : []); } -function serializeProviderCommand(commandId, payload, context) { - if (context.background === true || (payload?.job && context.background !== false)) { +function serializeProviderCommand(commandId, payload) { + if (payload?.job) { return { type: "job.started", job: normalizeV2Job(payload.job), @@ -411,7 +411,7 @@ export function serializeV2Result(commandId, legacyPayload, context = {}) { } if (PROVIDER_COMMANDS.has(id)) { - return serializeProviderCommand(id, payload, context); + return serializeProviderCommand(id, payload); } if (id === "status") { diff --git a/plugins/polycli/scripts/lib/command-registry.mjs b/plugins/polycli/scripts/lib/command-registry.mjs index 6a1743c..54deb18 100644 --- a/plugins/polycli/scripts/lib/command-registry.mjs +++ b/plugins/polycli/scripts/lib/command-registry.mjs @@ -458,11 +458,11 @@ export const COMMAND_DEFINITIONS = deepFreeze([ definition({ id: "tui", path: ["tui"], - summary: "Open the read-only terminal run inspector.", + summary: "Open the terminal run inspector; loading may update local recovery state.", usage: "polycli tui [--run-id ] [--history ]", surfaces: TERMINAL_SURFACE, dispatchTarget: "terminal-wrapper", - effects: { readsWorkspace: true }, + effects: { readsWorkspace: true, writesLocalState: true }, options: [ option("run-id", "string", "Select an initial run.", { valueName: "id" }), option("history", "integer", "Limit the run list.", { valueName: "n" }), @@ -890,6 +890,18 @@ function validateCommandPositionals(definition, parsed, enumSources) { throw commandArgumentError(definition, "Too many positional arguments.", { arguments: positionals }); } const positionalProvider = positionals[0] ?? null; + if (positionalProvider && explicitProvider) { + throw commandArgumentError( + definition, + `Provider target cannot be supplied as positional provider '${positionalProvider}' together with --provider '${explicitProvider}'.`, + { + argument: positionalProvider, + positionalProvider, + explicitProvider, + conflictsWith: "--provider", + }, + ); + } if (positionalProvider && !providers.includes(positionalProvider)) { throw commandArgumentError( definition, diff --git a/plugins/polycli/scripts/lib/job-control.mjs b/plugins/polycli/scripts/lib/job-control.mjs index e41a342..f87a295 100644 --- a/plugins/polycli/scripts/lib/job-control.mjs +++ b/plugins/polycli/scripts/lib/job-control.mjs @@ -10,10 +10,12 @@ import { readJobFile, readJobConfigFile, removeJobConfigFile, + removeJobStartFailureFile, resolveJobConfigFile, resolveJobFile, updateJobAtomically, } from "./state.mjs"; +import { recoverBackgroundStartFailure } from "./background-start.mjs"; import { createTerminalLedgerDescriptor, ensureRunLedgerTerminalPair, @@ -30,6 +32,51 @@ const WAIT_TARGETS = new Set(["terminal", "completed", "failed", "cancelled"]); const DEFAULT_STATUS_LIMIT = 8; const MAX_SELECTOR_CANDIDATES = 8; +function createDeadlineError() { + const error = new Error("cancellation deadline exceeded"); + error.code = "EDEADLINE"; + return error; +} + +function remainingDeadlineMs(deadlineAt) { + return Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : null; +} + +function deadlineLockOptions(deadlineAt) { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs == null) return {}; + if (remainingMs <= 0) throw createDeadlineError(); + return { + timeoutMs: Math.max(1, Math.ceil(remainingMs)), + pollMs: Math.max(1, Math.min(25, Math.ceil(remainingMs))), + }; +} + +function isDeadlineFailure(error, deadlineAt) { + return Number.isFinite(deadlineAt) + && ["EDEADLINE", "ELOCKTIMEOUT", "ETIMEDOUT"].includes(error?.code); +} + +async function awaitWithinDeadline(promise, deadlineAt) { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs == null) { + await promise; + return true; + } + if (remainingMs <= 0) return false; + let timer = null; + try { + return await Promise.race([ + Promise.resolve(promise).then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), remainingMs); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + function isProcessAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { @@ -40,20 +87,30 @@ function isProcessAlive(pid) { } } -export function isExpectedWorkerProcess(pid, configFile) { +export function isExpectedWorkerProcess(pid, configFile, { + deadlineAt = null, + platform = process.platform, + spawnProcess = spawnSync, +} = {}) { if (!Number.isInteger(pid) || pid <= 0 || !configFile) return null; + const remainingMs = Number.isFinite(deadlineAt) + ? Math.floor(deadlineAt - Date.now()) + : null; + if (remainingMs != null && remainingMs <= 0) return null; try { - const result = process.platform === "win32" - ? spawnSync("powershell.exe", [ + const spawnOptions = { + encoding: "utf8", + stdio: "pipe", + ...(remainingMs == null ? {} : { timeout: remainingMs }), + }; + const result = platform === "win32" + ? spawnProcess("powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine`, - ], { encoding: "utf8", stdio: "pipe" }) - : spawnSync("ps", ["-ww", "-o", "command=", "-p", String(pid)], { - encoding: "utf8", - stdio: "pipe", - }); + ], spawnOptions) + : spawnProcess("ps", ["-ww", "-o", "command=", "-p", String(pid)], spawnOptions); if (result.error) return null; if (result.status !== 0) return false; return result.stdout.includes("_job-worker") && result.stdout.includes(configFile); @@ -94,6 +151,10 @@ function isTerminalEnvelope(envelope) { return Boolean(envelope?.job && TERMINAL_STATUSES.has(envelope.job.status)); } +export function hasPendingCancellationIntent(envelope) { + return envelope?.cancellationIntent?.status === "requested"; +} + function buildRecoveredTerminalEvents( workspaceRoot, job, @@ -216,7 +277,20 @@ function prepareRecoveredTerminalEvents( config, { result = null, reason = "worker_exited", terminalDescriptor = null } = {}, ) { - const runContext = config?.runContext; + const descriptorEvent = terminalDescriptor?.events?.[0] ?? null; + const runContext = config?.runContext || (terminalDescriptor?.runId + ? { + runId: terminalDescriptor.runId, + command: descriptorEvent?.command ?? job.kind ?? null, + kind: descriptorEvent?.kind ?? job.kind ?? null, + provider: descriptorEvent?.provider ?? job.provider ?? null, + hostSurface: descriptorEvent?.hostSurface || "unknown", + invocationId: terminalDescriptor.invocationId ?? job.invocationId ?? null, + attemptId: terminalDescriptor.attemptId ?? job.attemptId ?? null, + jobId: terminalDescriptor.jobId ?? job.jobId ?? null, + logFile: job.logFile ?? null, + } + : null); if (!runContext?.runId) return { events: [], terminalDescriptor }; const events = buildRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { @@ -232,11 +306,11 @@ function prepareRecoveredTerminalEvents( }; } -function ensureRecoveredTerminalEvents(workspaceRoot, prepared) { +function ensureRecoveredTerminalEvents(workspaceRoot, prepared, { lockOptions = {} } = {}) { // The state lock serializes worker finalization, cancellation, and recovery for this job. // The shared helper publishes a missing pair atomically and refuses conflicting data. if (prepared.events.length > 0) { - ensureRunLedgerTerminalPair(workspaceRoot, prepared.events); + ensureRunLedgerTerminalPair(workspaceRoot, prepared.events, { lockOptions }); } } @@ -255,9 +329,19 @@ function cleanupRuntimePaths(config) { export function refreshJob(workspaceRoot, job) { if (!job || !ACTIVE_STATUSES.has(job.status)) { + if (job?.jobId && TERMINAL_STATUSES.has(job.status)) { + removeJobStartFailureFile(workspaceRoot, job.jobId); + } return job ? enrichJob(workspaceRoot, job) : null; } const storedEnvelope = readJobFile(resolveJobFile(workspaceRoot, job.jobId)); + if (hasPendingCancellationIntent(storedEnvelope)) { + return enrichJob(workspaceRoot, job); + } + const startFailureRecovery = recoverBackgroundStartFailure(workspaceRoot, job.jobId); + if (startFailureRecovery.written) { + return enrichJob(workspaceRoot, getJob(workspaceRoot, job.jobId) || job); + } if (!job.pid && !isTerminalEnvelope(storedEnvelope)) { return enrichJob(workspaceRoot, job); } @@ -349,8 +433,12 @@ export function refreshJob(workspaceRoot, job) { const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, job.jobId)); cleanupRuntimePaths(config); removeJobConfigFile(workspaceRoot, job.jobId); + removeJobStartFailureFile(workspaceRoot, job.jobId); } const current = write.written ? write.job : (getJob(workspaceRoot, job.jobId) || job); + if (TERMINAL_STATUSES.has(current?.status)) { + removeJobStartFailureFile(workspaceRoot, job.jobId); + } return enrichJob(workspaceRoot, current); } catch { // Keep the persisted job active when the ledger is temporarily unavailable; @@ -362,11 +450,11 @@ export function refreshJob(workspaceRoot, job) { export function buildStatusSnapshot(workspaceRoot, { showAll = false } = {}) { const refreshed = sortJobsNewestFirst(listJobs(workspaceRoot)).map((job) => refreshJob(workspaceRoot, job)); - const limited = showAll ? refreshed : refreshed.slice(0, DEFAULT_STATUS_LIMIT); + const recent = refreshed.filter((job) => TERMINAL_STATUSES.has(job.status)); return { totalJobs: refreshed.length, - running: limited.filter((job) => ACTIVE_STATUSES.has(job.status)), - recent: limited.filter((job) => TERMINAL_STATUSES.has(job.status)), + running: refreshed.filter((job) => ACTIVE_STATUSES.has(job.status)), + recent: showAll ? recent : recent.slice(0, DEFAULT_STATUS_LIMIT), }; } @@ -600,93 +688,102 @@ export async function cancelJob( terminate = terminateProcessTree, isWorkerAlive = isProcessAlive, isExpectedWorker = isExpectedWorkerProcess, + deadlineAt = null, } = {}, ) { - // Keep the public job active until a validated worker has been stopped. The envelope and ledger - // are a durable cancellation intent, so a crash before the signal can be retried safely instead - // of publishing "cancelled" while the worker still runs. + // Keep the public job active until a validated worker has been stopped. A distinct, non-terminal + // envelope intent makes that request durable without publishing a result or terminal ledger pair. let pidToKill = null; let configForCleanup = null; let cancellationEnvelope = null; let reason = null; const requestedAt = new Date().toISOString(); - const intentWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { - if (!current) { - reason = "not_found"; - return null; - } - if (!ACTIVE_STATUSES.has(current.status)) { - reason = "not_cancellable"; - return null; - } - const resumingCancellation = storedEnvelope?.job?.status === "cancelled"; - if (isTerminalEnvelope(storedEnvelope) && !resumingCancellation) { - reason = "not_cancellable"; - return null; - } - - pidToKill = current.pid ?? null; - configForCleanup = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); - const intentJob = resumingCancellation - ? { - ...current, - ...storedEnvelope.job, - status: "cancelled", - pid: null, - finishedAt: storedEnvelope.job.finishedAt || requestedAt, - updatedAt: requestedAt, + let intentWrite; + try { + intentWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { + if (!current) { + reason = "not_found"; + return null; } - : { - ...current, - status: "cancelled", - pid: null, - finishedAt: requestedAt, - updatedAt: requestedAt, + if (!ACTIVE_STATUSES.has(current.status)) { + reason = "not_cancellable"; + return null; + } + const resumingCancellation = hasPendingCancellationIntent(storedEnvelope); + if (isTerminalEnvelope(storedEnvelope) && !resumingCancellation) { + reason = "not_cancellable"; + return null; + } + + pidToKill = current.pid ?? null; + configForCleanup = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); + cancellationEnvelope = resumingCancellation + ? storedEnvelope + : { + job: current, + cancellationIntent: { + status: "requested", + requestedAt, + }, + }; + return { + // Do not make the state or envelope terminal yet. Both remain recovery points if this + // process exits after persisting the intent but before the worker receives its signal. + job: current, + envelope: cancellationEnvelope, }; - const cancellationResult = resumingCancellation - ? (storedEnvelope.result || { ok: false, error: "cancelled" }) - : { ok: false, error: "cancelled" }; - const terminal = prepareRecoveredTerminalEvents(workspaceRoot, intentJob, configForCleanup, { - result: cancellationResult, - reason: "cancelled", - terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null, - }); - cancellationEnvelope = { - job: intentJob, - result: cancellationResult, - terminalReason: "cancelled", - terminalDescriptor: terminal.terminalDescriptor, - }; - return { - // Do not make the state terminal yet. It remains the recovery point if this process exits - // after persisting the intent but before the worker receives its signal. - job: current, - envelope: cancellationEnvelope, - beforeStateCommit() { - ensureRecoveredTerminalEvents(workspaceRoot, terminal); - }, - }; - }); + }, { lockOptions: deadlineLockOptions(deadlineAt) }); + } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + throw error; + } if (!intentWrite.written) { return { cancelled: false, reason: reason || "not_cancellable", jobId }; } const configFile = resolveJobConfigFile(workspaceRoot, jobId); - if (pidToKill && isWorkerAlive(pidToKill)) { - if (!isExpectedWorker(pidToKill, configFile)) { + if (Number.isInteger(pidToKill) && pidToKill > 1 && isWorkerAlive(pidToKill)) { + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + if (!configForCleanup || isExpectedWorker(pidToKill, configFile, { deadlineAt }) !== true) { + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } return { cancelled: false, reason: "worker_identity_unverified", jobId }; } try { - await terminate(pidToKill, { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs != null && remainingMs <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + const terminatedWithinDeadline = await awaitWithinDeadline(terminate(pidToKill, { signal: "SIGINT", forceSignal: "SIGKILL", - forceAfterMs: 2_000, - }); + forceAfterMs: remainingMs == null + ? 2_000 + : Math.max(1, Math.min(2_000, Math.floor(remainingMs))), + deadlineAt, + }), deadlineAt); + if (!terminatedWithinDeadline) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } return { cancelled: false, reason: "kill_failed", jobId, killWarning: error.message }; } if (isWorkerAlive(pidToKill)) { - const postSignalIdentity = isExpectedWorker(pidToKill, configFile); + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + const postSignalIdentity = isExpectedWorker(pidToKill, configFile, { deadlineAt }); + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } if (postSignalIdentity === true) { return { cancelled: false, reason: "worker_still_running", jobId }; } @@ -697,49 +794,64 @@ export async function cancelJob( } let finalConfig = configForCleanup; - const finalWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { - if (!current) { - reason = "not_found"; - return null; - } - if (current.status === "cancelled") return null; - if (!ACTIVE_STATUSES.has(current.status) || storedEnvelope?.job?.status !== "cancelled") { - reason = "cancellation_finalization_pending"; - return null; - } - finalConfig = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)) || finalConfig; - const finishedAt = new Date().toISOString(); - const finalJob = { - ...current, - ...storedEnvelope.job, - status: "cancelled", - pid: null, - finishedAt: storedEnvelope.job.finishedAt || finishedAt, - updatedAt: finishedAt, - }; - const result = storedEnvelope.result || cancellationEnvelope?.result || { ok: false, error: "cancelled" }; - const terminal = prepareRecoveredTerminalEvents(workspaceRoot, finalJob, finalConfig, { - result, - reason: "cancelled", - terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null, - }); - return { - job: finalJob, - envelope: { - ...storedEnvelope, - job: finalJob, + let finalWrite; + try { + finalWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { + if (!current) { + reason = "not_found"; + return null; + } + if (current.status === "cancelled") return null; + if (!ACTIVE_STATUSES.has(current.status) || !hasPendingCancellationIntent(storedEnvelope)) { + reason = "cancellation_finalization_pending"; + return null; + } + finalConfig = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)) || finalConfig; + const finishedAt = new Date().toISOString(); + const storedCancelledJob = storedEnvelope?.job?.status === "cancelled" + ? storedEnvelope.job + : null; + const finalJob = { + ...current, + ...(storedCancelledJob || {}), + status: "cancelled", + pid: null, + finishedAt: storedCancelledJob?.finishedAt || finishedAt, + updatedAt: finishedAt, + }; + const result = storedEnvelope.result || cancellationEnvelope?.result || { ok: false, error: "cancelled" }; + const terminal = prepareRecoveredTerminalEvents(workspaceRoot, finalJob, finalConfig, { result, - terminalReason: "cancelled", - terminalDescriptor: terminal.terminalDescriptor, - }, - beforeStateCommit() { - ensureRecoveredTerminalEvents(workspaceRoot, terminal); - }, - }; - }); + reason: "cancelled", + terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null, + }); + return { + job: finalJob, + envelope: { + ...storedEnvelope, + job: finalJob, + result, + terminalReason: "cancelled", + terminalDescriptor: terminal.terminalDescriptor, + cancellationIntent: storedEnvelope.cancellationIntent, + }, + beforeStateCommit() { + ensureRecoveredTerminalEvents(workspaceRoot, terminal, { + lockOptions: deadlineLockOptions(deadlineAt), + }); + }, + }; + }, { lockOptions: deadlineLockOptions(deadlineAt) }); + } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + throw error; + } if (!finalWrite.written) { const current = getJob(workspaceRoot, jobId); if (current?.status === "cancelled") { + removeJobStartFailureFile(workspaceRoot, jobId); return { cancelled: true, jobId }; } return { cancelled: false, reason: reason || "cancellation_finalization_pending", jobId }; @@ -749,5 +861,6 @@ export async function cancelJob( // and the terminal state commit. The config remains available for any retry before this point. cleanupRuntimePaths(finalConfig); removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); return { cancelled: true, jobId }; } diff --git a/plugins/polycli/scripts/lib/run-ledger.mjs b/plugins/polycli/scripts/lib/run-ledger.mjs index 13b5068..39505c3 100644 --- a/plugins/polycli/scripts/lib/run-ledger.mjs +++ b/plugins/polycli/scripts/lib/run-ledger.mjs @@ -182,12 +182,24 @@ function redactLedgerError(error) { return { message: sanitizePublicErrorMessage(message, 300) }; } +function sanitizeLedgerPreview(preview) { + if (preview == null) return null; + const text = String(preview); + return sanitizePublicErrorMessage(text, text.length); +} + function redactTerminalDescriptor(descriptor) { if (!descriptor || typeof descriptor !== 'object') return descriptor ?? null; return { ...descriptor, events: Array.isArray(descriptor.events) - ? descriptor.events.map((event) => ({ ...event, error: redactLedgerError(event.error) })) + ? descriptor.events.map((event) => ({ + ...event, + error: redactLedgerError(event.error), + ...(Object.prototype.hasOwnProperty.call(event, 'preview') + ? { preview: sanitizeLedgerPreview(event.preview) } + : {}), + })) : descriptor.events, }; } @@ -222,7 +234,7 @@ export function createRunLedgerEvent(event = {}) { providerSessionId, timingRef: event.timingRef ?? null, error: redactLedgerError(event.error), - preview: event.preview ?? null, + preview: sanitizeLedgerPreview(event.preview), stdoutBytes: event.stdoutBytes ?? null, stderrBytes: event.stderrBytes ?? null, durationMs: event.durationMs ?? null, @@ -261,7 +273,7 @@ export function appendRunLedgerEvent(workspaceRoot, event) { return full; } -export function appendRunLedgerEvents(workspaceRoot, events) { +export function appendRunLedgerEvents(workspaceRoot, events, lockOptions = {}) { if (!Array.isArray(events)) { throw new TypeError('events must be an array'); } @@ -278,6 +290,7 @@ export function appendRunLedgerEvents(workspaceRoot, events) { workspaceSlug: event.workspaceSlug ?? workspaceSlug, })); appendNdjsonBatch(file, full, { + ...lockOptions, maxBytes: MAX_LEDGER_BYTES, keepRatio: KEEP_RATIO, retentionGroupKey: terminalLedgerRetentionGroupKey, @@ -503,7 +516,7 @@ function buildExpectedTerminalPair(events) { }; } -export function ensureRunLedgerTerminalPair(workspaceRoot, events) { +export function ensureRunLedgerTerminalPair(workspaceRoot, events, { lockOptions = {} } = {}) { if (!Array.isArray(events) || events.length !== 2) { throw new TypeError('terminal ledger pair must contain exactly two events'); } @@ -528,7 +541,7 @@ export function ensureRunLedgerTerminalPair(workspaceRoot, events) { if (ambiguousLegacy.length > 0) { throw new Error(`Incomplete or conflicting terminal ledger pair for ${identityLabel}`); } - return appendRunLedgerEvents(workspaceRoot, expected); + return appendRunLedgerEvents(workspaceRoot, expected, lockOptions); } if (existing.length === 1) { const [partial] = existing; @@ -554,7 +567,7 @@ export function ensureRunLedgerTerminalPair(workspaceRoot, events) { ...missing, terminalDescriptor: repairDescriptor, }; - return [...existing, ...appendRunLedgerEvents(workspaceRoot, [repair])]; + return [...existing, ...appendRunLedgerEvents(workspaceRoot, [repair], lockOptions)]; } if (!terminalPairMatches(existing, expected)) { throw new Error(`Incomplete or conflicting terminal ledger pair for ${identityLabel}`); diff --git a/plugins/polycli/scripts/lib/state.mjs b/plugins/polycli/scripts/lib/state.mjs index 0651534..ea505e3 100644 --- a/plugins/polycli/scripts/lib/state.mjs +++ b/plugins/polycli/scripts/lib/state.mjs @@ -168,8 +168,15 @@ function stateRootDir() { return describeStateRoot().stateRoot; } -export function resolveWorkspaceRoot(cwd = process.cwd()) { - const result = runCommand("git", ["rev-parse", "--show-toplevel"], { cwd }); +export function resolveWorkspaceRoot(cwd = process.cwd(), { deadlineAt = null } = {}) { + const remainingMs = Number.isFinite(deadlineAt) + ? Math.floor(deadlineAt - Date.now()) + : null; + if (remainingMs != null && remainingMs <= 0) return path.resolve(cwd); + const result = runCommand("git", ["rev-parse", "--show-toplevel"], { + cwd, + ...(remainingMs == null ? {} : { timeout: remainingMs }), + }); if (result.status === 0 && result.stdout.trim()) { return path.resolve(result.stdout.trim()); } @@ -200,6 +207,10 @@ export function resolveJobConfigFile(workspaceRoot, jobId) { return path.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); } +export function resolveJobStartFailureFile(workspaceRoot, jobId) { + return path.join(resolveJobsDir(workspaceRoot), `${jobId}.start-failure.json`); +} + function chmodPrivateDir(dir) { try { fs.chmodSync(dir, PRIVATE_DIR_MODE); @@ -281,6 +292,7 @@ export function saveState(workspaceRoot, state, { preserveJobIds = [] } = {}) { if (job && job.jobId && !keptIds.has(job.jobId)) { removeJobFile(workspaceRoot, job.jobId); removeJobConfigFile(workspaceRoot, job.jobId); + removeJobStartFailureFile(workspaceRoot, job.jobId); removeJobLogFile(workspaceRoot, job.jobId); } } @@ -297,7 +309,7 @@ export function updateState(workspaceRoot, mutate) { }); } -export function updateJobAtomically(workspaceRoot, jobId, buildNext) { +export function updateJobAtomically(workspaceRoot, jobId, buildNext, { lockOptions = {} } = {}) { ensureStateDir(workspaceRoot); const lockPath = `${resolveStateFile(workspaceRoot)}.lock`; return withLockfile(lockPath, () => { @@ -343,7 +355,7 @@ export function updateJobAtomically(workspaceRoot, jobId, buildNext) { } saveState(workspaceRoot, state, { preserveJobIds: [jobId] }); return { written: true, job, envelope }; - }); + }, lockOptions); } export function upsertJob(workspaceRoot, jobPatch) { @@ -451,6 +463,28 @@ export function removeJobConfigFile(workspaceRoot, jobId) { } } +export function writeJobStartFailureFile(workspaceRoot, jobId, payload) { + ensureStateDir(workspaceRoot); + writeJsonAtomic(resolveJobStartFailureFile(workspaceRoot, jobId), payload, { mode: PRIVATE_FILE_MODE }); + return resolveJobStartFailureFile(workspaceRoot, jobId); +} + +export function readJobStartFailureFile(workspaceRoot, jobId) { + try { + return JSON.parse(fs.readFileSync(resolveJobStartFailureFile(workspaceRoot, jobId), "utf8")); + } catch { + return null; + } +} + +export function removeJobStartFailureFile(workspaceRoot, jobId) { + try { + fs.unlinkSync(resolveJobStartFailureFile(workspaceRoot, jobId)); + } catch { + // ignore + } +} + export function removeJobLogFile(workspaceRoot, jobId) { try { fs.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); diff --git a/plugins/polycli/scripts/polycli-companion.bundle.mjs b/plugins/polycli/scripts/polycli-companion.bundle.mjs index 314c4f7..ebfb554 100755 --- a/plugins/polycli/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli/scripts/polycli-companion.bundle.mjs @@ -1,11 +1,10 @@ #!/usr/bin/env node // plugins/polycli/scripts/polycli-companion.mjs -import fs9 from "node:fs"; +import fs10 from "node:fs"; import path8 from "node:path"; -import process5 from "node:process"; +import process6 from "node:process"; import { randomUUID as randomUUID3 } from "node:crypto"; -import { spawn as spawn2 } from "node:child_process"; import { fileURLToPath } from "node:url"; // packages/polycli-utils/src/args.js @@ -383,8 +382,91 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { // packages/polycli-utils/src/process.js import { spawnSync } from "node:child_process"; import process3 from "node:process"; +var CONSERVATIVE_POINTER_BYTES = 8; +var WINDOWS_SAFE_ARGV_BUDGET_BYTES = 24 * 1024; +var POSIX_SAFE_ARGV_BUDGET_BYTES = 96 * 1024; +function stringStorageBytes(value) { + return Buffer.byteLength(String(value), "utf8") + 1; +} +function getSafeArgvBudgetBytes(platform = process3.platform) { + return platform === "win32" ? WINDOWS_SAFE_ARGV_BUDGET_BYTES : POSIX_SAFE_ARGV_BUDGET_BYTES; +} +function calculateArgvFootprint({ + command, + args = [], + env = process3.env +} = {}) { + const argv = [String(command ?? ""), ...args.map((arg) => String(arg))]; + const envEntries = Object.entries(env ?? {}).filter(([, value]) => value != null).map(([key, value]) => `${key}=${String(value)}`); + const argvBytes = argv.reduce((total, value) => total + stringStorageBytes(value), 0); + const envBytes = envEntries.reduce((total, value) => total + stringStorageBytes(value), 0); + const pointerBytes = (argv.length + envEntries.length + 2) * CONSERVATIVE_POINTER_BYTES; + const stringBytes = argvBytes + envBytes; + return { + totalBytes: stringBytes + pointerBytes, + stringBytes, + pointerBytes, + argvBytes, + envBytes, + argvCount: argv.length, + envCount: envEntries.length + }; +} +function preflightArgv(command, args = [], { + env = process3.env, + argvBudgetBytes = null, + argvBudgetHint = null +} = {}) { + const footprint = calculateArgvFootprint({ command, args, env }); + if (argvBudgetBytes == null) { + return { ok: true, budgetBytes: null, footprint, error: null }; + } + if (!Number.isSafeInteger(argvBudgetBytes) || argvBudgetBytes < 0) { + throw new TypeError("argvBudgetBytes must be a non-negative safe integer or null"); + } + if (footprint.totalBytes <= argvBudgetBytes) { + return { ok: true, budgetBytes: argvBudgetBytes, footprint, error: null }; + } + const counts = [ + `footprintBytes=${footprint.totalBytes}`, + `budgetBytes=${argvBudgetBytes}`, + `argvCount=${footprint.argvCount}`, + `envCount=${footprint.envCount}` + ].join(", "); + const suffix = typeof argvBudgetHint === "string" && argvBudgetHint.trim() ? ` ${argvBudgetHint.trim()}` : ""; + const error = Object.assign( + new Error(`argument list too long for the configured safe argv budget (${counts}).${suffix}`), + { + code: "E2BIG", + footprintBytes: footprint.totalBytes, + budgetBytes: argvBudgetBytes, + argvCount: footprint.argvCount, + envCount: footprint.envCount + } + ); + return { ok: false, budgetBytes: argvBudgetBytes, footprint, error }; +} function runCommand(command, args = [], options = {}) { - const result = spawnSync(command, args, { + const effectiveEnv = options.env ?? process3.env; + const preflight = preflightArgv(command, args, { + env: effectiveEnv, + argvBudgetBytes: options.argvBudgetBytes ?? null, + argvBudgetHint: options.argvBudgetHint ?? null + }); + if (!preflight.ok) { + return { + command, + args, + status: null, + signal: null, + stdout: "", + stderr: "", + error: preflight.error, + spawnErrorCode: "E2BIG" + }; + } + const spawnImpl = options.spawnImpl ?? spawnSync; + const result = spawnImpl(command, args, { cwd: options.cwd, env: options.env, encoding: "utf8", @@ -409,7 +491,8 @@ function runCommand(command, args = [], options = {}) { signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", - error + error, + spawnErrorCode: typeof error?.code === "string" ? error.code : null }; } function firstNonEmptyLine(text) { @@ -452,17 +535,41 @@ function formatCommandFailure(result) { } return parts.join(": "); } -async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SIGKILL", forceAfterMs = 5e3, ignoreMissing = true } = {}) { +async function terminateProcessTree(pid, { + signal = "SIGTERM", + forceSignal = "SIGKILL", + forceAfterMs = 5e3, + ignoreMissing = true, + deadlineAt = null, + platform = process3.platform, + runCommandImpl = runCommand, + now = Date.now, + sleep = (delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs)) +} = {}) { if (!Number.isInteger(pid) || pid <= 1) { throw new Error(`Invalid pid: ${pid}`); } + if (deadlineAt != null && !Number.isFinite(deadlineAt)) { + throw new TypeError("deadlineAt must be a finite epoch millisecond value or null"); + } + const remainingDeadlineMs2 = () => { + if (deadlineAt == null) return null; + const remainingMs = Math.floor(deadlineAt - now()); + if (remainingMs <= 0) { + const error = new Error("process termination deadline exceeded"); + error.code = "EDEADLINE"; + throw error; + } + return remainingMs; + }; const killOnce = (targetSignal) => { - if (process3.platform === "win32") { + const remainingMs = remainingDeadlineMs2(); + if (platform === "win32") { const args = ["/PID", String(pid), "/T"]; if (targetSignal === "SIGKILL") { args.push("/F"); } - const result = runCommand("taskkill", args); + const result = runCommandImpl("taskkill", args, remainingMs == null ? {} : { timeout: remainingMs }); if (result.error) { if (ignoreMissing && result.error.code === "ESRCH") return false; throw result.error; @@ -501,7 +608,9 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI if (!terminated || forceAfterMs <= 0) { return terminated; } - await new Promise((resolve) => setTimeout(resolve, forceAfterMs)); + const remainingBeforeWait = deadlineAt == null ? null : remainingDeadlineMs2(); + const waitMs = remainingBeforeWait == null ? forceAfterMs : Math.min(forceAfterMs, remainingBeforeWait); + await sleep(waitMs); try { killOnce(forceSignal); } catch (error) { @@ -514,6 +623,20 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI // packages/polycli-utils/src/session-id.js var UUID_SESSION_ID_REGEX = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}/i; +var RESUME_SESSION_ID_LINE_REGEX = new RegExp( + `^\\s*resume\\s+(${UUID_SESSION_ID_REGEX.source})\\s*$`, + "i" +); +function matchResumeSessionIdLine(text) { + if (typeof text !== "string" || text.length === 0) { + return null; + } + for (const line of text.split(/\r?\n/)) { + const match = line.match(RESUME_SESSION_ID_LINE_REGEX); + if (match) return match[1]; + } + return null; +} function matchSessionId(text, { patterns = [UUID_SESSION_ID_REGEX] } = {}) { if (typeof text !== "string" || text.length === 0) { return null; @@ -566,6 +689,16 @@ function formatProviderExitError(provider, status) { return `${provider} exited with code ${status}`; } function classifyProviderFailure(error, { provider = null } = {}) { + const structuredCode = typeof error?.code === "string" ? error.code.toUpperCase() : null; + if (structuredCode === "E2BIG") { + return "argument_list_too_long"; + } + if (structuredCode === "ENOENT") { + return "binary_missing"; + } + if (structuredCode === "ETIMEDOUT") { + return "timeout"; + } const text = typeof error === "string" ? error : String(error?.message ?? error ?? ""); if (!text.trim()) return null; if (provider === "qwen" && /\bmaximum session turn\b|\bmax(?:imum)? session turns?\b/i.test(text)) { @@ -574,6 +707,12 @@ function classifyProviderFailure(error, { provider = null } = {}) { if (/\bspawn\b.*\bENOENT\b|\bENOENT\b|\bnot found\b/i.test(text)) { return "binary_missing"; } + if (/\bE2BIG\b|\bargument list too long\b/i.test(text)) { + return "argument_list_too_long"; + } + if (/\b(output|capture|line buffer)\b.*\b(exceeded|overflow)\b/i.test(text)) { + return "output_overflow"; + } if (/\b(timed out|timeout)\b/i.test(text)) { return "timeout"; } @@ -643,6 +782,7 @@ function createLineDecoder({ encoding = "utf8", stripCarriageReturn = true, maxB } // packages/polycli-runtime/src/spawn.js +var DEFAULT_CAPTURE_LIMIT_BYTES = 1048576; function formatExitError(status, signal, { timedOut = false, aborted = false } = {}) { if (aborted) { return "process aborted"; @@ -658,6 +798,34 @@ function formatExitError(status, signal, { timedOut = false, aborted = false } = } return `process exited with code ${status}`; } +function normalizeCaptureLimit(value) { + return Number.isSafeInteger(value) && value >= 0 ? value : DEFAULT_CAPTURE_LIMIT_BYTES; +} +function spawnFailureResult(error, captureLimitBytes) { + const spawnErrorCode = typeof error?.code === "string" ? error.code : null; + return { + ok: false, + status: null, + signal: null, + timedOut: false, + aborted: false, + stdout: "", + stderr: "", + stdoutBytes: 0, + stderrBytes: 0, + stdoutTruncated: false, + stderrTruncated: false, + captureLimitBytes, + outputOverflowStream: null, + closeTimedOut: false, + terminationErrors: [], + terminationFailure: null, + terminationReason: null, + spawnErrorCode, + error: String(error?.message ?? error), + errorCode: classifyProviderFailure(error) + }; +} function spawnStreamingCommand({ bin, args = [], @@ -668,8 +836,11 @@ function spawnStreamingCommand({ killGraceMs = 2e3, signal = null, maxBufferBytes = 1048576, + maxCaptureBytes = DEFAULT_CAPTURE_LIMIT_BYTES, + argvBudgetBytes = null, + argvBudgetHint = null, stdio = ["pipe", "pipe", "pipe"], - detached = false, + detached = process.platform !== "win32", unref = false, spawnImpl = spawn, onStdoutLine = () => { @@ -677,54 +848,85 @@ function spawnStreamingCommand({ onStderrChunk = () => { } } = {}) { + const captureLimitBytes = normalizeCaptureLimit(maxCaptureBytes); + const preflight = preflightArgv(bin, args, { + env: env ?? process.env, + argvBudgetBytes, + argvBudgetHint + }); + if (!preflight.ok) { + return Promise.resolve(spawnFailureResult(preflight.error, captureLimitBytes)); + } return new Promise((resolve) => { let child; try { child = spawnImpl(bin, args, { cwd, env, stdio, detached }); } catch (error) { - resolve({ - ok: false, - status: null, - signal: null, - timedOut: false, - stdout: "", - stderr: "", - error: error.message - }); + resolve(spawnFailureResult(error, captureLimitBytes)); return; } if (unref && typeof child.unref === "function") { child.unref(); } const decoder = createLineDecoder({ maxBufferBytes }); - let stdout = ""; - let stderr = ""; + const stdoutParts = []; + const stderrParts = []; + let stdoutCapturedBytes = 0; + let stderrCapturedBytes = 0; + let stdoutBytes = 0; + let stderrBytes = 0; + let stdoutTruncated = false; + let stderrTruncated = false; + let outputOverflowStream = null; let timedOut = false; let aborted = false; let settled = false; let timer = null; let forceTimer = null; + let settleTimer = null; let decoderError = null; + let lifecycleFailure = null; let terminationRequested = false; - const signalChild = (signal2) => { + const terminationErrors = []; + const setLifecycleFailure = (code, message) => { + if (!lifecycleFailure) { + lifecycleFailure = { code, message }; + } + }; + const recordTerminationError = (error, killSignal, target) => { + terminationErrors.push({ + signal: killSignal, + target, + code: typeof error?.code === "string" ? error.code : null, + message: String(error?.message ?? error).slice(0, 4096) + }); + }; + const trySignal = (killSignal, target, sender) => { try { - if (detached && Number.isInteger(child.pid) && child.pid > 0 && process.platform !== "win32") { - process.kill(-child.pid, signal2); - return; + const delivered = sender(); + if (delivered === false) { + const error = new Error(`${target} kill returned false`); + error.code = "KILL_RETURNED_FALSE"; + recordTerminationError(error, killSignal, target); + return false; } - child.kill(signal2); - } catch { + return true; + } catch (error) { + recordTerminationError(error, killSignal, target); + return false; } }; - const terminateChild = () => { - if (terminationRequested) return; - terminationRequested = true; - signalChild("SIGTERM"); - if (killGraceMs > 0) { - forceTimer = setTimeout(() => { - signalChild("SIGKILL"); - }, killGraceMs); + const signalChild = (killSignal) => { + const canSignalGroup = detached && Number.isInteger(child.pid) && child.pid > 0 && process.platform !== "win32"; + if (canSignalGroup) { + const groupSignalled = trySignal( + killSignal, + "process_group", + () => process.kill(-child.pid, killSignal) + ); + if (groupSignalled) return; } + trySignal(killSignal, "child", () => child.kill(killSignal)); }; const cleanup = () => { if (signal && typeof signal.removeEventListener === "function") { @@ -736,119 +938,215 @@ function spawnStreamingCommand({ child.off?.("error", handleChildError); child.off?.("close", handleChildClose); }; + const capturedStdout = () => stdoutParts.join(""); + const capturedStderr = () => Buffer.concat(stderrParts, stderrCapturedBytes).toString("utf8"); + const buildResult = (status, signalName, { closeTimedOut = false } = {}) => { + if (!lifecycleFailure) { + if (timedOut || status === 124) { + setLifecycleFailure("timeout", "process timed out"); + } else if (aborted || signalName === "SIGINT" || status === 130) { + setLifecycleFailure("cancelled", aborted ? "process aborted" : "process interrupted"); + } else if (signalName || status === 143) { + setLifecycleFailure("terminated", formatExitError(status, signalName)); + } + } + const stdout = capturedStdout(); + const stderr = capturedStderr(); + const ok = status === 0 && !lifecycleFailure && !closeTimedOut; + const error = ok ? null : lifecycleFailure?.message ?? (stderr.trim() || formatExitError(status, signalName, { timedOut, aborted })); + return { + ok, + status, + signal: signalName, + timedOut, + aborted, + stdout, + stderr, + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated, + captureLimitBytes, + outputOverflowStream, + closeTimedOut, + terminationErrors: terminationErrors.map((entry) => ({ ...entry })), + terminationFailure: closeTimedOut ? "close_timeout" : terminationErrors.length > 0 ? "signal_error" : null, + terminationReason: lifecycleFailure?.code ?? null, + spawnErrorCode: null, + error, + errorCode: lifecycleFailure?.code ?? classifyProviderFailure(error) + }; + }; const finish = (result) => { if (settled) return; settled = true; if (timer) clearTimeout(timer); if (forceTimer) clearTimeout(forceTimer); + if (settleTimer) clearTimeout(settleTimer); cleanup(); resolve(result); }; - const decoderFailureResult = (status, signalName) => ({ - ok: false, - status, - signal: signalName, - timedOut, - stdout, - stderr, - error: decoderError - }); + const settleWithoutClose = () => { + if (settled) return; + finish(buildResult(null, null, { closeTimedOut: true })); + }; + const forceKill = () => { + if (settled) return; + signalChild("SIGKILL"); + if (settled) return; + const closeWaitMs = Math.max(10, Math.max(0, killGraceMs)); + settleTimer = setTimeout(settleWithoutClose, closeWaitMs); + }; + const terminateChild = (code, message) => { + setLifecycleFailure(code, message); + if (terminationRequested) return; + terminationRequested = true; + if (timer) { + clearTimeout(timer); + timer = null; + } + signalChild("SIGTERM"); + if (settled) return; + forceTimer = setTimeout(forceKill, Math.max(0, killGraceMs)); + }; + const handleOutputOverflow = (streamName) => { + if (!outputOverflowStream) { + outputOverflowStream = streamName; + } + terminateChild( + "output_overflow", + `${streamName} capture exceeded maxCaptureBytes (${captureLimitBytes})` + ); + }; + const appendStdoutLine = (line, { trailingNewline = true } = {}) => { + const text = trailingNewline ? `${line} +` : line; + const bytes = Buffer.byteLength(text); + if (stdoutCapturedBytes + bytes > captureLimitBytes) { + stdoutTruncated = true; + return false; + } + stdoutParts.push(text); + stdoutCapturedBytes += bytes; + try { + onStdoutLine(line); + } catch { + } + return true; + }; const handleDecoderError = (error) => { if (decoderError || settled) return; decoderError = String(error?.message ?? error).slice(0, 4096); + stdoutTruncated = true; + outputOverflowStream = outputOverflowStream || "stdout"; child.stdout?.off?.("data", handleStdoutData); - terminateChild(); + terminateChild("output_overflow", decoderError); }; const abortHandler = () => { if (settled || aborted) return; aborted = true; - terminateChild(); + terminateChild("cancelled", "process aborted"); }; if (timeout != null) { timer = setTimeout(() => { - if (settled || timedOut) return; + if (settled || timedOut || terminationRequested) return; timedOut = true; - terminateChild(); + terminateChild("timeout", "process timed out"); }, timeout); } const handleChildError = (error) => { - if (decoderError) { + if (terminationRequested) { return; } finish({ - ok: false, - status: null, - signal: null, + ...spawnFailureResult(error, captureLimitBytes), timedOut, - stdout, - stderr, - error: error.message + aborted, + stdout: capturedStdout(), + stderr: capturedStderr(), + stdoutBytes, + stderrBytes, + stdoutTruncated, + stderrTruncated }); }; + const captureStderr = (chunk, { emit = true } = {}) => { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + stderrBytes += buffer.length; + const remaining = Math.max(captureLimitBytes - stderrCapturedBytes, 0); + const accepted = buffer.subarray(0, remaining); + if (accepted.length > 0) { + stderrParts.push(Buffer.from(accepted)); + stderrCapturedBytes += accepted.length; + if (emit) { + try { + onStderrChunk(accepted.toString("utf8")); + } catch { + } + } + } + if (accepted.length < buffer.length) { + stderrTruncated = true; + child.stderr?.off?.("data", handleStderrData); + handleOutputOverflow("stderr"); + } + }; const handleStdinError = (error) => { if (error?.code === "EPIPE" || error?.code === "ERR_STREAM_DESTROYED") { return; } - stderr += `${error.message} -`; + captureStderr(Buffer.from(`${error.message} +`), { emit: false }); }; const handleStdoutData = (chunk) => { if (settled) return; + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + const previousBytes = stdoutBytes; + stdoutBytes += buffer.length; + const remaining = Math.max(captureLimitBytes - previousBytes, 0); + const accepted = buffer.subarray(0, remaining); let lines; try { - lines = decoder.push(chunk); + lines = accepted.length > 0 ? decoder.push(accepted) : []; } catch (error) { handleDecoderError(error); return; } for (const line of lines) { - stdout += `${line} -`; - try { - onStdoutLine(line); - } catch { + if (!appendStdoutLine(line)) { + child.stdout?.off?.("data", handleStdoutData); + handleOutputOverflow("stdout"); + return; } } + if (accepted.length < buffer.length) { + stdoutTruncated = true; + child.stdout?.off?.("data", handleStdoutData); + handleOutputOverflow("stdout"); + } }; const handleStderrData = (chunk) => { if (settled) return; - const text = chunk.toString("utf8"); - stderr += text; - try { - onStderrChunk(text); - } catch { - } + captureStderr(chunk); }; const handleChildClose = (status, signalName) => { - if (decoderError) { - finish(decoderFailureResult(status, signalName)); - return; - } - let lines; - try { - lines = decoder.end(); - } catch (error) { - handleDecoderError(error); - finish(decoderFailureResult(status, signalName)); - return; - } - for (const line of lines) { - stdout += `${line} -`; + if (!decoderError && !stdoutTruncated) { + let lines; try { - onStdoutLine(line); - } catch { + lines = decoder.end(); + } catch (error) { + handleDecoderError(error); + finish(buildResult(status, signalName)); + return; + } + for (const line of lines) { + if (!appendStdoutLine(line, { trailingNewline: false })) { + handleOutputOverflow("stdout"); + break; + } } } - finish({ - ok: status === 0 && !timedOut && !aborted, - status, - signal: signalName, - timedOut, - stdout, - stderr, - error: status === 0 && !timedOut && !aborted ? null : stderr.trim() || formatExitError(status, signalName, { timedOut, aborted }) - }); + finish(buildResult(status, signalName)); }; child.on("error", handleChildError); child.stdin?.on?.("error", handleStdinError); @@ -888,6 +1186,8 @@ var DEFAULT_TIMEOUT_MS = 9e5; var TMUX_START_TIMEOUT_MS = 3e4; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; +var SAFE_PROMPT_ARGV_BUDGET_BYTES = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var CLAUDE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var CLAUDE_TMUX_ENV_EXACT = /* @__PURE__ */ new Set([ "ALL_PROXY", @@ -989,33 +1289,26 @@ function buildClaudeInvocation({ maxTurns = 10, resumeSessionId = null, extraArgs = [], - bin = CLAUDE_BIN + bin = CLAUDE_BIN, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { const promptText = String(prompt ?? ""); - const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD; - const args = ["-p"]; - if (!useStdin) { - args.push(promptText); - } - args.push("--output-format", outputFormat); - if (outputFormat === "stream-json") { - args.push("--verbose"); - } - if (permissionMode) { - args.push("--permission-mode", permissionMode); - } - if (Number.isFinite(maxTurns) && maxTurns > 0) { - args.push("--max-turns", String(maxTurns)); - } - if (model) { - args.push("--model", model); - } - if (resumeSessionId) { - args.push("--resume", resumeSessionId); - } - if (extraArgs.length > 0) { - args.push(...extraArgs); - } + const buildArgs = (includePrompt) => { + const nextArgs = ["-p"]; + if (includePrompt) nextArgs.push(promptText); + nextArgs.push("--output-format", outputFormat); + if (outputFormat === "stream-json") nextArgs.push("--verbose"); + if (permissionMode) nextArgs.push("--permission-mode", permissionMode); + if (Number.isFinite(maxTurns) && maxTurns > 0) nextArgs.push("--max-turns", String(maxTurns)); + if (model) nextArgs.push("--model", model); + if (resumeSessionId) nextArgs.push("--resume", resumeSessionId); + if (extraArgs.length > 0) nextArgs.push(...extraArgs); + return nextArgs; + }; + const inlineArgs = buildArgs(true); + const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD || !preflightArgv(bin, inlineArgs, { env, argvBudgetBytes }).ok; + const args = useStdin ? buildArgs(false) : inlineArgs; return { bin, args, @@ -1390,13 +1683,8 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = } try { const parsed = JSON.parse(text.slice(jsonStart)); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); const response = typeof parsed.result === "string" ? parsed.result : ""; - const sessionId = parsed.session_id ?? parsed.sessionId ?? resolvedSession.sessionId ?? null; + const sessionId = parsed.session_id ?? parsed.sessionId ?? matchResumeSessionIdLine(stderr) ?? null; const errorText2 = isClaudeErrorResultEvent(parsed) ? getClaudeErrorText(parsed) : null; const processError = status === 0 ? null : String(stderr ?? "").trim() || formatProviderExitError("claude", status); return { @@ -1480,7 +1768,10 @@ function runClaudePrompt({ extraArgs = [], resumeSessionId = null, defaultModel = null, - bin = CLAUDE_BIN + bin = CLAUDE_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { const invocation = buildClaudeInvocation({ prompt, @@ -1490,17 +1781,25 @@ function runClaudePrompt({ maxTurns, resumeSessionId, extraArgs, - bin + bin, + env, + argvBudgetBytes }); const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, - input: invocation.input + input: invocation.input, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `claude timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `claude timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error), + spawnErrorCode: result.spawnErrorCode ?? null }; } return parseClaudeJsonResult(result.stdout, result.stderr, result.status, { @@ -1514,6 +1813,7 @@ function runClaudePromptStreaming({ maxTurns = 10, cwd, timeout = DEFAULT_TIMEOUT_MS, + killGraceMs, extraArgs = [], resumeSessionId = null, defaultModel = null, @@ -1525,7 +1825,8 @@ function runClaudePromptStreaming({ executionMode = "print", env = process.env, signalEmitter = process, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES } = {}) { if (executionMode === "tmux-tui") { return Promise.resolve(runClaudeTuiPrompt({ @@ -1552,7 +1853,9 @@ function runClaudePromptStreaming({ maxTurns, resumeSessionId, extraArgs, - bin + bin, + env, + argvBudgetBytes }); return spawnStreamingCommand({ bin: invocation.bin, @@ -1561,7 +1864,10 @@ function runClaudePromptStreaming({ env, input: invocation.input, timeout, + killGraceMs, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT, onStdoutLine(line) { const parsed = parseStreamJsonLine(line, { allowPrefix: true }); if (parsed.ok) { @@ -1573,25 +1879,23 @@ function runClaudePromptStreaming({ } }).then((result) => { const parsed = parseClaudeStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const resultError = isClaudeErrorResultEvent(parsed.resultEvent) ? getClaudeErrorText(parsed.resultEvent) : null; const hasSuccessfulResult = Boolean( parsed.resultEvent && parsed.resultEvent.type === "result" && !isClaudeErrorResultEvent(parsed.resultEvent) ); - const completed = result.ok || result.timedOut && hasSuccessfulResult; + const recoveredTimeout = result.timedOut && hasSuccessfulResult && !result.closeTimedOut && !result.terminationFailure; + const completed = result.ok || recoveredTimeout; return { ...result, ...parsed, timedOut: completed ? false : result.timedOut, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), model: parsed.model ?? model ?? defaultModel, ok: completed && !resultError && hasVisibleText, - error: completed ? resultError || (hasVisibleText ? null : "claude produced no visible text") : result.error + error: completed ? resultError || (hasVisibleText ? null : "claude produced no visible text") : result.error, + errorCode: completed && !resultError ? hasVisibleText ? null : "no_visible_text" : result.errorCode, + terminationReason: completed ? null : result.terminationReason }; }); } @@ -1600,6 +1904,8 @@ function runClaudePromptStreaming({ var COPILOT_BIN = process.env.COPILOT_CLI_BIN || "copilot"; var DEFAULT_TIMEOUT_MS2 = 9e5; var AUTH_CHECK_TIMEOUT_MS2 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES2 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT2 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var COPILOT_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS2 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -1792,7 +2098,10 @@ function runCopilotPrompt({ allowAllPaths = true, allowAllUrls = true, noAskUser = true, - bin = COPILOT_BIN + bin = COPILOT_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES2 } = {}) { const invocation = buildCopilotInvocation({ prompt, @@ -1808,23 +2117,27 @@ function runCopilotPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT2 + }); if (result.error) { return { ok: false, resumeStatus: getCopilotResumeStatus(resumeSessionId, null), - error: result.error.code === "ETIMEDOUT" ? `copilot timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `copilot timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error, { provider: "copilot" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseCopilotStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getCopilotResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); - const sessionId = parsed.sessionId ?? resolvedSession.sessionId; + const sessionId = parsed.sessionId ?? matchResumeSessionIdLine(result.stderr); return { ok: result.status === 0 && !resultError && hasVisibleText, response: parsed.response, @@ -1851,7 +2164,9 @@ function runCopilotPromptStreaming({ onEvent = () => { }, bin = COPILOT_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES2 } = {}) { const invocation = buildCopilotInvocation({ prompt, @@ -1871,9 +2186,11 @@ function runCopilotPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT2, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -1884,14 +2201,9 @@ function runCopilotPromptStreaming({ } }).then((result) => { const parsed = parseCopilotStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getCopilotResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); - const sessionId = parsed.sessionId ?? resolvedSession.sessionId; + const sessionId = parsed.sessionId ?? matchResumeSessionIdLine(result.stderr); return { ...result, ...parsed, @@ -1908,6 +2220,8 @@ var GEMINI_BIN = process.env.GEMINI_CLI_BIN || "gemini"; var DEFAULT_TIMEOUT_MS3 = 3e5; var AUTH_CHECK_TIMEOUT_MS3 = 3e4; var PROMPT_STDIN_THRESHOLD2 = 1e5; +var SAFE_PROMPT_ARGV_BUDGET_BYTES3 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT3 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var GEMINI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var VALID_GEMINI_EFFORTS = /* @__PURE__ */ new Set(["low", "medium", "high"]); var TRANSIENT_PROBE_ERROR_PATTERNS3 = [ @@ -1944,16 +2258,23 @@ function buildGeminiInvocation({ outputFormat = "json", resumeSessionId = null, extraArgs = [], - bin = GEMINI_BIN + bin = GEMINI_BIN, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { const promptText = applyGeminiEffort(prompt, effort); - const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD2; - const args = ["-p", useStdin ? "" : promptText, "-o", outputFormat]; const resolvedApprovalMode = write ? "auto_edit" : approvalMode; - if (model) args.push("-m", model); - args.push("--approval-mode", resolvedApprovalMode); - if (resumeSessionId) args.push("--resume", resumeSessionId); - if (extraArgs.length > 0) args.push(...extraArgs); + const buildArgs = (inlinePrompt) => { + const nextArgs = ["-p", inlinePrompt ? promptText : "", "-o", outputFormat]; + if (model) nextArgs.push("-m", model); + nextArgs.push("--approval-mode", resolvedApprovalMode); + if (resumeSessionId) nextArgs.push("--resume", resumeSessionId); + if (extraArgs.length > 0) nextArgs.push(...extraArgs); + return nextArgs; + }; + const inlineArgs = buildArgs(true); + const useStdin = Buffer.byteLength(promptText, "utf8") > PROMPT_STDIN_THRESHOLD2 || !preflightArgv(bin, inlineArgs, { env, argvBudgetBytes }).ok; + const args = useStdin ? buildArgs(false) : inlineArgs; return { bin, args, @@ -2085,8 +2406,12 @@ function runGeminiPrompt({ extraArgs = [], resumeSessionId = null, defaultModel = null, - bin = GEMINI_BIN + bin = GEMINI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { + const childEnv = buildGeminiEnv(env); const invocation = buildGeminiInvocation({ prompt, model, @@ -2096,18 +2421,25 @@ function runGeminiPrompt({ outputFormat: "json", resumeSessionId, extraArgs, - bin + bin, + env: childEnv, + argvBudgetBytes }); const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, input: invocation.input, - env: buildGeminiEnv() + env: childEnv, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT3 }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `gemini timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `gemini timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error), + spawnErrorCode: result.spawnErrorCode ?? null }; } return parseGeminiJsonResult(result.stdout, result.stderr, result.status, { @@ -2128,8 +2460,11 @@ function runGeminiPromptStreaming({ onEvent = () => { }, bin = GEMINI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES3 } = {}) { + const childEnv = buildGeminiEnv(env); const invocation = buildGeminiInvocation({ prompt, model, @@ -2139,16 +2474,20 @@ function runGeminiPromptStreaming({ outputFormat: "stream-json", resumeSessionId, extraArgs, - bin + bin, + env: childEnv, + argvBudgetBytes }); return spawnStreamingCommand({ bin: invocation.bin, args: invocation.args, cwd, - env: buildGeminiEnv(), + env: childEnv, input: invocation.input, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT3, onStdoutLine(line) { const parsed = parseStreamJsonLine(line, { allowPrefix: true }); if (parsed.ok) { @@ -2186,6 +2525,8 @@ import path2 from "node:path"; var KIMI_BIN = process.env.KIMI_CLI_BIN || "kimi"; var DEFAULT_TIMEOUT_MS4 = 9e5; var AUTH_CHECK_TIMEOUT_MS4 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES4 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT4 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var KIMI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS4 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -2297,13 +2638,28 @@ function runKimiPrompt({ resumeSessionId = null, resumeLast = false, defaultModel = null, - bin = KIMI_BIN + bin = KIMI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES4 } = {}) { const invocation = buildKimiInvocation({ prompt, model, resumeSessionId, resumeLast, extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT4 + }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `kimi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error: error2, errorCode: classifyProviderFailure(error2, { provider: "kimi" }) }; + return { + ok: false, + error: error2, + errorCode: classifyProviderFailure(result.error, { provider: "kimi" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } if (result.status !== 0) { const error2 = result.stderr.trim() || formatProviderExitError("kimi", result.status); @@ -2340,16 +2696,20 @@ function runKimiPromptStreaming({ onEvent = () => { }, bin = KIMI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES4 } = {}) { const invocation = buildKimiInvocation({ prompt, model, resumeSessionId, resumeLast, extraArgs, bin }); return spawnStreamingCommand({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT4, onStdoutLine(line) { const event = parseKimiEventLine(line); if (event) { @@ -2371,7 +2731,7 @@ function runKimiPromptStreaming({ model: parsed.model ?? model ?? defaultModel ?? readKimiDefaultModel(), ok, error, - errorCode: classifyProviderFailure(error, { provider: "kimi" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "kimi" }) }; }); } @@ -2380,6 +2740,8 @@ function runKimiPromptStreaming({ var QWEN_BIN = process.env.QWEN_CLI_BIN || "qwen"; var DEFAULT_TIMEOUT_MS5 = 3e5; var AUTH_CHECK_TIMEOUT_MS5 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES5 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT5 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; var PROXY_KEYS = ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy"]; var NO_PROXY_DEFAULTS = ["localhost", "127.0.0.1"]; @@ -2627,7 +2989,9 @@ function runQwenPrompt({ appendSystem, appendDirs, extraArgs = [], - bin = QWEN_BIN + bin = QWEN_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES5 } = {}) { const invocation = buildQwenInvocation({ prompt, @@ -2647,18 +3011,21 @@ function runQwenPrompt({ const result = runCommand(invocation.bin, invocation.args, { cwd, env, - timeout + timeout, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT5 }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `qwen timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error: error2, errorCode: classifyProviderFailure(error2, { provider: "qwen" }) }; + return { + ok: false, + error: error2, + errorCode: classifyProviderFailure(result.error, { provider: "qwen" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } const parsed = parseQwenStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const resultEventError = extractQwenResultError(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.status === 0 && !resultEventError && hasVisibleText ? null : result.stderr.trim() || resultEventError || (result.status === 0 ? "qwen produced no visible text" : formatProviderExitError("qwen", result.status)); @@ -2668,7 +3035,7 @@ function runQwenPrompt({ status: result.status, stderr: result.stderr, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), error, errorCode }; @@ -2692,7 +3059,8 @@ function runQwenPromptStreaming({ onEvent = () => { }, bin = QWEN_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES5 } = {}) { const invocation = buildQwenInvocation({ prompt, @@ -2715,10 +3083,12 @@ function runQwenPromptStreaming({ cwd, env, timeout, - detached: background, + detached: background ? true : void 0, unref: background, stdio: ["ignore", "pipe", "pipe"], spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT5, onStdoutLine(line) { if (!line.trim().startsWith("{")) return; try { @@ -2728,19 +3098,15 @@ function runQwenPromptStreaming({ } }).then((result) => { const parsed = parseQwenStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const resultEventError = extractQwenResultError(parsed.resultEvent); - const error = result.ok && !resultEventError ? hasVisibleText ? null : resultEventError || "qwen produced no visible text" : resultEventError || result.error; - const errorCode = resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" }); + const commonFailure = !result.ok && result.errorCode ? result.error : null; + const error = commonFailure || (result.ok && !resultEventError ? hasVisibleText ? null : resultEventError || "qwen produced no visible text" : resultEventError || result.error); + const errorCode = result.errorCode ?? (resultEventError ? classifyProviderFailure(resultEventError, { provider: "qwen" }) || "provider_error" : classifyProviderFailure(error, { provider: "qwen" })); return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(result.stderr), ok: result.ok && !resultEventError && hasVisibleText, error, errorCode @@ -2752,6 +3118,8 @@ function runQwenPromptStreaming({ var MMX_BIN = process.env.MMX_CLI_BIN || process.env.MINIMAX_CLI_BIN || "mmx"; var DEFAULT_TIMEOUT_MS6 = 12e4; var AUTH_CHECK_TIMEOUT_MS6 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES6 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT6 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var MINIMAX_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS6 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -2860,7 +3228,8 @@ function runMiniMaxPrompt({ defaultModel = null, env = process.env, bin = MMX_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES6 } = {}) { return new Promise((resolve) => { const invocation = buildMiniMaxInvocation({ prompt, model, extraArgs, bin }); @@ -2872,6 +3241,8 @@ function runMiniMaxPrompt({ timeout, stdio: ["ignore", "pipe", "pipe"], spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT6, onStdoutLine() { } }).then((result) => { @@ -2932,7 +3303,8 @@ async function runMiniMaxPromptStreaming({ onEvent = () => { }, bin = MMX_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES6 } = {}) { const events = []; return runMiniMaxPrompt({ @@ -2944,7 +3316,8 @@ async function runMiniMaxPromptStreaming({ defaultModel, env, bin, - spawnImpl + spawnImpl, + argvBudgetBytes }).then((result) => { try { const event = { @@ -2967,6 +3340,8 @@ var OPENCODE_BIN = process.env.OPENCODE_CLI_BIN || "opencode"; var DEFAULT_TIMEOUT_MS7 = 9e5; var AUTH_CHECK_TIMEOUT_MS7 = 3e4; var SESSION_EXPORT_TIMEOUT_MS = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES7 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT7 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var OPENCODE_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS7 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3153,11 +3528,6 @@ function parseOpenCodeStreamText(text) { } function parseOpenCodeJsonResult(stdout, stderr, status, { defaultModel = null } = {}) { const parsed = parseOpenCodeStreamText(stdout); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); const resultError = getOpenCodeResultError(parsed.resultEvent); const sessionErrorMessage = getOpenCodeSessionErrorDataMessage(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); @@ -3166,7 +3536,7 @@ function parseOpenCodeJsonResult(stdout, stderr, status, { defaultModel = null } ok: status === 0 && !resultError && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? matchResumeSessionIdLine(stderr), model: parsed.model ?? defaultModel, status, error, @@ -3214,7 +3584,9 @@ function runOpenCodePrompt({ variant = null, skipPermissions = true, defaultModel = null, - bin = OPENCODE_BIN + bin = OPENCODE_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES7 } = {}) { const invocation = buildOpenCodeInvocation({ prompt, @@ -3228,13 +3600,21 @@ function runOpenCodePrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, env }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT7 + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `opencode timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; return { ok: false, error, - errorCode: classifyProviderFailure(error, { provider: "opencode" }) + errorCode: classifyProviderFailure(result.error, { provider: "opencode" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseOpenCodeJsonResult(result.stdout, result.stderr, result.status, { @@ -3262,7 +3642,8 @@ function runOpenCodePromptStreaming({ onEvent = () => { }, bin = OPENCODE_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES7 } = {}) { const invocation = buildOpenCodeInvocation({ prompt, @@ -3283,6 +3664,8 @@ function runOpenCodePromptStreaming({ env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT7, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -3293,28 +3676,24 @@ function runOpenCodePromptStreaming({ } }).then((result) => { const parsed = parseOpenCodeStreamText(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); + const fallbackSessionId = matchResumeSessionIdLine(result.stderr); const resultError = getOpenCodeResultError(parsed.resultEvent); const sessionErrorMessage = getOpenCodeSessionErrorDataMessage(parsed.resultEvent); const hasVisibleText = Boolean(parsed.response.trim()); let resolvedModel = parsed.model ?? model ?? defaultModel; const ok = result.ok && !resultError && hasVisibleText; if (ok && !resolvedModel) { - resolvedModel = resolveOpenCodeSessionModel(parsed.sessionId ?? resolvedSession.sessionId, { cwd, env, bin }); + resolvedModel = resolveOpenCodeSessionModel(parsed.sessionId ?? fallbackSessionId, { cwd, env, bin }); } - const error = sessionErrorMessage || (result.ok ? resultError || (hasVisibleText ? null : "opencode produced no visible text") : result.error); + const error = (!result.ok && result.errorCode ? result.error : null) || sessionErrorMessage || (result.ok ? resultError || (hasVisibleText ? null : "opencode produced no visible text") : result.error); return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + sessionId: parsed.sessionId ?? fallbackSessionId, model: resolvedModel, ok, error, - errorCode: classifyProviderFailure(error, { provider: "opencode" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "opencode" }) }; }); } @@ -3324,6 +3703,8 @@ var PI_BIN = process.env.PI_CLI_BIN || "pi"; var DEFAULT_PI_MODEL = null; var DEFAULT_TIMEOUT_MS8 = 9e5; var AUTH_CHECK_TIMEOUT_MS8 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES8 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT8 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var PI_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS8 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3489,7 +3870,10 @@ function runPiPrompt({ continueLast = false, noSession = false, defaultModel = null, - bin = PI_BIN + bin = PI_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES8 } = {}) { const invocation = buildPiInvocation({ prompt, @@ -3501,11 +3885,20 @@ function runPiPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT8 + }); if (result.error) { return { ok: false, - error: result.error.code === "ETIMEDOUT" ? `pi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message + error: result.error.code === "ETIMEDOUT" ? `pi timed out after ${Math.round(timeout / 1e3)}s` : result.error.message, + errorCode: classifyProviderFailure(result.error, { provider: "pi" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parsePiStreamText(result.stdout); @@ -3542,7 +3935,9 @@ function runPiPromptStreaming({ onEvent = () => { }, bin = PI_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES8 } = {}) { const invocation = buildPiInvocation({ prompt, @@ -3558,9 +3953,11 @@ function runPiPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT8, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -3596,6 +3993,8 @@ var CMD_BIN = process.env.CMD_CLI_BIN || "cmd"; var DEFAULT_CMD_MODEL = "deepseek"; var DEFAULT_TIMEOUT_MS9 = 9e5; var AUTH_CHECK_TIMEOUT_MS9 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES9 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT9 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var CMD_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var TRANSIENT_PROBE_ERROR_PATTERNS9 = [ /\b(timed out|timeout|429|rate limit|no capacity available|temporar(?:y|ily)|service unavailable|overloaded|try again|econnreset|econnrefused|enotfound|network|socket hang up)\b/i @@ -3676,7 +4075,9 @@ function runCmdPrompt({ extraArgs = [], yolo = true, defaultModel = null, - bin = CMD_BIN + bin = CMD_BIN, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES9 } = {}) { const invocation = buildCmdInvocation({ prompt, @@ -3684,13 +4085,21 @@ function runCmdPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout, env }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT9 + }); if (result.error) { const error2 = result.error.code === "ETIMEDOUT" ? `cmd timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; return { ok: false, error: error2, - errorCode: classifyProviderFailure(error2, { provider: "cmd" }) + errorCode: classifyProviderFailure(result.error, { provider: "cmd" }), + spawnErrorCode: result.spawnErrorCode ?? null }; } const parsed = parseCmdTextResult(result.stdout); @@ -3721,7 +4130,8 @@ function runCmdPromptStreaming({ onEvent = () => { }, bin = CMD_BIN, - spawnImpl + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES9 } = {}) { const invocation = buildCmdInvocation({ prompt, @@ -3736,6 +4146,8 @@ function runCmdPromptStreaming({ env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT9, onStdoutLine(line) { const trimmed = line.trimEnd(); if (!trimmed.trim()) return; @@ -3754,7 +4166,7 @@ function runCmdPromptStreaming({ model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, ok: result.ok && hasVisibleText, error, - errorCode: classifyProviderFailure(error, { provider: "cmd" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "cmd" }) }; }); } @@ -3946,7 +4358,7 @@ function runAgyPromptStreaming({ const parsed = parseAgyTextResult(result.stdout); const filteredStderr = stripAgyBenignStderr(result.stderr); const hasVisibleText = Boolean(parsed.response.trim()); - const error = result.ok ? hasVisibleText ? null : "agy produced no visible text" : filteredStderr.trim() || result.error; + const error = !result.ok && result.errorCode ? result.error : result.ok ? hasVisibleText ? null : "agy produced no visible text" : filteredStderr.trim() || result.error; return { ...result, ...parsed, @@ -3955,7 +4367,7 @@ function runAgyPromptStreaming({ model: model ?? defaultModel ?? DEFAULT_AGY_MODEL, ok: result.ok && hasVisibleText, error, - errorCode: classifyProviderFailure(error, { provider: "agy" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "agy" }) }; }); } @@ -3964,6 +4376,8 @@ function runAgyPromptStreaming({ var GROK_BIN = process.env.GROK_CLI_BIN || "grok"; var DEFAULT_TIMEOUT_MS11 = 9e5; var AUTH_CHECK_TIMEOUT_MS11 = 3e4; +var SAFE_PROMPT_ARGV_BUDGET_BYTES10 = getSafeArgvBudgetBytes(); +var SAFE_PROMPT_ARGV_BUDGET_HINT10 = "Prompt exceeds the safe argv budget. When using review, pass --max-diff-bytes explicitly."; var DEFAULT_GROK_MODEL = "grok-4.5"; var GROK_EXPLICIT_AUTH_ERROR_RE = /\b(unauthenticated|unauthorized|not authenticated|not authorized|login required|log in|sign in|not logged in|invalid api key|missing api key|api key required|token expired|invalid token|credential(?:s)? (?:missing|invalid|expired)|permission denied|access denied|forbidden|401|403)\b/i; var SUCCESS_STOP_REASONS = /* @__PURE__ */ new Set(["endturn", "end_turn", "stop", "stop_sequence", "complete", "completed", "done", "finished", "maxtokens", "max_tokens", "length"]); @@ -4127,7 +4541,10 @@ function runGrokPrompt({ continueLast = false, extraArgs = [], defaultModel = null, - bin = GROK_BIN + bin = GROK_BIN, + env = process.env, + spawnImpl, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES10 } = {}) { const invocation = buildGrokInvocation({ prompt, @@ -4141,10 +4558,22 @@ function runGrokPrompt({ extraArgs, bin }); - const result = runCommand(invocation.bin, invocation.args, { cwd, timeout }); + const result = runCommand(invocation.bin, invocation.args, { + cwd, + timeout, + env, + spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT10 + }); if (result.error) { const error = result.error.code === "ETIMEDOUT" ? `grok timed out after ${Math.round(timeout / 1e3)}s` : result.error.message; - return { ok: false, error, errorCode: classifyProviderFailure(error, { provider: "grok" }) }; + return { + ok: false, + error, + errorCode: classifyProviderFailure(result.error, { provider: "grok" }), + spawnErrorCode: result.spawnErrorCode ?? null + }; } const parsed = parseGrokJsonResult(result.stdout, result.stderr, result.status, { defaultModel: model ?? defaultModel @@ -4166,7 +4595,9 @@ function runGrokPromptStreaming({ onEvent = () => { }, bin = GROK_BIN, - spawnImpl + spawnImpl, + env = process.env, + argvBudgetBytes = SAFE_PROMPT_ARGV_BUDGET_BYTES10 } = {}) { const invocation = buildGrokInvocation({ prompt, @@ -4184,9 +4615,11 @@ function runGrokPromptStreaming({ bin: invocation.bin, args: invocation.args, cwd, - env: { ...process.env }, + env: { ...env }, timeout, spawnImpl, + argvBudgetBytes, + argvBudgetHint: SAFE_PROMPT_ARGV_BUDGET_HINT10, onStdoutLine(line) { const trimmed = line.trim(); if (!trimmed.startsWith("{")) return; @@ -4200,14 +4633,14 @@ function runGrokPromptStreaming({ const hasVisibleText = Boolean(parsed.response.trim()); const stopReasonError = isNonSuccessStopReason(parsed.stopReason) ? `grok stopped with ${parsed.stopReason}` : null; const ok = result.ok && hasVisibleText && !parsed.providerError && !stopReasonError; - const error = ok ? null : parsed.providerError || stopReasonError || (result.ok ? "grok produced no visible text" : result.error); + const error = ok ? null : (!result.ok && result.errorCode ? result.error : null) || parsed.providerError || stopReasonError || (result.ok ? "grok produced no visible text" : result.error); return { ...result, ...parsed, model: model ?? defaultModel ?? DEFAULT_GROK_MODEL, ok, error, - errorCode: classifyProviderFailure(error, { provider: "grok" }) + errorCode: result.errorCode ?? classifyProviderFailure(error, { provider: "grok" }) }; }); } @@ -5653,11 +6086,11 @@ var COMMAND_DEFINITIONS = deepFreeze([ definition({ id: "tui", path: ["tui"], - summary: "Open the read-only terminal run inspector.", + summary: "Open the terminal run inspector; loading may update local recovery state.", usage: "polycli tui [--run-id ] [--history ]", surfaces: TERMINAL_SURFACE, dispatchTarget: "terminal-wrapper", - effects: { readsWorkspace: true }, + effects: { readsWorkspace: true, writesLocalState: true }, options: [ option("run-id", "string", "Select an initial run.", { valueName: "id" }), option("history", "integer", "Limit the run list.", { valueName: "n" }), @@ -6077,6 +6510,18 @@ function validateCommandPositionals(definition2, parsed, enumSources) { throw commandArgumentError(definition2, "Too many positional arguments.", { arguments: positionals }); } const positionalProvider = positionals[0] ?? null; + if (positionalProvider && explicitProvider) { + throw commandArgumentError( + definition2, + `Provider target cannot be supplied as positional provider '${positionalProvider}' together with --provider '${explicitProvider}'.`, + { + argument: positionalProvider, + positionalProvider, + explicitProvider, + conflictsWith: "--provider" + } + ); + } if (positionalProvider && !providers.includes(positionalProvider)) { throw commandArgumentError( definition2, @@ -6407,9 +6852,9 @@ function assertCommandRegistry({ assertCommandRegistry(); // plugins/polycli/scripts/lib/job-control.mjs -import fs6 from "node:fs"; +import fs7 from "node:fs"; import os4 from "node:os"; -import process4 from "node:process"; +import process5 from "node:process"; import { spawnSync as spawnSync3 } from "node:child_process"; // plugins/polycli/scripts/lib/state.mjs @@ -6550,8 +6995,13 @@ function describeStateRoot() { function stateRootDir() { return describeStateRoot().stateRoot; } -function resolveWorkspaceRoot(cwd = process.cwd()) { - const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { cwd }); +function resolveWorkspaceRoot(cwd = process.cwd(), { deadlineAt = null } = {}) { + const remainingMs = Number.isFinite(deadlineAt) ? Math.floor(deadlineAt - Date.now()) : null; + if (remainingMs != null && remainingMs <= 0) return path3.resolve(cwd); + const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { + cwd, + ...remainingMs == null ? {} : { timeout: remainingMs } + }); if (result.status === 0 && result.stdout.trim()) { return path3.resolve(result.stdout.trim()); } @@ -6575,6 +7025,9 @@ function resolveJobLogFile(workspaceRoot, jobId) { function resolveJobConfigFile(workspaceRoot, jobId) { return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); } +function resolveJobStartFailureFile(workspaceRoot, jobId) { + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.start-failure.json`); +} function chmodPrivateDir(dir) { try { fs3.chmodSync(dir, PRIVATE_DIR_MODE); @@ -6639,6 +7092,7 @@ function saveState(workspaceRoot, state, { preserveJobIds = [] } = {}) { if (job && job.jobId && !keptIds.has(job.jobId)) { removeJobFile(workspaceRoot, job.jobId); removeJobConfigFile(workspaceRoot, job.jobId); + removeJobStartFailureFile(workspaceRoot, job.jobId); removeJobLogFile(workspaceRoot, job.jobId); } } @@ -6653,7 +7107,7 @@ function updateState(workspaceRoot, mutate) { return saveState(workspaceRoot, state); }); } -function updateJobAtomically(workspaceRoot, jobId, buildNext) { +function updateJobAtomically(workspaceRoot, jobId, buildNext, { lockOptions = {} } = {}) { ensureStateDir(workspaceRoot); const lockPath = `${resolveStateFile(workspaceRoot)}.lock`; return withLockfile(lockPath, () => { @@ -6691,7 +7145,7 @@ function updateJobAtomically(workspaceRoot, jobId, buildNext) { } saveState(workspaceRoot, state, { preserveJobIds: [jobId] }); return { written: true, job, envelope }; - }); + }, lockOptions); } function upsertJob(workspaceRoot, jobPatch) { let savedJob = null; @@ -6772,182 +7226,36 @@ function removeJobConfigFile(workspaceRoot, jobId) { } catch { } } -function removeJobLogFile(workspaceRoot, jobId) { - try { - fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); - } catch { - } +function writeJobStartFailureFile(workspaceRoot, jobId, payload) { + ensureStateDir(workspaceRoot); + writeJsonAtomic(resolveJobStartFailureFile(workspaceRoot, jobId), payload, { mode: PRIVATE_FILE_MODE }); + return resolveJobStartFailureFile(workspaceRoot, jobId); } - -// plugins/polycli/scripts/lib/run-ledger.mjs -import { randomUUID as randomUUID2 } from "node:crypto"; -import { stat as fsStat } from "node:fs/promises"; -import path4 from "node:path"; - -// packages/polycli-utils/src/ndjson.js -import fs4 from "node:fs"; -function safeParseLine(line) { +function readJobStartFailureFile(workspaceRoot, jobId) { try { - return JSON.parse(line); + return JSON.parse(fs3.readFileSync(resolveJobStartFailureFile(workspaceRoot, jobId), "utf8")); } catch { return null; } } -function retainCompactedLines(lines, keepFrom, retentionGroupKey) { - const entries = []; - for (const line of lines) { - const record = safeParseLine(line); - if (record != null) { - entries.push({ line, record }); - } - } - if (typeof retentionGroupKey !== "function") { - return entries.slice(keepFrom).map((entry) => entry.line); - } - const grouped = entries.map((entry) => ({ - ...entry, - retentionGroup: retentionGroupKey(entry.record) - })); - const retainedStart = keepFrom < 0 ? Math.max(0, grouped.length + keepFrom) : Math.min(keepFrom, grouped.length); - const retainedGroups = new Set( - grouped.slice(keepFrom).map((entry) => entry.retentionGroup).filter((group) => group != null) - ); - return grouped.filter((entry, index) => index >= retainedStart || entry.retentionGroup != null && retainedGroups.has(entry.retentionGroup)).map((entry) => entry.line); -} -function chmodIfRequested(filePath, mode) { - if (mode === 438) return; +function removeJobStartFailureFile(workspaceRoot, jobId) { try { - fs4.chmodSync(filePath, mode); + fs3.unlinkSync(resolveJobStartFailureFile(workspaceRoot, jobId)); } catch { } } -function readNdjson(filePath) { - let text; +function removeJobLogFile(workspaceRoot, jobId) { try { - text = fs4.readFileSync(filePath, "utf8"); - } catch (error) { - if (error?.code === "ENOENT") { - return []; - } - throw error; - } - if (text.length === 0) { - return []; - } - const records = []; - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - const parsed = safeParseLine(trimmed); - if (parsed != null) { - records.push(parsed); - } - } - return records; -} -function appendNdjson(filePath, record, { - timeoutMs = 1e4, - staleMs = 3e4, - pollMs = 25, - maxBytes = null, - keepRatio = 0.5, - retentionGroupKey = null, - mode = 438 -} = {}) { - const lockPath = `${filePath}.lock`; - return withLockfile(lockPath, () => { - ensureParentDir(filePath); - let needsLeadingNewline = false; - try { - const stat = fs4.statSync(filePath); - if (stat.size > 0) { - const fd = fs4.openSync(filePath, "r"); - const lastByte = Buffer.alloc(1); - try { - fs4.readSync(fd, lastByte, 0, 1, stat.size - 1); - } finally { - fs4.closeSync(fd); - } - needsLeadingNewline = lastByte[0] !== 10; - } - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - const line = `${needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)} -`; - fs4.appendFileSync(filePath, line, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - if (maxBytes != null) { - const stat = fs4.statSync(filePath); - if (stat.size > maxBytes) { - const lines = fs4.readFileSync(filePath, "utf8").split("\n").filter(Boolean); - const validCount = lines.reduce((count, entry) => count + (safeParseLine(entry) != null ? 1 : 0), 0); - const keepFrom = Math.floor(validCount * (1 - keepRatio)); - const kept = retainCompactedLines(lines, keepFrom, retentionGroupKey); - writeFileAtomic(filePath, `${kept.join("\n")} -`, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - } - } - return true; - }, { timeoutMs, staleMs, pollMs }); -} -function appendNdjsonBatch(filePath, records, { - timeoutMs = 1e4, - staleMs = 3e4, - pollMs = 25, - maxBytes = null, - keepRatio = 0.5, - retentionGroupKey = null, - mode = 438 -} = {}) { - if (!Array.isArray(records)) { - throw new TypeError("records must be an array"); - } - if (records.length === 0) { - return true; + fs3.unlinkSync(resolveJobLogFile(workspaceRoot, jobId)); + } catch { } - const serializedBatch = records.map((record) => { - const serialized = JSON.stringify(record); - if (typeof serialized !== "string") { - throw new TypeError("each record must be JSON-serializable"); - } - return serialized; - }); - const batch = `${serializedBatch.join("\n")} -`; - const lockPath = `${filePath}.lock`; - return withLockfile(lockPath, () => { - ensureParentDir(filePath); - let text = ""; - try { - text = fs4.readFileSync(filePath, "utf8"); - } catch (error) { - if (error?.code !== "ENOENT") { - throw error; - } - } - let nextText = `${text}${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}${batch}`; - if (maxBytes != null && Buffer.byteLength(nextText, "utf8") > maxBytes) { - const existing = text.split("\n").filter((entry) => safeParseLine(entry) != null); - const targetCount = Math.max( - serializedBatch.length, - Math.ceil((existing.length + serializedBatch.length) * keepRatio) - ); - const allLines = [...existing, ...serializedBatch]; - const keepFrom = Math.max(0, allLines.length - targetCount); - const kept = retainCompactedLines(allLines, keepFrom, retentionGroupKey); - nextText = `${kept.join("\n")} -`; - } - writeFileAtomic(filePath, nextText, { encoding: "utf8", mode }); - chmodIfRequested(filePath, mode); - return true; - }, { timeoutMs, staleMs, pollMs }); } +// plugins/polycli/scripts/lib/background-start.mjs +import fs5 from "node:fs"; +import process4 from "node:process"; +import { spawn as spawn2 } from "node:child_process"; + // plugins/polycli/scripts/lib/cli-contract.mjs var ERROR_CODES = /* @__PURE__ */ new Set([ "invalid_argument", @@ -7250,8 +7558,8 @@ function normalizeStatusListWait(wait, payload) { function normalizeSessionEntries(entries) { return clonePublicResult(Array.isArray(entries) ? entries : []); } -function serializeProviderCommand(commandId, payload, context) { - if (context.background === true || payload?.job && context.background !== false) { +function serializeProviderCommand(commandId, payload) { + if (payload?.job) { return { type: "job.started", job: normalizeV2Job(payload.job) @@ -7282,7 +7590,7 @@ function serializeV2Result(commandId, legacyPayload, context = {}) { }; } if (PROVIDER_COMMANDS.has(id)) { - return serializeProviderCommand(id, payload, context); + return serializeProviderCommand(id, payload); } if (id === "status") { if (payload.job) { @@ -7444,24 +7752,193 @@ function createV2ErrorEnvelope(error, context = {}) { } // plugins/polycli/scripts/lib/run-ledger.mjs -var MAX_LEDGER_BYTES = 2e6; -var KEEP_RATIO = 0.5; -var PRIVATE_FILE_MODE2 = 384; -var RUN_ID_RE = /^[A-Za-z0-9_.-]{1,96}$/; -var SECRET_LONG_OPT_RE = /(token|secret|password|api-?key|access-?key|credential)/i; -var SECRET_ENV_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_KEY|CREDENTIAL)/i; -var PROMPT_COMMANDS = /* @__PURE__ */ new Set(["ask", "rescue", "review", "adversarial-review"]); -var VALUE_OPTIONS = /* @__PURE__ */ new Set([ - "--provider", - "--model", - "--base", - "--scope", - "--resume", - "--effort", - "--run-id", - "--timeout-ms", - "--history" -]); +import { randomUUID as randomUUID2 } from "node:crypto"; +import { stat as fsStat } from "node:fs/promises"; +import path4 from "node:path"; + +// packages/polycli-utils/src/ndjson.js +import fs4 from "node:fs"; +function safeParseLine(line) { + try { + return JSON.parse(line); + } catch { + return null; + } +} +function retainCompactedLines(lines, keepFrom, retentionGroupKey) { + const entries = []; + for (const line of lines) { + const record = safeParseLine(line); + if (record != null) { + entries.push({ line, record }); + } + } + if (typeof retentionGroupKey !== "function") { + return entries.slice(keepFrom).map((entry) => entry.line); + } + const grouped = entries.map((entry) => ({ + ...entry, + retentionGroup: retentionGroupKey(entry.record) + })); + const retainedStart = keepFrom < 0 ? Math.max(0, grouped.length + keepFrom) : Math.min(keepFrom, grouped.length); + const retainedGroups = new Set( + grouped.slice(keepFrom).map((entry) => entry.retentionGroup).filter((group) => group != null) + ); + return grouped.filter((entry, index) => index >= retainedStart || entry.retentionGroup != null && retainedGroups.has(entry.retentionGroup)).map((entry) => entry.line); +} +function chmodIfRequested(filePath, mode) { + if (mode === 438) return; + try { + fs4.chmodSync(filePath, mode); + } catch { + } +} +function readNdjson(filePath) { + let text; + try { + text = fs4.readFileSync(filePath, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") { + return []; + } + throw error; + } + if (text.length === 0) { + return []; + } + const records = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parsed = safeParseLine(trimmed); + if (parsed != null) { + records.push(parsed); + } + } + return records; +} +function appendNdjson(filePath, record, { + timeoutMs = 1e4, + staleMs = 3e4, + pollMs = 25, + maxBytes = null, + keepRatio = 0.5, + retentionGroupKey = null, + mode = 438 +} = {}) { + const lockPath = `${filePath}.lock`; + return withLockfile(lockPath, () => { + ensureParentDir(filePath); + let needsLeadingNewline = false; + try { + const stat = fs4.statSync(filePath); + if (stat.size > 0) { + const fd = fs4.openSync(filePath, "r"); + const lastByte = Buffer.alloc(1); + try { + fs4.readSync(fd, lastByte, 0, 1, stat.size - 1); + } finally { + fs4.closeSync(fd); + } + needsLeadingNewline = lastByte[0] !== 10; + } + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + const line = `${needsLeadingNewline ? "\n" : ""}${JSON.stringify(record)} +`; + fs4.appendFileSync(filePath, line, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + if (maxBytes != null) { + const stat = fs4.statSync(filePath); + if (stat.size > maxBytes) { + const lines = fs4.readFileSync(filePath, "utf8").split("\n").filter(Boolean); + const validCount = lines.reduce((count, entry) => count + (safeParseLine(entry) != null ? 1 : 0), 0); + const keepFrom = Math.floor(validCount * (1 - keepRatio)); + const kept = retainCompactedLines(lines, keepFrom, retentionGroupKey); + writeFileAtomic(filePath, `${kept.join("\n")} +`, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + } + } + return true; + }, { timeoutMs, staleMs, pollMs }); +} +function appendNdjsonBatch(filePath, records, { + timeoutMs = 1e4, + staleMs = 3e4, + pollMs = 25, + maxBytes = null, + keepRatio = 0.5, + retentionGroupKey = null, + mode = 438 +} = {}) { + if (!Array.isArray(records)) { + throw new TypeError("records must be an array"); + } + if (records.length === 0) { + return true; + } + const serializedBatch = records.map((record) => { + const serialized = JSON.stringify(record); + if (typeof serialized !== "string") { + throw new TypeError("each record must be JSON-serializable"); + } + return serialized; + }); + const batch = `${serializedBatch.join("\n")} +`; + const lockPath = `${filePath}.lock`; + return withLockfile(lockPath, () => { + ensureParentDir(filePath); + let text = ""; + try { + text = fs4.readFileSync(filePath, "utf8"); + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + let nextText = `${text}${text.length > 0 && !text.endsWith("\n") ? "\n" : ""}${batch}`; + if (maxBytes != null && Buffer.byteLength(nextText, "utf8") > maxBytes) { + const existing = text.split("\n").filter((entry) => safeParseLine(entry) != null); + const targetCount = Math.max( + serializedBatch.length, + Math.ceil((existing.length + serializedBatch.length) * keepRatio) + ); + const allLines = [...existing, ...serializedBatch]; + const keepFrom = Math.max(0, allLines.length - targetCount); + const kept = retainCompactedLines(allLines, keepFrom, retentionGroupKey); + nextText = `${kept.join("\n")} +`; + } + writeFileAtomic(filePath, nextText, { encoding: "utf8", mode }); + chmodIfRequested(filePath, mode); + return true; + }, { timeoutMs, staleMs, pollMs }); +} + +// plugins/polycli/scripts/lib/run-ledger.mjs +var MAX_LEDGER_BYTES = 2e6; +var KEEP_RATIO = 0.5; +var PRIVATE_FILE_MODE2 = 384; +var RUN_ID_RE = /^[A-Za-z0-9_.-]{1,96}$/; +var SECRET_LONG_OPT_RE = /(token|secret|password|api-?key|access-?key|credential)/i; +var SECRET_ENV_KEY_RE = /(TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_KEY|CREDENTIAL)/i; +var PROMPT_COMMANDS = /* @__PURE__ */ new Set(["ask", "rescue", "review", "adversarial-review"]); +var VALUE_OPTIONS = /* @__PURE__ */ new Set([ + "--provider", + "--model", + "--base", + "--scope", + "--resume", + "--effort", + "--run-id", + "--timeout-ms", + "--history" +]); var SHORT_VALUE_OPTIONS = /* @__PURE__ */ new Set(["-m"]); var FOCUS_VALUE_OPTIONS = /* @__PURE__ */ new Set(["--focus"]); var VALID_HOST_SURFACES = /* @__PURE__ */ new Set([ @@ -7610,11 +8087,20 @@ function redactLedgerError(error) { if (message == null || message === "") return null; return { message: sanitizePublicErrorMessage(message, 300) }; } +function sanitizeLedgerPreview(preview) { + if (preview == null) return null; + const text = String(preview); + return sanitizePublicErrorMessage(text, text.length); +} function redactTerminalDescriptor(descriptor) { if (!descriptor || typeof descriptor !== "object") return descriptor ?? null; return { ...descriptor, - events: Array.isArray(descriptor.events) ? descriptor.events.map((event) => ({ ...event, error: redactLedgerError(event.error) })) : descriptor.events + events: Array.isArray(descriptor.events) ? descriptor.events.map((event) => ({ + ...event, + error: redactLedgerError(event.error), + ...Object.prototype.hasOwnProperty.call(event, "preview") ? { preview: sanitizeLedgerPreview(event.preview) } : {} + })) : descriptor.events }; } function createRunLedgerEvent(event = {}) { @@ -7643,7 +8129,7 @@ function createRunLedgerEvent(event = {}) { providerSessionId, timingRef: event.timingRef ?? null, error: redactLedgerError(event.error), - preview: event.preview ?? null, + preview: sanitizeLedgerPreview(event.preview), stdoutBytes: event.stdoutBytes ?? null, stderrBytes: event.stderrBytes ?? null, durationMs: event.durationMs ?? null, @@ -7677,7 +8163,7 @@ function appendRunLedgerEvent(workspaceRoot, event) { }); return full; } -function appendRunLedgerEvents(workspaceRoot, events) { +function appendRunLedgerEvents(workspaceRoot, events, lockOptions = {}) { if (!Array.isArray(events)) { throw new TypeError("events must be an array"); } @@ -7691,6 +8177,7 @@ function appendRunLedgerEvents(workspaceRoot, events) { workspaceSlug: event.workspaceSlug ?? workspaceSlug })); appendNdjsonBatch(file, full, { + ...lockOptions, maxBytes: MAX_LEDGER_BYTES, keepRatio: KEEP_RATIO, retentionGroupKey: terminalLedgerRetentionGroupKey, @@ -7880,7 +8367,7 @@ function buildExpectedTerminalPair(events) { expected: rawExpected.map((event) => ({ ...event, terminalDescriptor: descriptor })) }; } -function ensureRunLedgerTerminalPair(workspaceRoot, events) { +function ensureRunLedgerTerminalPair(workspaceRoot, events, { lockOptions = {} } = {}) { if (!Array.isArray(events) || events.length !== 2) { throw new TypeError("terminal ledger pair must contain exactly two events"); } @@ -7897,7 +8384,7 @@ function ensureRunLedgerTerminalPair(workspaceRoot, events) { if (ambiguousLegacy.length > 0) { throw new Error(`Incomplete or conflicting terminal ledger pair for ${identityLabel}`); } - return appendRunLedgerEvents(workspaceRoot, expected); + return appendRunLedgerEvents(workspaceRoot, expected, lockOptions); } if (existing.length === 1) { const [partial] = existing; @@ -7920,7 +8407,7 @@ function ensureRunLedgerTerminalPair(workspaceRoot, events) { ...missing, terminalDescriptor: repairDescriptor }; - return [...existing, ...appendRunLedgerEvents(workspaceRoot, [repair])]; + return [...existing, ...appendRunLedgerEvents(workspaceRoot, [repair], lockOptions)]; } if (!terminalPairMatches(existing, expected)) { throw new Error(`Incomplete or conflicting terminal ledger pair for ${identityLabel}`); @@ -8251,40 +8738,342 @@ function summarizeRunLedger(events) { incrementCount(failureClassCounts, classifyRunFailure(event)); } return { - runId: group.runId, - commands: group.commands, - startedAt: group.events[0]?.at || null, - updatedAt: group.events.at(-1)?.at || null, - providerCount: new Set(decisions.map((event) => event.provider)).size, - adoptedCount: decisions.filter((event) => event.status === "adopted").length, - skippedCount: decisions.filter((event) => event.status === "skipped").length, - failedCount: decisions.filter((event) => event.status === "failed").length, - failureClassCounts + runId: group.runId, + commands: group.commands, + startedAt: group.events[0]?.at || null, + updatedAt: group.events.at(-1)?.at || null, + providerCount: new Set(decisions.map((event) => event.provider)).size, + adoptedCount: decisions.filter((event) => event.status === "adopted").length, + skippedCount: decisions.filter((event) => event.status === "skipped").length, + failedCount: decisions.filter((event) => event.status === "failed").length, + failureClassCounts + }; + }); +} +function buildRunExplanation(events, runId) { + const group = groupRunLedgerEvents(events).get(runId); + if (!group) { + return { runId, found: false, text: `Run ${runId} was not found.`, events: [] }; + } + const decisions = group.projectedEvents.filter((event) => event.phase === "provider_decision"); + const lines = decisions.map( + (event) => `${event.provider || "run"} ${event.status}${event.reason ? ` (${event.reason})` : ""}` + ); + for (const event of group.projectedEvents.filter((item) => item.phase === "attempt_result" && item.status === "failed")) { + const subject = event.provider || event.jobId || "run"; + lines.push(`attempt ${subject} failed (${classifyRunFailure(event)})`); + } + const terminalProviders = new Set(group.projectedEvents.filter((event) => TERMINAL_LEDGER_PHASES.has(event.phase) && event.provider).map((event) => event.provider)); + for (const provider of new Set(group.projectedEvents.filter((event) => (event.phase === "attempt_started" || event.phase === "job_started") && event.provider).map((event) => event.provider))) { + if (!terminalProviders.has(provider)) lines.push(`${provider} unfinished`); + } + return { runId, found: true, text: lines.join("\n"), events: group.events }; +} + +// plugins/polycli/scripts/lib/background-start.mjs +var ACTIVE_JOB_STATUSES2 = /* @__PURE__ */ new Set(["queued", "running"]); +var TERMINAL_JOB_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); +function blocksFailureFinalizer(envelope) { + return TERMINAL_JOB_STATUSES2.has(envelope?.job?.status) || envelope?.cancellationIntent?.status === "requested" && ACTIVE_JOB_STATUSES2.has(envelope?.job?.status); +} +function cleanupRuntimeOptions(runtimeOptions = {}) { + const cleanupPaths = Array.isArray(runtimeOptions?.cleanupPaths) ? runtimeOptions.cleanupPaths : []; + for (const cleanupPath of cleanupPaths) { + if (typeof cleanupPath !== "string" || cleanupPath.trim() === "") continue; + try { + fs5.rmSync(cleanupPath, { recursive: true, force: true }); + } catch { + } + } +} +function buildRunEvent(runContext, base) { + if (!runContext?.runId) return null; + const command = base.command || runContext.command; + return { + runId: runContext.runId, + hostSurface: runContext.hostSurface || "unknown", + argv: runContext.argv || [], + invocationId: runContext.invocationId ?? null, + attemptId: runContext.attemptId ?? null, + jobId: runContext.jobId ?? base.jobId ?? null, + command, + commands: Array.from(new Set([ + ...runContext.commands || [], + command, + ...base.commands || [] + ].filter(Boolean))).sort(), + ...base + }; +} +function prepareFailureEvents(runContext, events) { + const material = events.map((event) => buildRunEvent(runContext, event)).filter(Boolean); + if (material.length === 0) return []; + const terminalDescriptor = createTerminalLedgerDescriptor(material); + return material.map((event) => ({ ...event, terminalDescriptor })); +} +function safeRunIdentity(jobId, execution, runContext) { + return { + runId: runContext?.runId ?? null, + invocationId: runContext?.invocationId ?? null, + attemptId: runContext?.attemptId ?? null, + command: runContext?.command || execution.kind, + commands: Array.from(new Set([ + ...runContext?.commands || [], + runContext?.command, + execution.kind + ].filter(Boolean))).sort(), + hostSurface: runContext?.hostSurface || "unknown", + jobId, + provider: execution.provider, + kind: execution.kind, + logFile: runContext?.logFile ?? null + }; +} +function buildStartFailureRecovery(jobId, execution, runContext, error, runtimeOptions) { + const safeMessage = sanitizePublicErrorMessage(error?.message || error, 300); + const identity = safeRunIdentity(jobId, execution, runContext); + const terminalReason = `${execution.kind}_failed`; + const terminalEvents = prepareFailureEvents(identity, [ + { + command: identity.command, + kind: execution.kind, + provider: execution.provider, + phase: "attempt_result", + status: "failed", + reason: terminalReason, + attempt: { ordinal: 1 }, + jobId, + error: { message: safeMessage }, + logFile: identity.logFile + }, + { + command: identity.command, + kind: execution.kind, + provider: execution.provider, + phase: "provider_decision", + status: "failed", + reason: terminalReason, + jobId + } + ]); + return { + version: 1, + jobId, + provider: execution.provider, + kind: execution.kind, + error: safeMessage, + recordedAt: (/* @__PURE__ */ new Date()).toISOString(), + identity, + terminalDescriptor: terminalEvents[0]?.terminalDescriptor ?? null, + ownedCleanupPaths: Array.isArray(runtimeOptions?.cleanupPaths) ? runtimeOptions.cleanupPaths.filter((entry) => typeof entry === "string" && entry.trim() !== "") : [] + }; +} +function materialExecution(recovery) { + return { + provider: recovery.provider, + kind: recovery.kind, + runtimeOptions: { cleanupPaths: recovery.ownedCleanupPaths || [] } + }; +} +function finalizeBackgroundStartFailure(workspaceRoot, recovery, { lockOptions = {} } = {}) { + const { jobId } = recovery; + const execution = materialExecution(recovery); + const runContext = recovery.identity; + const safeMessage = recovery.error; + return updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { + if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || blocksFailureFinalizer(storedEnvelope)) { + return null; + } + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); + const failedJob = { + ...latest, + invocationId: latest.invocationId ?? runContext?.invocationId ?? null, + attemptId: latest.attemptId ?? runContext?.attemptId ?? null, + status: "failed", + pid: null, + finishedAt, + updatedAt: finishedAt, + error: safeMessage + }; + const terminalReason = `${execution.kind}_failed`; + const terminalEvents = prepareFailureEvents(runContext, [ + { + command: runContext?.command || execution.kind, + kind: execution.kind, + provider: execution.provider, + phase: "attempt_result", + status: "failed", + reason: terminalReason, + attempt: { ordinal: 1 }, + jobId, + error: { message: safeMessage }, + logFile: failedJob.logFile || runContext?.logFile || null + }, + { + command: runContext?.command || execution.kind, + kind: execution.kind, + provider: execution.provider, + phase: "provider_decision", + status: "failed", + reason: terminalReason, + jobId + } + ]).map((event) => ({ + ...event, + terminalDescriptor: recovery.terminalDescriptor || event.terminalDescriptor + })); + const terminalDescriptor = recovery.terminalDescriptor || terminalEvents[0]?.terminalDescriptor || null; + return { + job: failedJob, + envelope: { + job: failedJob, + result: { ok: false, error: safeMessage }, + terminalReason, + terminalDescriptor + }, + beforeStateCommit() { + if (terminalEvents.length > 0) { + ensureRunLedgerTerminalPair(workspaceRoot, terminalEvents); + } + } }; - }); + }, { lockOptions }); } -function buildRunExplanation(events, runId) { - const group = groupRunLedgerEvents(events).get(runId); - if (!group) { - return { runId, found: false, text: `Run ${runId} was not found.`, events: [] }; +function persistedJobBlocksFailure(workspaceRoot, jobId) { + const job = getJob(workspaceRoot, jobId); + const envelope = readJobFile(resolveJobFile(workspaceRoot, jobId)); + return !job || !ACTIVE_JOB_STATUSES2.has(job.status) || blocksFailureFinalizer(envelope); +} +function recoverBackgroundStartFailure(workspaceRoot, jobId, options = {}) { + const recovery = readJobStartFailureFile(workspaceRoot, jobId); + if (!recovery || recovery.version !== 1 || recovery.jobId !== jobId) { + return { written: false, finalizationError: null }; } - const decisions = group.projectedEvents.filter((event) => event.phase === "provider_decision"); - const lines = decisions.map( - (event) => `${event.provider || "run"} ${event.status}${event.reason ? ` (${event.reason})` : ""}` - ); - for (const event of group.projectedEvents.filter((item) => item.phase === "attempt_result" && item.status === "failed")) { - const subject = event.provider || event.jobId || "run"; - lines.push(`attempt ${subject} failed (${classifyRunFailure(event)})`); + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; } - const terminalProviders = new Set(group.projectedEvents.filter((event) => TERMINAL_LEDGER_PHASES.has(event.phase) && event.provider).map((event) => event.provider)); - for (const provider of new Set(group.projectedEvents.filter((event) => (event.phase === "attempt_started" || event.phase === "job_started") && event.provider).map((event) => event.provider))) { - if (!terminalProviders.has(provider)) lines.push(`${provider} unfinished`); + try { + const write = finalizeBackgroundStartFailure(workspaceRoot, recovery, options); + if (write.written) { + cleanupRuntimeOptions({ cleanupPaths: recovery.ownedCleanupPaths }); + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } + return { written: write.written, finalizationError: null }; + } catch (finalizationError) { + return { written: false, finalizationError }; } - return { runId, found: true, text: lines.join("\n"), events: group.events }; +} +function recordBackgroundStartFailure(workspaceRoot, jobId, execution, runContext, error, options = {}) { + const configFile = resolveJobConfigFile(workspaceRoot, jobId); + const config = readJobConfigFile(configFile); + const runtimeOptions = config?.execution?.runtimeOptions ?? execution.runtimeOptions; + const recovery = buildStartFailureRecovery(jobId, execution, runContext, error, runtimeOptions); + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + writeJobStartFailureFile(workspaceRoot, jobId, recovery); + } catch { + } + if (persistedJobBlocksFailure(workspaceRoot, jobId)) { + removeJobStartFailureFile(workspaceRoot, jobId); + return { written: false, finalizationError: null }; + } + try { + const write = finalizeBackgroundStartFailure(workspaceRoot, recovery, options); + if (write.written) { + cleanupRuntimeOptions(runtimeOptions); + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } + return { written: write.written, finalizationError: null }; + } catch (finalizationError) { + cleanupRuntimeOptions(runtimeOptions); + return { written: false, finalizationError }; + } +} +async function startBackgroundWorker({ + workspaceRoot, + job, + execution, + runContext, + config, + companionPath, + env = process4.env, + failureFinalizationOptions = {} +}, { + writeConfigFile = writeJobConfigFile, + writeLogFile = fs5.writeFileSync, + openLogFile = fs5.openSync, + closeLogFile = fs5.closeSync, + spawnWorker = spawn2 +} = {}) { + let child = null; + let logFd = null; + let startFailure = null; + let startWarning = null; + let closeWarning = null; + const configFile = resolveJobConfigFile(workspaceRoot, job.jobId); + try { + writeConfigFile(workspaceRoot, job.jobId, config); + writeLogFile(job.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] started ${job.provider} ${job.kind} +`, { + encoding: "utf8", + mode: 384 + }); + logFd = openLogFile(job.logFile, "a", 384); + child = spawnWorker(process4.execPath, [companionPath, "_job-worker", configFile], { + cwd: execution.cwd, + env: { ...env }, + stdio: ["ignore", logFd, logFd], + detached: true + }); + try { + child.once("error", (asyncError) => { + recordBackgroundStartFailure( + workspaceRoot, + job.jobId, + execution, + runContext, + asyncError, + failureFinalizationOptions + ); + }); + child.unref(); + } catch (error) { + startWarning = error; + } + } catch (error) { + startFailure = error; + } finally { + if (logFd != null) { + try { + closeLogFile(logFd); + } catch (error) { + if (child) closeWarning = error; + else if (!startFailure) startFailure = error; + } + } + } + if (startFailure) { + recordBackgroundStartFailure( + workspaceRoot, + job.jobId, + execution, + runContext, + startFailure, + failureFinalizationOptions + ); + throw startFailure; + } + return { child, startWarning, closeWarning }; } // plugins/polycli/scripts/lib/sessions.mjs -import fs5 from "node:fs"; +import fs6 from "node:fs"; import os3 from "node:os"; import path5 from "node:path"; import { createHash } from "node:crypto"; @@ -8330,7 +9119,7 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho case "kimi": { let realCwd = workspaceRoot; try { - realCwd = fs5.realpathSync(workspaceRoot); + realCwd = fs6.realpathSync(workspaceRoot); } catch { } const slug = `wd_${path5.basename(realCwd)}_${createHash("sha256").update(realCwd).digest("hex").slice(0, 12)}`; @@ -8355,7 +9144,7 @@ function deriveSessionArtifactCandidate({ provider, sessionId, workspaceRoot, ho return { path: null, reason: `no artifact derivation for provider ${provider ?? "?"}` }; } } -function recordArtifactPath(candidate, { homedir, lstatFn = fs5.lstatSync, realpathFn = fs5.realpathSync, existsFn = fs5.existsSync } = {}) { +function recordArtifactPath(candidate, { homedir, lstatFn = fs6.lstatSync, realpathFn = fs6.realpathSync, existsFn = fs6.existsSync } = {}) { if (!candidate || typeof candidate.path !== "string") return null; const { path: candidatePath, provider } = candidate; if (!existsFn(candidatePath)) return null; @@ -8432,7 +9221,7 @@ function collectNonPurgeableSessions(events = [], { homedir = os3.homedir() } = } return out; } -function planPurge({ recorded = [], homedir, lstatFn = fs5.lstatSync, realpathFn = fs5.realpathSync, existsFn = fs5.existsSync, sizeFn } = {}) { +function planPurge({ recorded = [], homedir, lstatFn = fs6.lstatSync, realpathFn = fs6.realpathSync, existsFn = fs6.existsSync, sizeFn } = {}) { const deletable = []; const skipped = []; const defaultSizeFn = (p) => { @@ -8499,7 +9288,7 @@ function planPurge({ recorded = [], homedir, lstatFn = fs5.lstatSync, realpathFn } return { deletable, skipped }; } -function executePurge(plan, { confirm = false, rmFn = (p) => fs5.rmSync(p, { recursive: true, force: true }) } = {}) { +function executePurge(plan, { confirm = false, rmFn = (p) => fs6.rmSync(p, { recursive: true, force: true }) } = {}) { const deletable = plan?.deletable ?? []; const skipped = plan?.skipped ?? []; if (!confirm) { @@ -8522,27 +9311,74 @@ var TERMINAL_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancel var WAIT_TARGETS = /* @__PURE__ */ new Set(["terminal", "completed", "failed", "cancelled"]); var DEFAULT_STATUS_LIMIT = 8; var MAX_SELECTOR_CANDIDATES = 8; +function createDeadlineError() { + const error = new Error("cancellation deadline exceeded"); + error.code = "EDEADLINE"; + return error; +} +function remainingDeadlineMs(deadlineAt) { + return Number.isFinite(deadlineAt) ? deadlineAt - Date.now() : null; +} +function deadlineLockOptions(deadlineAt) { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs == null) return {}; + if (remainingMs <= 0) throw createDeadlineError(); + return { + timeoutMs: Math.max(1, Math.ceil(remainingMs)), + pollMs: Math.max(1, Math.min(25, Math.ceil(remainingMs))) + }; +} +function isDeadlineFailure(error, deadlineAt) { + return Number.isFinite(deadlineAt) && ["EDEADLINE", "ELOCKTIMEOUT", "ETIMEDOUT"].includes(error?.code); +} +async function awaitWithinDeadline(promise, deadlineAt) { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs == null) { + await promise; + return true; + } + if (remainingMs <= 0) return false; + let timer = null; + try { + return await Promise.race([ + Promise.resolve(promise).then(() => true), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), remainingMs); + }) + ]); + } finally { + if (timer) clearTimeout(timer); + } +} function isProcessAlive(pid) { if (!Number.isInteger(pid) || pid <= 0) return false; try { - process4.kill(pid, 0); + process5.kill(pid, 0); return true; } catch { return false; } } -function isExpectedWorkerProcess(pid, configFile) { +function isExpectedWorkerProcess(pid, configFile, { + deadlineAt = null, + platform = process5.platform, + spawnProcess = spawnSync3 +} = {}) { if (!Number.isInteger(pid) || pid <= 0 || !configFile) return null; + const remainingMs = Number.isFinite(deadlineAt) ? Math.floor(deadlineAt - Date.now()) : null; + if (remainingMs != null && remainingMs <= 0) return null; try { - const result = process4.platform === "win32" ? spawnSync3("powershell.exe", [ + const spawnOptions = { + encoding: "utf8", + stdio: "pipe", + ...remainingMs == null ? {} : { timeout: remainingMs } + }; + const result = platform === "win32" ? spawnProcess("powershell.exe", [ "-NoProfile", "-NonInteractive", "-Command", `(Get-CimInstance Win32_Process -Filter 'ProcessId = ${pid}').CommandLine` - ], { encoding: "utf8", stdio: "pipe" }) : spawnSync3("ps", ["-ww", "-o", "command=", "-p", String(pid)], { - encoding: "utf8", - stdio: "pipe" - }); + ], spawnOptions) : spawnProcess("ps", ["-ww", "-o", "command=", "-p", String(pid)], spawnOptions); if (result.error) return null; if (result.status !== 0) return false; return result.stdout.includes("_job-worker") && result.stdout.includes(configFile); @@ -8556,7 +9392,7 @@ function sortJobsNewestFirst(jobs) { function readProgressPreview(logFile, maxLines = 4) { if (!logFile) return ""; try { - const lines = fs6.readFileSync(logFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); + const lines = fs7.readFileSync(logFile, "utf8").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); return lines.slice(-maxLines).join("\n"); } catch { return ""; @@ -8573,6 +9409,9 @@ function enrichJob(workspaceRoot, job) { function isTerminalEnvelope(envelope) { return Boolean(envelope?.job && TERMINAL_STATUSES2.has(envelope.job.status)); } +function hasPendingCancellationIntent(envelope) { + return envelope?.cancellationIntent?.status === "requested"; +} function buildRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result = null, reason = "worker_exited", terminalDescriptor = null } = {}) { const command = runContext.command || config?.execution?.kind || job.kind || null; const provider = runContext.provider || config?.execution?.provider || job.provider || null; @@ -8671,7 +9510,18 @@ function applyTerminalDescriptor(events, terminalDescriptor) { }); } function prepareRecoveredTerminalEvents(workspaceRoot, job, config, { result = null, reason = "worker_exited", terminalDescriptor = null } = {}) { - const runContext = config?.runContext; + const descriptorEvent = terminalDescriptor?.events?.[0] ?? null; + const runContext = config?.runContext || (terminalDescriptor?.runId ? { + runId: terminalDescriptor.runId, + command: descriptorEvent?.command ?? job.kind ?? null, + kind: descriptorEvent?.kind ?? job.kind ?? null, + provider: descriptorEvent?.provider ?? job.provider ?? null, + hostSurface: descriptorEvent?.hostSurface || "unknown", + invocationId: terminalDescriptor.invocationId ?? job.invocationId ?? null, + attemptId: terminalDescriptor.attemptId ?? job.attemptId ?? null, + jobId: terminalDescriptor.jobId ?? job.jobId ?? null, + logFile: job.logFile ?? null + } : null); if (!runContext?.runId) return { events: [], terminalDescriptor }; const events = buildRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result, @@ -8684,9 +9534,9 @@ function prepareRecoveredTerminalEvents(workspaceRoot, job, config, { result = n terminalDescriptor: descriptor }; } -function ensureRecoveredTerminalEvents(workspaceRoot, prepared) { +function ensureRecoveredTerminalEvents(workspaceRoot, prepared, { lockOptions = {} } = {}) { if (prepared.events.length > 0) { - ensureRunLedgerTerminalPair(workspaceRoot, prepared.events); + ensureRunLedgerTerminalPair(workspaceRoot, prepared.events, { lockOptions }); } } function cleanupRuntimePaths(config) { @@ -8695,16 +9545,26 @@ function cleanupRuntimePaths(config) { for (const cleanupPath of cleanupPaths) { if (typeof cleanupPath !== "string" || !cleanupPath) continue; try { - fs6.rmSync(cleanupPath, { recursive: true, force: true }); + fs7.rmSync(cleanupPath, { recursive: true, force: true }); } catch { } } } function refreshJob(workspaceRoot, job) { if (!job || !ACTIVE_STATUSES2.has(job.status)) { + if (job?.jobId && TERMINAL_STATUSES2.has(job.status)) { + removeJobStartFailureFile(workspaceRoot, job.jobId); + } return job ? enrichJob(workspaceRoot, job) : null; } const storedEnvelope = readJobFile(resolveJobFile(workspaceRoot, job.jobId)); + if (hasPendingCancellationIntent(storedEnvelope)) { + return enrichJob(workspaceRoot, job); + } + const startFailureRecovery = recoverBackgroundStartFailure(workspaceRoot, job.jobId); + if (startFailureRecovery.written) { + return enrichJob(workspaceRoot, getJob(workspaceRoot, job.jobId) || job); + } if (!job.pid && !isTerminalEnvelope(storedEnvelope)) { return enrichJob(workspaceRoot, job); } @@ -8778,8 +9638,12 @@ function refreshJob(workspaceRoot, job) { const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, job.jobId)); cleanupRuntimePaths(config); removeJobConfigFile(workspaceRoot, job.jobId); + removeJobStartFailureFile(workspaceRoot, job.jobId); } const current = write.written ? write.job : getJob(workspaceRoot, job.jobId) || job; + if (TERMINAL_STATUSES2.has(current?.status)) { + removeJobStartFailureFile(workspaceRoot, job.jobId); + } return enrichJob(workspaceRoot, current); } catch { return enrichJob(workspaceRoot, getJob(workspaceRoot, job.jobId) || job); @@ -8787,11 +9651,11 @@ function refreshJob(workspaceRoot, job) { } function buildStatusSnapshot(workspaceRoot, { showAll = false } = {}) { const refreshed = sortJobsNewestFirst(listJobs(workspaceRoot)).map((job) => refreshJob(workspaceRoot, job)); - const limited = showAll ? refreshed : refreshed.slice(0, DEFAULT_STATUS_LIMIT); + const recent = refreshed.filter((job) => TERMINAL_STATUSES2.has(job.status)); return { totalJobs: refreshed.length, - running: limited.filter((job) => ACTIVE_STATUSES2.has(job.status)), - recent: limited.filter((job) => TERMINAL_STATUSES2.has(job.status)) + running: refreshed.filter((job) => ACTIVE_STATUSES2.has(job.status)), + recent: showAll ? recent : recent.slice(0, DEFAULT_STATUS_LIMIT) }; } function refreshJobsForLedgerRecovery(workspaceRoot) { @@ -8974,84 +9838,94 @@ async function waitForJob(workspaceRoot, jobId, options = {}) { async function cancelJob(workspaceRoot, jobId, { terminate = terminateProcessTree, isWorkerAlive = isProcessAlive, - isExpectedWorker = isExpectedWorkerProcess + isExpectedWorker = isExpectedWorkerProcess, + deadlineAt = null } = {}) { let pidToKill = null; let configForCleanup = null; let cancellationEnvelope = null; let reason = null; const requestedAt = (/* @__PURE__ */ new Date()).toISOString(); - const intentWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { - if (!current) { - reason = "not_found"; - return null; - } - if (!ACTIVE_STATUSES2.has(current.status)) { - reason = "not_cancellable"; - return null; - } - const resumingCancellation = storedEnvelope?.job?.status === "cancelled"; - if (isTerminalEnvelope(storedEnvelope) && !resumingCancellation) { - reason = "not_cancellable"; - return null; - } - pidToKill = current.pid ?? null; - configForCleanup = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); - const intentJob = resumingCancellation ? { - ...current, - ...storedEnvelope.job, - status: "cancelled", - pid: null, - finishedAt: storedEnvelope.job.finishedAt || requestedAt, - updatedAt: requestedAt - } : { - ...current, - status: "cancelled", - pid: null, - finishedAt: requestedAt, - updatedAt: requestedAt - }; - const cancellationResult = resumingCancellation ? storedEnvelope.result || { ok: false, error: "cancelled" } : { ok: false, error: "cancelled" }; - const terminal = prepareRecoveredTerminalEvents(workspaceRoot, intentJob, configForCleanup, { - result: cancellationResult, - reason: "cancelled", - terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null - }); - cancellationEnvelope = { - job: intentJob, - result: cancellationResult, - terminalReason: "cancelled", - terminalDescriptor: terminal.terminalDescriptor - }; - return { - // Do not make the state terminal yet. It remains the recovery point if this process exits - // after persisting the intent but before the worker receives its signal. - job: current, - envelope: cancellationEnvelope, - beforeStateCommit() { - ensureRecoveredTerminalEvents(workspaceRoot, terminal); + let intentWrite; + try { + intentWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { + if (!current) { + reason = "not_found"; + return null; } - }; - }); + if (!ACTIVE_STATUSES2.has(current.status)) { + reason = "not_cancellable"; + return null; + } + const resumingCancellation = hasPendingCancellationIntent(storedEnvelope); + if (isTerminalEnvelope(storedEnvelope) && !resumingCancellation) { + reason = "not_cancellable"; + return null; + } + pidToKill = current.pid ?? null; + configForCleanup = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); + cancellationEnvelope = resumingCancellation ? storedEnvelope : { + job: current, + cancellationIntent: { + status: "requested", + requestedAt + } + }; + return { + // Do not make the state or envelope terminal yet. Both remain recovery points if this + // process exits after persisting the intent but before the worker receives its signal. + job: current, + envelope: cancellationEnvelope + }; + }, { lockOptions: deadlineLockOptions(deadlineAt) }); + } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + throw error; + } if (!intentWrite.written) { return { cancelled: false, reason: reason || "not_cancellable", jobId }; } const configFile = resolveJobConfigFile(workspaceRoot, jobId); - if (pidToKill && isWorkerAlive(pidToKill)) { - if (!isExpectedWorker(pidToKill, configFile)) { + if (Number.isInteger(pidToKill) && pidToKill > 1 && isWorkerAlive(pidToKill)) { + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + if (!configForCleanup || isExpectedWorker(pidToKill, configFile, { deadlineAt }) !== true) { + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } return { cancelled: false, reason: "worker_identity_unverified", jobId }; } try { - await terminate(pidToKill, { + const remainingMs = remainingDeadlineMs(deadlineAt); + if (remainingMs != null && remainingMs <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + const terminatedWithinDeadline = await awaitWithinDeadline(terminate(pidToKill, { signal: "SIGINT", forceSignal: "SIGKILL", - forceAfterMs: 2e3 - }); + forceAfterMs: remainingMs == null ? 2e3 : Math.max(1, Math.min(2e3, Math.floor(remainingMs))), + deadlineAt + }), deadlineAt); + if (!terminatedWithinDeadline) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } return { cancelled: false, reason: "kill_failed", jobId, killWarning: error.message }; } if (isWorkerAlive(pidToKill)) { - const postSignalIdentity = isExpectedWorker(pidToKill, configFile); + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + const postSignalIdentity = isExpectedWorker(pidToKill, configFile, { deadlineAt }); + if (remainingDeadlineMs(deadlineAt) != null && remainingDeadlineMs(deadlineAt) <= 0) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } if (postSignalIdentity === true) { return { cancelled: false, reason: "worker_still_running", jobId }; } @@ -9061,55 +9935,69 @@ async function cancelJob(workspaceRoot, jobId, { } } let finalConfig = configForCleanup; - const finalWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { - if (!current) { - reason = "not_found"; - return null; - } - if (current.status === "cancelled") return null; - if (!ACTIVE_STATUSES2.has(current.status) || storedEnvelope?.job?.status !== "cancelled") { - reason = "cancellation_finalization_pending"; - return null; - } - finalConfig = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)) || finalConfig; - const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); - const finalJob = { - ...current, - ...storedEnvelope.job, - status: "cancelled", - pid: null, - finishedAt: storedEnvelope.job.finishedAt || finishedAt, - updatedAt: finishedAt - }; - const result = storedEnvelope.result || cancellationEnvelope?.result || { ok: false, error: "cancelled" }; - const terminal = prepareRecoveredTerminalEvents(workspaceRoot, finalJob, finalConfig, { - result, - reason: "cancelled", - terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null - }); - return { - job: finalJob, - envelope: { - ...storedEnvelope, - job: finalJob, - result, - terminalReason: "cancelled", - terminalDescriptor: terminal.terminalDescriptor - }, - beforeStateCommit() { - ensureRecoveredTerminalEvents(workspaceRoot, terminal); + let finalWrite; + try { + finalWrite = updateJobAtomically(workspaceRoot, jobId, (current, storedEnvelope) => { + if (!current) { + reason = "not_found"; + return null; } - }; - }); + if (current.status === "cancelled") return null; + if (!ACTIVE_STATUSES2.has(current.status) || !hasPendingCancellationIntent(storedEnvelope)) { + reason = "cancellation_finalization_pending"; + return null; + } + finalConfig = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)) || finalConfig; + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); + const storedCancelledJob = storedEnvelope?.job?.status === "cancelled" ? storedEnvelope.job : null; + const finalJob = { + ...current, + ...storedCancelledJob || {}, + status: "cancelled", + pid: null, + finishedAt: storedCancelledJob?.finishedAt || finishedAt, + updatedAt: finishedAt + }; + const result = storedEnvelope.result || cancellationEnvelope?.result || { ok: false, error: "cancelled" }; + const terminal = prepareRecoveredTerminalEvents(workspaceRoot, finalJob, finalConfig, { + result, + reason: "cancelled", + terminalDescriptor: storedEnvelope?.terminalDescriptor ?? null + }); + return { + job: finalJob, + envelope: { + ...storedEnvelope, + job: finalJob, + result, + terminalReason: "cancelled", + terminalDescriptor: terminal.terminalDescriptor, + cancellationIntent: storedEnvelope.cancellationIntent + }, + beforeStateCommit() { + ensureRecoveredTerminalEvents(workspaceRoot, terminal, { + lockOptions: deadlineLockOptions(deadlineAt) + }); + } + }; + }, { lockOptions: deadlineLockOptions(deadlineAt) }); + } catch (error) { + if (isDeadlineFailure(error, deadlineAt)) { + return { cancelled: false, reason: "deadline_exceeded", jobId }; + } + throw error; + } if (!finalWrite.written) { const current = getJob(workspaceRoot, jobId); if (current?.status === "cancelled") { + removeJobStartFailureFile(workspaceRoot, jobId); return { cancelled: true, jobId }; } return { cancelled: false, reason: reason || "cancellation_finalization_pending", jobId }; } cleanupRuntimePaths(finalConfig); removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); return { cancelled: true, jobId }; } @@ -9271,7 +10159,7 @@ function resolveProvider({ provider, positionals = [] } = {}) { } // plugins/polycli/scripts/lib/review.mjs -import fs7 from "node:fs"; +import fs8 from "node:fs"; import os5 from "node:os"; import path6 from "node:path"; var DEFAULT_MAX_DIFF_BYTES = null; @@ -9331,7 +10219,7 @@ function git(cwd, args) { return runCommand("git", args, { cwd }); } function makeReviewTempDir(prefix) { - return fs7.mkdtempSync(path6.join(os5.tmpdir(), `polycli-review-${prefix}-`)); + return fs8.mkdtempSync(path6.join(os5.tmpdir(), `polycli-review-${prefix}-`)); } function assertNoReviewConstraintOverride(provider, runtimeOptions = {}) { const extraArgs = Array.isArray(runtimeOptions.extraArgs) ? runtimeOptions.extraArgs : []; @@ -9608,7 +10496,7 @@ function summarizeTimingRecords(records) { } // plugins/polycli/scripts/lib/preview.mjs -import fs8 from "node:fs"; +import fs9 from "node:fs"; var PREVIEW_MAX_LINES = 10; var PREVIEW_TAIL_CACHE = /* @__PURE__ */ new Map(); var PRIVATE_FILE_MODE4 = 384; @@ -9697,7 +10585,7 @@ function summarizeEventText(provider, event) { } return ""; } -function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PREVIEW_TAIL_CACHE } = {}) { +function appendPreview(logFile, provider, event, { fsImpl = fs9, tailCache = PREVIEW_TAIL_CACHE } = {}) { const text = summarizeEventText(provider, event); if (!text) return; const lines = String(text).split(/\r?\n/).map((line) => collapseWhitespace(line)).filter(Boolean).slice(0, PREVIEW_MAX_LINES); @@ -9708,9 +10596,9 @@ function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PRE } fsImpl.appendFileSync(logFile, `${lines.join("\n")} `, { encoding: "utf8", mode: PRIVATE_FILE_MODE4 }); - if (fsImpl === fs8) { + if (fsImpl === fs9) { try { - fs8.chmodSync(logFile, PRIVATE_FILE_MODE4); + fs9.chmodSync(logFile, PRIVATE_FILE_MODE4); } catch { } } @@ -9719,7 +10607,7 @@ function appendPreview(logFile, provider, event, { fsImpl = fs8, tailCache = PRE // plugins/polycli/scripts/polycli-companion.mjs var COMPANION_PATH = fileURLToPath(import.meta.url); -var BUILD_VERSION = true ? "0.6.30" : "0.0.0-dev"; +var BUILD_VERSION = true ? "0.6.31" : "0.0.0-dev"; var BUILD_VERSION_SOURCE = true ? "bundled-release" : "development"; var JOB_PREFIXES = { ask: "pa", @@ -9756,8 +10644,8 @@ function resolveTimeoutMs(provider, kind, { model = null, defaultModel = null } } var HEALTH_SENTINEL = "POLYCLI_HEALTH_OK"; var SESSION_ID_ENV = "POLYCLI_COMPANION_SESSION_ID"; -var TERMINAL_JOB_STATUSES2 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); -var ACTIVE_JOB_STATUSES2 = /* @__PURE__ */ new Set(["queued", "running"]); +var TERMINAL_JOB_STATUSES3 = /* @__PURE__ */ new Set(["completed", "failed", "cancelled"]); +var ACTIVE_JOB_STATUSES3 = /* @__PURE__ */ new Set(["queued", "running"]); var RUN_CONTEXT = { invocationId: null, runId: null, @@ -9821,21 +10709,24 @@ function ensureTerminalRunEventsForContext(workspaceRoot, prepared) { return prepared.events.length > 0 ? ensureRunLedgerTerminalPair(workspaceRoot, prepared.events) : []; } function hasTerminalJobEnvelope(envelope) { - return TERMINAL_JOB_STATUSES2.has(envelope?.job?.status); + return TERMINAL_JOB_STATUSES3.has(envelope?.job?.status); +} +function blocksBackgroundWorkerCommit(envelope) { + return hasTerminalJobEnvelope(envelope) || hasPendingCancellationIntent(envelope); } function claimBackgroundWorker(workspaceRoot, jobId) { const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || !ACTIVE_JOB_STATUSES3.has(latest.status) || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } - if (latest.pid != null && latest.pid !== process5.pid) { + if (latest.pid != null && latest.pid !== process6.pid) { return null; } return { job: { ...latest, status: "running", - pid: process5.pid, + pid: process6.pid, updatedAt: (/* @__PURE__ */ new Date()).toISOString() } }; @@ -9846,68 +10737,6 @@ function shouldRetainJobConfig(workspaceRoot, jobId) { const current = getJob(workspaceRoot, jobId); return current?.status === "queued" || current?.status === "running"; } -function recordBackgroundSpawnFailure(workspaceRoot, jobId, execution, runContext, error) { - const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); - try { - const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || !ACTIVE_JOB_STATUSES2.has(latest.status) || hasTerminalJobEnvelope(storedEnvelope)) { - return null; - } - const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); - const failedJob = { - ...latest, - ...execution.jobMeta, - status: "failed", - pid: null, - finishedAt, - updatedAt: finishedAt, - error: error.message - }; - const terminalReason = `${execution.kind}_failed`; - const terminalEvents = [ - { - command: runContext?.command || execution.kind, - kind: execution.kind, - provider: execution.provider, - phase: "attempt_result", - status: "failed", - reason: terminalReason, - attempt: { ordinal: 1 }, - jobId, - error: { message: String(error.message || error).slice(0, 300) }, - logFile: failedJob.logFile || null - }, - { - command: runContext?.command || execution.kind, - kind: execution.kind, - provider: execution.provider, - phase: "provider_decision", - status: "failed", - reason: terminalReason, - jobId - } - ]; - const terminal = prepareTerminalRunEventsForContext(runContext, terminalEvents); - return { - job: failedJob, - envelope: { - job: failedJob, - result: { ok: false, error: error.message }, - terminalReason, - terminalDescriptor: terminal.terminalDescriptor - }, - beforeStateCommit() { - ensureTerminalRunEventsForContext(workspaceRoot, terminal); - } - }; - }); - if (write.written) { - cleanupRuntimeOptions(config?.execution?.runtimeOptions); - removeJobConfigFile(workspaceRoot, jobId); - } - } catch { - } -} async function recordRunEvent(workspaceRoot, base = {}) { return recordRunEventForContext(workspaceRoot, buildCurrentRunContext(), base); } @@ -10029,9 +10858,9 @@ function assertPreDispatchReviewSafety(definition2, parsed) { function exitWithError(error) { const typed = toTypedCliError(error); if (RUN_CONTEXT.outputMode === "json-v2") { - process5.stdout.write(`${JSON.stringify(createV2ErrorEnvelope(typed, { + process6.stdout.write(`${JSON.stringify(createV2ErrorEnvelope(typed, { invocationId: RUN_CONTEXT.invocationId, - command: RUN_CONTEXT.command || process5.argv[2] || "", + command: RUN_CONTEXT.command || process6.argv[2] || "", hostSurface: RUN_CONTEXT.hostSurface, workspaceSlug: RUN_CONTEXT.workspaceSlug, runId: RUN_CONTEXT.runId, @@ -10041,19 +10870,19 @@ function exitWithError(error) { } else if (RUN_CONTEXT.outputMode === "legacy-json") { const code = error?.legacyCode || error?.code || classifyErrorCode(error?.message || ""); const legacyCode = code === "unknown_command" ? "unknown_subcommand" : code; - process5.stdout.write(`${JSON.stringify({ error: typed.message, code: legacyCode }, null, 2)} + process6.stdout.write(`${JSON.stringify({ error: typed.message, code: legacyCode }, null, 2)} `); } else { - process5.stderr.write(`Error: ${typed.message} + process6.stderr.write(`Error: ${typed.message} `); for (const suggestion of typed.data?.suggestions || []) { - process5.stderr.write(`Suggestion: ${suggestion} + process6.stderr.write(`Suggestion: ${suggestion} `); } - for (const step of typed.nextSteps) process5.stderr.write(`${step} + for (const step of typed.nextSteps) process6.stderr.write(`${step} `); } - process5.exitCode = typed.exitCode || error?.exitCode || 1; + process6.exitCode = typed.exitCode || error?.exitCode || 1; } function output(value, asJson) { if (RUN_CONTEXT.outputMode === "json-v2") { @@ -10069,18 +10898,18 @@ function output(value, asJson) { runId: RUN_CONTEXT.runId, jobId: result.job?.jobId ?? result.jobId ?? null }); - process5.stdout.write(`${JSON.stringify(envelope, null, 2)} + process6.stdout.write(`${JSON.stringify(envelope, null, 2)} `); RUN_CONTEXT.authoritativeJsonWritten = true; return; } if (asJson) { - process5.stdout.write(`${JSON.stringify(value, null, 2)} + process6.stdout.write(`${JSON.stringify(value, null, 2)} `); RUN_CONTEXT.authoritativeJsonWritten = true; return; } - process5.stdout.write(typeof value === "string" ? `${value} + process6.stdout.write(typeof value === "string" ? `${value} ` : `${JSON.stringify(value, null, 2)} `); } @@ -10149,7 +10978,7 @@ function resolveProviderModelCacheFile(workspaceRoot) { } function readProviderModelCache(workspaceRoot) { try { - const parsed = JSON.parse(fs9.readFileSync(resolveProviderModelCacheFile(workspaceRoot), "utf8")); + const parsed = JSON.parse(fs10.readFileSync(resolveProviderModelCacheFile(workspaceRoot), "utf8")); return parsed && typeof parsed === "object" ? parsed : {}; } catch { return {}; @@ -10162,7 +10991,7 @@ function readCachedProviderModel(workspaceRoot, provider) { function cacheProviderModel(workspaceRoot, provider, model) { if (typeof model !== "string" || !model.trim()) return; const cacheFile = resolveProviderModelCacheFile(workspaceRoot); - fs9.mkdirSync(path8.dirname(cacheFile), { recursive: true, mode: 448 }); + fs10.mkdirSync(path8.dirname(cacheFile), { recursive: true, mode: 448 }); withLockfile(`${cacheFile}.lock`, () => { writeJsonAtomic(cacheFile, { ...readProviderModelCache(workspaceRoot), [provider]: model }, { mode: 384 }); }); @@ -10188,11 +11017,11 @@ function skippedAuthDetail({ available, authProbeCost }) { } async function inspectProvider(provider, { probeAuth = false } = {}) { const runtime = getProviderRuntime(provider); - const availability = await Promise.resolve(runtime.getAvailability(process5.cwd())); + const availability = await Promise.resolve(runtime.getAvailability(process6.cwd())); const available = availability.available === true; const authProbeCost = normalizeAuthProbeCost(runtime); const authChecked = available && (probeAuth || authProbeCost === "status"); - const auth = authChecked ? await Promise.resolve(runtime.getAuthStatus(process5.cwd())) : null; + const auth = authChecked ? await Promise.resolve(runtime.getAuthStatus(process6.cwd())) : null; const row = { provider, available, @@ -10205,7 +11034,7 @@ async function inspectProvider(provider, { probeAuth = false } = {}) { model: auth?.model ?? null, capabilities: runtime.capabilities }; - cacheProviderModel(resolveWorkspaceRoot(process5.cwd()), provider, row.model); + cacheProviderModel(resolveWorkspaceRoot(process6.cwd()), provider, row.model); return row; } async function inspectSetupProvider(provider, { probeAuth, workspaceRoot }) { @@ -10277,7 +11106,7 @@ async function inspectSetupProvider(provider, { probeAuth, workspaceRoot }) { } async function inspectProviderAvailability(provider) { const runtime = getProviderRuntime(provider); - const availability = await Promise.resolve(runtime.getAvailability(process5.cwd())); + const availability = await Promise.resolve(runtime.getAvailability(process6.cwd())); const authProbeCost = normalizeAuthProbeCost(runtime); return { provider, @@ -10306,14 +11135,14 @@ function parseExecutionMode(options) { } function emitNote(line) { if (!line) return; - process5.stderr.write(`${line} + process6.stderr.write(`${line} `); } function emitRuntimeWarnings(result = {}) { if (!Array.isArray(result.warnings)) return; for (const warning of result.warnings) { if (typeof warning === "string" && warning.trim()) { - process5.stderr.write(`${warning.trim()} + process6.stderr.write(`${warning.trim()} `); } } @@ -10424,12 +11253,12 @@ function compactProviderResult(result = {}) { } return compact; } -function cleanupRuntimeOptions(runtimeOptions = {}) { +function cleanupRuntimeOptions2(runtimeOptions = {}) { const cleanupPaths = Array.isArray(runtimeOptions.cleanupPaths) ? runtimeOptions.cleanupPaths : []; for (const cleanupPath of cleanupPaths) { if (typeof cleanupPath !== "string" || cleanupPath.trim() === "") continue; try { - fs9.rmSync(cleanupPath, { recursive: true, force: true }); + fs10.rmSync(cleanupPath, { recursive: true, force: true }); } catch { } } @@ -10440,7 +11269,7 @@ function hydrateRuntimeOptions(runtimeOptions = {}) { } return { ...runtimeOptions, - env: { ...process5.env, ...runtimeOptions.env } + env: { ...process6.env, ...runtimeOptions.env } }; } async function runForegroundExecution(execution, asJson) { @@ -10494,7 +11323,7 @@ async function runForegroundExecution(execution, asJson) { } catch (error) { executionError = error; } finally { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } const resultOk = Boolean(result?.ok) && !executionError; const publicError = executionError?.message || result?.error || null; @@ -10561,9 +11390,9 @@ async function runForegroundExecution(execution, asJson) { return; } if (!result.ok) { - process5.stderr.write(`Error: ${String(result.error || `${execution.provider} ${execution.kind} failed`).slice(0, 500)} + process6.stderr.write(`Error: ${String(result.error || `${execution.provider} ${execution.kind} failed`).slice(0, 500)} `); - process5.exitCode = 1; + process6.exitCode = 1; return; } const lines = []; @@ -10590,7 +11419,7 @@ function buildQueuedJob(execution, workspaceRoot, attemptId) { updatedAt: now, invocationId: RUN_CONTEXT.invocationId, attemptId, - hostSessionId: process5.env[SESSION_ID_ENV] || null, + hostSessionId: process6.env[SESSION_ID_ENV] || null, providerSessionId: null, ...execution.jobMeta }; @@ -10736,7 +11565,7 @@ async function startBackgroundExecution(execution, asJson) { defaultModel: execution.defaultModel || null, logFile: job.logFile }); - writeJobConfigFile(workspaceRoot, job.jobId, { + const config = { workspaceRoot, hostSessionId: job.hostSessionId, execution: { @@ -10750,24 +11579,16 @@ async function startBackgroundExecution(execution, asJson) { }, jobId: job.jobId, runContext + }; + const { child } = await startBackgroundWorker({ + workspaceRoot, + job, + execution, + runContext, + config, + companionPath: COMPANION_PATH, + env: process6.env }); - fs9.writeFileSync(job.logFile, `[${(/* @__PURE__ */ new Date()).toISOString()}] started ${job.provider} ${job.kind} -`, { - encoding: "utf8", - mode: 384 - }); - const logFd = fs9.openSync(job.logFile, "a", 384); - const child = spawn2(process5.execPath, [COMPANION_PATH, "_job-worker", resolveJobConfigFile(workspaceRoot, job.jobId)], { - cwd: execution.cwd, - env: { ...process5.env }, - stdio: ["ignore", logFd, logFd], - detached: true - }); - child.once("error", (error) => { - recordBackgroundSpawnFailure(workspaceRoot, job.jobId, execution, runContext, error); - }); - child.unref(); - fs9.closeSync(logFd); const runningWrite = updateJobAtomically(workspaceRoot, job.jobId, (latest) => { if (!latest || latest.status !== "queued") return null; return { @@ -10780,10 +11601,10 @@ async function startBackgroundExecution(execution, asJson) { }; }); const runningJob = runningWrite.written ? runningWrite.job : getJob(workspaceRoot, job.jobId) || job; - if (!ACTIVE_JOB_STATUSES2.has(runningJob.status)) { + if (!ACTIVE_JOB_STATUSES3.has(runningJob.status)) { removeJobConfigFile(workspaceRoot, job.jobId); } - if (runContext && ACTIVE_JOB_STATUSES2.has(runningJob.status)) { + if (runContext && ACTIVE_JOB_STATUSES3.has(runningJob.status)) { await recordRunEventForContext(workspaceRoot, runContext, { command: execution.kind, kind: execution.kind, @@ -10811,7 +11632,7 @@ async function runSetup(rawArgs) { if (options["enable-review-gate"] && options["disable-review-gate"]) { throw new Error("Choose either --enable-review-gate or --disable-review-gate, not both."); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); if (options["enable-review-gate"]) { setConfig(workspaceRoot, "stopReviewGate", true); } else if (options["disable-review-gate"]) { @@ -10899,7 +11720,7 @@ async function probeProviderHealth({ report.probe.error = inspection.availabilityDetail || "provider CLI is unavailable"; } else if (provider === "claude") { try { - const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process5.cwd())); + const auth = await Promise.resolve(getProviderRuntime(provider).getAuthStatus(process6.cwd())); report.loggedIn = auth.loggedIn ?? false; report.authDetail = auth.detail ?? auth.reason ?? null; report.model = auth.model ?? report.model; @@ -10924,7 +11745,7 @@ async function probeProviderHealth({ prompt: `Reply with ${HEALTH_SENTINEL} only.`, model, defaultModel: model ? null : readCachedProviderModel(workspaceRoot, provider), - cwd: process5.cwd(), + cwd: process6.cwd(), timeout, kind: "health", measurementScope: "request", @@ -10996,7 +11817,7 @@ async function runHealth(rawArgs) { valueOptions: ["provider", "model", "timeout-ms"], aliasMap: { m: "model" } }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const timeoutMs = options["timeout-ms"] ? Number.parseInt(options["timeout-ms"], 10) : TIMEOUTS_MS.health; const timeout = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : TIMEOUTS_MS.health; const hasSingleProvider = Boolean(options.provider || positionals[0]); @@ -11084,7 +11905,7 @@ async function runHealth(rawArgs) { })); const payload = buildHealthPayload(results); if (!payload.anyHealthy) { - process5.exitCode = 2; + process6.exitCode = 2; } output( options.json ? payload : [ @@ -11106,7 +11927,7 @@ function parsePromptExecution(rawArgs, kind) { positionals }); validateEffort(options.effort); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const userPrompt = remainingPositionals.join(" ").trim(); if (!userPrompt) { throw new Error(`Missing prompt text for ${kind}.`); @@ -11123,7 +11944,7 @@ function parsePromptExecution(rawArgs, kind) { userPrompt, model: options.model || null, defaultModel: cachedDefaultModel, - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, kind, { model: options.model || null, defaultModel: cachedDefaultModel @@ -11173,7 +11994,7 @@ function buildStopReviewGateExecution(rawArgs) { if (!prompt) { throw new Error("Missing prompt text for stop-review-gate."); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const defaultModel = readCachedProviderModel(workspaceRoot, provider); return { options, @@ -11188,14 +12009,14 @@ function buildStopReviewGateExecution(rawArgs) { userPrompt: "stop-time review gate", model: null, defaultModel, - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, "review", { defaultModel }), meta: { stopReviewGate: true }, jobMeta: {}, measurementScope: "request", runtimeOptions: buildReviewRuntimeOptions({ provider, - cwd: process5.cwd() + cwd: process6.cwd() }) } }; @@ -11215,11 +12036,11 @@ function buildReviewExecution(rawArgs, { adversarial }) { positionals }); assertReviewProviderSupported(provider); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const focus = remainingPositionals.join(" ").trim(); const maxDiffBytes = parseMaxDiffBytes(options["max-diff-bytes"]); const reviewContext = collectReviewContext({ - cwd: process5.cwd(), + cwd: process6.cwd(), scope: options.scope, baseRef: options.base || null, maxDiffBytes @@ -11256,7 +12077,7 @@ function buildReviewExecution(rawArgs, { adversarial }) { userPrompt: focus || `${adversarial ? "adversarial " : ""}review ${reviewContext.scope}`, model: options.model || null, defaultModel: readCachedProviderModel(workspaceRoot, provider), - cwd: process5.cwd(), + cwd: process6.cwd(), timeout: resolveTimeoutMs(provider, adversarial ? "adversarial-review" : "review", { model: options.model || null, defaultModel: readCachedProviderModel(workspaceRoot, provider) @@ -11277,7 +12098,7 @@ function buildReviewExecution(rawArgs, { adversarial }) { measurementScope: "request", runtimeOptions: buildReviewRuntimeOptions({ provider, - cwd: process5.cwd() + cwd: process6.cwd() }) } }; @@ -11305,7 +12126,7 @@ async function runReviewCommand(rawArgs, { adversarial }) { ); return; } finally { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } } const { background } = parseExecutionMode(options); @@ -11320,7 +12141,7 @@ async function runStatus(rawArgs) { booleanOptions: ["json", "all", "wait"], valueOptions: ["job", "for", "timeout-ms"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const reference = options.job || positionals[0] || null; const selectorOptions = options.job ? { grammar: "explicit" } : void 0; const timeoutMs = options.wait ? parseStatusTimeoutMs(options["timeout-ms"]) : void 0; @@ -11329,7 +12150,7 @@ async function runStatus(rawArgs) { timeoutMs }); if (waited.waitTimedOut) { - process5.exitCode = 2; + process6.exitCode = 2; } if (options.json) { output(waited, true); @@ -11345,7 +12166,7 @@ async function runStatus(rawArgs) { for: options.for || "terminal" }); if (waited.waitTimedOut) { - process5.exitCode = 2; + process6.exitCode = 2; } if (waited.error && RUN_CONTEXT.outputMode === "json-v2") { throw cliError("job_not_found", `Job '${target.jobId}' was not found while waiting.`, { @@ -11386,7 +12207,7 @@ async function runResult(rawArgs) { booleanOptions: ["json"], valueOptions: ["job"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const job = resolveJobSelector( workspaceRoot, options.job || positionals[0] || "latest-terminal", @@ -11414,7 +12235,7 @@ async function runCancel(rawArgs) { booleanOptions: ["json"], valueOptions: ["job"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const selector = options.job || positionals[0] || "latest-active"; let job; try { @@ -11423,11 +12244,11 @@ async function runCancel(rawArgs) { if (RUN_CONTEXT.outputMode === "json-v2" || error.code === "ambiguous_selector") throw error; if (options.json) { output({ cancelled: false, reason: "not_found", jobId: positionals[0] || null }, true); - process5.exitCode = 1; + process6.exitCode = 1; return; } output(positionals[0] ? `Job ${positionals[0]} not found.` : "No active job found to cancel.", false); - process5.exitCode = 1; + process6.exitCode = 1; return; } const report = await cancelJob(workspaceRoot, job.jobId); @@ -11438,7 +12259,7 @@ async function runCancel(rawArgs) { } if (report.reason === "not_cancellable") { output(report, true); - process5.exitCode = 4; + process6.exitCode = 4; return; } throw new PolycliCliError({ @@ -11454,10 +12275,10 @@ async function runCancel(rawArgs) { output(`Cancelled job ${report.jobId}.`, false); } else if (report.reason === "not_cancellable") { output(`Job ${report.jobId} is already ${job.status}.`, false); - process5.exitCode = 4; + process6.exitCode = 4; } else { output(`Failed to cancel ${report.jobId}: ${report.error || report.reason}`, false); - process5.exitCode = 5; + process6.exitCode = 5; } } function formatMetric(metric) { @@ -11523,7 +12344,7 @@ async function runTiming(rawArgs) { booleanOptions: ["all", "json"], valueOptions: ["provider", "history"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const provider = options.provider ? resolveProvider({ provider: options.provider }).provider : null; const limit = parseHistoryLimit(options.history, { all: options.all }); const records = listTimingRecords(workspaceRoot, { @@ -11557,6 +12378,10 @@ async function runJobWorker(rawArgs) { const { workspaceRoot, execution, jobId, runContext } = payload; const current = claimBackgroundWorker(workspaceRoot, jobId); if (!current) { + if (!shouldRetainJobConfig(workspaceRoot, jobId)) { + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } return; } if (runContext?.runId) { @@ -11590,10 +12415,6 @@ async function runJobWorker(rawArgs) { appendPreview(current.logFile, execution.provider, event); } }); - if (result.timing) { - appendTimingRecord(workspaceRoot, result.timing); - } - cacheProviderModel(workspaceRoot, execution.provider, result.model); const compactResult = compactProviderResult(result); const sessionArtifactPath = resolveSessionArtifactPath( execution.provider, @@ -11601,9 +12422,13 @@ async function runJobWorker(rawArgs) { execution.cwd ); const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || latest.status === "cancelled" || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || latest.status === "cancelled" || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } + if (result.timing) { + appendTimingRecord(workspaceRoot, result.timing); + } + cacheProviderModel(workspaceRoot, execution.provider, result.model); const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); const finishedJob = { ...latest, @@ -11669,19 +12494,22 @@ async function runJobWorker(rawArgs) { // intent instead of exposing a terminal state with only half of its ledger pair. beforeStateCommit() { ensureTerminalRunEventsForContext(workspaceRoot, terminal); + removeJobStartFailureFile(workspaceRoot, jobId); } }; }); if (!write.written) { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } return; } removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } catch (error) { const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || latest.status === "cancelled" || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || latest.status === "cancelled" || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); @@ -11730,20 +12558,23 @@ async function runJobWorker(rawArgs) { }, beforeStateCommit() { ensureTerminalRunEventsForContext(workspaceRoot, terminal); + removeJobStartFailureFile(workspaceRoot, jobId); } }; }); if (!write.written) { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } return; } removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); throw error; } finally { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { - cleanupRuntimeOptions(execution.runtimeOptions); + cleanupRuntimeOptions2(execution.runtimeOptions); } } } @@ -11761,7 +12592,7 @@ function formatDebugRunsTable(runs) { return lines.join("\n"); } async function readDebugLedger({ raw = false } = {}) { - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); refreshJobsForLedgerRecovery(workspaceRoot); const events = await readRunLedgerEvents(workspaceRoot, { raw }); return { workspaceRoot, events }; @@ -11771,7 +12602,7 @@ async function runDebugTail(rawArgs) { booleanOptions: ["json", "wait"], valueOptions: ["after", "limit", "timeout-ms"] }); - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const result = await tailRunLedgerEvents(workspaceRoot, { runId: positionals[0] || null, after: options.after || null, @@ -11779,7 +12610,7 @@ async function runDebugTail(rawArgs) { wait: Boolean(options.wait), timeoutMs: options["timeout-ms"] == null ? void 0 : Number(options["timeout-ms"]) }); - if (result.waitTimedOut) process5.exitCode = 2; + if (result.waitTimedOut) process6.exitCode = 2; if (options.json) { output(result, true); return; @@ -11833,11 +12664,11 @@ function renderSessionsList(recorded, nonPurgeable = []) { } else { lines.push("Recorded upstream sessions (this workspace):"); for (const rec of recorded) { - const exists = fs9.existsSync(rec.sessionArtifactPath); + const exists = fs10.existsSync(rec.sessionArtifactPath); let size = ""; if (exists) { try { - size = ` ${formatBytes(fs9.lstatSync(rec.sessionArtifactPath).size)}`; + size = ` ${formatBytes(fs10.lstatSync(rec.sessionArtifactPath).size)}`; } catch { size = ""; } @@ -11875,7 +12706,7 @@ function renderPurgePlan(plan, summary, nonPurgeable = []) { return lines.join("\n"); } async function readSessionLedger() { - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); const events = await readRunLedgerEvents(workspaceRoot); const recorded = collectRecordedArtifacts(events); const nonPurgeable = collectNonPurgeableSessions(events); @@ -11954,10 +12785,10 @@ function commandResolutionError(fullArgs, resolution, hostSurface) { ); } async function main() { - const fullArgs = process5.argv.slice(2); + const fullArgs = process6.argv.slice(2); RUN_CONTEXT.invocationId = `inv_${randomUUID3().replaceAll("-", "").slice(0, 20)}`; RUN_CONTEXT.authoritativeJsonWritten = false; - RUN_CONTEXT.hostSurface = resolveHostSurface(process5.env, import.meta.url); + RUN_CONTEXT.hostSurface = resolveHostSurface(process6.env, import.meta.url); RUN_CONTEXT.rawArgs = fullArgs; const outputModes = scanOutputModes(fullArgs); const command = fullArgs[0]; @@ -12002,12 +12833,12 @@ async function main() { } if (definition2.runTracked) { try { - RUN_CONTEXT.runId = resolveRunId({ runId: explicitRunId }, process5.env); + RUN_CONTEXT.runId = resolveRunId({ runId: explicitRunId }, process6.env); } catch (error) { if (!/^Invalid run id:/.test(error?.message || "")) throw error; const typed = cliError("invalid_argument", error.message, { argument: "--run-id", - value: explicitRunId || process5.env.POLYCLI_RUN_ID || null + value: explicitRunId || process6.env.POLYCLI_RUN_ID || null }); typed.legacyCode = "error"; throw typed; @@ -12015,15 +12846,15 @@ async function main() { } else { RUN_CONTEXT.runId = null; } - RUN_CONTEXT.workspaceSlug = computeWorkspaceSlug(resolveWorkspaceRoot(process5.cwd())); + RUN_CONTEXT.workspaceSlug = computeWorkspaceSlug(resolveWorkspaceRoot(process6.cwd())); if (!RUN_CONTEXT.runId) { return dispatchCommand(definition2.id, rawArgs); } - const workspaceRoot = resolveWorkspaceRoot(process5.cwd()); + const workspaceRoot = resolveWorkspaceRoot(process6.cwd()); await recordRunEvent(workspaceRoot, { phase: "run_started", status: "started" }); try { const result = await dispatchCommand(definition2.id, rawArgs); - const failed = process5.exitCode != null && process5.exitCode !== 0; + const failed = process6.exitCode != null && process6.exitCode !== 0; try { await recordRunEvent(workspaceRoot, { phase: "run_summary", diff --git a/plugins/polycli/scripts/polycli-companion.mjs b/plugins/polycli/scripts/polycli-companion.mjs index e82e1cc..40c7691 100644 --- a/plugins/polycli/scripts/polycli-companion.mjs +++ b/plugins/polycli/scripts/polycli-companion.mjs @@ -4,7 +4,6 @@ import fs from "node:fs"; import path from "node:path"; import process from "node:process"; import { randomUUID } from "node:crypto"; -import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { parseArgs } from "@bbingz/polycli-utils/args"; @@ -33,11 +32,13 @@ import { import { buildStatusSnapshot, cancelJob, + hasPendingCancellationIntent, refreshJob, refreshJobsForLedgerRecovery, resolveJobSelector, waitForJob, } from "./lib/job-control.mjs"; +import { startBackgroundWorker } from "./lib/background-start.mjs"; import { buildPromptRuntimeOptions } from "./lib/prompt-runtime.mjs"; import { PROVIDER_IDS, resolveProvider } from "./lib/providers.mjs"; import { @@ -53,6 +54,7 @@ import { readJobConfigFile, readJobFile, removeJobConfigFile, + removeJobStartFailureFile, resolveJobConfigFile, resolveJobFile, resolveJobLogFile, @@ -63,7 +65,6 @@ import { setConfig, updateJobAtomically, upsertJob, - writeJobConfigFile, writeJobFile, } from "./lib/state.mjs"; import { @@ -222,12 +223,16 @@ function hasTerminalJobEnvelope(envelope) { return TERMINAL_JOB_STATUSES.has(envelope?.job?.status); } +function blocksBackgroundWorkerCommit(envelope) { + return hasTerminalJobEnvelope(envelope) || hasPendingCancellationIntent(envelope); +} + function claimBackgroundWorker(workspaceRoot, jobId) { const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { // The worker claims its own PID before it records any provider-facing event or invokes a // provider. This closes the parent spawn -> state-PID crash window: if cancellation won that // race, its terminal envelope makes this worker exit without doing work. - if (!latest || !ACTIVE_JOB_STATUSES.has(latest.status) || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || !ACTIVE_JOB_STATUSES.has(latest.status) || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } if (latest.pid != null && latest.pid !== process.pid) { @@ -250,71 +255,6 @@ function shouldRetainJobConfig(workspaceRoot, jobId) { return current?.status === "queued" || current?.status === "running"; } -function recordBackgroundSpawnFailure(workspaceRoot, jobId, execution, runContext, error) { - const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, jobId)); - try { - const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || !ACTIVE_JOB_STATUSES.has(latest.status) || hasTerminalJobEnvelope(storedEnvelope)) { - return null; - } - const finishedAt = new Date().toISOString(); - const failedJob = { - ...latest, - ...execution.jobMeta, - status: "failed", - pid: null, - finishedAt, - updatedAt: finishedAt, - error: error.message, - }; - const terminalReason = `${execution.kind}_failed`; - const terminalEvents = [ - { - command: runContext?.command || execution.kind, - kind: execution.kind, - provider: execution.provider, - phase: "attempt_result", - status: "failed", - reason: terminalReason, - attempt: { ordinal: 1 }, - jobId, - error: { message: String(error.message || error).slice(0, 300) }, - logFile: failedJob.logFile || null, - }, - { - command: runContext?.command || execution.kind, - kind: execution.kind, - provider: execution.provider, - phase: "provider_decision", - status: "failed", - reason: terminalReason, - jobId, - }, - ]; - const terminal = prepareTerminalRunEventsForContext(runContext, terminalEvents); - return { - job: failedJob, - envelope: { - job: failedJob, - result: { ok: false, error: error.message }, - terminalReason, - terminalDescriptor: terminal.terminalDescriptor, - }, - beforeStateCommit() { - ensureTerminalRunEventsForContext(workspaceRoot, terminal); - }, - }; - }); - if (write.written) { - cleanupRuntimeOptions(config?.execution?.runtimeOptions); - removeJobConfigFile(workspaceRoot, jobId); - } - } catch { - // A failed state/ledger write leaves the active job and, when the envelope write succeeded, - // a recoverable terminal intent. The next status refresh owns the retry. - } -} - async function recordRunEvent(workspaceRoot, base = {}) { return recordRunEventForContext(workspaceRoot, buildCurrentRunContext(), base); } @@ -1215,7 +1155,7 @@ async function startBackgroundExecution(execution, asJson) { defaultModel: execution.defaultModel || null, logFile: job.logFile, }); - writeJobConfigFile(workspaceRoot, job.jobId, { + const config = { workspaceRoot, hostSessionId: job.hostSessionId, execution: { @@ -1229,25 +1169,17 @@ async function startBackgroundExecution(execution, asJson) { }, jobId: job.jobId, runContext, + }; + const { child } = await startBackgroundWorker({ + workspaceRoot, + job, + execution, + runContext, + config, + companionPath: COMPANION_PATH, + env: process.env, }); - fs.writeFileSync(job.logFile, `[${new Date().toISOString()}] started ${job.provider} ${job.kind}\n`, { - encoding: "utf8", - mode: 0o600, - }); - const logFd = fs.openSync(job.logFile, "a", 0o600); - const child = spawn(process.execPath, [COMPANION_PATH, "_job-worker", resolveJobConfigFile(workspaceRoot, job.jobId)], { - cwd: execution.cwd, - env: { ...process.env }, - stdio: ["ignore", logFd, logFd], - detached: true, - }); - child.once("error", (error) => { - recordBackgroundSpawnFailure(workspaceRoot, job.jobId, execution, runContext, error); - }); - child.unref(); - fs.closeSync(logFd); - const runningWrite = updateJobAtomically(workspaceRoot, job.jobId, (latest) => { if (!latest || latest.status !== "queued") return null; return { @@ -2117,6 +2049,10 @@ async function runJobWorker(rawArgs) { // The parent may have been interrupted before writing the PID and a concurrent cancellation // won. Treat the terminal/non-owned state as a normal no-op rather than performing a late // provider call for a cancelled job. + if (!shouldRetainJobConfig(workspaceRoot, jobId)) { + removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); + } return; } @@ -2154,14 +2090,6 @@ async function runJobWorker(rawArgs) { }, }); - // A terminal status promises that all durable result, timing, and ledger writes - // are visible. Keep the job active until those writes complete so status --wait - // cannot race consumers that inspect or remove the state directory. - if (result.timing) { - appendTimingRecord(workspaceRoot, result.timing); - } - cacheProviderModel(workspaceRoot, execution.provider, result.model); - const compactResult = compactProviderResult(result); const sessionArtifactPath = resolveSessionArtifactPath( execution.provider, @@ -2169,9 +2097,17 @@ async function runJobWorker(rawArgs) { execution.cwd, ); const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || latest.status === "cancelled" || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || latest.status === "cancelled" || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } + // The state lock makes this the finalize-vs-cancel winner checkpoint. Only after the worker + // wins may it commit provider-derived timing/model side effects; if cancellation intent won, + // this callback returns without publishing any late provider material. + if (result.timing) { + appendTimingRecord(workspaceRoot, result.timing); + } + cacheProviderModel(workspaceRoot, execution.provider, result.model); + const finishedAt = new Date().toISOString(); const finishedJob = { ...latest, @@ -2243,19 +2179,26 @@ async function runJobWorker(rawArgs) { // intent instead of exposing a terminal state with only half of its ledger pair. beforeStateCommit() { ensureTerminalRunEventsForContext(workspaceRoot, terminal); + // The public terminal state is the observation boundary for start-failure recovery. + // Remove its stale sidecar only after the terminal ledger is durable, but before state + // publication, so no reader can observe completed/failed while recovery still appears + // pending. A crash here remains recoverable from the terminal envelope + ledger pair. + removeJobStartFailureFile(workspaceRoot, jobId); }, }; }); if (!write.written) { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } return; } removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } catch (error) { const write = updateJobAtomically(workspaceRoot, jobId, (latest, storedEnvelope) => { - if (!latest || latest.status === "cancelled" || hasTerminalJobEnvelope(storedEnvelope)) { + if (!latest || latest.status === "cancelled" || blocksBackgroundWorkerCommit(storedEnvelope)) { return null; } const finishedAt = new Date().toISOString(); @@ -2306,16 +2249,19 @@ async function runJobWorker(rawArgs) { }, beforeStateCommit() { ensureTerminalRunEventsForContext(workspaceRoot, terminal); + removeJobStartFailureFile(workspaceRoot, jobId); }, }; }); if (!write.written) { if (!shouldRetainJobConfig(workspaceRoot, jobId)) { removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); } return; } removeJobConfigFile(workspaceRoot, jobId); + removeJobStartFailureFile(workspaceRoot, jobId); throw error; } finally { // Cancellation deliberately keeps the public job active until its verified signal and ledger diff --git a/plugins/polycli/scripts/session-lifecycle-hook.mjs b/plugins/polycli/scripts/session-lifecycle-hook.mjs index 54b408e..7d71158 100644 --- a/plugins/polycli/scripts/session-lifecycle-hook.mjs +++ b/plugins/polycli/scripts/session-lifecycle-hook.mjs @@ -3,15 +3,15 @@ import fs from "node:fs"; import process from "node:process"; -import { isExpectedWorkerProcess } from "./lib/job-control.mjs"; +import { cancelJob } from "./lib/job-control.mjs"; import { - resolveJobConfigFile, + listJobs, resolveStateFile, resolveWorkspaceRoot, - updateState, } from "./lib/state.mjs"; export const SESSION_ID_ENV = "POLYCLI_COMPANION_SESSION_ID"; +export const SESSION_END_BUDGET_MS = 4_000; function readHookInput() { try { @@ -38,19 +38,6 @@ function appendEnvVar(name, value) { ); } -function terminateProcess(pid) { - if (!Number.isInteger(pid) || pid <= 1) return; - try { - process.kill(-pid, "SIGTERM"); - } catch { - try { - process.kill(pid, "SIGTERM"); - } catch { - // Process already gone. - } - } -} - function jobHostSessionId(job) { if (!job || typeof job !== "object") return null; if (Object.prototype.hasOwnProperty.call(job, "hostSessionId")) { @@ -62,47 +49,41 @@ function jobHostSessionId(job) { : null; } -export function cleanupSessionJobs(cwd, sessionId, { - isExpectedWorkerProcess: verifyWorker = isExpectedWorkerProcess, - terminateProcess: terminateWorker = terminateProcess, +export async function cleanupSessionJobs(cwd, sessionId, { + cancel = cancelJob, + isExpectedWorkerProcess: verifyWorker, + terminateProcess: terminateWorker, + isWorkerAlive, + budgetMs = SESSION_END_BUDGET_MS, } = {}) { - if (!cwd || !sessionId) return; + const cleanupStartedAt = Date.now(); + if (!cwd || !sessionId) return []; + if (!Number.isFinite(budgetMs) || budgetMs <= 0) { + throw new TypeError("budgetMs must be a positive number"); + } - const workspaceRoot = resolveWorkspaceRoot(cwd); + const deadlineAt = cleanupStartedAt + budgetMs; + const workspaceRoot = resolveWorkspaceRoot(cwd, { deadlineAt }); const stateFile = resolveStateFile(workspaceRoot); - if (!fs.existsSync(stateFile)) return; - - const workersToTerminate = []; - updateState(workspaceRoot, (state) => { - const jobs = Array.isArray(state.jobs) ? state.jobs : []; - const sessionJobs = jobs.filter((job) => jobHostSessionId(job) === sessionId); - if (sessionJobs.length === 0) return; - - for (const job of sessionJobs) { - if (job.status === "running" || job.status === "queued") { - workersToTerminate.push({ - pid: job.pid, - configFile: resolveJobConfigFile(workspaceRoot, job.jobId), - }); - } - } - - state.jobs = jobs.filter((job) => { - if (jobHostSessionId(job) !== sessionId) return true; - return job.status === "completed" - || job.status === "failed" - || job.status === "cancelled"; - }); - }); - - for (const { pid, configFile } of workersToTerminate) { - if (!Number.isInteger(pid) || pid <= 1 || !fs.existsSync(configFile)) continue; - if (verifyWorker(pid, configFile) !== true) continue; - terminateWorker(pid); - } + if (!fs.existsSync(stateFile)) return []; + + // Snapshot ownership only; cancelJob remains authoritative for every state, ledger, identity, + // termination, and cleanup transition. Running cancellations concurrently keeps SessionEnd + // within the hook timeout while each per-job state lock preserves serialization. + const active = listJobs(workspaceRoot).filter((job) => + (job.status === "running" || job.status === "queued") + && jobHostSessionId(job) === sessionId + ); + const cancelOptions = { + deadlineAt, + ...(verifyWorker ? { isExpectedWorker: verifyWorker } : {}), + ...(terminateWorker ? { terminate: terminateWorker } : {}), + ...(isWorkerAlive ? { isWorkerAlive } : {}), + }; + return Promise.allSettled(active.map((job) => cancel(workspaceRoot, job.jobId, cancelOptions))); } -export function handleLifecycleHook(eventName, input = {}) { +export async function handleLifecycleHook(eventName, input = {}) { if (eventName === "SessionStart") { appendEnvVar(SESSION_ID_ENV, input.session_id); return; @@ -111,23 +92,21 @@ export function handleLifecycleHook(eventName, input = {}) { if (eventName === "SessionEnd") { const cwd = input.cwd || process.cwd(); const sessionId = input.session_id || process.env[SESSION_ID_ENV]; - cleanupSessionJobs(cwd, sessionId); + await cleanupSessionJobs(cwd, sessionId); } } -function main() { +async function main() { const input = readHookInput(); const eventName = process.argv[2] ?? input.hook_event_name ?? ""; - handleLifecycleHook(eventName, input); + await handleLifecycleHook(eventName, input); } if (process.argv[1] && process.argv[1].endsWith("session-lifecycle-hook.mjs")) { - try { - main(); - } catch (err) { + main().catch((err) => { process.stderr.write( `[polycli session-lifecycle-hook] fatal: ${err && err.message ? err.message : String(err)}\n` ); - process.exit(1); - } + process.exitCode = 1; + }); } diff --git a/plugins/polycli/scripts/tests/background-start.test.mjs b/plugins/polycli/scripts/tests/background-start.test.mjs new file mode 100644 index 0000000..972dc0c --- /dev/null +++ b/plugins/polycli/scripts/tests/background-start.test.mjs @@ -0,0 +1,334 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { + recordBackgroundStartFailure, + startBackgroundWorker, +} from "../lib/background-start.mjs"; +import { refreshJob } from "../lib/job-control.mjs"; +import { readRunLedgerEvents } from "../lib/run-ledger.mjs"; +import { + ensureStateDir, + getJob, + readJobFile, + resolveJobConfigFile, + resolveJobFile, + resolveJobLogFile, + resolveJobStartFailureFile, + resolveStateDir, + resolveStateFile, + upsertJob, + writeJobFile, + writeJobStartFailureFile, +} from "../lib/state.mjs"; + +async function withBackgroundJob(name, callback) { + const workspaceRoot = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "polycli-bg-start-workspace-"))); + const stateRoot = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-bg-start-state-")); + const cleanupPath = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-bg-start-runtime-")); + const previous = process.env.POLYCLI_STATE_ROOT; + process.env.POLYCLI_STATE_ROOT = stateRoot; + const jobId = `job-${name}`; + const logFile = resolveJobLogFile(workspaceRoot, jobId); + const job = { + jobId, + provider: "qwen", + kind: "rescue", + status: "queued", + pid: null, + logFile, + }; + const execution = { + provider: "qwen", + kind: "rescue", + cwd: workspaceRoot, + runtimeOptions: { cleanupPaths: [cleanupPath] }, + jobMeta: {}, + }; + const runContext = { + runId: `run-${name}`, + invocationId: `inv-${name}`, + attemptId: `att-${name}`, + command: "rescue", + commands: ["rescue"], + hostSurface: "terminal", + argv: ["rescue", ""], + jobId, + provider: "qwen", + kind: "rescue", + logFile, + }; + const config = { + workspaceRoot, + jobId, + execution: { ...execution, measurementScope: "job" }, + runContext, + }; + ensureStateDir(workspaceRoot); + upsertJob(workspaceRoot, job); + try { + return await callback({ workspaceRoot, jobId, job, execution, runContext, config, logFile, cleanupPath }); + } finally { + if (previous == null) delete process.env.POLYCLI_STATE_ROOT; + else process.env.POLYCLI_STATE_ROOT = previous; + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + fs.rmSync(stateRoot, { recursive: true, force: true }); + fs.rmSync(cleanupPath, { recursive: true, force: true }); + } +} + +for (const fault of ["config", "log-write", "log-open", "spawn"]) { + test(`background start finalizes a synchronous ${fault} failure`, async () => { + await withBackgroundJob(fault, async (fixture) => { + let spawnCalls = 0; + let closeCalls = 0; + const injected = new Error(`injected ${fault} failure at /private/owned/runtime`); + const dependencies = { + ...(fault === "config" ? { writeConfigFile() { throw injected; } } : {}), + ...(fault === "log-write" ? { writeLogFile() { throw injected; } } : {}), + ...(fault === "log-open" ? { openLogFile() { throw injected; } } : {}), + closeLogFile(fd) { + closeCalls += 1; + fs.closeSync(fd); + }, + spawnWorker() { + spawnCalls += 1; + if (fault === "spawn") throw injected; + throw new Error(`spawn should not be reached for ${fault}`); + }, + }; + + await assert.rejects( + () => startBackgroundWorker({ + workspaceRoot: fixture.workspaceRoot, + job: fixture.job, + execution: fixture.execution, + runContext: fixture.runContext, + config: fixture.config, + companionPath: "/tmp/polycli-companion.mjs", + env: {}, + }, dependencies), + (error) => error === injected, + ); + + assert.equal(spawnCalls, fault === "spawn" ? 1 : 0); + assert.equal(closeCalls, fault === "spawn" ? 1 : 0); + assert.equal(getJob(fixture.workspaceRoot, fixture.jobId)?.status, "failed"); + const envelope = readJobFile(resolveJobFile(fixture.workspaceRoot, fixture.jobId)); + assert.equal(envelope?.job?.status, "failed"); + assert.equal(envelope?.result?.ok, false); + assert.match(envelope?.result?.error || "", //); + assert.doesNotMatch(envelope?.result?.error || "", /private\/owned/); + assert.equal(fs.existsSync(resolveJobConfigFile(fixture.workspaceRoot, fixture.jobId)), false); + assert.equal(fs.existsSync(fixture.cleanupPath), false); + const terminal = (await readRunLedgerEvents(fixture.workspaceRoot)).filter((event) => + ["attempt_result", "provider_decision"].includes(event.phase) + ); + assert.deepEqual(terminal.map((event) => [event.phase, event.status, event.reason]), [ + ["attempt_result", "failed", "rescue_failed"], + ["provider_decision", "failed", "rescue_failed"], + ]); + }); + }); +} + +test("background start does not publish failed when close throws after spawn succeeds", async () => { + await withBackgroundJob("close-after-spawn", async (fixture) => { + let errorHandler = null; + const child = { + pid: 4242, + once(event, handler) { + if (event === "error") errorHandler = handler; + }, + unref() {}, + }; + + const started = await startBackgroundWorker({ + workspaceRoot: fixture.workspaceRoot, + job: fixture.job, + execution: fixture.execution, + runContext: fixture.runContext, + config: fixture.config, + companionPath: "/tmp/polycli-companion.mjs", + env: {}, + }, { + openLogFile: () => 99, + spawnWorker: () => child, + closeLogFile() { + throw new Error("injected close failure"); + }, + }); + + assert.equal(started.child, child); + assert.match(started.closeWarning?.message || "", /close failure/); + assert.equal(typeof errorHandler, "function"); + assert.equal(getJob(fixture.workspaceRoot, fixture.jobId)?.status, "queued"); + assert.equal(readJobFile(resolveJobFile(fixture.workspaceRoot, fixture.jobId)), null); + assert.equal((await readRunLedgerEvents(fixture.workspaceRoot)).filter((event) => + ["attempt_result", "provider_decision"].includes(event.phase) + ).length, 0); + assert.equal(fs.existsSync(fixture.cleanupPath), true); + }); +}); + +test("background child async error uses the same terminal finalizer", async () => { + await withBackgroundJob("async-error", async (fixture) => { + let errorHandler = null; + const child = { + pid: 4242, + once(event, handler) { + if (event === "error") errorHandler = handler; + }, + unref() {}, + }; + await startBackgroundWorker({ + workspaceRoot: fixture.workspaceRoot, + job: fixture.job, + execution: fixture.execution, + runContext: fixture.runContext, + config: fixture.config, + companionPath: "/tmp/polycli-companion.mjs", + env: {}, + }, { + openLogFile: () => 99, + closeLogFile() {}, + spawnWorker: () => child, + }); + + errorHandler(new Error("async spawn error")); + + assert.equal(getJob(fixture.workspaceRoot, fixture.jobId)?.status, "failed"); + const terminal = (await readRunLedgerEvents(fixture.workspaceRoot)).filter((event) => + ["attempt_result", "provider_decision"].includes(event.phase) + ); + assert.equal(terminal.length, 2); + assert.equal(fs.existsSync(resolveJobConfigFile(fixture.workspaceRoot, fixture.jobId)), false); + assert.equal(fs.existsSync(fixture.cleanupPath), false); + }); +}); + +test("config failure remains recoverable when terminal ledger persistence is transiently unavailable", async () => { + await withBackgroundJob("config-ledger-retry", async (fixture) => { + const injected = new Error("original config failure"); + const ledgerFile = path.join(resolveStateDir(fixture.workspaceRoot), "run-ledger.ndjson"); + fs.mkdirSync(ledgerFile, { mode: 0o700 }); + + await assert.rejects( + () => startBackgroundWorker({ + workspaceRoot: fixture.workspaceRoot, + job: fixture.job, + execution: fixture.execution, + runContext: fixture.runContext, + config: fixture.config, + companionPath: "/tmp/polycli-companion.mjs", + env: {}, + }, { + writeConfigFile() { + throw injected; + }, + }), + (error) => error === injected, + ); + + assert.equal(getJob(fixture.workspaceRoot, fixture.jobId)?.status, "queued"); + assert.equal(readJobFile(resolveJobFile(fixture.workspaceRoot, fixture.jobId))?.job?.status, "failed"); + assert.equal(fs.existsSync(resolveJobConfigFile(fixture.workspaceRoot, fixture.jobId)), false); + assert.equal(fs.existsSync(fixture.cleanupPath), false, "owned runtime path is safe to clean because spawn was never reached"); + + fs.rmSync(ledgerFile, { recursive: true, force: true }); + const recovered = refreshJob(fixture.workspaceRoot, getJob(fixture.workspaceRoot, fixture.jobId)); + assert.equal(recovered.status, "failed"); + const terminal = (await readRunLedgerEvents(fixture.workspaceRoot)).filter((event) => + ["attempt_result", "provider_decision"].includes(event.phase) + ); + assert.deepEqual(terminal.map((event) => event.phase), ["attempt_result", "provider_decision"]); + }); +}); + +test("pre-envelope state-lock failure leaves a private safe sidecar that refresh finalizes exactly once", async () => { + await withBackgroundJob("state-lock-retry", async (fixture) => { + const injected = new Error("original spawn failure at /private/owned/runtime"); + const stateLock = `${resolveStateFile(fixture.workspaceRoot)}.lock`; + fs.writeFileSync(stateLock, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() }), { mode: 0o600 }); + + await assert.rejects( + () => startBackgroundWorker({ + workspaceRoot: fixture.workspaceRoot, + job: fixture.job, + execution: fixture.execution, + runContext: fixture.runContext, + config: fixture.config, + companionPath: "/tmp/polycli-companion.mjs", + env: {}, + failureFinalizationOptions: { lockOptions: { timeoutMs: 25, pollMs: 1 } }, + }, { + spawnWorker() { + throw injected; + }, + }), + (error) => error === injected, + ); + + const sidecarFile = resolveJobStartFailureFile(fixture.workspaceRoot, fixture.jobId); + const sidecarText = fs.readFileSync(sidecarFile, "utf8"); + const sidecar = JSON.parse(sidecarText); + assert.equal(fs.statSync(sidecarFile).mode & 0o777, 0o600); + assert.equal(sidecar.error, "original spawn failure at "); + assert.equal(sidecar.jobId, fixture.jobId); + assert.equal(sidecar.terminalDescriptor.jobId, fixture.jobId); + assert.doesNotMatch(sidecarText, /prompt:redacted|\"argv\"|\"env\"/); + assert.equal(getJob(fixture.workspaceRoot, fixture.jobId)?.status, "queued"); + assert.equal(readJobFile(resolveJobFile(fixture.workspaceRoot, fixture.jobId)), null); + assert.equal((await readRunLedgerEvents(fixture.workspaceRoot)).length, 0); + + fs.rmSync(stateLock, { force: true }); + const recovered = refreshJob(fixture.workspaceRoot, getJob(fixture.workspaceRoot, fixture.jobId)); + assert.equal(recovered.status, "failed"); + assert.equal(fs.existsSync(sidecarFile), false); + assert.equal(fs.existsSync(resolveJobConfigFile(fixture.workspaceRoot, fixture.jobId)), false); + assert.equal(fs.existsSync(fixture.cleanupPath), false); + refreshJob(fixture.workspaceRoot, recovered); + const terminal = (await readRunLedgerEvents(fixture.workspaceRoot)).filter((event) => + ["attempt_result", "provider_decision"].includes(event.phase) + ); + assert.deepEqual(terminal.map((event) => event.phase), ["attempt_result", "provider_decision"]); + }); +}); + +test("a late start-failure finalizer removes its sidecar without overwriting a terminal winner", async () => { + await withBackgroundJob("terminal-winner", async (fixture) => { + const finishedJob = { + ...getJob(fixture.workspaceRoot, fixture.jobId), + status: "completed", + pid: null, + finishedAt: new Date().toISOString(), + }; + upsertJob(fixture.workspaceRoot, finishedJob); + writeJobFile(fixture.workspaceRoot, fixture.jobId, { + job: finishedJob, + result: { ok: true, response: "winner" }, + }); + writeJobStartFailureFile(fixture.workspaceRoot, fixture.jobId, { + version: 1, + jobId: fixture.jobId, + error: "stale failure", + }); + + const report = recordBackgroundStartFailure( + fixture.workspaceRoot, + fixture.jobId, + fixture.execution, + fixture.runContext, + new Error("late async child error"), + ); + + assert.equal(report.written, false); + assert.equal(fs.existsSync(resolveJobStartFailureFile(fixture.workspaceRoot, fixture.jobId)), false); + const winner = readJobFile(resolveJobFile(fixture.workspaceRoot, fixture.jobId)); + assert.equal(winner.job.status, "completed"); + assert.equal(winner.result.response, "winner"); + }); +}); diff --git a/plugins/polycli/scripts/tests/cli-contract.test.mjs b/plugins/polycli/scripts/tests/cli-contract.test.mjs index c4f7a4a..06f17c0 100644 --- a/plugins/polycli/scripts/tests/cli-contract.test.mjs +++ b/plugins/polycli/scripts/tests/cli-contract.test.mjs @@ -217,6 +217,22 @@ test("serializeV2Result covers provider setup, health, execution, and background assert.equal("pid" in started.job, false); }); +test("background provider commands without a created job serialize their actual provider result", () => { + const skipped = serializeV2Result("review", { + ok: true, + provider: "cmd", + verdict: "no_changes", + diff: { scope: "auto", source: "none", bytes: 0 }, + }, { background: true }); + + assert.equal(skipped.type, "provider.execution"); + assert.equal(skipped.execution.provider, "cmd"); + assert.equal(skipped.execution.kind, "review"); + assert.equal(skipped.providerResult.ok, true); + assert.equal(skipped.providerResult.verdict, "no_changes"); + assert.equal("job" in skipped, false); +}); + test("a normal provider result with inner ok false remains a successful v2 envelope", () => { const result = serializeV2Result("rescue", { provider: "qwen", diff --git a/plugins/polycli/scripts/tests/command-registry.test.mjs b/plugins/polycli/scripts/tests/command-registry.test.mjs index 4a9a4f5..963eac5 100644 --- a/plugins/polycli/scripts/tests/command-registry.test.mjs +++ b/plugins/polycli/scripts/tests/command-registry.test.mjs @@ -144,6 +144,9 @@ test("agent-facing effects describe provider and recovery side effects honestly" ); } assert.equal(getCommandDefinition(["debug", "tail"]).effects.writesLocalState, false); + const tui = getCommandDefinition(["tui"]); + assert.equal(tui.effects.writesLocalState, true, "normal TUI loading may recover stale jobs"); + assert.doesNotMatch(tui.summary, /read-only/i); }); test("registry validation covers every declared uniqueness and safety invariant", () => { @@ -214,3 +217,24 @@ test("registered strict parsing preserves literal option-looking prompt tokens a assert.equal(parsed.options.provider, "qwen"); assert.deepEqual(parsed.positionals, ["--modle", "is", "literal"]); }); + +test("setup and health reject positional plus explicit provider targets even when identical", () => { + for (const command of ["setup", "health"]) { + for (const positional of ["qwen", "claude"]) { + assert.throws( + () => parseCommandArgs( + getCommandDefinition([command]), + [positional, "--provider", "qwen"], + { enumSources: { providers: ["claude", "qwen"] } }, + ), + (error) => { + assert.equal(error.code, "invalid_argument"); + assert.equal(error.data.positionalProvider, positional); + assert.equal(error.data.explicitProvider, "qwen"); + assert.match(error.message, /positional provider.*--provider/i); + return true; + }, + ); + } + } +}); diff --git a/plugins/polycli/scripts/tests/hooks.test.mjs b/plugins/polycli/scripts/tests/hooks.test.mjs index cb0543e..df585ad 100644 --- a/plugins/polycli/scripts/tests/hooks.test.mjs +++ b/plugins/polycli/scripts/tests/hooks.test.mjs @@ -6,8 +6,21 @@ import path from "node:path"; import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; -import { ensureStateDir, resolveStateFile } from "../lib/state.mjs"; -import { cleanupSessionJobs, handleLifecycleHook } from "../session-lifecycle-hook.mjs"; +import { + ensureStateDir, + listJobs, + readJobFile, + resolveJobConfigFile, + resolveJobFile, + resolveStateFile, + writeJobConfigFile, +} from "../lib/state.mjs"; +import { readRunLedgerEvents } from "../lib/run-ledger.mjs"; +import { + cleanupSessionJobs, + handleLifecycleHook, + SESSION_END_BUDGET_MS, +} from "../session-lifecycle-hook.mjs"; import { parseStopReviewOutput, resolveReviewProvider, @@ -104,6 +117,10 @@ test("hooks.json registers SessionStart, SessionEnd, and Stop hooks with legacy 'node "${CLAUDE_PLUGIN_ROOT}/scripts/session-lifecycle-hook.mjs" SessionEnd' ); assert.equal(hooks.hooks.SessionEnd[0].hooks[0].timeout, 5); + assert.ok( + SESSION_END_BUDGET_MS < hooks.hooks.SessionEnd[0].hooks[0].timeout * 1_000, + "the in-process cancellation deadline must leave time for hook startup and exit", + ); assert.equal( hooks.hooks.Stop[0].hooks[0].command, 'node "${CLAUDE_PLUGIN_ROOT}/scripts/stop-review-gate-hook.mjs"' @@ -126,8 +143,8 @@ test("SessionStart exports the Claude session id for later companion jobs", asyn } }); -test("SessionEnd removes only running jobs from the ended session and preserves terminal results", () => { - withPluginData(() => { +test("SessionEnd cancels only active jobs from the ended session and preserves terminal results", async () => { + await withPluginData(async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-workspace-")); try { writeWorkspaceState(workspaceRoot, { @@ -139,18 +156,21 @@ test("SessionEnd removes only running jobs from the ended session and preserves ], }); - handleLifecycleHook("SessionEnd", { cwd: workspaceRoot, session_id: "cc-session-1" }); + await handleLifecycleHook("SessionEnd", { cwd: workspaceRoot, session_id: "cc-session-1" }); const state = JSON.parse(fs.readFileSync(resolveStateFile(workspaceRoot), "utf8")); - assert.deepEqual(state.jobs.map((job) => job.jobId).sort(), ["pa-done", "pa-other"]); + assert.deepEqual( + state.jobs.map((job) => [job.jobId, job.status]).sort(), + [["pa-done", "completed"], ["pa-other", "running"], ["pa-running", "cancelled"]], + ); } finally { fs.rmSync(workspaceRoot, { recursive: true, force: true }); } }); }); -test("SessionEnd matches explicit hostSessionId and never matches providerSessionId", () => { - withPluginData(() => { +test("SessionEnd matches explicit hostSessionId and never matches providerSessionId", async () => { + await withPluginData(async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-workspace-")); try { writeWorkspaceState(workspaceRoot, { @@ -175,18 +195,21 @@ test("SessionEnd matches explicit hostSessionId and never matches providerSessio ], }); - handleLifecycleHook("SessionEnd", { cwd: workspaceRoot, session_id: "ended-host" }); + await handleLifecycleHook("SessionEnd", { cwd: workspaceRoot, session_id: "ended-host" }); const state = JSON.parse(fs.readFileSync(resolveStateFile(workspaceRoot), "utf8")); - assert.deepEqual(state.jobs.map((job) => job.jobId), ["provider-only-match"]); + assert.deepEqual( + state.jobs.map((job) => [job.jobId, job.status]).sort(), + [["host-match", "cancelled"], ["provider-only-match", "running"]], + ); } finally { fs.rmSync(workspaceRoot, { recursive: true, force: true }); } }); }); -test("SessionEnd does not try to terminate unsafe pid values", (t) => { - withPluginData(() => { +test("SessionEnd does not try to terminate unsafe pid values", async (t) => { + await withPluginData(async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-workspace-")); const kill = t.mock.method(process, "kill", () => { throw new Error("process.kill should not be called for unsafe pid values"); @@ -202,7 +225,7 @@ test("SessionEnd does not try to terminate unsafe pid values", (t) => { ], }); - handleLifecycleHook("SessionEnd", { cwd: workspaceRoot, session_id: "cc-session-1" }); + await handleLifecycleHook("SessionEnd", { cwd: workspaceRoot, session_id: "cc-session-1" }); assert.equal(kill.mock.callCount(), 0); } finally { @@ -211,10 +234,11 @@ test("SessionEnd does not try to terminate unsafe pid values", (t) => { }); }); -test("SessionEnd signals only a worker whose command line matches its retained config", (t) => { - withPluginData(() => { +test("SessionEnd signals only a worker whose command line matches its retained config", async () => { + await withPluginData(async () => { const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-workspace-")); const terminated = []; + const observedDeadlines = []; try { writeWorkspaceState(workspaceRoot, { version: 2, @@ -227,32 +251,280 @@ test("SessionEnd signals only a worker whose command line matches its retained c fs.mkdirSync(jobsDir, { recursive: true }); fs.writeFileSync(path.join(jobsDir, "verified-worker.config.json"), "{}\n", "utf8"); - cleanupSessionJobs(workspaceRoot, "ended", { - isExpectedWorkerProcess(pid) { + const alive = new Map([[4242, true], [4343, true]]); + await cleanupSessionJobs(workspaceRoot, "ended", { + isExpectedWorkerProcess(pid, _configFile, options) { + observedDeadlines.push(options?.deadlineAt); return pid === 4343; }, - terminateProcess(pid) { + isWorkerAlive(pid) { + return alive.get(pid) === true; + }, + async terminateProcess(pid, options) { terminated.push(pid); + observedDeadlines.push(options?.deadlineAt); + alive.set(pid, false); }, }); assert.deepEqual(terminated, [4343]); + assert.equal(observedDeadlines.length, 2); + assert.ok(observedDeadlines.every((deadline) => Number.isFinite(deadline))); + assert.equal(new Set(observedDeadlines).size, 1, "identity and termination must share one hook deadline"); const state = JSON.parse(fs.readFileSync(resolveStateFile(workspaceRoot), "utf8")); - assert.deepEqual(state.jobs, []); + assert.deepEqual( + state.jobs.map((job) => [job.jobId, job.status]).sort(), + [["mismatched-worker", "running"], ["verified-worker", "cancelled"]], + ); + assert.equal(readJobFile(resolveJobFile(workspaceRoot, "mismatched-worker"))?.cancellationIntent?.status, "requested"); } finally { fs.rmSync(workspaceRoot, { recursive: true, force: true }); } }); }); -test("SessionEnd state cleanup uses the locked state updater", () => { +test("SessionEnd delegates lifecycle ownership to cancelJob", () => { const source = fs.readFileSync(lifecycleHookPath, "utf8"); - assert.match(source, /\bupdateState\b/); + assert.match(source, /\bcancelJob\b/); + assert.doesNotMatch(source, /\bupdateState\b/); assert.doesNotMatch(source, /\bloadState\b/); assert.doesNotMatch(source, /\bsaveState\b/); }); +test("SessionEnd preserves unverifiable workers while cancelling other matching jobs", async () => { + await withPluginData(async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-workspace-")); + const jobs = [ + { jobId: "identity-false", pid: 4201 }, + { jobId: "identity-null", pid: 4202 }, + { jobId: "missing-config", pid: 4203 }, + { jobId: "terminate-throws", pid: 4204 }, + { jobId: "verified-success", pid: 4205 }, + ]; + try { + writeWorkspaceState(workspaceRoot, { + version: 2, + jobs: [ + ...jobs.map((job) => ({ ...job, hostSessionId: "ended", provider: "qwen", kind: "rescue", status: "running" })), + { jobId: "other-session", hostSessionId: "other", status: "running", pid: null }, + { jobId: "already-done", hostSessionId: "ended", status: "completed", pid: null }, + ], + }); + for (const job of jobs.filter((entry) => entry.jobId !== "missing-config")) { + writeJobConfigFile(workspaceRoot, job.jobId, { + workspaceRoot, + jobId: job.jobId, + execution: { provider: "qwen", kind: "rescue" }, + runContext: { + runId: `run-${job.jobId}`, + command: "rescue", + hostSurface: "terminal", + jobId: job.jobId, + provider: "qwen", + kind: "rescue", + }, + }); + } + const alive = new Map(jobs.map((job) => [job.pid, true])); + const terminated = []; + + await cleanupSessionJobs(workspaceRoot, "ended", { + isWorkerAlive(pid) { + return alive.get(pid) === true; + }, + isExpectedWorkerProcess(pid) { + if (pid === 4201) return false; + if (pid === 4202) return null; + return true; + }, + async terminateProcess(pid) { + terminated.push(pid); + if (pid === 4204) throw new Error("injected terminate failure"); + alive.set(pid, false); + }, + }); + + assert.deepEqual(terminated.sort(), [4204, 4205]); + const byId = new Map(listJobs(workspaceRoot).map((job) => [job.jobId, job])); + for (const jobId of ["identity-false", "identity-null", "missing-config", "terminate-throws"]) { + assert.equal(byId.get(jobId)?.status, "running", jobId); + assert.equal(readJobFile(resolveJobFile(workspaceRoot, jobId))?.cancellationIntent?.status, "requested", jobId); + } + assert.equal(byId.get("verified-success")?.status, "cancelled"); + assert.equal(byId.get("other-session")?.status, "running"); + assert.equal(byId.get("already-done")?.status, "completed"); + assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, "identity-false")), true); + assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, "identity-null")), true); + assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, "terminate-throws")), true); + assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, "verified-success")), false); + + const terminal = (await readRunLedgerEvents(workspaceRoot)).filter((event) => + ["attempt_result", "provider_decision"].includes(event.phase) + ); + assert.deepEqual(terminal.map((event) => [event.runId, event.phase, event.status]), [ + ["run-verified-success", "attempt_result", "cancelled"], + ["run-verified-success", "provider_decision", "cancelled"], + ]); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); +}); + +test("SessionEnd CLI awaits authoritative cancellation before exiting", async () => { + await withPluginData(async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-workspace-")); + try { + writeWorkspaceState(workspaceRoot, { + version: 2, + jobs: [ + { jobId: "cli-session-end", hostSessionId: "ended-cli", provider: "qwen", kind: "rescue", status: "queued", pid: null }, + ], + }); + + const result = await runNode(lifecycleHookPath, ["SessionEnd"], { + cwd: workspaceRoot, + input: JSON.stringify({ cwd: workspaceRoot, session_id: "ended-cli" }), + }); + + assert.equal(result.code, 0, result.stderr); + assert.equal(listJobs(workspaceRoot).find((job) => job.jobId === "cli-session-end")?.status, "cancelled"); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); +}); + +test("SessionEnd bounds state-lock contention by one shared deadline without publishing terminal state", async () => { + await withPluginData(async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-workspace-")); + const lockFile = `${resolveStateFile(workspaceRoot)}.lock`; + try { + writeWorkspaceState(workspaceRoot, { + version: 2, + jobs: [ + { jobId: "locked-one", hostSessionId: "ended", provider: "qwen", kind: "rescue", status: "queued", pid: null }, + { jobId: "locked-two", hostSessionId: "ended", provider: "qwen", kind: "rescue", status: "queued", pid: null }, + ], + }); + fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() }), { mode: 0o600 }); + + const startedAt = Date.now(); + await cleanupSessionJobs(workspaceRoot, "ended", { budgetMs: 60 }); + const elapsedMs = Date.now() - startedAt; + + assert.ok(elapsedMs < 300, `cleanup exceeded its shared budget: ${elapsedMs}ms`); + assert.deepEqual(listJobs(workspaceRoot).map((job) => [job.jobId, job.status]).sort(), [ + ["locked-one", "queued"], + ["locked-two", "queued"], + ]); + assert.equal(readJobFile(resolveJobFile(workspaceRoot, "locked-one")), null); + assert.equal(readJobFile(resolveJobFile(workspaceRoot, "locked-two")), null); + assert.equal((await readRunLedgerEvents(workspaceRoot)).length, 0); + } finally { + fs.rmSync(lockFile, { force: true }); + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); +}); + +test("SessionEnd bounds ledger-lock contention and leaves cancellation retryable", async () => { + await withPluginData(async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-workspace-")); + try { + writeWorkspaceState(workspaceRoot, { + version: 2, + jobs: [{ + jobId: "ledger-locked", + hostSessionId: "ended", + provider: "qwen", + kind: "rescue", + status: "queued", + pid: null, + }], + }); + writeJobConfigFile(workspaceRoot, "ledger-locked", { + workspaceRoot, + jobId: "ledger-locked", + execution: { provider: "qwen", kind: "rescue" }, + runContext: { + runId: "run-ledger-locked", + command: "rescue", + hostSurface: "terminal", + jobId: "ledger-locked", + provider: "qwen", + kind: "rescue", + }, + }); + const ledgerFile = path.join(path.dirname(resolveStateFile(workspaceRoot)), "run-ledger.ndjson"); + const ledgerLock = `${ledgerFile}.lock`; + fs.writeFileSync(ledgerLock, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() }), { mode: 0o600 }); + + const startedAt = Date.now(); + await cleanupSessionJobs(workspaceRoot, "ended", { budgetMs: 60 }); + assert.ok(Date.now() - startedAt < 300); + assert.equal(listJobs(workspaceRoot)[0]?.status, "queued"); + assert.equal((await readRunLedgerEvents(workspaceRoot)).length, 0); + assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, "ledger-locked")), true); + + fs.rmSync(ledgerLock, { force: true }); + await cleanupSessionJobs(workspaceRoot, "ended", { budgetMs: 500 }); + assert.equal(listJobs(workspaceRoot)[0]?.status, "cancelled"); + const terminal = (await readRunLedgerEvents(workspaceRoot)).filter((event) => + ["attempt_result", "provider_decision"].includes(event.phase) + ); + assert.deepEqual(terminal.map((event) => event.phase), ["attempt_result", "provider_decision"]); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); +}); + +test("SessionEnd applies one deadline to multiple slow terminations and keeps every job active", async () => { + await withPluginData(async () => { + const workspaceRoot = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-workspace-")); + const jobs = [4601, 4602, 4603].map((pid) => ({ + jobId: `slow-${pid}`, + hostSessionId: "ended", + provider: "qwen", + kind: "rescue", + status: "running", + pid, + })); + try { + writeWorkspaceState(workspaceRoot, { version: 2, jobs }); + for (const job of jobs) { + writeJobConfigFile(workspaceRoot, job.jobId, { + workspaceRoot, + jobId: job.jobId, + execution: { provider: "qwen", kind: "rescue" }, + runContext: { runId: `run-${job.jobId}`, command: "rescue", jobId: job.jobId }, + }); + } + + const startedAt = Date.now(); + await cleanupSessionJobs(workspaceRoot, "ended", { + budgetMs: 500, + isWorkerAlive: () => true, + isExpectedWorkerProcess: () => true, + terminateProcess: () => new Promise(() => {}), + }); + const elapsedMs = Date.now() - startedAt; + + assert.ok(elapsedMs < 1_000, `multiple termination waits exceeded one budget: ${elapsedMs}ms`); + for (const job of listJobs(workspaceRoot)) { + assert.equal(job.status, "running", job.jobId); + assert.equal(readJobFile(resolveJobFile(workspaceRoot, job.jobId))?.cancellationIntent?.status, "requested"); + assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, job.jobId)), true); + } + assert.equal((await readRunLedgerEvents(workspaceRoot)).length, 0); + } finally { + fs.rmSync(workspaceRoot, { recursive: true, force: true }); + } + }); +}); + test("parseStopReviewOutput scans all lines for a prose-prefixed BLOCK sentinel", () => { const result = parseStopReviewOutput("好的,这是审查:\nThe work is not done yet.\nBLOCK: tests were not run"); diff --git a/plugins/polycli/scripts/tests/integration.test.mjs b/plugins/polycli/scripts/tests/integration.test.mjs index 110d14f..e0f61d0 100644 --- a/plugins/polycli/scripts/tests/integration.test.mjs +++ b/plugins/polycli/scripts/tests/integration.test.mjs @@ -8,21 +8,26 @@ import { fileURLToPath } from "node:url"; import { ensureStateDir, + listJobs, readLastUsedProvider, readJobFile, resolveJobConfigFile, resolveJobFile, resolveJobLogFile, + resolveJobStartFailureFile, resolveStateDir, resolveWorkspaceRoot, upsertJob, writeJobConfigFile, + writeJobFile, + writeJobStartFailureFile, } from "../lib/state.mjs"; import { appendRunLedgerEvent, readRunLedgerEvents } from "../lib/run-ledger.mjs"; import { resolveTimingHistoryFile } from "../lib/timing.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const companionPath = path.resolve(__dirname, "..", "polycli-companion.bundle.mjs"); +const sourceCompanionPath = path.resolve(__dirname, "..", "polycli-companion.mjs"); function createFakeQwenBin() { const root = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-fake-qwen-")); @@ -80,7 +85,9 @@ async function waitForResultGate() { } (async () => { logEvent("start"); - process.stdout.write(JSON.stringify({ type: "system", subtype: "init", session_id: "11111111-1111-1111-1111-111111111111", model: "qwen-test" }) + "\\n"); + if (process.env.QWEN_OMIT_SESSION_ID !== "1") { + process.stdout.write(JSON.stringify({ type: "system", subtype: "init", session_id: "11111111-1111-1111-1111-111111111111", model: "qwen-test" }) + "\\n"); + } if (delay > 0) await sleep(delay); if (useTool) { process.stdout.write(JSON.stringify({ type: "assistant", message: { content: [{ type: "tool_use", id: "tool-1", name: "shell", input: { cmd: "pwd" } }] } }) + "\\n"); @@ -519,9 +526,9 @@ function cleanEnv(extra = {}) { return { ...env, ...extra }; } -function runCompanion(args, { cwd, env, timeout = 30_000 } = {}) { +function runCompanion(args, { cwd, env, timeout = 30_000, companion = companionPath } = {}) { return new Promise((resolve, reject) => { - const child = spawn("node", [companionPath, ...args], { + const child = spawn("node", [companion, ...args], { cwd, env, }); @@ -585,6 +592,18 @@ function createReviewWorkspace() { }; } +function createLargeReviewWorkspace() { + const workspace = createReviewWorkspace(); + fs.writeFileSync( + path.join(workspace.cwd, "large-review.txt"), + `LARGE_REVIEW_DIFF_MARKER_${"x".repeat(220_000)}\n`, + "utf8" + ); + gitSync(workspace.cwd, ["add", "large-review.txt"]); + gitSync(workspace.cwd, ["commit", "--amend", "--no-edit"]); + return workspace; +} + async function waitForTerminalJob(jobId, context) { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { @@ -1585,6 +1604,90 @@ test("integration: review --background preserves kimi runtime options and stored } }); +test("integration: source review keeps unlimited context but preflights argv-only providers", async () => { + const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); + const reviewWorkspace = createLargeReviewWorkspace(); + const argLog = path.join(pluginData, "qwen-large-review-argv.jsonl"); + const fake = createFakeQwenBin(); + const proseUuid = "423e4567-e89b-42d3-a456-426614174000"; + const previousPluginData = process.env.CLAUDE_PLUGIN_DATA; + try { + process.env.CLAUDE_PLUGIN_DATA = pluginData; + const baseEnv = cleanEnv({ + CLAUDE_PLUGIN_DATA: pluginData, + QWEN_CLI_BIN: fake.bin, + QWEN_ARGV_LOG: argLog, + QWEN_FIXED_REPLY: `answer mentions ${proseUuid}`, + QWEN_OMIT_SESSION_ID: "1", + }); + const defaultRunId = "qwen_large_review_default"; + const defaultReview = await runCompanion( + ["review", "--provider", "qwen", "--base", "HEAD~1", "--scope", "branch", "--json", "regressions only"], + { + cwd: reviewWorkspace.cwd, + env: { ...baseEnv, POLYCLI_RUN_ID: defaultRunId }, + companion: sourceCompanionPath, + } + ); + assert.equal(defaultReview.code, 0, defaultReview.stderr); + const rejected = JSON.parse(defaultReview.stdout); + assert.equal(rejected.ok, false); + assert.equal(rejected.meta.truncated, false); + assert.equal(rejected.spawnErrorCode, "E2BIG"); + assert.equal(rejected.errorCode, "argument_list_too_long"); + assert.match(rejected.error, /--max-diff-bytes/); + assert.equal(fs.existsSync(argLog), false, "oversized default review must be rejected before provider spawn"); + + const rejectedTerminal = (await readRunLedgerEvents(reviewWorkspace.cwd)) + .find((event) => event.runId === defaultRunId && event.phase === "attempt_result"); + assert.equal(rejectedTerminal?.status, "failed"); + assert.equal(rejectedTerminal?.errorCode, "argument_list_too_long"); + assert.equal(rejectedTerminal?.failureClass, "argument_list_too_long"); + + const cappedRunId = "qwen_large_review_capped"; + const cappedReview = await runCompanion( + [ + "review", + "--provider", + "qwen", + "--base", + "HEAD~1", + "--scope", + "branch", + "--max-diff-bytes", + "1024", + "--json", + "regressions only", + ], + { + cwd: reviewWorkspace.cwd, + env: { ...baseEnv, POLYCLI_RUN_ID: cappedRunId }, + companion: sourceCompanionPath, + } + ); + assert.equal(cappedReview.code, 0, cappedReview.stderr); + const capped = JSON.parse(cappedReview.stdout); + assert.equal(capped.ok, true); + assert.equal(capped.meta.truncated, true); + assert.match(capped.meta.truncationNotice, /1024 bytes/); + assert.equal(capped.response, `answer mentions ${proseUuid}`); + assert.equal(capped.sessionId, null); + + const logged = readJsonLine(argLog); + assert.equal(logged.argv.some((arg) => arg.includes("LARGE_REVIEW_DIFF_MARKER_")), true); + const cappedTerminal = (await readRunLedgerEvents(reviewWorkspace.cwd)) + .find((event) => event.runId === cappedRunId && event.phase === "attempt_result"); + assert.equal(cappedTerminal?.status, "completed"); + assert.equal(cappedTerminal?.providerSessionId, null); + } finally { + if (previousPluginData == null) delete process.env.CLAUDE_PLUGIN_DATA; + else process.env.CLAUDE_PLUGIN_DATA = previousPluginData; + fake.cleanup(); + reviewWorkspace.cleanup(); + fs.rmSync(pluginData, { recursive: true, force: true }); + } +}); + test("integration: review --background preserves qwen runtime options and stored response", async () => { const pluginData = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-plugin-data-")); const reviewWorkspace = createReviewWorkspace(); @@ -2602,6 +2705,30 @@ test("integration: review with no changes writes no_changes skipped decision", a ); }); +test("integration: background JSON v2 review with no changes reports the skipped result and creates no job", async (t) => { + const context = createLedgerContext(t); + gitInitSync(context.cwd); + const result = await runCompanion( + ["review", "--provider", "cmd", "--background", "--json-v2", "--run-id", "run-clean-background"], + context, + ); + assert.equal(result.code, 0, result.stderr); + const payload = JSON.parse(result.stdout); + assert.equal(payload.ok, true); + assert.equal(payload.result.type, "provider.execution"); + assert.equal(payload.result.execution.provider, "cmd"); + assert.equal(payload.result.providerResult.verdict, "no_changes"); + assert.equal("job" in payload.result, false); + assert.deepEqual(listJobs(context.cwd), []); + + const events = (await readRunLedgerEvents(context.cwd)) + .filter((event) => event.runId === "run-clean-background"); + const decisions = events.filter((event) => event.phase === "provider_decision"); + assert.equal(decisions.length, 1); + assert.equal(decisions[0].status, "skipped"); + assert.equal(decisions[0].reason, "no_changes"); +}); + test("integration: gemini no-changes review cleans up isolated cwd", async (t) => { const context = createLedgerContext(t); const fake = createFakeGeminiBin(); @@ -3064,6 +3191,158 @@ test("integration: a late worker cannot call a provider after cancellation won t assert.equal(events.some((event) => event.phase === "attempt_started"), false); }); +test("integration: a pending cancellation intent prevents a queued worker claim", async (t) => { + const fake = createFakeQwenBin(); + t.after(() => fake.cleanup()); + const eventLog = path.join(os.tmpdir(), `polycli-pending-cancel-claim-${Date.now()}-${Math.random()}.ndjson`); + t.after(() => fs.rmSync(eventLog, { force: true })); + const context = createBackgroundLedgerContext(t, { + QWEN_CLI_BIN: fake.bin, + QWEN_EVENT_LOG: eventLog, + }); + const sourceContext = { ...context, companion: sourceCompanionPath }; + const jobId = "job-pending-cancel-before-claim"; + const logFile = resolveJobLogFile(context.cwd, jobId); + const job = { + jobId, + provider: "qwen", + kind: "rescue", + status: "queued", + pid: null, + logFile, + }; + const config = { + workspaceRoot: context.cwd, + jobId, + execution: { + provider: "qwen", + kind: "rescue", + prompt: "__reply=SHOULD_NOT_RUN", + cwd: context.cwd, + timeout: 5_000, + measurementScope: "job", + meta: { background: true, jobId }, + }, + runContext: { + runId: "run-pending-cancel-before-claim", + command: "rescue", + hostSurface: "terminal", + jobId, + provider: "qwen", + kind: "rescue", + logFile, + }, + }; + ensureStateDir(context.cwd); + fs.writeFileSync(logFile, "queued\n"); + upsertJob(context.cwd, job); + writeJobConfigFile(context.cwd, jobId, config); + writeJobFile(context.cwd, jobId, { + job, + cancellationIntent: { status: "requested", requestedAt: new Date().toISOString() }, + }); + + const worker = await runCompanion(["_job-worker", resolveJobConfigFile(context.cwd, jobId)], sourceContext); + + assert.equal(worker.code, 0, worker.stderr); + assert.equal(fs.existsSync(eventLog), false, "pending cancellation must prevent provider invocation"); + assert.equal(listJobs(context.cwd).find((entry) => entry.jobId === jobId)?.status, "queued"); + assert.equal(readJobFile(resolveJobFile(context.cwd, jobId))?.cancellationIntent?.status, "requested"); + const events = (await readRunLedgerEvents(context.cwd)).filter((event) => + event.runId === "run-pending-cancel-before-claim" + ); + assert.equal(events.some((event) => event.phase === "attempt_started"), false); +}); + +test("integration: a pending cancellation intent prevents a late worker finalizer", async (t) => { + const fake = createFakeQwenBin(); + t.after(() => fake.cleanup()); + const resultGate = path.join(os.tmpdir(), `polycli-pending-cancel-finalize-${Date.now()}-${Math.random()}`); + const eventLog = `${resultGate}.events`; + fs.writeFileSync(resultGate, "hold\n", { mode: 0o600 }); + t.after(() => { + fs.rmSync(resultGate, { force: true }); + fs.rmSync(eventLog, { force: true }); + }); + const context = createBackgroundLedgerContext(t, { + QWEN_CLI_BIN: fake.bin, + QWEN_RESULT_GATE: resultGate, + QWEN_EVENT_LOG: eventLog, + }); + const sourceContext = { ...context, companion: sourceCompanionPath }; + const runId = "run-pending-cancel-before-finalize"; + const start = await runCompanion( + ["rescue", "--provider", "qwen", "--background", "--json", "--run-id", runId, "__reply=MUST_NOT_FINALIZE"], + sourceContext, + ); + assert.equal(start.code, 0, start.stderr); + const started = JSON.parse(start.stdout); + await waitForLedgerPhase(context.cwd, runId, "attempt_started"); + const activeJob = listJobs(context.cwd).find((job) => job.jobId === started.job.jobId); + writeJobFile(context.cwd, started.job.jobId, { + job: activeJob, + cancellationIntent: { status: "requested", requestedAt: new Date().toISOString() }, + }); + + fs.rmSync(resultGate, { force: true }); + const deadline = Date.now() + 5_000; + while (Date.now() < deadline) { + if (fs.existsSync(eventLog) && fs.readFileSync(eventLog, "utf8").includes('"event":"end"')) break; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + + assert.equal(listJobs(context.cwd).find((job) => job.jobId === started.job.jobId)?.status, "running"); + assert.equal(readJobFile(resolveJobFile(context.cwd, started.job.jobId))?.cancellationIntent?.status, "requested"); + const terminal = (await readRunLedgerEvents(context.cwd)).filter((event) => + event.runId === runId && ["attempt_result", "provider_decision"].includes(event.phase) + ); + assert.equal(terminal.length, 0); + assert.equal(fs.existsSync(resolveTimingHistoryFile(context.cwd)), false, "late worker must not commit timing after cancellation intent wins"); + assert.equal( + fs.existsSync(path.join(resolveStateDir(context.cwd), "provider-models.json")), + false, + "late worker must not update model cache after cancellation intent wins", + ); +}); + +test("integration: a worker terminal winner removes a stale start-failure sidecar", async (t) => { + const fake = createFakeQwenBin(); + t.after(() => fake.cleanup()); + const resultGate = path.join(os.tmpdir(), `polycli-sidecar-worker-finalize-${Date.now()}-${Math.random()}`); + fs.writeFileSync(resultGate, "hold\n", { mode: 0o600 }); + t.after(() => fs.rmSync(resultGate, { force: true })); + const context = createBackgroundLedgerContext(t, { + QWEN_CLI_BIN: fake.bin, + QWEN_RESULT_GATE: resultGate, + }); + const sourceContext = { ...context, companion: sourceCompanionPath }; + const runId = "run-worker-cleans-start-failure-sidecar"; + const start = await runCompanion( + ["rescue", "--provider", "qwen", "--background", "--json", "--run-id", runId, "__reply=WORKER_WINS"], + sourceContext, + ); + assert.equal(start.code, 0, start.stderr); + const started = JSON.parse(start.stdout); + await waitForLedgerPhase(context.cwd, runId, "attempt_started"); + writeJobStartFailureFile(context.cwd, started.job.jobId, { + version: 1, + jobId: started.job.jobId, + error: "stale start failure", + }); + + fs.rmSync(resultGate, { force: true }); + await waitForLedgerPhase(context.cwd, runId, "provider_decision"); + const stateDeadline = Date.now() + 5_000; + while (Date.now() < stateDeadline) { + if (listJobs(context.cwd).find((job) => job.jobId === started.job.jobId)?.status === "completed") break; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + assert.equal(listJobs(context.cwd).find((job) => job.jobId === started.job.jobId)?.status, "completed"); + assert.equal(fs.existsSync(resolveJobStartFailureFile(context.cwd, started.job.jobId)), false); +}); + test("integration: a worker does not publish state over a conflicting partial terminal pair", async (t) => { const fake = createFakeQwenBin(); t.after(() => fake.cleanup()); diff --git a/plugins/polycli/scripts/tests/job-control.test.mjs b/plugins/polycli/scripts/tests/job-control.test.mjs index cd88108..125cf8e 100644 --- a/plugins/polycli/scripts/tests/job-control.test.mjs +++ b/plugins/polycli/scripts/tests/job-control.test.mjs @@ -7,6 +7,7 @@ import path from "node:path"; import { buildStatusSnapshot, cancelJob, + isExpectedWorkerProcess, refreshJobsForLedgerRecovery, refreshJob, resolveJobSelector, @@ -21,9 +22,11 @@ import { resolveJobConfigFile, resolveJobFile, resolveJobLogFile, + resolveJobStartFailureFile, upsertJob, writeJobConfigFile, writeJobFile, + writeJobStartFailureFile, } from "../lib/state.mjs"; import { appendRunLedgerEvent, @@ -138,6 +141,11 @@ test("refreshJob records terminal ledger events when a worker exits before writi hostSurface: "terminal", logFile, }); + writeJobStartFailureFile(workspaceRoot, "job-missing-result", { + version: 1, + jobId: "stale-other-job", + error: "safe start failure", + }); const refreshed = refreshJob(workspaceRoot, listJobs(workspaceRoot)[0]); assert.equal(refreshed.status, "failed"); @@ -160,9 +168,44 @@ test("refreshJob records terminal ledger events when a worker exits before writi assert.equal(afterSecondRefresh.filter((event) => event.phase === "attempt_result").length, 1); assert.equal(afterSecondRefresh.filter((event) => event.phase === "provider_decision").length, 1); assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, "job-missing-result")), false); + assert.equal(fs.existsSync(resolveJobStartFailureFile(workspaceRoot, "job-missing-result")), false); }); }); +test("isExpectedWorkerProcess bounds its synchronous probe by the remaining deadline", () => { + const calls = []; + const deadlineAt = Date.now() + 1_000; + const matched = isExpectedWorkerProcess(4242, "/tmp/job.config.json", { + deadlineAt, + platform: "linux", + spawnProcess(command, args, options) { + calls.push({ command, args, options }); + return { + status: 0, + stdout: "node companion _job-worker /tmp/job.config.json\n", + stderr: "", + error: null, + }; + }, + }); + + assert.equal(matched, true); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, "ps"); + assert.ok(calls[0].options.timeout > 0 && calls[0].options.timeout <= 1_000); + + let expiredCalls = 0; + const expired = isExpectedWorkerProcess(4242, "/tmp/job.config.json", { + deadlineAt: Date.now() - 1, + platform: "linux", + spawnProcess() { + expiredCalls += 1; + }, + }); + assert.equal(expired, null); + assert.equal(expiredCalls, 0); +}); + test("refreshJob keeps a dead worker active until terminal ledger recovery succeeds", async () => { await withWorkspace(async (workspaceRoot) => { const jobId = "job-ledger-unavailable"; @@ -688,6 +731,34 @@ test("buildStatusSnapshot returns progress preview and recent jobs", () => { }); }); +test("buildStatusSnapshot retains every active job while bounding terminal history", () => { + withWorkspace((workspaceRoot) => { + upsertJob(workspaceRoot, { + jobId: "job-active-oldest", + provider: "qwen", + kind: "review", + status: "queued", + updatedAt: "2026-04-22T00:00:00.000Z", + }); + for (let index = 0; index < 9; index += 1) { + upsertJob(workspaceRoot, { + jobId: `job-terminal-${index}`, + provider: "qwen", + kind: "review", + status: "completed", + updatedAt: `2026-04-22T00:00:${String(index + 1).padStart(2, "0")}.000Z`, + finishedAt: `2026-04-22T00:00:${String(index + 1).padStart(2, "0")}.000Z`, + }); + } + + const snapshot = buildStatusSnapshot(workspaceRoot); + assert.equal(snapshot.totalJobs, 10); + assert.deepEqual(snapshot.running.map((job) => job.jobId), ["job-active-oldest"]); + assert.equal(snapshot.recent.length, 8); + assert.equal(snapshot.recent.some((job) => job.jobId === "job-terminal-0"), false); + }); +}); + test("resolveLatestActiveJob and resolveLatestTerminalJob prefer newest matching jobs", () => { withWorkspace((workspaceRoot) => { upsertJob(workspaceRoot, { @@ -1014,11 +1085,17 @@ test("cancelJob records cancelled ledger events, removes config, and cleans runt hostSurface: "terminal", logFile, }); + writeJobStartFailureFile(workspaceRoot, "job-cancel", { + version: 1, + jobId: "job-cancel", + error: "safe start failure", + }); const report = await cancelJob(workspaceRoot, "job-cancel"); assert.equal(report.cancelled, true); assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, "job-cancel")), false); + assert.equal(fs.existsSync(resolveJobStartFailureFile(workspaceRoot, "job-cancel")), false); assert.equal(fs.existsSync(cleanupDir), false); const events = (await readRunLedgerEvents(workspaceRoot)).filter((event) => event.runId === "run-cancel"); @@ -1114,7 +1191,9 @@ test("cancelJob resumes a persisted cancellation intent after a transient ledger fs.mkdirSync(ledgerFile, { mode: 0o700 }); await assert.rejects(() => cancelJob(workspaceRoot, jobId), /EISDIR|illegal operation|operation on a directory/i); assert.equal(listJobs(workspaceRoot).find((job) => job.jobId === jobId)?.status, "running"); - assert.equal(readJobFile(resolveJobFile(workspaceRoot, jobId))?.job?.status, "cancelled"); + const pendingEnvelope = readJobFile(resolveJobFile(workspaceRoot, jobId)); + assert.equal(pendingEnvelope?.job?.status, "cancelled"); + assert.equal(pendingEnvelope?.cancellationIntent?.status, "requested"); fs.rmSync(ledgerFile, { recursive: true, force: true }); const resumed = await cancelJob(workspaceRoot, jobId); @@ -1182,12 +1261,16 @@ test("cancelJob kills the worker before deleting its runtime paths", async () => let dirExistedAtKill = null; let statusAtKill = null; + let terminalEventsAtKill = null; let workerAlive = true; const report = await cancelJob(workspaceRoot, "job-order", { terminate: async () => { // The cleanup path (a review's live cwd) must still exist when the kill runs. dirExistedAtKill = fs.existsSync(cleanupDir); statusAtKill = listJobs(workspaceRoot).find((job) => job.jobId === "job-order")?.status; + terminalEventsAtKill = (await readRunLedgerEvents(workspaceRoot)).filter((event) => + event.runId === "run-order" && ["attempt_result", "provider_decision"].includes(event.phase) + ).length; workerAlive = false; }, isWorkerAlive: () => workerAlive, @@ -1197,7 +1280,15 @@ test("cancelJob kills the worker before deleting its runtime paths", async () => assert.equal(report.cancelled, true); assert.equal(dirExistedAtKill, true); assert.equal(statusAtKill, "running"); + assert.equal(terminalEventsAtKill, 0); assert.equal(fs.existsSync(cleanupDir), false); + const terminal = (await readRunLedgerEvents(workspaceRoot)).filter((event) => + event.runId === "run-order" && ["attempt_result", "provider_decision"].includes(event.phase) + ); + assert.deepEqual(terminal.map((event) => [event.phase, event.status]), [ + ["attempt_result", "cancelled"], + ["provider_decision", "cancelled"], + ]); }); }); @@ -1237,7 +1328,24 @@ test("cancelJob preserves runtime paths when the kill fails (worker may be alive assert.equal(fs.existsSync(cleanupDir), true); assert.equal(listJobs(workspaceRoot).find((job) => job.jobId === "job-killfail")?.status, "running"); assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, "job-killfail")), true); - fs.rmSync(cleanupDir, { recursive: true, force: true }); + const pendingEnvelope = readJobFile(resolveJobFile(workspaceRoot, "job-killfail")); + assert.equal(pendingEnvelope?.job?.status, "running"); + assert.equal(pendingEnvelope?.cancellationIntent?.status, "requested"); + assert.equal((await readRunLedgerEvents(workspaceRoot)).filter((event) => + event.runId === "run-killfail" && ["attempt_result", "provider_decision"].includes(event.phase) + ).length, 0); + + const retried = await cancelJob(workspaceRoot, "job-killfail", { + isWorkerAlive: () => false, + }); + assert.equal(retried.cancelled, true); + assert.equal(listJobs(workspaceRoot).find((job) => job.jobId === "job-killfail")?.status, "cancelled"); + assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, "job-killfail")), false); + assert.equal(fs.existsSync(cleanupDir), false); + const terminal = (await readRunLedgerEvents(workspaceRoot)).filter((event) => + event.runId === "run-killfail" && ["attempt_result", "provider_decision"].includes(event.phase) + ); + assert.deepEqual(terminal.map((event) => event.phase), ["attempt_result", "provider_decision"]); }); }); @@ -1278,6 +1386,105 @@ test("cancelJob refuses to signal a reused pid that no longer identifies its wor assert.equal(report.reason, "worker_identity_unverified"); assert.equal(signalled, false); assert.equal(listJobs(workspaceRoot).find((job) => job.jobId === jobId)?.status, "running"); - assert.equal(readJobFile(resolveJobFile(workspaceRoot, jobId))?.job?.status, "cancelled"); + const pendingEnvelope = readJobFile(resolveJobFile(workspaceRoot, jobId)); + assert.equal(pendingEnvelope?.job?.status, "running"); + assert.equal(pendingEnvelope?.cancellationIntent?.status, "requested"); + assert.equal((await readRunLedgerEvents(workspaceRoot)).filter((event) => + event.runId === "run-pid-reused" && ["attempt_result", "provider_decision"].includes(event.phase) + ).length, 0); + const refreshed = refreshJob(workspaceRoot, listJobs(workspaceRoot).find((job) => job.jobId === jobId)); + assert.equal(refreshed.status, "running"); }); }); + +test("cancelJob passes one absolute deadline to pre/post identity probes and termination", async () => { + await withWorkspace(async (workspaceRoot) => { + const jobId = "job-deadline-probes"; + upsertJob(workspaceRoot, { + jobId, + provider: "qwen", + kind: "rescue", + status: "running", + pid: 4242, + }); + writeJobConfigFile(workspaceRoot, jobId, { + workspaceRoot, + jobId, + execution: { provider: "qwen", kind: "rescue" }, + runContext: { runId: "run-deadline-probes", command: "rescue", jobId }, + }); + const deadlineAt = Date.now() + 1_000; + const observed = []; + + const report = await cancelJob(workspaceRoot, jobId, { + deadlineAt, + isWorkerAlive: () => true, + isExpectedWorker(_pid, _configFile, options) { + observed.push(options.deadlineAt); + return true; + }, + async terminate(_pid, options) { + observed.push(options.deadlineAt); + }, + }); + + assert.equal(report.cancelled, false); + assert.equal(report.reason, "worker_still_running"); + assert.deepEqual(observed, [deadlineAt, deadlineAt, deadlineAt]); + }); +}); + +test("cancelJob keeps an unverifiable or still-running worker active without terminal ledger", async (t) => { + for (const scenario of [ + { name: "identity unavailable", identity: null, expectedReason: "worker_identity_unverified" }, + { name: "worker survives termination", identity: true, expectedReason: "worker_still_running" }, + ]) { + await t.test(scenario.name, async () => { + await withWorkspace(async (workspaceRoot) => { + const jobId = `job-${scenario.name.replaceAll(" ", "-")}`; + const cleanupDir = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-cancel-pending-")); + upsertJob(workspaceRoot, { + jobId, + provider: "qwen", + kind: "rescue", + status: "running", + pid: 4242, + }); + writeJobConfigFile(workspaceRoot, jobId, { + workspaceRoot, + jobId, + execution: { + provider: "qwen", + kind: "rescue", + runtimeOptions: { cleanupPaths: [cleanupDir] }, + }, + runContext: { + runId: `run-${jobId}`, + command: "rescue", + hostSurface: "terminal", + jobId, + provider: "qwen", + kind: "rescue", + }, + }); + + const report = await cancelJob(workspaceRoot, jobId, { + terminate: async () => {}, + isWorkerAlive: () => true, + isExpectedWorker: () => scenario.identity, + }); + + assert.equal(report.cancelled, false); + assert.equal(report.reason, scenario.expectedReason); + assert.equal(listJobs(workspaceRoot).find((job) => job.jobId === jobId)?.status, "running"); + assert.equal(readJobFile(resolveJobFile(workspaceRoot, jobId))?.cancellationIntent?.status, "requested"); + assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, jobId)), true); + assert.equal(fs.existsSync(cleanupDir), true); + assert.equal((await readRunLedgerEvents(workspaceRoot)).filter((event) => + ["attempt_result", "provider_decision"].includes(event.phase) + ).length, 0); + fs.rmSync(cleanupDir, { recursive: true, force: true }); + }); + }); + } +}); diff --git a/plugins/polycli/scripts/tests/json-v2-integration.test.mjs b/plugins/polycli/scripts/tests/json-v2-integration.test.mjs index 6ca2b5a..125360b 100644 --- a/plugins/polycli/scripts/tests/json-v2-integration.test.mjs +++ b/plugins/polycli/scripts/tests/json-v2-integration.test.mjs @@ -62,6 +62,34 @@ test("--json-v2 serializes typed parse failures and suggestions", () => { } }); +test("setup and health reject positional plus explicit providers before state or provider access", () => { + for (const command of ["setup", "health"]) { + for (const positional of ["qwen", "claude"]) { + const cwd = fs.mkdtempSync(path.join(os.tmpdir(), `polycli-v2-provider-conflict-${command}-`)); + const marker = path.join(cwd, "provider-accessed"); + const fakeProvider = path.join(cwd, "provider-must-not-run"); + fs.writeFileSync(fakeProvider, `#!/bin/sh\ntouch '${marker}'\nexit 0\n`, { mode: 0o755 }); + try { + const result = run( + [command, positional, "--provider", "qwen", "--json-v2"], + cwd, + { QWEN_CLI_BIN: fakeProvider, CLAUDE_CLI_BIN: fakeProvider }, + ); + assert.equal(result.status, 1, `${command} ${positional}: ${result.stderr}`); + assert.equal(result.stderr, ""); + const payload = JSON.parse(result.stdout); + assert.equal(payload.error.code, "invalid_argument"); + assert.equal(payload.error.data.positionalProvider, positional); + assert.equal(payload.error.data.explicitProvider, "qwen"); + assert.equal(fs.existsSync(path.join(cwd, ".state")), false); + assert.equal(fs.existsSync(marker), false); + } finally { + fs.rmSync(cwd, { recursive: true, force: true }); + } + } + } +}); + test("legacy --json remains unwrapped and conflicting output modes are rejected", () => { const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-v2-compat-")); try { diff --git a/plugins/polycli/scripts/tests/run-ledger.test.mjs b/plugins/polycli/scripts/tests/run-ledger.test.mjs index 2cffce8..c81a050 100644 --- a/plugins/polycli/scripts/tests/run-ledger.test.mjs +++ b/plugins/polycli/scripts/tests/run-ledger.test.mjs @@ -991,6 +991,48 @@ test('run ledger redacts private absolute paths from terminal error material and assert.match(event.error.message, //); }); +test('run ledger sanitizes every preview at the persistence boundary', async () => { + await withTempWorkspace(async (workspaceRoot) => { + const rawPreviews = [ + 'TOKEN=foreground-preview-secret /private/foreground-preview-path', + 'PASSWORD=background-preview-secret /private/background-preview-path', + 'API_KEY=recovery-preview-secret /private/recovery-preview-path', + ]; + await appendRunLedgerEvent(workspaceRoot, { + runId: 'run-preview-foreground', + phase: 'attempt_result', + preview: rawPreviews[0], + }); + await appendRunLedgerEvents(workspaceRoot, [ + { + runId: 'run-preview-background', + jobId: 'job-preview-background', + phase: 'attempt_result', + preview: rawPreviews[1], + }, + { + runId: 'run-preview-recovery', + jobId: 'job-preview-recovery', + phase: 'provider_decision', + preview: rawPreviews[2], + }, + ]); + + const persisted = await readFile(resolveRunLedgerFile(workspaceRoot), 'utf8'); + for (const marker of ['foreground-preview-secret', 'background-preview-secret', 'recovery-preview-secret']) { + assert.doesNotMatch(persisted, new RegExp(marker)); + } + assert.doesNotMatch(persisted, /private\/(?:foreground|background|recovery)-preview-path/); + + const events = await readRunLedgerEvents(workspaceRoot); + assert.equal(events.length, 3); + for (const event of events) { + assert.match(event.preview, //); + assert.match(event.preview, //); + } + }); +}); + test('tailRunLedgerEvents returns the latest run last-N events in chronological ledger order', async () => { await withTempWorkspace(async (workspaceRoot) => { await appendRunLedgerEvent(workspaceRoot, { runId: 'run-old', phase: 'run_started' }); diff --git a/plugins/polycli/scripts/tests/state.test.mjs b/plugins/polycli/scripts/tests/state.test.mjs index 8d0adf6..fe6555c 100644 --- a/plugins/polycli/scripts/tests/state.test.mjs +++ b/plugins/polycli/scripts/tests/state.test.mjs @@ -16,6 +16,7 @@ import { resolveJobFile, resolveJobConfigFile, resolveJobLogFile, + resolveJobStartFailureFile, resolveStateDir, resolveWorkspaceRoot, saveState, @@ -25,6 +26,7 @@ import { upsertJob, writeJobConfigFile, writeJobFile, + writeJobStartFailureFile, } from "../lib/state.mjs"; import { readStateWithPreGateBReader } from "./fixtures/pre-gate-b-readers.mjs"; @@ -175,10 +177,11 @@ test("saveState reclaims on-disk artifacts for terminal jobs pruned past MAX_JOB const activeJob = { jobId: "queued-keep", status: "queued", updatedAt: "2025-12-31T00:00:00.000Z" }; // terminal-0 is the oldest -> pruned past MAX_JOBS; terminal-104 is newest -> kept; the active - // job is always kept. Write all three artifact kinds for each so we can prove reclamation. + // job is always kept. Write every artifact kind for each so we can prove reclamation. for (const jobId of ["terminal-0", "terminal-104", "queued-keep"]) { writeJobFile(workspaceRoot, jobId, { job: { jobId, status: "completed" }, result: { ok: true, response: "x" } }); writeJobConfigFile(workspaceRoot, jobId, { execution: { prompt: "p" } }); + writeJobStartFailureFile(workspaceRoot, jobId, { version: 1, jobId, error: "safe" }); fs.writeFileSync(resolveJobLogFile(workspaceRoot, jobId), "log line\n"); } @@ -187,9 +190,11 @@ test("saveState reclaims on-disk artifacts for terminal jobs pruned past MAX_JOB // Dropped terminal job: all artifacts reclaimed. assert.equal(fs.existsSync(resolveJobFile(workspaceRoot, "terminal-0")), false); assert.equal(fs.existsSync(resolveJobConfigFile(workspaceRoot, "terminal-0")), false); + assert.equal(fs.existsSync(resolveJobStartFailureFile(workspaceRoot, "terminal-0")), false); assert.equal(fs.existsSync(resolveJobLogFile(workspaceRoot, "terminal-0")), false); // Kept terminal job + active job: artifacts retained. assert.equal(fs.existsSync(resolveJobFile(workspaceRoot, "terminal-104")), true); + assert.equal(fs.existsSync(resolveJobStartFailureFile(workspaceRoot, "terminal-104")), true); assert.equal(fs.existsSync(resolveJobFile(workspaceRoot, "queued-keep")), true); assert.equal(fs.existsSync(resolveJobLogFile(workspaceRoot, "queued-keep")), true); }); diff --git a/scripts/build-plugin-bundles.mjs b/scripts/build-plugin-bundles.mjs index f816479..6e33f55 100644 --- a/scripts/build-plugin-bundles.mjs +++ b/scripts/build-plugin-bundles.mjs @@ -16,17 +16,15 @@ const ENTRY = path.join(REPO_ROOT, "plugins/polycli/scripts/polycli-companion.mj const TERMINAL_PACKAGE = JSON.parse( fs.readFileSync(path.join(REPO_ROOT, "packages/polycli-terminal/package.json"), "utf8"), ); -const TERMINAL_SURFACE_TARGET = path.join( - REPO_ROOT, - "packages/polycli-terminal/lib/command-surface.generated.mjs", -); -const TARGETS = [ +export const TERMINAL_COMMAND_SURFACE_TARGET = + "packages/polycli-terminal/lib/command-surface.generated.mjs"; +export const PLUGIN_BUNDLE_TARGETS = Object.freeze([ "plugins/polycli/scripts/polycli-companion.bundle.mjs", "plugins/polycli-codex/scripts/polycli-companion.bundle.mjs", "plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs", "plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs", "packages/polycli-terminal/bin/polycli-companion.bundle.mjs", -]; +]); export function renderTerminalCommandSurface() { const definitions = listCommandDefinitions({ hostSurface: "terminal" }); @@ -44,25 +42,55 @@ export function renderTerminalCommandSurface() { + `}\n`; } -function generateTerminalCommandSurface() { - fs.writeFileSync(TERMINAL_SURFACE_TARGET, renderTerminalCommandSurface(), "utf8"); -} - -async function main() { - generateTerminalCommandSurface(); - for (const relativeTarget of TARGETS) { - await build({ - entryPoints: [ENTRY], - outfile: path.join(REPO_ROOT, relativeTarget), +export async function renderExpectedPluginArtifacts({ + root = REPO_ROOT, + entry = root === REPO_ROOT ? ENTRY : path.join(root, "plugins/polycli/scripts/polycli-companion.mjs"), + targets = PLUGIN_BUNDLE_TARGETS, + generatedSurface = { + relativePath: TERMINAL_COMMAND_SURFACE_TARGET, + contents: Buffer.from(renderTerminalCommandSurface()), + }, + version = TERMINAL_PACKAGE.version, +} = {}) { + const artifacts = new Map(); + for (const relativeTarget of targets) { + const outfile = path.join(root, relativeTarget); + const result = await build({ + entryPoints: [entry], + outfile, bundle: true, format: "esm", platform: "node", target: "node20", legalComments: "none", define: { - __POLYCLI_VERSION__: JSON.stringify(TERMINAL_PACKAGE.version), + __POLYCLI_VERSION__: JSON.stringify(version), }, + write: false, }); + if (result.outputFiles.length !== 1) { + throw new Error(`expected one rendered output for ${relativeTarget}, got ${result.outputFiles.length}`); + } + artifacts.set(relativeTarget, Buffer.from(result.outputFiles[0].contents)); + } + + if (generatedSurface) { + artifacts.set( + generatedSurface.relativePath, + Buffer.isBuffer(generatedSurface.contents) + ? Buffer.from(generatedSurface.contents) + : Buffer.from(generatedSurface.contents, "utf8"), + ); + } + + return artifacts; +} + +async function main() { + const artifacts = await renderExpectedPluginArtifacts(); + for (const [relativeTarget, bytes] of artifacts) { + fs.mkdirSync(path.dirname(path.join(REPO_ROOT, relativeTarget)), { recursive: true }); + fs.writeFileSync(path.join(REPO_ROOT, relativeTarget), bytes); } } diff --git a/scripts/check-release.mjs b/scripts/check-release.mjs index c916399..c33ff28 100644 --- a/scripts/check-release.mjs +++ b/scripts/check-release.mjs @@ -49,15 +49,17 @@ function checkPublishable(packageDir) { } } -// Ordered release-gate steps. Deterministic validators run first; strict fixture +// Ordered release-gate steps. Source-derived generated-artifact freshness must +// run before npm test because the test command rebuilds tracked bundles in +// place. The remaining deterministic validators follow; strict fixture // freshness then prevents publishing against an installed CLI whose recorded // parser fixture is stale. Unavailable CLIs remain explicit skips in that // checker. check:review-drift runs afterward and self-skips absent CLIs (exit // 0), only exit-2ing on a real installed-CLI flag drift. Exported so the gate // composition is testable. export const RELEASE_STEPS = [ - ["npm", ["test"]], ["npm", ["run", "validate:bundles"]], + ["npm", ["test"]], ["npm", ["run", "validate:fixtures"]], ["npm", ["run", "check:fixture-freshness", "--", "--strict"]], ["npm", ["run", "validate:manifests"]], diff --git a/scripts/tests/check-review-cli-drift.test.mjs b/scripts/tests/check-review-cli-drift.test.mjs index 719dace..f3c7505 100644 --- a/scripts/tests/check-review-cli-drift.test.mjs +++ b/scripts/tests/check-review-cli-drift.test.mjs @@ -169,3 +169,15 @@ test("RELEASE_STEPS requires strict fixture freshness before publishing checks", assert.ok(freshnessIdx >= 0, "release gate must invoke check:fixture-freshness -- --strict"); assert.ok(freshnessIdx < driftIdx, "strict fixture freshness must run before installed-CLI drift review"); }); + +test("RELEASE_STEPS validates source-derived bundles before npm test can rebuild them", () => { + const freshnessIdx = RELEASE_STEPS.findIndex( + ([cmd, args]) => cmd === "npm" && args[0] === "run" && args[1] === "validate:bundles", + ); + const testIdx = RELEASE_STEPS.findIndex( + ([cmd, args]) => cmd === "npm" && args[0] === "test", + ); + + assert.ok(freshnessIdx >= 0, "release gate must invoke validate:bundles"); + assert.ok(testIdx > freshnessIdx, "source-derived bundle freshness must be checked before npm test"); +}); diff --git a/scripts/tests/terminal-command-surface.test.mjs b/scripts/tests/terminal-command-surface.test.mjs index 03d9d0e..7bcf6d1 100644 --- a/scripts/tests/terminal-command-surface.test.mjs +++ b/scripts/tests/terminal-command-surface.test.mjs @@ -11,12 +11,14 @@ test("generated terminal command metadata includes shared commands and delegated assert.equal(COMMAND_SURFACE_VERSION, 1); assert.ok(TERMINAL_COMMAND_DEFINITIONS.some((entry) => entry.id === "agent-context")); assert.equal(getTerminalCommandDefinition(["tui"]).dispatchTarget, "terminal-wrapper"); + assert.equal(getTerminalCommandDefinition(["tui"]).effects.writesLocalState, true); + assert.doesNotMatch(getTerminalCommandDefinition(["tui"]).summary, /read-only/i); assert.equal(getTerminalCommandDefinition(["_job-worker"]), null); }); test("terminal package declares the parser utility used by generated metadata consumers", async () => { const pkg = (await import("../../packages/polycli-terminal/package.json", { with: { type: "json" } })).default; const utils = (await import("../../packages/polycli-utils/package.json", { with: { type: "json" } })).default; - assert.equal(utils.version, "1.0.4", "strict parser extension requires a new publishable utils version"); + assert.equal(utils.version, "1.0.5", "process lifecycle and argv safety extensions require a new publishable utils version"); assert.equal(pkg.dependencies?.["@bbingz/polycli-utils"], utils.version); }); diff --git a/scripts/tests/validate-plugin-bundles.test.mjs b/scripts/tests/validate-plugin-bundles.test.mjs index 817bdf4..b4ec8e2 100644 --- a/scripts/tests/validate-plugin-bundles.test.mjs +++ b/scripts/tests/validate-plugin-bundles.test.mjs @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import test from "node:test"; +import * as bundleBuilder from "../build-plugin-bundles.mjs"; import { validatePluginBundles } from "../validate-plugin-bundles.mjs"; const REPO_ROOT = path.resolve(import.meta.dirname, "../.."); @@ -14,70 +15,146 @@ const REAL_BUNDLE_TARGETS = [ "plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs", "packages/polycli-terminal/bin/polycli-companion.bundle.mjs", ]; +const REAL_GENERATED_SURFACE = "packages/polycli-terminal/lib/command-surface.generated.mjs"; -function makeTempRoot() { - return fs.mkdtempSync(path.join(os.tmpdir(), "polycli-bundles-test-")); +function makeTempRoot(t) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-bundles-test-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; } -function writeFile(root, relativePath, text) { +function writeFile(root, relativePath, contents) { const filePath = path.join(root, relativePath); fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, text, "utf8"); + fs.writeFileSync(filePath, contents); } -test("validatePluginBundles accepts byte-identical bundle targets", () => { - const root = makeTempRoot(); - const targets = ["a/bundle.mjs", "b/bundle.mjs", "c/bundle.mjs"]; - for (const target of targets) { - writeFile(root, target, "console.log('same');\n"); +function readFiles(root, relativePaths) { + return new Map(relativePaths.map((relativePath) => [ + relativePath, + fs.readFileSync(path.join(root, relativePath)), + ])); +} + +function assertFilesEqual(actual, expected) { + assert.deepEqual([...actual.keys()], [...expected.keys()]); + for (const [relativePath, actualBytes] of actual) { + assert.ok(actualBytes.equals(expected.get(relativePath)), `${relativePath} changed unexpectedly`); } +} - const result = validatePluginBundles({ root, targets }); +function fixtureRenderOptions(root, targets, generatedSurface = null) { + return { + entry: path.join(root, "src/entry.mjs"), + targets, + generatedSurface, + version: "0.0.0-test", + }; +} - assert.deepEqual(result, { - ok: true, - checked: targets, +test("source-derived validation rejects five mutually identical stale bundles without rewriting them", async (t) => { + const root = makeTempRoot(t); + const targets = [ + "targets/one.mjs", + "targets/two.mjs", + "targets/three.mjs", + "targets/four.mjs", + "targets/five.mjs", + ]; + writeFile(root, "src/entry.mjs", "export const value = 'old';\n"); + const oldArtifacts = await bundleBuilder.renderExpectedPluginArtifacts({ + root, + ...fixtureRenderOptions(root, targets), }); + for (const [relativePath, bytes] of oldArtifacts) writeFile(root, relativePath, bytes); + + writeFile(root, "src/entry.mjs", "export const value = 'fresh';\n"); + const before = readFiles(root, targets); + assert.ok([...before.values()].every((bytes) => bytes.equals(before.get(targets[0])))); + + await assert.rejects( + validatePluginBundles({ + root, + renderOptions: fixtureRenderOptions(root, targets), + }), + /stale generated artifact: targets\/one\.mjs/, + ); + + assertFilesEqual(readFiles(root, targets), before); }); -test("validatePluginBundles rejects mismatched bundle targets", () => { - const root = makeTempRoot(); - const targets = ["a/bundle.mjs", "b/bundle.mjs"]; - writeFile(root, targets[0], "console.log('first');\n"); - writeFile(root, targets[1], "console.log('second');\n"); +test("source-derived validation rejects drift in a single bundle target", async (t) => { + const root = makeTempRoot(t); + const targets = ["targets/only.mjs"]; + writeFile(root, "src/entry.mjs", "export const value = 'fresh';\n"); + writeFile(root, targets[0], "export const value = 'stale';\n"); - assert.throws( - () => validatePluginBundles({ root, targets }), - /bundle drift detected: b\/bundle\.mjs differs from a\/bundle\.mjs/ + await assert.rejects( + validatePluginBundles({ + root, + renderOptions: fixtureRenderOptions(root, targets), + }), + /stale generated artifact: targets\/only\.mjs/, ); }); -test("validatePluginBundles rejects stale generated terminal command metadata", () => { - const root = makeTempRoot(); - const targets = ["a/bundle.mjs", "b/bundle.mjs"]; - for (const target of targets) writeFile(root, target, "same\n"); - writeFile(root, "terminal/generated.mjs", "stale\n"); +test("source-derived validation rejects stale generated terminal command metadata", async (t) => { + const root = makeTempRoot(t); + const targets = ["targets/only.mjs"]; + const generatedSurface = { + relativePath: "terminal/generated.mjs", + contents: Buffer.from("fresh metadata\n"), + }; + writeFile(root, "src/entry.mjs", "export const value = 'fresh';\n"); + const expected = await bundleBuilder.renderExpectedPluginArtifacts({ + root, + ...fixtureRenderOptions(root, targets, generatedSurface), + }); + writeFile(root, targets[0], expected.get(targets[0])); + writeFile(root, generatedSurface.relativePath, "stale metadata\n"); - assert.throws( - () => validatePluginBundles({ + await assert.rejects( + validatePluginBundles({ root, - targets, - generatedSurface: { - relativePath: "terminal/generated.mjs", - expected: "fresh\n", - }, + renderOptions: fixtureRenderOptions(root, targets, generatedSurface), }), - /generated command surface drift detected: terminal\/generated\.mjs/, + /stale generated artifact: terminal\/generated\.mjs/, ); }); -test("validatePluginBundles validates the five real companion bundle targets", () => { - for (const target of REAL_BUNDLE_TARGETS) { - assert.ok( - fs.existsSync(path.join(REPO_ROOT, target)), - `expected real bundle target on disk: ${target}`, - ); - } - const result = validatePluginBundles({ root: REPO_ROOT, targets: REAL_BUNDLE_TARGETS }); - assert.deepEqual(result, { ok: true, checked: REAL_BUNDLE_TARGETS }); +test("source-derived validation accepts matching generated artifacts", async (t) => { + const root = makeTempRoot(t); + const targets = ["targets/one.mjs", "targets/two.mjs"]; + const generatedSurface = { + relativePath: "terminal/generated.mjs", + contents: Buffer.from("fresh metadata\n"), + }; + const renderOptions = fixtureRenderOptions(root, targets, generatedSurface); + writeFile(root, "src/entry.mjs", "export const value = 'fresh';\n"); + const expected = await bundleBuilder.renderExpectedPluginArtifacts({ root, ...renderOptions }); + for (const [relativePath, bytes] of expected) writeFile(root, relativePath, bytes); + + const result = await validatePluginBundles({ root, renderOptions }); + + assert.deepEqual(result, { + ok: true, + checked: [...targets, generatedSurface.relativePath], + }); +}); + +test("real repository target manifest contains exactly five bundles and one generated metadata file", () => { + assert.deepEqual(bundleBuilder.PLUGIN_BUNDLE_TARGETS, REAL_BUNDLE_TARGETS); + assert.equal(bundleBuilder.TERMINAL_COMMAND_SURFACE_TARGET, REAL_GENERATED_SURFACE); + assert.equal(new Set(bundleBuilder.PLUGIN_BUNDLE_TARGETS).size, 5); +}); + +test("CI validates source-derived artifacts after install and before npm test", () => { + const workflow = fs.readFileSync(path.join(REPO_ROOT, ".github/workflows/ci.yml"), "utf8"); + const installIndex = workflow.indexOf("run: npm ci"); + const freshnessIndex = workflow.indexOf("run: npm run validate:bundles"); + const testIndex = workflow.indexOf("run: npm test"); + + assert.ok(installIndex >= 0, "CI must install dependencies"); + assert.ok(freshnessIndex > installIndex, "freshness validation must run after npm ci"); + assert.ok(testIndex > freshnessIndex, "freshness validation must run before npm test"); }); diff --git a/scripts/validate-plugin-bundles.mjs b/scripts/validate-plugin-bundles.mjs index ff49eed..c274184 100644 --- a/scripts/validate-plugin-bundles.mjs +++ b/scripts/validate-plugin-bundles.mjs @@ -1,69 +1,50 @@ #!/usr/bin/env node -import assert from "node:assert/strict"; import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { renderTerminalCommandSurface } from "./build-plugin-bundles.mjs"; +import { renderExpectedPluginArtifacts } from "./build-plugin-bundles.mjs"; const REPO_ROOT = path.resolve(import.meta.dirname, ".."); -const DEFAULT_TARGETS = [ - "plugins/polycli/scripts/polycli-companion.bundle.mjs", - "plugins/polycli-codex/scripts/polycli-companion.bundle.mjs", - "plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs", - "plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs", - "packages/polycli-terminal/bin/polycli-companion.bundle.mjs", -]; -const GENERATED_SURFACE = "packages/polycli-terminal/lib/command-surface.generated.mjs"; function readTarget(root, relativePath) { const filePath = path.join(root, relativePath); - assert.ok(fs.existsSync(filePath), `missing bundle target: ${relativePath}`); + if (!fs.existsSync(filePath)) throw new Error(`missing generated artifact: ${relativePath}`); return fs.readFileSync(filePath); } -export function validatePluginBundles({ +export async function validatePluginBundles({ root = REPO_ROOT, - targets = DEFAULT_TARGETS, - generatedSurface = root === REPO_ROOT - ? { relativePath: GENERATED_SURFACE, expected: renderTerminalCommandSurface() } - : null, + renderOptions = {}, + renderArtifacts = renderExpectedPluginArtifacts, } = {}) { - assert.ok(Array.isArray(targets) && targets.length > 1, "at least two bundle targets are required"); - - const [referenceTarget, ...otherTargets] = targets; - const referenceBytes = readTarget(root, referenceTarget); - for (const target of otherTargets) { - const bytes = readTarget(root, target); - if (!bytes.equals(referenceBytes)) { - throw new Error(`bundle drift detected: ${target} differs from ${referenceTarget}`); - } + const expectedArtifacts = await renderArtifacts({ ...renderOptions, root }); + if (!(expectedArtifacts instanceof Map) || expectedArtifacts.size === 0) { + throw new Error("artifact renderer returned no expected outputs"); } - if (generatedSurface) { - const actual = readTarget(root, generatedSurface.relativePath).toString("utf8"); - if (actual !== generatedSurface.expected) { - throw new Error(`generated command surface drift detected: ${generatedSurface.relativePath}`); + for (const [relativePath, expectedBytes] of expectedArtifacts) { + const actualBytes = readTarget(root, relativePath); + if (!actualBytes.equals(expectedBytes)) { + throw new Error(`stale generated artifact: ${relativePath}`); } } return { ok: true, - checked: targets, + checked: [...expectedArtifacts.keys()], }; } -function main() { - const result = validatePluginBundles(); +async function main() { + const result = await validatePluginBundles(); console.log(`plugin bundles ok: ${result.checked.length} checked`); } if (process.argv[1] === fileURLToPath(import.meta.url)) { - try { - main(); - } catch (error) { + main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exit(1); - } + }); }