mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 10:30:37 +09:00
Introduce AdapterSessionCodec interface for structured session serialization, deserialization, and display ID extraction. Implement codecs for claude_local and codex_local adapters with cwd validation — sessions saved for a different working directory are not resumed. Both adapters now return sessionParams and sessionDisplayId alongside legacy sessionId. Add isCodexUnknownSessionError detection and automatic retry with fresh session for codex_local (matching existing claude_local behavior). Inject approval context env vars (PAPERCLIP_APPROVAL_ID, PAPERCLIP_APPROVAL_STATUS, PAPERCLIP_LINKED_ISSUE_IDS) into adapter environments. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
211 lines
7.2 KiB
TypeScript
211 lines
7.2 KiB
TypeScript
import path from "node:path";
|
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclip/adapter-utils";
|
|
import {
|
|
asString,
|
|
asNumber,
|
|
asBoolean,
|
|
asStringArray,
|
|
parseObject,
|
|
buildPaperclipEnv,
|
|
redactEnvForLogs,
|
|
ensureAbsoluteDirectory,
|
|
ensureCommandResolvable,
|
|
ensurePathInEnv,
|
|
renderTemplate,
|
|
runChildProcess,
|
|
} from "@paperclip/adapter-utils/server-utils";
|
|
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
|
|
|
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, "codex");
|
|
const model = asString(config.model, "");
|
|
const search = asBoolean(config.search, false);
|
|
const bypass = asBoolean(config.dangerouslyBypassApprovalsAndSandbox, 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 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 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] Codex session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`,
|
|
);
|
|
}
|
|
const template = sessionId ? promptTemplate : bootstrapTemplate;
|
|
const prompt = renderTemplate(template, {
|
|
company: { id: agent.companyId },
|
|
agent,
|
|
run: { id: runId, source: "on_demand" },
|
|
context,
|
|
});
|
|
|
|
const buildArgs = (resumeSessionId: string | null) => {
|
|
const args = ["exec", "--json"];
|
|
if (search) args.unshift("--search");
|
|
if (bypass) args.push("--dangerously-bypass-approvals-and-sandbox");
|
|
if (model) args.push("--model", model);
|
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
if (resumeSessionId) args.push("resume", resumeSessionId, prompt);
|
|
else args.push(prompt);
|
|
return args;
|
|
};
|
|
|
|
const runAttempt = async (resumeSessionId: string | null) => {
|
|
const args = buildArgs(resumeSessionId);
|
|
if (onMeta) {
|
|
await onMeta({
|
|
adapterType: "codex_local",
|
|
command,
|
|
cwd,
|
|
commandArgs: args.map((value, idx) => {
|
|
if (idx === args.length - 1) return `<prompt ${prompt.length} chars>`;
|
|
return value;
|
|
}),
|
|
env: redactEnvForLogs(env),
|
|
prompt,
|
|
context,
|
|
});
|
|
}
|
|
|
|
const proc = await runChildProcess(runId, command, args, {
|
|
cwd,
|
|
env,
|
|
timeoutSec,
|
|
graceSec,
|
|
onLog,
|
|
});
|
|
return {
|
|
proc,
|
|
parsed: parseCodexJsonl(proc.stdout),
|
|
};
|
|
};
|
|
|
|
const toResult = (
|
|
attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; parsed: ReturnType<typeof parseCodexJsonl> },
|
|
clearSessionOnMissingSession = false,
|
|
): AdapterExecutionResult => {
|
|
if (attempt.proc.timedOut) {
|
|
return {
|
|
exitCode: attempt.proc.exitCode,
|
|
signal: attempt.proc.signal,
|
|
timedOut: true,
|
|
errorMessage: `Timed out after ${timeoutSec}s`,
|
|
clearSession: clearSessionOnMissingSession,
|
|
};
|
|
}
|
|
|
|
const resolvedSessionId = attempt.parsed.sessionId ?? runtimeSessionId ?? runtime.sessionId ?? null;
|
|
const resolvedSessionParams = resolvedSessionId
|
|
? ({ sessionId: resolvedSessionId, cwd } as Record<string, unknown>)
|
|
: null;
|
|
|
|
return {
|
|
exitCode: attempt.proc.exitCode,
|
|
signal: attempt.proc.signal,
|
|
timedOut: false,
|
|
errorMessage:
|
|
(attempt.proc.exitCode ?? 0) === 0
|
|
? null
|
|
: `Codex exited with code ${attempt.proc.exitCode ?? -1}`,
|
|
usage: attempt.parsed.usage,
|
|
sessionId: resolvedSessionId,
|
|
sessionParams: resolvedSessionParams,
|
|
sessionDisplayId: resolvedSessionId,
|
|
provider: "openai",
|
|
model,
|
|
costUsd: null,
|
|
resultJson: {
|
|
stdout: attempt.proc.stdout,
|
|
stderr: attempt.proc.stderr,
|
|
},
|
|
summary: attempt.parsed.summary,
|
|
clearSession: Boolean(clearSessionOnMissingSession && !resolvedSessionId),
|
|
};
|
|
};
|
|
|
|
const initial = await runAttempt(sessionId);
|
|
if (
|
|
sessionId &&
|
|
!initial.proc.timedOut &&
|
|
(initial.proc.exitCode ?? 0) !== 0 &&
|
|
isCodexUnknownSessionError(initial.proc.stdout, initial.proc.stderr)
|
|
) {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Codex resume session "${sessionId}" is unavailable; retrying with a fresh session.\n`,
|
|
);
|
|
const retry = await runAttempt(null);
|
|
return toResult(retry, true);
|
|
}
|
|
|
|
return toResult(initial);
|
|
}
|