mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-14 01:50:39 +09:00
## 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>
1731 lines
63 KiB
TypeScript
1731 lines
63 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { createHash, randomUUID } from "node:crypto";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
|
import { readAdapterExecutionTarget, adapterExecutionTargetSessionIdentity } from "@paperclipai/adapter-utils/execution-target";
|
|
import {
|
|
DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE,
|
|
applyPaperclipWorkspaceEnv,
|
|
asNumber,
|
|
asString,
|
|
buildInvocationEnvForLogs,
|
|
buildPaperclipEnv,
|
|
ensureAbsoluteDirectory,
|
|
ensurePathInEnv,
|
|
joinPromptSections,
|
|
materializePaperclipSkillCopy,
|
|
parseObject,
|
|
readPaperclipRuntimeSkillEntries,
|
|
readPaperclipIssueWorkModeFromContext,
|
|
renderPaperclipWakePrompt,
|
|
renderTemplate,
|
|
resolvePaperclipInstanceRootForAdapter,
|
|
resolvePaperclipDesiredSkillNames,
|
|
rewriteWorkspaceCwdEnvVarsForExecution,
|
|
shapePaperclipWorkspaceEnvForExecution,
|
|
stringifyPaperclipWakePayload,
|
|
type PaperclipSkillEntry,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
import { shellQuote } from "@paperclipai/adapter-utils/ssh";
|
|
import {
|
|
createAcpRuntime,
|
|
createAgentRegistry,
|
|
createRuntimeStore,
|
|
isAcpRuntimeError,
|
|
type AcpAgentRegistry,
|
|
type AcpRuntime,
|
|
type AcpRuntimeEvent,
|
|
type AcpRuntimeHandle,
|
|
type AcpRuntimeOptions,
|
|
type AcpRuntimeTurn,
|
|
type AcpRuntimeTurnResult,
|
|
} from "acpx/runtime";
|
|
import {
|
|
DEFAULT_ACPX_LOCAL_AGENT,
|
|
DEFAULT_ACPX_LOCAL_MODE,
|
|
DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS,
|
|
DEFAULT_ACPX_LOCAL_PERMISSION_MODE,
|
|
DEFAULT_ACPX_LOCAL_TIMEOUT_SEC,
|
|
DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS,
|
|
} from "../index.js";
|
|
|
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const WRAPPER_CLEANUP_RETENTION_MS = 15 * 60 * 1000;
|
|
const PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST = ".paperclip-managed-skills.json";
|
|
|
|
type AcpxRuntimeFactory = (options: AcpRuntimeOptions) => AcpRuntime;
|
|
|
|
interface RuntimeCacheEntry {
|
|
runtime: AcpRuntime;
|
|
handle: AcpRuntimeHandle;
|
|
fingerprint: string;
|
|
lastUsedAt: number;
|
|
cleanupTimer?: NodeJS.Timeout;
|
|
}
|
|
|
|
interface ExecuteDeps {
|
|
createRuntime?: AcpxRuntimeFactory;
|
|
now?: () => number;
|
|
warmHandles?: Map<string, RuntimeCacheEntry>;
|
|
}
|
|
|
|
interface AcpxPreparedRuntime {
|
|
acpxAgent: string;
|
|
mode: "persistent" | "oneshot";
|
|
cwd: string;
|
|
workspaceId: string;
|
|
workspaceRepoUrl: string;
|
|
workspaceRepoRef: string;
|
|
env: Record<string, string>;
|
|
loggedEnv: Record<string, string>;
|
|
stateDir: string;
|
|
permissionMode: "approve-all" | "approve-reads" | "deny-all";
|
|
nonInteractivePermissions: "deny" | "fail";
|
|
requestedModel: string;
|
|
requestedThinkingEffort: string;
|
|
fastMode: boolean;
|
|
timeoutSec: number;
|
|
sessionKey: string;
|
|
fingerprint: string;
|
|
agentCommand: string | null;
|
|
agentRegistry: AcpAgentRegistry;
|
|
remoteExecutionIdentity: Record<string, unknown> | null;
|
|
skillPromptInstructions: string;
|
|
skillsIdentity: Record<string, unknown>;
|
|
childStderrLogPath: string | null;
|
|
paperclipClaudeSettings: PaperclipClaudeSettingsResult | null;
|
|
}
|
|
|
|
const defaultWarmHandles = new Map<string, RuntimeCacheEntry>();
|
|
|
|
function stableJson(value: unknown): string {
|
|
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
if (value && typeof value === "object") {
|
|
return `{${Object.entries(value as Record<string, unknown>)
|
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
.map(([key, entry]) => `${JSON.stringify(key)}:${stableJson(entry)}`)
|
|
.join(",")}}`;
|
|
}
|
|
return JSON.stringify(value);
|
|
}
|
|
|
|
function shortHash(value: unknown): string {
|
|
return createHash("sha256").update(stableJson(value)).digest("hex").slice(0, 16);
|
|
}
|
|
|
|
function defaultPaperclipInstanceDir(): string {
|
|
const home = process.env.PAPERCLIP_HOME?.trim() || path.join(os.homedir(), ".paperclip");
|
|
const instanceId = process.env.PAPERCLIP_INSTANCE_ID?.trim() || "default";
|
|
return resolvePaperclipInstanceRootForAdapter({
|
|
homeDir: home,
|
|
instanceId,
|
|
});
|
|
}
|
|
|
|
function defaultStateDir(companyId: string, agentId: string): string {
|
|
return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "acpx-local", "agents", agentId);
|
|
}
|
|
|
|
function resolveManagedCodexHomeDir(companyId: string): string {
|
|
return path.join(defaultPaperclipInstanceDir(), "companies", companyId, "codex-home");
|
|
}
|
|
|
|
function packageRootDir(): string {
|
|
return path.resolve(__moduleDir, "../..");
|
|
}
|
|
|
|
function resolveBuiltInAgentCommand(agent: string): string | null {
|
|
const binName =
|
|
agent === "claude"
|
|
? "claude-agent-acp"
|
|
: agent === "codex"
|
|
? "codex-acp"
|
|
: null;
|
|
if (!binName) return null;
|
|
return path.join(packageRootDir(), "node_modules", ".bin", binName);
|
|
}
|
|
|
|
function normalizeAgent(config: Record<string, unknown>): string {
|
|
const agent = asString(config.agent, DEFAULT_ACPX_LOCAL_AGENT).trim();
|
|
return agent || DEFAULT_ACPX_LOCAL_AGENT;
|
|
}
|
|
|
|
async function pathExists(candidate: string): Promise<boolean> {
|
|
return fs.access(candidate).then(() => true).catch(() => false);
|
|
}
|
|
|
|
async function ensureParentDir(target: string): Promise<void> {
|
|
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
}
|
|
|
|
async function writeFileAtomically(input: {
|
|
target: string;
|
|
contents: string;
|
|
mode: number;
|
|
}): Promise<void> {
|
|
await ensureParentDir(input.target);
|
|
const tempPath = `${input.target}.tmp-${process.pid}-${randomUUID()}`;
|
|
const handle = await fs.open(tempPath, "wx", input.mode);
|
|
try {
|
|
await handle.writeFile(input.contents, "utf8");
|
|
await handle.close();
|
|
await fs.rename(tempPath, input.target);
|
|
await fs.chmod(input.target, input.mode).catch(() => {});
|
|
} catch (err) {
|
|
await handle.close().catch(() => {});
|
|
await fs.rm(tempPath, { force: true }).catch(() => {});
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function ensureSymlink(target: string, source: string): Promise<void> {
|
|
const resolvedSource = path.resolve(source);
|
|
const existing = await fs.lstat(target).catch(() => null);
|
|
if (!existing) {
|
|
await ensureParentDir(target);
|
|
await fs.symlink(resolvedSource, target);
|
|
return;
|
|
}
|
|
|
|
if (!existing.isSymbolicLink()) {
|
|
await fs.rm(target, { recursive: true, force: true });
|
|
await fs.symlink(resolvedSource, target);
|
|
return;
|
|
}
|
|
|
|
const linkedPath = await fs.readlink(target).catch(() => null);
|
|
if (!linkedPath) return;
|
|
|
|
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
|
|
if (resolvedLinkedPath === resolvedSource) return;
|
|
|
|
await fs.unlink(target);
|
|
await fs.symlink(resolvedSource, target);
|
|
}
|
|
|
|
async function ensureCopiedFile(target: string, source: string): Promise<void> {
|
|
if (await pathExists(target)) return;
|
|
await ensureParentDir(target);
|
|
await fs.copyFile(source, target);
|
|
}
|
|
|
|
async function prepareManagedCodexHome(input: {
|
|
companyId: string;
|
|
sourceHome: string;
|
|
targetHome: string;
|
|
onLog: AdapterExecutionContext["onLog"];
|
|
}): Promise<string> {
|
|
const { sourceHome, targetHome, onLog } = input;
|
|
if (path.resolve(sourceHome) === path.resolve(targetHome)) return targetHome;
|
|
|
|
await fs.mkdir(targetHome, { recursive: true });
|
|
|
|
const authJson = path.join(sourceHome, "auth.json");
|
|
if (await pathExists(authJson)) await ensureSymlink(path.join(targetHome, "auth.json"), authJson);
|
|
|
|
for (const name of ["config.json", "config.toml", "instructions.md"]) {
|
|
const source = path.join(sourceHome, name);
|
|
if (await pathExists(source)) await ensureCopiedFile(path.join(targetHome, name), source);
|
|
}
|
|
|
|
await onLog(
|
|
"stdout",
|
|
`[paperclip] Using Paperclip-managed ACPX Codex home "${targetHome}" (seeded from "${sourceHome}").\n`,
|
|
);
|
|
return targetHome;
|
|
}
|
|
|
|
async function hashPathContents(
|
|
candidate: string,
|
|
hash: ReturnType<typeof createHash>,
|
|
relativePath: string,
|
|
seenDirectories: Set<string>,
|
|
): Promise<void> {
|
|
const stat = await fs.lstat(candidate);
|
|
|
|
if (stat.isSymbolicLink()) {
|
|
hash.update(`symlink-skipped:${relativePath}\n`);
|
|
return;
|
|
}
|
|
|
|
if (stat.isDirectory()) {
|
|
const realDir = await fs.realpath(candidate).catch(() => candidate);
|
|
hash.update(`dir:${relativePath}\n`);
|
|
if (seenDirectories.has(realDir)) {
|
|
hash.update("loop\n");
|
|
return;
|
|
}
|
|
seenDirectories.add(realDir);
|
|
const entries = await fs.readdir(candidate, { withFileTypes: true });
|
|
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
for (const entry of entries) {
|
|
const childRelativePath = relativePath.length > 0 ? `${relativePath}/${entry.name}` : entry.name;
|
|
await hashPathContents(path.join(candidate, entry.name), hash, childRelativePath, seenDirectories);
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (stat.isFile()) {
|
|
hash.update(`file:${relativePath}\n`);
|
|
hash.update(await fs.readFile(candidate));
|
|
hash.update("\n");
|
|
return;
|
|
}
|
|
|
|
hash.update(`other:${relativePath}:${stat.mode}\n`);
|
|
}
|
|
|
|
async function buildSkillSetKey(input: {
|
|
skills: PaperclipSkillEntry[];
|
|
label: string;
|
|
}): Promise<string> {
|
|
const hash = createHash("sha256");
|
|
hash.update(`paperclip-acpx-${input.label}-skills:v1\n`);
|
|
const sorted = [...input.skills].sort((left, right) => left.runtimeName.localeCompare(right.runtimeName));
|
|
for (const entry of sorted) {
|
|
hash.update(`skill:${entry.key}:${entry.runtimeName}\n`);
|
|
await hashPathContents(entry.source, hash, entry.runtimeName, new Set<string>());
|
|
}
|
|
return hash.digest("hex");
|
|
}
|
|
|
|
async function resolveSelectedRuntimeSkills(
|
|
config: Record<string, unknown>,
|
|
): Promise<{ allSkills: PaperclipSkillEntry[]; selectedSkills: PaperclipSkillEntry[]; desiredSkillNames: string[] }> {
|
|
const allSkills = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
|
const desiredSkillNames = resolvePaperclipDesiredSkillNames(config, allSkills);
|
|
const desiredSet = new Set(desiredSkillNames);
|
|
return {
|
|
allSkills,
|
|
selectedSkills: allSkills.filter((entry) => desiredSet.has(entry.key)),
|
|
desiredSkillNames,
|
|
};
|
|
}
|
|
|
|
async function prepareClaudeSkillRuntime(input: {
|
|
stateDir: string;
|
|
config: Record<string, unknown>;
|
|
onLog: AdapterExecutionContext["onLog"];
|
|
}): Promise<{
|
|
identity: Record<string, unknown>;
|
|
promptInstructions: string;
|
|
commandNotes: string[];
|
|
}> {
|
|
const { selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config);
|
|
const skillSetKey = await buildSkillSetKey({ skills: selectedSkills, label: "claude" });
|
|
const bundleRoot = path.join(input.stateDir, "runtime-skills", "claude", skillSetKey);
|
|
const skillsHome = path.join(bundleRoot, ".claude", "skills");
|
|
await fs.mkdir(skillsHome, { recursive: true });
|
|
|
|
for (const entry of selectedSkills) {
|
|
const target = path.join(skillsHome, entry.runtimeName);
|
|
try {
|
|
const result = await materializePaperclipSkillCopy(entry.source, target);
|
|
if (result.skippedSymlinks.length > 0) {
|
|
await input.onLog(
|
|
"stdout",
|
|
`[paperclip] Materialized ACPX Claude skill "${entry.runtimeName}" into ${skillsHome} and skipped ${result.skippedSymlinks.length} symlink(s).\n`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
await input.onLog(
|
|
"stderr",
|
|
`[paperclip] Failed to materialize ACPX Claude skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const selectedNames = selectedSkills.map((entry) => entry.runtimeName).sort();
|
|
const promptInstructions = selectedSkills.length > 0
|
|
? [
|
|
"Paperclip has materialized selected runtime skills for this ACPX Claude session.",
|
|
`Skill root: ${skillsHome}`,
|
|
selectedNames.length > 0 ? `Selected skills: ${selectedNames.join(", ")}` : "",
|
|
"When a task calls for one of these skills, read its SKILL.md from that root and follow it.",
|
|
].filter(Boolean).join("\n")
|
|
: "";
|
|
|
|
return {
|
|
identity: {
|
|
mode: "claude",
|
|
skillSetKey,
|
|
desiredSkillNames,
|
|
selectedSkills: selectedNames,
|
|
skillRoot: selectedSkills.length > 0 ? skillsHome : null,
|
|
},
|
|
promptInstructions,
|
|
commandNotes: selectedSkills.length > 0
|
|
? [`Materialized ${selectedSkills.length} Paperclip skill(s) for ACPX Claude at ${skillsHome}.`]
|
|
: [],
|
|
};
|
|
}
|
|
|
|
async function readManagedCodexSkillsManifest(skillsHome: string): Promise<Set<string>> {
|
|
const manifestPath = path.join(skillsHome, PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST);
|
|
try {
|
|
const raw = JSON.parse(await fs.readFile(manifestPath, "utf8")) as unknown;
|
|
const parsed = parseObject(raw);
|
|
const skills = Array.isArray(parsed.managedSkillNames)
|
|
? parsed.managedSkillNames.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
: [];
|
|
return new Set(skills);
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
}
|
|
|
|
async function writeManagedCodexSkillsManifest(skillsHome: string, skillNames: Iterable<string>): Promise<void> {
|
|
const managedSkillNames = Array.from(new Set(skillNames)).sort();
|
|
await fs.writeFile(
|
|
path.join(skillsHome, PAPERCLIP_MANAGED_CODEX_SKILLS_MANIFEST),
|
|
`${JSON.stringify({ version: 1, managedSkillNames }, null, 2)}\n`,
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
async function removeSkillTarget(target: string): Promise<boolean> {
|
|
const existing = await fs.lstat(target).catch(() => null);
|
|
if (!existing) return false;
|
|
await fs.rm(target, { recursive: true, force: true });
|
|
return true;
|
|
}
|
|
|
|
async function reconcileManagedCodexSkills(input: {
|
|
skillsHome: string;
|
|
allSkills: PaperclipSkillEntry[];
|
|
selectedSkills: PaperclipSkillEntry[];
|
|
onLog: AdapterExecutionContext["onLog"];
|
|
}): Promise<void> {
|
|
const desired = new Set(input.selectedSkills.map((entry) => entry.runtimeName));
|
|
const managed = await readManagedCodexSkillsManifest(input.skillsHome);
|
|
const availableByRuntimeName = new Map(input.allSkills.map((entry) => [entry.runtimeName, entry]));
|
|
|
|
for (const name of managed) {
|
|
if (desired.has(name)) continue;
|
|
if (await removeSkillTarget(path.join(input.skillsHome, name))) {
|
|
await input.onLog("stdout", `[paperclip] Revoked ACPX Codex skill "${name}" from ${input.skillsHome}\n`);
|
|
}
|
|
}
|
|
|
|
for (const entry of input.allSkills) {
|
|
if (desired.has(entry.runtimeName) || managed.has(entry.runtimeName)) continue;
|
|
const target = path.join(input.skillsHome, entry.runtimeName);
|
|
const existing = await fs.lstat(target).catch(() => null);
|
|
if (!existing?.isSymbolicLink()) continue;
|
|
const linkedPath = await fs.readlink(target).catch(() => null);
|
|
if (!linkedPath) continue;
|
|
const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath);
|
|
if (resolvedLinkedPath !== path.resolve(entry.source)) continue;
|
|
if (await removeSkillTarget(target)) {
|
|
await input.onLog("stdout", `[paperclip] Revoked legacy ACPX Codex skill "${entry.runtimeName}" from ${input.skillsHome}\n`);
|
|
}
|
|
}
|
|
|
|
for (const name of managed) {
|
|
if (desired.has(name) || availableByRuntimeName.has(name)) continue;
|
|
if (await removeSkillTarget(path.join(input.skillsHome, name))) {
|
|
await input.onLog("stdout", `[paperclip] Revoked unavailable ACPX Codex skill "${name}" from ${input.skillsHome}\n`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function prepareCodexSkillRuntime(input: {
|
|
companyId: string;
|
|
config: Record<string, unknown>;
|
|
env: Record<string, string>;
|
|
onLog: AdapterExecutionContext["onLog"];
|
|
}): Promise<{ identity: Record<string, unknown>; commandNotes: string[] }> {
|
|
const envConfig = parseObject(input.config.env);
|
|
const configuredCodexHome =
|
|
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
|
|
? path.resolve(envConfig.CODEX_HOME.trim())
|
|
: null;
|
|
const sourceCodexHome =
|
|
typeof process.env.CODEX_HOME === "string" && process.env.CODEX_HOME.trim().length > 0
|
|
? path.resolve(process.env.CODEX_HOME.trim())
|
|
: path.join(os.homedir(), ".codex");
|
|
const managedCodexHome = resolveManagedCodexHomeDir(input.companyId);
|
|
const effectiveCodexHome = configuredCodexHome ??
|
|
await prepareManagedCodexHome({
|
|
companyId: input.companyId,
|
|
sourceHome: sourceCodexHome,
|
|
targetHome: managedCodexHome,
|
|
onLog: input.onLog,
|
|
});
|
|
const { allSkills, selectedSkills, desiredSkillNames } = await resolveSelectedRuntimeSkills(input.config);
|
|
const skillSetKey = await buildSkillSetKey({ skills: selectedSkills, label: "codex" });
|
|
const skillsHome = path.join(effectiveCodexHome, "skills");
|
|
await fs.mkdir(skillsHome, { recursive: true });
|
|
await reconcileManagedCodexSkills({
|
|
skillsHome,
|
|
allSkills,
|
|
selectedSkills,
|
|
onLog: input.onLog,
|
|
});
|
|
|
|
for (const entry of selectedSkills) {
|
|
const target = path.join(skillsHome, entry.runtimeName);
|
|
try {
|
|
const result = await materializePaperclipSkillCopy(entry.source, target);
|
|
if (result.skippedSymlinks.length > 0) {
|
|
await input.onLog(
|
|
"stdout",
|
|
`[paperclip] Materialized ACPX Codex skill "${entry.runtimeName}" into ${skillsHome} and skipped ${result.skippedSymlinks.length} symlink(s).\n`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
await input.onLog(
|
|
"stderr",
|
|
`[paperclip] Failed to inject ACPX Codex skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
}
|
|
}
|
|
await writeManagedCodexSkillsManifest(skillsHome, selectedSkills.map((entry) => entry.runtimeName));
|
|
|
|
input.env.CODEX_HOME = effectiveCodexHome;
|
|
|
|
return {
|
|
identity: {
|
|
mode: "codex",
|
|
skillSetKey,
|
|
desiredSkillNames,
|
|
selectedSkills: selectedSkills.map((entry) => entry.runtimeName).sort(),
|
|
codexHome: effectiveCodexHome,
|
|
skillsHome,
|
|
},
|
|
commandNotes: [`Prepared ACPX Codex skill home at ${skillsHome}.`],
|
|
};
|
|
}
|
|
|
|
function normalizeMode(config: Record<string, unknown>): "persistent" | "oneshot" {
|
|
return asString(config.mode, DEFAULT_ACPX_LOCAL_MODE) === "oneshot" ? "oneshot" : "persistent";
|
|
}
|
|
|
|
function normalizePermissionMode(config: Record<string, unknown>): "approve-all" | "approve-reads" | "deny-all" {
|
|
const value = asString(config.permissionMode, DEFAULT_ACPX_LOCAL_PERMISSION_MODE).trim();
|
|
if (value === "approve-reads" || value === "deny-all") return value;
|
|
if (value === "default") return "approve-reads";
|
|
return "approve-all";
|
|
}
|
|
|
|
function normalizeNonInteractivePermissions(config: Record<string, unknown>): "deny" | "fail" {
|
|
return asString(config.nonInteractivePermissions, DEFAULT_ACPX_LOCAL_NON_INTERACTIVE_PERMISSIONS) === "fail"
|
|
? "fail"
|
|
: "deny";
|
|
}
|
|
|
|
function normalizeRequestedThinkingEffort(config: Record<string, unknown>): string {
|
|
return (
|
|
asString(config.modelReasoningEffort, "") ||
|
|
asString(config.reasoningEffort, "") ||
|
|
asString(config.thinkingEffort, "") ||
|
|
asString(config.effort, "")
|
|
).trim();
|
|
}
|
|
|
|
function isCompatibleSession(
|
|
params: Record<string, unknown>,
|
|
runtime: Pick<AcpxPreparedRuntime, "fingerprint" | "sessionKey" | "cwd" | "mode" | "acpxAgent" | "remoteExecutionIdentity">,
|
|
): boolean {
|
|
if (asString(params.configFingerprint, "") !== runtime.fingerprint) return false;
|
|
if (asString(params.sessionKey, "") !== runtime.sessionKey) return false;
|
|
if (asString(params.agent, "") !== runtime.acpxAgent) return false;
|
|
if (asString(params.mode, "") !== runtime.mode) return false;
|
|
const savedCwd = asString(params.cwd, "");
|
|
if (!savedCwd || path.resolve(savedCwd) !== path.resolve(runtime.cwd)) return false;
|
|
const savedRemote = parseObject(params.remoteExecution);
|
|
return stableJson(savedRemote) === stableJson(runtime.remoteExecutionIdentity ?? {});
|
|
}
|
|
|
|
function buildSessionParams(input: {
|
|
prepared: AcpxPreparedRuntime;
|
|
handle: AcpRuntimeHandle;
|
|
}): Record<string, unknown> {
|
|
const { prepared, handle } = input;
|
|
return {
|
|
sessionKey: prepared.sessionKey,
|
|
runtimeSessionName: handle.runtimeSessionName,
|
|
acpxRecordId: handle.acpxRecordId,
|
|
acpSessionId: handle.backendSessionId,
|
|
agentSessionId: handle.agentSessionId,
|
|
agent: prepared.acpxAgent,
|
|
cwd: prepared.cwd,
|
|
mode: prepared.mode,
|
|
stateDir: prepared.stateDir,
|
|
configFingerprint: prepared.fingerprint,
|
|
...(prepared.requestedModel ? { model: prepared.requestedModel } : {}),
|
|
...(prepared.requestedThinkingEffort ? { thinkingEffort: prepared.requestedThinkingEffort } : {}),
|
|
...(prepared.fastMode ? { fastMode: true } : {}),
|
|
skills: prepared.skillsIdentity,
|
|
...(prepared.workspaceId ? { workspaceId: prepared.workspaceId } : {}),
|
|
...(prepared.workspaceRepoUrl ? { repoUrl: prepared.workspaceRepoUrl } : {}),
|
|
...(prepared.workspaceRepoRef ? { repoRef: prepared.workspaceRepoRef } : {}),
|
|
...(prepared.remoteExecutionIdentity ? { remoteExecution: prepared.remoteExecutionIdentity } : {}),
|
|
};
|
|
}
|
|
|
|
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 });
|
|
const envLines = Object.entries(input.env)
|
|
.filter(([key]) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, value]) => `${key}=${shellQuote(value)}`);
|
|
const wrapperHash = shortHash({
|
|
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`);
|
|
const script = [
|
|
"#!/usr/bin/env bash",
|
|
"set -euo pipefail",
|
|
`env_file=${shellQuote(envFilePath)}`,
|
|
"if [[ -f \"$env_file\" ]]; then",
|
|
" set -a",
|
|
" 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");
|
|
await writeFileAtomically({
|
|
target: envFilePath,
|
|
contents: `${envLines.join("\n")}\n`,
|
|
mode: 0o600,
|
|
});
|
|
await writeFileAtomically({
|
|
target: wrapperPath,
|
|
contents: script,
|
|
mode: 0o700,
|
|
});
|
|
await cleanupStaleAgentWrappers({
|
|
wrappersDir,
|
|
currentFileNames: new Set([path.basename(wrapperPath), path.basename(envFilePath)]),
|
|
});
|
|
return { wrapperPath, envFilePath };
|
|
}
|
|
|
|
async function cleanupStaleAgentWrappers(input: { wrappersDir: string; currentFileNames: Set<string> }) {
|
|
const wrappers = await fs.readdir(input.wrappersDir).catch(() => []);
|
|
const now = Date.now();
|
|
await Promise.all(
|
|
wrappers.map(async (name) => {
|
|
const isManagedWrapperFile = name.endsWith(".sh") || name.endsWith(".env");
|
|
if (!isManagedWrapperFile || input.currentFileNames.has(name)) return;
|
|
const wrapperPath = path.join(input.wrappersDir, name);
|
|
const stats = await fs.stat(wrapperPath).catch(() => null);
|
|
if (!stats || now - stats.mtimeMs < WRAPPER_CLEANUP_RETENTION_MS) return;
|
|
await fs.rm(wrapperPath, { force: true });
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function buildRuntime(input: {
|
|
ctx: AdapterExecutionContext;
|
|
}): Promise<AcpxPreparedRuntime> {
|
|
const { runId, agent, config, context, authToken } = input.ctx;
|
|
const workspaceContext = parseObject(context.paperclipWorkspace);
|
|
const workspaceCwd = asString(workspaceContext.cwd, "");
|
|
const workspaceSource = asString(workspaceContext.source, "");
|
|
const workspaceStrategy = asString(workspaceContext.strategy, "");
|
|
const workspaceId = asString(workspaceContext.workspaceId, "");
|
|
const workspaceRepoUrl = asString(workspaceContext.repoUrl, "");
|
|
const workspaceRepoRef = asString(workspaceContext.repoRef, "");
|
|
const workspaceBranch = asString(workspaceContext.branchName, "");
|
|
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
|
|
const agentHome = asString(workspaceContext.agentHome, "");
|
|
const configuredCwd = asString(config.cwd, "");
|
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
|
const executionTarget = readAdapterExecutionTarget({
|
|
executionTarget: input.ctx.executionTarget,
|
|
legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution,
|
|
});
|
|
const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget);
|
|
const effectiveExecutionCwd =
|
|
remoteExecutionIdentity && typeof remoteExecutionIdentity.remoteCwd === "string"
|
|
? remoteExecutionIdentity.remoteCwd
|
|
: cwd;
|
|
const executionTargetIsRemote = remoteExecutionIdentity !== null;
|
|
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
|
|
workspaceCwd: effectiveWorkspaceCwd,
|
|
workspaceWorktreePath,
|
|
executionTargetIsRemote,
|
|
executionCwd: effectiveExecutionCwd,
|
|
});
|
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
|
|
|
const acpxAgent = normalizeAgent(config);
|
|
const mode = normalizeMode(config);
|
|
const permissionMode = normalizePermissionMode(config);
|
|
const nonInteractivePermissions = normalizeNonInteractivePermissions(config);
|
|
const requestedModel = asString(config.model, "").trim();
|
|
const requestedThinkingEffort = normalizeRequestedThinkingEffort(config);
|
|
const fastMode = acpxAgent === "codex" && config.fastMode === true;
|
|
const timeoutSec = asNumber(config.timeoutSec, DEFAULT_ACPX_LOCAL_TIMEOUT_SEC);
|
|
const stateDir = path.resolve(asString(config.stateDir, "") || defaultStateDir(agent.companyId, agent.id));
|
|
await fs.mkdir(stateDir, { recursive: true });
|
|
|
|
const envConfig = parseObject(config.env);
|
|
const hasExplicitApiKey =
|
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent), PAPERCLIP_RUN_ID: runId };
|
|
const wakeTaskId =
|
|
(typeof context.taskId === "string" && context.taskId.trim()) ||
|
|
(typeof context.issueId === "string" && context.issueId.trim()) ||
|
|
"";
|
|
const wakeReason = typeof context.wakeReason === "string" ? context.wakeReason.trim() : "";
|
|
const wakeCommentId =
|
|
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim()) ||
|
|
(typeof context.commentId === "string" && context.commentId.trim()) ||
|
|
"";
|
|
const approvalId = typeof context.approvalId === "string" ? context.approvalId.trim() : "";
|
|
const approvalStatus = typeof context.approvalStatus === "string" ? context.approvalStatus.trim() : "";
|
|
const linkedIssueIds = Array.isArray(context.issueIds)
|
|
? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0)
|
|
: [];
|
|
const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake);
|
|
const issueWorkMode = readPaperclipIssueWorkModeFromContext(context);
|
|
if (wakeTaskId) env.PAPERCLIP_TASK_ID = wakeTaskId;
|
|
if (issueWorkMode) env.PAPERCLIP_ISSUE_WORK_MODE = issueWorkMode;
|
|
if (wakeReason) env.PAPERCLIP_WAKE_REASON = wakeReason;
|
|
if (wakeCommentId) env.PAPERCLIP_WAKE_COMMENT_ID = wakeCommentId;
|
|
if (approvalId) env.PAPERCLIP_APPROVAL_ID = approvalId;
|
|
if (approvalStatus) env.PAPERCLIP_APPROVAL_STATUS = approvalStatus;
|
|
if (linkedIssueIds.length > 0) env.PAPERCLIP_LINKED_ISSUE_IDS = linkedIssueIds.join(",");
|
|
if (wakePayloadJson) env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson;
|
|
applyPaperclipWorkspaceEnv(env, {
|
|
workspaceCwd: shapedWorkspaceEnv.workspaceCwd,
|
|
workspaceSource,
|
|
workspaceStrategy,
|
|
workspaceId,
|
|
workspaceRepoUrl,
|
|
workspaceRepoRef,
|
|
workspaceBranch,
|
|
workspaceWorktreePath: shapedWorkspaceEnv.workspaceWorktreePath,
|
|
agentHome,
|
|
});
|
|
const shapedEnvConfig = rewriteWorkspaceCwdEnvVarsForExecution({
|
|
env: envConfig,
|
|
workspaceCwd: effectiveWorkspaceCwd,
|
|
executionCwd: shapedWorkspaceEnv.workspaceCwd,
|
|
executionTargetIsRemote,
|
|
});
|
|
for (const [key, value] of Object.entries(shapedEnvConfig)) {
|
|
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,
|
|
config,
|
|
onLog: input.ctx.onLog,
|
|
});
|
|
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,
|
|
config,
|
|
env,
|
|
onLog: input.ctx.onLog,
|
|
});
|
|
skillsIdentity = preparedSkills.identity;
|
|
skillCommandNotes.push(...preparedSkills.commandNotes);
|
|
} else {
|
|
const desired = resolvePaperclipDesiredSkillNames(config, await readPaperclipRuntimeSkillEntries(config, __moduleDir));
|
|
skillsIdentity = { mode: "custom_unsupported", desiredSkillNames: desired };
|
|
if (desired.length > 0) {
|
|
skillCommandNotes.push("Selected Paperclip skills are tracked only; ACPX custom commands do not expose a runtime skill contract yet.");
|
|
}
|
|
}
|
|
|
|
const configuredCommand = asString(config.agentCommand, "").trim();
|
|
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;
|
|
const overrides = wrapperPath ? { [acpxAgent]: wrapperPath } : undefined;
|
|
const agentRegistry = createAgentRegistry({ overrides });
|
|
const fingerprint = shortHash({
|
|
acpxAgent,
|
|
agentCommand: agentCommand ?? acpxAgent,
|
|
cwd: path.resolve(cwd),
|
|
mode,
|
|
permissionMode,
|
|
nonInteractivePermissions,
|
|
requestedModel,
|
|
requestedThinkingEffort,
|
|
fastMode,
|
|
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}`;
|
|
const runtimeEnv = ensurePathInEnv({ ...process.env, ...env });
|
|
const loggedEnv = buildInvocationEnvForLogs(env, {
|
|
runtimeEnv,
|
|
includeRuntimeKeys: ["HOME"],
|
|
resolvedCommand: wrapperPath ?? agentCommand ?? acpxAgent,
|
|
});
|
|
|
|
return {
|
|
acpxAgent,
|
|
mode,
|
|
cwd,
|
|
workspaceId,
|
|
workspaceRepoUrl,
|
|
workspaceRepoRef,
|
|
env,
|
|
loggedEnv,
|
|
stateDir,
|
|
permissionMode,
|
|
nonInteractivePermissions,
|
|
requestedModel,
|
|
requestedThinkingEffort,
|
|
fastMode,
|
|
timeoutSec,
|
|
sessionKey,
|
|
fingerprint,
|
|
agentCommand,
|
|
agentRegistry,
|
|
remoteExecutionIdentity,
|
|
skillPromptInstructions,
|
|
skillsIdentity: {
|
|
...skillsIdentity,
|
|
commandNotes: skillCommandNotes,
|
|
},
|
|
childStderrLogPath,
|
|
paperclipClaudeSettings,
|
|
};
|
|
}
|
|
|
|
function sessionConfigOptions(prepared: AcpxPreparedRuntime): Array<{ key: string; value: string }> {
|
|
const options: Array<{ key: string; value: string }> = [];
|
|
// 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",
|
|
value: prepared.requestedThinkingEffort,
|
|
});
|
|
}
|
|
if (prepared.fastMode) {
|
|
options.push(
|
|
{ key: "service_tier", value: "fast" },
|
|
{ key: "features.fast_mode", value: "true" },
|
|
);
|
|
}
|
|
return options;
|
|
}
|
|
|
|
async function applySessionConfigOptions(input: {
|
|
runtime: AcpRuntime;
|
|
handle: AcpRuntimeHandle;
|
|
prepared: AcpxPreparedRuntime;
|
|
onLog: AdapterExecutionContext["onLog"];
|
|
}) {
|
|
const options = sessionConfigOptions(input.prepared);
|
|
if (options.length === 0) return;
|
|
if (!input.runtime.setConfigOption) {
|
|
const message =
|
|
"ACPX runtime does not expose session config controls; upgrade ACPX or remove configured model, effort, and fast mode overrides.";
|
|
await input.onLog("stderr", `[paperclip] ${message}\n`);
|
|
throw new Error(message);
|
|
}
|
|
for (const option of options) {
|
|
await input.runtime.setConfigOption({
|
|
handle: input.handle,
|
|
key: option.key,
|
|
value: option.value,
|
|
});
|
|
await input.onLog(
|
|
"stdout",
|
|
`[paperclip] Applied ACPX ${input.prepared.acpxAgent} config ${option.key}=${option.value}\n`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async function buildPrompt(ctx: AdapterExecutionContext, resumedSession: boolean): Promise<{
|
|
prompt: string;
|
|
promptMetrics: Record<string, number>;
|
|
commandNotes: string[];
|
|
}> {
|
|
const { agent, runId, config, context, onLog } = ctx;
|
|
const promptTemplate = asString(config.promptTemplate, DEFAULT_PAPERCLIP_AGENT_PROMPT_TEMPLATE);
|
|
const instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
|
let instructionsPrefix = "";
|
|
const commandNotes: string[] = [];
|
|
if (instructionsFilePath) {
|
|
try {
|
|
const instructionsContents = await fs.readFile(instructionsFilePath, "utf8");
|
|
instructionsPrefix =
|
|
`${instructionsContents}\n\n` +
|
|
`The above agent instructions were loaded from ${instructionsFilePath}. ` +
|
|
`Resolve any relative file references from ${instructionsDir}.\n\n`;
|
|
commandNotes.push(
|
|
`Loaded agent instructions from ${instructionsFilePath}`,
|
|
`Prepended instructions + path directive to the ACPX prompt (relative references from ${instructionsDir}).`,
|
|
);
|
|
} catch (err) {
|
|
const reason = err instanceof Error ? err.message : String(err);
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`,
|
|
);
|
|
commandNotes.push(`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read.`);
|
|
}
|
|
}
|
|
|
|
const bootstrapPromptTemplate = asString(config.bootstrapPromptTemplate, "");
|
|
const templateData = {
|
|
agentId: agent.id,
|
|
companyId: agent.companyId,
|
|
runId,
|
|
company: { id: agent.companyId },
|
|
agent,
|
|
run: { id: runId, source: "on_demand" },
|
|
context,
|
|
};
|
|
const renderedBootstrapPrompt =
|
|
!resumedSession && bootstrapPromptTemplate.trim().length > 0
|
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
|
: "";
|
|
const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession });
|
|
const shouldUseResumeDeltaPrompt = resumedSession && wakePrompt.length > 0;
|
|
const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix;
|
|
const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData);
|
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
|
const taskContextNote = asString(context.paperclipTaskMarkdown, "").trim();
|
|
const prompt = joinPromptSections([
|
|
promptInstructionsPrefix,
|
|
renderedBootstrapPrompt,
|
|
wakePrompt,
|
|
sessionHandoffNote,
|
|
taskContextNote,
|
|
renderedPrompt,
|
|
]);
|
|
|
|
return {
|
|
prompt,
|
|
commandNotes,
|
|
promptMetrics: {
|
|
promptChars: prompt.length,
|
|
instructionsChars: promptInstructionsPrefix.length,
|
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
|
wakePromptChars: wakePrompt.length,
|
|
sessionHandoffChars: sessionHandoffNote.length,
|
|
taskContextChars: taskContextNote.length,
|
|
heartbeatPromptChars: renderedPrompt.length,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function emitAcpxLog(ctx: AdapterExecutionContext, payload: Record<string, unknown>) {
|
|
await ctx.onLog("stdout", `${JSON.stringify(payload)}\n`);
|
|
}
|
|
|
|
async function emitRuntimeEvent(ctx: AdapterExecutionContext, event: AcpRuntimeEvent) {
|
|
if (event.type === "text_delta") {
|
|
await emitAcpxLog(ctx, {
|
|
type: "acpx.text_delta",
|
|
text: event.text,
|
|
channel: event.stream === "thought" ? "thought" : "output",
|
|
tag: event.tag,
|
|
});
|
|
return;
|
|
}
|
|
if (event.type === "tool_call") {
|
|
await emitAcpxLog(ctx, {
|
|
type: "acpx.tool_call",
|
|
name: event.title ?? "acp_tool",
|
|
toolCallId: event.toolCallId,
|
|
status: event.status,
|
|
text: event.text,
|
|
tag: event.tag,
|
|
});
|
|
return;
|
|
}
|
|
if (event.type === "status") {
|
|
await emitAcpxLog(ctx, {
|
|
type: "acpx.status",
|
|
text: event.text,
|
|
tag: event.tag,
|
|
used: event.used,
|
|
size: event.size,
|
|
});
|
|
return;
|
|
}
|
|
if (event.type === "done") {
|
|
await emitAcpxLog(ctx, {
|
|
type: "acpx.result",
|
|
summary: event.stopReason ?? "completed",
|
|
stopReason: event.stopReason,
|
|
});
|
|
return;
|
|
}
|
|
if (event.type === "error") {
|
|
await emitAcpxLog(ctx, {
|
|
type: "acpx.error",
|
|
message: event.message,
|
|
code: event.code,
|
|
retryable: event.retryable,
|
|
});
|
|
}
|
|
}
|
|
|
|
function resultErrorMessage(result: AcpRuntimeTurnResult): string | null {
|
|
if (result.status !== "failed") return null;
|
|
return result.error.message;
|
|
}
|
|
|
|
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 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", ...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", ...baseMeta },
|
|
};
|
|
}
|
|
return {
|
|
errorCode: "acpx_runtime_error",
|
|
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);
|
|
}
|
|
|
|
async function cleanupIdleHandles(input: {
|
|
handles: Map<string, RuntimeCacheEntry>;
|
|
now: number;
|
|
idleMs: number;
|
|
}) {
|
|
if (input.idleMs <= 0) return;
|
|
|
|
const stale: Array<[string, RuntimeCacheEntry]> = [];
|
|
for (const entry of input.handles.entries()) {
|
|
if (input.now - entry[1].lastUsedAt >= input.idleMs) stale.push(entry);
|
|
}
|
|
for (const [key, entry] of stale) {
|
|
await closeWarmHandle({
|
|
handles: input.handles,
|
|
key,
|
|
entry,
|
|
reason: "paperclip idle cleanup",
|
|
});
|
|
}
|
|
}
|
|
|
|
function clearWarmHandleTimer(entry: RuntimeCacheEntry) {
|
|
if (!entry.cleanupTimer) return;
|
|
clearTimeout(entry.cleanupTimer);
|
|
entry.cleanupTimer = undefined;
|
|
}
|
|
|
|
async function closeWarmHandle(input: {
|
|
handles: Map<string, RuntimeCacheEntry>;
|
|
key: string;
|
|
entry: RuntimeCacheEntry;
|
|
reason: string;
|
|
discardPersistentState?: boolean;
|
|
}) {
|
|
if (input.handles.get(input.key) === input.entry) {
|
|
input.handles.delete(input.key);
|
|
}
|
|
clearWarmHandleTimer(input.entry);
|
|
await input.entry.runtime.close({
|
|
handle: input.entry.handle,
|
|
reason: input.reason,
|
|
discardPersistentState: input.discardPersistentState ?? false,
|
|
}).catch(() => {});
|
|
}
|
|
|
|
function scheduleIdleHandleCleanup(input: {
|
|
handles: Map<string, RuntimeCacheEntry>;
|
|
key: string;
|
|
entry: RuntimeCacheEntry;
|
|
idleMs: number;
|
|
now: () => number;
|
|
}) {
|
|
clearWarmHandleTimer(input.entry);
|
|
if (input.idleMs <= 0) return;
|
|
|
|
const delayMs = Math.max(1, input.entry.lastUsedAt + input.idleMs - input.now());
|
|
input.entry.cleanupTimer = setTimeout(() => {
|
|
void (async () => {
|
|
const current = input.handles.get(input.key);
|
|
if (current !== input.entry) return;
|
|
const idleForMs = input.now() - input.entry.lastUsedAt;
|
|
if (idleForMs < input.idleMs) {
|
|
scheduleIdleHandleCleanup(input);
|
|
return;
|
|
}
|
|
await closeWarmHandle({
|
|
handles: input.handles,
|
|
key: input.key,
|
|
entry: input.entry,
|
|
reason: "paperclip idle cleanup",
|
|
});
|
|
})();
|
|
}, delayMs);
|
|
input.entry.cleanupTimer.unref?.();
|
|
}
|
|
|
|
function warmHandleMatches(
|
|
entry: RuntimeCacheEntry | undefined,
|
|
runtime: AcpRuntime,
|
|
handle: AcpRuntimeHandle,
|
|
): boolean {
|
|
return entry?.runtime === runtime && entry.handle === handle;
|
|
}
|
|
|
|
export function createAcpxLocalExecutor(deps: ExecuteDeps = {}) {
|
|
const createRuntime = deps.createRuntime ?? createAcpRuntime;
|
|
const now = deps.now ?? (() => Date.now());
|
|
const warmHandles = deps.warmHandles ?? defaultWarmHandles;
|
|
|
|
return async function executeAcpxLocal(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> {
|
|
const prepared = await buildRuntime({ ctx });
|
|
const warmIdleMs = asNumber(ctx.config.warmHandleIdleMs, DEFAULT_ACPX_LOCAL_WARM_HANDLE_IDLE_MS);
|
|
await cleanupIdleHandles({ handles: warmHandles, now: now(), idleMs: warmIdleMs });
|
|
|
|
const previousParams = parseObject(ctx.runtime.sessionParams);
|
|
const canResume = isCompatibleSession(previousParams, prepared);
|
|
const resumeSessionId = canResume ? asString(previousParams.acpSessionId, "") || undefined : undefined;
|
|
const cached = canResume ? warmHandles.get(prepared.sessionKey) : undefined;
|
|
const runtimeOptions: AcpRuntimeOptions = {
|
|
cwd: prepared.cwd,
|
|
sessionStore: createRuntimeStore({ stateDir: prepared.stateDir }),
|
|
agentRegistry: prepared.agentRegistry,
|
|
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);
|
|
if (!canResume && asString(previousParams.runtimeSessionName, "")) {
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[paperclip] ACPX session "${asString(previousParams.runtimeSessionName, "")}" does not match the current agent/cwd/mode/runtime identity; starting fresh in "${prepared.cwd}".\n`,
|
|
);
|
|
}
|
|
|
|
let handle = cached?.handle ?? null;
|
|
let resumedSession = Boolean(handle ?? resumeSessionId);
|
|
let clearSession = false;
|
|
|
|
try {
|
|
if (!handle) {
|
|
try {
|
|
handle = await runtime.ensureSession({
|
|
sessionKey: prepared.sessionKey,
|
|
agent: prepared.acpxAgent,
|
|
mode: prepared.mode,
|
|
cwd: prepared.cwd,
|
|
resumeSessionId,
|
|
});
|
|
} catch (err) {
|
|
if (!resumeSessionId || !isResumeFailure(err)) throw err;
|
|
clearSession = true;
|
|
resumedSession = false;
|
|
await ctx.onLog(
|
|
"stdout",
|
|
`[paperclip] ACPX resume session "${resumeSessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
);
|
|
handle = await runtime.ensureSession({
|
|
sessionKey: prepared.sessionKey,
|
|
agent: prepared.acpxAgent,
|
|
mode: prepared.mode,
|
|
cwd: prepared.cwd,
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
const { classified, message } = await emitAcpxFailure({
|
|
ctx,
|
|
prepared,
|
|
err,
|
|
phase: "ensure_session",
|
|
});
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage: message,
|
|
...classified,
|
|
provider: "acpx",
|
|
model: prepared.requestedModel || null,
|
|
clearSession,
|
|
resultJson: { phase: "ensure_session" },
|
|
summary: message,
|
|
};
|
|
}
|
|
|
|
if (!handle) {
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage: "ACPX did not return a runtime session handle.",
|
|
errorCode: "acpx_runtime_error",
|
|
provider: "acpx",
|
|
model: prepared.requestedModel || null,
|
|
resultJson: { phase: "ensure_session" },
|
|
summary: "ACPX did not return a runtime session handle.",
|
|
};
|
|
}
|
|
const sessionHandle = handle;
|
|
try {
|
|
await applySessionConfigOptions({
|
|
runtime,
|
|
handle: sessionHandle,
|
|
prepared,
|
|
onLog: ctx.onLog,
|
|
});
|
|
} catch (err) {
|
|
const { classified, message } = await emitAcpxFailure({
|
|
ctx,
|
|
prepared,
|
|
err,
|
|
phase: "configure_session",
|
|
});
|
|
await runtime.close({
|
|
handle: sessionHandle,
|
|
reason: "paperclip config cleanup",
|
|
discardPersistentState: false,
|
|
}).catch(() => {});
|
|
const existing = warmHandles.get(prepared.sessionKey);
|
|
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
|
|
clearWarmHandleTimer(existing);
|
|
warmHandles.delete(prepared.sessionKey);
|
|
}
|
|
return {
|
|
exitCode: 1,
|
|
signal: null,
|
|
timedOut: false,
|
|
errorMessage: message,
|
|
...classified,
|
|
provider: "acpx",
|
|
model: prepared.requestedModel || null,
|
|
clearSession,
|
|
resultJson: {
|
|
phase: "configure_session",
|
|
agent: prepared.acpxAgent,
|
|
requestedModel: prepared.requestedModel || null,
|
|
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
|
|
fastMode: prepared.fastMode,
|
|
},
|
|
summary: message,
|
|
};
|
|
}
|
|
const { prompt, promptMetrics, commandNotes } = await buildPrompt(ctx, resumedSession);
|
|
const runPrompt = joinPromptSections([prepared.skillPromptInstructions, prompt]);
|
|
await emitAcpxLog(ctx, {
|
|
type: "acpx.session",
|
|
agent: prepared.acpxAgent,
|
|
sessionId: sessionHandle.backendSessionId,
|
|
acpSessionId: sessionHandle.backendSessionId,
|
|
agentSessionId: sessionHandle.agentSessionId,
|
|
runtimeSessionName: sessionHandle.runtimeSessionName,
|
|
mode: prepared.mode,
|
|
permissionMode: prepared.permissionMode,
|
|
model: prepared.requestedModel || null,
|
|
thinkingEffort: prepared.requestedThinkingEffort || null,
|
|
fastMode: prepared.fastMode,
|
|
});
|
|
if (ctx.onMeta) {
|
|
await ctx.onMeta({
|
|
adapterType: "acpx_local",
|
|
command: prepared.agentCommand ?? prepared.acpxAgent,
|
|
cwd: prepared.cwd,
|
|
commandNotes: [
|
|
`ACPX runtime embedded in Paperclip with ${prepared.mode} session mode.`,
|
|
`Effective ACPX permission mode: ${prepared.permissionMode}.`,
|
|
...(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)
|
|
? prepared.skillsIdentity.commandNotes.filter((note): note is string => typeof note === "string")
|
|
: []),
|
|
...commandNotes,
|
|
],
|
|
env: prepared.loggedEnv,
|
|
prompt: runPrompt,
|
|
promptMetrics,
|
|
context: ctx.context,
|
|
});
|
|
}
|
|
|
|
let cancelActiveTurn: ((reason: string) => Promise<void>) | null = null;
|
|
let controller: AbortController | null = null;
|
|
let timeout: NodeJS.Timeout | null = null;
|
|
let timedOut = false;
|
|
const textParts: string[] = [];
|
|
try {
|
|
const timeoutMs = prepared.timeoutSec > 0 ? prepared.timeoutSec * 1000 : undefined;
|
|
controller = new AbortController();
|
|
if (timeoutMs) {
|
|
timeout = setTimeout(() => {
|
|
timedOut = true;
|
|
controller?.abort();
|
|
void cancelActiveTurn?.(`Timed out after ${prepared.timeoutSec}s`).catch(() => {});
|
|
}, timeoutMs);
|
|
}
|
|
const turn = runtime.startTurn({
|
|
handle: sessionHandle,
|
|
text: runPrompt,
|
|
mode: "prompt",
|
|
requestId: ctx.runId,
|
|
timeoutMs,
|
|
signal: controller?.signal,
|
|
});
|
|
cancelActiveTurn = async (reason: string) => {
|
|
await turn.cancel({ reason });
|
|
};
|
|
for await (const event of turn.events) {
|
|
if (event.type === "text_delta") textParts.push(event.text);
|
|
await emitRuntimeEvent(ctx, event);
|
|
}
|
|
const terminal = await turn.result;
|
|
if (timeout) clearTimeout(timeout);
|
|
if (terminal.status === "failed" || terminal.status === "cancelled" || timedOut) {
|
|
const existing = warmHandles.get(prepared.sessionKey);
|
|
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
|
|
await closeWarmHandle({
|
|
handles: warmHandles,
|
|
key: prepared.sessionKey,
|
|
entry: existing,
|
|
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
|
|
discardPersistentState: terminal.status === "cancelled" || timedOut,
|
|
});
|
|
} else {
|
|
await runtime.close({
|
|
handle: sessionHandle,
|
|
reason: timedOut ? "paperclip timeout cleanup" : `paperclip turn ${terminal.status}`,
|
|
discardPersistentState: terminal.status === "cancelled" || timedOut,
|
|
}).catch(() => {});
|
|
}
|
|
} else if (prepared.mode === "persistent" && warmIdleMs > 0) {
|
|
const existing = warmHandles.get(prepared.sessionKey);
|
|
if (existing && !warmHandleMatches(existing, runtime, sessionHandle)) {
|
|
await runtime.close({
|
|
handle: sessionHandle,
|
|
reason: "paperclip duplicate warm handle cleanup",
|
|
discardPersistentState: false,
|
|
}).catch(() => {});
|
|
} else {
|
|
const entry: RuntimeCacheEntry = {
|
|
runtime,
|
|
handle: sessionHandle,
|
|
fingerprint: prepared.fingerprint,
|
|
lastUsedAt: now(),
|
|
};
|
|
warmHandles.set(prepared.sessionKey, entry);
|
|
scheduleIdleHandleCleanup({
|
|
handles: warmHandles,
|
|
key: prepared.sessionKey,
|
|
entry,
|
|
idleMs: warmIdleMs,
|
|
now,
|
|
});
|
|
}
|
|
} else {
|
|
const existing = warmHandles.get(prepared.sessionKey);
|
|
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
|
|
await closeWarmHandle({
|
|
handles: warmHandles,
|
|
key: prepared.sessionKey,
|
|
entry: existing,
|
|
reason: "paperclip completed turn cleanup",
|
|
});
|
|
} else {
|
|
await runtime.close({
|
|
handle: sessionHandle,
|
|
reason: "paperclip completed turn cleanup",
|
|
discardPersistentState: false,
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
const errorMessage = timedOut
|
|
? `Timed out after ${prepared.timeoutSec}s`
|
|
: resultErrorMessage(terminal);
|
|
const terminalStopReason = terminal.status === "failed" ? terminal.error.message : terminal.stopReason;
|
|
await emitAcpxLog(ctx, {
|
|
type: terminal.status === "completed" ? "acpx.result" : "acpx.error",
|
|
summary: terminal.status,
|
|
stopReason: terminalStopReason,
|
|
message: errorMessage,
|
|
});
|
|
return {
|
|
exitCode: terminal.status === "completed" ? 0 : 1,
|
|
signal: timedOut ? "SIGTERM" : null,
|
|
timedOut,
|
|
errorMessage,
|
|
errorCode: terminal.status === "failed" ? "acpx_turn_failed" : timedOut ? "acpx_timeout" : null,
|
|
sessionId: sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
|
|
sessionParams: buildSessionParams({ prepared, handle: sessionHandle }),
|
|
sessionDisplayId: sessionHandle.agentSessionId ?? sessionHandle.backendSessionId ?? sessionHandle.runtimeSessionName,
|
|
provider: "acpx",
|
|
model: prepared.requestedModel || null,
|
|
billingType: "unknown",
|
|
costUsd: null,
|
|
resultJson: {
|
|
status: terminal.status,
|
|
stopReason: terminalStopReason,
|
|
permissionMode: prepared.permissionMode,
|
|
mode: prepared.mode,
|
|
requestedModel: prepared.requestedModel || null,
|
|
requestedThinkingEffort: prepared.requestedThinkingEffort || null,
|
|
fastMode: prepared.fastMode,
|
|
},
|
|
summary: textParts.join("").trim() || terminalStopReason || terminal.status,
|
|
clearSession,
|
|
};
|
|
} catch (err) {
|
|
if (timeout) clearTimeout(timeout);
|
|
const messageOverride = timedOut ? `Timed out after ${prepared.timeoutSec}s` : undefined;
|
|
const cancel = cancelActiveTurn as ((reason: string) => Promise<void>) | null;
|
|
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",
|
|
discardPersistentState: timedOut,
|
|
}).catch(() => {});
|
|
const existing = warmHandles.get(prepared.sessionKey);
|
|
if (warmHandleMatches(existing, runtime, sessionHandle) && existing) {
|
|
clearWarmHandleTimer(existing);
|
|
warmHandles.delete(prepared.sessionKey);
|
|
}
|
|
const { classified, message } = await emitAcpxFailure({
|
|
ctx,
|
|
prepared,
|
|
err,
|
|
phase: "turn",
|
|
messageOverride,
|
|
});
|
|
return {
|
|
exitCode: 1,
|
|
signal: timedOut ? "SIGTERM" : null,
|
|
timedOut,
|
|
errorMessage: message,
|
|
errorCode: timedOut ? "acpx_timeout" : classified.errorCode,
|
|
errorMeta: classified.errorMeta,
|
|
provider: "acpx",
|
|
model: prepared.requestedModel || null,
|
|
clearSession: clearSession || timedOut,
|
|
resultJson: { phase: "turn" },
|
|
summary: message,
|
|
};
|
|
}
|
|
};
|
|
}
|
|
|
|
export const execute = createAcpxLocalExecutor();
|