Files
paperclip/packages/adapters/claude-local/src/server/execute.ts
T
Forgotten 2c3c2cf724 feat: adapter model discovery, reasoning effort, and improved codex formatting
Add dynamic OpenAI model list fetching for codex adapter with caching,
async listModels interface, reasoning effort support for both claude and
codex adapters, optional timeouts (default to unlimited), wakeCommentId
context propagation, and richer codex stdout event parsing/formatting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 10:32:07 -06:00

304 lines
10 KiB
TypeScript

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<string> {
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<AdapterExecutionResult> {
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 effort = asString(config.effort, "");
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<string, string> = { ...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 wakeCommentId =
(typeof context.wakeCommentId === "string" && context.wakeCommentId.trim().length > 0 && context.wakeCommentId.trim()) ||
(typeof context.commentId === "string" && context.commentId.trim().length > 0 && context.commentId.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 (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(",");
}
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, 0);
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 (effort) args.push("--effort", effort);
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<typeof parseClaudeStreamJson>;
parsed: Record<string, unknown> | 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<string, unknown>)
: 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(() => {});
}
}