Spaces:
Paused
Paused
| import fs from "node:fs/promises"; | |
| import path from "node:path"; | |
| import { fileURLToPath } from "node:url"; | |
| import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; | |
| import { | |
| asString, | |
| asNumber, | |
| asBoolean, | |
| asStringArray, | |
| parseObject, | |
| buildPaperclipEnv, | |
| buildInvocationEnvForLogs, | |
| ensureAbsoluteDirectory, | |
| ensureCommandResolvable, | |
| ensurePaperclipSkillSymlink, | |
| ensurePathInEnv, | |
| readPaperclipRuntimeSkillEntries, | |
| resolveCommandForLogs, | |
| resolvePaperclipDesiredSkillNames, | |
| renderTemplate, | |
| renderPaperclipWakePrompt, | |
| stringifyPaperclipWakePayload, | |
| joinPromptSections, | |
| runChildProcess, | |
| } from "@paperclipai/adapter-utils/server-utils"; | |
| import { parseCodexJsonl, isCodexUnknownSessionError } from "./parse.js"; | |
| import { pathExists, prepareManagedCodexHome, resolveManagedCodexHomeDir, resolveSharedCodexHomeDir } from "./codex-home.js"; | |
| import { resolveCodexDesiredSkillNames } from "./skills.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"; | |
| } | |
| function resolveCodexBiller(env: Record<string, string>, billingType: "api" | "subscription"): string { | |
| const openAiCompatibleBiller = inferOpenAiCompatibleBiller(env, "openai"); | |
| if (openAiCompatibleBiller === "openrouter") return "openrouter"; | |
| return billingType === "subscription" ? "chatgpt" : openAiCompatibleBiller ?? "openai"; | |
| } | |
| 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 isLikelyPaperclipRuntimeSkillPath( | |
| candidate: string, | |
| skillName: string, | |
| options: { requireSkillMarkdown?: boolean } = {}, | |
| ): Promise<boolean> { | |
| if (path.basename(candidate) !== skillName) return false; | |
| const skillsRoot = path.dirname(candidate); | |
| if (path.basename(skillsRoot) !== "skills") return false; | |
| if (options.requireSkillMarkdown !== false && !(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; | |
| } | |
| async function pruneBrokenUnavailablePaperclipSkillSymlinks( | |
| skillsHome: string, | |
| allowedSkillNames: Iterable<string>, | |
| onLog: AdapterExecutionContext["onLog"], | |
| ) { | |
| const allowed = new Set(Array.from(allowedSkillNames)); | |
| const entries = await fs.readdir(skillsHome, { withFileTypes: true }).catch(() => []); | |
| for (const entry of entries) { | |
| if (allowed.has(entry.name) || !entry.isSymbolicLink()) continue; | |
| const target = path.join(skillsHome, entry.name); | |
| const linkedPath = await fs.readlink(target).catch(() => null); | |
| if (!linkedPath) continue; | |
| const resolvedLinkedPath = path.resolve(path.dirname(target), linkedPath); | |
| if (await pathExists(resolvedLinkedPath)) continue; | |
| if ( | |
| !(await isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.name, { | |
| requireSkillMarkdown: false, | |
| })) | |
| ) { | |
| continue; | |
| } | |
| await fs.unlink(target).catch(() => {}); | |
| await onLog( | |
| "stdout", | |
| `[paperclip] Removed stale Codex skill "${entry.name}" from ${skillsHome}\n`, | |
| ); | |
| } | |
| } | |
| function resolveCodexSkillsDir(codexHome: string): string { | |
| return path.join(codexHome, "skills"); | |
| } | |
| type EnsureCodexSkillsInjectedOptions = { | |
| skillsHome?: string; | |
| skillsEntries?: Array<{ key: string; runtimeName: string; source: string }>; | |
| desiredSkillNames?: string[]; | |
| linkSkill?: (source: string, target: string) => Promise<void>; | |
| }; | |
| export async function ensureCodexSkillsInjected( | |
| onLog: AdapterExecutionContext["onLog"], | |
| options: EnsureCodexSkillsInjectedOptions = {}, | |
| ) { | |
| const allSkillsEntries = options.skillsEntries ?? await readPaperclipRuntimeSkillEntries({}, __moduleDir); | |
| const desiredSkillNames = | |
| options.desiredSkillNames ?? allSkillsEntries.map((entry) => entry.key); | |
| const desiredSet = new Set(desiredSkillNames); | |
| const skillsEntries = allSkillsEntries.filter((entry) => desiredSet.has(entry.key)); | |
| if (skillsEntries.length === 0) return; | |
| const skillsHome = options.skillsHome ?? resolveCodexSkillsDir(resolveSharedCodexHomeDir()); | |
| await fs.mkdir(skillsHome, { recursive: true }); | |
| const linkSkill = options.linkSkill; | |
| for (const entry of skillsEntries) { | |
| const target = path.join(skillsHome, entry.runtimeName); | |
| 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 isLikelyPaperclipRuntimeSkillPath(resolvedLinkedPath, entry.runtimeName)) | |
| ) { | |
| await fs.unlink(target); | |
| if (linkSkill) { | |
| await linkSkill(entry.source, target); | |
| } else { | |
| await fs.symlink(entry.source, target); | |
| } | |
| await onLog( | |
| "stdout", | |
| `[paperclip] Repaired Codex skill "${entry.runtimeName}" into ${skillsHome}\n`, | |
| ); | |
| continue; | |
| } | |
| } | |
| const result = await ensurePaperclipSkillSymlink(entry.source, target, linkSkill); | |
| if (result === "skipped") continue; | |
| await onLog( | |
| "stdout", | |
| `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} Codex skill "${entry.runtimeName}" into ${skillsHome}\n`, | |
| ); | |
| } catch (err) { | |
| await onLog( | |
| "stderr", | |
| `[paperclip] Failed to inject Codex skill "${entry.key}" into ${skillsHome}: ${err instanceof Error ? err.message : String(err)}\n`, | |
| ); | |
| } | |
| } | |
| await pruneBrokenUnavailablePaperclipSkillSymlinks( | |
| skillsHome, | |
| skillsEntries.map((entry) => entry.runtimeName), | |
| onLog, | |
| ); | |
| } | |
| export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExecutionResult> { | |
| const { runId, agent, runtime, config, context, onLog, onMeta, onSpawn, 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; | |
| const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); | |
| const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries); | |
| await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); | |
| const preparedManagedCodexHome = | |
| configuredCodexHome ? null : await prepareManagedCodexHome(process.env, onLog, agent.companyId); | |
| const defaultCodexHome = resolveManagedCodexHomeDir(process.env, agent.companyId); | |
| const effectiveCodexHome = configuredCodexHome ?? preparedManagedCodexHome ?? defaultCodexHome; | |
| await fs.mkdir(effectiveCodexHome, { recursive: true }); | |
| // Inject skills into the same CODEX_HOME that Codex will actually run with | |
| // (managed home in the default case, or an explicit override from adapter config). | |
| const codexSkillsDir = resolveCodexSkillsDir(effectiveCodexHome); | |
| await ensureCodexSkillsInjected( | |
| onLog, | |
| { | |
| skillsHome: codexSkillsDir, | |
| skillsEntries: codexSkillEntries, | |
| desiredSkillNames, | |
| }, | |
| ); | |
| const hasExplicitApiKey = | |
| typeof envConfig.PAPERCLIP_API_KEY === "string" && envConfig.PAPERCLIP_API_KEY.trim().length > 0; | |
| const env: Record<string, string> = { ...buildPaperclipEnv(agent) }; | |
| 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) | |
| : []; | |
| const wakePayloadJson = stringifyPaperclipWakePayload(context.paperclipWake); | |
| 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 (wakePayloadJson) { | |
| env.PAPERCLIP_WAKE_PAYLOAD_JSON = wakePayloadJson; | |
| } | |
| 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 effectiveEnv = Object.fromEntries( | |
| Object.entries({ ...process.env, ...env }).filter( | |
| (entry): entry is [string, string] => typeof entry[1] === "string", | |
| ), | |
| ); | |
| const billingType = resolveCodexBillingType(effectiveEnv); | |
| const runtimeEnv = ensurePathInEnv(effectiveEnv); | |
| await ensureCommandResolvable(command, cwd, runtimeEnv); | |
| const resolvedCommand = await resolveCommandForLogs(command, cwd, runtimeEnv); | |
| const loggedEnv = buildInvocationEnvForLogs(env, { | |
| runtimeEnv, | |
| includeRuntimeKeys: ["HOME"], | |
| resolvedCommand, | |
| }); | |
| 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( | |
| "stdout", | |
| `[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; | |
| } catch (err) { | |
| const reason = err instanceof Error ? err.message : String(err); | |
| await onLog( | |
| "stdout", | |
| `[paperclip] Warning: could not read agent instructions file "${instructionsFilePath}": ${reason}\n`, | |
| ); | |
| } | |
| } | |
| const repoAgentsNote = | |
| "Codex exec automatically applies repo-scoped AGENTS.md instructions from the current workspace; Paperclip does not currently suppress that discovery."; | |
| 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 = | |
| !sessionId && bootstrapPromptTemplate.trim().length > 0 | |
| ? renderTemplate(bootstrapPromptTemplate, templateData).trim() | |
| : ""; | |
| const wakePrompt = renderPaperclipWakePrompt(context.paperclipWake, { resumedSession: Boolean(sessionId) }); | |
| const shouldUseResumeDeltaPrompt = Boolean(sessionId) && wakePrompt.length > 0; | |
| const promptInstructionsPrefix = shouldUseResumeDeltaPrompt ? "" : instructionsPrefix; | |
| instructionsChars = promptInstructionsPrefix.length; | |
| const commandNotes = (() => { | |
| if (!instructionsFilePath) { | |
| return [repoAgentsNote]; | |
| } | |
| if (instructionsPrefix.length > 0) { | |
| if (shouldUseResumeDeltaPrompt) { | |
| return [ | |
| `Loaded agent instructions from ${instructionsFilePath}`, | |
| "Skipped stdin instruction reinjection because an existing Codex session is being resumed with a wake delta.", | |
| repoAgentsNote, | |
| ]; | |
| } | |
| return [ | |
| `Loaded agent instructions from ${instructionsFilePath}`, | |
| `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, | |
| repoAgentsNote, | |
| ]; | |
| } | |
| return [ | |
| `Configured instructionsFilePath ${instructionsFilePath}, but file could not be read; continuing without injected instructions.`, | |
| repoAgentsNote, | |
| ]; | |
| })(); | |
| const renderedPrompt = shouldUseResumeDeltaPrompt ? "" : renderTemplate(promptTemplate, templateData); | |
| const sessionHandoffNote = asString(context.paperclipSessionHandoffMarkdown, "").trim(); | |
| const prompt = joinPromptSections([ | |
| promptInstructionsPrefix, | |
| renderedBootstrapPrompt, | |
| wakePrompt, | |
| sessionHandoffNote, | |
| renderedPrompt, | |
| ]); | |
| const promptMetrics = { | |
| promptChars: prompt.length, | |
| instructionsChars, | |
| bootstrapPromptChars: renderedBootstrapPrompt.length, | |
| wakePromptChars: wakePrompt.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: resolvedCommand, | |
| cwd, | |
| commandNotes, | |
| commandArgs: args.map((value, idx) => { | |
| if (idx === args.length - 1 && value !== "-") return `<prompt ${prompt.length} chars>`; | |
| return value; | |
| }), | |
| env: loggedEnv, | |
| prompt, | |
| promptMetrics, | |
| context, | |
| }); | |
| } | |
| const proc = await runChildProcess(runId, command, args, { | |
| cwd, | |
| env, | |
| stdin: prompt, | |
| timeoutSec, | |
| graceSec, | |
| onSpawn, | |
| 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", | |
| biller: resolveCodexBiller(effectiveEnv, billingType), | |
| 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( | |
| "stdout", | |
| `[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); | |
| } | |