From f40f9a151706f7feec0e014f6e125793ada4bdf8 Mon Sep 17 00:00:00 2001 From: surenny233 Date: Mon, 20 Jul 2026 13:56:14 +0000 Subject: [PATCH 1/2] feat(daemon): add optional Codex ACP backend --- .env.example | 19 +- packages/daemon/README.md | 10 +- packages/daemon/package.json | 2 + packages/daemon/src/acp.test.ts | 140 +++++++ packages/daemon/src/acp.ts | 338 ++++++++++++++++ packages/daemon/src/runner.test.ts | 28 ++ packages/daemon/src/runner.ts | 206 +++++++--- .../server/src/components/LoopDetailView.tsx | 1 + packages/server/src/components/RunView.tsx | 30 +- packages/server/src/components/actionUi.tsx | 38 +- packages/server/src/db/schema.ts | 5 + packages/server/src/lib/resumeCommand.test.ts | 9 + packages/server/src/lib/resumeCommand.ts | 18 +- packages/server/src/types.ts | 4 + pnpm-lock.yaml | 378 ++++++++++++++++++ 15 files changed, 1138 insertions(+), 88 deletions(-) create mode 100644 packages/daemon/src/acp.test.ts create mode 100644 packages/daemon/src/acp.ts diff --git a/.env.example b/.env.example index 959a421..023171a 100644 --- a/.env.example +++ b/.env.example @@ -107,15 +107,24 @@ LOOPANY_LOG_LEVEL=info # LOOPANY_ROOTS=~/Workspace,~/notes # cwd jail (empty = unrestricted) # LOOPANY_POLL_MS=3000 # LOOPANY_CLAUDE_BIN=claude +# Codex execution backend. `native` (default) runs `codex exec --json`; `acp` +# runs the daemon's pinned local acpx + codex-acp dependencies and records a +# normalized live trace plus detailed token/context usage. No prompt is hosted. +# LOOPANY_CODEX_BACKEND=acp +# Optional ACP executable escape hatches (normally leave unset; local pinned JS +# entrypoints are resolved without PATH/npx). +# LOOPANY_ACPX_BIN=acpx +# LOOPANY_CODEX_ACP_BIN=codex-acp # Coding-agent (claude) wall-clock timeout, ms. Opt-in override: a positive value # arms the timer; unset/0/invalid/negative ⇒ unlimited (the default). A run that # disappears is caught by the server's inactivity sweep, not this daemon timer. # LOOPANY_EXEC_TIMEOUT_MS=900000 -# Transient-failure resume: on a TRANSIENT claude crash (API error / connection -# closed / ECONNRESET / overloaded / rate limit / 5xx) the daemon retries via -# `claude --resume `. Auth/quota, poisoned, and task failures never -# retry; timeouts and aborts never retry. Max retries default 2 (set 0 to -# disable); backoff base default 15000ms (then 4x, jittered). +# Transient-failure resume: on a TRANSIENT coding-agent crash (API error / +# connection closed / ECONNRESET / overloaded / rate limit / 5xx) the daemon +# resumes the captured session (native CLI id or ACP named session). Auth/quota, +# poisoned, and task failures never retry; timeouts and aborts never retry. Max +# retries default 2 (set 0 to disable); backoff base default 15000ms (then 4x, +# jittered). # LOOPANY_TRANSIENT_RETRIES=2 # LOOPANY_TRANSIENT_RETRY_BASE_MS=15000 # Per-loop sync caps: a loop folder is a synced CONTENT home (reports/state/ui/ diff --git a/packages/daemon/README.md b/packages/daemon/README.md index 4e3ef64..28d7dcf 100644 --- a/packages/daemon/README.md +++ b/packages/daemon/README.md @@ -6,7 +6,7 @@ executes your scheduled agent loops locally via your own coding agent. Loopany is **BYOA** (bring your own agent): the server schedules, stores, and notifies, but never runs an LLM or executes your code. This daemon is the execution half - it polls the server for due runs, spawns the loop's coding agent -(Claude Code, `codex exec` for a `codex` loop, or the grok CLI for a `grok` loop) +(Claude Code, native `codex exec` or optional ACP for a `codex` loop, or the grok CLI for a `grok` loop) in the loop's working directory, and reports the results back. ## Requirements @@ -94,6 +94,14 @@ defensively caps how much it syncs per loop (`LOOPANY_SYNC_MAX_FILES` / `LOOPANY_SYNC_MAX_BYTES`) so a stray checkout can never flood the sync. Your code and credentials stay on your machine. +For Codex, the default backend remains the native CLI. Set +`LOOPANY_CODEX_BACKEND=acp` before starting the daemon to use the package's +pinned local `acpx` + `codex-acp` transport. ACP adds normalized live activity, +tool/file trace, session identity, and detailed token/context usage to run +detail; it still executes locally and sends only Loopany's normal run report to +your configured Loopany server. Set the value back to `native` for an immediate +rollback. No `npx` download is performed at run time. + The package also bundles the **loopany agent skill**, which teaches a coding agent how to author and evolve loops; `loopany up` (and `loopany new`) install it at user scope for every coding agent loopany knows about (Claude Code diff --git a/packages/daemon/package.json b/packages/daemon/package.json index 74660b5..50c0c66 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -31,6 +31,8 @@ "prepublishOnly": "node scripts/sync-skill.mjs && node scripts/copy-runtime-assets.mjs && tsc" }, "dependencies": { + "@agentclientprotocol/codex-acp": "1.1.4", + "acpx": "0.12.0", "chokidar": "^4.0.3", "mcporter": "0.12.3", "pino": "^10.3.1", diff --git a/packages/daemon/src/acp.test.ts b/packages/daemon/src/acp.test.ts new file mode 100644 index 0000000..fbd63bd --- /dev/null +++ b/packages/daemon/src/acp.test.ts @@ -0,0 +1,140 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, describe, expect, test } from "vitest"; + +import { + acpSessionName, + buildCodexAcpEnsureSpawn, + buildCodexAcpSpawn, + costFromAcpResult, + makeAcpStreamConsumer, + resolveCodexBackend, +} from "./acp.js"; + +afterEach(() => { + delete process.env.LOOPANY_ACPX_BIN; + delete process.env.LOOPANY_CODEX_ACP_BIN; +}); + +describe("Codex ACP backend selection and spawn", () => { + test("is opt-in and rejects a typo instead of silently changing transport", () => { + expect(resolveCodexBackend(undefined)).toBe("native"); + expect(resolveCodexBackend("cli")).toBe("native"); + expect(resolveCodexBackend("ACP")).toBe("acp"); + expect(() => resolveCodexBackend("apc")).toThrow(/native or acp/); + }); + + test("uses pinned local JS entrypoints, strict JSON, unattended permissions, and a named session", () => { + const spawn = buildCodexAcpSpawn({ prompt: "do it", sessionName: "loopany-run-1", model: "gpt-test" }); + expect(spawn.bin).toBe(process.execPath); + expect(spawn.args[0]).toMatch(/acpx[/\\]dist[/\\]cli\.js$/); + expect(spawn.args).toContain("--json-strict"); + expect(spawn.args).toContain("--approve-all"); + expect(spawn.args).toContain("--suppress-reads"); + expect(spawn.args[spawn.args.indexOf("--auth-policy") + 1]).toBe("skip"); + expect(spawn.args).toContain("loopany-run-1"); + expect(spawn.args).toContain("gpt-test"); + const agentCommand = spawn.args[spawn.args.indexOf("--agent") + 1]; + expect(agentCommand).toMatch(/codex-acp[/\\]dist[/\\]index\.js/); + expect(spawn.args.at(-1)).toBe("do it"); + }); + + test("builds the explicit named-session ensure required before a prompt", () => { + process.env.LOOPANY_ACPX_BIN = "/opt/acpx"; + const spawn = buildCodexAcpEnsureSpawn({ sessionName: "loopany-run-1" }); + expect(spawn.args.slice(-4)).toEqual(["sessions", "ensure", "--name", "loopany-run-1"]); + }); + + test("supports explicit acpx and adapter command escape hatches", () => { + process.env.LOOPANY_ACPX_BIN = "/opt/acpx"; + process.env.LOOPANY_CODEX_ACP_BIN = "/opt/codex-acp --profile loop"; + const spawn = buildCodexAcpSpawn({ prompt: "p", sessionName: "s" }); + expect(spawn.bin).toBe("/opt/acpx"); + expect(spawn.args[0]).toBe("--agent"); + expect(spawn.args[1]).toBe("/opt/codex-acp --profile loop"); + }); + + test("makes a bounded acpx session name from an arbitrary run id", () => { + expect(acpSessionName("run:one/two")).toBe("loopany-run-one-two"); + expect(acpSessionName("x".repeat(200)).length).toBeLessThanOrEqual(104); + }); +}); + +describe("Codex ACP JSON-RPC parser", () => { + test("maps the real terminal usage shape, including cache and reasoning tokens", () => { + expect(costFromAcpResult({ + stopReason: "end_turn", + usage: { totalTokens: 120, inputTokens: 80, cachedReadTokens: 30, outputTokens: 10, thoughtTokens: 4 }, + })).toEqual({ + totalTokens: 120, + inputTokens: 80, + outputTokens: 10, + cacheReadTokens: 30, + reasoningTokens: 4, + numTurns: 1, + }); + }); + + test("derives session, progress, trace, artifact, context, final text, and detailed usage", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "loopany-acp-")); + fs.writeFileSync(path.join(root, "changed.ts"), "old\n", "utf8"); + const progress: Array<{ step: number; label: string }> = []; + try { + const stream = makeAcpStreamConsumer((p) => progress.push(p), root); + const events = [ + { jsonrpc: "2.0", id: 1, result: { sessionId: "thread-1" } }, + { jsonrpc: "2.0", method: "session/update", params: { sessionId: "thread-1", update: { sessionUpdate: "agent_thought_chunk", messageId: "thought-1", content: { type: "text", text: "Inspect" } } } }, + { jsonrpc: "2.0", method: "session/update", params: { sessionId: "thread-1", update: { sessionUpdate: "agent_thought_chunk", messageId: "thought-1", content: { type: "text", text: "ing" } } } }, + { jsonrpc: "2.0", method: "session/update", params: { sessionId: "thread-1", update: { sessionUpdate: "tool_call", toolCallId: "tool-1", title: "Edit changed.ts", kind: "edit", status: "in_progress", rawInput: { patch: "..." }, locations: [{ path: "changed.ts", line: 1 }] } } }, + { jsonrpc: "2.0", method: "session/update", params: { sessionId: "thread-1", update: { sessionUpdate: "tool_call_update", toolCallId: "tool-1", title: "Edit changed.ts", kind: "edit", status: "completed", content: [{ type: "content", content: { type: "text", text: "Updated changed.ts" } }], locations: [{ path: "changed.ts", line: 1 }] } } }, + { jsonrpc: "2.0", method: "session/update", params: { sessionId: "thread-1", update: { sessionUpdate: "tool_call", toolCallId: "tool-2", title: "Editing files", kind: "edit", status: "in_progress", content: [{ type: "diff", oldText: null, newText: "new", path: path.join(root, "created.ts"), _meta: { kind: "add" } }] } } }, + { jsonrpc: "2.0", method: "session/update", params: { sessionId: "thread-1", update: { sessionUpdate: "tool_call_update", toolCallId: "tool-2", status: "completed" } } }, + { jsonrpc: "2.0", method: "session/update", params: { sessionId: "thread-1", update: { sessionUpdate: "agent_message_chunk", messageId: "answer-1", content: { type: "text", text: "DO" }, _meta: { codex: { phase: "final_answer" } } } } }, + { jsonrpc: "2.0", method: "session/update", params: { sessionId: "thread-1", update: { sessionUpdate: "agent_message_chunk", messageId: "answer-1", content: { type: "text", text: "NE" }, _meta: { codex: { phase: "final_answer" } } } } }, + { jsonrpc: "2.0", method: "session/update", params: { sessionId: "thread-1", update: { sessionUpdate: "usage_update", used: 26673, size: 258400 } } }, + { jsonrpc: "2.0", id: 2, result: { stopReason: "end_turn", usage: { totalTokens: 26673, inputTokens: 16679, cachedReadTokens: 9984, outputTokens: 10, thoughtTokens: 0 } } }, + ]; + // Exercise chunk boundaries and the unterminated-last-line flush. + const raw = events.map((event) => JSON.stringify(event)).join("\n"); + stream.feed(raw.slice(0, 137)); + stream.feed(raw.slice(137)); + const final = stream.result(); + + expect(final.sessionId).toBe("thread-1"); + expect(final.stopReason).toBe("end_turn"); + expect(final.finalText).toBe("DONE"); + expect(final.cost).toEqual({ + contextTokens: 26673, + contextWindow: 258400, + totalTokens: 26673, + inputTokens: 16679, + outputTokens: 10, + cacheReadTokens: 9984, + reasoningTokens: 0, + numTurns: 1, + }); + expect(final.artifacts).toEqual([ + { path: "changed.ts", kind: "edited" }, + { path: "created.ts", kind: "created" }, + ]); + expect(final.transcript).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "text", text: "Thinking: Inspecting" }), + expect.objectContaining({ kind: "tool", name: "Edit changed.ts" }), + expect.objectContaining({ kind: "result", text: "Updated changed.ts" }), + expect.objectContaining({ kind: "text", text: "DONE" }), + ])); + expect(progress.length).toBeGreaterThan(0); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + test("surfaces a JSON-RPC error without throwing on stray non-JSON", () => { + const stream = makeAcpStreamConsumer(() => {}, process.cwd()); + stream.feed("adapter banner\n"); + stream.feed('{"jsonrpc":"2.0","id":2,"error":{"code":-32603,"message":"boom","data":{"message":"connection reset"}}}'); + expect(stream.result().error).toContain("boom"); + }); +}); diff --git a/packages/daemon/src/acp.ts b/packages/daemon/src/acp.ts new file mode 100644 index 0000000..98e560c --- /dev/null +++ b/packages/daemon/src/acp.ts @@ -0,0 +1,338 @@ +/** + * ACP transport for coding agents. + * + * Loopany still owns scheduling, run leases, callbacks, and reporting. acpx owns + * the ACP client lifecycle, while the bundled Codex ACP adapter translates the + * Codex App Server stream into protocol-level JSON-RPC events. This module keeps + * that alpha CLI surface behind one small adapter so the native CLI path remains + * the default and a future in-process ACP client is a local replacement. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import type { RunArtifact, TranscriptStep } from "./artifacts.js"; +import type { AgentSpawn, RunCost } from "./runner.js"; + +export type CodexBackend = "native" | "acp"; + +export interface AcpStreamFinal { + sessionId?: string; + stopReason?: string; + finalText?: string; + error?: string; + cost?: RunCost; + artifacts: RunArtifact[]; + transcript: TranscriptStep[]; +} + +const STEP_TEXT_MAX = 1500; +const MAX_STEPS = 80; +const FINAL_TEXT_MAX = 64 * 1024; + +/** Opt-in only until the ACP path has soaked on real loops. */ +export function resolveCodexBackend(raw = process.env.LOOPANY_CODEX_BACKEND): CodexBackend { + const value = raw?.trim().toLowerCase(); + if (!value || value === "native" || value === "cli") return "native"; + if (value === "acp") return "acp"; + throw new Error(`LOOPANY_CODEX_BACKEND must be native or acp (got ${JSON.stringify(raw)})`); +} + +/** Stable, acpx-safe name: one persistent ACP session per Loopany run. */ +export function acpSessionName(runId: string): string { + const safe = runId.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 96) || "run"; + return `loopany-${safe}`; +} + +function quotedCommandPart(value: string): string { + // acpx parses the raw --agent value itself; JSON string quoting gives it one + // argv word even when an npm/global install path contains spaces. + return JSON.stringify(value); +} + +/** + * Run the bundled/pinned ACP client and Codex adapter without relying on PATH or + * npx. Escape hatches accept an executable for acpx and a raw ACP agent command. + */ +function codexAcpInvocation(opts: { + sessionName: string; + model?: string | null; +}, commandArgs: string[]): AgentSpawn { + const acpxBin = process.env.LOOPANY_ACPX_BIN; + const acpxCli = fileURLToPath(import.meta.resolve("acpx")); + const adapterCli = fileURLToPath(import.meta.resolve("@agentclientprotocol/codex-acp")); + const agentCommand = + process.env.LOOPANY_CODEX_ACP_BIN || + `${quotedCommandPart(process.execPath)} ${quotedCommandPart(adapterCli)}`; + const prefix = acpxBin ? [] : [acpxCli]; + return { + bin: acpxBin || process.execPath, + args: [ + ...prefix, + "--agent", agentCommand, + "--approve-all", + "--non-interactive-permissions", "fail", + // The bundled adapter can use the Codex App Server's existing ChatGPT + // login even when it advertises only an ACP api-key method headlessly. + // `fail` incorrectly rejects that valid local-login path; skip means + // "do not initiate a separate ACP authenticate exchange". + "--auth-policy", "skip", + "--format", "json", + "--json-strict", + "--suppress-reads", + // The queue owner may linger just long enough to flush state; a later + // retry reconnects through the named session and ACP session/load. + "--ttl", "1", + ...(opts.model ? ["--model", opts.model] : []), + ...commandArgs, + ], + }; +} + +export function buildCodexAcpSpawn(opts: { + prompt: string; + sessionName: string; + model?: string | null; +}): AgentSpawn { + return codexAcpInvocation(opts, ["prompt", "--session", opts.sessionName, opts.prompt]); +} + +/** acpx does not implicitly create a named session: ensure it before prompt. */ +export function buildCodexAcpEnsureSpawn(opts: { + sessionName: string; + model?: string | null; +}): AgentSpawn { + return codexAcpInvocation(opts, ["sessions", "ensure", "--name", opts.sessionName]); +} + +function nonNeg(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined; +} + +function firstNonNeg(...values: unknown[]): number | undefined { + for (const value of values) { + const n = nonNeg(value); + if (n !== undefined) return n; + } + return undefined; +} + +/** Detailed end-turn usage from ACP's session/prompt response. */ +export function costFromAcpResult(result: any): RunCost | undefined { + const usage = result?.usage && typeof result.usage === "object" ? result.usage : {}; + const quota = result?._meta?.quota?.token_count ?? {}; + const cost: RunCost = { + totalTokens: firstNonNeg(usage.totalTokens, quota.totalTokens), + inputTokens: firstNonNeg(usage.inputTokens, quota.inputTokens), + outputTokens: firstNonNeg(usage.outputTokens, quota.outputTokens), + cacheReadTokens: firstNonNeg(usage.cachedReadTokens, usage.cachedInputTokens, quota.cachedInputTokens), + reasoningTokens: firstNonNeg(usage.thoughtTokens, usage.reasoningOutputTokens, quota.reasoningOutputTokens), + numTurns: 1, + }; + const hasUsage = Object.entries(cost).some(([key, value]) => key !== "numTurns" && value !== undefined); + return hasUsage ? cost : undefined; +} + +function mergeDefined(base: T | undefined, patch: T | undefined): T | undefined { + if (!base) return patch; + if (!patch) return base; + return { ...base, ...Object.fromEntries(Object.entries(patch).filter(([, value]) => value !== undefined)) } as T; +} + +function oneLine(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function clip(value: string, max = STEP_TEXT_MAX): string { + return value.length > max ? `${value.slice(0, max)} …[truncated]` : value; +} + +function compact(value: unknown): string { + if (typeof value === "string") return clip(value); + try { + return clip(JSON.stringify(value)); + } catch { + return ""; + } +} + +function contentText(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map(contentText).filter(Boolean).join("\n"); + if (!value || typeof value !== "object") return ""; + const record = value as Record; + if (typeof record.text === "string") return record.text; + if (typeof record.content === "string") return record.content; + if (record.content != null) return contentText(record.content); + for (const key of ["stdout", "stderr", "output", "message", "result"]) { + if (typeof record[key] === "string") return record[key] as string; + } + if (typeof record.formatted_output === "string") return record.formatted_output; + return ""; +} + +/** + * Parse raw ACP JSON-RPC (the output of `acpx --format json --json-strict`). + * The parser is protocol-first: session/update drives trace/progress, while the + * terminal session/prompt response supplies the exact token breakdown. + */ +export function makeAcpStreamConsumer( + onProgress: (progress: { step: number; label: string }) => void, + workdir: string, +): { feed: (chunk: string) => void; result: () => AcpStreamFinal } { + let buffer = ""; + let progressStep = 0; + let finalText = ""; + const out: AcpStreamFinal = { artifacts: [], transcript: [] }; + const textSteps = new Map(); + const toolSteps = new Map(); + const artifactKinds = new Map(); + const root = path.resolve(workdir); + + const progress = (label: string): void => { + const line = oneLine(label); + if (line) onProgress({ step: (progressStep += 1), label: clip(line, 80) }); + }; + const addStep = (step: TranscriptStep): number => { + if (out.transcript.length >= MAX_STEPS) return -1; + out.transcript.push(step); + return out.transcript.length - 1; + }; + const appendText = (key: string, prefix: string, text: string): void => { + if (!text) return; + const existing = textSteps.get(key); + if (existing !== undefined) { + const step = out.transcript[existing]; + if (step) step.text = clip(`${step.text ?? ""}${text}`); + return; + } + const index = addStep({ kind: "text", text: clip(`${prefix}${text}`) }); + if (index >= 0) textSteps.set(key, index); + }; + const markArtifact = (candidate: unknown, explicitKind?: "created" | "edited"): void => { + if (typeof candidate !== "string" || !candidate.trim()) return; + const abs = path.resolve(root, candidate); + if (abs !== root && !abs.startsWith(root + path.sep)) return; + const rel = path.relative(root, abs); + if (!rel) return; + const kind: "created" | "edited" = explicitKind ?? (fs.existsSync(abs) ? "edited" : "created"); + if (artifactKinds.get(rel) !== "created") artifactKinds.set(rel, kind); + }; + const diffArtifacts = (content: unknown): void => { + const items = Array.isArray(content) ? content : content ? [content] : []; + for (const item of items) { + if (!item || typeof item !== "object") continue; + const record = item as Record; + if (record.type === "diff" && typeof record.path === "string") { + markArtifact(record.path, record?._meta?.kind === "add" ? "created" : "edited"); + } + if (record.content != null) diffArtifacts(record.content); + } + }; + const recordTool = (update: any): void => { + const id = typeof update.toolCallId === "string" ? update.toolCallId : `tool-${toolSteps.size}`; + let state = toolSteps.get(id); + if (!state) { + const name = String(update.title || update.kind || "tool"); + const input = compact(update.rawInput ?? { kind: update.kind, locations: update.locations }); + const index = addStep({ kind: "tool", name, ...(input ? { input } : {}) }); + state = { index, name, resultRecorded: false }; + toolSteps.set(id, state); + progress(name); + } else if (state.index >= 0) { + const step = out.transcript[state.index]; + if (step && typeof update.title === "string") { + step.name = update.title; + state.name = update.title; + } + if (step && update.rawInput !== undefined) step.input = compact(update.rawInput); + } + const locations = Array.isArray(update.locations) ? update.locations : []; + diffArtifacts(update.content); + if (update.kind === "edit" || /\b(edit|write|patch|file change)\b/i.test(String(update.title ?? ""))) { + for (const location of locations) markArtifact(location?.path ?? location?.file ?? location?.uri); + } + const terminal = ["completed", "failed", "rejected", "cancelled"].includes(String(update.status)); + if (terminal && !state.resultRecorded) { + const text = contentText(update.content) || contentText(update.rawOutput) || String(update.status); + if (text.trim()) addStep({ kind: "result", text: clip(text.trim()) }); + state.resultRecorded = true; + progress(`${update.title || update.kind || state.name}: ${update.status}`); + } + }; + const handleUpdate = (params: any): void => { + if (typeof params?.sessionId === "string") out.sessionId = params.sessionId; + const update = params?.update; + if (!update || typeof update !== "object") return; + switch (update.sessionUpdate) { + case "agent_message_chunk": { + const text = contentText(update.content); + const id = `message:${update.messageId ?? textSteps.size}`; + appendText(id, "", text); + if (update?._meta?.codex?.phase === "final_answer" || update?._meta?.codex?.phase == null) { + finalText = clip(`${finalText}${text}`, FINAL_TEXT_MAX); + } + progress(finalText || text); + break; + } + case "agent_thought_chunk": { + const text = contentText(update.content); + appendText(`thought:${update.messageId ?? textSteps.size}`, "Thinking: ", text); + progress(`Thinking: ${text}`); + break; + } + case "tool_call": + case "tool_call_update": + recordTool(update); + break; + case "plan": + appendText(`plan:${textSteps.size}`, "Plan: ", contentText(update.content ?? update.entries ?? update)); + progress("Updating plan"); + break; + case "usage_update": { + const context: RunCost = { + contextTokens: nonNeg(update.used), + contextWindow: nonNeg(update.size), + }; + out.cost = mergeDefined(out.cost, context); + break; + } + } + }; + const handleLine = (line: string): void => { + let event: any; + try { + event = JSON.parse(line); + } catch { + return; + } + if (event?.method === "session/update") handleUpdate(event.params); + if (typeof event?.result?.sessionId === "string") out.sessionId = event.result.sessionId; + if (typeof event?.result?.stopReason === "string") { + out.stopReason = event.result.stopReason; + out.cost = mergeDefined(out.cost, costFromAcpResult(event.result)); + } + if (event?.error && typeof event.error === "object") { + const detail = contentText(event.error.data); + out.error = `${event.error.message || "ACP error"}${detail ? `: ${oneLine(detail)}` : ""}`; + } + }; + const feed = (chunk: string): void => { + buffer += chunk; + let newline: number; + while ((newline = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (line) handleLine(line); + } + }; + const result = (): AcpStreamFinal => { + const rest = buffer.trim(); + buffer = ""; + if (rest) handleLine(rest); + out.finalText = finalText.trim() || undefined; + out.artifacts = [...artifactKinds].map(([artifactPath, kind]) => ({ path: artifactPath, kind })); + return out; + }; + return { feed, result }; +} diff --git a/packages/daemon/src/runner.test.ts b/packages/daemon/src/runner.test.ts index 2c352d1..7585447 100644 --- a/packages/daemon/src/runner.test.ts +++ b/packages/daemon/src/runner.test.ts @@ -226,6 +226,8 @@ describe("buildAgentSpawn", () => { delete process.env.LOOPANY_CLAUDE_BIN; delete process.env.LOOPANY_GROK_BIN; delete process.env.LOOPANY_CODEX_BIN; + delete process.env.LOOPANY_ACPX_BIN; + delete process.env.LOOPANY_CODEX_ACP_BIN; }); test("claude-code: default bin + the claude arg vector (--verbose, stream-json, sys file)", () => { @@ -314,6 +316,25 @@ describe("buildAgentSpawn", () => { "continue", ]); }); + + test("codex ACP: parallel backend uses acpx protocol stream, not codex exec flags", () => { + process.env.LOOPANY_ACPX_BIN = "/opt/acpx"; + process.env.LOOPANY_CODEX_ACP_BIN = "/opt/codex-acp"; + const { bin, args } = buildAgentSpawn({ + agent: "codex", + backend: "acp", + acpSession: "loopany-run-7", + prompt: "observe it", + resumeSessionId: "thread-ignored-routing-is-by-name", + model: "gpt-test", + }); + expect(bin).toBe("/opt/acpx"); + expect(args).toContain("/opt/codex-acp"); + expect(args).toContain("--json-strict"); + expect(args).toContain("loopany-run-7"); + expect(args).not.toContain("exec"); + expect(args).not.toContain("--dangerously-bypass-approvals-and-sandbox"); + }); }); describe("addCost", () => { @@ -326,6 +347,13 @@ describe("addCost", () => { outputTokens: 5, }); }); + + test("keeps the latest ACP context occupancy/window instead of summing them", () => { + expect(addCost( + { inputTokens: 10, contextTokens: 100, contextWindow: 1000 }, + { inputTokens: 20, contextTokens: 130, contextWindow: 1000 }, + )).toEqual({ inputTokens: 30, contextTokens: 130, contextWindow: 1000 }); + }); }); // ---- fallback path integration ---- diff --git a/packages/daemon/src/runner.ts b/packages/daemon/src/runner.ts index f131555..cabde99 100644 --- a/packages/daemon/src/runner.ts +++ b/packages/daemon/src/runner.ts @@ -20,6 +20,15 @@ import { setProgress, clearProgress } from "./progress.js"; import { flushLoop, markRunActive, markRunDone } from "./watcher.js"; import { LOOPANY_DIR } from "./config.js"; import type { CodingAgent } from "./create.js"; +import { + acpSessionName, + buildCodexAcpEnsureSpawn, + buildCodexAcpSpawn, + makeAcpStreamConsumer, + resolveCodexBackend, + type AcpStreamFinal, + type CodexBackend, +} from "./acp.js"; export interface Delivery { runId: string; @@ -51,10 +60,15 @@ export interface Delivery { * older claude / a timed-out run may carry none of it. */ export interface RunCost { usd?: number; + totalTokens?: number; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheCreationTokens?: number; + reasoningTokens?: number; + /** Latest context occupancy and advertised context-window size. */ + contextTokens?: number; + contextWindow?: number; numTurns?: number; } @@ -101,7 +115,7 @@ export interface AgentSpawn { * - `claude-code`: `claude -p … --output-format stream-json --verbose …` * - `grok`: mirrors Claude's shape but uses `streaming-json`, drops `--verbose` * (exit 2) and `--append-system-prompt-file` (no file form). - * - `codex`: a DIFFERENT surface — `codex exec` / `codex exec resume`, not `-p`. + * - `codex` native: `codex exec` / `codex exec resume`, not `-p`. * Flags verified against codex-cli 0.143.0: `--json` (JSONL on stdout), * `--dangerously-bypass-approvals-and-sandbox` (unattended BYOA, same intent * as claude/grok `bypassPermissions`), optional `-m` / `--model`, and @@ -109,23 +123,32 @@ export interface AgentSpawn { * * Escape hatches: `LOOPANY_CLAUDE_BIN` / `LOOPANY_GROK_BIN` / `LOOPANY_CODEX_BIN`. * - * Telemetry note: grok's headless stream is grok-native (`thought`/`text`/`end`) - * and codex `--json` is not Claude stream-json either — the Claude-shaped - * `makeStreamConsumer` parses nothing from either. Both still mark OK on exit 0; - * the agent's own `loopany report` persists the result. Daemon-side live- - * progress/cost/transcript for non-Claude agents is degraded until a per-agent - * stream adapter lands. + * Codex may instead use the structured ACP arm when + * `LOOPANY_CODEX_BACKEND=acp`; it runs pinned acpx + codex-acp dependencies and + * is parsed by makeAcpStreamConsumer. Native remains the default/rollback path. + * Grok telemetry remains degraded because its stream is still grok-native. */ export function buildAgentSpawn(opts: { agent: CodingAgent; prompt: string; resumeSessionId?: string; model?: string | null; + /** codex-only; ignored by the other agents. */ + backend?: CodexBackend; + /** ACP persistent-session routing key (one per Loopany run). */ + acpSession?: string; /** claude-only: the system-prompt file path (falsy ⇒ flag omitted). */ sysFile?: string; }): AgentSpawn { - const { agent, prompt, resumeSessionId, model, sysFile } = opts; + const { agent, prompt, resumeSessionId, model, sysFile, backend = "native", acpSession } = opts; if (agent === "codex") { + if (backend === "acp") { + return buildCodexAcpSpawn({ + prompt, + sessionName: acpSession ?? "loopany-run", + model, + }); + } // Codex surface is `codex exec [OPTIONS] [PROMPT]` / `codex exec resume // [OPTIONS] [SESSION_ID] [PROMPT]` — never Claude's `-p` / stream-json flags. const modelArgs = model ? ["-m", model] : []; @@ -185,6 +208,8 @@ const rawExecTimeout = Number(process.env.LOOPANY_EXEC_TIMEOUT_MS); const TIMEOUT_MS = Number.isFinite(rawExecTimeout) && rawExecTimeout > 0 ? rawExecTimeout : 0; /** Hard cap on the pre-report flush so a slow/hung server can't delay reporting. */ const FLUSH_TIMEOUT_MS = 2500; +/** ACP named-session setup is infrastructure, not the unbounded agent turn. */ +const ACP_SESSION_SETUP_TIMEOUT_MS = 60_000; // Transient-failure recovery: when claude dies mid-run on an infrastructure // error (an API "Connection closed mid-response", ECONNRESET, overloaded/5xx, @@ -248,12 +273,27 @@ export function buildResumeTask(reason: string): string { export function addCost(a: RunCost | undefined, b: RunCost | undefined): RunCost | undefined { if (!a) return b; if (!b) return a; - const keys: (keyof RunCost)[] = ["usd", "inputTokens", "outputTokens", "cacheReadTokens", "cacheCreationTokens", "numTurns"]; + const keys: (keyof RunCost)[] = [ + "usd", + "totalTokens", + "inputTokens", + "outputTokens", + "cacheReadTokens", + "cacheCreationTokens", + "reasoningTokens", + "numTurns", + ]; const out: RunCost = {}; for (const k of keys) { const sum = (a[k] ?? 0) + (b[k] ?? 0); if (a[k] !== undefined || b[k] !== undefined) out[k] = sum; } + // Context occupancy/window describe the latest session state; summing them + // across a resumed turn would invent a context size that never existed. + out.contextTokens = b.contextTokens ?? a.contextTokens; + out.contextWindow = b.contextWindow ?? a.contextWindow; + if (out.contextTokens === undefined) delete out.contextTokens; + if (out.contextWindow === undefined) delete out.contextWindow; return out; } @@ -384,13 +424,15 @@ async function runDeliveryImpl(d: Delivery, serverUrl: string, roots: string[], } } - // 2. Exec: run claude (no workflow, the workflow escalated, or it FAILED → fallback). + // 2. Exec: run the selected coding agent (no workflow, escalation, or fallback). let ok = false; let sessionId: string | undefined; let error: string | undefined; let finalText: string | undefined; let cost: RunCost | undefined; let attempts = 0; + const streamedArtifacts = new Map(); + const streamedTranscript: TranscriptStep[] = []; // System prompt goes in ~/.loopany/runs (passed to claude by absolute path), not // the workdir — keeps the run's cwd clean. Removed in `finally`. Batches 1-2 move // the full run instructions into the first user turn, so `systemPrompt` is now empty @@ -404,7 +446,14 @@ async function runDeliveryImpl(d: Delivery, serverUrl: string, roots: string[], // claude-code. Spawn + credential set branch on the agent; agentLabel names the // binary family in failure reasons (claude / codex / grok). const agent: CodingAgent = d.loop.agent ?? "claude-code"; - const agentLabel = agent === "claude-code" ? "claude" : agent; + let codexBackend: CodexBackend = "native"; + try { + if (agent === "codex") codexBackend = resolveCodexBackend(); + } catch (err) { + return reportRun({ runId: d.runId, ok: false, durationMs: Date.now() - start, error: msg(err) }); + } + const usesAcp = agent === "codex" && codexBackend === "acp"; + const agentLabel = agent === "claude-code" ? "claude" : usesAcp ? "codex ACP" : agent; try { if (hasSystemPrompt) { fs.mkdirSync(runsDir, { recursive: true }); @@ -417,6 +466,14 @@ async function runDeliveryImpl(d: Delivery, serverUrl: string, roots: string[], PATH: `${CALLBACK_BIN_DIR}${path.delimiter}${process.env.PATH ?? ""}`, LOOPANY_RUN_TOKEN: d.runToken, LOOPANY_SERVER_URL: serverUrl, + // Match the native Codex arm's unattended/full-access semantics. NO_BROWSER + // prevents a headless scheduled run from trying to launch an auth browser. + ...(usesAcp + ? { + INITIAL_AGENT_MODE: process.env.INITIAL_AGENT_MODE || "agent-full-access", + NO_BROWSER: "1", + } + : {}), }; const task = workflowFailure ? buildWorkflowFallbackTask(d.task, workflowFailure, dateStamp(), d.loop.name, d.loop.id) @@ -424,6 +481,31 @@ async function runDeliveryImpl(d: Delivery, serverUrl: string, roots: string[], ? `${d.task}\n\nworkflow signal:\n${escalation}` : d.task; + if (usesAcp) { + // Named sessions are what make a transient retry resume the same Codex + // thread. acpx deliberately does not create a named session implicitly. + const setup = buildCodexAcpEnsureSpawn({ + sessionName: acpSessionName(d.runId), + model: d.loop.model, + }); + setProgress(d.runId, { step: 0, label: "starting Codex ACP session" }); + try { + const initialized = await runProcess(setup.bin, setup.args, { + cwd: workdir, + env, + timeoutMs: ACP_SESSION_SETUP_TIMEOUT_MS, + signal, + }); + if (initialized.timedOut) throw new Error("ACP session setup timed out (60s)"); + if (initialized.code !== 0) { + const detail = (initialized.stderr || initialized.stdout || "acpx sessions ensure failed").trim().slice(-1000); + throw new Error(detail); + } + } finally { + clearProgress(d.runId); + } + } + // Attempt loop: the first pass runs the task; each further pass RESUMES the // same session after a transient infrastructure failure (see the constants // block above). Timeouts never retry (our own wall-clock guard, not a @@ -432,54 +514,84 @@ async function runDeliveryImpl(d: Delivery, serverUrl: string, roots: string[], for (;;) { attempts += 1; const resuming = attempts > 1; - const prompt = resuming ? buildResumeTask(error ?? "transient API error") : task; - // Claude stream-json (JSONL) yields live progress + a terminal `result` event. - // Grok/codex emit non-Claude streams we can't yet parse — see buildAgentSpawn. + const prompt = resuming + ? buildResumeTask(error ?? "transient API error") + : usesAcp && hasSystemPrompt + ? `${d.systemPrompt}\n\n${task}` + : task; const { bin, args } = buildAgentSpawn({ agent, prompt, resumeSessionId: resuming ? sessionId : undefined, model: d.loop.model, sysFile: hasSystemPrompt ? sysFile : undefined, + backend: codexBackend, + acpSession: acpSessionName(d.runId), }); - const stream = makeStreamConsumer((p) => setProgress(d.runId, p)); + const stream = usesAcp + ? makeAcpStreamConsumer((p) => setProgress(d.runId, p), workdir) + : makeStreamConsumer((p) => setProgress(d.runId, p)); const r = await runProcess(bin, args, { cwd: workdir, env, timeoutMs: TIMEOUT_MS, onStdout: stream.feed, signal }); clearProgress(d.runId); - const final = stream.result(); error = undefined; finalText = undefined; - if (r.timedOut) { - error = `${agentLabel} timed out (${Math.round(TIMEOUT_MS / 1000)}s)`; - // The stream captured the session id early — keep the pointer so exactly - // the runs that need debugging (timeouts) still get the transcript/artifact - // recovery below instead of losing their session. + if (usesAcp) { + // `stream` is chosen from a runtime backend flag; make that correlation + // explicit for TypeScript (both consumers intentionally share feed()). + const final = stream.result() as AcpStreamFinal; sessionId = final.sessionId ?? sessionId; - break; - } else if (final.json) { - ok = !final.json.is_error && r.code === 0; - // `--resume` forks a NEW session id — track the latest so a further - // resume (and the transcript/artifact recovery below) follow the fork. - sessionId = final.sessionId ?? final.json.session_id ?? sessionId; - finalText = final.json.result?.trim() || undefined; - cost = addCost(cost, costFromResult(final.json)); + finalText = final.finalText; + cost = addCost(cost, final.cost); + for (const artifact of final.artifacts) { + if (streamedArtifacts.get(artifact.path) !== "created") streamedArtifacts.set(artifact.path, artifact.kind); + } + for (const step of final.transcript) { + if (streamedTranscript.length >= 80) break; + streamedTranscript.push(step); + } + if (r.timedOut) { + error = `${agentLabel} timed out (${Math.round(TIMEOUT_MS / 1000)}s)`; + break; + } + const abnormalStop = final.stopReason && final.stopReason !== "end_turn" ? final.stopReason : undefined; + ok = r.code === 0 && !final.error && !abnormalStop; if (!ok) { - // A non-zero exit can arrive WITH a clean result event (subtype "success") — - // recording "success" as the error reads as nonsense; name the exit instead. - const subtype = final.json.subtype; error = - subtype && subtype !== "success" - ? subtype - : r.code !== 0 - ? `${agentLabel} exited with code ${r.code}` - : `${agentLabel} reported an error`; + final.error || + (abnormalStop ? `${agentLabel} stopped: ${abnormalStop}` : undefined) || + (r.stderr || `${agentLabel} exited with code ${r.code}`).trim().slice(0, 500); } - } else if (r.code === 0) { - ok = true; - sessionId = final.sessionId ?? sessionId; } else { - error = (r.stderr || r.stdout || `${agentLabel} produced no output`).trim().slice(0, 500); - sessionId = final.sessionId ?? sessionId; + const final = stream.result() as StreamFinal; + if (r.timedOut) { + error = `${agentLabel} timed out (${Math.round(TIMEOUT_MS / 1000)}s)`; + // The stream captured the session id early — keep the pointer so exactly + // the runs that need debugging still get transcript/artifact recovery. + sessionId = final.sessionId ?? sessionId; + break; + } else if (final.json) { + ok = !final.json.is_error && r.code === 0; + // Native resume may fork a NEW session id — follow the latest fork. + sessionId = final.sessionId ?? final.json.session_id ?? sessionId; + finalText = final.json.result?.trim() || undefined; + cost = addCost(cost, costFromResult(final.json)); + if (!ok) { + const subtype = final.json.subtype; + error = + subtype && subtype !== "success" + ? subtype + : r.code !== 0 + ? `${agentLabel} exited with code ${r.code}` + : `${agentLabel} reported an error`; + } + } else if (r.code === 0) { + ok = true; + sessionId = final.sessionId ?? sessionId; + } else { + error = (r.stderr || r.stdout || `${agentLabel} produced no output`).trim().slice(0, 500); + sessionId = final.sessionId ?? sessionId; + } } if (ok || attempts > TRANSIENT_RETRIES || !sessionId || signal?.aborted) break; @@ -488,7 +600,7 @@ async function runDeliveryImpl(d: Delivery, serverUrl: string, roots: string[], const wait = retryDelayMs(attempts); logger.warn( { runId: d.runId, attempt: attempts, waitMs: wait, error }, - "transient claude failure — resuming the session after backoff", + "transient coding-agent failure — resuming the session after backoff", ); // Keep the live signal honest during the wait (and the server's inactivity // sweep fed — the progress stamp rides the poll heartbeat). @@ -503,10 +615,14 @@ async function runDeliveryImpl(d: Delivery, serverUrl: string, roots: string[], if (sysFile) fs.rmSync(sysFile, { force: true }); // don't let prompt files accumulate } - // Recover this session's artifacts + slimmed trace from ONE transcript read (best-effort). + // ACP already streamed a normalized trace. Native Claude keeps its fast local + // transcript recovery; native Codex/Grok still have no on-disk adapter here. let artifacts: RunArtifact[] | undefined; let transcript: TranscriptStep[] | undefined; - if (sessionId) { + if (usesAcp) { + if (streamedArtifacts.size) artifacts = [...streamedArtifacts].map(([artifactPath, kind]) => ({ path: artifactPath, kind })); + if (streamedTranscript.length) transcript = streamedTranscript; + } else if (sessionId) { try { const trace = sessionTrace(sessionId, workdir); if (trace.artifacts.length) artifacts = trace.artifacts; diff --git a/packages/server/src/components/LoopDetailView.tsx b/packages/server/src/components/LoopDetailView.tsx index 27b282a..584da12 100644 --- a/packages/server/src/components/LoopDetailView.tsx +++ b/packages/server/src/components/LoopDetailView.tsx @@ -246,6 +246,7 @@ export function LoopDetailView({ id }: { id: string }) { sessionId: detail?.runs.find((r) => r.sessionId)?.sessionId ?? null, dir: detail ? detail.job.exec?.workdir || loopDir(detail.job.taskFile) : null, machineName: detail?.machine.name || null, + agent: detail?.job.agent ?? null, label: 'Continue agent session', }) diff --git a/packages/server/src/components/RunView.tsx b/packages/server/src/components/RunView.tsx index e9f58b9..48a6838 100644 --- a/packages/server/src/components/RunView.tsx +++ b/packages/server/src/components/RunView.tsx @@ -35,6 +35,22 @@ function Field({ k, children }: { k: string; children: React.ReactNode }) { ) } +/** Agent-neutral token summary. ACP agents may report usage without a USD estimate. */ +function usageSummary(usage: RunSummary['usage']): string | null { + if (!usage) return null + const input = (usage.inputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + (usage.cacheCreationTokens ?? 0) + const hasDetail = usage.inputTokens != null || usage.outputTokens != null || usage.cacheReadTokens != null + const detail = hasDetail + ? `${fnum(input)} in · ${fnum(usage.outputTokens ?? 0)} out${usage.reasoningTokens != null ? ` · ${fnum(usage.reasoningTokens)} reasoning` : ''}` + : usage.totalTokens != null + ? `${fnum(usage.totalTokens)} total` + : null + const context = usage.contextTokens != null + ? `${fnum(usage.contextTokens)}${usage.contextWindow != null ? ` / ${fnum(usage.contextWindow)}` : ''} context` + : null + return [detail, context].filter(Boolean).join(' · ') || null +} + /** A friendly, expand-on-click payload block (system prompt / user query). */ function Fold({ title, sub, body }: { title: string; sub?: string; body: string }) { return ( @@ -323,6 +339,7 @@ export function RunDetailView({ loopId, runId }: { loopId: string; runId: string sessionId: run?.sessionId ?? null, dir: detail ? detail.job.exec?.workdir || loopDir(detail.job.taskFile) : null, machineName: detail?.machine.name || null, + agent: detail?.job.agent ?? null, label: 'Continue agent session', }) @@ -357,6 +374,7 @@ export function RunDetailView({ loopId, runId }: { loopId: string; runId: string const jobName = detail.summary.name const roleChip = run.role || null + const usageText = usageSummary(run.usage) return ( <> {/* header card - mirrors the loop detail page */} @@ -444,16 +462,8 @@ export function RunDetailView({ loopId, runId }: { loopId: string; runId: string {run.status && {run.status}} {run.durationMs != null && {dur(run.durationMs)}} - {run.costUsd != null && ( - - {money(run.costUsd)} - {run.usage && (run.usage.inputTokens != null || run.usage.outputTokens != null) && ( - - ({fnum((run.usage.inputTokens ?? 0) + (run.usage.cacheReadTokens ?? 0) + (run.usage.cacheCreationTokens ?? 0))} in · {fnum(run.usage.outputTokens ?? 0)} out) - - )} - - )} + {run.costUsd != null && {money(run.costUsd)}} + {usageText && {usageText}} {run.state != null && ( {JSON.stringify(run.state)} diff --git a/packages/server/src/components/actionUi.tsx b/packages/server/src/components/actionUi.tsx index 7c14c48..658bebc 100644 --- a/packages/server/src/components/actionUi.tsx +++ b/packages/server/src/components/actionUi.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from 'react' +import type { CodingAgent } from '../types' import { buildResumeCommand } from '../lib/resumeCommand' import { btn, btnDanger, btnPrimary } from './ui' @@ -84,35 +85,32 @@ export function ConfirmBar({ ) } -/** The claude-code pixel-terminal mark (LobeHub icon set), in the Claude brand - * orange. Decorative (aria-hidden): the button text stays the accessible name, - * keeping generic copy agent-neutral while the LOGO is factual — only Claude - * stream-json currently yields a captured session id for this affordance - * (codex/grok execute on their own CLIs; telemetry is still degraded, no - * session id). Swap per-agent once another agent yields a resumable session. */ -function ClaudeCodeMark({ size = 14 }: { size?: number }) { +/** Neutral terminal mark: both Claude and ACP-backed Codex sessions can now be + * resumed, while the accessible button text remains agent-neutral. */ +function AgentSessionMark({ size = 14 }: { size?: number }) { return ( - + + ) } /** * "Continue agent session" copy affordance (run page + loop page) — copies a - * ready-to-paste terminal command (`cd '' && claude --resume `) + * ready-to-paste terminal command for the run's recorded agent * that reopens the run's coding-agent session on the owner's machine. BYOA: the * session lives there, so copy-a-command is the whole feature. * @@ -122,19 +120,19 @@ function ClaudeCodeMark({ size = 14 }: { size?: number }) { * got pushed to its own line). The caller places `button` IN the row and `hint` * BELOW the row. `sessionId: null` ⇒ both render null, so callers can invoke * the hook unconditionally (hooks can't be conditional) while data loads. - * Prose stays agent-neutral; the literal `claude` binary in the copied command - * is factual (only Claude stream-json currently yields a captured session id — - * codex/grok execute, but daemon telemetry is still degraded for them). + * Prose stays agent-neutral; the command builder selects Claude or Codex. */ export function useContinueSession({ sessionId, dir, machineName, + agent, label, }: { sessionId: string | null dir?: string | null machineName?: string | null + agent?: CodingAgent | null label: string }): { button: React.ReactNode; hint: React.ReactNode } { const [copied, setCopied] = useState(false) @@ -142,7 +140,9 @@ export function useContinueSession({ if (!sessionId) return { button: null, hint: null } const onCopy = async () => { try { - await navigator.clipboard.writeText(buildResumeCommand({ sessionId, dir })) + await navigator.clipboard.writeText( + buildResumeCommand({ sessionId, dir, agent: agent === 'codex' ? 'codex' : 'claude-code' }), + ) setCopyErr(false) setCopied(true) setTimeout(() => setCopied(false), 4000) @@ -159,7 +159,7 @@ export function useContinueSession({ onClick={() => void onCopy()} > - + {copied ? '✓ Command copied' : label} diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index 999458f..d973495 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -53,10 +53,15 @@ export interface RunArtifact { * older daemon / a timed-out run reports none). Rides in a JSON column; the * aggregable USD figure gets its own real column (`runs.costUsd`). */ export interface RunUsage { + totalTokens?: number; inputTokens?: number; outputTokens?: number; cacheReadTokens?: number; cacheCreationTokens?: number; + reasoningTokens?: number; + /** Latest ACP context occupancy/window. Unlike token totals, these are not additive. */ + contextTokens?: number; + contextWindow?: number; numTurns?: number; /** Total claude invocations for the run — present only when > 1 (the daemon's * transient-failure recovery resumed the session; cost/tokens are the sum). */ diff --git a/packages/server/src/lib/resumeCommand.test.ts b/packages/server/src/lib/resumeCommand.test.ts index 7a3b980..cff9c28 100644 --- a/packages/server/src/lib/resumeCommand.test.ts +++ b/packages/server/src/lib/resumeCommand.test.ts @@ -14,6 +14,15 @@ describe('buildResumeCommand', () => { expect(buildResumeCommand({ sessionId: 'sess-abc123', dir: '' })).toBe('claude --resume sess-abc123') }) + it('uses Codex resume for an ACP-backed Codex thread', () => { + expect(buildResumeCommand({ sessionId: '019f7f87-thread', dir: '/work/repo', agent: 'codex' })).toBe( + "cd '/work/repo' && codex exec resume 019f7f87-thread", + ) + expect(buildResumeCommand({ sessionId: '019f7f87-thread', agent: 'codex' })).toBe( + 'codex exec resume 019f7f87-thread', + ) + }) + it('quotes a dir with spaces', () => { expect(buildResumeCommand({ sessionId: 's1', dir: '/Users/me/My Projects/loop' })).toBe( "cd '/Users/me/My Projects/loop' && claude --resume s1", diff --git a/packages/server/src/lib/resumeCommand.ts b/packages/server/src/lib/resumeCommand.ts index 0db04ee..1a72839 100644 --- a/packages/server/src/lib/resumeCommand.ts +++ b/packages/server/src/lib/resumeCommand.ts @@ -3,11 +3,9 @@ * * Execution is BYOA - the session lives on the owner's machine, so the server * can only hand back a ready-to-paste terminal command. Pure + unit-testable, - * mirroring `editPrompt.ts`. The binary is literally `claude` because only - * Claude stream-json currently yields a captured session id for this affordance: - * codex/grok execute on their own CLIs but daemon telemetry is still degraded - * (no session id to resume). Branch on the loop's agent once another agent - * yields a resumable captured session. + * mirroring `editPrompt.ts`. ACP-backed Codex runs report their Codex thread id, + * so the command must follow the recorded loop agent instead of assuming every + * captured session belongs to Claude. */ /** Single-quote a path for POSIX shells (embedded `'` becomes `'\''`). */ @@ -16,16 +14,20 @@ function shellQuote(s: string): string { } /** Build the terminal command that resumes a run's coding-agent session: - * `cd '' && claude --resume ` when the loop's on-disk dir is known + * `cd '' && ` when the loop's on-disk dir is known * (resume is cwd-scoped), or the bare resume command when it isn't - never a - * fabricated path (same degradation contract as `loopDir`). */ + * fabricated path (same degradation contract as `loopDir`). `agent` defaults + * to Claude for older callers/runs; Grok currently does not report resumable + * session ids, so it intentionally follows that legacy fallback. */ export function buildResumeCommand({ sessionId, dir, + agent = 'claude-code', }: { sessionId: string dir?: string | null + agent?: 'claude-code' | 'codex' }): string { - const resume = `claude --resume ${sessionId}` + const resume = agent === 'codex' ? `codex exec resume ${sessionId}` : `claude --resume ${sessionId}` return dir ? `cd ${shellQuote(dir)} && ${resume}` : resume } diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index d5aac11..9d33acd 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -67,10 +67,14 @@ export interface RunSummary { costUsd: number | null /** Token-count breakdown reported with the cost (display-only detail). */ usage: { + totalTokens?: number inputTokens?: number outputTokens?: number cacheReadTokens?: number cacheCreationTokens?: number + reasoningTokens?: number + contextTokens?: number + contextWindow?: number numTurns?: number } | null error: string | null diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad575bd..1a54006 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: packages/daemon: dependencies: + '@agentclientprotocol/codex-acp': + specifier: 1.1.4 + version: 1.1.4 + acpx: + specifier: 0.12.0 + version: 0.12.0 chokidar: specifier: ^4.0.3 version: 4.0.3 @@ -163,6 +169,26 @@ importers: packages: + /@agentclientprotocol/codex-acp@1.1.4: + resolution: {integrity: sha512-DzusIpGwlQwMWuHgJhU8FWMsyQvzjenB93IEzQATkdbNulo5Rd9GKOz8+B+/C9iWWxmyXgtgmjzaL+iRFyDryQ==} + hasBin: true + dependencies: + '@agentclientprotocol/sdk': 1.2.1(zod@4.4.3) + '@openai/codex': 0.144.6 + diff: 9.0.0 + open: 11.0.0 + vscode-jsonrpc: 9.0.1 + zod: 4.4.3 + dev: false + + /@agentclientprotocol/sdk@1.2.1(zod@4.4.3): + resolution: {integrity: sha512-jwYUdOQR7tc+Zfch53VL4JJyUNK/46q03uUTYb+PjECsmnNl94XFXOfYLJ8RBpMNidXd1rpOAVgb0vqD98xImA==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + dependencies: + zod: 4.4.3 + dev: false + /@asamuzakjp/css-color@5.1.11: resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} @@ -817,6 +843,24 @@ packages: dependencies: css-tree: 3.2.1 + /@clack/core@1.4.3: + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + dev: false + + /@clack/prompts@1.7.0: + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + dev: false + /@codemirror/autocomplete@6.20.3: resolution: {integrity: sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==} dependencies: @@ -1854,6 +1898,73 @@ packages: engines: {node: '>=20.0'} dev: false + /@openai/codex@0.144.6: + resolution: {integrity: sha512-wk+2CWiBNXiJLBoN2D08N9RceWkSBnlgk5g2K1a4CXrP/C0gdlHyRUG7RFzm9y41DCK/7tvCct233JVxyFmznw==} + engines: {node: '>=16'} + hasBin: true + optionalDependencies: + '@openai/codex-darwin-arm64': /@openai/codex@0.144.6-darwin-arm64 + '@openai/codex-darwin-x64': /@openai/codex@0.144.6-darwin-x64 + '@openai/codex-linux-arm64': /@openai/codex@0.144.6-linux-arm64 + '@openai/codex-linux-x64': /@openai/codex@0.144.6-linux-x64 + '@openai/codex-win32-arm64': /@openai/codex@0.144.6-win32-arm64 + '@openai/codex-win32-x64': /@openai/codex@0.144.6-win32-x64 + dev: false + + /@openai/codex@0.144.6-darwin-arm64: + resolution: {integrity: sha512-6zgvh70MzBNSeT17HEhSOrmmGGZGAKzSC7x6JAq+edkJkdPYA9P0I1tG7aJ49GlBkBxuC+MKBH1qm6+2Cghcww==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@openai/codex@0.144.6-darwin-x64: + resolution: {integrity: sha512-THRyPG0zSU6M8NQAge1LHEHsJDnoH4BpKsfJHB/qe3Fm+Wf6zqAmWJFlOKzBm27m0K2Hq3za4Ac2I5p5i4yp/A==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: false + optional: true + + /@openai/codex@0.144.6-linux-arm64: + resolution: {integrity: sha512-PGiLXMN+2IQRkf7tOLi64dMInjU1pRLbz0Rwfj/yt2Y97SZQqAjFQoi2wmswmqtqMDnfwCPTC1DRXVQkvU6T6Q==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@openai/codex@0.144.6-linux-x64: + resolution: {integrity: sha512-4E7EnzCg0OnBxCyYnwJ+qnZwWHYe0YScr5ucKWbngE9u4+0XrpWELqq2Kn9jl5GZK8MDjU7PrJwFIwusHOHjuw==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: false + optional: true + + /@openai/codex@0.144.6-win32-arm64: + resolution: {integrity: sha512-SpMjXJLW43JzMP0K62mVcYfmFcpk0BK4AOgYmWSfyZHs3iRtHMd0UYw7605n/9lwkT2EqbwQLT2omZFeKJFzwA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: false + optional: true + + /@openai/codex@0.144.6-win32-x64: + resolution: {integrity: sha512-dN39VnjEthKz5io1RNWwZDtErdSn07nW3pGUgvlA6DMxgm/nuGaIAZO/sG/Hgxq/x5j9HteAENfrFgVkpZ0lFg==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: false + optional: true + /@opentelemetry/semantic-conventions@1.41.1: resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==} engines: {node: '>=14'} @@ -3106,6 +3217,22 @@ packages: hasBin: true dev: false + /acpx@0.12.0: + resolution: {integrity: sha512-APYpN04XFWrCGuSBvM4HTKWWFH8uSIuzc+qI7aCGeVdP9o4euZeBosFEkmNUHvBOop0XBemg6d8RsNvzXN3Mgw==} + engines: {node: '>=22.13.0'} + hasBin: true + dependencies: + '@agentclientprotocol/sdk': 1.2.1(zod@4.4.3) + commander: 15.0.0 + skillflag: 0.2.0 + tsx: 4.23.1 + zod: 4.4.3 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + dev: false + /ajv-formats@3.0.1(ajv@8.20.0): resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} peerDependencies: @@ -3160,6 +3287,15 @@ packages: engines: {node: '>=8.0.0'} dev: false + /b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + dev: false + /babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} dependencies: @@ -3170,6 +3306,66 @@ packages: transitivePeerDependencies: - supports-color + /bare-events@2.9.1: + resolution: {integrity: sha512-Z0oHEHAFDZkffN8Qc39zNZjQlMDkPJRyyyZieU1VH7u8c5S+qHZ2S8ixdKIAxEjfHO7FJxXmJWgteOghVanIsg==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + dev: false + + /bare-fs@4.7.4: + resolution: {integrity: sha512-y1kC+ffIx/tPLdTE693uNjHfzTfr+ravR5tvWlMXe25nELbkqV400S71qHDwbkAQ1FVEZobB1NFRzFbCCcyBCQ==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + dependencies: + bare-events: 2.9.1 + bare-path: 3.1.1 + bare-stream: 2.13.3(bare-events@2.9.1) + bare-url: 2.4.5 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + dev: false + + /bare-path@3.1.1: + resolution: {integrity: sha512-JprUlveX3QjApC1cTpsUOiscADftCGVWkzitbHsRqv84hzYwYHw2mbluddsq5TvI8mH/8Ov1f4BiMAdcB0oYnQ==} + dev: false + + /bare-stream@2.13.3(bare-events@2.9.1): + resolution: {integrity: sha512-Kc+brLqvEqGkjyfiwJmImAOqLZL7OsoLKuavx+hJjgVV3nLTOjloJyPMFxjUPerGGHrNH0fLU06jjykMLWrERQ==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + dependencies: + b4a: 1.8.1 + bare-events: 2.9.1 + streamx: 2.28.0 + teex: 1.0.1 + transitivePeerDependencies: + - react-native-b4a + dev: false + + /bare-url@2.4.5: + resolution: {integrity: sha512-K+y9xF1tN+CdPu4qWwr0QiK1Al07eFPGYK5M2pDXcmHdMdgC/tT/bpmMe1hrmRHaidKLkXrC+cRNYf3XVDUhSQ==} + dependencies: + bare-path: 3.1.1 + dev: false + /baseline-browser-mapping@2.10.38: resolution: {integrity: sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==} engines: {node: '>=6.0.0'} @@ -3320,6 +3516,13 @@ packages: /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + /bundle-name@4.1.0: + resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} + engines: {node: '>=18'} + dependencies: + run-applescript: 7.1.0 + dev: false + /bytes@3.1.2: resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} engines: {node: '>= 0.8'} @@ -3635,6 +3838,24 @@ packages: /decimal.js@10.6.0: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + /default-browser-id@5.0.1: + resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==} + engines: {node: '>=18'} + dev: false + + /default-browser@5.5.0: + resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==} + engines: {node: '>=18'} + dependencies: + bundle-name: 4.1.0 + default-browser-id: 5.0.1 + dev: false + + /define-lazy-prop@3.0.0: + resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} + engines: {node: '>=12'} + dev: false + /defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} dev: false @@ -4006,6 +4227,14 @@ packages: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} dev: false + /events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + dependencies: + bare-events: 2.9.1 + transitivePeerDependencies: + - bare-abort-controller + dev: false + /eventsource-parser@3.1.0: resolution: {integrity: sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==} engines: {node: '>=18.0.0'} @@ -4079,14 +4308,34 @@ packages: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} dev: false + /fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + dev: false + /fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} dev: false + /fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + dev: false + + /fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + dependencies: + fast-string-truncated-width: 3.0.3 + dev: false + /fast-uri@3.1.3: resolution: {integrity: sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==} dev: false + /fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + dependencies: + fast-string-width: 3.0.2 + dev: false + /fdir@6.5.0(picomatch@4.0.4): resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -4342,11 +4591,30 @@ packages: engines: {node: '>= 0.10'} dev: false + /is-docker@3.0.0: + resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + hasBin: true + dev: false + /is-fullwidth-code-point@3.0.0: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} dev: true + /is-in-ssh@1.0.0: + resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==} + engines: {node: '>=20'} + dev: false + + /is-inside-container@1.0.0: + resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} + engines: {node: '>=14.16'} + hasBin: true + dependencies: + is-docker: 3.0.0 + dev: false + /is-interactive@2.0.0: resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} engines: {node: '>=12'} @@ -4364,6 +4632,13 @@ packages: engines: {node: '>=18'} dev: false + /is-wsl@3.1.1: + resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} + engines: {node: '>=16'} + dependencies: + is-inside-container: 1.0.0 + dev: false + /isbot@5.1.43: resolution: {integrity: sha512-drJhFmibra4LO6Wd7D3Oi6UICRK9244vSZkmxzhlZP0TTdwCA2ueK4PEkUkzPYeuqug9+cqqdWPgihjk5+83Cg==} engines: {node: '>=18'} @@ -4804,6 +5079,18 @@ packages: mimic-function: 5.0.1 dev: false + /open@11.0.0: + resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==} + engines: {node: '>=20'} + dependencies: + default-browser: 5.5.0 + define-lazy-prop: 3.0.0 + is-in-ssh: 1.0.0 + is-inside-container: 1.0.0 + powershell-utils: 0.1.0 + wsl-utils: 0.3.1 + dev: false + /ora@9.4.1: resolution: {integrity: sha512-6VlU9MLXbjVQD04AZCMX28hVtA5bUoadvUqO76MUCVA0ilwJbMiHsITRPfyVm6p/BC0Av/BXMujx39WCe1LEqw==} engines: {node: '>=20'} @@ -4910,6 +5197,11 @@ packages: resolution: {integrity: sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==} engines: {node: '>=12'} + /powershell-utils@0.1.0: + resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} + engines: {node: '>=20'} + dev: false + /prettier@3.8.4: resolution: {integrity: sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==} engines: {node: '>=14'} @@ -5179,6 +5471,11 @@ packages: - supports-color dev: false + /run-applescript@7.1.0: + resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==} + engines: {node: '>=18'} + dev: false + /safe-stable-stringify@2.5.0: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} @@ -5316,6 +5613,23 @@ packages: engines: {node: '>=14'} dev: false + /sisteransi@1.0.5: + resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + dev: false + + /skillflag@0.2.0: + resolution: {integrity: sha512-7ZmEpBeEoPLc+hqZ/StAnCO/hulgEPANzPyZgOM/CZ5zc3b0ApSp3URavY5POM/OKyi5d9+UC/Q21OoiYC2kJw==} + engines: {node: '>=18'} + hasBin: true + dependencies: + '@clack/prompts': 1.7.0 + tar-stream: 3.2.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + dev: false + /sonic-boom@4.2.1: resolution: {integrity: sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==} dependencies: @@ -5367,6 +5681,17 @@ packages: engines: {node: '>=18'} dev: false + /streamx@2.28.0: + resolution: {integrity: sha512-1Yowhzjf0ivGMrTIkY9hav5TxobO9qIVqUE41fiCGMGgc3CLlf4MY+9AHmZqBWgDTue0fY9zWjYFVyf6Diuobw==} + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + dev: false + /string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -5431,6 +5756,36 @@ packages: engines: {node: '>=6'} dev: true + /tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} + dependencies: + b4a: 1.8.1 + bare-fs: 4.7.4 + fast-fifo: 1.3.2 + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + dev: false + + /teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + dependencies: + streamx: 2.28.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + dev: false + + /text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + dev: false + /thread-stream@4.2.0: resolution: {integrity: sha512-e2zZ96wSChazBsbENf/Pcm/4swHt2cEKQ92rhUjkL9GCKiTDJIaTBenjE/m9DXi0QBmTMDkFDdOomUy20A1tDQ==} engines: {node: '>=20'} @@ -5499,6 +5854,16 @@ packages: optionalDependencies: fsevents: 2.3.3 + /tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + dev: false + /type-is@2.1.0: resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} engines: {node: '>= 18'} @@ -5794,6 +6159,11 @@ packages: transitivePeerDependencies: - msw + /vscode-jsonrpc@9.0.1: + resolution: {integrity: sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==} + engines: {node: '>=14.0.0'} + dev: false + /w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} dev: false @@ -5855,6 +6225,14 @@ packages: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} dev: false + /wsl-utils@0.3.1: + resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==} + engines: {node: '>=20'} + dependencies: + is-wsl: 3.1.1 + powershell-utils: 0.1.0 + dev: false + /xml-name-validator@5.0.0: resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} engines: {node: '>=18'} From 3ee95ca07fbcd57859819c03321cad8f6f9a3019 Mon Sep 17 00:00:00 2001 From: surenny233 Date: Thu, 23 Jul 2026 04:21:45 +0000 Subject: [PATCH 2/2] fix(daemon): harden pid liveness checks --- packages/daemon/src/control.test.ts | 8 +-- packages/daemon/src/control.ts | 4 +- packages/daemon/src/home.ts | 4 +- packages/daemon/src/pidfile.test.ts | 81 ++++++++++++++++++++++++++++- packages/daemon/src/pidfile.ts | 54 ++++++++++++++----- 5 files changed, 132 insertions(+), 19 deletions(-) diff --git a/packages/daemon/src/control.test.ts b/packages/daemon/src/control.test.ts index 222a21a..0ecbd1f 100644 --- a/packages/daemon/src/control.test.ts +++ b/packages/daemon/src/control.test.ts @@ -36,7 +36,7 @@ describe("runStatus", () => { expect(cap.stdout()).toContain("loopany up"); }); - test("stale pidfile (pid dead) → not running, clears the stale file", async () => { + test("stale pidfile (pid dead) → not running without mutating state", async () => { let cleared = false; const cap = capture({ readPid: () => ({ pid: 999 }), @@ -46,11 +46,11 @@ describe("runStatus", () => { token: undefined, }); await runStatus([], cap); - expect(cleared).toBe(true); + expect(cleared).toBe(false); expect(cap.stdout()).toContain("not running"); }); - test("pid alive but start-time mismatch (reused pid) → not running, clears stale file", async () => { + test("pid alive but start-time mismatch (reused pid) → not running without mutating state", async () => { let cleared = false; const cap = capture({ readPid: () => ({ pid: 4242, startTime: "Mon Jun 30 09:00:00 2026" }), @@ -61,7 +61,7 @@ describe("runStatus", () => { token: undefined, }); await runStatus([], cap); - expect(cleared).toBe(true); + expect(cleared).toBe(false); expect(cap.stdout()).toContain("not running"); }); diff --git a/packages/daemon/src/control.ts b/packages/daemon/src/control.ts index a7d6937..35800bd 100644 --- a/packages/daemon/src/control.ts +++ b/packages/daemon/src/control.ts @@ -78,7 +78,9 @@ export async function runStatus(args: string[], injected: ControlDeps = {}): Pro const server = "server" in injected ? (injected.server ?? "") : resolveServerUrl(undefined); const token = "token" in injected ? injected.token : readStored(DEVICE_FILE); // The shared pidfile.verifiedRunningPid check (reused-pid safe), fed our seams. - const pid = verifiedRunningPid(d); + // `status` is read-only. A sandbox may have an incomplete process view, so an + // uncertain/stale result may be reported but must never erase the pidfile. + const pid = verifiedRunningPid({ ...d, clearStale: false }); d.out("loopany status:\n"); d.out( diff --git a/packages/daemon/src/home.ts b/packages/daemon/src/home.ts index 51ff108..2d1d291 100644 --- a/packages/daemon/src/home.ts +++ b/packages/daemon/src/home.ts @@ -56,7 +56,9 @@ export async function runHome(injected: HomeDeps = {}): Promise { const out = injected.out ?? ((s: string) => void process.stdout.write(s)); const cwd = (injected.cwd ?? (() => process.cwd()))(); const homedir = (injected.homedir ?? os.homedir)(); - const pid = (injected.localPid ?? (() => verifiedRunningPid()))(); + // The ambient SessionStart home is read-only. Never let a sandbox-dependent + // liveness probe erase the daemon's management handle. + const pid = (injected.localPid ?? (() => verifiedRunningPid({ clearStale: false })))(); // The durable `loopany` path (our shim OR a real global on PATH) for the home's // `bin:` line (P8). Null ⇒ npx-without-global; the SERVER then renders the honest // "not on PATH — npm i -g" fallback so the line ALWAYS leads the home (F7). diff --git a/packages/daemon/src/pidfile.test.ts b/packages/daemon/src/pidfile.test.ts index 4214e52..da67ce0 100644 --- a/packages/daemon/src/pidfile.test.ts +++ b/packages/daemon/src/pidfile.test.ts @@ -11,7 +11,54 @@ import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; -import { verifiedRunningPid } from "./pidfile.js"; +import { isAlive, processStartTimeEnv, verifiedRunningPid } from "./pidfile.js"; + +describe("isAlive", () => { + const error = (code: string): NodeJS.ErrnoException => Object.assign(new Error(code), { code }); + + test("successful signal probe → alive", () => { + expect(isAlive(7, { + signal: () => {}, + procVisible: () => false, + platform: "linux", + })).toBe(true); + }); + + test("EPERM means the process exists", () => { + expect(isAlive(7, { + signal: () => { throw error("EPERM"); }, + procVisible: () => false, + platform: "linux", + })).toBe(true); + }); + + test("Linux ESRCH falls back to proc visibility", () => { + expect(isAlive(7, { + signal: () => { throw error("ESRCH"); }, + procVisible: () => true, + platform: "linux", + })).toBe(true); + expect(isAlive(7, { + signal: () => { throw error("ESRCH"); }, + procVisible: () => false, + platform: "linux", + })).toBe(false); + }); + + test("does not use the Linux fallback on another platform", () => { + expect(isAlive(7, { + signal: () => { throw error("ESRCH"); }, + procVisible: () => true, + platform: "darwin", + })).toBe(false); + }); + + test("rejects invalid pids before probing", () => { + let probed = false; + expect(isAlive(0, { signal: () => { probed = true; } })).toBe(false); + expect(probed).toBe(false); + }); +}); describe("verifiedRunningPid (seams injected)", () => { const noClear = () => {}; @@ -50,6 +97,24 @@ describe("verifiedRunningPid (seams injected)", () => { expect(cleared).toBe(true); }); + test("read-only probe never clears a dead or mismatched pidfile", () => { + let clears = 0; + expect(verifiedRunningPid({ + readPid: () => ({ pid: 7 }), + alive: () => false, + clearPid: () => { clears += 1; }, + clearStale: false, + })).toBeUndefined(); + expect(verifiedRunningPid({ + readPid: () => ({ pid: 7, startTime: "t1" }), + alive: () => true, + startTime: () => "t2", + clearPid: () => { clears += 1; }, + clearStale: false, + })).toBeUndefined(); + expect(clears).toBe(0); + }); + test("start-time unreadable at check time → degrades to alive-only", () => { const pid = verifiedRunningPid({ readPid: () => ({ pid: 7, startTime: "t1" }), @@ -65,6 +130,20 @@ describe("verifiedRunningPid (seams injected)", () => { }); }); +describe("processStartTime", () => { + test("forces a stable timezone and locale for ps", () => { + expect(processStartTimeEnv({ + PATH: "/bin", + TZ: "Asia/Shanghai", + LC_ALL: "zh_CN.UTF-8", + })).toEqual({ + PATH: "/bin", + TZ: "UTC", + LC_ALL: "C", + }); + }); +}); + describe("clearPidFile ownership (real fs under a temp LOOPANY_HOME)", () => { const prevHome = process.env.LOOPANY_HOME; let home: string | undefined; diff --git a/packages/daemon/src/pidfile.ts b/packages/daemon/src/pidfile.ts index ae4c05f..abbdf40 100644 --- a/packages/daemon/src/pidfile.ts +++ b/packages/daemon/src/pidfile.ts @@ -23,18 +23,26 @@ export const PID_FILE = path.join(LOOPANY_DIR, "daemon.pid"); /** A pidfile record: the daemon's pid plus a best-effort identity marker. */ export type PidRecord = { pid: number; startTime?: string }; +/** Deterministic environment for the cross-platform `ps lstart` identity. */ +export function processStartTimeEnv(env: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv { + return { ...env, TZ: "UTC", LC_ALL: "C" }; +} + /** * Best-effort process start time, used as a second identity field so a REUSED * pid (after an unclean crash left the pidfile behind) can't be mistaken for our * daemon. `ps -p -o lstart=` prints the process's start timestamp on both - * macOS and Linux; one shared helper is used at write-time AND check-time so the - * two strings compare byte-for-byte. Returns undefined when `ps` is unavailable, - * the pid is gone, or anything else fails — callers degrade to alive-only. + * macOS and Linux. Its output depends on the caller's timezone and locale, so + * force UTC + the C locale: `up` may run outside an agent sandbox while a later + * `status` runs inside one, and the same process must still compare byte-for-byte. + * Returns undefined when `ps` is unavailable, the pid is gone, or anything else + * fails — callers degrade to alive-only. */ export function processStartTime(pid: number): string | undefined { try { const out = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf8", + env: processStartTimeEnv(), stdio: ["ignore", "pipe", "ignore"], }).trim(); return out || undefined; @@ -84,17 +92,34 @@ export function clearPidFile(onlyIfPid?: number): void { } } +export type AliveProbeDeps = { + signal?: (pid: number) => void; + procVisible?: (pid: number) => boolean; + platform?: NodeJS.Platform; +}; + /** * Is a process with this pid alive? `kill(pid, 0)` probes without delivering a - * signal: it throws ESRCH when no such process exists, EPERM when the process - * exists but is owned by someone else (still alive, so treat as running). + * signal: it normally throws ESRCH when no such process exists and EPERM when + * the process exists but is owned by someone else. + * + * Some Linux agent sandboxes return ESRCH for a host process even though that + * same process is visible through `/proc` and `ps`. In that case `/proc/` + * is the authoritative fallback. The separately recorded start time still + * protects callers from treating a reused pid as this daemon. */ -export function isAlive(pid: number): boolean { +export function isAlive(pid: number, injected: AliveProbeDeps = {}): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + const signal = injected.signal ?? ((target: number) => process.kill(target, 0)); + const platform = injected.platform ?? process.platform; + const procVisible = injected.procVisible ?? ((target: number) => fs.existsSync(`/proc/${target}/stat`)); try { - process.kill(pid, 0); + signal(pid); return true; } catch (err) { - return (err as NodeJS.ErrnoException).code === "EPERM"; + const code = (err as NodeJS.ErrnoException).code; + if (code === "EPERM") return true; + return code === "ESRCH" && platform === "linux" && procVisible(pid); } } @@ -104,6 +129,9 @@ export type PidCheckDeps = { alive?: (pid: number) => boolean; startTime?: (pid: number) => string | undefined; clearPid?: () => void; + /** Read-only callers set false: an uncertain probe may report "not running", + * but must not destroy the only handle to a live daemon. */ + clearStale?: boolean; }; /** @@ -112,8 +140,10 @@ export type PidCheckDeps = { * start-time still equals the one we recorded) — so a pid REUSED by an unrelated * process after an unclean crash (which left the pidfile behind) is NOT mistaken * for the daemon and never signaled. A dead pid OR a start-time mismatch is - * cleared as a side effect so a stale file doesn't linger. When the start-time - * can't be read at check time we degrade to alive-only (best-effort, never crash). + * cleared by mutating lifecycle callers by default; read-only callers pass + * `clearStale:false` so a best-effort probe cannot erase the only management + * handle to a live daemon. When the start-time can't be read at check time we + * degrade to alive-only (best-effort, never crash). */ export function verifiedRunningPid(deps: PidCheckDeps = {}): number | undefined { const readPid = deps.readPid ?? readPidFile; @@ -123,13 +153,13 @@ export function verifiedRunningPid(deps: PidCheckDeps = {}): number | undefined const rec = readPid(); if (rec === undefined) return undefined; if (!alive(rec.pid)) { - clearPid(); + if (deps.clearStale !== false) clearPid(); return undefined; } if (rec.startTime !== undefined) { const live = startTime(rec.pid); if (live !== undefined && live !== rec.startTime) { - clearPid(); + if (deps.clearStale !== false) clearPid(); return undefined; } }