diff --git a/packages/adapters/acpx-local/src/server/execute.test.ts b/packages/adapters/acpx-local/src/server/execute.test.ts index b009b1bc..4d1f28af 100644 --- a/packages/adapters/acpx-local/src/server/execute.test.ts +++ b/packages/adapters/acpx-local/src/server/execute.test.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; +import type { AcpRuntimeOptions } from "acpx/runtime"; import { createAcpxLocalExecutor } from "./execute.js"; const tempRoots: string[] = []; @@ -376,6 +377,126 @@ describe("acpx_local runtime skill isolation", () => { expect(envFiles.filter((contents) => contents.includes("PAPERCLIP_API_KEY='second-key'"))).toHaveLength(1); }); + it("enriches acpx.error diagnostics and child stderr when ensureSession rejects", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const runStderrDir = path.join(stateDir, "run-stderr"); + await fs.mkdir(runStderrDir, { recursive: true }); + const stderrTail = "claude-agent-acp: SDK init failed (auth missing)"; + await fs.writeFile(path.join(runStderrDir, "run-1.log"), `${stderrTail}\n`, "utf8"); + + class FakeAcpRuntimeError extends Error { + readonly code = "ACP_SESSION_INIT_FAILED"; + readonly cause: Error; + readonly retryable = false; + constructor(message: string, cause: Error) { + super(message); + this.name = "AcpRuntimeError"; + this.cause = cause; + } + } + + const logs: Array<{ stream: string; text: string }> = []; + const execute = createAcpxLocalExecutor({ + createRuntime: () => ({ + ensureSession: async () => { + throw new FakeAcpRuntimeError( + "session/new failed: backend rejected initialize", + new Error("upstream timeout"), + ); + }, + startTurn: () => ({ + events: (async function* () {})(), + result: Promise.resolve({ status: "completed", stopReason: "end_turn" }), + cancel: async () => {}, + }), + close: async () => {}, + }) as never, + }); + + const result = await execute({ + runId: "run-1", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir, + }, + context: {}, + onLog: async (stream: "stdout" | "stderr", text: string) => { + logs.push({ stream, text }); + }, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(1); + expect(result.errorCode).toBe("acpx_session_init_failed"); + const meta = result.errorMeta ?? {}; + expect(meta.errorName).toBe("AcpRuntimeError"); + expect(meta.acpCode).toBe("ACP_SESSION_INIT_FAILED"); + expect(meta.causeMessage).toBe("upstream timeout"); + expect(meta.retryable).toBe(false); + expect(typeof meta.stackPreview).toBe("string"); + expect(meta.phase).toBe("ensure_session"); + + const errorLogLine = logs.find((entry) => entry.stream === "stdout" && entry.text.includes("\"type\":\"acpx.error\"")); + expect(errorLogLine).toBeTruthy(); + const errorPayload = JSON.parse(errorLogLine!.text.trim()); + expect(errorPayload.phase).toBe("ensure_session"); + expect(errorPayload.errorName).toBe("AcpRuntimeError"); + expect(errorPayload.acpCode).toBe("ACP_SESSION_INIT_FAILED"); + expect(errorPayload.causeMessage).toBe("upstream timeout"); + expect(errorPayload.childStderrTail).toContain("SDK init failed"); + + const stderrLog = logs.find((entry) => entry.stream === "stderr" && entry.text.includes("ACPX child stderr tail")); + expect(stderrLog).toBeTruthy(); + expect(stderrLog!.text).toContain(stderrTail); + }); + + it("writes wrapper that redirects child stderr to a per-run log file", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + + const runtimeOptions: AcpRuntimeOptions[] = []; + const execute = createAcpxLocalExecutor({ + createRuntime: (options) => { + runtimeOptions.push(options as unknown as AcpRuntimeOptions); + return buildRuntime() as never; + }, + }); + + const result = await execute({ + runId: "run-stderr-1", + agent: { id: "agent-1", companyId: "company-1" }, + runtime: {}, + config: { + agent: "custom", + agentCommand: "node ./fake-acp.js", + stateDir, + }, + context: {}, + onLog: async () => {}, + onMeta: async () => {}, + } as never); + + expect(result.exitCode).toBe(0); + const verboseFlags = runtimeOptions.map((options) => (options as { verbose?: boolean }).verbose); + // verbose is scoped to the claude agent (PAPA-388); the custom agent here + // should not opt in to ACPX runtime verbose session-event logs. + expect(verboseFlags.every((flag) => flag === false)).toBe(true); + + const wrappers = await fs.readdir(path.join(stateDir, "wrappers")); + const wrapperFile = wrappers.find((name) => name.endsWith(".sh")); + expect(wrapperFile).toBeTruthy(); + const wrapper = await fs.readFile(path.join(stateDir, "wrappers", wrapperFile!), "utf8"); + expect(wrapper).toContain("stderr_dir="); + expect(wrapper).toContain("run-stderr"); + expect(wrapper).toContain("PAPERCLIP_RUN_ID"); + expect(wrapper).toContain("tee -a"); + expect(wrapper).toContain("exec node ./fake-acp.js"); + }); + it("passes Paperclip env through the ACP agent wrapper instead of process.env", async () => { let observedApiKeyDuringStream: string | undefined; const execute = createAcpxLocalExecutor({ @@ -422,4 +543,160 @@ describe("acpx_local runtime skill isolation", () => { else process.env.PAPERCLIP_API_KEY = previousApiKey; } }); + + it("writes a Paperclip-managed .claude/settings.local.json for the claude agent so it can reach the Paperclip API", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + const { meta } = await runExecutor( + { agent: "claude", stateDir, cwd }, + { context: { paperclipWorkspace: { cwd, agentHome: path.join(root, "agent-home") } } }, + ); + + const settingsPath = path.join(cwd, ".claude", "settings.local.json"); + const written = JSON.parse(await fs.readFile(settingsPath, "utf8")) as { + permissions?: { + allow?: unknown; + additionalDirectories?: unknown; + defaultMode?: unknown; + }; + }; + expect(written.permissions?.defaultMode).toBe("default"); + const allow = written.permissions?.allow; + expect(Array.isArray(allow)).toBe(true); + expect(allow).toContain("Bash(curl:*)"); + expect(allow).toContain(`Bash(${cwd}/scripts/paperclip-issue-update.sh:*)`); + const additionalDirectories = written.permissions?.additionalDirectories as string[] | undefined; + expect(Array.isArray(additionalDirectories)).toBe(true); + expect(additionalDirectories).toContain(stateDir); + expect(additionalDirectories).toContain(path.join(root, "agent-home")); + + const note = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) => + entry.includes("Paperclip-managed Claude settings"), + ); + expect(note).toBeTruthy(); + }); + + it("merges Paperclip allowlist into an existing .claude/settings.local.json without losing user entries", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(path.join(cwd, ".claude"), { recursive: true }); + await fs.writeFile( + path.join(cwd, ".claude", "settings.local.json"), + JSON.stringify( + { + statusLine: { type: "command", command: "preserve-me" }, + permissions: { + allow: ["Bash(npm test:*)"], + additionalDirectories: ["/Users/example/custom"], + defaultMode: "acceptEdits", + }, + }, + null, + 2, + ), + "utf8", + ); + + await runExecutor( + { agent: "claude", stateDir, cwd }, + { context: { paperclipWorkspace: { cwd } } }, + ); + + const written = JSON.parse( + await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"), + ) as { + statusLine?: unknown; + permissions?: { + allow?: string[]; + additionalDirectories?: string[]; + defaultMode?: string; + }; + }; + expect(written.statusLine).toEqual({ type: "command", command: "preserve-me" }); + expect(written.permissions?.defaultMode).toBe("acceptEdits"); + expect(written.permissions?.allow).toContain("Bash(npm test:*)"); + expect(written.permissions?.allow).toContain("Bash(curl:*)"); + expect(written.permissions?.additionalDirectories).toContain("/Users/example/custom"); + expect(written.permissions?.additionalDirectories).toContain(stateDir); + }); + + it("overrides a user-supplied dontAsk defaultMode so ACPX can route Bash through canUseTool", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(path.join(cwd, ".claude"), { recursive: true }); + await fs.writeFile( + path.join(cwd, ".claude", "settings.local.json"), + JSON.stringify({ permissions: { defaultMode: "dontAsk" } }, null, 2), + "utf8", + ); + + const { meta } = await runExecutor( + { agent: "claude", stateDir, cwd }, + { context: { paperclipWorkspace: { cwd } } }, + ); + + const written = JSON.parse( + await fs.readFile(path.join(cwd, ".claude", "settings.local.json"), "utf8"), + ) as { permissions?: { defaultMode?: string } }; + expect(written.permissions?.defaultMode).toBe("default"); + + const overrideNote = (meta[0]?.commandNotes as string[] | undefined)?.find((entry) => + entry.includes("overrode user dontAsk"), + ); + expect(overrideNote).toBeTruthy(); + }); + + it("opts the claude agent into ACPX runtime verbose logs but leaves codex/custom agents quiet", async () => { + const root = await makeTempRoot(); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + const verboseByAgent: Record = {}; + for (const agent of ["claude", "codex", "custom"] as const) { + const runtimeOptions: AcpRuntimeOptions[] = []; + const execute = createAcpxLocalExecutor({ + createRuntime: (options) => { + runtimeOptions.push(options as AcpRuntimeOptions); + return buildRuntime() as never; + }, + }); + const result = await execute({ + runId: `run-${agent}`, + agent: { id: `agent-${agent}`, companyId: "company-1" }, + runtime: {}, + config: + agent === "custom" + ? { agent, agentCommand: "node ./fake-acp.js", stateDir: path.join(root, `state-${agent}`), cwd } + : { agent, stateDir: path.join(root, `state-${agent}`), cwd }, + context: { paperclipWorkspace: { cwd } }, + onLog: async () => {}, + onMeta: async () => {}, + } as never); + expect(result.exitCode).toBe(0); + verboseByAgent[agent] = (runtimeOptions[0] as { verbose?: boolean } | undefined)?.verbose; + } + + expect(verboseByAgent.claude).toBe(true); + expect(verboseByAgent.codex).toBe(false); + expect(verboseByAgent.custom).toBe(false); + }); + + it("does not touch .claude/settings.local.json for the codex agent", async () => { + const root = await makeTempRoot(); + const stateDir = path.join(root, "state"); + const cwd = path.join(root, "worktree"); + await fs.mkdir(cwd, { recursive: true }); + + await runExecutor( + { agent: "codex", stateDir, cwd }, + { context: { paperclipWorkspace: { cwd } } }, + ); + + expect(await pathExists(path.join(cwd, ".claude", "settings.local.json"))).toBe(false); + }); }); diff --git a/packages/adapters/acpx-local/src/server/execute.ts b/packages/adapters/acpx-local/src/server/execute.ts index 4914af44..d465b02c 100644 --- a/packages/adapters/acpx-local/src/server/execute.ts +++ b/packages/adapters/acpx-local/src/server/execute.ts @@ -94,6 +94,8 @@ interface AcpxPreparedRuntime { remoteExecutionIdentity: Record | null; skillPromptInstructions: string; skillsIdentity: Record; + childStderrLogPath: string | null; + paperclipClaudeSettings: PaperclipClaudeSettingsResult | null; } const defaultWarmHandles = new Map(); @@ -564,11 +566,105 @@ function buildSessionParams(input: { }; } +interface PaperclipClaudeSettingsResult { + filePath: string; + allow: string[]; + additionalDirectories: string[]; + defaultMode: string; + overrodeDontAsk: boolean; +} + +function uniqueSorted(values: Array): string[] { + return [...new Set(values.filter((value): value is string => typeof value === "string" && value.length > 0))].sort(); +} + +// Phase 4.1 (PAPA-388): the Claude Code SDK that `claude-agent-acp` runs uses +// `settingSources: ["user", "project", "local"]`. By writing a per-worktree +// `.claude/settings.local.json` we override the user's potentially-restrictive +// `~/.claude/settings.json` (e.g. `defaultMode: "dontAsk"`, which silently +// denies every non-allowlisted tool and never reaches `canUseTool`), and we +// widen the SDK's Read sandbox to include the Paperclip state dirs the agent +// needs to talk to its own control plane. +async function writePaperclipClaudeSettings(input: { + cwd: string; + stateDir: string; + agentHome: string; + companyId: string; +}): Promise { + const filePath = path.join(input.cwd, ".claude", "settings.local.json"); + const instanceRoot = defaultPaperclipInstanceDir(); + const companyRoot = path.join(instanceRoot, "companies", input.companyId); + const paperclipAdditionalDirectories = uniqueSorted([ + input.stateDir, + input.agentHome, + companyRoot, + ]); + const paperclipAllow = uniqueSorted([ + "Bash(curl:*)", + "Bash(env:*)", + "Bash(env)", + `Bash(${input.cwd}/scripts/paperclip-issue-update.sh:*)`, + `Bash(${input.cwd}/scripts/paperclip:*)`, + ]); + + let existing: Record = {}; + const existingRaw = await fs.readFile(filePath, "utf8").catch(() => null); + if (existingRaw) { + try { + const parsed = JSON.parse(existingRaw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) existing = parsed as Record; + } catch { + // Malformed settings file — leave it alone in `existing` and our merge will replace it with a valid one. + } + } + const existingPerms = + existing.permissions && typeof existing.permissions === "object" && !Array.isArray(existing.permissions) + ? (existing.permissions as Record) + : {}; + const existingAllow = Array.isArray(existingPerms.allow) + ? (existingPerms.allow as unknown[]).filter((value): value is string => typeof value === "string") + : []; + const existingAdditionalDirectories = Array.isArray(existingPerms.additionalDirectories) + ? (existingPerms.additionalDirectories as unknown[]).filter((value): value is string => typeof value === "string") + : []; + const mergedAllow = uniqueSorted([...existingAllow, ...paperclipAllow]); + const mergedAdditionalDirectories = uniqueSorted([ + ...existingAdditionalDirectories, + ...paperclipAdditionalDirectories, + ]); + const existingDefaultMode = + typeof existingPerms.defaultMode === "string" ? (existingPerms.defaultMode as string) : ""; + const defaultMode = + existingDefaultMode && existingDefaultMode !== "dontAsk" ? existingDefaultMode : "default"; + const overrodeDontAsk = existingDefaultMode === "dontAsk"; + + const nextPermissions: Record = { + ...existingPerms, + allow: mergedAllow, + additionalDirectories: mergedAdditionalDirectories, + defaultMode, + }; + const next: Record = { ...existing, permissions: nextPermissions }; + await writeFileAtomically({ + target: filePath, + contents: `${JSON.stringify(next, null, 2)}\n`, + mode: 0o600, + }); + return { + filePath, + allow: mergedAllow, + additionalDirectories: mergedAdditionalDirectories, + defaultMode, + overrodeDontAsk, + }; +} + async function writeAgentWrapper(input: { stateDir: string; acpxAgent: string; agentCommandShell: string; env: Record; + childStderrDir: string; }): Promise<{ wrapperPath: string; envFilePath: string }> { const wrappersDir = path.join(input.stateDir, "wrappers"); await fs.mkdir(wrappersDir, { recursive: true }); @@ -580,6 +676,7 @@ async function writeAgentWrapper(input: { agent: input.acpxAgent, command: input.agentCommandShell, env: envLines, + childStderrDir: input.childStderrDir, }); const wrapperPath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.sh`); const envFilePath = path.join(wrappersDir, `${input.acpxAgent}-${wrapperHash}.env`); @@ -592,6 +689,11 @@ async function writeAgentWrapper(input: { " source \"$env_file\"", " set +a", "fi", + `stderr_dir=${shellQuote(input.childStderrDir)}`, + "if [[ -n \"${PAPERCLIP_RUN_ID:-}\" ]]; then", + " mkdir -p \"$stderr_dir\"", + " exec 2> >(tee -a \"$stderr_dir/$PAPERCLIP_RUN_ID.log\" >&2)", + "fi", `exec ${input.agentCommandShell} "$@"`, "", ].join("\n"); @@ -723,10 +825,20 @@ async function buildRuntime(input: { if (typeof value === "string") env[key] = value; } if (!hasExplicitApiKey && authToken) env.PAPERCLIP_API_KEY = authToken; + // For the claude agent, set model via ANTHROPIC_MODEL at startup rather than + // via session/set_config_option — the ACP server's set_config_option handler + // validates the value against its internal available-models list and rejects + // bare model IDs (e.g. "claude-opus-4-7") that don't exactly match a model + // entry in some versions. ANTHROPIC_MODEL is read during initialization, so + // it reliably sets the model before any turns are run. + if (requestedModel && acpxAgent === "claude" && !env.ANTHROPIC_MODEL) { + env.ANTHROPIC_MODEL = requestedModel; + } let skillPromptInstructions = ""; let skillsIdentity: Record = { mode: "unsupported" }; const skillCommandNotes: string[] = []; + let paperclipClaudeSettings: PaperclipClaudeSettingsResult | null = null; if (acpxAgent === "claude") { const preparedSkills = await prepareClaudeSkillRuntime({ stateDir, @@ -736,6 +848,17 @@ async function buildRuntime(input: { skillPromptInstructions = preparedSkills.promptInstructions; skillsIdentity = preparedSkills.identity; skillCommandNotes.push(...preparedSkills.commandNotes); + paperclipClaudeSettings = await writePaperclipClaudeSettings({ + cwd, + stateDir, + agentHome, + companyId: agent.companyId, + }); + skillCommandNotes.push( + `Wrote Paperclip-managed Claude settings to ${paperclipClaudeSettings.filePath} (defaultMode=${paperclipClaudeSettings.defaultMode}${ + paperclipClaudeSettings.overrodeDontAsk ? "; overrode user dontAsk" : "" + }, +${paperclipClaudeSettings.additionalDirectories.length} read root(s), +${paperclipClaudeSettings.allow.length} allow rule(s)).`, + ); } else if (acpxAgent === "codex") { const preparedSkills = await prepareCodexSkillRuntime({ companyId: agent.companyId, @@ -757,12 +880,15 @@ async function buildRuntime(input: { const builtInCommand = resolveBuiltInAgentCommand(acpxAgent); const agentCommand = configuredCommand || builtInCommand || null; const agentCommandShell = configuredCommand || (builtInCommand ? shellQuote(builtInCommand) : ""); + const childStderrDir = path.join(stateDir, "run-stderr"); + const childStderrLogPath = agentCommand ? path.join(childStderrDir, `${runId}.log`) : null; const wrapper = agentCommand ? await writeAgentWrapper({ stateDir, acpxAgent, agentCommandShell, env, + childStderrDir, }) : null; const wrapperPath = wrapper?.wrapperPath ?? null; @@ -781,6 +907,13 @@ async function buildRuntime(input: { remoteExecutionIdentity, skillsIdentity, skillPromptInstructions, + paperclipClaudeSettings: paperclipClaudeSettings + ? { + allow: paperclipClaudeSettings.allow, + additionalDirectories: paperclipClaudeSettings.additionalDirectories, + defaultMode: paperclipClaudeSettings.defaultMode, + } + : null, }); const taskKey = asString(input.ctx.runtime.taskKey, "") || wakeTaskId || workspaceId || "default"; const sessionKey = `paperclip:${agent.companyId}:${agent.id}:${taskKey}:${fingerprint}`; @@ -817,12 +950,19 @@ async function buildRuntime(input: { ...skillsIdentity, commandNotes: skillCommandNotes, }, + childStderrLogPath, + paperclipClaudeSettings, }; } function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> { const options: Array<{ key: string; value: string }> = []; - if (prepared.requestedModel) options.push({ key: "model", value: prepared.requestedModel }); + // Model for the claude agent is pre-set via ANTHROPIC_MODEL env var at + // startup; skip set_config_option to avoid ACP-server model-name validation + // that rejects bare IDs like "claude-opus-4-7" in some runtime versions. + if (prepared.requestedModel && prepared.acpxAgent !== "claude") { + options.push({ key: "model", value: prepared.requestedModel }); + } if (prepared.requestedThinkingEffort) { options.push({ key: prepared.acpxAgent === "codex" ? "reasoning_effort" : "effort", @@ -999,33 +1139,151 @@ function resultErrorMessage(result: AcpRuntimeTurnResult): string | null { return result.error.message; } -function classifyError(err: unknown): Pick { - const message = err instanceof Error ? err.message : String(err); +type AcpxExecutionPhase = "ensure_session" | "configure_session" | "turn"; + +function describeErrorDiagnostics(err: unknown): { + errorName: string; + acpCode: string | null; + causeMessage: string | null; + retryable: boolean | null; + stackPreview: string | null; +} { + const errorName = + err instanceof Error ? err.name || err.constructor.name : typeof err; const maybeCode = err && typeof err === "object" && typeof (err as { code?: unknown }).code === "string" ? (err as { code: string }).code : null; - const acpCode = isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null; + const acpCode = + isAcpRuntimeError(err) || (maybeCode?.startsWith("ACP_") ?? false) ? maybeCode : null; + const cause = + err && typeof err === "object" && (err as { cause?: unknown }).cause !== undefined + ? (err as { cause?: unknown }).cause + : undefined; + const causeMessage = + cause instanceof Error + ? cause.message + : typeof cause === "string" + ? cause + : null; + const retryable = + err && typeof err === "object" && typeof (err as { retryable?: unknown }).retryable === "boolean" + ? (err as { retryable: boolean }).retryable + : null; + const stack = err instanceof Error && typeof err.stack === "string" ? err.stack : ""; + const stackPreview = stack ? stack.split("\n").slice(0, 6).join("\n") : null; + return { errorName, acpCode, causeMessage, retryable, stackPreview }; +} + +function classifyError( + err: unknown, + phase?: AcpxExecutionPhase, +): Pick { + const message = err instanceof Error ? err.message : String(err); + const diagnostics = describeErrorDiagnostics(err); + const { acpCode, errorName, causeMessage, retryable, stackPreview } = diagnostics; + const baseMeta: Record = { + errorName, + ...(acpCode ? { acpCode } : {}), + ...(causeMessage ? { causeMessage } : {}), + ...(retryable !== null ? { retryable } : {}), + ...(stackPreview ? { stackPreview } : {}), + ...(phase ? { phase } : {}), + }; const lower = message.toLowerCase(); const authLike = lower.includes("auth") || lower.includes("login") || lower.includes("credential"); if (authLike) { return { errorCode: "acpx_auth_required", - errorMeta: { category: "auth", ...(acpCode ? { acpCode } : {}) }, + errorMeta: { category: "auth", ...baseMeta }, + }; + } + const phaseCode = (() => { + if (acpCode === "ACP_SESSION_INIT_FAILED") return "acpx_session_init_failed"; + if (acpCode === "ACP_TURN_FAILED") return "acpx_turn_failed"; + if (acpCode === "ACP_BACKEND_MISSING") return "acpx_backend_missing"; + if (acpCode === "ACP_BACKEND_UNAVAILABLE") return "acpx_backend_unavailable"; + if (phase === "ensure_session") return "acpx_session_init_failed"; + if (phase === "configure_session") return "acpx_session_config_failed"; + if (phase === "turn") return "acpx_turn_failed"; + return null; + })(); + if (phaseCode) { + return { + errorCode: phaseCode, + errorMeta: { category: acpCode ? "protocol" : "runtime", ...baseMeta }, }; } if (acpCode) { return { errorCode: "acpx_protocol_error", - errorMeta: { category: "protocol", acpCode }, + errorMeta: { category: "protocol", ...baseMeta }, }; } return { errorCode: "acpx_runtime_error", - errorMeta: { category: "runtime" }, + errorMeta: { category: "runtime", ...baseMeta }, }; } +async function readChildStderrTail(input: { + logPath: string | null; + maxBytes?: number; +}): Promise { + if (!input.logPath) return null; + const maxBytes = input.maxBytes ?? 4096; + let handle: fs.FileHandle | null = null; + try { + const stat = await fs.stat(input.logPath); + if (stat.size === 0) return null; + handle = await fs.open(input.logPath, "r"); + const readBytes = Math.min(stat.size, maxBytes); + const buffer = Buffer.alloc(readBytes); + await handle.read(buffer, 0, readBytes, Math.max(0, stat.size - readBytes)); + const tail = buffer.toString("utf8").trim(); + return tail.length > 0 ? tail : null; + } catch { + return null; + } finally { + if (handle) await handle.close().catch(() => {}); + } +} + +async function emitAcpxFailure(input: { + ctx: AdapterExecutionContext; + prepared: AcpxPreparedRuntime; + err: unknown; + phase: AcpxExecutionPhase; + // Replace the err-derived message in both the stderr-tail log header and the + // acpx.error payload. Used by the turn path to surface "Timed out after Ns" + // instead of the raw underlying error message. + messageOverride?: string; +}): Promise<{ + classified: Pick; + message: string; + childStderrTail: string | null; +}> { + const { ctx, prepared, err, phase, messageOverride } = input; + const rawMessage = err instanceof Error ? err.message : String(err); + const message = messageOverride ?? rawMessage; + const classified = classifyError(err, phase); + const childStderrTail = await readChildStderrTail({ logPath: prepared.childStderrLogPath }); + if (childStderrTail) { + await ctx.onLog( + "stderr", + `[paperclip] ACPX child stderr tail (${phase}):\n${childStderrTail}\n`, + ); + } + await emitAcpxLog(ctx, { + type: "acpx.error", + message, + phase, + ...classified.errorMeta, + ...(childStderrTail ? { childStderrTail } : {}), + }); + return { classified, message, childStderrTail }; +} + function isResumeFailure(err: unknown): boolean { const message = err instanceof Error ? err.message : String(err); return /resume|load|not found|no session|unknown session|conversation/i.test(message); @@ -1136,6 +1394,11 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { permissionMode: prepared.permissionMode, nonInteractivePermissions: prepared.nonInteractivePermissions, timeoutMs: prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined, + // Scope ACPX runtime verbose logs to the claude agent only — that's the + // surface we know needs the extra session-event detail (PAPA-388). codex + // and custom agents already emit their own per-tool output and don't + // benefit from doubling the log volume. + verbose: prepared.acpxAgent === "claude", }; const runtime = cached?.runtime ?? createRuntime(runtimeOptions); if (cached) clearWarmHandleTimer(cached); @@ -1177,9 +1440,12 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { } } } catch (err) { - const classified = classifyError(err); - const message = err instanceof Error ? err.message : String(err); - await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta }); + const { classified, message } = await emitAcpxFailure({ + ctx, + prepared, + err, + phase: "ensure_session", + }); return { exitCode: 1, signal: null, @@ -1216,9 +1482,12 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { onLog: ctx.onLog, }); } catch (err) { - const classified = classifyError(err); - const message = err instanceof Error ? err.message : String(err); - await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta }); + const { classified, message } = await emitAcpxFailure({ + ctx, + prepared, + err, + phase: "configure_session", + }); await runtime.close({ handle: sessionHandle, reason: "paperclip config cleanup", @@ -1271,7 +1540,13 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { commandNotes: [ `ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`, `Effective ACPX permission mode: ${prepared.permissionMode}.`, - ...(prepared.requestedModel ? [`Requested ACPX model: ${prepared.requestedModel}.`] : []), + ...(prepared.requestedModel + ? [ + prepared.acpxAgent === "claude" + ? `Requested ACPX model: ${prepared.requestedModel} (set via ANTHROPIC_MODEL env at startup).` + : `Requested ACPX model: ${prepared.requestedModel}.`, + ] + : []), ...(prepared.requestedThinkingEffort ? [`Requested ACPX thinking effort: ${prepared.requestedThinkingEffort}.`] : []), ...(prepared.fastMode ? ["Requested ACPX Codex fast mode."] : []), ...(Array.isArray(prepared.skillsIdentity.commandNotes) @@ -1414,10 +1689,11 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { }; } catch (err) { if (timeout) clearTimeout(timeout); - const classified = classifyError(err); - const message = timedOut ? `Timed out after ${prepared.timeoutSec}s` : err instanceof Error ? err.message : String(err); + const messageOverride = timedOut ? `Timed out after ${prepared.timeoutSec}s` : undefined; const cancel = cancelActiveTurn as ((reason: string) => Promise) | null; - if (cancel) await cancel(message).catch(() => {}); + const preEmitMessage = + messageOverride ?? (err instanceof Error ? err.message : String(err)); + if (cancel) await cancel(preEmitMessage).catch(() => {}); await runtime.close({ handle: sessionHandle, reason: timedOut ? "paperclip timeout cleanup" : "paperclip error cleanup", @@ -1428,7 +1704,13 @@ export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) { clearWarmHandleTimer(existing); warmHandles.delete(prepared.sessionKey); } - await emitAcpxLog(ctx, { type: "acpx.error", message, ...classified.errorMeta }); + const { classified, message } = await emitAcpxFailure({ + ctx, + prepared, + err, + phase: "turn", + messageOverride, + }); return { exitCode: 1, signal: timedOut ? "SIGTERM" : null, diff --git a/server/src/__tests__/acpx-local-execute.test.ts b/server/src/__tests__/acpx-local-execute.test.ts index 148e52a0..6e3922e3 100644 --- a/server/src/__tests__/acpx-local-execute.test.ts +++ b/server/src/__tests__/acpx-local-execute.test.ts @@ -574,7 +574,7 @@ describe("acpx_local execute", () => { const execute = createAcpxLocalExecutor({ createRuntime: () => runtime }); const result = await execute(buildContext(root)); expect(result.exitCode).toBe(1); - expect(result.errorCode).toBe("acpx_protocol_error"); + expect(result.errorCode).toBe("acpx_session_init_failed"); expect(result.errorMeta).toMatchObject({ category: "protocol", acpCode: "ACP_SESSION_INIT_FAILED",