The $AGENT_HOME environment variable was referenced by skills (e.g. para-memory-files) but never actually set, causing runtime errors like "/HEARTBEAT.md: No such file or directory" when agents tried to resolve paths relative to their home directory. Add agentHome to the paperclipWorkspace context in the heartbeat service and propagate it as the AGENT_HOME env var in all local adapters. Co-Authored-By: Paperclip <noreply@paperclip.ing>
540 lines
20 KiB
TypeScript
540 lines
20 KiB
TypeScript
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import type { AdapterExecutionContext, AdapterExecutionResult } from "@paperclipai/adapter-utils";
|
|
import {
|
|
asString,
|
|
asNumber,
|
|
asBoolean,
|
|
asStringArray,
|
|
parseObject,
|
|
buildPaperclipEnv,
|
|
redactEnvForLogs,
|
|
ensureAbsoluteDirectory,
|
|
ensureCommandResolvable,
|
|
ensurePaperclipSkillSymlink,
|
|
ensurePathInEnv,
|
|
listPaperclipSkillEntries,
|
|
removeMaintainerOnlySkillSymlinks,
|
|
renderTemplate,
|
|
joinPromptSections,
|
|
runChildProcess,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js";
|
|
import { pathExists, prepareWorktreeCodexHome, resolveCodexHomeDir } from "./codex-home.js";
|
|
|
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
const CODEX_ROLLOUT_NOISE_RE =
|
|
/^\d{4}-\d{2}-\d{2}T[^\s]+\s+ERROR\s+codex_core::rollout::list:\s+state db missing rollout path for thread\s+[a-z0-9-]+$/i;
|
|
|
|
function stripCodexRolloutNoise(text: string): string {
|
|
const parts = text.split(/\r?\n/);
|
|
const kept: string[] = [];
|
|
for (const part of parts) {
|
|
const trimmed = part.trim();
|
|
if (!trimmed) {
|
|
kept.push(part);
|
|
continue;
|
|
}
|
|
if (CODEX_ROLLOUT_NOISE_RE.test(trimmed)) continue;
|
|
kept.push(part);
|
|
}
|
|
return kept.join("\n");
|
|
}
|
|
|
|
function firstNonEmptyLine(text: string): string {
|
|
return (
|
|
text
|
|
.split(/\r?\n/)
|
|
.map((line) => line.trim())
|
|
.find(Boolean) ?? ""
|
|
);
|
|
}
|
|
|
|
function hasNonEmptyEnvValue(env: Record<string, string>, key: string): boolean {
|
|
const raw = env[key];
|
|
return typeof raw === "string" && raw.trim().length > 0;
|
|
}
|
|
|
|
function resolveCodexBillingType(env: Record<string, string>): "api" | "subscription" {
|
|
// Codex uses API-key auth when OPENAI_API_KEY is present; otherwise rely on local login/session auth.
|
|
return hasNonEmptyEnvValue(env, "OPENAI_API_KEY") ? "api" : "subscription";
|
|
}
|
|
|
|
async function isLikelyPaperclipRepoRoot(candidate: string): Promise<boolean> {
|
|
const [hasWorkspace, hasPackageJson, hasServerDir, hasAdapterUtilsDir] = await Promise.all([
|
|
pathExists(path.join(candidate, "pnpm-workspace.yaml")),
|
|
pathExists(path.join(candidate, "package.json")),
|
|
pathExists(path.join(candidate, "server")),
|
|
pathExists(path.join(candidate, "packages", "adapter-utils")),
|
|
]);
|
|
|
|
return hasWorkspace && hasPackageJson && hasServerDir && hasAdapterUtilsDir;
|
|
}
|
|
|
|
async function isLikelyPaperclipRuntimeSkillSource(candidate: string, skillName: string): Promise<boolean> {
|
|
if (path.basename(candidate) !== skillName) return false;
|
|
const skillsRoot = path.dirname(candidate);
|
|
if (path.basename(skillsRoot) !== "skills") return false;
|
|
if (!(await pathExists(path.join(candidate, "SKILL.md")))) return false;
|
|
|
|
let cursor = path.dirname(skillsRoot);
|
|
for (let depth = 0; depth < 6; depth += 1) {
|
|
if (await isLikelyPaperclipRepoRoot(cursor)) return true;
|
|
const parent = path.dirname(cursor);
|
|
if (parent === cursor) break;
|
|
cursor = parent;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
type EnsureCodexSkillsInjectedOptions = {
|
|
skillsHome?: string;
|
|
skillsEntries?: Awaited<ReturnType<typeof listPaperclipSkillEntries>>;
|
|
linkSkill?: (source: string, target: string) => Promise<void>;
|
|
};
|
|
|
|
export async function ensureCodexSkillsInjected(
|
|
onLog: AdapterExecutionContext["onLog"],
|
|
options: EnsureCodexSkillsInjectedOptions = {},
|
|
) {
|
|
const skillsEntries = options.skillsEntries ?? await listPaperclipSkillEntries(__moduleDir);
|
|
if (skillsEntries.length === 0) return;
|
|
|
|
const skillsHome = options.skillsHome ?? path.join(resolveCodexHomeDir(process.env), "skills");
|
|
await fs.mkdir(skillsHome, { recursive: true });
|
|
const removedSkills = await removeMaintainerOnlySkillSymlinks(
|
|
skillsHome,
|
|
skillsEntries.map((entry) => entry.name),
|
|
);
|
|
for (const skillName of removedSkills) {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Removed maintainer-only Codex skill "${skillName}" from ${skillsHome}\n`,
|
|
);
|
|
}
|
|
const linkSkill = options.linkSkill;
|
|
for (const entry of skillsEntries) {
|
|
const target = path.join(skillsHome, entry.name);
|
|
|
|
try {
|
|
const existing = await fs.lstat(target).catch(() => null);
|
|
if (existing?.isSymbolicLink()) {
|
|
const linkedPath = await fs.readlink(target).catch(() => null);
|
|
const resolvedLinkedPath = linkedPath
|
|
? path.resolve(path.dirname(target), linkedPath)
|
|
: null;
|
|
if (
|
|
resolvedLinkedPath &&
|
|
resolvedLinkedPath !== entry.source &&
|
|
(await isLikelyPaperclipRuntimeSkillSource(resolvedLinkedPath, entry.name))
|
|
) {
|
|
await fs.unlink(target);
|
|
if (linkSkill) {
|
|
await linkSkill(entry.source, target);
|
|
} else {
|
|
await fs.symlink(entry.source, target);
|
|
}
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Repaired Codex skill "${entry.name}" into ${skillsHome}\n`,
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill);
|
|
if (result === "skipped") continue;
|
|
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.name}" into ${skillsHome}\n`,
|
|
);
|
|
} catch (err) {
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Failed to inject Codex skill "${entry.name}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 command = asString(config.command, "codex");
|
|
const model = asString(config.model, "");
|
|
const modelReasoningEffort = asString(
|
|
config.modelReasoningEffort,
|
|
asString(config.reasoningEffort, ""),
|
|
);
|
|
const search = asBoolean(config.search, false);
|
|
const bypass = asBoolean(
|
|
config.dangerouslyBypassApprovalsAndSandbox,
|
|
asBoolean(config.dangerouslyBypassSandbox, false),
|
|
);
|
|
|
|
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 workspaceHints = Array.isArray(context.paperclipWorkspaces)
|
|
? context.paperclipWorkspaces.filter(
|
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
|
)
|
|
: [];
|
|
const runtimeServiceIntents = Array.isArray(context.paperclipRuntimeServiceIntents)
|
|
? context.paperclipRuntimeServiceIntents.filter(
|
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
|
)
|
|
: [];
|
|
const runtimeServices = Array.isArray(context.paperclipRuntimeServices)
|
|
? context.paperclipRuntimeServices.filter(
|
|
(value): value is Record<string, unknown> => typeof value === "object" && value !== null,
|
|
)
|
|
: [];
|
|
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
|
|
const configuredCwd = asString(config.cwd, "");
|
|
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
|
|
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
|
|
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
|
|
const envConfig = parseObject(config.env);
|
|
const configuredCodexHome =
|
|
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
|
|
? path.resolve(envConfig.CODEX_HOME.trim())
|
|
: null;
|
|
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
|
|
const preparedWorktreeCodexHome =
|
|
configuredCodexHome ? null : await prepareWorktreeCodexHome(process.env, onLog);
|
|
const effectiveCodexHome = configuredCodexHome ?? preparedWorktreeCodexHome;
|
|
await ensureCodexSkillsInjected(
|
|
onLog,
|
|
effectiveCodexHome ? { skillsHome: path.join(effectiveCodexHome, "skills") } : {},
|
|
);
|
|
const hasExplicitApiKey =
|
|
typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0;
|
|
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
|
|
if (effectiveCodexHome) {
|
|
env.CODEX_HOME = effectiveCodexHome;
|
|
}
|
|
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(",");
|
|
}
|
|
if (effectiveWorkspaceCwd) {
|
|
env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd;
|
|
}
|
|
if (workspaceSource) {
|
|
env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource;
|
|
}
|
|
if (workspaceStrategy) {
|
|
env.PAPERCLIP_WORKSPACE_STRATEGY = workspaceStrategy;
|
|
}
|
|
if (workspaceId) {
|
|
env.PAPERCLIP_WORKSPACE_ID = workspaceId;
|
|
}
|
|
if (workspaceRepoUrl) {
|
|
env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl;
|
|
}
|
|
if (workspaceRepoRef) {
|
|
env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef;
|
|
}
|
|
if (workspaceBranch) {
|
|
env.PAPERCLIP_WORKSPACE_BRANCH = workspaceBranch;
|
|
}
|
|
if (workspaceWorktreePath) {
|
|
env.PAPERCLIP_WORKSPACE_WORKTREE_PATH = workspaceWorktreePath;
|
|
}
|
|
if (agentHome) {
|
|
env.AGENT_HOME = agentHome;
|
|
}
|
|
if (workspaceHints.length > 0) {
|
|
env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints);
|
|
}
|
|
if (runtimeServiceIntents.length > 0) {
|
|
env.PAPERCLIP_RUNTIME_SERVICE_INTENTS_JSON = JSON.stringify(runtimeServiceIntents);
|
|
}
|
|
if (runtimeServices.length > 0) {
|
|
env.PAPERCLIP_RUNTIME_SERVICES_JSON = JSON.stringify(runtimeServices);
|
|
}
|
|
if (runtimePrimaryUrl) {
|
|
env.PAPERCLIP_RUNTIME_PRIMARY_URL = runtimePrimaryUrl;
|
|
}
|
|
for (const [k, v] of Object.entries(envConfig)) {
|
|
if (typeof v === "string") env[k] = v;
|
|
}
|
|
if (!hasExplicitApiKey && authToken) {
|
|
env.PAPERCLIP_API_KEY = authToken;
|
|
}
|
|
const billingType = resolveCodexBillingType(env);
|
|
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 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 instructionsFilePath = asString(config.instructionsFilePath, "").trim();
|
|
const instructionsDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : "";
|
|
let instructionsPrefix = "";
|
|
let instructionsChars = 0;
|
|
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`;
|
|
instructionsChars = instructionsPrefix.length;
|
|
await onLog(
|
|
"stderr",
|
|
`[paperclip] Loaded agent instructions file: ${instructionsFilePath}\n`,
|
|
);
|
|
} 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`,
|
|
);
|
|
}
|
|
}
|
|
const commandNotes = (() => {
|
|
if (!instructionsFilePath) return [] as string[];
|
|
if (instructionsPrefix.length > 0) {
|
|
return [
|
|
`Loaded agent instructions from ${instructionsFilePath}`,
|
|
`Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`,
|
|
];
|
|
}
|
|
return [
|
|
`Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`,
|
|
];
|
|
})();
|
|
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 renderedPrompt = renderTemplate(promptTemplate, templateData);
|
|
const renderedBootstrapPrompt =
|
|
!sessionId && bootstrapPromptTemplate.trim().length > 0
|
|
? renderTemplate(bootstrapPromptTemplate, templateData).trim()
|
|
: "";
|
|
const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim();
|
|
const prompt = joinPromptSections([
|
|
instructionsPrefix,
|
|
renderedBootstrapPrompt,
|
|
sessionHandoffNote,
|
|
renderedPrompt,
|
|
]);
|
|
const promptMetrics = {
|
|
promptChars: prompt.length,
|
|
instructionsChars,
|
|
bootstrapPromptChars: renderedBootstrapPrompt.length,
|
|
sessionHandoffChars: sessionHandoffNote.length,
|
|
heartbeatPromptChars: renderedPrompt.length,
|
|
};
|
|
|
|
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 (modelReasoningEffort) args.push("-c", `model_reasoning_effort=${JSON.stringify(modelReasoningEffort)}`);
|
|
if (extraArgs.length > 0) args.push(...extraArgs);
|
|
if (resumeSessionId) args.push("resume", resumeSessionId, "-");
|
|
else args.push("-");
|
|
return args;
|
|
};
|
|
|
|
const runAttempt = async (resumeSessionId: string | null) => {
|
|
const args = buildArgs(resumeSessionId);
|
|
if (onMeta) {
|
|
await onMeta({
|
|
adapterType: "codex_local",
|
|
command,
|
|
cwd,
|
|
commandNotes,
|
|
commandArgs: args.map((value, idx) => {
|
|
if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`;
|
|
return value;
|
|
}),
|
|
env: redactEnvForLogs(env),
|
|
prompt,
|
|
promptMetrics,
|
|
context,
|
|
});
|
|
}
|
|
|
|
const proc = await runChildProcess(runId, command, args, {
|
|
cwd,
|
|
env,
|
|
stdin: prompt,
|
|
timeoutSec,
|
|
graceSec,
|
|
onLog: async (stream, chunk) => {
|
|
if (stream !== "stderr") {
|
|
await onLog(stream, chunk);
|
|
return;
|
|
}
|
|
const cleaned = stripCodexRolloutNoise(chunk);
|
|
if (!cleaned.trim()) return;
|
|
await onLog(stream, cleaned);
|
|
},
|
|
});
|
|
const cleanedStderr = stripCodexRolloutNoise(proc.stderr);
|
|
return {
|
|
proc: {
|
|
...proc,
|
|
stderr: cleanedStderr,
|
|
},
|
|
rawStderr: proc.stderr,
|
|
parsed: parseCodexJsonl(proc.stdout),
|
|
};
|
|
};
|
|
|
|
const toResult = (
|
|
attempt: { proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; rawStderr: 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,
|
|
...(workspaceId ? { workspaceId } : {}),
|
|
...(workspaceRepoUrl ? { repoUrl: workspaceRepoUrl } : {}),
|
|
...(workspaceRepoRef ? { repoRef: workspaceRepoRef } : {}),
|
|
} as Record<string, unknown>)
|
|
: null;
|
|
const parsedError = typeof attempt.parsed.errorMessage === "string" ? attempt.parsed.errorMessage.trim() : "";
|
|
const stderrLine = firstNonEmptyLine(attempt.proc.stderr);
|
|
const fallbackErrorMessage =
|
|
parsedError ||
|
|
stderrLine ||
|
|
`Codex exited with code ${attempt.proc.exitCode ?? -1}`;
|
|
|
|
return {
|
|
exitCode: attempt.proc.exitCode,
|
|
signal: attempt.proc.signal,
|
|
timedOut: false,
|
|
errorMessage:
|
|
(attempt.proc.exitCode ?? 0) === 0
|
|
? null
|
|
: fallbackErrorMessage,
|
|
usage: attempt.parsed.usage,
|
|
sessionId: resolvedSessionId,
|
|
sessionParams: resolvedSessionParams,
|
|
sessionDisplayId: resolvedSessionId,
|
|
provider: "openai",
|
|
model,
|
|
billingType,
|
|
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.rawStderr)
|
|
) {
|
|
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);
|
|
}
|