import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils"; import type { RunProcessResult } from "@paperclip/adapter-utils/server-utils"; import { asString, asNumber, asBoolean, asStringArray, parseObject, parseJson, buildPaperclipEnv, redactEnvForLogs, ensureAbsoluteDirectory, ensureCommandResolvable, ensurePathInEnv, renderTemplate, runChildProcess, } from "@paperclip/adapter-utils/server-utils"; import { parseClaudeStreamJson, describeClaudeFailure, isClaudeUnknownSessionError } from "./parse.js"; const PAPERCLIP_SKILLS_DIR = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../../../../skills", ); /** * Create a tmpdir with `.claude/skills/` containing symlinks to skills from * the repo's `skills/` directory, so `--add-dir` makes Claude Code discover * them as proper registered skills. */ async function buildSkillsDir(): Promise { const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-skills-")); const target = path.join(tmp, ".claude", "skills"); await fs.mkdir(target, { recursive: true }); const entries = await fs.readdir(PAPERCLIP_SKILLS_DIR, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { await fs.symlink( path.join(PAPERCLIP_SKILLS_DIR, entry.name), path.join(target, entry.name), ); } } return tmp; } export async function execute(ctx: AdapterExecutionContext): Promise { const { runId, agent, runtime, config, context, onLog, onMeta, authToken } = ctx; const promptTemplate = asString( config.promptTemplate, "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", ); const bootstrapTemplate = asString(config.bootstrapPromptTemplate, promptTemplate); const command = asString(config.command, "claude"); const model = asString(config.model, ""); const maxTurns = asNumber(config.maxTurnsPerRun, 0); const dangerouslySkipPermissions = asBoolean(config.dangerouslySkipPermissions, false); const cwd = asString(config.cwd, process.cwd()); await ensureAbsoluteDirectory(cwd); const envConfig = parseObject(config.env); const hasExplicitApiKey = typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; const env: Record = { ...buildPaperclipEnv(agent) }; env.PAPERCLIP_RUN_ID = runId; const wakeTaskId = (typeof context.taskId === "string" && context.taskId.trim().length > 0 && context.taskId.trim()) || (typeof context.issueId === "string" && context.issueId.trim().length > 0 && context.issueId.trim()) || null; const wakeReason = typeof context.wakeReason === "string" && context.wakeReason.trim().length > 0 ? context.wakeReason.trim() : null; const approvalId = typeof context.approvalId === "string" && context.approvalId.trim().length > 0 ? context.approvalId.trim() : null; const approvalStatus = typeof context.approvalStatus === "string" && context.approvalStatus.trim().length > 0 ? context.approvalStatus.trim() : null; const linkedIssueIds = Array.isArray(context.issueIds) ? context.issueIds.filter((value): value is string => typeof value === "string" && value.trim().length > 0) : []; if (wakeTaskId) { env.PAPERCLIP_TASK_ID = wakeTaskId; } if (wakeReason) { env.PAPERCLIP_WAKE_REASON = wakeReason; } 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(","); } for (const [k, v] of Object.entries(envConfig)) { if (typeof v === "string") env[k] = v; } if (!hasExplicitApiKey && authToken) { env.PAPERCLIP_API_KEY = authToken; } const runtimeEnv = ensurePathInEnv({ ...process.env, ...env }); await ensureCommandResolvable(command, cwd, runtimeEnv); const timeoutSec = asNumber(config.timeoutSec, 1800); const graceSec = asNumber(config.graceSec, 20); const extraArgs = (() => { const fromExtraArgs = asStringArray(config.extraArgs); if (fromExtraArgs.length > 0) return fromExtraArgs; return asStringArray(config.args); })(); const skillsDir = await buildSkillsDir(); const runtimeSessionParams = parseObject(runtime.sessionParams); const runtimeSessionId = asString(runtimeSessionParams.sessionId, runtime.sessionId ?? ""); const runtimeSessionCwd = asString(runtimeSessionParams.cwd, ""); const canResumeSession = runtimeSessionId.length > 0 && (runtimeSessionCwd.length === 0 || path.resolve(runtimeSessionCwd) === path.resolve(cwd)); const sessionId = canResumeSession ? runtimeSessionId : null; if (runtimeSessionId && !canResumeSession) { await onLog( "stderr", `[paperclip] Claude session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, ); } const template = sessionId ? promptTemplate : bootstrapTemplate; const prompt = renderTemplate(template, { agentId: agent.id, companyId: agent.companyId, runId, company: { id: agent.companyId }, agent, run: { id: runId, source: "on_demand" }, context, }); const buildClaudeArgs = (resumeSessionId: string | null) => { const args = ["--print", "-", "--output-format", "stream-json", "--verbose"]; if (resumeSessionId) args.push("--resume", resumeSessionId); if (dangerouslySkipPermissions) args.push("--dangerously-skip-permissions"); if (model) args.push("--model", model); if (maxTurns > 0) args.push("--max-turns", String(maxTurns)); args.push("--add-dir", skillsDir); if (extraArgs.length > 0) args.push(...extraArgs); return args; }; const parseFallbackErrorMessage = (proc: RunProcessResult) => { const stderrLine = proc.stderr .split(/\r?\n/) .map((line) => line.trim()) .find(Boolean) ?? ""; if ((proc.exitCode ?? 0) === 0) { return "Failed to parse claude JSON output"; } return stderrLine ? `Claude exited with code ${proc.exitCode ?? -1}: ${stderrLine}` : `Claude exited with code ${proc.exitCode ?? -1}`; }; const runAttempt = async (resumeSessionId: string | null) => { const args = buildClaudeArgs(resumeSessionId); if (onMeta) { await onMeta({ adapterType: "claude_local", command, cwd, commandArgs: args, env: redactEnvForLogs(env), prompt, context, }); } const proc = await runChildProcess(runId, command, args, { cwd, env, stdin: prompt, timeoutSec, graceSec, onLog, }); const parsedStream = parseClaudeStreamJson(proc.stdout); const parsed = parsedStream.resultJson ?? parseJson(proc.stdout); return { proc, parsedStream, parsed }; }; const toAdapterResult = ( attempt: { proc: RunProcessResult; parsedStream: ReturnType; parsed: Record | null; }, opts: { fallbackSessionId: string | null; clearSessionOnMissingSession?: boolean }, ): AdapterExecutionResult => { const { proc, parsedStream, parsed } = attempt; if (proc.timedOut) { return { exitCode: proc.exitCode, signal: proc.signal, timedOut: true, errorMessage: `Timed out after ${timeoutSec}s`, clearSession: Boolean(opts.clearSessionOnMissingSession), }; } if (!parsed) { return { exitCode: proc.exitCode, signal: proc.signal, timedOut: false, errorMessage: parseFallbackErrorMessage(proc), resultJson: { stdout: proc.stdout, stderr: proc.stderr, }, clearSession: Boolean(opts.clearSessionOnMissingSession), }; } const usage = parsedStream.usage ?? (() => { const usageObj = parseObject(parsed.usage); return { inputTokens: asNumber(usageObj.input_tokens, 0), cachedInputTokens: asNumber(usageObj.cache_read_input_tokens, 0), outputTokens: asNumber(usageObj.output_tokens, 0), }; })(); const resolvedSessionId = parsedStream.sessionId ?? (asString(parsed.session_id, opts.fallbackSessionId ?? "") || opts.fallbackSessionId); const resolvedSessionParams = resolvedSessionId ? ({ sessionId: resolvedSessionId, cwd } as Record) : null; return { exitCode: proc.exitCode, signal: proc.signal, timedOut: false, errorMessage: (proc.exitCode ?? 0) === 0 ? null : describeClaudeFailure(parsed) ?? `Claude exited with code ${proc.exitCode ?? -1}`, usage, sessionId: resolvedSessionId, sessionParams: resolvedSessionParams, sessionDisplayId: resolvedSessionId, provider: "anthropic", model: parsedStream.model || asString(parsed.model, model), costUsd: parsedStream.costUsd ?? asNumber(parsed.total_cost_usd, 0), resultJson: parsed, summary: parsedStream.summary || asString(parsed.result, ""), clearSession: Boolean(opts.clearSessionOnMissingSession && !resolvedSessionId), }; }; try { const initial = await runAttempt(sessionId ?? null); if ( sessionId && !initial.proc.timedOut && (initial.proc.exitCode ?? 0) !== 0 && initial.parsed && isClaudeUnknownSessionError(initial.parsed) ) { await onLog( "stderr", `[paperclip] Claude resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`, ); const retry = await runAttempt(null); return toAdapterResult(retry, { fallbackSessionId: null, clearSessionOnMissingSession: true }); } return toAdapterResult(initial, { fallbackSessionId: runtimeSessionId || runtime.sessionId }); } finally { fs.rm(skillsDir, { recursive: true, force: true }).catch(() => {}); } }