From 642c55814657ec395dccb0249da4121d466c0db7 Mon Sep 17 00:00:00 2001 From: bbingz Date: Tue, 2 Jun 2026 11:23:02 +0800 Subject: [PATCH 1/3] fix(runtime): harden auth-probe, session-id, signal-kill classification (deep-review 1/2) Review-driven fixes from the 2026-06-02 workflow+codegraph review. Runtime + utils layer (host-lib concurrency fixes follow in a second commit): - auth probes no longer regress transient failures to loggedIn:false. qwen normalizes ETIMEDOUT in runQwenPrompt; claude/copilot/minimax gain the transient/explicit split (timeout/429/ECONNRESET -> inconclusive, only an explicit auth error -> loggedOut). - cmd no longer fabricates a sessionId by scanning prose stdout for a UUID (hard null, cf. agy v0.6.18). - gemini/pi restrict the resolveSessionId fallback to stderr/file (stdout blanked) so a UUID in the answer prose can never be promoted to a fabricated sessionId; stderr id fallback preserved. - gemini sync parseGeminiJsonResult gates ok on status===0 (was ok:true on a non-zero exit). - runCommand surfaces a signal kill (status:null + signal, no error) as a synthetic error so the sync providers stop reading a SIGKILL/SIGTERM as success. - createLineDecoder enforces maxBufferBytes on the post-drain residual, so a burst of complete newline-terminated lines is no longer rejected and dropped. - withLockfile reclaims a stale no-pid / partial-write lock by mtime instead of wedging for the full timeout. Tests: +21 (474/474). npm test green; companion bundles byte-identical. Refs: memory project_deep_review_2026_06_02. --- .codegraph/.gitignore | 16 ++ packages/polycli-runtime/src/claude.js | 31 ++- packages/polycli-runtime/src/cmd.js | 19 +- packages/polycli-runtime/src/copilot.js | 30 +- packages/polycli-runtime/src/gemini.js | 25 +- packages/polycli-runtime/src/minimax.js | 25 +- packages/polycli-runtime/src/pi.js | 11 +- packages/polycli-runtime/src/qwen.js | 4 +- packages/polycli-runtime/test/claude.test.js | 19 ++ packages/polycli-runtime/test/cmd.test.js | 42 +++ packages/polycli-runtime/test/copilot.test.js | 19 ++ packages/polycli-runtime/test/errors.test.js | 30 +- packages/polycli-runtime/test/gemini.test.js | 30 ++ packages/polycli-runtime/test/minimax.test.js | 32 +++ packages/polycli-runtime/test/pi.test.js | 16 ++ packages/polycli-runtime/test/qwen.test.js | 23 ++ .../bin/polycli-companion.bundle.mjs | 259 ++++++++++++------ .../polycli-timing/test/aggregate.test.js | 47 ++++ packages/polycli-utils/src/atomic-save.js | 99 +++++-- packages/polycli-utils/src/process.js | 18 +- packages/polycli-utils/src/stream.js | 8 +- .../polycli-utils/test/atomic-save.test.js | 29 ++ packages/polycli-utils/test/process.test.js | 11 + packages/polycli-utils/test/stream.test.js | 13 +- .../scripts/polycli-companion.bundle.mjs | 259 ++++++++++++------ .../scripts/polycli-companion.bundle.mjs | 259 ++++++++++++------ .../scripts/polycli-companion.bundle.mjs | 259 ++++++++++++------ .../scripts/polycli-companion.bundle.mjs | 259 ++++++++++++------ 28 files changed, 1356 insertions(+), 536 deletions(-) create mode 100644 .codegraph/.gitignore diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore new file mode 100644 index 0000000..9de0f16 --- /dev/null +++ b/.codegraph/.gitignore @@ -0,0 +1,16 @@ +# CodeGraph data files +# These are local to each machine and should not be committed + +# Database +*.db +*.db-wal +*.db-shm + +# Cache +cache/ + +# Logs +*.log + +# Hook markers +.dirty diff --git a/packages/polycli-runtime/src/claude.js b/packages/polycli-runtime/src/claude.js index 621d768..703cb31 100644 --- a/packages/polycli-runtime/src/claude.js +++ b/packages/polycli-runtime/src/claude.js @@ -9,6 +9,10 @@ const CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; const DEFAULT_TIMEOUT_MS = 900_000; const AUTH_CHECK_TIMEOUT_MS = 30_000; const PROMPT_STDIN_THRESHOLD = 100_000; +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; +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, +]; function collectTextFromContent(content) { if (typeof content === "string") { @@ -213,22 +217,31 @@ export function getClaudeAvailability(cwd) { return binaryAvailable(CLAUDE_BIN, ["--version"], { cwd }); } -export function getClaudeAuthStatus(cwd) { - const result = runClaudePrompt({ +export function getClaudeAuthStatus(cwd, { promptRunner = runClaudePrompt } = {}) { + const result = promptRunner({ prompt: "ping", cwd, timeout: AUTH_CHECK_TIMEOUT_MS, }); - if (!result.ok) { - return { loggedIn: false, detail: result.error }; + if (result.ok) { + return { + loggedIn: true, + detail: "authenticated", + model: result.model ?? null, + }; } - return { - loggedIn: true, - detail: "authenticated", - model: null, - }; + // A timeout / 429 / transient probe failure must NOT regress to loggedIn:false + // (the probe is inconclusive, not proof of logout). + const detail = String(result.error ?? "").trim() || "claude auth probe failed"; + if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { + return { loggedIn: false, detail }; + } + if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + } + return { loggedIn: false, detail }; } export function runClaudePrompt({ diff --git a/packages/polycli-runtime/src/cmd.js b/packages/polycli-runtime/src/cmd.js index 9ca71b0..0197fa2 100644 --- a/packages/polycli-runtime/src/cmd.js +++ b/packages/polycli-runtime/src/cmd.js @@ -1,5 +1,4 @@ import { binaryAvailable, runCommand } from "@bbingz/polycli-utils/process"; -import { resolveSessionId } from "@bbingz/polycli-utils/session-id"; import { classifyProviderFailure, formatProviderExitError } from "./errors.js"; import { spawnStreamingCommand } from "./spawn.js"; @@ -124,11 +123,6 @@ export function runCmdPrompt({ } const parsed = parseCmdTextResult(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"], - }); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.status === 0 @@ -138,7 +132,9 @@ export function runCmdPrompt({ ok: result.status === 0 && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: resolvedSession.sessionId, + // cmd stdout is pure assistant prose with no session-id field; never scan it for a + // UUID, which would fabricate a sessionId from any UUID in the answer (cf. agy v0.6.18). + sessionId: null, model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, error, errorCode: classifyProviderFailure(error, { provider: "cmd" }), @@ -180,11 +176,6 @@ export function runCmdPromptStreaming({ }, }).then((result) => { const parsed = parseCmdTextResult(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"], - }); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.ok ? (hasVisibleText ? null : "cmd produced no visible text") @@ -192,7 +183,9 @@ export function runCmdPromptStreaming({ return { ...result, ...parsed, - sessionId: resolvedSession.sessionId, + // cmd stdout is pure assistant prose with no session-id field; never scan it for a + // UUID, which would fabricate a sessionId from any UUID in the answer (cf. agy v0.6.18). + sessionId: null, model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, ok: result.ok && hasVisibleText, error, diff --git a/packages/polycli-runtime/src/copilot.js b/packages/polycli-runtime/src/copilot.js index f7a2b02..039e12d 100644 --- a/packages/polycli-runtime/src/copilot.js +++ b/packages/polycli-runtime/src/copilot.js @@ -7,6 +7,10 @@ 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 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, +]; function collectCopilotContentText(content) { if (typeof content === "string") { @@ -179,22 +183,30 @@ export function getCopilotAvailability(cwd) { return binaryAvailable(COPILOT_BIN, ["--version"], { cwd }); } -export function getCopilotAuthStatus(cwd) { - const result = runCopilotPrompt({ +export function getCopilotAuthStatus(cwd, { promptRunner = runCopilotPrompt } = {}) { + const result = promptRunner({ prompt: "ping", cwd, timeout: AUTH_CHECK_TIMEOUT_MS, }); - if (!result.ok) { - return { loggedIn: false, detail: result.error }; + if (result.ok) { + return { + loggedIn: true, + detail: "authenticated", + model: result.model ?? null, + }; } - return { - loggedIn: true, - detail: "authenticated", - model: result.model ?? null, - }; + // A timeout / 429 / transient probe failure must NOT regress to loggedIn:false. + const detail = String(result.error ?? "").trim() || "copilot auth probe failed"; + if (COPILOT_EXPLICIT_AUTH_ERROR_RE.test(detail)) { + return { loggedIn: false, detail }; + } + if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + } + return { loggedIn: false, detail }; } export function runCopilotPrompt({ diff --git a/packages/polycli-runtime/src/gemini.js b/packages/polycli-runtime/src/gemini.js index 31d3092..319e518 100644 --- a/packages/polycli-runtime/src/gemini.js +++ b/packages/polycli-runtime/src/gemini.js @@ -122,11 +122,6 @@ function parseGeminiJsonResult(stdout, stderr, status, { defaultModel = null } = try { const parsed = JSON.parse(text.slice(jsonStart)); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"], - }); if (parsed.error) { return { ok: false, @@ -135,6 +130,21 @@ function parseGeminiJsonResult(stdout, stderr, status, { defaultModel = null } = status, }; } + if (status !== 0) { + return { + ok: false, + error: String(stderr ?? "").trim() || formatProviderExitError("gemini", status), + status, + }; + } + // Session id is the structured parsed.session_id; the stderr/file fallback is allowed + // (gemini may print an id to stderr) but stdout is blanked so a UUID inside the answer + // prose (which lives in the same JSON) can never be promoted to a fabricated sessionId. + const resolvedSession = resolveSessionId({ + stdout: "", + stderr, + priority: ["stdout", "stderr", "file"], + }); return { ok: true, response: parsed.response ?? "", @@ -270,7 +280,7 @@ export function runGeminiPromptStreaming({ }).then((result) => { const parsed = parseGeminiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"], }); @@ -281,7 +291,8 @@ export function runGeminiPromptStreaming({ return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // stdout blanked so a UUID in the answer prose is never promoted to a fabricated id. + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel, ok: result.ok && !resultError && hasVisibleText, error: result.ok diff --git a/packages/polycli-runtime/src/minimax.js b/packages/polycli-runtime/src/minimax.js index 2c3d7d9..8fab248 100644 --- a/packages/polycli-runtime/src/minimax.js +++ b/packages/polycli-runtime/src/minimax.js @@ -5,6 +5,10 @@ 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 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, +]; export function stripAnsiSgr(text) { return String(text ?? "").replace(/\x1b\[[0-9;]*m/g, ""); @@ -136,17 +140,30 @@ export function getMiniMaxAvailability(cwd) { return binaryAvailable(MMX_BIN, ["--version"], { cwd }); } -export async function getMiniMaxAuthStatus(cwd) { - const result = runCommand(MMX_BIN, ["auth", "status", "--output", "json", "--non-interactive"], { +export async function getMiniMaxAuthStatus(cwd, { runner = runCommand } = {}) { + const result = runner(MMX_BIN, ["auth", "status", "--output", "json", "--non-interactive"], { cwd, timeout: AUTH_CHECK_TIMEOUT_MS, }); + // A timeout / 429 / transient failure of the auth-status subcommand is inconclusive, + // not proof of logout — it must NOT regress to loggedIn:false. if (result.error) { - return { loggedIn: false, detail: result.error.message }; + const detail = result.error.code === "ETIMEDOUT" + ? `mmx auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS / 1000)}s` + : result.error.message; + if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; } if (result.status !== 0) { - return { loggedIn: false, detail: result.stderr.trim() || `mmx auth status exited with code ${result.status}` }; + const detail = result.stderr.trim() || `mmx auth status exited with code ${result.status}`; + if (!MINIMAX_EXPLICIT_AUTH_ERROR_RE.test(detail) + && TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; } const text = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim(); diff --git a/packages/polycli-runtime/src/pi.js b/packages/polycli-runtime/src/pi.js index 2c31043..e6950f7 100644 --- a/packages/polycli-runtime/src/pi.js +++ b/packages/polycli-runtime/src/pi.js @@ -232,7 +232,7 @@ export function runPiPrompt({ const parsed = parsePiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"], }); @@ -246,7 +246,9 @@ export function runPiPrompt({ ok: result.status === 0 && !resultError && !providerError && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // pi's session id comes from its structured `session` event; stdout is blanked so a UUID + // in the answer prose can never be promoted to a fabricated id (stderr/file still allowed). + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel ?? DEFAULT_PI_MODEL, error: result.status === 0 ? (resultError || providerError || (hasVisibleText ? null : "pi produced no visible text")) @@ -297,7 +299,7 @@ export function runPiPromptStreaming({ }).then((result) => { const parsed = parsePiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"], }); @@ -309,7 +311,8 @@ export function runPiPromptStreaming({ return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // stdout blanked so a UUID in the answer prose is never promoted to a fabricated id. + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel ?? DEFAULT_PI_MODEL, ok: result.ok && !resultError && !providerError && hasVisibleText, error: result.ok diff --git a/packages/polycli-runtime/src/qwen.js b/packages/polycli-runtime/src/qwen.js index f69997a..c10a675 100644 --- a/packages/polycli-runtime/src/qwen.js +++ b/packages/polycli-runtime/src/qwen.js @@ -288,7 +288,9 @@ export function runQwenPrompt({ }); if (result.error) { - const error = result.error.message; + 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" }) }; } diff --git a/packages/polycli-runtime/test/claude.test.js b/packages/polycli-runtime/test/claude.test.js index 9f9b7bb..1c3e0e6 100644 --- a/packages/polycli-runtime/test/claude.test.js +++ b/packages/polycli-runtime/test/claude.test.js @@ -9,6 +9,7 @@ import { loadStreamFixture } from "./helpers/fixture-replay.mjs"; import { buildClaudeInvocation, extractClaudeText, + getClaudeAuthStatus, parseClaudeJsonResult, parseClaudeStreamText, runClaudePrompt, @@ -325,3 +326,21 @@ test("parseClaudeStreamText replays a captured real cli fixture", () => { "claude ask result must carry a non-empty model" ); }); + +test("getClaudeAuthStatus keeps loggedIn=true for a transient/timeout probe failure", () => { + const auth = getClaudeAuthStatus(process.cwd(), { + promptRunner: () => ({ ok: false, error: "claude timed out after 30s" }), + }); + + assert.equal(auth.loggedIn, true); + assert.match(auth.detail, /inconclusive/i); +}); + +test("getClaudeAuthStatus reports loggedIn=false only on an explicit auth error", () => { + const auth = getClaudeAuthStatus(process.cwd(), { + promptRunner: () => ({ ok: false, error: "401 Unauthorized: invalid api key" }), + }); + + assert.equal(auth.loggedIn, false); + assert.match(auth.detail, /unauthorized/i); +}); diff --git a/packages/polycli-runtime/test/cmd.test.js b/packages/polycli-runtime/test/cmd.test.js index 81accbc..6a855c5 100644 --- a/packages/polycli-runtime/test/cmd.test.js +++ b/packages/polycli-runtime/test/cmd.test.js @@ -165,3 +165,45 @@ test("runCmdPromptStreaming emits text events for plain stdout lines", async () { type: "text_delta", delta: "world" }, ]); }); + +test("runCmdPrompt never fabricates a sessionId from a UUID in prose stdout", () => { + withFakeCmdBin( + `#!/usr/bin/env node +process.stdout.write("Sure, here is a uuid: 123e4567-e89b-42d3-a456-426614174000\\n"); +`, + ({ root, bin }) => { + const result = runCmdPrompt({ prompt: "give me a uuid", cwd: root, bin }); + + assert.equal(result.ok, true); + assert.match(result.response, /123e4567-e89b-42d3-a456-426614174000/); + assert.equal(result.sessionId, null); + } + ); +}); + +test("runCmdPromptStreaming never fabricates a sessionId from prose", async () => { + const child = new EventEmitter(); + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.stdin = { write() {}, end() {}, on() {} }; + child.kill = () => {}; + child.unref = () => {}; + + const result = await runCmdPromptStreaming({ + prompt: "give me a uuid", + cwd: process.cwd(), + timeout: 5_000, + onEvent() {}, + spawnImpl() { + queueMicrotask(() => { + child.stdout.emit("data", "here is one: 123e4567-e89b-42d3-a456-426614174000\n"); + child.emit("close", 0, null); + }); + return child; + }, + }); + + assert.equal(result.ok, true); + assert.match(result.response, /123e4567-e89b-42d3-a456-426614174000/); + assert.equal(result.sessionId, null); +}); diff --git a/packages/polycli-runtime/test/copilot.test.js b/packages/polycli-runtime/test/copilot.test.js index ab22144..21c049a 100644 --- a/packages/polycli-runtime/test/copilot.test.js +++ b/packages/polycli-runtime/test/copilot.test.js @@ -9,6 +9,7 @@ import { loadStreamFixture } from "./helpers/fixture-replay.mjs"; import { buildCopilotInvocation, extractCopilotText, + getCopilotAuthStatus, parseCopilotStreamText, runCopilotPrompt, runCopilotPromptStreaming, @@ -263,3 +264,21 @@ test("parseCopilotStreamText replays a captured real cli fixture", () => { assert.equal(parsed.response, meta.expected.response); assert.equal(parsed.sessionId, meta.expected.sessionId); }); + +test("getCopilotAuthStatus keeps loggedIn=true for a transient/timeout probe failure", () => { + const auth = getCopilotAuthStatus(process.cwd(), { + promptRunner: () => ({ ok: false, error: "copilot timed out after 30s" }), + }); + + assert.equal(auth.loggedIn, true); + assert.match(auth.detail, /inconclusive/i); +}); + +test("getCopilotAuthStatus reports loggedIn=false only on an explicit auth error", () => { + const auth = getCopilotAuthStatus(process.cwd(), { + promptRunner: () => ({ ok: false, error: "Not authenticated: please sign in" }), + }); + + assert.equal(auth.loggedIn, false); + assert.match(auth.detail, /sign in/i); +}); diff --git a/packages/polycli-runtime/test/errors.test.js b/packages/polycli-runtime/test/errors.test.js index d4c24b1..d55dc42 100644 --- a/packages/polycli-runtime/test/errors.test.js +++ b/packages/polycli-runtime/test/errors.test.js @@ -1,7 +1,7 @@ import test from "node:test"; import assert from "node:assert/strict"; -import { formatProviderExitError } from "../src/errors.js"; +import { classifyProviderFailure, formatProviderExitError } from "../src/errors.js"; test("formatProviderExitError maps special exit codes to semantic messages", () => { assert.equal(formatProviderExitError("claude", 124), "claude timed out"); @@ -9,3 +9,31 @@ test("formatProviderExitError maps special exit codes to semantic messages", () assert.equal(formatProviderExitError("claude", 143), "claude terminated"); assert.equal(formatProviderExitError("claude", 2), "claude exited with code 2"); }); + +test("classifyProviderFailure maps each failure signal to its class", () => { + assert.equal(classifyProviderFailure("spawn cmd ENOENT"), "binary_missing"); + assert.equal(classifyProviderFailure("gemini timed out after 30s"), "timeout"); + assert.equal(classifyProviderFailure("process terminated by signal SIGTERM"), "terminated"); + assert.equal(classifyProviderFailure("process interrupted"), "cancelled"); + assert.equal(classifyProviderFailure("cmd produced no visible text"), "no_visible_text"); + assert.equal(classifyProviderFailure("401 invalid credential"), "auth"); + assert.equal(classifyProviderFailure(""), null); + assert.equal(classifyProviderFailure("something unremarkable happened"), null); +}); + +test("classifyProviderFailure recognizes qwen max-session-turns only for qwen", () => { + assert.equal( + classifyProviderFailure("Maximum session turns exceeded", { provider: "qwen" }), + "qwen_max_session_turns" + ); + assert.notEqual( + classifyProviderFailure("Maximum session turns exceeded"), + "qwen_max_session_turns" + ); +}); + +test("classifyProviderFailure accepts an Error object and orders binary_missing before timeout", () => { + assert.equal(classifyProviderFailure(new Error("spawn ENOENT")), "binary_missing"); + // '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 6b2fb3c..f9de673 100644 --- a/packages/polycli-runtime/test/gemini.test.js +++ b/packages/polycli-runtime/test/gemini.test.js @@ -268,3 +268,33 @@ test("parseGeminiStreamText replays a captured real cli fixture", () => { "gemini ask result must carry a non-empty model" ); }); + +test("runGeminiPrompt reports failure on a non-zero exit even when stdout has valid JSON", () => { + withFakeGeminiBin( + `#!/usr/bin/env node +process.stdout.write(JSON.stringify({ response: "partial answer" }) + "\\n"); +process.exit(2); +`, + ({ root, bin }) => { + const result = runGeminiPrompt({ prompt: "ping", cwd: root, bin }); + + assert.equal(result.ok, false); + assert.match(result.error, /exited with code 2/i); + } + ); +}); + +test("runGeminiPrompt never fabricates a sessionId from a UUID in the answer prose", () => { + withFakeGeminiBin( + `#!/usr/bin/env node +process.stdout.write(JSON.stringify({ response: "your uuid is 123e4567-e89b-42d3-a456-426614174000" }) + "\\n"); +`, + ({ root, bin }) => { + const result = runGeminiPrompt({ prompt: "give me a uuid", cwd: root, bin }); + + assert.equal(result.ok, true); + assert.match(result.response, /123e4567-e89b-42d3-a456-426614174000/); + assert.equal(result.sessionId, null); + } + ); +}); diff --git a/packages/polycli-runtime/test/minimax.test.js b/packages/polycli-runtime/test/minimax.test.js index e7ad48d..a30af38 100644 --- a/packages/polycli-runtime/test/minimax.test.js +++ b/packages/polycli-runtime/test/minimax.test.js @@ -7,6 +7,7 @@ import { buildMiniMaxInvocation, extractMiniMaxLogPath, extractMiniMaxResponseFromLogText, + getMiniMaxAuthStatus, parseMiniMaxResponseBlocks, runMiniMaxPrompt, stripAnsiSgr, @@ -182,3 +183,34 @@ test("minimax helpers replay a captured real cli fixture", () => { assert.ok(extractMiniMaxLogPath(stream)); assert.deepEqual(extractMiniMaxResponseFromLogText(logText), meta.expected); }); + +test("getMiniMaxAuthStatus stays inconclusive (loggedIn:true) on a probe timeout", async () => { + const auth = await getMiniMaxAuthStatus(process.cwd(), { + runner: () => ({ + error: { code: "ETIMEDOUT", message: "spawnSync mmx ETIMEDOUT" }, + status: null, + stdout: "", + stderr: "", + }), + }); + + assert.equal(auth.loggedIn, true); + assert.match(auth.detail, /inconclusive/i); +}); + +test("getMiniMaxAuthStatus stays inconclusive on a transient non-zero exit", async () => { + const auth = await getMiniMaxAuthStatus(process.cwd(), { + runner: () => ({ error: null, status: 1, stdout: "", stderr: "503 service unavailable, try again" }), + }); + + assert.equal(auth.loggedIn, true); + assert.match(auth.detail, /inconclusive/i); +}); + +test("getMiniMaxAuthStatus reports loggedIn=false on an explicit auth failure", async () => { + const auth = await getMiniMaxAuthStatus(process.cwd(), { + runner: () => ({ error: null, status: 1, stdout: "", stderr: "401 unauthorized: invalid api key" }), + }); + + assert.equal(auth.loggedIn, false); +}); diff --git a/packages/polycli-runtime/test/pi.test.js b/packages/polycli-runtime/test/pi.test.js index 29c75a2..6bd8aa5 100644 --- a/packages/polycli-runtime/test/pi.test.js +++ b/packages/polycli-runtime/test/pi.test.js @@ -215,6 +215,22 @@ process.stdout.write(JSON.stringify({ type: "agent_end", result: { text: "hello ); }); +test("runPiPrompt never fabricates a sessionId from a UUID in the answer prose", () => { + withFakePiBin( + `#!/usr/bin/env node +process.stdout.write(JSON.stringify({ type: "message_update", assistantMessageEvent: { type: "text_delta", delta: "your uuid is 123e4567-e89b-42d3-a456-426614174000" } }) + "\\n"); +process.stdout.write(JSON.stringify({ type: "agent_end", result: { text: "your uuid is 123e4567-e89b-42d3-a456-426614174000" } }) + "\\n"); +`, + ({ root, bin }) => { + const result = runPiPrompt({ prompt: "give me a uuid", cwd: root, bin }); + + assert.equal(result.ok, true); + assert.match(result.response, /123e4567-e89b-42d3-a456-426614174000/); + assert.equal(result.sessionId, null); + } + ); +}); + test("runPiPrompt leaves model null when neither caller nor pi reports a model", () => { withFakePiBin( `#!/usr/bin/env node diff --git a/packages/polycli-runtime/test/qwen.test.js b/packages/polycli-runtime/test/qwen.test.js index 431eb13..77013f4 100644 --- a/packages/polycli-runtime/test/qwen.test.js +++ b/packages/polycli-runtime/test/qwen.test.js @@ -402,6 +402,29 @@ test("parseQwenStreamText replays a captured real cli fixture", () => { assert.equal(parsed.sessionId, meta.expected.sessionId); }); +test("runQwenPrompt normalizes a real spawn timeout so the auth probe stays inconclusive", () => { + withFakeQwenBin( + `#!/usr/bin/env node +setTimeout(() => {}, 5000); +`, + ({ root, env }) => { + const result = runQwenPrompt({ prompt: "ping", cwd: root, env, timeout: 200 }); + + assert.equal(result.ok, false); + assert.match(result.error, /qwen timed out after/i); + + // The normalized message must classify as transient so auth stays inconclusive, + // never regressing a timeout to loggedIn:false. + const auth = getQwenAuthStatus(root, { + envBuilder: () => env, + promptRunner: () => result, + }); + assert.equal(auth.loggedIn, true); + assert.match(auth.detail, /inconclusive/i); + } + ); +}); + test("runQwenPromptStreaming returns a structured failure on spawn error", async () => { const child = new EventEmitter(); child.stdout = new EventEmitter(); diff --git a/packages/polycli-terminal/bin/polycli-companion.bundle.mjs b/packages/polycli-terminal/bin/polycli-companion.bundle.mjs index 2e4cd02..188f229 100755 --- a/packages/polycli-terminal/bin/polycli-companion.bundle.mjs +++ b/packages/polycli-terminal/bin/polycli-companion.bundle.mjs @@ -154,14 +154,22 @@ function runCommand(command, args = [], options = {}) { detached: options.detached ?? false }); const preserveNullStatus = options.preserveNullStatus ?? false; + const status = result.status ?? (preserveNullStatus ? null : 0); + let error = result.error ?? null; + if (!error && result.status == null && result.signal && !preserveNullStatus) { + error = Object.assign( + new Error(`process terminated by signal ${result.signal}`), + { code: result.signal } + ); + } return { command, args, - status: result.status ?? (preserveNullStatus ? null : 0), + status, signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", - error: result.error ?? null + error }; } function firstNonEmptyLine(text) { @@ -367,13 +375,14 @@ function createLineDecoder({ encoding = "utf8", stripCarriageReturn = true, maxB push(chunk) { if (chunk == null) return []; buffer += decoder.write(chunk); + const lines = drain(); assertBufferLimit(); - return drain(); + return lines; }, end() { buffer += decoder.end(); - assertBufferLimit(); const lines = drain(); + assertBufferLimit(); if (buffer.length > 0) { lines.push(normalize(buffer)); buffer = ""; @@ -612,6 +621,10 @@ var CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; var DEFAULT_TIMEOUT_MS = 9e5; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; +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 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 +]; function collectTextFromContent(content) { if (typeof content === "string") { return content; @@ -780,20 +793,27 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = function getClaudeAvailability(cwd) { return binaryAvailable(CLAUDE_BIN, ["--version"], { cwd }); } -function getClaudeAuthStatus(cwd) { - const result = runClaudePrompt({ +function getClaudeAuthStatus(cwd, { promptRunner = runClaudePrompt } = {}) { + const result = promptRunner({ prompt: "ping", cwd, timeout: AUTH_CHECK_TIMEOUT_MS }); - if (!result.ok) { - return { loggedIn: false, detail: result.error }; + if (result.ok) { + return { + loggedIn: true, + detail: "authenticated", + model: result.model ?? null + }; } - return { - loggedIn: true, - detail: "authenticated", - model: null - }; + const detail = String(result.error ?? "").trim() || "claude auth probe failed"; + if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { + return { loggedIn: false, detail }; + } + if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + } + return { loggedIn: false, detail }; } function runClaudePrompt({ prompt, @@ -903,6 +923,10 @@ function runClaudePromptStreaming({ var COPILOT_BIN = process.env.COPILOT_CLI_BIN || "copilot"; var DEFAULT_TIMEOUT_MS2 = 9e5; var AUTH_CHECK_TIMEOUT_MS2 = 3e4; +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 +]; function collectCopilotContentText(content) { if (typeof content === "string") { return content; @@ -1051,20 +1075,27 @@ ${event.data.content}`; function getCopilotAvailability(cwd) { return binaryAvailable(COPILOT_BIN, ["--version"], { cwd }); } -function getCopilotAuthStatus(cwd) { - const result = runCopilotPrompt({ +function getCopilotAuthStatus(cwd, { promptRunner = runCopilotPrompt } = {}) { + const result = promptRunner({ prompt: "ping", cwd, timeout: AUTH_CHECK_TIMEOUT_MS2 }); - if (!result.ok) { - return { loggedIn: false, detail: result.error }; + if (result.ok) { + return { + loggedIn: true, + detail: "authenticated", + model: result.model ?? null + }; } - return { - loggedIn: true, - detail: "authenticated", - model: result.model ?? null - }; + const detail = String(result.error ?? "").trim() || "copilot auth probe failed"; + if (COPILOT_EXPLICIT_AUTH_ERROR_RE.test(detail)) { + return { loggedIn: false, detail }; + } + if (TRANSIENT_PROBE_ERROR_PATTERNS2.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + } + return { loggedIn: false, detail }; } function runCopilotPrompt({ prompt, @@ -1191,7 +1222,7 @@ var AUTH_CHECK_TIMEOUT_MS3 = 3e4; var PROMPT_STDIN_THRESHOLD2 = 1e5; 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_PATTERNS = [ +var TRANSIENT_PROBE_ERROR_PATTERNS3 = [ /\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 ]; function buildGeminiEnv(parentEnv = process.env) { @@ -1295,11 +1326,6 @@ function parseGeminiJsonResult(stdout, stderr, status, { defaultModel = null } = } try { const parsed = JSON.parse(text.slice(jsonStart)); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); if (parsed.error) { return { ok: false, @@ -1308,6 +1334,18 @@ function parseGeminiJsonResult(stdout, stderr, status, { defaultModel = null } = status }; } + if (status !== 0) { + return { + ok: false, + error: String(stderr ?? "").trim() || formatProviderExitError("gemini", status), + status + }; + } + const resolvedSession = resolveSessionId({ + stdout: "", + stderr, + priority: ["stdout", "stderr", "file"] + }); return { ok: true, response: parsed.response ?? "", @@ -1335,7 +1373,7 @@ function buildGeminiAuthStatus(test) { if (GEMINI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS3.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; } return { loggedIn: false, detail }; @@ -1435,7 +1473,7 @@ function runGeminiPromptStreaming({ }).then((result) => { const parsed = parseGeminiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -1444,7 +1482,8 @@ function runGeminiPromptStreaming({ return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // stdout blanked so a UUID in the answer prose is never promoted to a fabricated id. + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel, ok: result.ok && !resultError && hasVisibleText, error: result.ok ? resultError || (hasVisibleText ? null : "gemini produced no visible text") : result.error @@ -1463,7 +1502,7 @@ var AUTH_CHECK_TIMEOUT_MS4 = 3e4; var PROMPT_STDIN_THRESHOLD_BYTES = 1e5; 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 KIMI_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -var TRANSIENT_PROBE_ERROR_PATTERNS2 = [ +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 ]; var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path.join(os.homedir(), ".kimi", "config.toml"); @@ -1695,7 +1734,7 @@ function buildKimiAuthStatus(result) { if (KIMI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS2.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS4.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? configModel }; } return { loggedIn: false, detail }; @@ -1857,7 +1896,7 @@ 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"]; var QWEN_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_PATTERNS3 = [ +var TRANSIENT_PROBE_ERROR_PATTERNS5 = [ /\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 ]; var ENV_ALLOW_EXACT = /* @__PURE__ */ new Set([ @@ -2066,7 +2105,7 @@ function buildQwenAuthStatus(pingResult) { if (QWEN_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS3.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS5.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: pingResult.model ?? null }; } return { loggedIn: false, detail }; @@ -2119,7 +2158,7 @@ function runQwenPrompt({ timeout }); if (result.error) { - const error2 = result.error.message; + 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" }) }; } const parsed = parseQwenStreamText(result.stdout); @@ -2218,6 +2257,10 @@ 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 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 +]; function stripAnsiSgr(text) { return String(text ?? "").replace(/\x1b\[[0-9;]*m/g, ""); } @@ -2256,16 +2299,24 @@ function extractMiniMaxEventText(event) { function getMiniMaxAvailability(cwd) { return binaryAvailable(MMX_BIN, ["--version"], { cwd }); } -async function getMiniMaxAuthStatus(cwd) { - const result = runCommand(MMX_BIN, ["auth", "status", "--output", "json", "--non-interactive"], { +async function getMiniMaxAuthStatus(cwd, { runner = runCommand } = {}) { + const result = runner(MMX_BIN, ["auth", "status", "--output", "json", "--non-interactive"], { cwd, timeout: AUTH_CHECK_TIMEOUT_MS6 }); if (result.error) { - return { loggedIn: false, detail: result.error.message }; + const detail = result.error.code === "ETIMEDOUT" ? `mmx auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS6 / 1e3)}s` : result.error.message; + if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; } if (result.status !== 0) { - return { loggedIn: false, detail: result.stderr.trim() || `mmx auth status exited with code ${result.status}` }; + const detail = result.stderr.trim() || `mmx auth status exited with code ${result.status}`; + if (!MINIMAX_EXPLICIT_AUTH_ERROR_RE.test(detail) && TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; } const text = `${result.stdout ?? ""} ${result.stderr ?? ""}`.trim(); @@ -2422,7 +2473,7 @@ var DEFAULT_TIMEOUT_MS7 = 9e5; var AUTH_CHECK_TIMEOUT_MS7 = 3e4; var SESSION_EXPORT_TIMEOUT_MS = 3e4; 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_PATTERNS4 = [ +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 ]; function collectOpenCodeContentText(content) { @@ -2628,7 +2679,7 @@ function buildOpenCodeAuthStatus(result) { if (OPENCODE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS4.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS7.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; } return { loggedIn: false, detail }; @@ -2764,7 +2815,7 @@ var DEFAULT_PI_MODEL = null; var DEFAULT_TIMEOUT_MS8 = 9e5; var AUTH_CHECK_TIMEOUT_MS8 = 3e4; 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_PATTERNS5 = [ +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 ]; function collectPiContentText(content) { @@ -2905,7 +2956,7 @@ function buildPiAuthStatus(result) { if (PI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS5.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? DEFAULT_PI_MODEL }; } return { loggedIn: false, detail }; @@ -2949,7 +3000,7 @@ function runPiPrompt({ } const parsed = parsePiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -2960,7 +3011,9 @@ function runPiPrompt({ ok: result.status === 0 && !resultError && !providerError && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // pi's session id comes from its structured `session` event; stdout is blanked so a UUID + // in the answer prose can never be promoted to a fabricated id (stderr/file still allowed). + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel ?? DEFAULT_PI_MODEL, error: result.status === 0 ? resultError || providerError || (hasVisibleText ? null : "pi produced no visible text") : result.stderr.trim() || formatProviderExitError("pi", result.status), status: result.status @@ -3009,7 +3062,7 @@ function runPiPromptStreaming({ }).then((result) => { const parsed = parsePiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -3019,7 +3072,8 @@ function runPiPromptStreaming({ return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // stdout blanked so a UUID in the answer prose is never promoted to a fabricated id. + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel ?? DEFAULT_PI_MODEL, ok: result.ok && !resultError && !providerError && hasVisibleText, error: result.ok ? resultError || providerError || (hasVisibleText ? null : "pi produced no visible text") : result.error @@ -3033,7 +3087,7 @@ var DEFAULT_CMD_MODEL = "deepseek"; var DEFAULT_TIMEOUT_MS9 = 9e5; var AUTH_CHECK_TIMEOUT_MS9 = 3e4; 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_PATTERNS6 = [ +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 ]; function buildCmdInvocation({ @@ -3085,7 +3139,7 @@ ${result.stderr ?? ""}`.trim(); } if (result.error) { const message = result.error.code === "ETIMEDOUT" ? `cmd auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS9 / 1e3)}s` : result.error.message; - if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(message))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS9.some((pattern) => pattern.test(message))) { return { loggedIn: true, detail: `auth probe inconclusive: ${message}`, model: DEFAULT_CMD_MODEL }; } return { loggedIn: false, detail: message }; @@ -3094,7 +3148,7 @@ ${result.stderr ?? ""}`.trim(); if (CMD_EXPLICIT_AUTH_ERROR_RE.test(fallback)) { return { loggedIn: false, detail: fallback }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(fallback))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS9.some((pattern) => pattern.test(fallback))) { return { loggedIn: true, detail: `auth probe inconclusive: ${fallback}`, model: DEFAULT_CMD_MODEL }; } return { loggedIn: false, detail: fallback }; @@ -3130,18 +3184,15 @@ function runCmdPrompt({ }; } const parsed = parseCmdTextResult(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.status === 0 ? hasVisibleText ? null : "cmd produced no visible text" : result.stderr.trim() || formatProviderExitError("cmd", result.status); return { ok: result.status === 0 && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: resolvedSession.sessionId, + // cmd stdout is pure assistant prose with no session-id field; never scan it for a + // UUID, which would fabricate a sessionId from any UUID in the answer (cf. agy v0.6.18). + sessionId: null, model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, error, errorCode: classifyProviderFailure(error, { provider: "cmd" }), @@ -3182,17 +3233,14 @@ function runCmdPromptStreaming({ } }).then((result) => { const parsed = parseCmdTextResult(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.ok ? hasVisibleText ? null : "cmd produced no visible text" : result.error; return { ...result, ...parsed, - sessionId: resolvedSession.sessionId, + // cmd stdout is pure assistant prose with no session-id field; never scan it for a + // UUID, which would fabricate a sessionId from any UUID in the answer (cf. agy v0.6.18). + sessionId: null, model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, ok: result.ok && hasVisibleText, error, @@ -3207,7 +3255,7 @@ var DEFAULT_AGY_MODEL = null; var DEFAULT_TIMEOUT_MS10 = 9e5; var AUTH_CHECK_TIMEOUT_MS10 = 3e4; var AGY_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 = [ +var TRANSIENT_PROBE_ERROR_PATTERNS10 = [ /\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 ]; var AGY_BENIGN_STDERR_RE = /^Shell cwd was reset/i; @@ -3272,7 +3320,7 @@ ${String(result.response ?? "")}`.trim(); if (AGY_EXPLICIT_AUTH_ERROR_RE.test(probeText)) { return { loggedIn: false, detail: probeText }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS7.some((pattern) => pattern.test(probeText))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS10.some((pattern) => pattern.test(probeText))) { return { loggedIn: true, detail: `auth probe inconclusive: ${probeText}`, model: DEFAULT_AGY_MODEL }; } if (result.ok || result.status === 0) { @@ -4579,6 +4627,60 @@ function writeFileAtomic(filePath, contents, options = {}) { writeFileAtomicSync(filePath, contents, options); return filePath; } +function unlinkIfExists(filePath) { + try { + fs3.unlinkSync(filePath); + } catch { + } +} +function tryReclaimStaleLock(lockPath, staleMs) { + let raw; + try { + raw = fs3.readFileSync(lockPath, "utf8"); + } catch { + return true; + } + let lock = null; + try { + lock = JSON.parse(raw); + } catch { + lock = null; + } + const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; + const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; + if (pid != null) { + try { + process3.kill(pid, 0); + } catch (killError) { + if (killError.code === "ESRCH") { + unlinkIfExists(lockPath); + return true; + } + if (killError.code !== "EPERM") { + throw killError; + } + } + const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs2 != null && ageMs2 > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; + } + let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs == null) { + try { + ageMs = Date.now() - fs3.statSync(lockPath).mtimeMs; + } catch { + return true; + } + } + if (ageMs != null && ageMs > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; +} function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { ensureParentDir(lockPath); const deadline = Date.now() + timeoutMs; @@ -4607,32 +4709,7 @@ function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = if (error.code !== "EEXIST") { throw error; } - try { - const lock = JSON.parse(fs3.readFileSync(lockPath, "utf8")); - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - const lockAgeMs = acquiredAt == null ? null : Date.now() - acquiredAt; - let ownerAlive = false; - if (pid != null) { - try { - process3.kill(pid, 0); - ownerAlive = true; - } catch (killError) { - if (killError.code === "ESRCH") { - fs3.unlinkSync(lockPath); - continue; - } - if (killError.code !== "EPERM") { - throw killError; - } - ownerAlive = true; - } - } - if (ownerAlive && lockAgeMs != null && lockAgeMs > staleMs) { - fs3.unlinkSync(lockPath); - continue; - } - } catch { + if (tryReclaimStaleLock(lockPath, staleMs)) { continue; } sleepSync2(pollMs); diff --git a/packages/polycli-timing/test/aggregate.test.js b/packages/polycli-timing/test/aggregate.test.js index 3e47ee2..587e221 100644 --- a/packages/polycli-timing/test/aggregate.test.js +++ b/packages/polycli-timing/test/aggregate.test.js @@ -160,3 +160,50 @@ test("aggregateTimingRecords preserves unsupported-only metrics without fake per assert.equal(ttft.capability, "unsupported"); assert.equal(ttft.p50, null); }); + +test("aggregateTimingRecords labels a metric 'mixed' when unsupported in one record and supported in another", () => { + const summary = aggregateTimingRecords([ + { + version: 1, + provider: "qwen", + runtimePersistence: "session", + measurementScope: "request", + completedAt: "2026-04-21T10:00:00.000Z", + metrics: { + cold: { status: "unsupported", ms: null }, + ttft: { status: "unsupported", ms: null }, + gen: { status: "measured", ms: 100 }, + tool: { status: "unsupported", ms: null }, + retry: { status: "unsupported", ms: null }, + tail: { status: "measured", ms: 10 }, + total: { status: "measured", ms: 110 }, + }, + }, + { + version: 1, + provider: "qwen", + runtimePersistence: "session", + measurementScope: "request", + completedAt: "2026-04-21T10:01:00.000Z", + metrics: { + cold: { status: "unsupported", ms: null }, + ttft: { status: "measured", ms: 200 }, + gen: { status: "measured", ms: 120 }, + tool: { status: "zero", ms: 0 }, + retry: { status: "unsupported", ms: null }, + tail: { status: "measured", ms: 12 }, + total: { status: "measured", ms: 332 }, + }, + }, + ]); + + const ttft = summary.byProvider.qwen.metrics.ttft; + assert.equal(ttft.capability, "mixed"); + assert.equal(ttft.unsupportedCount, 1); + assert.equal(ttft.measuredCount, 1); + // the four states stay distinct, never collapsed into one another + assert.equal(ttft.zeroCount, 0); + assert.equal(ttft.missingCount, 0); + // a metric unsupported in BOTH records remains 'unsupported', not 'mixed' + assert.equal(summary.byProvider.qwen.metrics.cold.capability, "unsupported"); +}); diff --git a/packages/polycli-utils/src/atomic-save.js b/packages/polycli-utils/src/atomic-save.js index 9f939e6..4f2069c 100644 --- a/packages/polycli-utils/src/atomic-save.js +++ b/packages/polycli-utils/src/atomic-save.js @@ -83,6 +83,76 @@ export function writeJsonAtomic(filePath, value, { spaces = 2, finalNewline = tr return writeFileAtomic(filePath, text, "utf8"); } +function unlinkIfExists(filePath) { + try { + fs.unlinkSync(filePath); + } catch { + // already gone — fine + } +} + +// Decide whether an existing (EEXIST) lock can be reclaimed. Returns true when the lock was +// removed (or already vanished) and the caller should retry acquiring; false when it is still +// held by a live owner and the caller should keep waiting. Handles the partial-write case where +// the holder crashed after O_EXCL created the file but before/while writing a valid {pid} body: +// such a no-pid / unparseable lock is reclaimed by age (acquiredAt or file mtime) once stale, +// instead of wedging the store for the full timeout. +function tryReclaimStaleLock(lockPath, staleMs) { + let raw; + try { + raw = fs.readFileSync(lockPath, "utf8"); + } catch { + return true; // vanished between EEXIST and read — retry the acquire + } + + let lock = null; + try { + lock = JSON.parse(raw); + } catch { + lock = null; + } + + const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; + const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; + + if (pid != null) { + try { + process.kill(pid, 0); + } catch (killError) { + if (killError.code === "ESRCH") { + unlinkIfExists(lockPath); + return true; + } + if (killError.code !== "EPERM") { + throw killError; + } + // EPERM: owner is alive but not ours — fall through to the stale-age check. + } + const ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs != null && ageMs > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; + } + + // No valid pid: holder crashed mid-write (partial/empty/malformed lock body). Reclaim once + // older than staleMs, using acquiredAt when present otherwise the lock file's mtime. + let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs == null) { + try { + ageMs = Date.now() - fs.statSync(lockPath).mtimeMs; + } catch { + return true; // vanished — retry the acquire + } + } + if (ageMs != null && ageMs > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; +} + export function withLockfile( lockPath, fn, @@ -117,34 +187,7 @@ export function withLockfile( if (error.code !== "EEXIST") { throw error; } - try { - const lock = JSON.parse(fs.readFileSync(lockPath, "utf8")); - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - const lockAgeMs = acquiredAt == null ? null : Date.now() - acquiredAt; - let ownerAlive = false; - - if (pid != null) { - try { - process.kill(pid, 0); - ownerAlive = true; - } catch (killError) { - if (killError.code === "ESRCH") { - fs.unlinkSync(lockPath); - continue; - } - if (killError.code !== "EPERM") { - throw killError; - } - ownerAlive = true; - } - } - - if (ownerAlive && lockAgeMs != null && lockAgeMs > staleMs) { - fs.unlinkSync(lockPath); - continue; - } - } catch { + if (tryReclaimStaleLock(lockPath, staleMs)) { continue; } sleepSync(pollMs); diff --git a/packages/polycli-utils/src/process.js b/packages/polycli-utils/src/process.js index d034b53..2ace7fd 100644 --- a/packages/polycli-utils/src/process.js +++ b/packages/polycli-utils/src/process.js @@ -12,15 +12,29 @@ export function runCommand(command, args = [], options = {}) { detached: options.detached ?? false, }); const preserveNullStatus = options.preserveNullStatus ?? false; + const status = result.status ?? (preserveNullStatus ? null : 0); + + // A child terminated by a signal (e.g. SIGKILL/OOM, SIGTERM, Ctrl-C) reports status:null + // with no spawn error. When we coerce that null to 0 (the default), callers that gate on + // `status === 0` would misread a signal kill as a SUCCESSFUL run. Surface a synthetic error + // so the existing `if (result.error)` failure branch in every sync provider catches it. + // (A timeout already sets result.error=ETIMEDOUT, so this only fires on a pure signal kill.) + let error = result.error ?? null; + if (!error && result.status == null && result.signal && !preserveNullStatus) { + error = Object.assign( + new Error(`process terminated by signal ${result.signal}`), + { code: result.signal } + ); + } return { command, args, - status: result.status ?? (preserveNullStatus ? null : 0), + status, signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", - error: result.error ?? null, + error, }; } diff --git a/packages/polycli-utils/src/stream.js b/packages/polycli-utils/src/stream.js index d2d7a07..5aee5f2 100644 --- a/packages/polycli-utils/src/stream.js +++ b/packages/polycli-utils/src/stream.js @@ -32,13 +32,17 @@ export function createLineDecoder({ encoding = "utf8", stripCarriageReturn = tru push(chunk) { if (chunk == null) return []; buffer += decoder.write(chunk); + // Drain complete lines FIRST, then enforce the limit on the unterminated residual only. + // Checking before draining would reject a single chunk carrying many complete short + // lines (all drainable) as if it were one pathological line, and drop that data. + const lines = drain(); assertBufferLimit(); - return drain(); + return lines; }, end() { buffer += decoder.end(); - assertBufferLimit(); const lines = drain(); + assertBufferLimit(); if (buffer.length > 0) { lines.push(normalize(buffer)); buffer = ""; diff --git a/packages/polycli-utils/test/atomic-save.test.js b/packages/polycli-utils/test/atomic-save.test.js index 075f8f1..8fdb2bf 100644 --- a/packages/polycli-utils/test/atomic-save.test.js +++ b/packages/polycli-utils/test/atomic-save.test.js @@ -110,3 +110,32 @@ test("withLockfile reclaims a stale lock when the recorded pid appears live", (t assert.ok(kill.mock.callCount() >= 1); assert.equal(fs.existsSync(lockPath), false); }); + +test("withLockfile reclaims a stale no-pid (partial-write) lock by mtime", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-lock-nopid-")); + const lockPath = path.join(dir, "state.lock"); + // Holder crashed after O_EXCL created the file but before writing a valid {pid} body. + fs.writeFileSync(lockPath, "", "utf8"); + const old = new Date(Date.now() - 60_000); + fs.utimesSync(lockPath, old, old); + + const result = withLockfile(lockPath, () => "acquired", { + timeoutMs: 100, + pollMs: 1, + staleMs: 25, + }); + + assert.equal(result, "acquired"); + assert.equal(fs.existsSync(lockPath), false); +}); + +test("withLockfile waits on a fresh no-pid lock instead of reclaiming it immediately", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "polycli-lock-nopid-fresh-")); + const lockPath = path.join(dir, "state.lock"); + fs.writeFileSync(lockPath, "", "utf8"); // fresh empty lock (mtime ~ now) + + assert.throws( + () => withLockfile(lockPath, () => "unreachable", { timeoutMs: 25, pollMs: 1, staleMs: 10_000 }), + LockfileTimeoutError + ); +}); diff --git a/packages/polycli-utils/test/process.test.js b/packages/polycli-utils/test/process.test.js index f9f69fd..0727f1a 100644 --- a/packages/polycli-utils/test/process.test.js +++ b/packages/polycli-utils/test/process.test.js @@ -19,6 +19,17 @@ test("runCommand can preserve null status for signaled children", () => { assert.equal(result.signal, "SIGTERM"); }); +test("runCommand surfaces a signal kill as an error so it is not read as success", () => { + const result = runCommand( + process.execPath, + ["-e", "process.stdout.write('partial'); process.kill(process.pid, 'SIGKILL')"] + ); + // status is coerced to 0 by default, but the synthetic error prevents a false success. + assert.equal(result.signal, "SIGKILL"); + assert.ok(result.error, "a signal-killed child must surface an error"); + assert.match(result.error.message, /signal/i); +}); + test("binaryAvailable reports missing binaries as unavailable", () => { const result = binaryAvailable("__polycli_missing_binary__"); assert.equal(result.available, false); diff --git a/packages/polycli-utils/test/stream.test.js b/packages/polycli-utils/test/stream.test.js index 454b21b..66b46b8 100644 --- a/packages/polycli-utils/test/stream.test.js +++ b/packages/polycli-utils/test/stream.test.js @@ -17,7 +17,7 @@ test("createLineDecoder preserves UTF-8 characters split across chunks", () => { assert.deepEqual(finalPass, ["second"]); }); -test("createLineDecoder rejects an overlong line buffer", () => { +test("createLineDecoder rejects an overlong unterminated line buffer", () => { const decoder = createLineDecoder({ maxBufferBytes: 4 }); assert.throws( @@ -25,3 +25,14 @@ test("createLineDecoder rejects an overlong line buffer", () => { /Line buffer exceeded maxBufferBytes/ ); }); + +test("createLineDecoder drains a burst of complete lines that exceeds the buffer limit", () => { + const decoder = createLineDecoder({ maxBufferBytes: 16 }); + // 10 complete 3-byte lines = 30 bytes, well over the 16-byte limit, but every line is + // newline-terminated and therefore drainable — the limit only guards an unterminated line. + const lines = decoder.push(Buffer.from("ab\n".repeat(10))); + + assert.equal(lines.length, 10); + assert.ok(lines.every((line) => line === "ab")); + assert.deepEqual(decoder.end(), []); +}); diff --git a/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs b/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs index 2e4cd02..188f229 100755 --- a/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs @@ -154,14 +154,22 @@ function runCommand(command, args = [], options = {}) { detached: options.detached ?? false }); const preserveNullStatus = options.preserveNullStatus ?? false; + const status = result.status ?? (preserveNullStatus ? null : 0); + let error = result.error ?? null; + if (!error && result.status == null && result.signal && !preserveNullStatus) { + error = Object.assign( + new Error(`process terminated by signal ${result.signal}`), + { code: result.signal } + ); + } return { command, args, - status: result.status ?? (preserveNullStatus ? null : 0), + status, signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", - error: result.error ?? null + error }; } function firstNonEmptyLine(text) { @@ -367,13 +375,14 @@ function createLineDecoder({ encoding = "utf8", stripCarriageReturn = true, maxB push(chunk) { if (chunk == null) return []; buffer += decoder.write(chunk); + const lines = drain(); assertBufferLimit(); - return drain(); + return lines; }, end() { buffer += decoder.end(); - assertBufferLimit(); const lines = drain(); + assertBufferLimit(); if (buffer.length > 0) { lines.push(normalize(buffer)); buffer = ""; @@ -612,6 +621,10 @@ var CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; var DEFAULT_TIMEOUT_MS = 9e5; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; +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 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 +]; function collectTextFromContent(content) { if (typeof content === "string") { return content; @@ -780,20 +793,27 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = function getClaudeAvailability(cwd) { return binaryAvailable(CLAUDE_BIN, ["--version"], { cwd }); } -function getClaudeAuthStatus(cwd) { - const result = runClaudePrompt({ +function getClaudeAuthStatus(cwd, { promptRunner = runClaudePrompt } = {}) { + const result = promptRunner({ prompt: "ping", cwd, timeout: AUTH_CHECK_TIMEOUT_MS }); - if (!result.ok) { - return { loggedIn: false, detail: result.error }; + if (result.ok) { + return { + loggedIn: true, + detail: "authenticated", + model: result.model ?? null + }; } - return { - loggedIn: true, - detail: "authenticated", - model: null - }; + const detail = String(result.error ?? "").trim() || "claude auth probe failed"; + if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { + return { loggedIn: false, detail }; + } + if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + } + return { loggedIn: false, detail }; } function runClaudePrompt({ prompt, @@ -903,6 +923,10 @@ function runClaudePromptStreaming({ var COPILOT_BIN = process.env.COPILOT_CLI_BIN || "copilot"; var DEFAULT_TIMEOUT_MS2 = 9e5; var AUTH_CHECK_TIMEOUT_MS2 = 3e4; +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 +]; function collectCopilotContentText(content) { if (typeof content === "string") { return content; @@ -1051,20 +1075,27 @@ ${event.data.content}`; function getCopilotAvailability(cwd) { return binaryAvailable(COPILOT_BIN, ["--version"], { cwd }); } -function getCopilotAuthStatus(cwd) { - const result = runCopilotPrompt({ +function getCopilotAuthStatus(cwd, { promptRunner = runCopilotPrompt } = {}) { + const result = promptRunner({ prompt: "ping", cwd, timeout: AUTH_CHECK_TIMEOUT_MS2 }); - if (!result.ok) { - return { loggedIn: false, detail: result.error }; + if (result.ok) { + return { + loggedIn: true, + detail: "authenticated", + model: result.model ?? null + }; } - return { - loggedIn: true, - detail: "authenticated", - model: result.model ?? null - }; + const detail = String(result.error ?? "").trim() || "copilot auth probe failed"; + if (COPILOT_EXPLICIT_AUTH_ERROR_RE.test(detail)) { + return { loggedIn: false, detail }; + } + if (TRANSIENT_PROBE_ERROR_PATTERNS2.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + } + return { loggedIn: false, detail }; } function runCopilotPrompt({ prompt, @@ -1191,7 +1222,7 @@ var AUTH_CHECK_TIMEOUT_MS3 = 3e4; var PROMPT_STDIN_THRESHOLD2 = 1e5; 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_PATTERNS = [ +var TRANSIENT_PROBE_ERROR_PATTERNS3 = [ /\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 ]; function buildGeminiEnv(parentEnv = process.env) { @@ -1295,11 +1326,6 @@ function parseGeminiJsonResult(stdout, stderr, status, { defaultModel = null } = } try { const parsed = JSON.parse(text.slice(jsonStart)); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); if (parsed.error) { return { ok: false, @@ -1308,6 +1334,18 @@ function parseGeminiJsonResult(stdout, stderr, status, { defaultModel = null } = status }; } + if (status !== 0) { + return { + ok: false, + error: String(stderr ?? "").trim() || formatProviderExitError("gemini", status), + status + }; + } + const resolvedSession = resolveSessionId({ + stdout: "", + stderr, + priority: ["stdout", "stderr", "file"] + }); return { ok: true, response: parsed.response ?? "", @@ -1335,7 +1373,7 @@ function buildGeminiAuthStatus(test) { if (GEMINI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS3.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; } return { loggedIn: false, detail }; @@ -1435,7 +1473,7 @@ function runGeminiPromptStreaming({ }).then((result) => { const parsed = parseGeminiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -1444,7 +1482,8 @@ function runGeminiPromptStreaming({ return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // stdout blanked so a UUID in the answer prose is never promoted to a fabricated id. + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel, ok: result.ok && !resultError && hasVisibleText, error: result.ok ? resultError || (hasVisibleText ? null : "gemini produced no visible text") : result.error @@ -1463,7 +1502,7 @@ var AUTH_CHECK_TIMEOUT_MS4 = 3e4; var PROMPT_STDIN_THRESHOLD_BYTES = 1e5; 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 KIMI_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -var TRANSIENT_PROBE_ERROR_PATTERNS2 = [ +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 ]; var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path.join(os.homedir(), ".kimi", "config.toml"); @@ -1695,7 +1734,7 @@ function buildKimiAuthStatus(result) { if (KIMI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS2.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS4.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? configModel }; } return { loggedIn: false, detail }; @@ -1857,7 +1896,7 @@ 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"]; var QWEN_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_PATTERNS3 = [ +var TRANSIENT_PROBE_ERROR_PATTERNS5 = [ /\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 ]; var ENV_ALLOW_EXACT = /* @__PURE__ */ new Set([ @@ -2066,7 +2105,7 @@ function buildQwenAuthStatus(pingResult) { if (QWEN_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS3.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS5.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: pingResult.model ?? null }; } return { loggedIn: false, detail }; @@ -2119,7 +2158,7 @@ function runQwenPrompt({ timeout }); if (result.error) { - const error2 = result.error.message; + 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" }) }; } const parsed = parseQwenStreamText(result.stdout); @@ -2218,6 +2257,10 @@ 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 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 +]; function stripAnsiSgr(text) { return String(text ?? "").replace(/\x1b\[[0-9;]*m/g, ""); } @@ -2256,16 +2299,24 @@ function extractMiniMaxEventText(event) { function getMiniMaxAvailability(cwd) { return binaryAvailable(MMX_BIN, ["--version"], { cwd }); } -async function getMiniMaxAuthStatus(cwd) { - const result = runCommand(MMX_BIN, ["auth", "status", "--output", "json", "--non-interactive"], { +async function getMiniMaxAuthStatus(cwd, { runner = runCommand } = {}) { + const result = runner(MMX_BIN, ["auth", "status", "--output", "json", "--non-interactive"], { cwd, timeout: AUTH_CHECK_TIMEOUT_MS6 }); if (result.error) { - return { loggedIn: false, detail: result.error.message }; + const detail = result.error.code === "ETIMEDOUT" ? `mmx auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS6 / 1e3)}s` : result.error.message; + if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; } if (result.status !== 0) { - return { loggedIn: false, detail: result.stderr.trim() || `mmx auth status exited with code ${result.status}` }; + const detail = result.stderr.trim() || `mmx auth status exited with code ${result.status}`; + if (!MINIMAX_EXPLICIT_AUTH_ERROR_RE.test(detail) && TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; } const text = `${result.stdout ?? ""} ${result.stderr ?? ""}`.trim(); @@ -2422,7 +2473,7 @@ var DEFAULT_TIMEOUT_MS7 = 9e5; var AUTH_CHECK_TIMEOUT_MS7 = 3e4; var SESSION_EXPORT_TIMEOUT_MS = 3e4; 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_PATTERNS4 = [ +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 ]; function collectOpenCodeContentText(content) { @@ -2628,7 +2679,7 @@ function buildOpenCodeAuthStatus(result) { if (OPENCODE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS4.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS7.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; } return { loggedIn: false, detail }; @@ -2764,7 +2815,7 @@ var DEFAULT_PI_MODEL = null; var DEFAULT_TIMEOUT_MS8 = 9e5; var AUTH_CHECK_TIMEOUT_MS8 = 3e4; 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_PATTERNS5 = [ +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 ]; function collectPiContentText(content) { @@ -2905,7 +2956,7 @@ function buildPiAuthStatus(result) { if (PI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS5.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? DEFAULT_PI_MODEL }; } return { loggedIn: false, detail }; @@ -2949,7 +3000,7 @@ function runPiPrompt({ } const parsed = parsePiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -2960,7 +3011,9 @@ function runPiPrompt({ ok: result.status === 0 && !resultError && !providerError && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // pi's session id comes from its structured `session` event; stdout is blanked so a UUID + // in the answer prose can never be promoted to a fabricated id (stderr/file still allowed). + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel ?? DEFAULT_PI_MODEL, error: result.status === 0 ? resultError || providerError || (hasVisibleText ? null : "pi produced no visible text") : result.stderr.trim() || formatProviderExitError("pi", result.status), status: result.status @@ -3009,7 +3062,7 @@ function runPiPromptStreaming({ }).then((result) => { const parsed = parsePiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -3019,7 +3072,8 @@ function runPiPromptStreaming({ return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // stdout blanked so a UUID in the answer prose is never promoted to a fabricated id. + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel ?? DEFAULT_PI_MODEL, ok: result.ok && !resultError && !providerError && hasVisibleText, error: result.ok ? resultError || providerError || (hasVisibleText ? null : "pi produced no visible text") : result.error @@ -3033,7 +3087,7 @@ var DEFAULT_CMD_MODEL = "deepseek"; var DEFAULT_TIMEOUT_MS9 = 9e5; var AUTH_CHECK_TIMEOUT_MS9 = 3e4; 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_PATTERNS6 = [ +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 ]; function buildCmdInvocation({ @@ -3085,7 +3139,7 @@ ${result.stderr ?? ""}`.trim(); } if (result.error) { const message = result.error.code === "ETIMEDOUT" ? `cmd auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS9 / 1e3)}s` : result.error.message; - if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(message))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS9.some((pattern) => pattern.test(message))) { return { loggedIn: true, detail: `auth probe inconclusive: ${message}`, model: DEFAULT_CMD_MODEL }; } return { loggedIn: false, detail: message }; @@ -3094,7 +3148,7 @@ ${result.stderr ?? ""}`.trim(); if (CMD_EXPLICIT_AUTH_ERROR_RE.test(fallback)) { return { loggedIn: false, detail: fallback }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(fallback))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS9.some((pattern) => pattern.test(fallback))) { return { loggedIn: true, detail: `auth probe inconclusive: ${fallback}`, model: DEFAULT_CMD_MODEL }; } return { loggedIn: false, detail: fallback }; @@ -3130,18 +3184,15 @@ function runCmdPrompt({ }; } const parsed = parseCmdTextResult(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.status === 0 ? hasVisibleText ? null : "cmd produced no visible text" : result.stderr.trim() || formatProviderExitError("cmd", result.status); return { ok: result.status === 0 && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: resolvedSession.sessionId, + // cmd stdout is pure assistant prose with no session-id field; never scan it for a + // UUID, which would fabricate a sessionId from any UUID in the answer (cf. agy v0.6.18). + sessionId: null, model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, error, errorCode: classifyProviderFailure(error, { provider: "cmd" }), @@ -3182,17 +3233,14 @@ function runCmdPromptStreaming({ } }).then((result) => { const parsed = parseCmdTextResult(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.ok ? hasVisibleText ? null : "cmd produced no visible text" : result.error; return { ...result, ...parsed, - sessionId: resolvedSession.sessionId, + // cmd stdout is pure assistant prose with no session-id field; never scan it for a + // UUID, which would fabricate a sessionId from any UUID in the answer (cf. agy v0.6.18). + sessionId: null, model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, ok: result.ok && hasVisibleText, error, @@ -3207,7 +3255,7 @@ var DEFAULT_AGY_MODEL = null; var DEFAULT_TIMEOUT_MS10 = 9e5; var AUTH_CHECK_TIMEOUT_MS10 = 3e4; var AGY_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 = [ +var TRANSIENT_PROBE_ERROR_PATTERNS10 = [ /\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 ]; var AGY_BENIGN_STDERR_RE = /^Shell cwd was reset/i; @@ -3272,7 +3320,7 @@ ${String(result.response ?? "")}`.trim(); if (AGY_EXPLICIT_AUTH_ERROR_RE.test(probeText)) { return { loggedIn: false, detail: probeText }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS7.some((pattern) => pattern.test(probeText))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS10.some((pattern) => pattern.test(probeText))) { return { loggedIn: true, detail: `auth probe inconclusive: ${probeText}`, model: DEFAULT_AGY_MODEL }; } if (result.ok || result.status === 0) { @@ -4579,6 +4627,60 @@ function writeFileAtomic(filePath, contents, options = {}) { writeFileAtomicSync(filePath, contents, options); return filePath; } +function unlinkIfExists(filePath) { + try { + fs3.unlinkSync(filePath); + } catch { + } +} +function tryReclaimStaleLock(lockPath, staleMs) { + let raw; + try { + raw = fs3.readFileSync(lockPath, "utf8"); + } catch { + return true; + } + let lock = null; + try { + lock = JSON.parse(raw); + } catch { + lock = null; + } + const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; + const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; + if (pid != null) { + try { + process3.kill(pid, 0); + } catch (killError) { + if (killError.code === "ESRCH") { + unlinkIfExists(lockPath); + return true; + } + if (killError.code !== "EPERM") { + throw killError; + } + } + const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs2 != null && ageMs2 > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; + } + let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs == null) { + try { + ageMs = Date.now() - fs3.statSync(lockPath).mtimeMs; + } catch { + return true; + } + } + if (ageMs != null && ageMs > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; +} function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { ensureParentDir(lockPath); const deadline = Date.now() + timeoutMs; @@ -4607,32 +4709,7 @@ function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = if (error.code !== "EEXIST") { throw error; } - try { - const lock = JSON.parse(fs3.readFileSync(lockPath, "utf8")); - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - const lockAgeMs = acquiredAt == null ? null : Date.now() - acquiredAt; - let ownerAlive = false; - if (pid != null) { - try { - process3.kill(pid, 0); - ownerAlive = true; - } catch (killError) { - if (killError.code === "ESRCH") { - fs3.unlinkSync(lockPath); - continue; - } - if (killError.code !== "EPERM") { - throw killError; - } - ownerAlive = true; - } - } - if (ownerAlive && lockAgeMs != null && lockAgeMs > staleMs) { - fs3.unlinkSync(lockPath); - continue; - } - } catch { + if (tryReclaimStaleLock(lockPath, staleMs)) { continue; } sleepSync2(pollMs); diff --git a/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs b/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs index 2e4cd02..188f229 100755 --- a/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs @@ -154,14 +154,22 @@ function runCommand(command, args = [], options = {}) { detached: options.detached ?? false }); const preserveNullStatus = options.preserveNullStatus ?? false; + const status = result.status ?? (preserveNullStatus ? null : 0); + let error = result.error ?? null; + if (!error && result.status == null && result.signal && !preserveNullStatus) { + error = Object.assign( + new Error(`process terminated by signal ${result.signal}`), + { code: result.signal } + ); + } return { command, args, - status: result.status ?? (preserveNullStatus ? null : 0), + status, signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", - error: result.error ?? null + error }; } function firstNonEmptyLine(text) { @@ -367,13 +375,14 @@ function createLineDecoder({ encoding = "utf8", stripCarriageReturn = true, maxB push(chunk) { if (chunk == null) return []; buffer += decoder.write(chunk); + const lines = drain(); assertBufferLimit(); - return drain(); + return lines; }, end() { buffer += decoder.end(); - assertBufferLimit(); const lines = drain(); + assertBufferLimit(); if (buffer.length > 0) { lines.push(normalize(buffer)); buffer = ""; @@ -612,6 +621,10 @@ var CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; var DEFAULT_TIMEOUT_MS = 9e5; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; +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 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 +]; function collectTextFromContent(content) { if (typeof content === "string") { return content; @@ -780,20 +793,27 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = function getClaudeAvailability(cwd) { return binaryAvailable(CLAUDE_BIN, ["--version"], { cwd }); } -function getClaudeAuthStatus(cwd) { - const result = runClaudePrompt({ +function getClaudeAuthStatus(cwd, { promptRunner = runClaudePrompt } = {}) { + const result = promptRunner({ prompt: "ping", cwd, timeout: AUTH_CHECK_TIMEOUT_MS }); - if (!result.ok) { - return { loggedIn: false, detail: result.error }; + if (result.ok) { + return { + loggedIn: true, + detail: "authenticated", + model: result.model ?? null + }; } - return { - loggedIn: true, - detail: "authenticated", - model: null - }; + const detail = String(result.error ?? "").trim() || "claude auth probe failed"; + if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { + return { loggedIn: false, detail }; + } + if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + } + return { loggedIn: false, detail }; } function runClaudePrompt({ prompt, @@ -903,6 +923,10 @@ function runClaudePromptStreaming({ var COPILOT_BIN = process.env.COPILOT_CLI_BIN || "copilot"; var DEFAULT_TIMEOUT_MS2 = 9e5; var AUTH_CHECK_TIMEOUT_MS2 = 3e4; +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 +]; function collectCopilotContentText(content) { if (typeof content === "string") { return content; @@ -1051,20 +1075,27 @@ ${event.data.content}`; function getCopilotAvailability(cwd) { return binaryAvailable(COPILOT_BIN, ["--version"], { cwd }); } -function getCopilotAuthStatus(cwd) { - const result = runCopilotPrompt({ +function getCopilotAuthStatus(cwd, { promptRunner = runCopilotPrompt } = {}) { + const result = promptRunner({ prompt: "ping", cwd, timeout: AUTH_CHECK_TIMEOUT_MS2 }); - if (!result.ok) { - return { loggedIn: false, detail: result.error }; + if (result.ok) { + return { + loggedIn: true, + detail: "authenticated", + model: result.model ?? null + }; } - return { - loggedIn: true, - detail: "authenticated", - model: result.model ?? null - }; + const detail = String(result.error ?? "").trim() || "copilot auth probe failed"; + if (COPILOT_EXPLICIT_AUTH_ERROR_RE.test(detail)) { + return { loggedIn: false, detail }; + } + if (TRANSIENT_PROBE_ERROR_PATTERNS2.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + } + return { loggedIn: false, detail }; } function runCopilotPrompt({ prompt, @@ -1191,7 +1222,7 @@ var AUTH_CHECK_TIMEOUT_MS3 = 3e4; var PROMPT_STDIN_THRESHOLD2 = 1e5; 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_PATTERNS = [ +var TRANSIENT_PROBE_ERROR_PATTERNS3 = [ /\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 ]; function buildGeminiEnv(parentEnv = process.env) { @@ -1295,11 +1326,6 @@ function parseGeminiJsonResult(stdout, stderr, status, { defaultModel = null } = } try { const parsed = JSON.parse(text.slice(jsonStart)); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); if (parsed.error) { return { ok: false, @@ -1308,6 +1334,18 @@ function parseGeminiJsonResult(stdout, stderr, status, { defaultModel = null } = status }; } + if (status !== 0) { + return { + ok: false, + error: String(stderr ?? "").trim() || formatProviderExitError("gemini", status), + status + }; + } + const resolvedSession = resolveSessionId({ + stdout: "", + stderr, + priority: ["stdout", "stderr", "file"] + }); return { ok: true, response: parsed.response ?? "", @@ -1335,7 +1373,7 @@ function buildGeminiAuthStatus(test) { if (GEMINI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS3.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; } return { loggedIn: false, detail }; @@ -1435,7 +1473,7 @@ function runGeminiPromptStreaming({ }).then((result) => { const parsed = parseGeminiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -1444,7 +1482,8 @@ function runGeminiPromptStreaming({ return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // stdout blanked so a UUID in the answer prose is never promoted to a fabricated id. + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel, ok: result.ok && !resultError && hasVisibleText, error: result.ok ? resultError || (hasVisibleText ? null : "gemini produced no visible text") : result.error @@ -1463,7 +1502,7 @@ var AUTH_CHECK_TIMEOUT_MS4 = 3e4; var PROMPT_STDIN_THRESHOLD_BYTES = 1e5; 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 KIMI_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -var TRANSIENT_PROBE_ERROR_PATTERNS2 = [ +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 ]; var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path.join(os.homedir(), ".kimi", "config.toml"); @@ -1695,7 +1734,7 @@ function buildKimiAuthStatus(result) { if (KIMI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS2.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS4.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? configModel }; } return { loggedIn: false, detail }; @@ -1857,7 +1896,7 @@ 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"]; var QWEN_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_PATTERNS3 = [ +var TRANSIENT_PROBE_ERROR_PATTERNS5 = [ /\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 ]; var ENV_ALLOW_EXACT = /* @__PURE__ */ new Set([ @@ -2066,7 +2105,7 @@ function buildQwenAuthStatus(pingResult) { if (QWEN_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS3.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS5.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: pingResult.model ?? null }; } return { loggedIn: false, detail }; @@ -2119,7 +2158,7 @@ function runQwenPrompt({ timeout }); if (result.error) { - const error2 = result.error.message; + 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" }) }; } const parsed = parseQwenStreamText(result.stdout); @@ -2218,6 +2257,10 @@ 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 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 +]; function stripAnsiSgr(text) { return String(text ?? "").replace(/\x1b\[[0-9;]*m/g, ""); } @@ -2256,16 +2299,24 @@ function extractMiniMaxEventText(event) { function getMiniMaxAvailability(cwd) { return binaryAvailable(MMX_BIN, ["--version"], { cwd }); } -async function getMiniMaxAuthStatus(cwd) { - const result = runCommand(MMX_BIN, ["auth", "status", "--output", "json", "--non-interactive"], { +async function getMiniMaxAuthStatus(cwd, { runner = runCommand } = {}) { + const result = runner(MMX_BIN, ["auth", "status", "--output", "json", "--non-interactive"], { cwd, timeout: AUTH_CHECK_TIMEOUT_MS6 }); if (result.error) { - return { loggedIn: false, detail: result.error.message }; + const detail = result.error.code === "ETIMEDOUT" ? `mmx auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS6 / 1e3)}s` : result.error.message; + if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; } if (result.status !== 0) { - return { loggedIn: false, detail: result.stderr.trim() || `mmx auth status exited with code ${result.status}` }; + const detail = result.stderr.trim() || `mmx auth status exited with code ${result.status}`; + if (!MINIMAX_EXPLICIT_AUTH_ERROR_RE.test(detail) && TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; } const text = `${result.stdout ?? ""} ${result.stderr ?? ""}`.trim(); @@ -2422,7 +2473,7 @@ var DEFAULT_TIMEOUT_MS7 = 9e5; var AUTH_CHECK_TIMEOUT_MS7 = 3e4; var SESSION_EXPORT_TIMEOUT_MS = 3e4; 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_PATTERNS4 = [ +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 ]; function collectOpenCodeContentText(content) { @@ -2628,7 +2679,7 @@ function buildOpenCodeAuthStatus(result) { if (OPENCODE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS4.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS7.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; } return { loggedIn: false, detail }; @@ -2764,7 +2815,7 @@ var DEFAULT_PI_MODEL = null; var DEFAULT_TIMEOUT_MS8 = 9e5; var AUTH_CHECK_TIMEOUT_MS8 = 3e4; 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_PATTERNS5 = [ +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 ]; function collectPiContentText(content) { @@ -2905,7 +2956,7 @@ function buildPiAuthStatus(result) { if (PI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS5.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? DEFAULT_PI_MODEL }; } return { loggedIn: false, detail }; @@ -2949,7 +3000,7 @@ function runPiPrompt({ } const parsed = parsePiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -2960,7 +3011,9 @@ function runPiPrompt({ ok: result.status === 0 && !resultError && !providerError && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // pi's session id comes from its structured `session` event; stdout is blanked so a UUID + // in the answer prose can never be promoted to a fabricated id (stderr/file still allowed). + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel ?? DEFAULT_PI_MODEL, error: result.status === 0 ? resultError || providerError || (hasVisibleText ? null : "pi produced no visible text") : result.stderr.trim() || formatProviderExitError("pi", result.status), status: result.status @@ -3009,7 +3062,7 @@ function runPiPromptStreaming({ }).then((result) => { const parsed = parsePiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -3019,7 +3072,8 @@ function runPiPromptStreaming({ return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // stdout blanked so a UUID in the answer prose is never promoted to a fabricated id. + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel ?? DEFAULT_PI_MODEL, ok: result.ok && !resultError && !providerError && hasVisibleText, error: result.ok ? resultError || providerError || (hasVisibleText ? null : "pi produced no visible text") : result.error @@ -3033,7 +3087,7 @@ var DEFAULT_CMD_MODEL = "deepseek"; var DEFAULT_TIMEOUT_MS9 = 9e5; var AUTH_CHECK_TIMEOUT_MS9 = 3e4; 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_PATTERNS6 = [ +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 ]; function buildCmdInvocation({ @@ -3085,7 +3139,7 @@ ${result.stderr ?? ""}`.trim(); } if (result.error) { const message = result.error.code === "ETIMEDOUT" ? `cmd auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS9 / 1e3)}s` : result.error.message; - if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(message))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS9.some((pattern) => pattern.test(message))) { return { loggedIn: true, detail: `auth probe inconclusive: ${message}`, model: DEFAULT_CMD_MODEL }; } return { loggedIn: false, detail: message }; @@ -3094,7 +3148,7 @@ ${result.stderr ?? ""}`.trim(); if (CMD_EXPLICIT_AUTH_ERROR_RE.test(fallback)) { return { loggedIn: false, detail: fallback }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(fallback))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS9.some((pattern) => pattern.test(fallback))) { return { loggedIn: true, detail: `auth probe inconclusive: ${fallback}`, model: DEFAULT_CMD_MODEL }; } return { loggedIn: false, detail: fallback }; @@ -3130,18 +3184,15 @@ function runCmdPrompt({ }; } const parsed = parseCmdTextResult(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.status === 0 ? hasVisibleText ? null : "cmd produced no visible text" : result.stderr.trim() || formatProviderExitError("cmd", result.status); return { ok: result.status === 0 && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: resolvedSession.sessionId, + // cmd stdout is pure assistant prose with no session-id field; never scan it for a + // UUID, which would fabricate a sessionId from any UUID in the answer (cf. agy v0.6.18). + sessionId: null, model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, error, errorCode: classifyProviderFailure(error, { provider: "cmd" }), @@ -3182,17 +3233,14 @@ function runCmdPromptStreaming({ } }).then((result) => { const parsed = parseCmdTextResult(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.ok ? hasVisibleText ? null : "cmd produced no visible text" : result.error; return { ...result, ...parsed, - sessionId: resolvedSession.sessionId, + // cmd stdout is pure assistant prose with no session-id field; never scan it for a + // UUID, which would fabricate a sessionId from any UUID in the answer (cf. agy v0.6.18). + sessionId: null, model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, ok: result.ok && hasVisibleText, error, @@ -3207,7 +3255,7 @@ var DEFAULT_AGY_MODEL = null; var DEFAULT_TIMEOUT_MS10 = 9e5; var AUTH_CHECK_TIMEOUT_MS10 = 3e4; var AGY_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 = [ +var TRANSIENT_PROBE_ERROR_PATTERNS10 = [ /\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 ]; var AGY_BENIGN_STDERR_RE = /^Shell cwd was reset/i; @@ -3272,7 +3320,7 @@ ${String(result.response ?? "")}`.trim(); if (AGY_EXPLICIT_AUTH_ERROR_RE.test(probeText)) { return { loggedIn: false, detail: probeText }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS7.some((pattern) => pattern.test(probeText))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS10.some((pattern) => pattern.test(probeText))) { return { loggedIn: true, detail: `auth probe inconclusive: ${probeText}`, model: DEFAULT_AGY_MODEL }; } if (result.ok || result.status === 0) { @@ -4579,6 +4627,60 @@ function writeFileAtomic(filePath, contents, options = {}) { writeFileAtomicSync(filePath, contents, options); return filePath; } +function unlinkIfExists(filePath) { + try { + fs3.unlinkSync(filePath); + } catch { + } +} +function tryReclaimStaleLock(lockPath, staleMs) { + let raw; + try { + raw = fs3.readFileSync(lockPath, "utf8"); + } catch { + return true; + } + let lock = null; + try { + lock = JSON.parse(raw); + } catch { + lock = null; + } + const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; + const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; + if (pid != null) { + try { + process3.kill(pid, 0); + } catch (killError) { + if (killError.code === "ESRCH") { + unlinkIfExists(lockPath); + return true; + } + if (killError.code !== "EPERM") { + throw killError; + } + } + const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs2 != null && ageMs2 > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; + } + let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs == null) { + try { + ageMs = Date.now() - fs3.statSync(lockPath).mtimeMs; + } catch { + return true; + } + } + if (ageMs != null && ageMs > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; +} function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { ensureParentDir(lockPath); const deadline = Date.now() + timeoutMs; @@ -4607,32 +4709,7 @@ function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = if (error.code !== "EEXIST") { throw error; } - try { - const lock = JSON.parse(fs3.readFileSync(lockPath, "utf8")); - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - const lockAgeMs = acquiredAt == null ? null : Date.now() - acquiredAt; - let ownerAlive = false; - if (pid != null) { - try { - process3.kill(pid, 0); - ownerAlive = true; - } catch (killError) { - if (killError.code === "ESRCH") { - fs3.unlinkSync(lockPath); - continue; - } - if (killError.code !== "EPERM") { - throw killError; - } - ownerAlive = true; - } - } - if (ownerAlive && lockAgeMs != null && lockAgeMs > staleMs) { - fs3.unlinkSync(lockPath); - continue; - } - } catch { + if (tryReclaimStaleLock(lockPath, staleMs)) { continue; } sleepSync2(pollMs); diff --git a/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs b/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs index 2e4cd02..188f229 100755 --- a/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs @@ -154,14 +154,22 @@ function runCommand(command, args = [], options = {}) { detached: options.detached ?? false }); const preserveNullStatus = options.preserveNullStatus ?? false; + const status = result.status ?? (preserveNullStatus ? null : 0); + let error = result.error ?? null; + if (!error && result.status == null && result.signal && !preserveNullStatus) { + error = Object.assign( + new Error(`process terminated by signal ${result.signal}`), + { code: result.signal } + ); + } return { command, args, - status: result.status ?? (preserveNullStatus ? null : 0), + status, signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", - error: result.error ?? null + error }; } function firstNonEmptyLine(text) { @@ -367,13 +375,14 @@ function createLineDecoder({ encoding = "utf8", stripCarriageReturn = true, maxB push(chunk) { if (chunk == null) return []; buffer += decoder.write(chunk); + const lines = drain(); assertBufferLimit(); - return drain(); + return lines; }, end() { buffer += decoder.end(); - assertBufferLimit(); const lines = drain(); + assertBufferLimit(); if (buffer.length > 0) { lines.push(normalize(buffer)); buffer = ""; @@ -612,6 +621,10 @@ var CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; var DEFAULT_TIMEOUT_MS = 9e5; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; +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 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 +]; function collectTextFromContent(content) { if (typeof content === "string") { return content; @@ -780,20 +793,27 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = function getClaudeAvailability(cwd) { return binaryAvailable(CLAUDE_BIN, ["--version"], { cwd }); } -function getClaudeAuthStatus(cwd) { - const result = runClaudePrompt({ +function getClaudeAuthStatus(cwd, { promptRunner = runClaudePrompt } = {}) { + const result = promptRunner({ prompt: "ping", cwd, timeout: AUTH_CHECK_TIMEOUT_MS }); - if (!result.ok) { - return { loggedIn: false, detail: result.error }; + if (result.ok) { + return { + loggedIn: true, + detail: "authenticated", + model: result.model ?? null + }; } - return { - loggedIn: true, - detail: "authenticated", - model: null - }; + const detail = String(result.error ?? "").trim() || "claude auth probe failed"; + if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { + return { loggedIn: false, detail }; + } + if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + } + return { loggedIn: false, detail }; } function runClaudePrompt({ prompt, @@ -903,6 +923,10 @@ function runClaudePromptStreaming({ var COPILOT_BIN = process.env.COPILOT_CLI_BIN || "copilot"; var DEFAULT_TIMEOUT_MS2 = 9e5; var AUTH_CHECK_TIMEOUT_MS2 = 3e4; +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 +]; function collectCopilotContentText(content) { if (typeof content === "string") { return content; @@ -1051,20 +1075,27 @@ ${event.data.content}`; function getCopilotAvailability(cwd) { return binaryAvailable(COPILOT_BIN, ["--version"], { cwd }); } -function getCopilotAuthStatus(cwd) { - const result = runCopilotPrompt({ +function getCopilotAuthStatus(cwd, { promptRunner = runCopilotPrompt } = {}) { + const result = promptRunner({ prompt: "ping", cwd, timeout: AUTH_CHECK_TIMEOUT_MS2 }); - if (!result.ok) { - return { loggedIn: false, detail: result.error }; + if (result.ok) { + return { + loggedIn: true, + detail: "authenticated", + model: result.model ?? null + }; } - return { - loggedIn: true, - detail: "authenticated", - model: result.model ?? null - }; + const detail = String(result.error ?? "").trim() || "copilot auth probe failed"; + if (COPILOT_EXPLICIT_AUTH_ERROR_RE.test(detail)) { + return { loggedIn: false, detail }; + } + if (TRANSIENT_PROBE_ERROR_PATTERNS2.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + } + return { loggedIn: false, detail }; } function runCopilotPrompt({ prompt, @@ -1191,7 +1222,7 @@ var AUTH_CHECK_TIMEOUT_MS3 = 3e4; var PROMPT_STDIN_THRESHOLD2 = 1e5; 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_PATTERNS = [ +var TRANSIENT_PROBE_ERROR_PATTERNS3 = [ /\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 ]; function buildGeminiEnv(parentEnv = process.env) { @@ -1295,11 +1326,6 @@ function parseGeminiJsonResult(stdout, stderr, status, { defaultModel = null } = } try { const parsed = JSON.parse(text.slice(jsonStart)); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); if (parsed.error) { return { ok: false, @@ -1308,6 +1334,18 @@ function parseGeminiJsonResult(stdout, stderr, status, { defaultModel = null } = status }; } + if (status !== 0) { + return { + ok: false, + error: String(stderr ?? "").trim() || formatProviderExitError("gemini", status), + status + }; + } + const resolvedSession = resolveSessionId({ + stdout: "", + stderr, + priority: ["stdout", "stderr", "file"] + }); return { ok: true, response: parsed.response ?? "", @@ -1335,7 +1373,7 @@ function buildGeminiAuthStatus(test) { if (GEMINI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS3.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; } return { loggedIn: false, detail }; @@ -1435,7 +1473,7 @@ function runGeminiPromptStreaming({ }).then((result) => { const parsed = parseGeminiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -1444,7 +1482,8 @@ function runGeminiPromptStreaming({ return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // stdout blanked so a UUID in the answer prose is never promoted to a fabricated id. + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel, ok: result.ok && !resultError && hasVisibleText, error: result.ok ? resultError || (hasVisibleText ? null : "gemini produced no visible text") : result.error @@ -1463,7 +1502,7 @@ var AUTH_CHECK_TIMEOUT_MS4 = 3e4; var PROMPT_STDIN_THRESHOLD_BYTES = 1e5; 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 KIMI_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -var TRANSIENT_PROBE_ERROR_PATTERNS2 = [ +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 ]; var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path.join(os.homedir(), ".kimi", "config.toml"); @@ -1695,7 +1734,7 @@ function buildKimiAuthStatus(result) { if (KIMI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS2.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS4.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? configModel }; } return { loggedIn: false, detail }; @@ -1857,7 +1896,7 @@ 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"]; var QWEN_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_PATTERNS3 = [ +var TRANSIENT_PROBE_ERROR_PATTERNS5 = [ /\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 ]; var ENV_ALLOW_EXACT = /* @__PURE__ */ new Set([ @@ -2066,7 +2105,7 @@ function buildQwenAuthStatus(pingResult) { if (QWEN_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS3.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS5.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: pingResult.model ?? null }; } return { loggedIn: false, detail }; @@ -2119,7 +2158,7 @@ function runQwenPrompt({ timeout }); if (result.error) { - const error2 = result.error.message; + 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" }) }; } const parsed = parseQwenStreamText(result.stdout); @@ -2218,6 +2257,10 @@ 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 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 +]; function stripAnsiSgr(text) { return String(text ?? "").replace(/\x1b\[[0-9;]*m/g, ""); } @@ -2256,16 +2299,24 @@ function extractMiniMaxEventText(event) { function getMiniMaxAvailability(cwd) { return binaryAvailable(MMX_BIN, ["--version"], { cwd }); } -async function getMiniMaxAuthStatus(cwd) { - const result = runCommand(MMX_BIN, ["auth", "status", "--output", "json", "--non-interactive"], { +async function getMiniMaxAuthStatus(cwd, { runner = runCommand } = {}) { + const result = runner(MMX_BIN, ["auth", "status", "--output", "json", "--non-interactive"], { cwd, timeout: AUTH_CHECK_TIMEOUT_MS6 }); if (result.error) { - return { loggedIn: false, detail: result.error.message }; + const detail = result.error.code === "ETIMEDOUT" ? `mmx auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS6 / 1e3)}s` : result.error.message; + if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; } if (result.status !== 0) { - return { loggedIn: false, detail: result.stderr.trim() || `mmx auth status exited with code ${result.status}` }; + const detail = result.stderr.trim() || `mmx auth status exited with code ${result.status}`; + if (!MINIMAX_EXPLICIT_AUTH_ERROR_RE.test(detail) && TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; } const text = `${result.stdout ?? ""} ${result.stderr ?? ""}`.trim(); @@ -2422,7 +2473,7 @@ var DEFAULT_TIMEOUT_MS7 = 9e5; var AUTH_CHECK_TIMEOUT_MS7 = 3e4; var SESSION_EXPORT_TIMEOUT_MS = 3e4; 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_PATTERNS4 = [ +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 ]; function collectOpenCodeContentText(content) { @@ -2628,7 +2679,7 @@ function buildOpenCodeAuthStatus(result) { if (OPENCODE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS4.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS7.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; } return { loggedIn: false, detail }; @@ -2764,7 +2815,7 @@ var DEFAULT_PI_MODEL = null; var DEFAULT_TIMEOUT_MS8 = 9e5; var AUTH_CHECK_TIMEOUT_MS8 = 3e4; 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_PATTERNS5 = [ +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 ]; function collectPiContentText(content) { @@ -2905,7 +2956,7 @@ function buildPiAuthStatus(result) { if (PI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS5.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? DEFAULT_PI_MODEL }; } return { loggedIn: false, detail }; @@ -2949,7 +3000,7 @@ function runPiPrompt({ } const parsed = parsePiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -2960,7 +3011,9 @@ function runPiPrompt({ ok: result.status === 0 && !resultError && !providerError && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // pi's session id comes from its structured `session` event; stdout is blanked so a UUID + // in the answer prose can never be promoted to a fabricated id (stderr/file still allowed). + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel ?? DEFAULT_PI_MODEL, error: result.status === 0 ? resultError || providerError || (hasVisibleText ? null : "pi produced no visible text") : result.stderr.trim() || formatProviderExitError("pi", result.status), status: result.status @@ -3009,7 +3062,7 @@ function runPiPromptStreaming({ }).then((result) => { const parsed = parsePiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -3019,7 +3072,8 @@ function runPiPromptStreaming({ return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // stdout blanked so a UUID in the answer prose is never promoted to a fabricated id. + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel ?? DEFAULT_PI_MODEL, ok: result.ok && !resultError && !providerError && hasVisibleText, error: result.ok ? resultError || providerError || (hasVisibleText ? null : "pi produced no visible text") : result.error @@ -3033,7 +3087,7 @@ var DEFAULT_CMD_MODEL = "deepseek"; var DEFAULT_TIMEOUT_MS9 = 9e5; var AUTH_CHECK_TIMEOUT_MS9 = 3e4; 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_PATTERNS6 = [ +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 ]; function buildCmdInvocation({ @@ -3085,7 +3139,7 @@ ${result.stderr ?? ""}`.trim(); } if (result.error) { const message = result.error.code === "ETIMEDOUT" ? `cmd auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS9 / 1e3)}s` : result.error.message; - if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(message))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS9.some((pattern) => pattern.test(message))) { return { loggedIn: true, detail: `auth probe inconclusive: ${message}`, model: DEFAULT_CMD_MODEL }; } return { loggedIn: false, detail: message }; @@ -3094,7 +3148,7 @@ ${result.stderr ?? ""}`.trim(); if (CMD_EXPLICIT_AUTH_ERROR_RE.test(fallback)) { return { loggedIn: false, detail: fallback }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(fallback))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS9.some((pattern) => pattern.test(fallback))) { return { loggedIn: true, detail: `auth probe inconclusive: ${fallback}`, model: DEFAULT_CMD_MODEL }; } return { loggedIn: false, detail: fallback }; @@ -3130,18 +3184,15 @@ function runCmdPrompt({ }; } const parsed = parseCmdTextResult(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.status === 0 ? hasVisibleText ? null : "cmd produced no visible text" : result.stderr.trim() || formatProviderExitError("cmd", result.status); return { ok: result.status === 0 && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: resolvedSession.sessionId, + // cmd stdout is pure assistant prose with no session-id field; never scan it for a + // UUID, which would fabricate a sessionId from any UUID in the answer (cf. agy v0.6.18). + sessionId: null, model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, error, errorCode: classifyProviderFailure(error, { provider: "cmd" }), @@ -3182,17 +3233,14 @@ function runCmdPromptStreaming({ } }).then((result) => { const parsed = parseCmdTextResult(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.ok ? hasVisibleText ? null : "cmd produced no visible text" : result.error; return { ...result, ...parsed, - sessionId: resolvedSession.sessionId, + // cmd stdout is pure assistant prose with no session-id field; never scan it for a + // UUID, which would fabricate a sessionId from any UUID in the answer (cf. agy v0.6.18). + sessionId: null, model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, ok: result.ok && hasVisibleText, error, @@ -3207,7 +3255,7 @@ var DEFAULT_AGY_MODEL = null; var DEFAULT_TIMEOUT_MS10 = 9e5; var AUTH_CHECK_TIMEOUT_MS10 = 3e4; var AGY_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 = [ +var TRANSIENT_PROBE_ERROR_PATTERNS10 = [ /\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 ]; var AGY_BENIGN_STDERR_RE = /^Shell cwd was reset/i; @@ -3272,7 +3320,7 @@ ${String(result.response ?? "")}`.trim(); if (AGY_EXPLICIT_AUTH_ERROR_RE.test(probeText)) { return { loggedIn: false, detail: probeText }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS7.some((pattern) => pattern.test(probeText))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS10.some((pattern) => pattern.test(probeText))) { return { loggedIn: true, detail: `auth probe inconclusive: ${probeText}`, model: DEFAULT_AGY_MODEL }; } if (result.ok || result.status === 0) { @@ -4579,6 +4627,60 @@ function writeFileAtomic(filePath, contents, options = {}) { writeFileAtomicSync(filePath, contents, options); return filePath; } +function unlinkIfExists(filePath) { + try { + fs3.unlinkSync(filePath); + } catch { + } +} +function tryReclaimStaleLock(lockPath, staleMs) { + let raw; + try { + raw = fs3.readFileSync(lockPath, "utf8"); + } catch { + return true; + } + let lock = null; + try { + lock = JSON.parse(raw); + } catch { + lock = null; + } + const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; + const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; + if (pid != null) { + try { + process3.kill(pid, 0); + } catch (killError) { + if (killError.code === "ESRCH") { + unlinkIfExists(lockPath); + return true; + } + if (killError.code !== "EPERM") { + throw killError; + } + } + const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs2 != null && ageMs2 > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; + } + let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs == null) { + try { + ageMs = Date.now() - fs3.statSync(lockPath).mtimeMs; + } catch { + return true; + } + } + if (ageMs != null && ageMs > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; +} function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { ensureParentDir(lockPath); const deadline = Date.now() + timeoutMs; @@ -4607,32 +4709,7 @@ function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = if (error.code !== "EEXIST") { throw error; } - try { - const lock = JSON.parse(fs3.readFileSync(lockPath, "utf8")); - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - const lockAgeMs = acquiredAt == null ? null : Date.now() - acquiredAt; - let ownerAlive = false; - if (pid != null) { - try { - process3.kill(pid, 0); - ownerAlive = true; - } catch (killError) { - if (killError.code === "ESRCH") { - fs3.unlinkSync(lockPath); - continue; - } - if (killError.code !== "EPERM") { - throw killError; - } - ownerAlive = true; - } - } - if (ownerAlive && lockAgeMs != null && lockAgeMs > staleMs) { - fs3.unlinkSync(lockPath); - continue; - } - } catch { + if (tryReclaimStaleLock(lockPath, staleMs)) { continue; } sleepSync2(pollMs); diff --git a/plugins/polycli/scripts/polycli-companion.bundle.mjs b/plugins/polycli/scripts/polycli-companion.bundle.mjs index 2e4cd02..188f229 100755 --- a/plugins/polycli/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli/scripts/polycli-companion.bundle.mjs @@ -154,14 +154,22 @@ function runCommand(command, args = [], options = {}) { detached: options.detached ?? false }); const preserveNullStatus = options.preserveNullStatus ?? false; + const status = result.status ?? (preserveNullStatus ? null : 0); + let error = result.error ?? null; + if (!error && result.status == null && result.signal && !preserveNullStatus) { + error = Object.assign( + new Error(`process terminated by signal ${result.signal}`), + { code: result.signal } + ); + } return { command, args, - status: result.status ?? (preserveNullStatus ? null : 0), + status, signal: result.signal ?? null, stdout: result.stdout ?? "", stderr: result.stderr ?? "", - error: result.error ?? null + error }; } function firstNonEmptyLine(text) { @@ -367,13 +375,14 @@ function createLineDecoder({ encoding = "utf8", stripCarriageReturn = true, maxB push(chunk) { if (chunk == null) return []; buffer += decoder.write(chunk); + const lines = drain(); assertBufferLimit(); - return drain(); + return lines; }, end() { buffer += decoder.end(); - assertBufferLimit(); const lines = drain(); + assertBufferLimit(); if (buffer.length > 0) { lines.push(normalize(buffer)); buffer = ""; @@ -612,6 +621,10 @@ var CLAUDE_BIN = process.env.CLAUDE_CLI_BIN || "claude"; var DEFAULT_TIMEOUT_MS = 9e5; var AUTH_CHECK_TIMEOUT_MS = 3e4; var PROMPT_STDIN_THRESHOLD = 1e5; +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 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 +]; function collectTextFromContent(content) { if (typeof content === "string") { return content; @@ -780,20 +793,27 @@ function parseClaudeJsonResult(stdout, stderr, status, { defaultModel = null } = function getClaudeAvailability(cwd) { return binaryAvailable(CLAUDE_BIN, ["--version"], { cwd }); } -function getClaudeAuthStatus(cwd) { - const result = runClaudePrompt({ +function getClaudeAuthStatus(cwd, { promptRunner = runClaudePrompt } = {}) { + const result = promptRunner({ prompt: "ping", cwd, timeout: AUTH_CHECK_TIMEOUT_MS }); - if (!result.ok) { - return { loggedIn: false, detail: result.error }; + if (result.ok) { + return { + loggedIn: true, + detail: "authenticated", + model: result.model ?? null + }; } - return { - loggedIn: true, - detail: "authenticated", - model: null - }; + const detail = String(result.error ?? "").trim() || "claude auth probe failed"; + if (CLAUDE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { + return { loggedIn: false, detail }; + } + if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + } + return { loggedIn: false, detail }; } function runClaudePrompt({ prompt, @@ -903,6 +923,10 @@ function runClaudePromptStreaming({ var COPILOT_BIN = process.env.COPILOT_CLI_BIN || "copilot"; var DEFAULT_TIMEOUT_MS2 = 9e5; var AUTH_CHECK_TIMEOUT_MS2 = 3e4; +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 +]; function collectCopilotContentText(content) { if (typeof content === "string") { return content; @@ -1051,20 +1075,27 @@ ${event.data.content}`; function getCopilotAvailability(cwd) { return binaryAvailable(COPILOT_BIN, ["--version"], { cwd }); } -function getCopilotAuthStatus(cwd) { - const result = runCopilotPrompt({ +function getCopilotAuthStatus(cwd, { promptRunner = runCopilotPrompt } = {}) { + const result = promptRunner({ prompt: "ping", cwd, timeout: AUTH_CHECK_TIMEOUT_MS2 }); - if (!result.ok) { - return { loggedIn: false, detail: result.error }; + if (result.ok) { + return { + loggedIn: true, + detail: "authenticated", + model: result.model ?? null + }; } - return { - loggedIn: true, - detail: "authenticated", - model: result.model ?? null - }; + const detail = String(result.error ?? "").trim() || "copilot auth probe failed"; + if (COPILOT_EXPLICIT_AUTH_ERROR_RE.test(detail)) { + return { loggedIn: false, detail }; + } + if (TRANSIENT_PROBE_ERROR_PATTERNS2.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; + } + return { loggedIn: false, detail }; } function runCopilotPrompt({ prompt, @@ -1191,7 +1222,7 @@ var AUTH_CHECK_TIMEOUT_MS3 = 3e4; var PROMPT_STDIN_THRESHOLD2 = 1e5; 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_PATTERNS = [ +var TRANSIENT_PROBE_ERROR_PATTERNS3 = [ /\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 ]; function buildGeminiEnv(parentEnv = process.env) { @@ -1295,11 +1326,6 @@ function parseGeminiJsonResult(stdout, stderr, status, { defaultModel = null } = } try { const parsed = JSON.parse(text.slice(jsonStart)); - const resolvedSession = resolveSessionId({ - stdout, - stderr, - priority: ["stdout", "stderr", "file"] - }); if (parsed.error) { return { ok: false, @@ -1308,6 +1334,18 @@ function parseGeminiJsonResult(stdout, stderr, status, { defaultModel = null } = status }; } + if (status !== 0) { + return { + ok: false, + error: String(stderr ?? "").trim() || formatProviderExitError("gemini", status), + status + }; + } + const resolvedSession = resolveSessionId({ + stdout: "", + stderr, + priority: ["stdout", "stderr", "file"] + }); return { ok: true, response: parsed.response ?? "", @@ -1335,7 +1373,7 @@ function buildGeminiAuthStatus(test) { if (GEMINI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS3.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; } return { loggedIn: false, detail }; @@ -1435,7 +1473,7 @@ function runGeminiPromptStreaming({ }).then((result) => { const parsed = parseGeminiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -1444,7 +1482,8 @@ function runGeminiPromptStreaming({ return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // stdout blanked so a UUID in the answer prose is never promoted to a fabricated id. + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel, ok: result.ok && !resultError && hasVisibleText, error: result.ok ? resultError || (hasVisibleText ? null : "gemini produced no visible text") : result.error @@ -1463,7 +1502,7 @@ var AUTH_CHECK_TIMEOUT_MS4 = 3e4; var PROMPT_STDIN_THRESHOLD_BYTES = 1e5; 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 KIMI_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -var TRANSIENT_PROBE_ERROR_PATTERNS2 = [ +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 ]; var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path.join(os.homedir(), ".kimi", "config.toml"); @@ -1695,7 +1734,7 @@ function buildKimiAuthStatus(result) { if (KIMI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS2.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS4.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? configModel }; } return { loggedIn: false, detail }; @@ -1857,7 +1896,7 @@ 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"]; var QWEN_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_PATTERNS3 = [ +var TRANSIENT_PROBE_ERROR_PATTERNS5 = [ /\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 ]; var ENV_ALLOW_EXACT = /* @__PURE__ */ new Set([ @@ -2066,7 +2105,7 @@ function buildQwenAuthStatus(pingResult) { if (QWEN_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS3.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS5.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: pingResult.model ?? null }; } return { loggedIn: false, detail }; @@ -2119,7 +2158,7 @@ function runQwenPrompt({ timeout }); if (result.error) { - const error2 = result.error.message; + 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" }) }; } const parsed = parseQwenStreamText(result.stdout); @@ -2218,6 +2257,10 @@ 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 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 +]; function stripAnsiSgr(text) { return String(text ?? "").replace(/\x1b\[[0-9;]*m/g, ""); } @@ -2256,16 +2299,24 @@ function extractMiniMaxEventText(event) { function getMiniMaxAvailability(cwd) { return binaryAvailable(MMX_BIN, ["--version"], { cwd }); } -async function getMiniMaxAuthStatus(cwd) { - const result = runCommand(MMX_BIN, ["auth", "status", "--output", "json", "--non-interactive"], { +async function getMiniMaxAuthStatus(cwd, { runner = runCommand } = {}) { + const result = runner(MMX_BIN, ["auth", "status", "--output", "json", "--non-interactive"], { cwd, timeout: AUTH_CHECK_TIMEOUT_MS6 }); if (result.error) { - return { loggedIn: false, detail: result.error.message }; + const detail = result.error.code === "ETIMEDOUT" ? `mmx auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS6 / 1e3)}s` : result.error.message; + if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; } if (result.status !== 0) { - return { loggedIn: false, detail: result.stderr.trim() || `mmx auth status exited with code ${result.status}` }; + const detail = result.stderr.trim() || `mmx auth status exited with code ${result.status}`; + if (!MINIMAX_EXPLICIT_AUTH_ERROR_RE.test(detail) && TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(detail))) { + return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: null }; + } + return { loggedIn: false, detail }; } const text = `${result.stdout ?? ""} ${result.stderr ?? ""}`.trim(); @@ -2422,7 +2473,7 @@ var DEFAULT_TIMEOUT_MS7 = 9e5; var AUTH_CHECK_TIMEOUT_MS7 = 3e4; var SESSION_EXPORT_TIMEOUT_MS = 3e4; 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_PATTERNS4 = [ +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 ]; function collectOpenCodeContentText(content) { @@ -2628,7 +2679,7 @@ function buildOpenCodeAuthStatus(result) { if (OPENCODE_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS4.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS7.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? null }; } return { loggedIn: false, detail }; @@ -2764,7 +2815,7 @@ var DEFAULT_PI_MODEL = null; var DEFAULT_TIMEOUT_MS8 = 9e5; var AUTH_CHECK_TIMEOUT_MS8 = 3e4; 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_PATTERNS5 = [ +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 ]; function collectPiContentText(content) { @@ -2905,7 +2956,7 @@ function buildPiAuthStatus(result) { if (PI_EXPLICIT_AUTH_ERROR_RE.test(detail)) { return { loggedIn: false, detail }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS5.some((pattern) => pattern.test(detail))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS8.some((pattern) => pattern.test(detail))) { return { loggedIn: true, detail: `auth probe inconclusive: ${detail}`, model: result.model ?? DEFAULT_PI_MODEL }; } return { loggedIn: false, detail }; @@ -2949,7 +3000,7 @@ function runPiPrompt({ } const parsed = parsePiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -2960,7 +3011,9 @@ function runPiPrompt({ ok: result.status === 0 && !resultError && !providerError && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // pi's session id comes from its structured `session` event; stdout is blanked so a UUID + // in the answer prose can never be promoted to a fabricated id (stderr/file still allowed). + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel ?? DEFAULT_PI_MODEL, error: result.status === 0 ? resultError || providerError || (hasVisibleText ? null : "pi produced no visible text") : result.stderr.trim() || formatProviderExitError("pi", result.status), status: result.status @@ -3009,7 +3062,7 @@ function runPiPromptStreaming({ }).then((result) => { const parsed = parsePiStreamText(result.stdout); const resolvedSession = resolveSessionId({ - stdout: result.stdout, + stdout: "", stderr: result.stderr, priority: ["stdout", "stderr", "file"] }); @@ -3019,7 +3072,8 @@ function runPiPromptStreaming({ return { ...result, ...parsed, - sessionId: parsed.sessionId ?? resolvedSession.sessionId, + // stdout blanked so a UUID in the answer prose is never promoted to a fabricated id. + sessionId: parsed.sessionId ?? resolvedSession.sessionId ?? null, model: parsed.model ?? model ?? defaultModel ?? DEFAULT_PI_MODEL, ok: result.ok && !resultError && !providerError && hasVisibleText, error: result.ok ? resultError || providerError || (hasVisibleText ? null : "pi produced no visible text") : result.error @@ -3033,7 +3087,7 @@ var DEFAULT_CMD_MODEL = "deepseek"; var DEFAULT_TIMEOUT_MS9 = 9e5; var AUTH_CHECK_TIMEOUT_MS9 = 3e4; 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_PATTERNS6 = [ +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 ]; function buildCmdInvocation({ @@ -3085,7 +3139,7 @@ ${result.stderr ?? ""}`.trim(); } if (result.error) { const message = result.error.code === "ETIMEDOUT" ? `cmd auth probe timed out after ${Math.round(AUTH_CHECK_TIMEOUT_MS9 / 1e3)}s` : result.error.message; - if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(message))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS9.some((pattern) => pattern.test(message))) { return { loggedIn: true, detail: `auth probe inconclusive: ${message}`, model: DEFAULT_CMD_MODEL }; } return { loggedIn: false, detail: message }; @@ -3094,7 +3148,7 @@ ${result.stderr ?? ""}`.trim(); if (CMD_EXPLICIT_AUTH_ERROR_RE.test(fallback)) { return { loggedIn: false, detail: fallback }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS6.some((pattern) => pattern.test(fallback))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS9.some((pattern) => pattern.test(fallback))) { return { loggedIn: true, detail: `auth probe inconclusive: ${fallback}`, model: DEFAULT_CMD_MODEL }; } return { loggedIn: false, detail: fallback }; @@ -3130,18 +3184,15 @@ function runCmdPrompt({ }; } const parsed = parseCmdTextResult(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.status === 0 ? hasVisibleText ? null : "cmd produced no visible text" : result.stderr.trim() || formatProviderExitError("cmd", result.status); return { ok: result.status === 0 && hasVisibleText, response: parsed.response, events: parsed.events, - sessionId: resolvedSession.sessionId, + // cmd stdout is pure assistant prose with no session-id field; never scan it for a + // UUID, which would fabricate a sessionId from any UUID in the answer (cf. agy v0.6.18). + sessionId: null, model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, error, errorCode: classifyProviderFailure(error, { provider: "cmd" }), @@ -3182,17 +3233,14 @@ function runCmdPromptStreaming({ } }).then((result) => { const parsed = parseCmdTextResult(result.stdout); - const resolvedSession = resolveSessionId({ - stdout: result.stdout, - stderr: result.stderr, - priority: ["stdout", "stderr", "file"] - }); const hasVisibleText = Boolean(parsed.response.trim()); const error = result.ok ? hasVisibleText ? null : "cmd produced no visible text" : result.error; return { ...result, ...parsed, - sessionId: resolvedSession.sessionId, + // cmd stdout is pure assistant prose with no session-id field; never scan it for a + // UUID, which would fabricate a sessionId from any UUID in the answer (cf. agy v0.6.18). + sessionId: null, model: model ?? defaultModel ?? DEFAULT_CMD_MODEL, ok: result.ok && hasVisibleText, error, @@ -3207,7 +3255,7 @@ var DEFAULT_AGY_MODEL = null; var DEFAULT_TIMEOUT_MS10 = 9e5; var AUTH_CHECK_TIMEOUT_MS10 = 3e4; var AGY_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 = [ +var TRANSIENT_PROBE_ERROR_PATTERNS10 = [ /\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 ]; var AGY_BENIGN_STDERR_RE = /^Shell cwd was reset/i; @@ -3272,7 +3320,7 @@ ${String(result.response ?? "")}`.trim(); if (AGY_EXPLICIT_AUTH_ERROR_RE.test(probeText)) { return { loggedIn: false, detail: probeText }; } - if (TRANSIENT_PROBE_ERROR_PATTERNS7.some((pattern) => pattern.test(probeText))) { + if (TRANSIENT_PROBE_ERROR_PATTERNS10.some((pattern) => pattern.test(probeText))) { return { loggedIn: true, detail: `auth probe inconclusive: ${probeText}`, model: DEFAULT_AGY_MODEL }; } if (result.ok || result.status === 0) { @@ -4579,6 +4627,60 @@ function writeFileAtomic(filePath, contents, options = {}) { writeFileAtomicSync(filePath, contents, options); return filePath; } +function unlinkIfExists(filePath) { + try { + fs3.unlinkSync(filePath); + } catch { + } +} +function tryReclaimStaleLock(lockPath, staleMs) { + let raw; + try { + raw = fs3.readFileSync(lockPath, "utf8"); + } catch { + return true; + } + let lock = null; + try { + lock = JSON.parse(raw); + } catch { + lock = null; + } + const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; + const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; + if (pid != null) { + try { + process3.kill(pid, 0); + } catch (killError) { + if (killError.code === "ESRCH") { + unlinkIfExists(lockPath); + return true; + } + if (killError.code !== "EPERM") { + throw killError; + } + } + const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs2 != null && ageMs2 > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; + } + let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs == null) { + try { + ageMs = Date.now() - fs3.statSync(lockPath).mtimeMs; + } catch { + return true; + } + } + if (ageMs != null && ageMs > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; +} function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { ensureParentDir(lockPath); const deadline = Date.now() + timeoutMs; @@ -4607,32 +4709,7 @@ function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = if (error.code !== "EEXIST") { throw error; } - try { - const lock = JSON.parse(fs3.readFileSync(lockPath, "utf8")); - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - const lockAgeMs = acquiredAt == null ? null : Date.now() - acquiredAt; - let ownerAlive = false; - if (pid != null) { - try { - process3.kill(pid, 0); - ownerAlive = true; - } catch (killError) { - if (killError.code === "ESRCH") { - fs3.unlinkSync(lockPath); - continue; - } - if (killError.code !== "EPERM") { - throw killError; - } - ownerAlive = true; - } - } - if (ownerAlive && lockAgeMs != null && lockAgeMs > staleMs) { - fs3.unlinkSync(lockPath); - continue; - } - } catch { + if (tryReclaimStaleLock(lockPath, staleMs)) { continue; } sleepSync2(pollMs); From a943e54ffb7683c8c2081c6d95d19dbcfd8325c7 Mon Sep 17 00:00:00 2001 From: bbingz Date: Tue, 2 Jun 2026 11:32:38 +0800 Subject: [PATCH 2/3] fix(host): harden state/lockfile + job-control concurrency (deep-review 2/2) Host-lib half of the 2026-06-02 workflow+codegraph review batch: - state.mjs no longer ships byte-duplicate writeJsonAtomic/withLockfile; it imports the single hardened implementation from @bbingz/polycli-utils/atomic-save (which now also reclaims a stale no-pid/partial-write lock), removing the drift class behind the v0.6.15 split-store regression. - cacheProviderModel serializes its read-modify-write under a lockfile and writes atomically, fixing the cross-process lost update where concurrent runs dropped each other's cached model. - recoverLedgerTerminalEvents serializes read -> hasLedgerPhase -> append -> removeConfig under a recover lock (distinct path from the ndjson append lock) so two concurrent refreshJob() callers can no longer double-append a run's terminal events. - cancelJob flips status + captures the pid atomically under the state lock BEFORE signalling, so it never kills a pid read from a stale pre-lock snapshot (the pid was confirmed ACTIVE at lock time). Deferred (documented, not rushed): post-finalize ledger recovery for a worker that crashed between the atomic finalize and the ledger append (needs a crashed-vs-normal-terminal discriminator), and a PID-reuse liveness witness (record process start-time at the spawn site; inherent to PID-only liveness). Both medium/low; tracked in memory project_deep_review_2026_06_02. npm test 474/474; companion bundles byte-identical. --- .../bin/polycli-companion.bundle.mjs | 558 +++++++----------- .../scripts/polycli-companion.bundle.mjs | 558 +++++++----------- .../scripts/polycli-companion.bundle.mjs | 558 +++++++----------- .../scripts/polycli-companion.bundle.mjs | 558 +++++++----------- plugins/polycli/scripts/lib/job-control.mjs | 65 +- plugins/polycli/scripts/lib/state.mjs | 104 +--- .../scripts/polycli-companion.bundle.mjs | 558 +++++++----------- plugins/polycli/scripts/polycli-companion.mjs | 12 +- 8 files changed, 1180 insertions(+), 1791 deletions(-) diff --git a/packages/polycli-terminal/bin/polycli-companion.bundle.mjs b/packages/polycli-terminal/bin/polycli-companion.bundle.mjs index 188f229..c04eedc 100755 --- a/packages/polycli-terminal/bin/polycli-companion.bundle.mjs +++ b/packages/polycli-terminal/bin/polycli-companion.bundle.mjs @@ -84,6 +84,170 @@ function parseArgs(argv, config = {}) { return { options, positionals }; } +// packages/polycli-utils/src/atomic-save.js +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import process2 from "node:process"; +var LockfileTimeoutError = class extends Error { + constructor(lockPath, timeoutMs) { + super(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); + this.code = "ELOCKTIMEOUT"; + this.lockPath = lockPath; + this.timeoutMs = timeoutMs; + } +}; +function sleepSync(ms) { + if (ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} +function ensureParentDir(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} +function normalizeWriteOptions(options) { + if (typeof options === "string") { + return { + flag: "w", + mode: 438, + writeOptions: options + }; + } + if (options && typeof options === "object") { + const { flag = "w", mode = 438, ...writeOptions } = options; + return { + flag, + mode, + writeOptions: Object.keys(writeOptions).length > 0 ? writeOptions : void 0 + }; + } + return { + flag: "w", + mode: 438, + writeOptions: void 0 + }; +} +function writeFileAtomicSync(filePath, contents, options = {}) { + ensureParentDir(filePath); + const tmpPath = `${filePath}.tmp.${process2.pid}.${Date.now()}.${crypto.randomUUID()}`; + const { flag, mode, writeOptions } = normalizeWriteOptions(options); + const fd = fs.openSync(tmpPath, flag, mode); + try { + fs.writeFileSync(fd, contents, writeOptions); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmpPath, filePath); + const dirFd = fs.openSync(path.dirname(filePath), "r"); + try { + fs.fsyncSync(dirFd); + } catch (error) { + if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { + throw error; + } + } finally { + fs.closeSync(dirFd); + } +} +function writeFileAtomic(filePath, contents, options = {}) { + writeFileAtomicSync(filePath, contents, options); + return filePath; +} +function writeJsonAtomic(filePath, value, { spaces = 2, finalNewline = true } = {}) { + const text = JSON.stringify(value, null, spaces) + (finalNewline ? "\n" : ""); + return writeFileAtomic(filePath, text, "utf8"); +} +function unlinkIfExists(filePath) { + try { + fs.unlinkSync(filePath); + } catch { + } +} +function tryReclaimStaleLock(lockPath, staleMs) { + let raw; + try { + raw = fs.readFileSync(lockPath, "utf8"); + } catch { + return true; + } + let lock = null; + try { + lock = JSON.parse(raw); + } catch { + lock = null; + } + const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; + const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; + if (pid != null) { + try { + process2.kill(pid, 0); + } catch (killError) { + if (killError.code === "ESRCH") { + unlinkIfExists(lockPath); + return true; + } + if (killError.code !== "EPERM") { + throw killError; + } + } + const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs2 != null && ageMs2 > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; + } + let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs == null) { + try { + ageMs = Date.now() - fs.statSync(lockPath).mtimeMs; + } catch { + return true; + } + } + if (ageMs != null && ageMs > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; +} +function withLockfile(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { + ensureParentDir(lockPath); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const fd = fs.openSync( + lockPath, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, + 384 + ); + try { + fs.writeFileSync(fd, JSON.stringify({ pid: process2.pid, acquiredAt: Date.now() }), "utf8"); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + try { + return fn(); + } finally { + try { + fs.unlinkSync(lockPath); + } catch { + } + } + } catch (error) { + if (error.code !== "EEXIST") { + throw error; + } + if (tryReclaimStaleLock(lockPath, staleMs)) { + continue; + } + sleepSync(pollMs); + } + } + throw new LockfileTimeoutError(lockPath, timeoutMs); +} + // packages/polycli-runtime/src/constants.js var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy"]; var PROVIDER_OPERATION_NAMES = ["prompt"]; @@ -142,7 +306,7 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { // packages/polycli-utils/src/process.js import { spawnSync } from "node:child_process"; -import process2 from "node:process"; +import process3 from "node:process"; function runCommand(command, args = [], options = {}) { const result = spawnSync(command, args, { cwd: options.cwd, @@ -217,7 +381,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI throw new Error(`Invalid pid: ${pid}`); } const killOnce = (targetSignal) => { - if (process2.platform === "win32") { + if (process3.platform === "win32") { const args = ["/PID", String(pid), "/T"]; if (targetSignal === "SIGKILL") { args.push("/F"); @@ -236,7 +400,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI return true; } try { - process2.kill(-pid, targetSignal); + process3.kill(-pid, targetSignal); return true; } catch (error) { if (error.code === "ESRCH") { @@ -246,7 +410,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI if (error.code === "EINVAL") { throw error; } - process2.kill(pid, targetSignal); + process3.kill(pid, targetSignal); return true; } }; @@ -1492,9 +1656,9 @@ function runGeminiPromptStreaming({ } // packages/polycli-runtime/src/kimi.js -import fs from "node:fs"; +import fs2 from "node:fs"; import os from "node:os"; -import path from "node:path"; +import path2 from "node:path"; import { createHash } from "node:crypto"; var KIMI_BIN = process.env.KIMI_CLI_BIN || "kimi"; var DEFAULT_TIMEOUT_MS4 = 9e5; @@ -1505,24 +1669,24 @@ var KIMI_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 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 ]; -var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path.join(os.homedir(), ".kimi", "config.toml"); +var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path2.join(os.homedir(), ".kimi", "config.toml"); function isKimiResumeFooter(text) { return /^To resume:\s*kimi\s+-r\s+/im.test(String(text ?? "").trim()); } function kimiJsonPath() { - return path.join(os.homedir(), ".kimi", "kimi.json"); + return path2.join(os.homedir(), ".kimi", "kimi.json"); } function kimiSessionsDir() { - return path.join(os.homedir(), ".kimi", "sessions"); + return path2.join(os.homedir(), ".kimi", "sessions"); } function resolveRealCwd(cwd) { - return fs.realpathSync(cwd || process.cwd()); + return fs2.realpathSync(cwd || process.cwd()); } function md5CwdPath(realCwd) { return createHash("md5").update(realCwd).digest("hex"); } function formatKimiResumeError(reason, { sessionId, cwd, errCode } = {}) { - const cwdBase = cwd ? path.basename(cwd) : "?"; + const cwdBase = cwd ? path2.basename(cwd) : "?"; if (reason === "invalid-uuid") return "invalid sessionId format; expected UUID."; if (reason === "no-prior-session") return `no prior kimi session for this directory (${cwdBase}). Use /polycli:ask --provider kimi to start one.`; if (reason === "kimi-json-malformed") return "~/.kimi/kimi.json is malformed; cannot resolve last session."; @@ -1534,7 +1698,7 @@ function formatKimiResumeError(reason, { sessionId, cwd, errCode } = {}) { function readKimiLastSession(realCwd) { let raw; try { - raw = fs.readFileSync(kimiJsonPath(), "utf8"); + raw = fs2.readFileSync(kimiJsonPath(), "utf8"); } catch (error) { if (error.code === "ENOENT") return { ok: false, reason: "no-prior-session" }; return { ok: false, reason: "fs-error", errCode: error.code }; @@ -1558,14 +1722,14 @@ function validateKimiResumeTarget({ realCwd, cwdHash, sessionId }) { if (typeof sessionId !== "string" || !KIMI_UUID_RE.test(sessionId)) { return { ok: false, reason: "invalid-uuid" }; } - const sessionDir = path.join(kimiSessionsDir(), cwdHash, sessionId); - const contextPath = path.join(sessionDir, "context.jsonl"); + const sessionDir = path2.join(kimiSessionsDir(), cwdHash, sessionId); + const contextPath = path2.join(sessionDir, "context.jsonl"); try { - const dirStat = fs.statSync(sessionDir); + const dirStat = fs2.statSync(sessionDir); if (!dirStat.isDirectory()) { return { ok: false, reason: "session-not-found" }; } - const contextStat = fs.statSync(contextPath); + const contextStat = fs2.statSync(contextPath); if (!contextStat.isFile() || contextStat.size === 0) { return { ok: false, reason: "session-empty" }; } @@ -1574,7 +1738,7 @@ function validateKimiResumeTarget({ realCwd, cwdHash, sessionId }) { if (error.code === "ENOENT") { return { ok: false, - reason: fs.existsSync(sessionDir) ? "session-empty" : "session-not-found" + reason: fs2.existsSync(sessionDir) ? "session-empty" : "session-not-found" }; } return { ok: false, reason: "fs-error", errCode: error.code, realCwd }; @@ -1647,7 +1811,7 @@ function resolveKimiResumeSession({ } function readKimiDefaultModel() { try { - const text = fs.readFileSync(KIMI_CONFIG_PATH, "utf8"); + const text = fs2.readFileSync(KIMI_CONFIG_PATH, "utf8"); const match = text.match(/^default_model\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))/m); return match ? match[1] ?? match[2] ?? match[3] ?? null : null; } catch { @@ -4208,10 +4372,10 @@ import os4 from "node:os"; import process4 from "node:process"; // plugins/polycli/scripts/lib/state.mjs -import crypto from "node:crypto"; -import fs2 from "node:fs"; +import crypto2 from "node:crypto"; +import fs3 from "node:fs"; import os2 from "node:os"; -import path2 from "node:path"; +import path3 from "node:path"; import { spawnSync as spawnSync2 } from "node:child_process"; var STATE_VERSION = 1; var STATE_FILE_NAME = "state.json"; @@ -4219,99 +4383,7 @@ var JOBS_DIR_NAME = "jobs"; var MAX_JOBS = 100; var POLYCLI_STATE_ROOT_ENV = "POLYCLI_STATE_ROOT"; var PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; -var FALLBACK_STATE_ROOT = path2.join(os2.tmpdir(), "polycli-companion"); -function sleepSync(ms) { - if (ms <= 0) return; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} -function writeJsonAtomic(filePath, value) { - fs2.mkdirSync(path2.dirname(filePath), { recursive: true }); - const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID()}`; - const fd = fs2.openSync(tmpPath, "w", 438); - try { - fs2.writeFileSync(fd, `${JSON.stringify(value, null, 2)} -`, "utf8"); - fs2.fsyncSync(fd); - } finally { - fs2.closeSync(fd); - } - fs2.renameSync(tmpPath, filePath); - try { - const dirFd = fs2.openSync(path2.dirname(filePath), "r"); - try { - fs2.fsyncSync(dirFd); - } finally { - fs2.closeSync(dirFd); - } - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; - } - } -} -function withLockfile(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { - fs2.mkdirSync(path2.dirname(lockPath), { recursive: true }); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const fd = fs2.openSync( - lockPath, - fs2.constants.O_CREAT | fs2.constants.O_EXCL | fs2.constants.O_WRONLY, - 384 - ); - try { - fs2.writeFileSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() }), "utf8"); - fs2.fsyncSync(fd); - } finally { - fs2.closeSync(fd); - } - try { - return fn(); - } finally { - try { - fs2.unlinkSync(lockPath); - } catch { - } - } - } catch (error2) { - if (error2.code !== "EEXIST") { - throw error2; - } - try { - const lock = JSON.parse(fs2.readFileSync(lockPath, "utf8")); - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - const lockAgeMs = acquiredAt == null ? null : Date.now() - acquiredAt; - let ownerAlive = false; - if (pid != null) { - try { - process.kill(pid, 0); - ownerAlive = true; - } catch (killError) { - if (killError.code === "ESRCH") { - fs2.unlinkSync(lockPath); - continue; - } - if (killError.code !== "EPERM") { - throw killError; - } - ownerAlive = true; - } - } - if (ownerAlive && lockAgeMs != null && lockAgeMs > staleMs) { - fs2.unlinkSync(lockPath); - continue; - } - } catch { - continue; - } - sleepSync(pollMs); - } - } - const error = new Error(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); - error.code = "ELOCKTIMEOUT"; - throw error; -} +var FALLBACK_STATE_ROOT = path3.join(os2.tmpdir(), "polycli-companion"); function runCommand2(command, args = [], options = {}) { const result = spawnSync2(command, args, { cwd: options.cwd, @@ -4332,8 +4404,8 @@ function runCommand2(command, args = [], options = {}) { }; } function computeWorkspaceSlug(workspaceRoot) { - const base = path2.basename(workspaceRoot).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "workspace"; - const hash = crypto.createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12); + const base = path3.basename(workspaceRoot).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "workspace"; + const hash = crypto2.createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12); return `${base}-${hash}`; } function defaultState() { @@ -4349,7 +4421,7 @@ function buildCorruptBackupPath(stateFile) { } function backupCorruptStateFile(stateFile) { try { - fs2.renameSync(stateFile, buildCorruptBackupPath(stateFile)); + fs3.renameSync(stateFile, buildCorruptBackupPath(stateFile)); } catch { } } @@ -4357,14 +4429,14 @@ function describeStateRoot() { const polycliStateRoot = process.env[POLYCLI_STATE_ROOT_ENV]; if (polycliStateRoot) { return { - stateRoot: path2.resolve(polycliStateRoot), + stateRoot: path3.resolve(polycliStateRoot), source: POLYCLI_STATE_ROOT_ENV }; } const pluginData = process.env[PLUGIN_DATA_ENV]; if (pluginData) { return { - stateRoot: path2.join(pluginData, "state"), + stateRoot: path3.join(pluginData, "state"), source: PLUGIN_DATA_ENV }; } @@ -4379,36 +4451,36 @@ function stateRootDir() { function resolveWorkspaceRoot(cwd = process.cwd()) { const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { cwd }); if (result.status === 0 && result.stdout.trim()) { - return path2.resolve(result.stdout.trim()); + return path3.resolve(result.stdout.trim()); } - return path2.resolve(cwd); + return path3.resolve(cwd); } function resolveStateDir(workspaceRoot) { - return path2.join(stateRootDir(), computeWorkspaceSlug(workspaceRoot)); + return path3.join(stateRootDir(), computeWorkspaceSlug(workspaceRoot)); } function resolveStateFile(workspaceRoot) { - return path2.join(resolveStateDir(workspaceRoot), STATE_FILE_NAME); + return path3.join(resolveStateDir(workspaceRoot), STATE_FILE_NAME); } function resolveJobsDir(workspaceRoot) { - return path2.join(resolveStateDir(workspaceRoot), JOBS_DIR_NAME); + return path3.join(resolveStateDir(workspaceRoot), JOBS_DIR_NAME); } function resolveJobFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.json`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.json`); } function resolveJobLogFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.log`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.log`); } function resolveJobConfigFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); } function ensureStateDir(workspaceRoot) { - fs2.mkdirSync(resolveJobsDir(workspaceRoot), { recursive: true }); + fs3.mkdirSync(resolveJobsDir(workspaceRoot), { recursive: true }); } function loadState(workspaceRoot) { const stateFile = resolveStateFile(workspaceRoot); let raw; try { - raw = fs2.readFileSync(stateFile, "utf8"); + raw = fs3.readFileSync(stateFile, "utf8"); } catch { return defaultState(); } @@ -4527,7 +4599,7 @@ function writeJobFile(workspaceRoot, jobId, payload) { } function readJobFile(jobFile) { try { - return JSON.parse(fs2.readFileSync(jobFile, "utf8")); + return JSON.parse(fs3.readFileSync(jobFile, "utf8")); } catch { return null; } @@ -4539,14 +4611,14 @@ function writeJobConfigFile(workspaceRoot, jobId, payload) { } function readJobConfigFile(configFile) { try { - return JSON.parse(fs2.readFileSync(configFile, "utf8")); + return JSON.parse(fs3.readFileSync(configFile, "utf8")); } catch { return null; } } function removeJobConfigFile(workspaceRoot, jobId) { try { - fs2.unlinkSync(resolveJobConfigFile(workspaceRoot, jobId)); + fs3.unlinkSync(resolveJobConfigFile(workspaceRoot, jobId)); } catch { } } @@ -4557,168 +4629,6 @@ import path4 from "node:path"; // packages/polycli-utils/src/ndjson.js import fs4 from "node:fs"; - -// packages/polycli-utils/src/atomic-save.js -import crypto2 from "node:crypto"; -import fs3 from "node:fs"; -import path3 from "node:path"; -import process3 from "node:process"; -var LockfileTimeoutError = class extends Error { - constructor(lockPath, timeoutMs) { - super(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); - this.code = "ELOCKTIMEOUT"; - this.lockPath = lockPath; - this.timeoutMs = timeoutMs; - } -}; -function sleepSync2(ms) { - if (ms <= 0) return; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} -function ensureParentDir(filePath) { - fs3.mkdirSync(path3.dirname(filePath), { recursive: true }); -} -function normalizeWriteOptions(options) { - if (typeof options === "string") { - return { - flag: "w", - mode: 438, - writeOptions: options - }; - } - if (options && typeof options === "object") { - const { flag = "w", mode = 438, ...writeOptions } = options; - return { - flag, - mode, - writeOptions: Object.keys(writeOptions).length > 0 ? writeOptions : void 0 - }; - } - return { - flag: "w", - mode: 438, - writeOptions: void 0 - }; -} -function writeFileAtomicSync(filePath, contents, options = {}) { - ensureParentDir(filePath); - const tmpPath = `${filePath}.tmp.${process3.pid}.${Date.now()}.${crypto2.randomUUID()}`; - const { flag, mode, writeOptions } = normalizeWriteOptions(options); - const fd = fs3.openSync(tmpPath, flag, mode); - try { - fs3.writeFileSync(fd, contents, writeOptions); - fs3.fsyncSync(fd); - } finally { - fs3.closeSync(fd); - } - fs3.renameSync(tmpPath, filePath); - const dirFd = fs3.openSync(path3.dirname(filePath), "r"); - try { - fs3.fsyncSync(dirFd); - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; - } - } finally { - fs3.closeSync(dirFd); - } -} -function writeFileAtomic(filePath, contents, options = {}) { - writeFileAtomicSync(filePath, contents, options); - return filePath; -} -function unlinkIfExists(filePath) { - try { - fs3.unlinkSync(filePath); - } catch { - } -} -function tryReclaimStaleLock(lockPath, staleMs) { - let raw; - try { - raw = fs3.readFileSync(lockPath, "utf8"); - } catch { - return true; - } - let lock = null; - try { - lock = JSON.parse(raw); - } catch { - lock = null; - } - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - if (pid != null) { - try { - process3.kill(pid, 0); - } catch (killError) { - if (killError.code === "ESRCH") { - unlinkIfExists(lockPath); - return true; - } - if (killError.code !== "EPERM") { - throw killError; - } - } - const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs2 != null && ageMs2 > staleMs) { - unlinkIfExists(lockPath); - return true; - } - return false; - } - let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs == null) { - try { - ageMs = Date.now() - fs3.statSync(lockPath).mtimeMs; - } catch { - return true; - } - } - if (ageMs != null && ageMs > staleMs) { - unlinkIfExists(lockPath); - return true; - } - return false; -} -function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { - ensureParentDir(lockPath); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const fd = fs3.openSync( - lockPath, - fs3.constants.O_CREAT | fs3.constants.O_EXCL | fs3.constants.O_WRONLY, - 384 - ); - try { - fs3.writeFileSync(fd, JSON.stringify({ pid: process3.pid, acquiredAt: Date.now() }), "utf8"); - fs3.fsyncSync(fd); - } finally { - fs3.closeSync(fd); - } - try { - return fn(); - } finally { - try { - fs3.unlinkSync(lockPath); - } catch { - } - } - } catch (error) { - if (error.code !== "EEXIST") { - throw error; - } - if (tryReclaimStaleLock(lockPath, staleMs)) { - continue; - } - sleepSync2(pollMs); - } - } - throw new LockfileTimeoutError(lockPath, timeoutMs); -} - -// packages/polycli-utils/src/ndjson.js function safeParseLine(line) { try { return JSON.parse(line); @@ -4752,7 +4662,7 @@ function readNdjson(filePath) { } function appendNdjson(filePath, record, { timeoutMs = 1e4, staleMs = 3e4, pollMs = 25, maxBytes = null, keepRatio = 0.5 } = {}) { const lockPath = `${filePath}.lock`; - return withLockfile2(lockPath, () => { + return withLockfile(lockPath, () => { ensureParentDir(filePath); let needsLeadingNewline = false; try { @@ -5309,6 +5219,10 @@ function recoverLedgerTerminalEvents(workspaceRoot, job, { result = null, reason const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, job.jobId)); const runContext = config?.runContext; if (!runContext?.runId) return; + const recoverLock = `${resolveRunLedgerFile(workspaceRoot)}.recover.lock`; + withLockfile(recoverLock, () => writeRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result, reason })); +} +function writeRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result = null, reason = "worker_exited" } = {}) { const events = readRunLedgerEvents(workspaceRoot); const command = runContext.command || config?.execution?.kind || job.kind || null; const provider = runContext.provider || config?.execution?.provider || job.provider || null; @@ -5454,36 +5368,9 @@ async function waitForJob(workspaceRoot, jobId, { timeoutMs = 24e4, pollInterval return { job: timed ? refreshJob(workspaceRoot, timed) : null, waitTimedOut: true }; } async function cancelJob(workspaceRoot, jobId) { - const job = getJob(workspaceRoot, jobId); - if (!job) { - return { cancelled: false, reason: "not_found", jobId }; - } - if (!ACTIVE_STATUSES.has(job.status)) { - return { cancelled: false, reason: "not_cancellable", jobId }; - } - try { - if (job.pid) { - await terminateProcessTree(job.pid, { - signal: "SIGINT", - forceSignal: "SIGKILL", - forceAfterMs: 2e3 - }); - } - } catch (error) { - return { - cancelled: false, - reason: "cancel_failed", - jobId, - error: error.message - }; - } - const cancelledJob = { - ...job, - status: "cancelled", - pid: null, - finishedAt: (/* @__PURE__ */ new Date()).toISOString() - }; + let pidToKill = null; let reason = null; + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); const write = updateJobAtomically(workspaceRoot, jobId, (current) => { if (!current) { reason = "not_found"; @@ -5493,11 +5380,12 @@ async function cancelJob(workspaceRoot, jobId) { reason = "not_cancellable"; return null; } + pidToKill = current.pid ?? null; const nextJob = { ...current, status: "cancelled", pid: null, - finishedAt: cancelledJob.finishedAt + finishedAt }; return { job: nextJob, @@ -5513,6 +5401,17 @@ async function cancelJob(workspaceRoot, jobId) { if (!write.written) { return { cancelled: false, reason: reason || "not_cancellable", jobId }; } + if (pidToKill) { + try { + await terminateProcessTree(pidToKill, { + signal: "SIGINT", + forceSignal: "SIGKILL", + forceAfterMs: 2e3 + }); + } catch (error) { + return { cancelled: true, jobId, killWarning: error.message }; + } + } return { cancelled: true, jobId }; } @@ -6285,12 +6184,9 @@ function cacheProviderModel(workspaceRoot, provider, model) { if (typeof model !== "string" || !model.trim()) return; const cacheFile = resolveProviderModelCacheFile(workspaceRoot); fs9.mkdirSync(path8.dirname(cacheFile), { recursive: true }); - fs9.writeFileSync( - cacheFile, - `${JSON.stringify({ ...readProviderModelCache(workspaceRoot), [provider]: model }, null, 2)} -`, - "utf8" - ); + withLockfile(`${cacheFile}.lock`, () => { + writeJsonAtomic(cacheFile, { ...readProviderModelCache(workspaceRoot), [provider]: model }); + }); } async function inspectProvider(provider) { const runtime = getProviderRuntime(provider); diff --git a/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs b/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs index 188f229..c04eedc 100755 --- a/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-codex/scripts/polycli-companion.bundle.mjs @@ -84,6 +84,170 @@ function parseArgs(argv, config = {}) { return { options, positionals }; } +// packages/polycli-utils/src/atomic-save.js +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import process2 from "node:process"; +var LockfileTimeoutError = class extends Error { + constructor(lockPath, timeoutMs) { + super(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); + this.code = "ELOCKTIMEOUT"; + this.lockPath = lockPath; + this.timeoutMs = timeoutMs; + } +}; +function sleepSync(ms) { + if (ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} +function ensureParentDir(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} +function normalizeWriteOptions(options) { + if (typeof options === "string") { + return { + flag: "w", + mode: 438, + writeOptions: options + }; + } + if (options && typeof options === "object") { + const { flag = "w", mode = 438, ...writeOptions } = options; + return { + flag, + mode, + writeOptions: Object.keys(writeOptions).length > 0 ? writeOptions : void 0 + }; + } + return { + flag: "w", + mode: 438, + writeOptions: void 0 + }; +} +function writeFileAtomicSync(filePath, contents, options = {}) { + ensureParentDir(filePath); + const tmpPath = `${filePath}.tmp.${process2.pid}.${Date.now()}.${crypto.randomUUID()}`; + const { flag, mode, writeOptions } = normalizeWriteOptions(options); + const fd = fs.openSync(tmpPath, flag, mode); + try { + fs.writeFileSync(fd, contents, writeOptions); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmpPath, filePath); + const dirFd = fs.openSync(path.dirname(filePath), "r"); + try { + fs.fsyncSync(dirFd); + } catch (error) { + if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { + throw error; + } + } finally { + fs.closeSync(dirFd); + } +} +function writeFileAtomic(filePath, contents, options = {}) { + writeFileAtomicSync(filePath, contents, options); + return filePath; +} +function writeJsonAtomic(filePath, value, { spaces = 2, finalNewline = true } = {}) { + const text = JSON.stringify(value, null, spaces) + (finalNewline ? "\n" : ""); + return writeFileAtomic(filePath, text, "utf8"); +} +function unlinkIfExists(filePath) { + try { + fs.unlinkSync(filePath); + } catch { + } +} +function tryReclaimStaleLock(lockPath, staleMs) { + let raw; + try { + raw = fs.readFileSync(lockPath, "utf8"); + } catch { + return true; + } + let lock = null; + try { + lock = JSON.parse(raw); + } catch { + lock = null; + } + const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; + const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; + if (pid != null) { + try { + process2.kill(pid, 0); + } catch (killError) { + if (killError.code === "ESRCH") { + unlinkIfExists(lockPath); + return true; + } + if (killError.code !== "EPERM") { + throw killError; + } + } + const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs2 != null && ageMs2 > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; + } + let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs == null) { + try { + ageMs = Date.now() - fs.statSync(lockPath).mtimeMs; + } catch { + return true; + } + } + if (ageMs != null && ageMs > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; +} +function withLockfile(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { + ensureParentDir(lockPath); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const fd = fs.openSync( + lockPath, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, + 384 + ); + try { + fs.writeFileSync(fd, JSON.stringify({ pid: process2.pid, acquiredAt: Date.now() }), "utf8"); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + try { + return fn(); + } finally { + try { + fs.unlinkSync(lockPath); + } catch { + } + } + } catch (error) { + if (error.code !== "EEXIST") { + throw error; + } + if (tryReclaimStaleLock(lockPath, staleMs)) { + continue; + } + sleepSync(pollMs); + } + } + throw new LockfileTimeoutError(lockPath, timeoutMs); +} + // packages/polycli-runtime/src/constants.js var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy"]; var PROVIDER_OPERATION_NAMES = ["prompt"]; @@ -142,7 +306,7 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { // packages/polycli-utils/src/process.js import { spawnSync } from "node:child_process"; -import process2 from "node:process"; +import process3 from "node:process"; function runCommand(command, args = [], options = {}) { const result = spawnSync(command, args, { cwd: options.cwd, @@ -217,7 +381,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI throw new Error(`Invalid pid: ${pid}`); } const killOnce = (targetSignal) => { - if (process2.platform === "win32") { + if (process3.platform === "win32") { const args = ["/PID", String(pid), "/T"]; if (targetSignal === "SIGKILL") { args.push("/F"); @@ -236,7 +400,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI return true; } try { - process2.kill(-pid, targetSignal); + process3.kill(-pid, targetSignal); return true; } catch (error) { if (error.code === "ESRCH") { @@ -246,7 +410,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI if (error.code === "EINVAL") { throw error; } - process2.kill(pid, targetSignal); + process3.kill(pid, targetSignal); return true; } }; @@ -1492,9 +1656,9 @@ function runGeminiPromptStreaming({ } // packages/polycli-runtime/src/kimi.js -import fs from "node:fs"; +import fs2 from "node:fs"; import os from "node:os"; -import path from "node:path"; +import path2 from "node:path"; import { createHash } from "node:crypto"; var KIMI_BIN = process.env.KIMI_CLI_BIN || "kimi"; var DEFAULT_TIMEOUT_MS4 = 9e5; @@ -1505,24 +1669,24 @@ var KIMI_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 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 ]; -var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path.join(os.homedir(), ".kimi", "config.toml"); +var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path2.join(os.homedir(), ".kimi", "config.toml"); function isKimiResumeFooter(text) { return /^To resume:\s*kimi\s+-r\s+/im.test(String(text ?? "").trim()); } function kimiJsonPath() { - return path.join(os.homedir(), ".kimi", "kimi.json"); + return path2.join(os.homedir(), ".kimi", "kimi.json"); } function kimiSessionsDir() { - return path.join(os.homedir(), ".kimi", "sessions"); + return path2.join(os.homedir(), ".kimi", "sessions"); } function resolveRealCwd(cwd) { - return fs.realpathSync(cwd || process.cwd()); + return fs2.realpathSync(cwd || process.cwd()); } function md5CwdPath(realCwd) { return createHash("md5").update(realCwd).digest("hex"); } function formatKimiResumeError(reason, { sessionId, cwd, errCode } = {}) { - const cwdBase = cwd ? path.basename(cwd) : "?"; + const cwdBase = cwd ? path2.basename(cwd) : "?"; if (reason === "invalid-uuid") return "invalid sessionId format; expected UUID."; if (reason === "no-prior-session") return `no prior kimi session for this directory (${cwdBase}). Use /polycli:ask --provider kimi to start one.`; if (reason === "kimi-json-malformed") return "~/.kimi/kimi.json is malformed; cannot resolve last session."; @@ -1534,7 +1698,7 @@ function formatKimiResumeError(reason, { sessionId, cwd, errCode } = {}) { function readKimiLastSession(realCwd) { let raw; try { - raw = fs.readFileSync(kimiJsonPath(), "utf8"); + raw = fs2.readFileSync(kimiJsonPath(), "utf8"); } catch (error) { if (error.code === "ENOENT") return { ok: false, reason: "no-prior-session" }; return { ok: false, reason: "fs-error", errCode: error.code }; @@ -1558,14 +1722,14 @@ function validateKimiResumeTarget({ realCwd, cwdHash, sessionId }) { if (typeof sessionId !== "string" || !KIMI_UUID_RE.test(sessionId)) { return { ok: false, reason: "invalid-uuid" }; } - const sessionDir = path.join(kimiSessionsDir(), cwdHash, sessionId); - const contextPath = path.join(sessionDir, "context.jsonl"); + const sessionDir = path2.join(kimiSessionsDir(), cwdHash, sessionId); + const contextPath = path2.join(sessionDir, "context.jsonl"); try { - const dirStat = fs.statSync(sessionDir); + const dirStat = fs2.statSync(sessionDir); if (!dirStat.isDirectory()) { return { ok: false, reason: "session-not-found" }; } - const contextStat = fs.statSync(contextPath); + const contextStat = fs2.statSync(contextPath); if (!contextStat.isFile() || contextStat.size === 0) { return { ok: false, reason: "session-empty" }; } @@ -1574,7 +1738,7 @@ function validateKimiResumeTarget({ realCwd, cwdHash, sessionId }) { if (error.code === "ENOENT") { return { ok: false, - reason: fs.existsSync(sessionDir) ? "session-empty" : "session-not-found" + reason: fs2.existsSync(sessionDir) ? "session-empty" : "session-not-found" }; } return { ok: false, reason: "fs-error", errCode: error.code, realCwd }; @@ -1647,7 +1811,7 @@ function resolveKimiResumeSession({ } function readKimiDefaultModel() { try { - const text = fs.readFileSync(KIMI_CONFIG_PATH, "utf8"); + const text = fs2.readFileSync(KIMI_CONFIG_PATH, "utf8"); const match = text.match(/^default_model\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))/m); return match ? match[1] ?? match[2] ?? match[3] ?? null : null; } catch { @@ -4208,10 +4372,10 @@ import os4 from "node:os"; import process4 from "node:process"; // plugins/polycli/scripts/lib/state.mjs -import crypto from "node:crypto"; -import fs2 from "node:fs"; +import crypto2 from "node:crypto"; +import fs3 from "node:fs"; import os2 from "node:os"; -import path2 from "node:path"; +import path3 from "node:path"; import { spawnSync as spawnSync2 } from "node:child_process"; var STATE_VERSION = 1; var STATE_FILE_NAME = "state.json"; @@ -4219,99 +4383,7 @@ var JOBS_DIR_NAME = "jobs"; var MAX_JOBS = 100; var POLYCLI_STATE_ROOT_ENV = "POLYCLI_STATE_ROOT"; var PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; -var FALLBACK_STATE_ROOT = path2.join(os2.tmpdir(), "polycli-companion"); -function sleepSync(ms) { - if (ms <= 0) return; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} -function writeJsonAtomic(filePath, value) { - fs2.mkdirSync(path2.dirname(filePath), { recursive: true }); - const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID()}`; - const fd = fs2.openSync(tmpPath, "w", 438); - try { - fs2.writeFileSync(fd, `${JSON.stringify(value, null, 2)} -`, "utf8"); - fs2.fsyncSync(fd); - } finally { - fs2.closeSync(fd); - } - fs2.renameSync(tmpPath, filePath); - try { - const dirFd = fs2.openSync(path2.dirname(filePath), "r"); - try { - fs2.fsyncSync(dirFd); - } finally { - fs2.closeSync(dirFd); - } - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; - } - } -} -function withLockfile(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { - fs2.mkdirSync(path2.dirname(lockPath), { recursive: true }); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const fd = fs2.openSync( - lockPath, - fs2.constants.O_CREAT | fs2.constants.O_EXCL | fs2.constants.O_WRONLY, - 384 - ); - try { - fs2.writeFileSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() }), "utf8"); - fs2.fsyncSync(fd); - } finally { - fs2.closeSync(fd); - } - try { - return fn(); - } finally { - try { - fs2.unlinkSync(lockPath); - } catch { - } - } - } catch (error2) { - if (error2.code !== "EEXIST") { - throw error2; - } - try { - const lock = JSON.parse(fs2.readFileSync(lockPath, "utf8")); - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - const lockAgeMs = acquiredAt == null ? null : Date.now() - acquiredAt; - let ownerAlive = false; - if (pid != null) { - try { - process.kill(pid, 0); - ownerAlive = true; - } catch (killError) { - if (killError.code === "ESRCH") { - fs2.unlinkSync(lockPath); - continue; - } - if (killError.code !== "EPERM") { - throw killError; - } - ownerAlive = true; - } - } - if (ownerAlive && lockAgeMs != null && lockAgeMs > staleMs) { - fs2.unlinkSync(lockPath); - continue; - } - } catch { - continue; - } - sleepSync(pollMs); - } - } - const error = new Error(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); - error.code = "ELOCKTIMEOUT"; - throw error; -} +var FALLBACK_STATE_ROOT = path3.join(os2.tmpdir(), "polycli-companion"); function runCommand2(command, args = [], options = {}) { const result = spawnSync2(command, args, { cwd: options.cwd, @@ -4332,8 +4404,8 @@ function runCommand2(command, args = [], options = {}) { }; } function computeWorkspaceSlug(workspaceRoot) { - const base = path2.basename(workspaceRoot).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "workspace"; - const hash = crypto.createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12); + const base = path3.basename(workspaceRoot).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "workspace"; + const hash = crypto2.createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12); return `${base}-${hash}`; } function defaultState() { @@ -4349,7 +4421,7 @@ function buildCorruptBackupPath(stateFile) { } function backupCorruptStateFile(stateFile) { try { - fs2.renameSync(stateFile, buildCorruptBackupPath(stateFile)); + fs3.renameSync(stateFile, buildCorruptBackupPath(stateFile)); } catch { } } @@ -4357,14 +4429,14 @@ function describeStateRoot() { const polycliStateRoot = process.env[POLYCLI_STATE_ROOT_ENV]; if (polycliStateRoot) { return { - stateRoot: path2.resolve(polycliStateRoot), + stateRoot: path3.resolve(polycliStateRoot), source: POLYCLI_STATE_ROOT_ENV }; } const pluginData = process.env[PLUGIN_DATA_ENV]; if (pluginData) { return { - stateRoot: path2.join(pluginData, "state"), + stateRoot: path3.join(pluginData, "state"), source: PLUGIN_DATA_ENV }; } @@ -4379,36 +4451,36 @@ function stateRootDir() { function resolveWorkspaceRoot(cwd = process.cwd()) { const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { cwd }); if (result.status === 0 && result.stdout.trim()) { - return path2.resolve(result.stdout.trim()); + return path3.resolve(result.stdout.trim()); } - return path2.resolve(cwd); + return path3.resolve(cwd); } function resolveStateDir(workspaceRoot) { - return path2.join(stateRootDir(), computeWorkspaceSlug(workspaceRoot)); + return path3.join(stateRootDir(), computeWorkspaceSlug(workspaceRoot)); } function resolveStateFile(workspaceRoot) { - return path2.join(resolveStateDir(workspaceRoot), STATE_FILE_NAME); + return path3.join(resolveStateDir(workspaceRoot), STATE_FILE_NAME); } function resolveJobsDir(workspaceRoot) { - return path2.join(resolveStateDir(workspaceRoot), JOBS_DIR_NAME); + return path3.join(resolveStateDir(workspaceRoot), JOBS_DIR_NAME); } function resolveJobFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.json`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.json`); } function resolveJobLogFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.log`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.log`); } function resolveJobConfigFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); } function ensureStateDir(workspaceRoot) { - fs2.mkdirSync(resolveJobsDir(workspaceRoot), { recursive: true }); + fs3.mkdirSync(resolveJobsDir(workspaceRoot), { recursive: true }); } function loadState(workspaceRoot) { const stateFile = resolveStateFile(workspaceRoot); let raw; try { - raw = fs2.readFileSync(stateFile, "utf8"); + raw = fs3.readFileSync(stateFile, "utf8"); } catch { return defaultState(); } @@ -4527,7 +4599,7 @@ function writeJobFile(workspaceRoot, jobId, payload) { } function readJobFile(jobFile) { try { - return JSON.parse(fs2.readFileSync(jobFile, "utf8")); + return JSON.parse(fs3.readFileSync(jobFile, "utf8")); } catch { return null; } @@ -4539,14 +4611,14 @@ function writeJobConfigFile(workspaceRoot, jobId, payload) { } function readJobConfigFile(configFile) { try { - return JSON.parse(fs2.readFileSync(configFile, "utf8")); + return JSON.parse(fs3.readFileSync(configFile, "utf8")); } catch { return null; } } function removeJobConfigFile(workspaceRoot, jobId) { try { - fs2.unlinkSync(resolveJobConfigFile(workspaceRoot, jobId)); + fs3.unlinkSync(resolveJobConfigFile(workspaceRoot, jobId)); } catch { } } @@ -4557,168 +4629,6 @@ import path4 from "node:path"; // packages/polycli-utils/src/ndjson.js import fs4 from "node:fs"; - -// packages/polycli-utils/src/atomic-save.js -import crypto2 from "node:crypto"; -import fs3 from "node:fs"; -import path3 from "node:path"; -import process3 from "node:process"; -var LockfileTimeoutError = class extends Error { - constructor(lockPath, timeoutMs) { - super(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); - this.code = "ELOCKTIMEOUT"; - this.lockPath = lockPath; - this.timeoutMs = timeoutMs; - } -}; -function sleepSync2(ms) { - if (ms <= 0) return; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} -function ensureParentDir(filePath) { - fs3.mkdirSync(path3.dirname(filePath), { recursive: true }); -} -function normalizeWriteOptions(options) { - if (typeof options === "string") { - return { - flag: "w", - mode: 438, - writeOptions: options - }; - } - if (options && typeof options === "object") { - const { flag = "w", mode = 438, ...writeOptions } = options; - return { - flag, - mode, - writeOptions: Object.keys(writeOptions).length > 0 ? writeOptions : void 0 - }; - } - return { - flag: "w", - mode: 438, - writeOptions: void 0 - }; -} -function writeFileAtomicSync(filePath, contents, options = {}) { - ensureParentDir(filePath); - const tmpPath = `${filePath}.tmp.${process3.pid}.${Date.now()}.${crypto2.randomUUID()}`; - const { flag, mode, writeOptions } = normalizeWriteOptions(options); - const fd = fs3.openSync(tmpPath, flag, mode); - try { - fs3.writeFileSync(fd, contents, writeOptions); - fs3.fsyncSync(fd); - } finally { - fs3.closeSync(fd); - } - fs3.renameSync(tmpPath, filePath); - const dirFd = fs3.openSync(path3.dirname(filePath), "r"); - try { - fs3.fsyncSync(dirFd); - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; - } - } finally { - fs3.closeSync(dirFd); - } -} -function writeFileAtomic(filePath, contents, options = {}) { - writeFileAtomicSync(filePath, contents, options); - return filePath; -} -function unlinkIfExists(filePath) { - try { - fs3.unlinkSync(filePath); - } catch { - } -} -function tryReclaimStaleLock(lockPath, staleMs) { - let raw; - try { - raw = fs3.readFileSync(lockPath, "utf8"); - } catch { - return true; - } - let lock = null; - try { - lock = JSON.parse(raw); - } catch { - lock = null; - } - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - if (pid != null) { - try { - process3.kill(pid, 0); - } catch (killError) { - if (killError.code === "ESRCH") { - unlinkIfExists(lockPath); - return true; - } - if (killError.code !== "EPERM") { - throw killError; - } - } - const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs2 != null && ageMs2 > staleMs) { - unlinkIfExists(lockPath); - return true; - } - return false; - } - let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs == null) { - try { - ageMs = Date.now() - fs3.statSync(lockPath).mtimeMs; - } catch { - return true; - } - } - if (ageMs != null && ageMs > staleMs) { - unlinkIfExists(lockPath); - return true; - } - return false; -} -function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { - ensureParentDir(lockPath); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const fd = fs3.openSync( - lockPath, - fs3.constants.O_CREAT | fs3.constants.O_EXCL | fs3.constants.O_WRONLY, - 384 - ); - try { - fs3.writeFileSync(fd, JSON.stringify({ pid: process3.pid, acquiredAt: Date.now() }), "utf8"); - fs3.fsyncSync(fd); - } finally { - fs3.closeSync(fd); - } - try { - return fn(); - } finally { - try { - fs3.unlinkSync(lockPath); - } catch { - } - } - } catch (error) { - if (error.code !== "EEXIST") { - throw error; - } - if (tryReclaimStaleLock(lockPath, staleMs)) { - continue; - } - sleepSync2(pollMs); - } - } - throw new LockfileTimeoutError(lockPath, timeoutMs); -} - -// packages/polycli-utils/src/ndjson.js function safeParseLine(line) { try { return JSON.parse(line); @@ -4752,7 +4662,7 @@ function readNdjson(filePath) { } function appendNdjson(filePath, record, { timeoutMs = 1e4, staleMs = 3e4, pollMs = 25, maxBytes = null, keepRatio = 0.5 } = {}) { const lockPath = `${filePath}.lock`; - return withLockfile2(lockPath, () => { + return withLockfile(lockPath, () => { ensureParentDir(filePath); let needsLeadingNewline = false; try { @@ -5309,6 +5219,10 @@ function recoverLedgerTerminalEvents(workspaceRoot, job, { result = null, reason const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, job.jobId)); const runContext = config?.runContext; if (!runContext?.runId) return; + const recoverLock = `${resolveRunLedgerFile(workspaceRoot)}.recover.lock`; + withLockfile(recoverLock, () => writeRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result, reason })); +} +function writeRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result = null, reason = "worker_exited" } = {}) { const events = readRunLedgerEvents(workspaceRoot); const command = runContext.command || config?.execution?.kind || job.kind || null; const provider = runContext.provider || config?.execution?.provider || job.provider || null; @@ -5454,36 +5368,9 @@ async function waitForJob(workspaceRoot, jobId, { timeoutMs = 24e4, pollInterval return { job: timed ? refreshJob(workspaceRoot, timed) : null, waitTimedOut: true }; } async function cancelJob(workspaceRoot, jobId) { - const job = getJob(workspaceRoot, jobId); - if (!job) { - return { cancelled: false, reason: "not_found", jobId }; - } - if (!ACTIVE_STATUSES.has(job.status)) { - return { cancelled: false, reason: "not_cancellable", jobId }; - } - try { - if (job.pid) { - await terminateProcessTree(job.pid, { - signal: "SIGINT", - forceSignal: "SIGKILL", - forceAfterMs: 2e3 - }); - } - } catch (error) { - return { - cancelled: false, - reason: "cancel_failed", - jobId, - error: error.message - }; - } - const cancelledJob = { - ...job, - status: "cancelled", - pid: null, - finishedAt: (/* @__PURE__ */ new Date()).toISOString() - }; + let pidToKill = null; let reason = null; + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); const write = updateJobAtomically(workspaceRoot, jobId, (current) => { if (!current) { reason = "not_found"; @@ -5493,11 +5380,12 @@ async function cancelJob(workspaceRoot, jobId) { reason = "not_cancellable"; return null; } + pidToKill = current.pid ?? null; const nextJob = { ...current, status: "cancelled", pid: null, - finishedAt: cancelledJob.finishedAt + finishedAt }; return { job: nextJob, @@ -5513,6 +5401,17 @@ async function cancelJob(workspaceRoot, jobId) { if (!write.written) { return { cancelled: false, reason: reason || "not_cancellable", jobId }; } + if (pidToKill) { + try { + await terminateProcessTree(pidToKill, { + signal: "SIGINT", + forceSignal: "SIGKILL", + forceAfterMs: 2e3 + }); + } catch (error) { + return { cancelled: true, jobId, killWarning: error.message }; + } + } return { cancelled: true, jobId }; } @@ -6285,12 +6184,9 @@ function cacheProviderModel(workspaceRoot, provider, model) { if (typeof model !== "string" || !model.trim()) return; const cacheFile = resolveProviderModelCacheFile(workspaceRoot); fs9.mkdirSync(path8.dirname(cacheFile), { recursive: true }); - fs9.writeFileSync( - cacheFile, - `${JSON.stringify({ ...readProviderModelCache(workspaceRoot), [provider]: model }, null, 2)} -`, - "utf8" - ); + withLockfile(`${cacheFile}.lock`, () => { + writeJsonAtomic(cacheFile, { ...readProviderModelCache(workspaceRoot), [provider]: model }); + }); } async function inspectProvider(provider) { const runtime = getProviderRuntime(provider); diff --git a/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs b/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs index 188f229..c04eedc 100755 --- a/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-copilot/scripts/polycli-companion.bundle.mjs @@ -84,6 +84,170 @@ function parseArgs(argv, config = {}) { return { options, positionals }; } +// packages/polycli-utils/src/atomic-save.js +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import process2 from "node:process"; +var LockfileTimeoutError = class extends Error { + constructor(lockPath, timeoutMs) { + super(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); + this.code = "ELOCKTIMEOUT"; + this.lockPath = lockPath; + this.timeoutMs = timeoutMs; + } +}; +function sleepSync(ms) { + if (ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} +function ensureParentDir(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} +function normalizeWriteOptions(options) { + if (typeof options === "string") { + return { + flag: "w", + mode: 438, + writeOptions: options + }; + } + if (options && typeof options === "object") { + const { flag = "w", mode = 438, ...writeOptions } = options; + return { + flag, + mode, + writeOptions: Object.keys(writeOptions).length > 0 ? writeOptions : void 0 + }; + } + return { + flag: "w", + mode: 438, + writeOptions: void 0 + }; +} +function writeFileAtomicSync(filePath, contents, options = {}) { + ensureParentDir(filePath); + const tmpPath = `${filePath}.tmp.${process2.pid}.${Date.now()}.${crypto.randomUUID()}`; + const { flag, mode, writeOptions } = normalizeWriteOptions(options); + const fd = fs.openSync(tmpPath, flag, mode); + try { + fs.writeFileSync(fd, contents, writeOptions); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmpPath, filePath); + const dirFd = fs.openSync(path.dirname(filePath), "r"); + try { + fs.fsyncSync(dirFd); + } catch (error) { + if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { + throw error; + } + } finally { + fs.closeSync(dirFd); + } +} +function writeFileAtomic(filePath, contents, options = {}) { + writeFileAtomicSync(filePath, contents, options); + return filePath; +} +function writeJsonAtomic(filePath, value, { spaces = 2, finalNewline = true } = {}) { + const text = JSON.stringify(value, null, spaces) + (finalNewline ? "\n" : ""); + return writeFileAtomic(filePath, text, "utf8"); +} +function unlinkIfExists(filePath) { + try { + fs.unlinkSync(filePath); + } catch { + } +} +function tryReclaimStaleLock(lockPath, staleMs) { + let raw; + try { + raw = fs.readFileSync(lockPath, "utf8"); + } catch { + return true; + } + let lock = null; + try { + lock = JSON.parse(raw); + } catch { + lock = null; + } + const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; + const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; + if (pid != null) { + try { + process2.kill(pid, 0); + } catch (killError) { + if (killError.code === "ESRCH") { + unlinkIfExists(lockPath); + return true; + } + if (killError.code !== "EPERM") { + throw killError; + } + } + const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs2 != null && ageMs2 > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; + } + let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs == null) { + try { + ageMs = Date.now() - fs.statSync(lockPath).mtimeMs; + } catch { + return true; + } + } + if (ageMs != null && ageMs > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; +} +function withLockfile(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { + ensureParentDir(lockPath); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const fd = fs.openSync( + lockPath, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, + 384 + ); + try { + fs.writeFileSync(fd, JSON.stringify({ pid: process2.pid, acquiredAt: Date.now() }), "utf8"); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + try { + return fn(); + } finally { + try { + fs.unlinkSync(lockPath); + } catch { + } + } + } catch (error) { + if (error.code !== "EEXIST") { + throw error; + } + if (tryReclaimStaleLock(lockPath, staleMs)) { + continue; + } + sleepSync(pollMs); + } + } + throw new LockfileTimeoutError(lockPath, timeoutMs); +} + // packages/polycli-runtime/src/constants.js var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy"]; var PROVIDER_OPERATION_NAMES = ["prompt"]; @@ -142,7 +306,7 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { // packages/polycli-utils/src/process.js import { spawnSync } from "node:child_process"; -import process2 from "node:process"; +import process3 from "node:process"; function runCommand(command, args = [], options = {}) { const result = spawnSync(command, args, { cwd: options.cwd, @@ -217,7 +381,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI throw new Error(`Invalid pid: ${pid}`); } const killOnce = (targetSignal) => { - if (process2.platform === "win32") { + if (process3.platform === "win32") { const args = ["/PID", String(pid), "/T"]; if (targetSignal === "SIGKILL") { args.push("/F"); @@ -236,7 +400,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI return true; } try { - process2.kill(-pid, targetSignal); + process3.kill(-pid, targetSignal); return true; } catch (error) { if (error.code === "ESRCH") { @@ -246,7 +410,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI if (error.code === "EINVAL") { throw error; } - process2.kill(pid, targetSignal); + process3.kill(pid, targetSignal); return true; } }; @@ -1492,9 +1656,9 @@ function runGeminiPromptStreaming({ } // packages/polycli-runtime/src/kimi.js -import fs from "node:fs"; +import fs2 from "node:fs"; import os from "node:os"; -import path from "node:path"; +import path2 from "node:path"; import { createHash } from "node:crypto"; var KIMI_BIN = process.env.KIMI_CLI_BIN || "kimi"; var DEFAULT_TIMEOUT_MS4 = 9e5; @@ -1505,24 +1669,24 @@ var KIMI_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 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 ]; -var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path.join(os.homedir(), ".kimi", "config.toml"); +var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path2.join(os.homedir(), ".kimi", "config.toml"); function isKimiResumeFooter(text) { return /^To resume:\s*kimi\s+-r\s+/im.test(String(text ?? "").trim()); } function kimiJsonPath() { - return path.join(os.homedir(), ".kimi", "kimi.json"); + return path2.join(os.homedir(), ".kimi", "kimi.json"); } function kimiSessionsDir() { - return path.join(os.homedir(), ".kimi", "sessions"); + return path2.join(os.homedir(), ".kimi", "sessions"); } function resolveRealCwd(cwd) { - return fs.realpathSync(cwd || process.cwd()); + return fs2.realpathSync(cwd || process.cwd()); } function md5CwdPath(realCwd) { return createHash("md5").update(realCwd).digest("hex"); } function formatKimiResumeError(reason, { sessionId, cwd, errCode } = {}) { - const cwdBase = cwd ? path.basename(cwd) : "?"; + const cwdBase = cwd ? path2.basename(cwd) : "?"; if (reason === "invalid-uuid") return "invalid sessionId format; expected UUID."; if (reason === "no-prior-session") return `no prior kimi session for this directory (${cwdBase}). Use /polycli:ask --provider kimi to start one.`; if (reason === "kimi-json-malformed") return "~/.kimi/kimi.json is malformed; cannot resolve last session."; @@ -1534,7 +1698,7 @@ function formatKimiResumeError(reason, { sessionId, cwd, errCode } = {}) { function readKimiLastSession(realCwd) { let raw; try { - raw = fs.readFileSync(kimiJsonPath(), "utf8"); + raw = fs2.readFileSync(kimiJsonPath(), "utf8"); } catch (error) { if (error.code === "ENOENT") return { ok: false, reason: "no-prior-session" }; return { ok: false, reason: "fs-error", errCode: error.code }; @@ -1558,14 +1722,14 @@ function validateKimiResumeTarget({ realCwd, cwdHash, sessionId }) { if (typeof sessionId !== "string" || !KIMI_UUID_RE.test(sessionId)) { return { ok: false, reason: "invalid-uuid" }; } - const sessionDir = path.join(kimiSessionsDir(), cwdHash, sessionId); - const contextPath = path.join(sessionDir, "context.jsonl"); + const sessionDir = path2.join(kimiSessionsDir(), cwdHash, sessionId); + const contextPath = path2.join(sessionDir, "context.jsonl"); try { - const dirStat = fs.statSync(sessionDir); + const dirStat = fs2.statSync(sessionDir); if (!dirStat.isDirectory()) { return { ok: false, reason: "session-not-found" }; } - const contextStat = fs.statSync(contextPath); + const contextStat = fs2.statSync(contextPath); if (!contextStat.isFile() || contextStat.size === 0) { return { ok: false, reason: "session-empty" }; } @@ -1574,7 +1738,7 @@ function validateKimiResumeTarget({ realCwd, cwdHash, sessionId }) { if (error.code === "ENOENT") { return { ok: false, - reason: fs.existsSync(sessionDir) ? "session-empty" : "session-not-found" + reason: fs2.existsSync(sessionDir) ? "session-empty" : "session-not-found" }; } return { ok: false, reason: "fs-error", errCode: error.code, realCwd }; @@ -1647,7 +1811,7 @@ function resolveKimiResumeSession({ } function readKimiDefaultModel() { try { - const text = fs.readFileSync(KIMI_CONFIG_PATH, "utf8"); + const text = fs2.readFileSync(KIMI_CONFIG_PATH, "utf8"); const match = text.match(/^default_model\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))/m); return match ? match[1] ?? match[2] ?? match[3] ?? null : null; } catch { @@ -4208,10 +4372,10 @@ import os4 from "node:os"; import process4 from "node:process"; // plugins/polycli/scripts/lib/state.mjs -import crypto from "node:crypto"; -import fs2 from "node:fs"; +import crypto2 from "node:crypto"; +import fs3 from "node:fs"; import os2 from "node:os"; -import path2 from "node:path"; +import path3 from "node:path"; import { spawnSync as spawnSync2 } from "node:child_process"; var STATE_VERSION = 1; var STATE_FILE_NAME = "state.json"; @@ -4219,99 +4383,7 @@ var JOBS_DIR_NAME = "jobs"; var MAX_JOBS = 100; var POLYCLI_STATE_ROOT_ENV = "POLYCLI_STATE_ROOT"; var PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; -var FALLBACK_STATE_ROOT = path2.join(os2.tmpdir(), "polycli-companion"); -function sleepSync(ms) { - if (ms <= 0) return; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} -function writeJsonAtomic(filePath, value) { - fs2.mkdirSync(path2.dirname(filePath), { recursive: true }); - const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID()}`; - const fd = fs2.openSync(tmpPath, "w", 438); - try { - fs2.writeFileSync(fd, `${JSON.stringify(value, null, 2)} -`, "utf8"); - fs2.fsyncSync(fd); - } finally { - fs2.closeSync(fd); - } - fs2.renameSync(tmpPath, filePath); - try { - const dirFd = fs2.openSync(path2.dirname(filePath), "r"); - try { - fs2.fsyncSync(dirFd); - } finally { - fs2.closeSync(dirFd); - } - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; - } - } -} -function withLockfile(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { - fs2.mkdirSync(path2.dirname(lockPath), { recursive: true }); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const fd = fs2.openSync( - lockPath, - fs2.constants.O_CREAT | fs2.constants.O_EXCL | fs2.constants.O_WRONLY, - 384 - ); - try { - fs2.writeFileSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() }), "utf8"); - fs2.fsyncSync(fd); - } finally { - fs2.closeSync(fd); - } - try { - return fn(); - } finally { - try { - fs2.unlinkSync(lockPath); - } catch { - } - } - } catch (error2) { - if (error2.code !== "EEXIST") { - throw error2; - } - try { - const lock = JSON.parse(fs2.readFileSync(lockPath, "utf8")); - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - const lockAgeMs = acquiredAt == null ? null : Date.now() - acquiredAt; - let ownerAlive = false; - if (pid != null) { - try { - process.kill(pid, 0); - ownerAlive = true; - } catch (killError) { - if (killError.code === "ESRCH") { - fs2.unlinkSync(lockPath); - continue; - } - if (killError.code !== "EPERM") { - throw killError; - } - ownerAlive = true; - } - } - if (ownerAlive && lockAgeMs != null && lockAgeMs > staleMs) { - fs2.unlinkSync(lockPath); - continue; - } - } catch { - continue; - } - sleepSync(pollMs); - } - } - const error = new Error(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); - error.code = "ELOCKTIMEOUT"; - throw error; -} +var FALLBACK_STATE_ROOT = path3.join(os2.tmpdir(), "polycli-companion"); function runCommand2(command, args = [], options = {}) { const result = spawnSync2(command, args, { cwd: options.cwd, @@ -4332,8 +4404,8 @@ function runCommand2(command, args = [], options = {}) { }; } function computeWorkspaceSlug(workspaceRoot) { - const base = path2.basename(workspaceRoot).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "workspace"; - const hash = crypto.createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12); + const base = path3.basename(workspaceRoot).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "workspace"; + const hash = crypto2.createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12); return `${base}-${hash}`; } function defaultState() { @@ -4349,7 +4421,7 @@ function buildCorruptBackupPath(stateFile) { } function backupCorruptStateFile(stateFile) { try { - fs2.renameSync(stateFile, buildCorruptBackupPath(stateFile)); + fs3.renameSync(stateFile, buildCorruptBackupPath(stateFile)); } catch { } } @@ -4357,14 +4429,14 @@ function describeStateRoot() { const polycliStateRoot = process.env[POLYCLI_STATE_ROOT_ENV]; if (polycliStateRoot) { return { - stateRoot: path2.resolve(polycliStateRoot), + stateRoot: path3.resolve(polycliStateRoot), source: POLYCLI_STATE_ROOT_ENV }; } const pluginData = process.env[PLUGIN_DATA_ENV]; if (pluginData) { return { - stateRoot: path2.join(pluginData, "state"), + stateRoot: path3.join(pluginData, "state"), source: PLUGIN_DATA_ENV }; } @@ -4379,36 +4451,36 @@ function stateRootDir() { function resolveWorkspaceRoot(cwd = process.cwd()) { const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { cwd }); if (result.status === 0 && result.stdout.trim()) { - return path2.resolve(result.stdout.trim()); + return path3.resolve(result.stdout.trim()); } - return path2.resolve(cwd); + return path3.resolve(cwd); } function resolveStateDir(workspaceRoot) { - return path2.join(stateRootDir(), computeWorkspaceSlug(workspaceRoot)); + return path3.join(stateRootDir(), computeWorkspaceSlug(workspaceRoot)); } function resolveStateFile(workspaceRoot) { - return path2.join(resolveStateDir(workspaceRoot), STATE_FILE_NAME); + return path3.join(resolveStateDir(workspaceRoot), STATE_FILE_NAME); } function resolveJobsDir(workspaceRoot) { - return path2.join(resolveStateDir(workspaceRoot), JOBS_DIR_NAME); + return path3.join(resolveStateDir(workspaceRoot), JOBS_DIR_NAME); } function resolveJobFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.json`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.json`); } function resolveJobLogFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.log`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.log`); } function resolveJobConfigFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); } function ensureStateDir(workspaceRoot) { - fs2.mkdirSync(resolveJobsDir(workspaceRoot), { recursive: true }); + fs3.mkdirSync(resolveJobsDir(workspaceRoot), { recursive: true }); } function loadState(workspaceRoot) { const stateFile = resolveStateFile(workspaceRoot); let raw; try { - raw = fs2.readFileSync(stateFile, "utf8"); + raw = fs3.readFileSync(stateFile, "utf8"); } catch { return defaultState(); } @@ -4527,7 +4599,7 @@ function writeJobFile(workspaceRoot, jobId, payload) { } function readJobFile(jobFile) { try { - return JSON.parse(fs2.readFileSync(jobFile, "utf8")); + return JSON.parse(fs3.readFileSync(jobFile, "utf8")); } catch { return null; } @@ -4539,14 +4611,14 @@ function writeJobConfigFile(workspaceRoot, jobId, payload) { } function readJobConfigFile(configFile) { try { - return JSON.parse(fs2.readFileSync(configFile, "utf8")); + return JSON.parse(fs3.readFileSync(configFile, "utf8")); } catch { return null; } } function removeJobConfigFile(workspaceRoot, jobId) { try { - fs2.unlinkSync(resolveJobConfigFile(workspaceRoot, jobId)); + fs3.unlinkSync(resolveJobConfigFile(workspaceRoot, jobId)); } catch { } } @@ -4557,168 +4629,6 @@ import path4 from "node:path"; // packages/polycli-utils/src/ndjson.js import fs4 from "node:fs"; - -// packages/polycli-utils/src/atomic-save.js -import crypto2 from "node:crypto"; -import fs3 from "node:fs"; -import path3 from "node:path"; -import process3 from "node:process"; -var LockfileTimeoutError = class extends Error { - constructor(lockPath, timeoutMs) { - super(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); - this.code = "ELOCKTIMEOUT"; - this.lockPath = lockPath; - this.timeoutMs = timeoutMs; - } -}; -function sleepSync2(ms) { - if (ms <= 0) return; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} -function ensureParentDir(filePath) { - fs3.mkdirSync(path3.dirname(filePath), { recursive: true }); -} -function normalizeWriteOptions(options) { - if (typeof options === "string") { - return { - flag: "w", - mode: 438, - writeOptions: options - }; - } - if (options && typeof options === "object") { - const { flag = "w", mode = 438, ...writeOptions } = options; - return { - flag, - mode, - writeOptions: Object.keys(writeOptions).length > 0 ? writeOptions : void 0 - }; - } - return { - flag: "w", - mode: 438, - writeOptions: void 0 - }; -} -function writeFileAtomicSync(filePath, contents, options = {}) { - ensureParentDir(filePath); - const tmpPath = `${filePath}.tmp.${process3.pid}.${Date.now()}.${crypto2.randomUUID()}`; - const { flag, mode, writeOptions } = normalizeWriteOptions(options); - const fd = fs3.openSync(tmpPath, flag, mode); - try { - fs3.writeFileSync(fd, contents, writeOptions); - fs3.fsyncSync(fd); - } finally { - fs3.closeSync(fd); - } - fs3.renameSync(tmpPath, filePath); - const dirFd = fs3.openSync(path3.dirname(filePath), "r"); - try { - fs3.fsyncSync(dirFd); - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; - } - } finally { - fs3.closeSync(dirFd); - } -} -function writeFileAtomic(filePath, contents, options = {}) { - writeFileAtomicSync(filePath, contents, options); - return filePath; -} -function unlinkIfExists(filePath) { - try { - fs3.unlinkSync(filePath); - } catch { - } -} -function tryReclaimStaleLock(lockPath, staleMs) { - let raw; - try { - raw = fs3.readFileSync(lockPath, "utf8"); - } catch { - return true; - } - let lock = null; - try { - lock = JSON.parse(raw); - } catch { - lock = null; - } - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - if (pid != null) { - try { - process3.kill(pid, 0); - } catch (killError) { - if (killError.code === "ESRCH") { - unlinkIfExists(lockPath); - return true; - } - if (killError.code !== "EPERM") { - throw killError; - } - } - const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs2 != null && ageMs2 > staleMs) { - unlinkIfExists(lockPath); - return true; - } - return false; - } - let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs == null) { - try { - ageMs = Date.now() - fs3.statSync(lockPath).mtimeMs; - } catch { - return true; - } - } - if (ageMs != null && ageMs > staleMs) { - unlinkIfExists(lockPath); - return true; - } - return false; -} -function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { - ensureParentDir(lockPath); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const fd = fs3.openSync( - lockPath, - fs3.constants.O_CREAT | fs3.constants.O_EXCL | fs3.constants.O_WRONLY, - 384 - ); - try { - fs3.writeFileSync(fd, JSON.stringify({ pid: process3.pid, acquiredAt: Date.now() }), "utf8"); - fs3.fsyncSync(fd); - } finally { - fs3.closeSync(fd); - } - try { - return fn(); - } finally { - try { - fs3.unlinkSync(lockPath); - } catch { - } - } - } catch (error) { - if (error.code !== "EEXIST") { - throw error; - } - if (tryReclaimStaleLock(lockPath, staleMs)) { - continue; - } - sleepSync2(pollMs); - } - } - throw new LockfileTimeoutError(lockPath, timeoutMs); -} - -// packages/polycli-utils/src/ndjson.js function safeParseLine(line) { try { return JSON.parse(line); @@ -4752,7 +4662,7 @@ function readNdjson(filePath) { } function appendNdjson(filePath, record, { timeoutMs = 1e4, staleMs = 3e4, pollMs = 25, maxBytes = null, keepRatio = 0.5 } = {}) { const lockPath = `${filePath}.lock`; - return withLockfile2(lockPath, () => { + return withLockfile(lockPath, () => { ensureParentDir(filePath); let needsLeadingNewline = false; try { @@ -5309,6 +5219,10 @@ function recoverLedgerTerminalEvents(workspaceRoot, job, { result = null, reason const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, job.jobId)); const runContext = config?.runContext; if (!runContext?.runId) return; + const recoverLock = `${resolveRunLedgerFile(workspaceRoot)}.recover.lock`; + withLockfile(recoverLock, () => writeRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result, reason })); +} +function writeRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result = null, reason = "worker_exited" } = {}) { const events = readRunLedgerEvents(workspaceRoot); const command = runContext.command || config?.execution?.kind || job.kind || null; const provider = runContext.provider || config?.execution?.provider || job.provider || null; @@ -5454,36 +5368,9 @@ async function waitForJob(workspaceRoot, jobId, { timeoutMs = 24e4, pollInterval return { job: timed ? refreshJob(workspaceRoot, timed) : null, waitTimedOut: true }; } async function cancelJob(workspaceRoot, jobId) { - const job = getJob(workspaceRoot, jobId); - if (!job) { - return { cancelled: false, reason: "not_found", jobId }; - } - if (!ACTIVE_STATUSES.has(job.status)) { - return { cancelled: false, reason: "not_cancellable", jobId }; - } - try { - if (job.pid) { - await terminateProcessTree(job.pid, { - signal: "SIGINT", - forceSignal: "SIGKILL", - forceAfterMs: 2e3 - }); - } - } catch (error) { - return { - cancelled: false, - reason: "cancel_failed", - jobId, - error: error.message - }; - } - const cancelledJob = { - ...job, - status: "cancelled", - pid: null, - finishedAt: (/* @__PURE__ */ new Date()).toISOString() - }; + let pidToKill = null; let reason = null; + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); const write = updateJobAtomically(workspaceRoot, jobId, (current) => { if (!current) { reason = "not_found"; @@ -5493,11 +5380,12 @@ async function cancelJob(workspaceRoot, jobId) { reason = "not_cancellable"; return null; } + pidToKill = current.pid ?? null; const nextJob = { ...current, status: "cancelled", pid: null, - finishedAt: cancelledJob.finishedAt + finishedAt }; return { job: nextJob, @@ -5513,6 +5401,17 @@ async function cancelJob(workspaceRoot, jobId) { if (!write.written) { return { cancelled: false, reason: reason || "not_cancellable", jobId }; } + if (pidToKill) { + try { + await terminateProcessTree(pidToKill, { + signal: "SIGINT", + forceSignal: "SIGKILL", + forceAfterMs: 2e3 + }); + } catch (error) { + return { cancelled: true, jobId, killWarning: error.message }; + } + } return { cancelled: true, jobId }; } @@ -6285,12 +6184,9 @@ function cacheProviderModel(workspaceRoot, provider, model) { if (typeof model !== "string" || !model.trim()) return; const cacheFile = resolveProviderModelCacheFile(workspaceRoot); fs9.mkdirSync(path8.dirname(cacheFile), { recursive: true }); - fs9.writeFileSync( - cacheFile, - `${JSON.stringify({ ...readProviderModelCache(workspaceRoot), [provider]: model }, null, 2)} -`, - "utf8" - ); + withLockfile(`${cacheFile}.lock`, () => { + writeJsonAtomic(cacheFile, { ...readProviderModelCache(workspaceRoot), [provider]: model }); + }); } async function inspectProvider(provider) { const runtime = getProviderRuntime(provider); diff --git a/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs b/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs index 188f229..c04eedc 100755 --- a/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli-opencode/scripts/polycli-companion.bundle.mjs @@ -84,6 +84,170 @@ function parseArgs(argv, config = {}) { return { options, positionals }; } +// packages/polycli-utils/src/atomic-save.js +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import process2 from "node:process"; +var LockfileTimeoutError = class extends Error { + constructor(lockPath, timeoutMs) { + super(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); + this.code = "ELOCKTIMEOUT"; + this.lockPath = lockPath; + this.timeoutMs = timeoutMs; + } +}; +function sleepSync(ms) { + if (ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} +function ensureParentDir(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} +function normalizeWriteOptions(options) { + if (typeof options === "string") { + return { + flag: "w", + mode: 438, + writeOptions: options + }; + } + if (options && typeof options === "object") { + const { flag = "w", mode = 438, ...writeOptions } = options; + return { + flag, + mode, + writeOptions: Object.keys(writeOptions).length > 0 ? writeOptions : void 0 + }; + } + return { + flag: "w", + mode: 438, + writeOptions: void 0 + }; +} +function writeFileAtomicSync(filePath, contents, options = {}) { + ensureParentDir(filePath); + const tmpPath = `${filePath}.tmp.${process2.pid}.${Date.now()}.${crypto.randomUUID()}`; + const { flag, mode, writeOptions } = normalizeWriteOptions(options); + const fd = fs.openSync(tmpPath, flag, mode); + try { + fs.writeFileSync(fd, contents, writeOptions); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmpPath, filePath); + const dirFd = fs.openSync(path.dirname(filePath), "r"); + try { + fs.fsyncSync(dirFd); + } catch (error) { + if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { + throw error; + } + } finally { + fs.closeSync(dirFd); + } +} +function writeFileAtomic(filePath, contents, options = {}) { + writeFileAtomicSync(filePath, contents, options); + return filePath; +} +function writeJsonAtomic(filePath, value, { spaces = 2, finalNewline = true } = {}) { + const text = JSON.stringify(value, null, spaces) + (finalNewline ? "\n" : ""); + return writeFileAtomic(filePath, text, "utf8"); +} +function unlinkIfExists(filePath) { + try { + fs.unlinkSync(filePath); + } catch { + } +} +function tryReclaimStaleLock(lockPath, staleMs) { + let raw; + try { + raw = fs.readFileSync(lockPath, "utf8"); + } catch { + return true; + } + let lock = null; + try { + lock = JSON.parse(raw); + } catch { + lock = null; + } + const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; + const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; + if (pid != null) { + try { + process2.kill(pid, 0); + } catch (killError) { + if (killError.code === "ESRCH") { + unlinkIfExists(lockPath); + return true; + } + if (killError.code !== "EPERM") { + throw killError; + } + } + const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs2 != null && ageMs2 > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; + } + let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs == null) { + try { + ageMs = Date.now() - fs.statSync(lockPath).mtimeMs; + } catch { + return true; + } + } + if (ageMs != null && ageMs > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; +} +function withLockfile(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { + ensureParentDir(lockPath); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const fd = fs.openSync( + lockPath, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, + 384 + ); + try { + fs.writeFileSync(fd, JSON.stringify({ pid: process2.pid, acquiredAt: Date.now() }), "utf8"); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + try { + return fn(); + } finally { + try { + fs.unlinkSync(lockPath); + } catch { + } + } + } catch (error) { + if (error.code !== "EEXIST") { + throw error; + } + if (tryReclaimStaleLock(lockPath, staleMs)) { + continue; + } + sleepSync(pollMs); + } + } + throw new LockfileTimeoutError(lockPath, timeoutMs); +} + // packages/polycli-runtime/src/constants.js var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy"]; var PROVIDER_OPERATION_NAMES = ["prompt"]; @@ -142,7 +306,7 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { // packages/polycli-utils/src/process.js import { spawnSync } from "node:child_process"; -import process2 from "node:process"; +import process3 from "node:process"; function runCommand(command, args = [], options = {}) { const result = spawnSync(command, args, { cwd: options.cwd, @@ -217,7 +381,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI throw new Error(`Invalid pid: ${pid}`); } const killOnce = (targetSignal) => { - if (process2.platform === "win32") { + if (process3.platform === "win32") { const args = ["/PID", String(pid), "/T"]; if (targetSignal === "SIGKILL") { args.push("/F"); @@ -236,7 +400,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI return true; } try { - process2.kill(-pid, targetSignal); + process3.kill(-pid, targetSignal); return true; } catch (error) { if (error.code === "ESRCH") { @@ -246,7 +410,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI if (error.code === "EINVAL") { throw error; } - process2.kill(pid, targetSignal); + process3.kill(pid, targetSignal); return true; } }; @@ -1492,9 +1656,9 @@ function runGeminiPromptStreaming({ } // packages/polycli-runtime/src/kimi.js -import fs from "node:fs"; +import fs2 from "node:fs"; import os from "node:os"; -import path from "node:path"; +import path2 from "node:path"; import { createHash } from "node:crypto"; var KIMI_BIN = process.env.KIMI_CLI_BIN || "kimi"; var DEFAULT_TIMEOUT_MS4 = 9e5; @@ -1505,24 +1669,24 @@ var KIMI_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 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 ]; -var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path.join(os.homedir(), ".kimi", "config.toml"); +var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path2.join(os.homedir(), ".kimi", "config.toml"); function isKimiResumeFooter(text) { return /^To resume:\s*kimi\s+-r\s+/im.test(String(text ?? "").trim()); } function kimiJsonPath() { - return path.join(os.homedir(), ".kimi", "kimi.json"); + return path2.join(os.homedir(), ".kimi", "kimi.json"); } function kimiSessionsDir() { - return path.join(os.homedir(), ".kimi", "sessions"); + return path2.join(os.homedir(), ".kimi", "sessions"); } function resolveRealCwd(cwd) { - return fs.realpathSync(cwd || process.cwd()); + return fs2.realpathSync(cwd || process.cwd()); } function md5CwdPath(realCwd) { return createHash("md5").update(realCwd).digest("hex"); } function formatKimiResumeError(reason, { sessionId, cwd, errCode } = {}) { - const cwdBase = cwd ? path.basename(cwd) : "?"; + const cwdBase = cwd ? path2.basename(cwd) : "?"; if (reason === "invalid-uuid") return "invalid sessionId format; expected UUID."; if (reason === "no-prior-session") return `no prior kimi session for this directory (${cwdBase}). Use /polycli:ask --provider kimi to start one.`; if (reason === "kimi-json-malformed") return "~/.kimi/kimi.json is malformed; cannot resolve last session."; @@ -1534,7 +1698,7 @@ function formatKimiResumeError(reason, { sessionId, cwd, errCode } = {}) { function readKimiLastSession(realCwd) { let raw; try { - raw = fs.readFileSync(kimiJsonPath(), "utf8"); + raw = fs2.readFileSync(kimiJsonPath(), "utf8"); } catch (error) { if (error.code === "ENOENT") return { ok: false, reason: "no-prior-session" }; return { ok: false, reason: "fs-error", errCode: error.code }; @@ -1558,14 +1722,14 @@ function validateKimiResumeTarget({ realCwd, cwdHash, sessionId }) { if (typeof sessionId !== "string" || !KIMI_UUID_RE.test(sessionId)) { return { ok: false, reason: "invalid-uuid" }; } - const sessionDir = path.join(kimiSessionsDir(), cwdHash, sessionId); - const contextPath = path.join(sessionDir, "context.jsonl"); + const sessionDir = path2.join(kimiSessionsDir(), cwdHash, sessionId); + const contextPath = path2.join(sessionDir, "context.jsonl"); try { - const dirStat = fs.statSync(sessionDir); + const dirStat = fs2.statSync(sessionDir); if (!dirStat.isDirectory()) { return { ok: false, reason: "session-not-found" }; } - const contextStat = fs.statSync(contextPath); + const contextStat = fs2.statSync(contextPath); if (!contextStat.isFile() || contextStat.size === 0) { return { ok: false, reason: "session-empty" }; } @@ -1574,7 +1738,7 @@ function validateKimiResumeTarget({ realCwd, cwdHash, sessionId }) { if (error.code === "ENOENT") { return { ok: false, - reason: fs.existsSync(sessionDir) ? "session-empty" : "session-not-found" + reason: fs2.existsSync(sessionDir) ? "session-empty" : "session-not-found" }; } return { ok: false, reason: "fs-error", errCode: error.code, realCwd }; @@ -1647,7 +1811,7 @@ function resolveKimiResumeSession({ } function readKimiDefaultModel() { try { - const text = fs.readFileSync(KIMI_CONFIG_PATH, "utf8"); + const text = fs2.readFileSync(KIMI_CONFIG_PATH, "utf8"); const match = text.match(/^default_model\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))/m); return match ? match[1] ?? match[2] ?? match[3] ?? null : null; } catch { @@ -4208,10 +4372,10 @@ import os4 from "node:os"; import process4 from "node:process"; // plugins/polycli/scripts/lib/state.mjs -import crypto from "node:crypto"; -import fs2 from "node:fs"; +import crypto2 from "node:crypto"; +import fs3 from "node:fs"; import os2 from "node:os"; -import path2 from "node:path"; +import path3 from "node:path"; import { spawnSync as spawnSync2 } from "node:child_process"; var STATE_VERSION = 1; var STATE_FILE_NAME = "state.json"; @@ -4219,99 +4383,7 @@ var JOBS_DIR_NAME = "jobs"; var MAX_JOBS = 100; var POLYCLI_STATE_ROOT_ENV = "POLYCLI_STATE_ROOT"; var PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; -var FALLBACK_STATE_ROOT = path2.join(os2.tmpdir(), "polycli-companion"); -function sleepSync(ms) { - if (ms <= 0) return; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} -function writeJsonAtomic(filePath, value) { - fs2.mkdirSync(path2.dirname(filePath), { recursive: true }); - const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID()}`; - const fd = fs2.openSync(tmpPath, "w", 438); - try { - fs2.writeFileSync(fd, `${JSON.stringify(value, null, 2)} -`, "utf8"); - fs2.fsyncSync(fd); - } finally { - fs2.closeSync(fd); - } - fs2.renameSync(tmpPath, filePath); - try { - const dirFd = fs2.openSync(path2.dirname(filePath), "r"); - try { - fs2.fsyncSync(dirFd); - } finally { - fs2.closeSync(dirFd); - } - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; - } - } -} -function withLockfile(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { - fs2.mkdirSync(path2.dirname(lockPath), { recursive: true }); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const fd = fs2.openSync( - lockPath, - fs2.constants.O_CREAT | fs2.constants.O_EXCL | fs2.constants.O_WRONLY, - 384 - ); - try { - fs2.writeFileSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() }), "utf8"); - fs2.fsyncSync(fd); - } finally { - fs2.closeSync(fd); - } - try { - return fn(); - } finally { - try { - fs2.unlinkSync(lockPath); - } catch { - } - } - } catch (error2) { - if (error2.code !== "EEXIST") { - throw error2; - } - try { - const lock = JSON.parse(fs2.readFileSync(lockPath, "utf8")); - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - const lockAgeMs = acquiredAt == null ? null : Date.now() - acquiredAt; - let ownerAlive = false; - if (pid != null) { - try { - process.kill(pid, 0); - ownerAlive = true; - } catch (killError) { - if (killError.code === "ESRCH") { - fs2.unlinkSync(lockPath); - continue; - } - if (killError.code !== "EPERM") { - throw killError; - } - ownerAlive = true; - } - } - if (ownerAlive && lockAgeMs != null && lockAgeMs > staleMs) { - fs2.unlinkSync(lockPath); - continue; - } - } catch { - continue; - } - sleepSync(pollMs); - } - } - const error = new Error(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); - error.code = "ELOCKTIMEOUT"; - throw error; -} +var FALLBACK_STATE_ROOT = path3.join(os2.tmpdir(), "polycli-companion"); function runCommand2(command, args = [], options = {}) { const result = spawnSync2(command, args, { cwd: options.cwd, @@ -4332,8 +4404,8 @@ function runCommand2(command, args = [], options = {}) { }; } function computeWorkspaceSlug(workspaceRoot) { - const base = path2.basename(workspaceRoot).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "workspace"; - const hash = crypto.createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12); + const base = path3.basename(workspaceRoot).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "workspace"; + const hash = crypto2.createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12); return `${base}-${hash}`; } function defaultState() { @@ -4349,7 +4421,7 @@ function buildCorruptBackupPath(stateFile) { } function backupCorruptStateFile(stateFile) { try { - fs2.renameSync(stateFile, buildCorruptBackupPath(stateFile)); + fs3.renameSync(stateFile, buildCorruptBackupPath(stateFile)); } catch { } } @@ -4357,14 +4429,14 @@ function describeStateRoot() { const polycliStateRoot = process.env[POLYCLI_STATE_ROOT_ENV]; if (polycliStateRoot) { return { - stateRoot: path2.resolve(polycliStateRoot), + stateRoot: path3.resolve(polycliStateRoot), source: POLYCLI_STATE_ROOT_ENV }; } const pluginData = process.env[PLUGIN_DATA_ENV]; if (pluginData) { return { - stateRoot: path2.join(pluginData, "state"), + stateRoot: path3.join(pluginData, "state"), source: PLUGIN_DATA_ENV }; } @@ -4379,36 +4451,36 @@ function stateRootDir() { function resolveWorkspaceRoot(cwd = process.cwd()) { const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { cwd }); if (result.status === 0 && result.stdout.trim()) { - return path2.resolve(result.stdout.trim()); + return path3.resolve(result.stdout.trim()); } - return path2.resolve(cwd); + return path3.resolve(cwd); } function resolveStateDir(workspaceRoot) { - return path2.join(stateRootDir(), computeWorkspaceSlug(workspaceRoot)); + return path3.join(stateRootDir(), computeWorkspaceSlug(workspaceRoot)); } function resolveStateFile(workspaceRoot) { - return path2.join(resolveStateDir(workspaceRoot), STATE_FILE_NAME); + return path3.join(resolveStateDir(workspaceRoot), STATE_FILE_NAME); } function resolveJobsDir(workspaceRoot) { - return path2.join(resolveStateDir(workspaceRoot), JOBS_DIR_NAME); + return path3.join(resolveStateDir(workspaceRoot), JOBS_DIR_NAME); } function resolveJobFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.json`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.json`); } function resolveJobLogFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.log`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.log`); } function resolveJobConfigFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); } function ensureStateDir(workspaceRoot) { - fs2.mkdirSync(resolveJobsDir(workspaceRoot), { recursive: true }); + fs3.mkdirSync(resolveJobsDir(workspaceRoot), { recursive: true }); } function loadState(workspaceRoot) { const stateFile = resolveStateFile(workspaceRoot); let raw; try { - raw = fs2.readFileSync(stateFile, "utf8"); + raw = fs3.readFileSync(stateFile, "utf8"); } catch { return defaultState(); } @@ -4527,7 +4599,7 @@ function writeJobFile(workspaceRoot, jobId, payload) { } function readJobFile(jobFile) { try { - return JSON.parse(fs2.readFileSync(jobFile, "utf8")); + return JSON.parse(fs3.readFileSync(jobFile, "utf8")); } catch { return null; } @@ -4539,14 +4611,14 @@ function writeJobConfigFile(workspaceRoot, jobId, payload) { } function readJobConfigFile(configFile) { try { - return JSON.parse(fs2.readFileSync(configFile, "utf8")); + return JSON.parse(fs3.readFileSync(configFile, "utf8")); } catch { return null; } } function removeJobConfigFile(workspaceRoot, jobId) { try { - fs2.unlinkSync(resolveJobConfigFile(workspaceRoot, jobId)); + fs3.unlinkSync(resolveJobConfigFile(workspaceRoot, jobId)); } catch { } } @@ -4557,168 +4629,6 @@ import path4 from "node:path"; // packages/polycli-utils/src/ndjson.js import fs4 from "node:fs"; - -// packages/polycli-utils/src/atomic-save.js -import crypto2 from "node:crypto"; -import fs3 from "node:fs"; -import path3 from "node:path"; -import process3 from "node:process"; -var LockfileTimeoutError = class extends Error { - constructor(lockPath, timeoutMs) { - super(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); - this.code = "ELOCKTIMEOUT"; - this.lockPath = lockPath; - this.timeoutMs = timeoutMs; - } -}; -function sleepSync2(ms) { - if (ms <= 0) return; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} -function ensureParentDir(filePath) { - fs3.mkdirSync(path3.dirname(filePath), { recursive: true }); -} -function normalizeWriteOptions(options) { - if (typeof options === "string") { - return { - flag: "w", - mode: 438, - writeOptions: options - }; - } - if (options && typeof options === "object") { - const { flag = "w", mode = 438, ...writeOptions } = options; - return { - flag, - mode, - writeOptions: Object.keys(writeOptions).length > 0 ? writeOptions : void 0 - }; - } - return { - flag: "w", - mode: 438, - writeOptions: void 0 - }; -} -function writeFileAtomicSync(filePath, contents, options = {}) { - ensureParentDir(filePath); - const tmpPath = `${filePath}.tmp.${process3.pid}.${Date.now()}.${crypto2.randomUUID()}`; - const { flag, mode, writeOptions } = normalizeWriteOptions(options); - const fd = fs3.openSync(tmpPath, flag, mode); - try { - fs3.writeFileSync(fd, contents, writeOptions); - fs3.fsyncSync(fd); - } finally { - fs3.closeSync(fd); - } - fs3.renameSync(tmpPath, filePath); - const dirFd = fs3.openSync(path3.dirname(filePath), "r"); - try { - fs3.fsyncSync(dirFd); - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; - } - } finally { - fs3.closeSync(dirFd); - } -} -function writeFileAtomic(filePath, contents, options = {}) { - writeFileAtomicSync(filePath, contents, options); - return filePath; -} -function unlinkIfExists(filePath) { - try { - fs3.unlinkSync(filePath); - } catch { - } -} -function tryReclaimStaleLock(lockPath, staleMs) { - let raw; - try { - raw = fs3.readFileSync(lockPath, "utf8"); - } catch { - return true; - } - let lock = null; - try { - lock = JSON.parse(raw); - } catch { - lock = null; - } - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - if (pid != null) { - try { - process3.kill(pid, 0); - } catch (killError) { - if (killError.code === "ESRCH") { - unlinkIfExists(lockPath); - return true; - } - if (killError.code !== "EPERM") { - throw killError; - } - } - const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs2 != null && ageMs2 > staleMs) { - unlinkIfExists(lockPath); - return true; - } - return false; - } - let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs == null) { - try { - ageMs = Date.now() - fs3.statSync(lockPath).mtimeMs; - } catch { - return true; - } - } - if (ageMs != null && ageMs > staleMs) { - unlinkIfExists(lockPath); - return true; - } - return false; -} -function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { - ensureParentDir(lockPath); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const fd = fs3.openSync( - lockPath, - fs3.constants.O_CREAT | fs3.constants.O_EXCL | fs3.constants.O_WRONLY, - 384 - ); - try { - fs3.writeFileSync(fd, JSON.stringify({ pid: process3.pid, acquiredAt: Date.now() }), "utf8"); - fs3.fsyncSync(fd); - } finally { - fs3.closeSync(fd); - } - try { - return fn(); - } finally { - try { - fs3.unlinkSync(lockPath); - } catch { - } - } - } catch (error) { - if (error.code !== "EEXIST") { - throw error; - } - if (tryReclaimStaleLock(lockPath, staleMs)) { - continue; - } - sleepSync2(pollMs); - } - } - throw new LockfileTimeoutError(lockPath, timeoutMs); -} - -// packages/polycli-utils/src/ndjson.js function safeParseLine(line) { try { return JSON.parse(line); @@ -4752,7 +4662,7 @@ function readNdjson(filePath) { } function appendNdjson(filePath, record, { timeoutMs = 1e4, staleMs = 3e4, pollMs = 25, maxBytes = null, keepRatio = 0.5 } = {}) { const lockPath = `${filePath}.lock`; - return withLockfile2(lockPath, () => { + return withLockfile(lockPath, () => { ensureParentDir(filePath); let needsLeadingNewline = false; try { @@ -5309,6 +5219,10 @@ function recoverLedgerTerminalEvents(workspaceRoot, job, { result = null, reason const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, job.jobId)); const runContext = config?.runContext; if (!runContext?.runId) return; + const recoverLock = `${resolveRunLedgerFile(workspaceRoot)}.recover.lock`; + withLockfile(recoverLock, () => writeRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result, reason })); +} +function writeRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result = null, reason = "worker_exited" } = {}) { const events = readRunLedgerEvents(workspaceRoot); const command = runContext.command || config?.execution?.kind || job.kind || null; const provider = runContext.provider || config?.execution?.provider || job.provider || null; @@ -5454,36 +5368,9 @@ async function waitForJob(workspaceRoot, jobId, { timeoutMs = 24e4, pollInterval return { job: timed ? refreshJob(workspaceRoot, timed) : null, waitTimedOut: true }; } async function cancelJob(workspaceRoot, jobId) { - const job = getJob(workspaceRoot, jobId); - if (!job) { - return { cancelled: false, reason: "not_found", jobId }; - } - if (!ACTIVE_STATUSES.has(job.status)) { - return { cancelled: false, reason: "not_cancellable", jobId }; - } - try { - if (job.pid) { - await terminateProcessTree(job.pid, { - signal: "SIGINT", - forceSignal: "SIGKILL", - forceAfterMs: 2e3 - }); - } - } catch (error) { - return { - cancelled: false, - reason: "cancel_failed", - jobId, - error: error.message - }; - } - const cancelledJob = { - ...job, - status: "cancelled", - pid: null, - finishedAt: (/* @__PURE__ */ new Date()).toISOString() - }; + let pidToKill = null; let reason = null; + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); const write = updateJobAtomically(workspaceRoot, jobId, (current) => { if (!current) { reason = "not_found"; @@ -5493,11 +5380,12 @@ async function cancelJob(workspaceRoot, jobId) { reason = "not_cancellable"; return null; } + pidToKill = current.pid ?? null; const nextJob = { ...current, status: "cancelled", pid: null, - finishedAt: cancelledJob.finishedAt + finishedAt }; return { job: nextJob, @@ -5513,6 +5401,17 @@ async function cancelJob(workspaceRoot, jobId) { if (!write.written) { return { cancelled: false, reason: reason || "not_cancellable", jobId }; } + if (pidToKill) { + try { + await terminateProcessTree(pidToKill, { + signal: "SIGINT", + forceSignal: "SIGKILL", + forceAfterMs: 2e3 + }); + } catch (error) { + return { cancelled: true, jobId, killWarning: error.message }; + } + } return { cancelled: true, jobId }; } @@ -6285,12 +6184,9 @@ function cacheProviderModel(workspaceRoot, provider, model) { if (typeof model !== "string" || !model.trim()) return; const cacheFile = resolveProviderModelCacheFile(workspaceRoot); fs9.mkdirSync(path8.dirname(cacheFile), { recursive: true }); - fs9.writeFileSync( - cacheFile, - `${JSON.stringify({ ...readProviderModelCache(workspaceRoot), [provider]: model }, null, 2)} -`, - "utf8" - ); + withLockfile(`${cacheFile}.lock`, () => { + writeJsonAtomic(cacheFile, { ...readProviderModelCache(workspaceRoot), [provider]: model }); + }); } async function inspectProvider(provider) { const runtime = getProviderRuntime(provider); diff --git a/plugins/polycli/scripts/lib/job-control.mjs b/plugins/polycli/scripts/lib/job-control.mjs index af21089..02a948b 100644 --- a/plugins/polycli/scripts/lib/job-control.mjs +++ b/plugins/polycli/scripts/lib/job-control.mjs @@ -3,6 +3,7 @@ import os from "node:os"; import process from "node:process"; import { terminateProcessTree } from "@bbingz/polycli-utils/process"; +import { withLockfile } from "@bbingz/polycli-utils/atomic-save"; import { getJob, @@ -19,6 +20,7 @@ import { import { appendRunLedgerEvent, readRunLedgerEvents, + resolveRunLedgerFile, } from "./run-ledger.mjs"; import { deriveSessionArtifactCandidate, @@ -76,6 +78,16 @@ function recoverLedgerTerminalEvents(workspaceRoot, job, { result = null, reason const runContext = config?.runContext; if (!runContext?.runId) return; + // Serialize the whole read -> hasLedgerPhase -> append -> removeConfig across processes. + // appendRunLedgerEvent only locks its own single append, so two concurrent refreshJob() callers + // could both observe "no terminal events yet" and each append, double-counting the run. The + // recover lock is a distinct path from the ndjson append lock, so there is no deadlock. + const recoverLock = `${resolveRunLedgerFile(workspaceRoot)}.recover.lock`; + withLockfile(recoverLock, () => + writeRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result, reason })); +} + +function writeRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result = null, reason = "worker_exited" } = {}) { const events = readRunLedgerEvents(workspaceRoot); const command = runContext.command || config?.execution?.kind || job.kind || null; const provider = runContext.provider || config?.execution?.provider || job.provider || null; @@ -246,38 +258,13 @@ export async function waitForJob(workspaceRoot, jobId, { timeoutMs = 240_000, po } export async function cancelJob(workspaceRoot, jobId) { - const job = getJob(workspaceRoot, jobId); - if (!job) { - return { cancelled: false, reason: "not_found", jobId }; - } - if (!ACTIVE_STATUSES.has(job.status)) { - return { cancelled: false, reason: "not_cancellable", jobId }; - } - - try { - if (job.pid) { - await terminateProcessTree(job.pid, { - signal: "SIGINT", - forceSignal: "SIGKILL", - forceAfterMs: 2_000, - }); - } - } catch (error) { - return { - cancelled: false, - reason: "cancel_failed", - jobId, - error: error.message, - }; - } - - const cancelledJob = { - ...job, - status: "cancelled", - pid: null, - finishedAt: new Date().toISOString(), - }; + // Flip the job to cancelled and capture its pid atomically under the state lock FIRST, then + // signal that pid. Previously cancelJob read the job WITHOUT a lock and killed job.pid before + // re-validating, so a stale pre-lock snapshot could signal a pid the worker had already freed + // (and the OS reused). The pid we kill below was confirmed ACTIVE at lock time. + let pidToKill = null; let reason = null; + const finishedAt = new Date().toISOString(); const write = updateJobAtomically(workspaceRoot, jobId, (current) => { if (!current) { reason = "not_found"; @@ -287,11 +274,12 @@ export async function cancelJob(workspaceRoot, jobId) { reason = "not_cancellable"; return null; } + pidToKill = current.pid ?? null; const nextJob = { ...current, status: "cancelled", pid: null, - finishedAt: cancelledJob.finishedAt, + finishedAt, }; return { job: nextJob, @@ -307,5 +295,18 @@ export async function cancelJob(workspaceRoot, jobId) { if (!write.written) { return { cancelled: false, reason: reason || "not_cancellable", jobId }; } + + if (pidToKill) { + try { + await terminateProcessTree(pidToKill, { + signal: "SIGINT", + forceSignal: "SIGKILL", + forceAfterMs: 2_000, + }); + } catch (error) { + // The job is already recorded as cancelled; surface the kill problem without un-cancelling. + return { cancelled: true, jobId, killWarning: error.message }; + } + } return { cancelled: true, jobId }; } diff --git a/plugins/polycli/scripts/lib/state.mjs b/plugins/polycli/scripts/lib/state.mjs index 2b512a0..fcfb3e1 100644 --- a/plugins/polycli/scripts/lib/state.mjs +++ b/plugins/polycli/scripts/lib/state.mjs @@ -4,6 +4,11 @@ import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; +// Single hardened implementation of the atomic-write + lockfile primitives. state.mjs used to +// carry byte-for-byte copies of these; importing the shared utils version keeps the two in +// lockstep (so e.g. the no-pid stale-lock reclaim fix lands here too) instead of silently drifting. +import { withLockfile, writeJsonAtomic } from "@bbingz/polycli-utils/atomic-save"; + const STATE_VERSION = 1; const STATE_FILE_NAME = "state.json"; const JOBS_DIR_NAME = "jobs"; @@ -12,105 +17,6 @@ const POLYCLI_STATE_ROOT_ENV = "POLYCLI_STATE_ROOT"; const PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; const FALLBACK_STATE_ROOT = path.join(os.tmpdir(), "polycli-companion"); -function sleepSync(ms) { - if (ms <= 0) return; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} - -function writeJsonAtomic(filePath, value) { - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID()}`; - const fd = fs.openSync(tmpPath, "w", 0o666); - try { - fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, "utf8"); - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } - fs.renameSync(tmpPath, filePath); - try { - const dirFd = fs.openSync(path.dirname(filePath), "r"); - try { - fs.fsyncSync(dirFd); - } finally { - fs.closeSync(dirFd); - } - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; - } - } -} - -function withLockfile(lockPath, fn, { timeoutMs = 10_000, staleMs = 600_000, pollMs = 25 } = {}) { - fs.mkdirSync(path.dirname(lockPath), { recursive: true }); - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - try { - const fd = fs.openSync( - lockPath, - fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, - 0o600 - ); - try { - fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() }), "utf8"); - fs.fsyncSync(fd); - } finally { - fs.closeSync(fd); - } - try { - return fn(); - } finally { - try { - fs.unlinkSync(lockPath); - } catch { - // ignore - } - } - } catch (error) { - if (error.code !== "EEXIST") { - throw error; - } - try { - const lock = JSON.parse(fs.readFileSync(lockPath, "utf8")); - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - const lockAgeMs = acquiredAt == null ? null : Date.now() - acquiredAt; - let ownerAlive = false; - - if (pid != null) { - try { - process.kill(pid, 0); - ownerAlive = true; - } catch (killError) { - if (killError.code === "ESRCH") { - fs.unlinkSync(lockPath); - continue; - } - if (killError.code !== "EPERM") { - throw killError; - } - ownerAlive = true; - } - } - - if (ownerAlive && lockAgeMs != null && lockAgeMs > staleMs) { - fs.unlinkSync(lockPath); - continue; - } - } catch { - continue; - } - sleepSync(pollMs); - } - } - - const error = new Error(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); - error.code = "ELOCKTIMEOUT"; - throw error; -} - function runCommand(command, args = [], options = {}) { const result = spawnSync(command, args, { cwd: options.cwd, diff --git a/plugins/polycli/scripts/polycli-companion.bundle.mjs b/plugins/polycli/scripts/polycli-companion.bundle.mjs index 188f229..c04eedc 100755 --- a/plugins/polycli/scripts/polycli-companion.bundle.mjs +++ b/plugins/polycli/scripts/polycli-companion.bundle.mjs @@ -84,6 +84,170 @@ function parseArgs(argv, config = {}) { return { options, positionals }; } +// packages/polycli-utils/src/atomic-save.js +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import process2 from "node:process"; +var LockfileTimeoutError = class extends Error { + constructor(lockPath, timeoutMs) { + super(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); + this.code = "ELOCKTIMEOUT"; + this.lockPath = lockPath; + this.timeoutMs = timeoutMs; + } +}; +function sleepSync(ms) { + if (ms <= 0) return; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); +} +function ensureParentDir(filePath) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); +} +function normalizeWriteOptions(options) { + if (typeof options === "string") { + return { + flag: "w", + mode: 438, + writeOptions: options + }; + } + if (options && typeof options === "object") { + const { flag = "w", mode = 438, ...writeOptions } = options; + return { + flag, + mode, + writeOptions: Object.keys(writeOptions).length > 0 ? writeOptions : void 0 + }; + } + return { + flag: "w", + mode: 438, + writeOptions: void 0 + }; +} +function writeFileAtomicSync(filePath, contents, options = {}) { + ensureParentDir(filePath); + const tmpPath = `${filePath}.tmp.${process2.pid}.${Date.now()}.${crypto.randomUUID()}`; + const { flag, mode, writeOptions } = normalizeWriteOptions(options); + const fd = fs.openSync(tmpPath, flag, mode); + try { + fs.writeFileSync(fd, contents, writeOptions); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tmpPath, filePath); + const dirFd = fs.openSync(path.dirname(filePath), "r"); + try { + fs.fsyncSync(dirFd); + } catch (error) { + if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { + throw error; + } + } finally { + fs.closeSync(dirFd); + } +} +function writeFileAtomic(filePath, contents, options = {}) { + writeFileAtomicSync(filePath, contents, options); + return filePath; +} +function writeJsonAtomic(filePath, value, { spaces = 2, finalNewline = true } = {}) { + const text = JSON.stringify(value, null, spaces) + (finalNewline ? "\n" : ""); + return writeFileAtomic(filePath, text, "utf8"); +} +function unlinkIfExists(filePath) { + try { + fs.unlinkSync(filePath); + } catch { + } +} +function tryReclaimStaleLock(lockPath, staleMs) { + let raw; + try { + raw = fs.readFileSync(lockPath, "utf8"); + } catch { + return true; + } + let lock = null; + try { + lock = JSON.parse(raw); + } catch { + lock = null; + } + const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; + const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; + if (pid != null) { + try { + process2.kill(pid, 0); + } catch (killError) { + if (killError.code === "ESRCH") { + unlinkIfExists(lockPath); + return true; + } + if (killError.code !== "EPERM") { + throw killError; + } + } + const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs2 != null && ageMs2 > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; + } + let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; + if (ageMs == null) { + try { + ageMs = Date.now() - fs.statSync(lockPath).mtimeMs; + } catch { + return true; + } + } + if (ageMs != null && ageMs > staleMs) { + unlinkIfExists(lockPath); + return true; + } + return false; +} +function withLockfile(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { + ensureParentDir(lockPath); + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const fd = fs.openSync( + lockPath, + fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, + 384 + ); + try { + fs.writeFileSync(fd, JSON.stringify({ pid: process2.pid, acquiredAt: Date.now() }), "utf8"); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + try { + return fn(); + } finally { + try { + fs.unlinkSync(lockPath); + } catch { + } + } + } catch (error) { + if (error.code !== "EEXIST") { + throw error; + } + if (tryReclaimStaleLock(lockPath, staleMs)) { + continue; + } + sleepSync(pollMs); + } + } + throw new LockfileTimeoutError(lockPath, timeoutMs); +} + // packages/polycli-runtime/src/constants.js var PROVIDER_IDS = ["gemini", "kimi", "qwen", "minimax", "claude", "copilot", "opencode", "pi", "cmd", "agy"]; var PROVIDER_OPERATION_NAMES = ["prompt"]; @@ -142,7 +306,7 @@ function parseStreamJsonLine(raw, { allowPrefix = true } = {}) { // packages/polycli-utils/src/process.js import { spawnSync } from "node:child_process"; -import process2 from "node:process"; +import process3 from "node:process"; function runCommand(command, args = [], options = {}) { const result = spawnSync(command, args, { cwd: options.cwd, @@ -217,7 +381,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI throw new Error(`Invalid pid: ${pid}`); } const killOnce = (targetSignal) => { - if (process2.platform === "win32") { + if (process3.platform === "win32") { const args = ["/PID", String(pid), "/T"]; if (targetSignal === "SIGKILL") { args.push("/F"); @@ -236,7 +400,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI return true; } try { - process2.kill(-pid, targetSignal); + process3.kill(-pid, targetSignal); return true; } catch (error) { if (error.code === "ESRCH") { @@ -246,7 +410,7 @@ async function terminateProcessTree(pid, { signal = "SIGTERM", forceSignal = "SI if (error.code === "EINVAL") { throw error; } - process2.kill(pid, targetSignal); + process3.kill(pid, targetSignal); return true; } }; @@ -1492,9 +1656,9 @@ function runGeminiPromptStreaming({ } // packages/polycli-runtime/src/kimi.js -import fs from "node:fs"; +import fs2 from "node:fs"; import os from "node:os"; -import path from "node:path"; +import path2 from "node:path"; import { createHash } from "node:crypto"; var KIMI_BIN = process.env.KIMI_CLI_BIN || "kimi"; var DEFAULT_TIMEOUT_MS4 = 9e5; @@ -1505,24 +1669,24 @@ var KIMI_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12 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 ]; -var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path.join(os.homedir(), ".kimi", "config.toml"); +var KIMI_CONFIG_PATH = process.env.KIMI_CONFIG_PATH || path2.join(os.homedir(), ".kimi", "config.toml"); function isKimiResumeFooter(text) { return /^To resume:\s*kimi\s+-r\s+/im.test(String(text ?? "").trim()); } function kimiJsonPath() { - return path.join(os.homedir(), ".kimi", "kimi.json"); + return path2.join(os.homedir(), ".kimi", "kimi.json"); } function kimiSessionsDir() { - return path.join(os.homedir(), ".kimi", "sessions"); + return path2.join(os.homedir(), ".kimi", "sessions"); } function resolveRealCwd(cwd) { - return fs.realpathSync(cwd || process.cwd()); + return fs2.realpathSync(cwd || process.cwd()); } function md5CwdPath(realCwd) { return createHash("md5").update(realCwd).digest("hex"); } function formatKimiResumeError(reason, { sessionId, cwd, errCode } = {}) { - const cwdBase = cwd ? path.basename(cwd) : "?"; + const cwdBase = cwd ? path2.basename(cwd) : "?"; if (reason === "invalid-uuid") return "invalid sessionId format; expected UUID."; if (reason === "no-prior-session") return `no prior kimi session for this directory (${cwdBase}). Use /polycli:ask --provider kimi to start one.`; if (reason === "kimi-json-malformed") return "~/.kimi/kimi.json is malformed; cannot resolve last session."; @@ -1534,7 +1698,7 @@ function formatKimiResumeError(reason, { sessionId, cwd, errCode } = {}) { function readKimiLastSession(realCwd) { let raw; try { - raw = fs.readFileSync(kimiJsonPath(), "utf8"); + raw = fs2.readFileSync(kimiJsonPath(), "utf8"); } catch (error) { if (error.code === "ENOENT") return { ok: false, reason: "no-prior-session" }; return { ok: false, reason: "fs-error", errCode: error.code }; @@ -1558,14 +1722,14 @@ function validateKimiResumeTarget({ realCwd, cwdHash, sessionId }) { if (typeof sessionId !== "string" || !KIMI_UUID_RE.test(sessionId)) { return { ok: false, reason: "invalid-uuid" }; } - const sessionDir = path.join(kimiSessionsDir(), cwdHash, sessionId); - const contextPath = path.join(sessionDir, "context.jsonl"); + const sessionDir = path2.join(kimiSessionsDir(), cwdHash, sessionId); + const contextPath = path2.join(sessionDir, "context.jsonl"); try { - const dirStat = fs.statSync(sessionDir); + const dirStat = fs2.statSync(sessionDir); if (!dirStat.isDirectory()) { return { ok: false, reason: "session-not-found" }; } - const contextStat = fs.statSync(contextPath); + const contextStat = fs2.statSync(contextPath); if (!contextStat.isFile() || contextStat.size === 0) { return { ok: false, reason: "session-empty" }; } @@ -1574,7 +1738,7 @@ function validateKimiResumeTarget({ realCwd, cwdHash, sessionId }) { if (error.code === "ENOENT") { return { ok: false, - reason: fs.existsSync(sessionDir) ? "session-empty" : "session-not-found" + reason: fs2.existsSync(sessionDir) ? "session-empty" : "session-not-found" }; } return { ok: false, reason: "fs-error", errCode: error.code, realCwd }; @@ -1647,7 +1811,7 @@ function resolveKimiResumeSession({ } function readKimiDefaultModel() { try { - const text = fs.readFileSync(KIMI_CONFIG_PATH, "utf8"); + const text = fs2.readFileSync(KIMI_CONFIG_PATH, "utf8"); const match = text.match(/^default_model\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))/m); return match ? match[1] ?? match[2] ?? match[3] ?? null : null; } catch { @@ -4208,10 +4372,10 @@ import os4 from "node:os"; import process4 from "node:process"; // plugins/polycli/scripts/lib/state.mjs -import crypto from "node:crypto"; -import fs2 from "node:fs"; +import crypto2 from "node:crypto"; +import fs3 from "node:fs"; import os2 from "node:os"; -import path2 from "node:path"; +import path3 from "node:path"; import { spawnSync as spawnSync2 } from "node:child_process"; var STATE_VERSION = 1; var STATE_FILE_NAME = "state.json"; @@ -4219,99 +4383,7 @@ var JOBS_DIR_NAME = "jobs"; var MAX_JOBS = 100; var POLYCLI_STATE_ROOT_ENV = "POLYCLI_STATE_ROOT"; var PLUGIN_DATA_ENV = "CLAUDE_PLUGIN_DATA"; -var FALLBACK_STATE_ROOT = path2.join(os2.tmpdir(), "polycli-companion"); -function sleepSync(ms) { - if (ms <= 0) return; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} -function writeJsonAtomic(filePath, value) { - fs2.mkdirSync(path2.dirname(filePath), { recursive: true }); - const tmpPath = `${filePath}.tmp.${process.pid}.${Date.now()}.${crypto.randomUUID()}`; - const fd = fs2.openSync(tmpPath, "w", 438); - try { - fs2.writeFileSync(fd, `${JSON.stringify(value, null, 2)} -`, "utf8"); - fs2.fsyncSync(fd); - } finally { - fs2.closeSync(fd); - } - fs2.renameSync(tmpPath, filePath); - try { - const dirFd = fs2.openSync(path2.dirname(filePath), "r"); - try { - fs2.fsyncSync(dirFd); - } finally { - fs2.closeSync(dirFd); - } - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; - } - } -} -function withLockfile(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { - fs2.mkdirSync(path2.dirname(lockPath), { recursive: true }); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const fd = fs2.openSync( - lockPath, - fs2.constants.O_CREAT | fs2.constants.O_EXCL | fs2.constants.O_WRONLY, - 384 - ); - try { - fs2.writeFileSync(fd, JSON.stringify({ pid: process.pid, acquiredAt: Date.now() }), "utf8"); - fs2.fsyncSync(fd); - } finally { - fs2.closeSync(fd); - } - try { - return fn(); - } finally { - try { - fs2.unlinkSync(lockPath); - } catch { - } - } - } catch (error2) { - if (error2.code !== "EEXIST") { - throw error2; - } - try { - const lock = JSON.parse(fs2.readFileSync(lockPath, "utf8")); - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - const lockAgeMs = acquiredAt == null ? null : Date.now() - acquiredAt; - let ownerAlive = false; - if (pid != null) { - try { - process.kill(pid, 0); - ownerAlive = true; - } catch (killError) { - if (killError.code === "ESRCH") { - fs2.unlinkSync(lockPath); - continue; - } - if (killError.code !== "EPERM") { - throw killError; - } - ownerAlive = true; - } - } - if (ownerAlive && lockAgeMs != null && lockAgeMs > staleMs) { - fs2.unlinkSync(lockPath); - continue; - } - } catch { - continue; - } - sleepSync(pollMs); - } - } - const error = new Error(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); - error.code = "ELOCKTIMEOUT"; - throw error; -} +var FALLBACK_STATE_ROOT = path3.join(os2.tmpdir(), "polycli-companion"); function runCommand2(command, args = [], options = {}) { const result = spawnSync2(command, args, { cwd: options.cwd, @@ -4332,8 +4404,8 @@ function runCommand2(command, args = [], options = {}) { }; } function computeWorkspaceSlug(workspaceRoot) { - const base = path2.basename(workspaceRoot).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "workspace"; - const hash = crypto.createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12); + const base = path3.basename(workspaceRoot).replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 40) || "workspace"; + const hash = crypto2.createHash("sha256").update(workspaceRoot).digest("hex").slice(0, 12); return `${base}-${hash}`; } function defaultState() { @@ -4349,7 +4421,7 @@ function buildCorruptBackupPath(stateFile) { } function backupCorruptStateFile(stateFile) { try { - fs2.renameSync(stateFile, buildCorruptBackupPath(stateFile)); + fs3.renameSync(stateFile, buildCorruptBackupPath(stateFile)); } catch { } } @@ -4357,14 +4429,14 @@ function describeStateRoot() { const polycliStateRoot = process.env[POLYCLI_STATE_ROOT_ENV]; if (polycliStateRoot) { return { - stateRoot: path2.resolve(polycliStateRoot), + stateRoot: path3.resolve(polycliStateRoot), source: POLYCLI_STATE_ROOT_ENV }; } const pluginData = process.env[PLUGIN_DATA_ENV]; if (pluginData) { return { - stateRoot: path2.join(pluginData, "state"), + stateRoot: path3.join(pluginData, "state"), source: PLUGIN_DATA_ENV }; } @@ -4379,36 +4451,36 @@ function stateRootDir() { function resolveWorkspaceRoot(cwd = process.cwd()) { const result = runCommand2("git", ["rev-parse", "--show-toplevel"], { cwd }); if (result.status === 0 && result.stdout.trim()) { - return path2.resolve(result.stdout.trim()); + return path3.resolve(result.stdout.trim()); } - return path2.resolve(cwd); + return path3.resolve(cwd); } function resolveStateDir(workspaceRoot) { - return path2.join(stateRootDir(), computeWorkspaceSlug(workspaceRoot)); + return path3.join(stateRootDir(), computeWorkspaceSlug(workspaceRoot)); } function resolveStateFile(workspaceRoot) { - return path2.join(resolveStateDir(workspaceRoot), STATE_FILE_NAME); + return path3.join(resolveStateDir(workspaceRoot), STATE_FILE_NAME); } function resolveJobsDir(workspaceRoot) { - return path2.join(resolveStateDir(workspaceRoot), JOBS_DIR_NAME); + return path3.join(resolveStateDir(workspaceRoot), JOBS_DIR_NAME); } function resolveJobFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.json`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.json`); } function resolveJobLogFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.log`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.log`); } function resolveJobConfigFile(workspaceRoot, jobId) { - return path2.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); + return path3.join(resolveJobsDir(workspaceRoot), `${jobId}.config.json`); } function ensureStateDir(workspaceRoot) { - fs2.mkdirSync(resolveJobsDir(workspaceRoot), { recursive: true }); + fs3.mkdirSync(resolveJobsDir(workspaceRoot), { recursive: true }); } function loadState(workspaceRoot) { const stateFile = resolveStateFile(workspaceRoot); let raw; try { - raw = fs2.readFileSync(stateFile, "utf8"); + raw = fs3.readFileSync(stateFile, "utf8"); } catch { return defaultState(); } @@ -4527,7 +4599,7 @@ function writeJobFile(workspaceRoot, jobId, payload) { } function readJobFile(jobFile) { try { - return JSON.parse(fs2.readFileSync(jobFile, "utf8")); + return JSON.parse(fs3.readFileSync(jobFile, "utf8")); } catch { return null; } @@ -4539,14 +4611,14 @@ function writeJobConfigFile(workspaceRoot, jobId, payload) { } function readJobConfigFile(configFile) { try { - return JSON.parse(fs2.readFileSync(configFile, "utf8")); + return JSON.parse(fs3.readFileSync(configFile, "utf8")); } catch { return null; } } function removeJobConfigFile(workspaceRoot, jobId) { try { - fs2.unlinkSync(resolveJobConfigFile(workspaceRoot, jobId)); + fs3.unlinkSync(resolveJobConfigFile(workspaceRoot, jobId)); } catch { } } @@ -4557,168 +4629,6 @@ import path4 from "node:path"; // packages/polycli-utils/src/ndjson.js import fs4 from "node:fs"; - -// packages/polycli-utils/src/atomic-save.js -import crypto2 from "node:crypto"; -import fs3 from "node:fs"; -import path3 from "node:path"; -import process3 from "node:process"; -var LockfileTimeoutError = class extends Error { - constructor(lockPath, timeoutMs) { - super(`Timed out acquiring lockfile ${lockPath} after ${timeoutMs}ms`); - this.code = "ELOCKTIMEOUT"; - this.lockPath = lockPath; - this.timeoutMs = timeoutMs; - } -}; -function sleepSync2(ms) { - if (ms <= 0) return; - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); -} -function ensureParentDir(filePath) { - fs3.mkdirSync(path3.dirname(filePath), { recursive: true }); -} -function normalizeWriteOptions(options) { - if (typeof options === "string") { - return { - flag: "w", - mode: 438, - writeOptions: options - }; - } - if (options && typeof options === "object") { - const { flag = "w", mode = 438, ...writeOptions } = options; - return { - flag, - mode, - writeOptions: Object.keys(writeOptions).length > 0 ? writeOptions : void 0 - }; - } - return { - flag: "w", - mode: 438, - writeOptions: void 0 - }; -} -function writeFileAtomicSync(filePath, contents, options = {}) { - ensureParentDir(filePath); - const tmpPath = `${filePath}.tmp.${process3.pid}.${Date.now()}.${crypto2.randomUUID()}`; - const { flag, mode, writeOptions } = normalizeWriteOptions(options); - const fd = fs3.openSync(tmpPath, flag, mode); - try { - fs3.writeFileSync(fd, contents, writeOptions); - fs3.fsyncSync(fd); - } finally { - fs3.closeSync(fd); - } - fs3.renameSync(tmpPath, filePath); - const dirFd = fs3.openSync(path3.dirname(filePath), "r"); - try { - fs3.fsyncSync(dirFd); - } catch (error) { - if (!["EINVAL", "ENOTSUP", "EPERM"].includes(error?.code)) { - throw error; - } - } finally { - fs3.closeSync(dirFd); - } -} -function writeFileAtomic(filePath, contents, options = {}) { - writeFileAtomicSync(filePath, contents, options); - return filePath; -} -function unlinkIfExists(filePath) { - try { - fs3.unlinkSync(filePath); - } catch { - } -} -function tryReclaimStaleLock(lockPath, staleMs) { - let raw; - try { - raw = fs3.readFileSync(lockPath, "utf8"); - } catch { - return true; - } - let lock = null; - try { - lock = JSON.parse(raw); - } catch { - lock = null; - } - const pid = Number.isInteger(lock?.pid) && lock.pid > 0 ? lock.pid : null; - const acquiredAt = Number.isFinite(lock?.acquiredAt) ? lock.acquiredAt : null; - if (pid != null) { - try { - process3.kill(pid, 0); - } catch (killError) { - if (killError.code === "ESRCH") { - unlinkIfExists(lockPath); - return true; - } - if (killError.code !== "EPERM") { - throw killError; - } - } - const ageMs2 = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs2 != null && ageMs2 > staleMs) { - unlinkIfExists(lockPath); - return true; - } - return false; - } - let ageMs = acquiredAt == null ? null : Date.now() - acquiredAt; - if (ageMs == null) { - try { - ageMs = Date.now() - fs3.statSync(lockPath).mtimeMs; - } catch { - return true; - } - } - if (ageMs != null && ageMs > staleMs) { - unlinkIfExists(lockPath); - return true; - } - return false; -} -function withLockfile2(lockPath, fn, { timeoutMs = 1e4, staleMs = 6e5, pollMs = 25 } = {}) { - ensureParentDir(lockPath); - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - try { - const fd = fs3.openSync( - lockPath, - fs3.constants.O_CREAT | fs3.constants.O_EXCL | fs3.constants.O_WRONLY, - 384 - ); - try { - fs3.writeFileSync(fd, JSON.stringify({ pid: process3.pid, acquiredAt: Date.now() }), "utf8"); - fs3.fsyncSync(fd); - } finally { - fs3.closeSync(fd); - } - try { - return fn(); - } finally { - try { - fs3.unlinkSync(lockPath); - } catch { - } - } - } catch (error) { - if (error.code !== "EEXIST") { - throw error; - } - if (tryReclaimStaleLock(lockPath, staleMs)) { - continue; - } - sleepSync2(pollMs); - } - } - throw new LockfileTimeoutError(lockPath, timeoutMs); -} - -// packages/polycli-utils/src/ndjson.js function safeParseLine(line) { try { return JSON.parse(line); @@ -4752,7 +4662,7 @@ function readNdjson(filePath) { } function appendNdjson(filePath, record, { timeoutMs = 1e4, staleMs = 3e4, pollMs = 25, maxBytes = null, keepRatio = 0.5 } = {}) { const lockPath = `${filePath}.lock`; - return withLockfile2(lockPath, () => { + return withLockfile(lockPath, () => { ensureParentDir(filePath); let needsLeadingNewline = false; try { @@ -5309,6 +5219,10 @@ function recoverLedgerTerminalEvents(workspaceRoot, job, { result = null, reason const config = readJobConfigFile(resolveJobConfigFile(workspaceRoot, job.jobId)); const runContext = config?.runContext; if (!runContext?.runId) return; + const recoverLock = `${resolveRunLedgerFile(workspaceRoot)}.recover.lock`; + withLockfile(recoverLock, () => writeRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result, reason })); +} +function writeRecoveredTerminalEvents(workspaceRoot, job, config, runContext, { result = null, reason = "worker_exited" } = {}) { const events = readRunLedgerEvents(workspaceRoot); const command = runContext.command || config?.execution?.kind || job.kind || null; const provider = runContext.provider || config?.execution?.provider || job.provider || null; @@ -5454,36 +5368,9 @@ async function waitForJob(workspaceRoot, jobId, { timeoutMs = 24e4, pollInterval return { job: timed ? refreshJob(workspaceRoot, timed) : null, waitTimedOut: true }; } async function cancelJob(workspaceRoot, jobId) { - const job = getJob(workspaceRoot, jobId); - if (!job) { - return { cancelled: false, reason: "not_found", jobId }; - } - if (!ACTIVE_STATUSES.has(job.status)) { - return { cancelled: false, reason: "not_cancellable", jobId }; - } - try { - if (job.pid) { - await terminateProcessTree(job.pid, { - signal: "SIGINT", - forceSignal: "SIGKILL", - forceAfterMs: 2e3 - }); - } - } catch (error) { - return { - cancelled: false, - reason: "cancel_failed", - jobId, - error: error.message - }; - } - const cancelledJob = { - ...job, - status: "cancelled", - pid: null, - finishedAt: (/* @__PURE__ */ new Date()).toISOString() - }; + let pidToKill = null; let reason = null; + const finishedAt = (/* @__PURE__ */ new Date()).toISOString(); const write = updateJobAtomically(workspaceRoot, jobId, (current) => { if (!current) { reason = "not_found"; @@ -5493,11 +5380,12 @@ async function cancelJob(workspaceRoot, jobId) { reason = "not_cancellable"; return null; } + pidToKill = current.pid ?? null; const nextJob = { ...current, status: "cancelled", pid: null, - finishedAt: cancelledJob.finishedAt + finishedAt }; return { job: nextJob, @@ -5513,6 +5401,17 @@ async function cancelJob(workspaceRoot, jobId) { if (!write.written) { return { cancelled: false, reason: reason || "not_cancellable", jobId }; } + if (pidToKill) { + try { + await terminateProcessTree(pidToKill, { + signal: "SIGINT", + forceSignal: "SIGKILL", + forceAfterMs: 2e3 + }); + } catch (error) { + return { cancelled: true, jobId, killWarning: error.message }; + } + } return { cancelled: true, jobId }; } @@ -6285,12 +6184,9 @@ function cacheProviderModel(workspaceRoot, provider, model) { if (typeof model !== "string" || !model.trim()) return; const cacheFile = resolveProviderModelCacheFile(workspaceRoot); fs9.mkdirSync(path8.dirname(cacheFile), { recursive: true }); - fs9.writeFileSync( - cacheFile, - `${JSON.stringify({ ...readProviderModelCache(workspaceRoot), [provider]: model }, null, 2)} -`, - "utf8" - ); + withLockfile(`${cacheFile}.lock`, () => { + writeJsonAtomic(cacheFile, { ...readProviderModelCache(workspaceRoot), [provider]: model }); + }); } async function inspectProvider(provider) { const runtime = getProviderRuntime(provider); diff --git a/plugins/polycli/scripts/polycli-companion.mjs b/plugins/polycli/scripts/polycli-companion.mjs index d039d81..8cb5db9 100644 --- a/plugins/polycli/scripts/polycli-companion.mjs +++ b/plugins/polycli/scripts/polycli-companion.mjs @@ -8,6 +8,7 @@ import { spawn } from "node:child_process"; import { fileURLToPath } from "node:url"; import { parseArgs } from "@bbingz/polycli-utils/args"; +import { withLockfile, writeJsonAtomic } from "@bbingz/polycli-utils/atomic-save"; import { getProviderRuntime, listProviderRuntimes, runProviderPromptStreaming } from "@bbingz/polycli-runtime"; import { @@ -260,11 +261,12 @@ function cacheProviderModel(workspaceRoot, provider, model) { if (typeof model !== "string" || !model.trim()) return; const cacheFile = resolveProviderModelCacheFile(workspaceRoot); fs.mkdirSync(path.dirname(cacheFile), { recursive: true }); - fs.writeFileSync( - cacheFile, - `${JSON.stringify({ ...readProviderModelCache(workspaceRoot), [provider]: model }, null, 2)}\n`, - "utf8" - ); + // Serialize the read-modify-write under a lock and write atomically. A bare writeFileSync RMW + // let two concurrent invocations against the same workspace each read the old cache, add only + // their own provider, and last-writer-wins — silently dropping the other's freshly-cached model. + withLockfile(`${cacheFile}.lock`, () => { + writeJsonAtomic(cacheFile, { ...readProviderModelCache(workspaceRoot), [provider]: model }); + }); } async function inspectProvider(provider) { From a6934176678cfa23d2daa52fc6fbba3d368fb1e8 Mon Sep 17 00:00:00 2001 From: bbingz Date: Tue, 2 Jun 2026 16:24:46 +0800 Subject: [PATCH 3/3] chore(deep-review): untrack accidental .codegraph artifact + gitignore it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review of PR #5 stalled (~20min, no output) on the 1249-line diff, so this gate ran a 6-dimension adversarial self-review instead (20 reviewers: auth-probe transient cluster, atomic-save locking, signal-kill, stream limit, job-control concurrency, state dedup, companion sessionId, test adequacy). 14 raw findings → 0 survived adversarial verification: the 25 hardening fixes are correctly implemented with no regressions (auth transient→inconclusive consistent across all 10 providers; signal-kill surfaces a synthetic error before status is read; no-pid stale-lock reclaim correct and tested; state.mjs reduction is pure helper extraction; sessionId never fabricated). Only actionable item: the branch tracked `.codegraph/.gitignore` (a CodeGraph index artifact) while root `.gitignore` lacked a `.codegraph/` entry. Untrack the artifact and ignore the directory (matching the kimi/grok branches). Residual (NOT changed, low-risk): cacheProviderModel now throws on lock-acquire timeout — but it already threw on FS errors at the same unguarded call sites, so the propagation behavior is essentially pre-existing; the timeout path is extremely low-probability and self-heals via the no-pid reclaim fix. No source/bundle change; npm test stays 474/474. --- .codegraph/.gitignore | 16 ---------------- .gitignore | 3 +++ 2 files changed, 3 insertions(+), 16 deletions(-) delete mode 100644 .codegraph/.gitignore diff --git a/.codegraph/.gitignore b/.codegraph/.gitignore deleted file mode 100644 index 9de0f16..0000000 --- a/.codegraph/.gitignore +++ /dev/null @@ -1,16 +0,0 @@ -# CodeGraph data files -# These are local to each machine and should not be committed - -# Database -*.db -*.db-wal -*.db-shm - -# Cache -cache/ - -# Logs -*.log - -# Hook markers -.dirty diff --git a/.gitignore b/.gitignore index 039a37e..16be833 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,6 @@ dist/ # Claude Code worktree state (agent isolation runs) .claude/ + +# CodeGraph local index (not committed) +.codegraph/