| import fs from "node:fs/promises"; |
| import os from "node:os"; |
| import path from "node:path"; |
| import { fileURLToPath } from "node:url"; |
| import { inferOpenAiCompatibleBiller, type AdapterExecutionContext, type AdapterExecutionResult } from "@paperclipai/adapter-utils"; |
| import { |
| asString, |
| asNumber, |
| asStringArray, |
| parseObject, |
| buildPaperclipEnv, |
| joinPromptSections, |
| redactEnvForLogs, |
| ensureAbsoluteDirectory, |
| ensureCommandResolvable, |
| ensurePaperclipSkillSymlink, |
| ensurePathInEnv, |
| renderTemplate, |
| runChildProcess, |
| readPaperclipRuntimeSkillEntries, |
| resolvePaperclipDesiredSkillNames, |
| } from "@paperclipai/adapter-utils/server-utils"; |
| import { isOpenCodeUnknownSessionError, parseOpenCodeJsonl } from "./parse.js"; |
| import { ensureOpenCodeModelConfiguredAndAvailable } from "./models.js"; |
| import { removeMaintainerOnlySkillSymlinks } from "@paperclipai/adapter-utils/server-utils"; |
| import { prepareOpenCodeRuntimeConfig } from "./runtime-config.js"; |
|
|
| const __moduleDir = path.dirname(fileURLToPath(import.meta.url)); |
|
|
| function firstNonEmptyLine(text: string): string { |
| return ( |
| text |
| .split(/\r?\n/) |
| .map((line) => line.trim()) |
| .find(Boolean) ?? "" |
| ); |
| } |
|
|
| function parseModelProvider(model: string | null): string | null { |
| if (!model) return null; |
| const trimmed = model.trim(); |
| if (!trimmed.includes("/")) return null; |
| return trimmed.slice(0, trimmed.indexOf("/")).trim() || null; |
| } |
|
|
| function resolveOpenCodeBiller(env: Record<string, string>, provider: string | null): string { |
| return inferOpenAiCompatibleBiller(env, null) ?? provider ?? "unknown"; |
| } |
|
|
| function claudeSkillsHome(): string { |
| return path.join(os.homedir(), ".claude", "skills"); |
| } |
|
|
| async function ensureOpenCodeSkillsInjected( |
| onLog: AdapterExecutionContext["onLog"], |
| skillsEntries: Array<{ key: string; runtimeName: string; source: string }>, |
| desiredSkillNames?: string[], |
| ) { |
| const skillsHome = claudeSkillsHome(); |
| await fs.mkdir(skillsHome, { recursive: true }); |
| const desiredSet = new Set(desiredSkillNames ?? skillsEntries.map((entry) => entry.key)); |
| const selectedEntries = skillsEntries.filter((entry) => desiredSet.has(entry.key)); |
| const removedSkills = await removeMaintainerOnlySkillSymlinks( |
| skillsHome, |
| selectedEntries.map((entry) => entry.runtimeName), |
| ); |
| for (const skillName of removedSkills) { |
| await onLog( |
| "stderr", |
| `[paperclip] Removed maintainer-only OpenCode skill "${skillName}" from ${skillsHome}\n`, |
| ); |
| } |
| for (const entry of selectedEntries) { |
| const target = path.join(skillsHome, entry.runtimeName); |
|
|
| try { |
| const result = await ensurePaperclipSkillSymlink(entry.source, target); |
| if (result === "skipped") continue; |
| await onLog( |
| "stderr", |
| `[paperclip] ${result === "repaired" ? "Repaired" : "Injected"} OpenCode skill "${entry.key}" into ${skillsHome}\n`, |
| ); |
| } catch (err) { |
| await onLog( |
| "stderr", |
| `[paperclip] Failed to inject OpenCode skill "${entry.key}" 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, onSpawn, authToken } = ctx; |
|
|
| const promptTemplate = asString( |
| config.promptTemplate, |
| "You are agent {{agent.id}} ({{agent.name}}). Continue your Paperclip work.", |
| ); |
| const command = asString(config.command, "opencode"); |
| const model = asString(config.model, "").trim(); |
| const variant = asString(config.variant, "").trim(); |
|
|
| const workspaceContext = parseObject(context.paperclipWorkspace); |
| const workspaceCwd = asString(workspaceContext.cwd, ""); |
| const workspaceSource = asString(workspaceContext.source, ""); |
| const workspaceId = asString(workspaceContext.workspaceId, ""); |
| const workspaceRepoUrl = asString(workspaceContext.repoUrl, ""); |
| const workspaceRepoRef = asString(workspaceContext.repoRef, ""); |
| 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 configuredCwd = asString(config.cwd, ""); |
| const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0; |
| const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd; |
| const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd(); |
| await ensureAbsoluteDirectory(cwd, { createIfMissing: true }); |
| const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir); |
| const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries); |
| await ensureOpenCodeSkillsInjected( |
| onLog, |
| openCodeSkillEntries, |
| desiredOpenCodeSkillNames, |
| ); |
|
|
| 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(","); |
| if (effectiveWorkspaceCwd) env.PAPERCLIP_WORKSPACE_CWD = effectiveWorkspaceCwd; |
| if (workspaceSource) env.PAPERCLIP_WORKSPACE_SOURCE = workspaceSource; |
| if (workspaceId) env.PAPERCLIP_WORKSPACE_ID = workspaceId; |
| if (workspaceRepoUrl) env.PAPERCLIP_WORKSPACE_REPO_URL = workspaceRepoUrl; |
| if (workspaceRepoRef) env.PAPERCLIP_WORKSPACE_REPO_REF = workspaceRepoRef; |
| if (agentHome) env.AGENT_HOME = agentHome; |
| if (workspaceHints.length > 0) env.PAPERCLIP_WORKSPACES_JSON = JSON.stringify(workspaceHints); |
|
|
| for (const [key, value] of Object.entries(envConfig)) { |
| if (typeof value === "string") env[key] = value; |
| } |
| |
| |
| |
| |
| env.OPENCODE_DISABLE_PROJECT_CONFIG = "true"; |
| if (!hasExplicitApiKey && authToken) { |
| env.PAPERCLIP_API_KEY = authToken; |
| } |
| const preparedRuntimeConfig = await prepareOpenCodeRuntimeConfig({ env, config }); |
| try { |
| const runtimeEnv = Object.fromEntries( |
| Object.entries(ensurePathInEnv({ ...process.env, ...preparedRuntimeConfig.env })).filter( |
| (entry): entry is [string, string] => typeof entry[1] === "string", |
| ), |
| ); |
| await ensureCommandResolvable(command, cwd, runtimeEnv); |
|
|
| await ensureOpenCodeModelConfiguredAndAvailable({ |
| model, |
| command, |
| cwd, |
| env: 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( |
| "stdout", |
| `[paperclip] OpenCode session "${runtimeSessionId}" was saved for cwd "${runtimeSessionCwd}" and will not be resumed in "${cwd}".\n`, |
| ); |
| } |
|
|
| const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); |
| const resolvedInstructionsFilePath = instructionsFilePath |
| ? path.resolve(cwd, instructionsFilePath) |
| : ""; |
| const instructionsDir = resolvedInstructionsFilePath ? `${path.dirname(resolvedInstructionsFilePath)}/` : ""; |
| let instructionsPrefix = ""; |
| if (resolvedInstructionsFilePath) { |
| try { |
| const instructionsContents = await fs.readFile(resolvedInstructionsFilePath, "utf8"); |
| instructionsPrefix = |
| `${instructionsContents}\n\n` + |
| `The above agent instructions were loaded from ${resolvedInstructionsFilePath}. ` + |
| `Resolve any relative file references from ${instructionsDir}.\n\n`; |
| } catch (err) { |
| const reason = err instanceof Error ? err.message : String(err); |
| await onLog( |
| "stdout", |
| `[paperclip] Warning: could not read agent instructions file "${resolvedInstructionsFilePath}": ${reason}\n`, |
| ); |
| } |
| } |
|
|
| const commandNotes = (() => { |
| const notes = [...preparedRuntimeConfig.notes]; |
| if (!resolvedInstructionsFilePath) return notes; |
| if (instructionsPrefix.length > 0) { |
| notes.push(`Loaded agent instructions from ${resolvedInstructionsFilePath}`); |
| notes.push( |
| `Prepended instructions + path directive to stdin prompt (relative references from ${instructionsDir}).`, |
| ); |
| return notes; |
| } |
| notes.push( |
| `Configured instructionsFilePath ${resolvedInstructionsFilePath}, but file could not be read; continuing without injected instructions.`, |
| ); |
| return notes; |
| })(); |
|
|
| 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: instructionsPrefix.length, |
| bootstrapPromptChars: renderedBootstrapPrompt.length, |
| sessionHandoffChars: sessionHandoffNote.length, |
| heartbeatPromptChars: renderedPrompt.length, |
| }; |
|
|
| const buildArgs = (resumeSessionId: string | null) => { |
| const args = ["run", "--format", "json"]; |
| if (resumeSessionId) args.push("--session", resumeSessionId); |
| if (model) args.push("--model", model); |
| if (variant) args.push("--variant", variant); |
| if (extraArgs.length > 0) args.push(...extraArgs); |
| return args; |
| }; |
|
|
| const runAttempt = async (resumeSessionId: string | null) => { |
| const args = buildArgs(resumeSessionId); |
| if (onMeta) { |
| await onMeta({ |
| adapterType: "opencode_local", |
| command, |
| cwd, |
| commandNotes, |
| commandArgs: [...args, `<stdin prompt ${prompt.length} chars>`], |
| env: redactEnvForLogs(preparedRuntimeConfig.env), |
| prompt, |
| promptMetrics, |
| context, |
| }); |
| } |
|
|
| const proc = await runChildProcess(runId, command, args, { |
| cwd, |
| env: runtimeEnv, |
| stdin: prompt, |
| timeoutSec, |
| graceSec, |
| onSpawn, |
| onLog, |
| }); |
| return { |
| proc, |
| rawStderr: proc.stderr, |
| parsed: parseOpenCodeJsonl(proc.stdout), |
| }; |
| }; |
|
|
| const toResult = ( |
| attempt: { |
| proc: { exitCode: number | null; signal: string | null; timedOut: boolean; stdout: string; stderr: string }; |
| rawStderr: string; |
| parsed: ReturnType<typeof parseOpenCodeJsonl>; |
| }, |
| 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 ?? |
| (clearSessionOnMissingSession ? null : 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 rawExitCode = attempt.proc.exitCode; |
| const synthesizedExitCode = parsedError && (rawExitCode ?? 0) === 0 ? 1 : rawExitCode; |
| const fallbackErrorMessage = |
| parsedError || |
| stderrLine || |
| `OpenCode exited with code ${synthesizedExitCode ?? -1}`; |
| const modelId = model || null; |
|
|
| return { |
| exitCode: synthesizedExitCode, |
| signal: attempt.proc.signal, |
| timedOut: false, |
| errorMessage: (synthesizedExitCode ?? 0) === 0 ? null : fallbackErrorMessage, |
| usage: { |
| inputTokens: attempt.parsed.usage.inputTokens, |
| outputTokens: attempt.parsed.usage.outputTokens, |
| cachedInputTokens: attempt.parsed.usage.cachedInputTokens, |
| }, |
| sessionId: resolvedSessionId, |
| sessionParams: resolvedSessionParams, |
| sessionDisplayId: resolvedSessionId, |
| provider: parseModelProvider(modelId), |
| biller: resolveOpenCodeBiller(runtimeEnv, parseModelProvider(modelId)), |
| model: modelId, |
| billingType: "unknown", |
| costUsd: attempt.parsed.costUsd, |
| resultJson: { |
| stdout: attempt.proc.stdout, |
| stderr: attempt.proc.stderr, |
| }, |
| summary: attempt.parsed.summary, |
| clearSession: Boolean(clearSessionOnMissingSession && !attempt.parsed.sessionId), |
| }; |
| }; |
|
|
| const initial = await runAttempt(sessionId); |
| const initialFailed = |
| !initial.proc.timedOut && ((initial.proc.exitCode ?? 0) !== 0 || Boolean(initial.parsed.errorMessage)); |
| if ( |
| sessionId && |
| initialFailed && |
| isOpenCodeUnknownSessionError(initial.proc.stdout, initial.rawStderr) |
| ) { |
| await onLog( |
| "stdout", |
| `[paperclip] OpenCode session "${sessionId}" is unavailable; retrying with a fresh session.\n`, |
| ); |
| const retry = await runAttempt(null); |
| return toResult(retry, true); |
| } |
|
|
| return toResult(initial); |
| } finally { |
| await preparedRuntimeConfig.cleanup(); |
| } |
| } |
|
|