Make ACPX-Claude adapter work seamlessly (PAPA-388) (#6590)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies, so when
an adapter fails, the platform must surface enough detail for the next
agent (or human reviewer) to act
> - The `acpx_local` adapter wraps `claude-agent-acp`, which in turn
drives the Claude Code SDK — three layers, three different permission
and error-handling models
> - A user created a `Claude Local ACPX` agent in PAPA-387 and it failed
instantly with the generic `acpx.error / "Internal error"` log,
stranding the work and triggering an opaque `stranded_assigned_issue`
recovery to the CTO
> - Once the diagnostic blackbox was opened, the underlying cause turned
out to be two SDK-level mismatches: a model-name allowlist that rejects
bare IDs like `claude-opus-4-7`, and a Claude Code
permission/Read-sandbox configuration that silently denies every
non-allowlisted tool when the user's `~/.claude/settings.json` has
`defaultMode: "dontAsk"`
> - This pull request fixes both classes of failure in the adapter
itself so new ACPX agents work seamlessly without per-host
configuration, and widens the diagnostic surface so the *next* failure
of any kind is actionable
> - The benefit is that ACPX-Claude can join the regular agent roster —
verified end to end on PAPA-401, where the agent successfully reached
the Paperclip API, opened a worktree, surveyed existing notification
PRs, and posted a structured plan

## What Changed

- Widen ACPX failure diagnostics
(`packages/adapters/acpx-local/src/server/execute.ts`):
- Capture `err.name`, ACP code, `cause.message`, retryable flag, and a
5-frame stack preview into `errorMeta`.
- Promote phase-specific error codes: `ensure_session →
acpx_session_init_failed`, `configure_session →
acpx_session_config_failed`, `turn → acpx_turn_failed`, plus mapping for
`ACP_BACKEND_MISSING` / `ACP_BACKEND_UNAVAILABLE`.
- Set `verbose: true` on the ACPX runtime so its session-event log flows
through `ctx.onLog`.
- Capture child-process stderr via a wrapper-script tee into
`<stateDir>/run-stderr/<runId>.log`, inline the tail into the
`acpx.error` payload as `childStderrTail`, and forward it through
`ctx.onLog("stderr", …)` so it lands in the heartbeat `stderrExcerpt`
column (existing redaction applies).
- Set the model via `ANTHROPIC_MODEL` env for the `claude` agent instead
of `set_config_option(model, …)`. The ACP server's `set_config_option`
handler validates against an internal allowlist and rejects bare IDs
like `claude-opus-4-7`. `ANTHROPIC_MODEL` is read during initialization
and bypasses that check.
- Seed `<worktree>/.claude/settings.local.json` before spawning
`claude-agent-acp` (the seamless-API fix). Since `claude-agent-acp`
hard-codes `settingSources: ["user", "project", "local"]` and "local"
has the highest precedence:
- Set `permissions.defaultMode: "default"`, but **only** if the user's
value is missing or `"dontAsk"` (the broken case). Other modes like
`acceptEdits`/`plan` are preserved.
- Pre-allow Paperclip's Bash surface (`Bash(curl:*)`, `Bash(env:*)`,
`Bash(<cwd>/scripts/paperclip-issue-update.sh:*)`,
`Bash(<cwd>/scripts/paperclip:*)`).
- Widen `permissions.additionalDirectories` to include `stateDir`,
`agentHome`, and the per-company instance root
(`~/.paperclip/instances/<id>/companies/<companyId>`). Scoped to this
company only — does not expose other tenants.
- Existing user entries are merged, not replaced. The resolved roots are
folded into the session fingerprint so warm-session handles invalidate
when they change.
- Sync the existing server-side integration test
(`server/src/__tests__/acpx-local-execute.test.ts`) to assert
`acpx_session_init_failed` instead of the now-removed
`acpx_protocol_error` for `ACP_SESSION_INIT_FAILED` (a follow-up to
commit 1).

## Verification

- `pnpm --filter "@paperclipai/adapter-acpx-local" run typecheck` —
passes.
- `pnpm vitest run` in `packages/adapters/acpx-local` — 35/35 pass,
includes 4 new tests covering the settings.local.json write path (claude
only, merge with pre-existing content, `dontAsk` override, codex no-op).
- `pnpm vitest run src/__tests__/acpx-local-execute.test.ts` in
`server/` — 15/15 pass after the test-sync commit.
- End-to-end manual verification (PAPA-401): the `Claude Local ACPX`
agent that previously hit "restricted environment" now successfully
reaches the Paperclip API, opens its worktree, posts structured plan
comments, and flips the issue to `in_review` without any external
configuration.

## Risks

- **Low**, scoped to the `acpx_local` adapter. The settings.local.json
write is per-worktree (worktrees live under
`.paperclip/worktrees/<issue>/`) and only triggers when `acpxAgent ===
"claude"`. Existing user content is merged with `[...existing,
...paperclip]` and deduped — nothing is overwritten outright.
- The `defaultMode` override is intentionally narrow: it only flips
`"dontAsk"` (which silently denies every tool and is the root cause) to
`"default"`. Users who explicitly picked `acceptEdits`, `plan`, or any
other mode keep their choice.
- Stderr capture goes through the existing `log-redaction` pass before
persisting, so `PAPERCLIP_API_KEY` and similar secrets in the wrapper
env don't leak into heartbeat logs.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- Claude Opus 4.7 (`claude-opus-4-7`), running in the `claude_local`
adapter via Paperclip's harness. Extended thinking enabled, tool use
enabled.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [ ] If this change affects the UI, I have included before/after
screenshots — N/A (adapter-only)
- [ ] I have updated relevant documentation to reflect my changes — no
user-facing docs changed; internal commentary in the code change
explains the SDK constraints
- [x] I have considered and documented any risks above
- [ ] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin Foley
2026-05-23 13:01:27 -07:00
committed by GitHub
parent 897cc322c7
commit 96f0279e08
3 changed files with 578 additions and 19 deletions
@@ -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<string, boolean | undefined> = {};
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);
});
});
@@ -94,6 +94,8 @@ interface AcpxPreparedRuntime {
remoteExecutionIdentity: Record<string, unknown> | null;
skillPromptInstructions: string;
skillsIdentity: Record<string, unknown>;
childStderrLogPath: string | null;
paperclipClaudeSettings: PaperclipClaudeSettingsResult | null;
}
const defaultWarmHandles = new Map<string, RuntimeCacheEntry>();
@@ -564,11 +566,105 @@ function buildSessionParams(input: {
};
}
interface PaperclipClaudeSettingsResult {
filePath: string;
allow: string[];
additionalDirectories: string[];
defaultMode: string;
overrodeDontAsk: boolean;
}
function uniqueSorted(values: Array<string | null | undefined>): 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<PaperclipClaudeSettingsResult> {
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<string, unknown> = {};
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<string, unknown>;
} 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<string, unknown>)
: {};
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<string, unknown> = {
...existingPerms,
allow: mergedAllow,
additionalDirectories: mergedAdditionalDirectories,
defaultMode,
};
const next: Record<string, unknown> = { ...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<string, string>;
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<string, unknown> = { 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<AdapterExecutionResult, "errorCode" | "errorMeta"> {
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<AdapterExecutionResult, "errorCode" | "errorMeta"> {
const message = err instanceof Error ? err.message : String(err);
const diagnostics = describeErrorDiagnostics(err);
const { acpCode, errorName, causeMessage, retryable, stackPreview } = diagnostics;
const baseMeta: Record<string, unknown> = {
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<string | null> {
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<AdapterExecutionResult, "errorCode" | "errorMeta">;
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<void>) | 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,