import { spawn, type ChildProcess } from "node:child_process"; import { randomBytes } from "node:crypto"; import { accessSync, constants, readFileSync, realpathSync, statSync } from "node:fs"; import { createRequire } from "node:module"; import { chmod, mkdir, mkdtemp, readFile, readdir, realpath, rm, writeFile, } from "node:fs/promises"; import { createServer } from "node:net"; import { Agent as HttpAgent, createServer as createHttpServer, request as requestHttp, type IncomingMessage } from "node:http"; import { Agent as HttpsAgent, request as requestHttps } from "node:https"; import { getCACertificates } from "node:tls"; import { pipeline } from "node:stream/promises"; import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import { CODEX_SKILLLESS_BASE_INSTRUCTIONS, createCodexTaskEnvelope, type CodexTaskEnvelope, } from "../../contracts/codex.js"; import { PRP_BLOCK_TOOL_NAME, PRP_COMPLETION_TOOL_NAME, } from "../../contracts/completion-result.js"; import type { HarnessDriver, HarnessDriverConfigValidation, HarnessDriverDescriptor, HarnessSession, HarnessSessionRecoveryResult, HarnessTranscriptSnapshot, OpenHarnessSessionInput, PersistedHarnessSession, HarnessRuntimeRequest, HarnessRuntimeRequestHandoff, HarnessRuntimeRequestResolution, PaperclipQuestion, PaperclipQuestionResponse, PaperclipQuestionSet, } from "../../contracts/harness-driver.js"; import { PAPERCLIP_QUESTION_SET_SCHEMA, PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, harnessRuntimeInputExpiredOutcome, harnessRuntimeRequestOutcome, parseHarnessRuntimeRequestResolution, parsePaperclipQuestionSet, } from "../../contracts/harness-driver.js"; import type { NativeSessionCapabilities, NativeUserMessage, } from "../../contracts/types.js"; import type { NativeRuntimeContextSnapshot } from "../../contracts/runtime-context.js"; import { createProviderTraceFileSink, type ProviderTraceFileSink, } from "../../contracts/provider-trace-file-sink.js"; import type { PrpEvent, PrpStructuredRunResult, } from "../../protocol/replay-contract.js"; import { validatePrpStructuredRunResult } from "../../protocol/replay-contract.js"; import { paperclipWorkspaceFileReferencesFromText } from "../../live/workspace-file-reference.js"; import { canonicalOpenCodeDisplayToolName, canonicalProviderEventsFromOpenCodePart, providerFamilyCapabilities, } from "../../provider-events.js"; import { canonicalOpenCodeMcpToolName, startOpenCodeMcpBridge, type OpenCodeMcpBridge, } from "./mcp-bridge.js"; import { nativeMcpLaunchBinding } from "../native-mcp.js"; import { materializeNativeRuntimeSkills } from "../runtime-context-materializer.js"; export const OPENCODE_SERVER_DRIVER_KIND = "opencode_server" as const; export const QUALIFIED_OPENCODE_VERSION = "1.18.34" as const; export const QUALIFIED_OPENCODE_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731" as const; /** Resolve a declared, pinned native dependency without PATH or install fallback. */ export function resolvePinnedOpenCodeCommand( issuer: string | URL = import.meta.url, target: { platform?: string; architecture?: string } = {}, ): string { try { const platform = target.platform ?? process.platform; const architecture = target.architecture ?? process.arch; const packageNames: Record = { "linux-x64": ["opencode-linux-x64-baseline"], "darwin-arm64": ["opencode-darwin-arm64"], "darwin-x64": ["opencode-darwin-x64-baseline"], }; const candidates = packageNames[`${platform}-${architecture}`]; if (!candidates) { throw new Error("OpenCode native target is not qualified"); } const manifestPath = realpathSync(createRequire(issuer).resolve("opencode-ai/package.json")); const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); if (manifest.name !== "opencode-ai" || manifest.version !== QUALIFIED_OPENCODE_VERSION) { throw new Error(`OpenCode package version mismatch: expected opencode-ai@${QUALIFIED_OPENCODE_VERSION}`); } const dependencyRequire = createRequire(manifestPath); for (const name of candidates) { if (manifest.optionalDependencies?.[name] !== QUALIFIED_OPENCODE_VERSION) { throw new Error("OpenCode platform dependency does not match its qualified version"); } let platformManifest: string; try { platformManifest = realpathSync(dependencyRequire.resolve(`${name}/package.json`)); } catch (error) { if (["MODULE_NOT_FOUND", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) { continue; } throw error; } const metadata = JSON.parse(readFileSync(platformManifest, "utf8")); if (metadata.name !== name || metadata.version !== QUALIFIED_OPENCODE_VERSION || !Array.isArray(metadata.os) || !metadata.os.includes(platform) || !Array.isArray(metadata.cpu) || !metadata.cpu.includes(architecture)) { throw new Error("OpenCode installed platform artifact identity mismatch"); } const root = dirname(platformManifest); const executable = realpathSync(resolve(root, "bin/opencode")); const inside = relative(root, executable); if (!inside || inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside)) { throw new Error("OpenCode executable escapes its qualified platform package"); } if (!statSync(executable).isFile()) { throw new Error("OpenCode executable is not a regular file"); } accessSync(executable, constants.X_OK); return executable; } throw new Error("OpenCode qualified platform dependency is missing"); } catch (error) { throw new Error( `Pinned OpenCode runtime unavailable: ${(error as Error).message}. Restore Paperclip's runtime dependencies.`, { cause: error }, ); } } type DynamicToolHandler = (call: { tool: string; callId: string; threadId: string; turnId: string; arguments: unknown; }) => Promise; export type OpenCodeCompletionFeedback = (result: PrpStructuredRunResult, call: { tool: string; callId: string; threadId: string; turnId: string; }) => Promise; export interface OpenCodeServerDriverOptions { model: string; permissionMode?: "allow" | "ask" | "deny"; taskEnvelope?: CodexTaskEnvelope; conversationMode?: "task" | "prepared"; runnerInstanceId?: string; command?: string; /** Inherited runner-owned executable descriptor duplicated into the child. */ commandFd?: number; /** Runner-owned executable path lifecycle used only by the macOS proxy. */ commandLifecycle?: { beforeSpawn(): void; afterSpawn(): void; }; runtimeDirectory: string; systemInstructions?: string; runtimeContext?: NativeRuntimeContextSnapshot | null; environment?: NodeJS.ProcessEnv; dynamicTools?: readonly Readonly>[]; dynamicToolHandler?: DynamicToolHandler; completionFeedback?: OpenCodeCompletionFeedback; onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string; }) => Promise; /** Keep false when an outer supervisor already owns the process group. */ isolateProcessGroup?: boolean; onDiagnostic?: (message: string) => void; fetch?: typeof globalThis.fetch; now?: () => Date; } interface OpenCodeRuntime { baseUrl: string; authHeader: string; version: string; permissionMode: "allow" | "ask" | "deny"; process: ChildProcess; bridge: OpenCodeMcpBridge; trace: ProviderTraceFileSink | null; sensitiveValues: readonly string[]; close(input?: { finalizeTrace?: boolean; reason?: string | null; }): Promise; } const CAPABILITIES: NativeSessionCapabilities = { resume: true, toolRefreshOnResume: true, typedEvents: true, typedEventFamilies: providerFamilyCapabilities({ tool_execution: "available", research: "available", delegation: "available", context: "available", artifact: "policy_disabled", provider_notice: "available", }), steering: false, interruption: true, structuredResult: true, read: true, reconciliation: true, usage: true, dynamicTools: true, runtimeRequestResolution: true, runtimeRequestHandoff: true, goals: false, threadLineage: false, unsupported: ["steering", "goals", "threadLineage"], }; export class OpenCodeServerDriver implements HarnessDriver { readonly #options: OpenCodeServerDriverOptions; constructor(options: OpenCodeServerDriverOptions) { this.#options = options; } async descriptor(): Promise { return { kind: OPENCODE_SERVER_DRIVER_KIND, displayName: "OpenCode server", version: QUALIFIED_OPENCODE_VERSION, protocolVersion: "http+sse/v1", runtimeContextCapabilities: { instructions: "native", skills: "native", mcp: "native", }, capabilities: structuredClone(CAPABILITIES), }; } async validateConfig( config: unknown, ): Promise { const candidate = isRecord(config) ? config : {}; const issues = []; const model = typeof candidate.model === "string" ? candidate.model.trim() : ""; if (!validModel(model)) issues.push({ path: "model", code: "invalid_model", message: "OpenCode model must use provider/model form.", }); const command = typeof candidate.command === "string" ? candidate.command.trim() : "opencode"; if (!command) issues.push({ path: "command", code: "invalid_command", message: "OpenCode command cannot be empty.", }); const permissionMode = candidate.permissionMode ?? "allow"; if ( permissionMode !== "allow" && permissionMode !== "ask" && permissionMode !== "deny" ) { issues.push({ path: "permissionMode", code: "invalid_permission_mode", message: "OpenCode permission mode must be allow, ask, or deny.", }); } return issues.length === 0 ? { ok: true, config: { model, command, permissionMode }, issues: [] } : { ok: false, config: null, issues }; } async openSession(input: OpenHarnessSessionInput): Promise { return this.#open(input, null); } async recoverSession( snapshot: PersistedHarnessSession, ): Promise { if ( snapshot.driverKind !== OPENCODE_SERVER_DRIVER_KIND || !snapshot.runId || !snapshot.normalizedSessionId || !snapshot.driverSessionId ) return { recovered: false, reason: "persisted OpenCode session identity is incomplete", }; try { const session = await this.#open( { runId: snapshot.runId, normalizedSessionId: snapshot.normalizedSessionId, workingDirectory: await this.#readWorkspace( snapshot.normalizedSessionId, ), }, snapshot, ); return { recovered: true, session }; } catch (error) { return { recovered: false, reason: redact(String(error), [ this.#options.environment?.OPENROUTER_API_KEY, this.#options.environment?.PAPERCLIP_AI_PROVIDER_KEY, ]), }; } } async #readWorkspace(normalizedSessionId: string): Promise { const raw = await readFile( join( sessionRoot(this.#options.runtimeDirectory, normalizedSessionId), "workspace", ), "utf8", ); return raw.trim(); } async #open( input: OpenHarnessSessionInput, snapshot: PersistedHarnessSession | null, ): Promise { const cwd = validateWorkspace(input.workingDirectory); const root = sessionRoot( this.#options.runtimeDirectory, input.normalizedSessionId, ); await mkdir(root, { recursive: true, mode: 0o700 }); await writeFile(join(root, "workspace"), `${cwd}\n`, { mode: 0o600 }); const trace = await createProviderTraceFileSink({ path: this.#options.environment?.PAPERCLIP_PROVIDER_TRACE_PATH, provider: "opencode", channel: "typescript_opencode_native", maxBytes: this.#options.environment?.PAPERCLIP_PROVIDER_TRACE_MAX_BYTES, }); let lastError: unknown = null; for (let attempt = 1; attempt <= 3; attempt += 1) { let session: OpenCodeHarnessSession | null = null; let runtime: OpenCodeRuntime | null = null; try { runtime = await startRuntime({ options: this.#options, root, cwd, trace, dispatch: (call) => { if (session === null) throw new Error("OpenCode session is not ready for tool calls"); return session.dispatchTool(call); }, }); const fetcher = this.#options.fetch ?? globalThis.fetch; let providerSessionId = snapshot?.providerSessionId ?? snapshot?.driverSessionId ?? null; if (providerSessionId !== null) { const existing = await api( fetcher, runtime, `/session/${encodeURIComponent(providerSessionId)}`, ); if (!isRecord(existing) || text(existing.id) !== providerSessionId) throw new Error("OpenCode resumed a different session"); } else { const created = await api(fetcher, runtime, "/session", { method: "POST", body: JSON.stringify({ title: `Paperclip ${input.runId}` }), }); providerSessionId = text(record(created).id); if (!providerSessionId) throw new Error("OpenCode session creation omitted its id"); } session = new OpenCodeHarnessSession({ runtime, fetcher, runId: input.runId, normalizedSessionId: input.normalizedSessionId, providerSessionId, workingDirectory: cwd, runnerInstanceId: this.#options.runnerInstanceId ?? `paperclip-opencode-${input.runId}`, model: this.#options.model, taskEnvelope: this.#options.taskEnvelope ?? createCodexTaskEnvelope({ objective: "Complete the supplied task.", }), conversationMode: this.#options.conversationMode, systemInstructions: this.#options.systemInstructions ?? CODEX_SKILLLESS_BASE_INSTRUCTIONS, dynamicToolHandler: this.#options.dynamicToolHandler, completionFeedback: this.#options.completionFeedback, snapshot, now: this.#options.now ?? (() => new Date()), }); session.startEventPump(); return session; } catch (error) { lastError = error; await runtime?.close({ finalizeTrace: false }); if (attempt === 3 || !retryableOpenCodeStartupError(error)) { await trace?.finish({ reason: "opencode_session_start_failed" }); throw error; } this.#options.onDiagnostic?.( `OpenCode session startup attempt ${attempt} failed; retrying.`, ); await new Promise((resolveDelay) => setTimeout(resolveDelay, 100 * attempt), ); } } throw lastError; } } class OpenCodeHarnessSession implements HarnessSession { readonly #runtime: OpenCodeRuntime; readonly #fetch: typeof globalThis.fetch; #runId: string; readonly #normalizedSessionId: string; readonly #providerSessionId: string; readonly #workingDirectory: string; readonly #runnerInstanceId: string; readonly #model: string; readonly #taskEnvelope: CodexTaskEnvelope; readonly #systemInstructions: string; readonly #dynamicToolHandler?: DynamicToolHandler; readonly #completionFeedback?: OpenCodeCompletionFeedback; readonly #now: () => Date; readonly #events = new AsyncQueue(); readonly #transcript: PrpEvent[] = []; readonly #terminalTurns = new Map(); readonly #seenProviderEvents = new Set(); // The turn that created each native message. A raw OpenCode frame carries // no turn identity of its own, so this map — not the mutable active-turn // pointer, which can already have moved on to a later turn by the time a // straggling frame for this message arrives — is the source of truth for // which turn a message's content belongs to. readonly #messageTurnIds = new Map(); readonly #messageRoles = new Map(); readonly #pendingMessageParts = new Map< string, Array> >(); readonly #partText = new Map(); readonly #completedTextPartIds = new Set(); readonly #completedReasoningPartIds = new Set(); readonly #completedTextParts: Array<{ partId: string; messageId: string | null; text: string; item: Record; observedSourceSeq: number; }> = []; readonly #messageUsageFingerprints = new Map(); readonly #workspaceChangesByTurn = new Map>(); readonly #emittedFileReferences = new Set(); readonly #pendingRuntimeRequests = new Map< string, { request: HarnessRuntimeRequest; nativeQuestions: Record[]; submittedResponse?: PaperclipQuestionResponse; submittedAction?: "accept" | "accept_for_session" | "decline" | "cancel"; settling?: boolean; } >(); #activeTraceFrameId: number | null = null; #activeTraceEmittedEventIds: string[] = []; #sourceSequence: number; #activeTurnId: string | null; #result: PrpStructuredRunResult | null; #resultFingerprint: string | null; #resultCallId: string | null; #resultTurnId: string | null; #semanticResultTextBoundary: number | null = null; #semanticResultProviderMessageId: string | null = null; #lastNonTerminalToolSourceSeq = 0; #usage: Record | null = null; readonly #conversationMode: "task" | "prepared"; #sendFullContext: boolean; #closed = false; #completionSettlement: Promise | null = null; #abort = new AbortController(); constructor(input: { runtime: OpenCodeRuntime; fetcher: typeof globalThis.fetch; runId: string; normalizedSessionId: string; providerSessionId: string; workingDirectory: string; runnerInstanceId: string; model: string; conversationMode?: "task" | "prepared"; taskEnvelope: CodexTaskEnvelope; systemInstructions: string; dynamicToolHandler?: DynamicToolHandler; completionFeedback?: OpenCodeCompletionFeedback; snapshot: PersistedHarnessSession | null; now: () => Date; }) { this.#runtime = input.runtime; this.#fetch = input.fetcher; this.#runId = input.runId; this.#sourceSequence = 0; this.#normalizedSessionId = input.normalizedSessionId; this.#providerSessionId = input.providerSessionId; this.#workingDirectory = input.workingDirectory; this.#runnerInstanceId = input.runnerInstanceId; this.#model = input.model; this.#conversationMode = input.conversationMode ?? "task"; this.#taskEnvelope = input.taskEnvelope; this.#systemInstructions = input.systemInstructions; this.#dynamicToolHandler = input.dynamicToolHandler; this.#completionFeedback = input.completionFeedback; this.#now = input.now; this.#sendFullContext = input.snapshot === null && this.#conversationMode !== "prepared"; this.#sourceSequence = input.snapshot?.lastSourceSequence ?? 0; this.#activeTurnId = input.snapshot?.activeTurnId ?? null; const restored = input.snapshot?.semanticResult ?? null; this.#result = restored?.result ?? null; this.#resultFingerprint = restored?.fingerprint ?? null; this.#resultCallId = restored?.callId ?? null; this.#resultTurnId = restored?.turnId ?? null; for (const terminal of input.snapshot?.terminalTurns ?? []) { this.#terminalTurns.set(terminal.turnId, terminal.fingerprint); } if (this.#activeTurnId && this.#terminalTurns.has(this.#activeTurnId)) { this.#activeTurnId = null; } this.#emit(input.snapshot ? "session.resumed" : "session.started", { driverSessionId: input.providerSessionId, providerSessionId: input.providerSessionId, context: { protocolVersion: "http+sse/v1", opencodeVersion: input.runtime.version, model: input.model, modelProvider: input.model.split("/", 1)[0], workingDirectory: input.workingDirectory, environmentKeys: sanitizedEnvironmentKeys(), permissionMode: input.runtime.permissionMode, }, }); this.#emit( "item.completed", { kind: "model", text: `${input.model} (OpenCode ${input.runtime.version})`, model: { name: input.model, provider: input.model.split("/", 1)[0], opencodeVersion: input.runtime.version, }, }, { itemId: `${input.providerSessionId}:model` }, ); } ids() { return { driverSessionId: this.#providerSessionId, providerSessionId: this.#providerSessionId, displayId: this.#providerSessionId, }; } attachRun(input: { runId: string }): void { if (this.#activeTurnId !== null) throw new Error("opencode_run_attach_busy"); if (!input.runId) throw new Error("opencode_run_attach_invalid"); this.#runId = input.runId; this.#result = null; this.#resultFingerprint = null; this.#resultCallId = null; this.#resultTurnId = null; this.#semanticResultTextBoundary = null; this.#semanticResultProviderMessageId = null; this.#lastNonTerminalToolSourceSeq = 0; this.#completedTextPartIds.clear(); this.#completedReasoningPartIds.clear(); this.#completedTextParts.length = 0; // `#terminalTurns` clears here for its own persisted-snapshot bookkeeping. // The late-frame gate in `#emit` does not depend on this map: it compares // against `#activeTurnId` directly, so a frame for the just-finished turn // stays blocked even after this new run attaches (see `#emit`). this.#terminalTurns.clear(); this.#sendFullContext = false; this.#emit("run.attached", { runId: input.runId, sameSession: true }); } events(): AsyncIterable { return this.#events; } startEventPump(): void { void this.#recoverPendingRuntimeRequests() .catch((error) => this.#emit("harness.diagnostic", { code: "opencode_runtime_request_recovery_failed", message: redact(String(error), this.#runtime.sensitiveValues), }), ) .finally(() => this.#pumpEvents()); } async startTurn(input: { message: NativeUserMessage; }): Promise<{ turnId: string }> { if (this.#activeTurnId !== null) throw new Error("OpenCode session already has an active turn"); const turnId = `turn-${randomBytes(12).toString("hex")}`; this.#activeTurnId = turnId; this.#emit("turn.submitted", { envelopeSchema: this.#taskEnvelope.schema, text: input.message.text, }); this.#emit("turn.accepted", { turnId }, { turnId }); this.#emit("turn.started", { status: "inProgress" }, { turnId }); const [providerID, ...modelParts] = this.#model.split("/"); const modelID = modelParts.join("/"); // A resumed OpenCode provider session already retains the original system // instructions and task envelope in its conversation. Repeating both on // every Paperclip continuation can overflow smaller context windows and // OpenCode then completes with `finish: unknown` and zero tokens. The // native model envelope still carries the authoritative wake delta, // interaction responses, completion contract, and current issue context. const prompt = this.#sendFullContext ? JSON.stringify({ task: this.#taskEnvelope, message: input.message.text, }) : input.message.text; await api( this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/prompt_async`, { method: "POST", body: JSON.stringify({ // OpenCode 1.18's PromptPayload carries the provider and model at the // top level. Older examples used a nested `model` object; 1.18 // silently ignores that shape and falls back to the configured model, // which can turn `openrouter/deepseek/...` into a duplicated provider // lookup. Keep Paperclip's persisted provider/model form, but adapt it // at this HTTP boundary. providerID, modelID, // Keep question enabled in the isolated config. OpenCode 1.18.32 // turns a prompt's deprecated `tools` map into replacement session // permissions, so a sparse override here discards the session policy. ...(this.#sendFullContext ? { system: this.#systemInstructions } : {}), parts: [{ type: "text", text: prompt }], }), }, ); this.#sendFullContext = false; return { turnId }; } async interrupt(input: { turnId?: string; reason?: string }): Promise { await this.#completionSettlement; if ( input.turnId && this.#activeTurnId && input.turnId !== this.#activeTurnId ) throw new Error("stale OpenCode turn"); await api( this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/abort`, { method: "POST" }, ); } pendingRuntimeRequests(): HarnessRuntimeRequest[] { return [...this.#pendingRuntimeRequests.values()].map(({ request }) => structuredClone(request), ); } async resolveRuntimeRequest(input: { requestId: string; turnId: string; resolution: HarnessRuntimeRequestResolution; }): Promise { const pending = this.#pendingRuntimeRequests.get(input.requestId); if (!pending) throw new Error( `OpenCode request ${input.requestId} is no longer pending`, ); if ( pending.request.turnId !== input.turnId || this.#activeTurnId !== input.turnId ) { throw new Error( `OpenCode request ${input.requestId} belongs to a stale turn`, ); } const resolution = parseHarnessRuntimeRequestResolution( pending.request.requestKind, input.resolution, pending.request.input, ); if (pending.settling) throw new Error( `OpenCode request ${input.requestId} is already settling`, ); pending.settling = true; const submit = async (operation: Promise) => { try { await operation; } catch (error) { if (this.#pendingRuntimeRequests.get(input.requestId) === pending) pending.settling = false; throw error; } }; const workspace = `directory=${encodeURIComponent(this.#workingDirectory)}`; if (pending.request.requestKind === "permission_approval") { const action = resolution.action === "accept" || resolution.action === "accept_for_session" ? resolution.action : resolution.action === "decline" || resolution.action === "cancel" ? resolution.action : "decline"; pending.submittedAction = action; await submit( api( this.#fetch, this.#runtime, `/permission/${encodeURIComponent(input.requestId)}/reply?${workspace}`, { method: "POST", body: JSON.stringify({ reply: action === "accept" ? "once" : action === "accept_for_session" ? "always" : "reject", }), }, ), ); if (!this.#pendingRuntimeRequests.delete(input.requestId)) return; this.#emit( "runtime_request.resolved", harnessRuntimeRequestOutcome(pending.request, { action }), { turnId: input.turnId, itemId: pending.request.itemId, }, ); return; } const base = `/question/${encodeURIComponent(input.requestId)}`; if (resolution.action === "submit" && "response" in resolution) { // OpenCode can broadcast question.replied before the reply HTTP request // returns. Retain the canonical response before crossing that boundary // so the racing terminal event carries the same durable answer record. pending.submittedResponse = structuredClone(resolution.response); await submit( api(this.#fetch, this.#runtime, `${base}/reply?${workspace}`, { method: "POST", body: JSON.stringify({ answers: openCodeAnswers(pending, resolution.response), }), }), ); } else if (resolution.action === "submit" && "answers" in resolution) { await submit( api(this.#fetch, this.#runtime, `${base}/reply?${workspace}`, { method: "POST", body: JSON.stringify({ answers: pending.nativeQuestions.map( (question, index) => resolution.answers[openCodeQuestionId(question, index)] ?.answers ?? [], ), }), }), ); } else { await submit( api(this.#fetch, this.#runtime, `${base}/reject?${workspace}`, { method: "POST", }), ); } // question.replied/question.rejected can race the HTTP response on the SSE // stream. The first terminal fact wins; the echo must not emit a duplicate. if (!this.#pendingRuntimeRequests.delete(input.requestId)) return; this.#emit( "runtime_request.resolved", harnessRuntimeRequestOutcome(pending.request, { action: resolution.action, ...(resolution.action === "submit" && "response" in resolution ? { response: resolution.response } : {}), }), { turnId: input.turnId, itemId: pending.request.itemId }, ); } handoffRuntimeRequest(input: { requestId: string; turnId: string; reason: "durable_handoff"; signal: AbortSignal; }): HarnessRuntimeRequestHandoff { if (input.signal.aborted) { return { result: "already_settled", cleanup: Promise.resolve() }; } const pending = this.#pendingRuntimeRequests.get(input.requestId); if ( !pending || pending.request.input === undefined || pending.request.turnId !== input.turnId || this.#activeTurnId !== input.turnId || pending.settling || pending.submittedResponse !== undefined || pending.submittedAction !== undefined ) return { result: "already_settled", cleanup: Promise.resolve() }; if (!this.#pendingRuntimeRequests.delete(input.requestId)) { return { result: "already_settled", cleanup: Promise.resolve() }; } this.#emit( "runtime_request.expired", harnessRuntimeInputExpiredOutcome(pending.request, input.reason), { turnId: input.turnId, itemId: pending.request.itemId }, ); const workspace = `directory=${encodeURIComponent(this.#workingDirectory)}`; const cleanup = Promise.allSettled([ api( this.#fetch, this.#runtime, `/question/${encodeURIComponent(input.requestId)}/reject?${workspace}`, { method: "POST" }, ), api( this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/abort`, { method: "POST" }, ), ]).then(() => undefined); return { result: "handed_off", cleanup }; } async read(): Promise> { const messages = await api( this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/message`, ); return { sessionId: this.#providerSessionId, messages }; } async reconcile(): Promise> { const status = await api(this.#fetch, this.#runtime, "/session/status"); return { sessionId: this.#providerSessionId, status: record(status)[this.#providerSessionId] ?? null, }; } async usage(): Promise | null> { return this.#usage === null ? null : structuredClone(this.#usage); } async transcript(): Promise { return { schema: "paperclip-runner/harness-transcript/v1", complete: true, eventCount: this.#transcript.length, events: structuredClone(this.#transcript), omissionReason: null, }; } async snapshot(): Promise { return { driverKind: OPENCODE_SERVER_DRIVER_KIND, driverSessionId: this.#providerSessionId, providerSessionId: this.#providerSessionId, runId: this.#runId, normalizedSessionId: this.#normalizedSessionId, activeTurnId: this.#activeTurnId, semanticResult: this.#result && this.#resultFingerprint && this.#resultTurnId ? { result: structuredClone(this.#result), fingerprint: this.#resultFingerprint, callId: this.#resultCallId, turnId: this.#resultTurnId, } : null, terminalTurns: [...this.#terminalTurns].map(([turnId, fingerprint]) => ({ turnId, fingerprint, })), pendingRuntimeRequests: this.pendingRuntimeRequests(), lastSourceSequence: this.#sourceSequence, }; } async close(): Promise { if (this.#closed) return; // A controller response is authoritative. Settle its tool result before // closing the provider or the event stream, rather than reject after acceptance. await this.#completionSettlement; if (this.#closed) return; this.#closed = true; this.#abort.abort(); // Settle whatever is still pending, including a request whose turn // already went terminal without the driver observing it (see // `#settlePendingRuntimeRequestsForTurn`). Bypass the terminal-turn // gate so each settlement event still reaches the consumer instead of // getting dropped as a late frame. for (const requestId of [...this.#pendingRuntimeRequests.keys()]) this.#settlePendingRuntimeRequest(requestId); this.#events.close(); await this.#runtime.close(); } // A request can outlive its own turn: the turn can fail or get cancelled // while the request is still pending, which clears `#activeTurnId` // without settling the request. Settle any such request the moment its // owning turn goes terminal, before emitting the terminal turn event // itself, so the settlement event reaches the consumer within the same // turn it belongs to instead of waiting for a later, separate close. #settlePendingRuntimeRequestsForTurn(turnId: string): void { const requestIds = [...this.#pendingRuntimeRequests] .filter(([, pending]) => pending.request.turnId === turnId) .map(([requestId]) => requestId); for (const requestId of requestIds) this.#settlePendingRuntimeRequest(requestId); } #settlePendingRuntimeRequest(requestId: string): void { const pending = this.#pendingRuntimeRequests.get(requestId); if (!pending) return; this.#pendingRuntimeRequests.delete(requestId); const { request } = pending; this.#emit( request.input === undefined ? "runtime_request.cancelled" : "runtime_request.expired", request.input === undefined ? harnessRuntimeRequestOutcome(request, { reason: "session_closed" }) : harnessRuntimeInputExpiredOutcome(request, "provider_process_lost"), { turnId: request.turnId, itemId: request.itemId }, { bypassTerminalTurnGate: true }, ); } async dispatchTool(call: { tool: string; callId: string; arguments: unknown; }): Promise { const tool = canonicalOpenCodeMcpToolName(call.tool); const turnId = this.#activeTurnId; if (turnId === null) throw new Error("OpenCode tool call is not bound to an active turn"); this.#emit( "item.started", { kind: "dynamicToolCall", item: { type: "tool_call", id: call.callId, name: tool, arguments: call.arguments, }, }, { turnId, itemId: call.callId }, ); if (tool === PRP_COMPLETION_TOOL_NAME || tool === PRP_BLOCK_TOOL_NAME) { let settle: (() => void) | undefined; try { if (this.#closed || this.#completionSettlement) throw new Error("A completion is already settling or the session is closed"); const validation = validatePrpStructuredRunResult(call.arguments); if (!validation.ok) throw new Error("Invalid semantic result"); if ( (tool === PRP_BLOCK_TOOL_NAME && validation.result.reportedWorkDisposition !== "blocked") || (tool === PRP_COMPLETION_TOOL_NAME && validation.result.reportedWorkDisposition === "blocked") ) throw new Error( "Semantic result disposition does not match the terminal tool", ); if ( validation.result.completionClaim.contractRevision !== this.#taskEnvelope.completionContract.revision ) { throw new Error( "Semantic result completion contract revision does not match", ); } const expectedIds = this.#taskEnvelope.completionContract.criteria.map((criterion) => criterion.id); const receivedIds = validation.result.completionClaim.criteria.map((criterion) => criterion.criterionId); if (receivedIds.length !== expectedIds.length || new Set(receivedIds).size !== receivedIds.length || receivedIds.some((id) => !expectedIds.includes(id))) { // Reject at the tool boundary so the provider can repair its claim. // Emitting a bad result here kills runnerd's strict outer validation. throw new Error(`Semantic result criteria must contain exactly these criterionIds, once each: ${JSON.stringify(expectedIds)}. Keep contractRevision ${JSON.stringify(this.#taskEnvelope.completionContract.revision)}.`); } const fingerprint = canonicalJson(validation.result); if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint) throw new Error("A different semantic result was already committed"); this.#completionSettlement = new Promise(resolve => { settle = resolve; }); // Wait for the bound controller before committing or resolving the // provider call. A rejection remains repairable in this same turn. const feedback = this.#completionFeedback ? await this.#completionFeedback(validation.result, { tool, callId: call.callId, threadId: this.#providerSessionId, turnId, }) : "Semantic completion accepted."; if (typeof feedback !== "string" || !feedback.trim()) throw new Error("Completion feedback omitted its response text"); if (this.#resultFingerprint && this.#resultFingerprint !== fingerprint) throw new Error("A different semantic result was already committed"); if (!this.#resultFingerprint) { this.#result = structuredClone(validation.result); this.#resultFingerprint = fingerprint; this.#resultCallId = call.callId; this.#resultTurnId = turnId; this.#semanticResultTextBoundary = this.#completedTextParts.length; this.#emit("run.result.proposed", validation.result, { turnId, itemId: call.callId, }); } this.#emit( "item.completed", { kind: "dynamicToolCall", item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, result: feedback, }, }, { turnId, itemId: call.callId }, ); return { accepted: true, feedback }; } catch (error) { // A rejected semantic call still completes its tool activity item. // Otherwise a later question can appear to have an in-flight tool. this.#emit("item.completed", { kind: "dynamicToolCall", item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, is_error: true, error: error instanceof Error ? error.message : String(error) }, }, { turnId, itemId: call.callId }); throw error; } finally { if (settle) { this.#completionSettlement = null; settle(); } } } if (!this.#dynamicToolHandler) throw new Error("Unsupported Paperclip operation"); try { const result = await this.#dynamicToolHandler({ tool, callId: call.callId, threadId: this.#providerSessionId, turnId, arguments: call.arguments, }); this.#emit( "item.completed", { kind: "dynamicToolCall", item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, result, }, }, { turnId, itemId: call.callId }, ); return result; } catch (error) { this.#emit( "item.completed", { kind: "dynamicToolCall", item: { type: "tool_result", id: call.callId, tool_use_id: call.callId, error: redact(String(error)), is_error: true, }, }, { turnId, itemId: call.callId }, ); throw error; } } async #recoverPendingRuntimeRequests(): Promise { await this.#recoverPendingQuestions(); const value = await api( this.#fetch, this.#runtime, `/permission?directory=${encodeURIComponent(this.#workingDirectory)}`, ); const pending = Array.isArray(value) ? value : Array.isArray(record(value).permissions) ? (record(value).permissions as unknown[]) : []; for (const entry of pending) this.#acceptPermission(record(entry), "permission.recovered"); } async #recoverPendingQuestions(): Promise { const value = await api( this.#fetch, this.#runtime, `/question?directory=${encodeURIComponent(this.#workingDirectory)}`, ); const pending = Array.isArray(value) ? value : Array.isArray(record(value).questions) ? (record(value).questions as unknown[]) : []; for (const entry of pending) { const question = record(entry); const sessionId = text(question.sessionID, text(question.sessionId)); if (sessionId && sessionId !== this.#providerSessionId) continue; this.#acceptQuestion(question); } } #acceptQuestion(properties: Record): void { const turnId = this.#activeTurnId; if (!turnId) return; const requestId = text( properties.id, text(properties.requestID, text(properties.requestId)), ); const nativeQuestions = Array.isArray(properties.questions) ? properties.questions.map(record).slice(0, 64) : []; if (!requestId || nativeQuestions.length === 0) { this.#emit( "harness.diagnostic", { code: "runtime_input_rejected", adapter: "opencode-server", reason: "OpenCode emitted a question event without a request id or supported questions.", }, { turnId, ...(requestId ? { itemId: requestId } : {}) }, ); return; } if (this.#pendingRuntimeRequests.has(requestId)) return; let input: PaperclipQuestionSet; try { input = normalizeOpenCodeQuestionSet(nativeQuestions, properties); } catch { this.#emit( "harness.diagnostic", { code: "runtime_input_rejected", adapter: "opencode-server", reason: "OpenCode emitted a malformed or ambiguous question set.", }, { turnId, itemId: requestId }, ); return; } const request: HarnessRuntimeRequest = { requestId, requestKind: "user_input", method: "question.asked", turnId, itemId: requestId, status: "pending", prompt: "OpenCode requests user input.", details: {}, input, origin: { adapter: "opencode-server", provider: "opencode", method: "question.asked", }, }; this.#pendingRuntimeRequests.set(requestId, { request, nativeQuestions }); this.#emit( "runtime_request.created", { request: { schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, requestKind: "runtime", requestId, type: "input", status: "pending", prompt: request.prompt, input, origin: request.origin, turnId, itemId: request.itemId, }, }, { turnId, itemId: request.itemId }, ); } #acceptPermission(properties: Record, method: string): void { const turnId = this.#activeTurnId; if (!turnId) return; const permission = isRecord(properties.permission) ? properties.permission : isRecord(properties.request) ? properties.request : properties; const requestId = text( permission.id, text( permission.requestID, text(permission.requestId, text(permission.permissionID)), ), ); if (!requestId || this.#pendingRuntimeRequests.has(requestId)) return; const title = text( permission.title, text(permission.permission, text(permission.tool, "requested operation")), ); const request: HarnessRuntimeRequest = { requestId, requestKind: "permission_approval", method, turnId, itemId: requestId, status: "pending", prompt: `OpenCode requests permission for ${title}.`.slice(0, 4000), details: bounded(permission), origin: { adapter: "opencode-server", provider: "opencode", method }, }; this.#pendingRuntimeRequests.set(requestId, { request, nativeQuestions: [], }); this.#emit( "runtime_request.created", { request: { schema: PAPERCLIP_RUNTIME_REQUEST_SCHEMA_V2, requestKind: "runtime", requestId, type: "permission", status: "pending", prompt: request.prompt, actions: ["accept", "accept_for_session", "decline", "cancel"], details: request.details, origin: request.origin, turnId, itemId: request.itemId, }, }, { turnId, itemId: request.itemId }, ); } async #pumpEvents(): Promise { let attempts = 0; while (!this.#closed && !this.#abort.signal.aborted) { try { const response = await this.#fetch(`${this.#runtime.baseUrl}/event`, { headers: { Authorization: this.#runtime.authHeader, Accept: "text/event-stream", }, signal: this.#abort.signal, }); if (!response.ok || !response.body) throw new Error( `OpenCode event stream returned HTTP ${response.status}`, ); const outboundFrameId = this.#runtime.trace?.frame({ direction: "client_to_provider", raw: "", transport: "http_sse", nativeMethod: "GET /event", }); if (outboundFrameId) { this.#runtime.trace?.interpretation({ frameId: outboundFrameId, stage: "typescript_opencode_http_transport", ruleId: "opencode.http.GET_event", disposition: "operator_only", reason: "Opened the OpenCode server-sent event stream", }); } for await (const frame of parseSseFrames(response.body)) { if (this.#closed) return; const frameId = this.#runtime.trace?.frame({ direction: "provider_to_client", raw: frame.raw, transport: "http_sse", nativeMethod: "SSE /event", }) ?? null; let event: unknown; try { event = JSON.parse(frame.data); if (frameId) { this.#runtime.trace?.interpretation({ frameId, stage: "typescript_opencode_sse_parse", ruleId: "opencode.sse.json", disposition: "mapped", fieldMappings: [ { inputPath: "$raw.data", outputPath: "$providerEvent", action: "normalized", reason: "Decoded the exact SSE data payload as an OpenCode event", }, ], reason: "OpenCode SSE frame parsed as JSON", }); } } catch (error) { if (frameId) { this.#runtime.trace?.interpretation({ frameId, stage: "typescript_opencode_sse_parse", ruleId: "opencode.sse.invalid_json", disposition: "rejected", reason: `OpenCode SSE data was not valid JSON: ${String(error).slice(0, 400)}`, }); } throw error; } const type = text(record(event).type); const properties = record(record(event).properties); if (type === "session.idle" || type === "session.error" || (type === "session.status" && text(record(record(event).properties).status && record(record(record(event).properties).status).type) === "idle")) { // Do not seal the turn while its bound controller is deciding a // finishing call. Acceptance/rejection and the tool result must // precede the provider's terminal event. await this.#completionSettlement; if (this.#closed) return; } this.#mapProviderEvent(event, frameId); } throw new Error( "OpenCode event stream closed before the session became terminal", ); } catch (error) { if (this.#closed || this.#abort.signal.aborted) return; attempts += 1; if (attempts > 3) { await this.#completionSettlement; if (this.#closed) return; this.#emit("harness.diagnostic", { code: "opencode_sse_failed", message: redact(String(error), this.#runtime.sensitiveValues), }); this.#events.fail(error); return; } await new Promise((resolve) => setTimeout(resolve, 50 * attempts)); } } } #mapProviderEvent(value: unknown, traceFrameId: number | null = null): void { this.#activeTraceFrameId = traceFrameId; this.#activeTraceEmittedEventIds = []; let rejected: unknown = null; try { this.#mapProviderEventValue(value); } catch (error) { rejected = error; throw error; } finally { if (traceFrameId) { const providerType = text(record(value).type, "unknown"); this.#runtime.trace?.interpretation({ frameId: traceFrameId, stage: "typescript_opencode_driver_normalization", ruleId: `opencode.normalize.${providerType.replace(/[^A-Za-z0-9_.-]/g, "_")}`, disposition: rejected ? "rejected" : this.#activeTraceEmittedEventIds.length > 0 ? "mapped" : "ignored", emittedEventIds: [...this.#activeTraceEmittedEventIds], fieldMappings: this.#activeTraceEmittedEventIds.length > 0 ? [ { inputPath: "properties", outputPath: "prpEvent.payload", action: "normalized", reason: "Mapped OpenCode event fields into canonical PRP payloads", }, ] : [], reason: rejected ? `OpenCode event normalization failed: ${String(rejected).slice(0, 400)}` : this.#activeTraceEmittedEventIds.length > 0 ? "OpenCode event emitted one or more canonical PRP events" : "OpenCode event was observed but produced no canonical PRP event", }); } this.#activeTraceFrameId = null; this.#activeTraceEmittedEventIds = []; } } #mapProviderEventValue(value: unknown): void { const event = record(value); const properties = record(event.properties); const type = text(event.type); const eventId = text(event.id, canonicalJson(value)); if (this.#seenProviderEvents.has(eventId)) return; this.#seenProviderEvents.add(eventId); if (this.#seenProviderEvents.size > 10_000) this.#seenProviderEvents.delete( this.#seenProviderEvents.values().next().value!, ); const sessionId = text( properties.sessionID, text(record(properties.info).sessionID), ); if (sessionId && sessionId !== this.#providerSessionId) return; const turnId = this.#activeTurnId; if (type === "question.asked" && turnId) { this.#acceptQuestion(properties); return; } if ( (type === "permission.updated" || type === "permission.asked" || type === "permission.v2.asked") && turnId ) { this.#acceptPermission(properties, type); return; } if ( (type === "permission.replied" || type === "permission.v2.replied") && turnId ) { const permission = isRecord(properties.permission) ? properties.permission : isRecord(properties.request) ? properties.request : properties; const requestId = text( permission.id, text( permission.requestID, text(permission.requestId, text(permission.permissionID)), ), ); const pending = this.#pendingRuntimeRequests.get(requestId); if (!pending || pending.request.requestKind !== "permission_approval") return; this.#pendingRuntimeRequests.delete(requestId); const nativeReply = text( properties.reply, text( properties.response, text(permission.reply, text(permission.response)), ), ); const action = pending.submittedAction ?? (nativeReply === "always" || nativeReply === "always_allow" ? "accept_for_session" : nativeReply === "once" || nativeReply === "allow" ? "accept" : "decline"); this.#emit( "runtime_request.resolved", harnessRuntimeRequestOutcome(pending.request, { action }), { turnId: pending.request.turnId, itemId: pending.request.itemId, }, ); return; } if ( (type === "question.replied" || type === "question.rejected") && turnId ) { const requestId = text( properties.id, text(properties.requestID, text(properties.requestId)), ); const pending = this.#pendingRuntimeRequests.get(requestId); if (!pending) return; this.#pendingRuntimeRequests.delete(requestId); this.#emit( type === "question.replied" ? "runtime_request.resolved" : "runtime_request.cancelled", harnessRuntimeRequestOutcome( pending.request, type === "question.replied" ? { action: "submit", response: pending.submittedResponse } : { reason: "provider_rejected" }, ), { turnId: pending.request.turnId, itemId: pending.request.itemId }, ); return; } if (type === "message.part.updated") { const part = record(properties.part); const messageId = text(part.messageID, text(part.messageId)); if (!messageId) return; // Resolve the turn this message actually belongs to, not whichever // turn is active right now. A straggling part for an earlier message // must stay attributed to the turn that created that message. const owningTurnId = this.#messageTurnIds.get(messageId) ?? turnId; if (!owningTurnId) return; const role = this.#messageRoles.get(messageId); if (role === "assistant") this.#emitAssistantPart(part, owningTurnId); else if (role === undefined) { const pending = this.#pendingMessageParts.get(messageId) ?? []; if (pending.length < 100) pending.push(part); this.#pendingMessageParts.set(messageId, pending); } return; } if (type === "message.updated") { const info = record(properties.info); const messageId = text( info.id, text(info.messageID, text(info.messageId)), ); const role = text(info.role); // Record the message's owning turn at the moment OpenCode first // reports it. A later turn that reuses the same native message id // legitimately reclaims ownership; a stale message never sees this // branch again, so its recorded owner never changes. if (messageId && role && turnId) this.#messageTurnIds.set(messageId, turnId); const owningTurnId = messageId ? (this.#messageTurnIds.get(messageId) ?? turnId) : turnId; if (!owningTurnId) return; if (messageId && role) { this.#messageRoles.set(messageId, role); const pending = this.#pendingMessageParts.get(messageId) ?? []; this.#pendingMessageParts.delete(messageId); if (role === "assistant") for (const part of pending) this.#emitAssistantPart(part, owningTurnId); } const tokens = record(info.tokens); if ( role === "assistant" && (Object.keys(tokens).length > 0 || typeof info.cost === "number") ) { this.#usage = bounded({ ...tokens, costUsd: info.cost ?? null, model: this.#model, provider: this.#model.split("/", 1)[0], driverVersion: this.#runtime.version, }); const usageFingerprint = canonicalJson(this.#usage); const hasMeaningfulUsage = hasPositiveNumber(tokens) || (typeof info.cost === "number" && info.cost > 0); if ( hasMeaningfulUsage && this.#messageUsageFingerprints.get(messageId) !== usageFingerprint ) { this.#messageUsageFingerprints.set(messageId, usageFingerprint); this.#emit( "item.completed", { kind: "usage", usage: this.#usage, usageMessageId: messageId }, { turnId: owningTurnId, itemId: `${owningTurnId}:usage` }, ); } } return; } if ( (type === "session.idle" || (type === "session.status" && text(record(properties.status).type) === "idle")) && turnId ) { const fingerprint = canonicalJson({ status: "completed", semanticResult: this.#resultFingerprint, }); this.#terminalTurns.set(turnId, fingerprint); this.#emitSettledFinalAgentMessage(turnId); const workspace = this.#workspaceChangesByTurn.get(turnId); if (workspace !== undefined) this.#emit( "workspace.diff.recorded", { ...workspace, complete: true }, { turnId, itemId: `${turnId}:workspace` }, ); // Settle any request this turn never answered before the terminal // event, so a consumer that stops reading at that terminal event still // observes the settlement. this.#settlePendingRuntimeRequestsForTurn(turnId); // Emit while this turn is still `#activeTurnId`; the gate in `#emit` // drops any frame whose turnId is not the active turn, so nulling it // first would make `#emit` drop this very event. this.#emit("turn.completed", { status: "completed" }, { turnId }); this.#activeTurnId = null; return; } if (type === "session.error" && turnId) { const providerError = record(properties.error); const providerErrorData = record(providerError.data); if ( text(providerError.name) === "MessageAbortedError" && text(providerErrorData.message, text(providerError.message)) === "Aborted" ) { // OpenCode reports its normal /abort control path as session.error. That // endpoint is also how Paperclip parks a provider turn after a durable // governed interaction is created, so presenting it as a provider // failure produces a false red error immediately above a healthy wait // card. Preserve the provider fact as a cancelled terminal event; the // native session loop independently commits the authoritative yielded // result when this abort followed a governed wait. // Settle any request this turn never answered before the terminal // event, so a consumer that stops reading at that terminal event // still observes the settlement. this.#settlePendingRuntimeRequestsForTurn(turnId); this.#emit( "turn.cancelled", { status: "cancelled", error: bounded(properties.error ?? properties), }, { turnId }, ); this.#terminalTurns.set(turnId, canonicalJson({ status: "cancelled" })); this.#activeTurnId = null; return; } this.#emit( "provider.notice.recorded", { schema: "paperclip.provider.notice.v1", noticeId: `${turnId}:session-error`, severity: "error", category: "session_error", scope: "turn", recoverable: false, userActionable: true, summary: redact( text(record(properties.error).message, "OpenCode session failed."), this.#runtime.sensitiveValues, ).slice(0, 4000), }, { turnId, itemId: `${turnId}:session-error` }, ); // Settle any request this turn never answered before the terminal // event, so a consumer that stops reading at that terminal event still // observes the settlement. this.#settlePendingRuntimeRequestsForTurn(turnId); this.#emit( "turn.failed", { status: "failed", error: bounded(properties.error ?? properties) }, { turnId }, ); this.#terminalTurns.set(turnId, canonicalJson({ status: "failed" })); this.#activeTurnId = null; } } #emitAssistantPart(part: Record, turnId: string): void { const partId = text(part.id, `${turnId}:part`); const partType = text(part.type, "unknown"); const messageId = text(part.messageID, text(part.messageId)) || null; const canonicalToolName = canonicalOpenCodeMcpToolName( canonicalOpenCodeDisplayToolName( text(part.tool, text(part.name)), text(part.callID, text(part.callId)), ), ); if ( ["tool", "tool-call", "tool_call"].includes(partType) && [PRP_COMPLETION_TOOL_NAME, PRP_BLOCK_TOOL_NAME].includes( canonicalToolName as typeof PRP_COMPLETION_TOOL_NAME, ) && messageId ) { // OpenCode may report the terminal MCP call before it marks the text // part from the same assistant message complete. Correlating by native // message identity selects that response while excluding both earlier // commentary messages and later acknowledgement-only messages. this.#semanticResultProviderMessageId = messageId; } for (const canonical of canonicalProviderEventsFromOpenCodePart(part)) { this.#emit(canonical.eventType, canonical.payload, { turnId, itemId: canonical.itemId, }); } if ( ["tool", "tool-call", "tool_call"].includes(partType) && ![PRP_COMPLETION_TOOL_NAME, PRP_BLOCK_TOOL_NAME].includes( canonicalToolName as typeof PRP_COMPLETION_TOOL_NAME, ) ) { this.#lastNonTerminalToolSourceSeq = this.#sourceSequence; } if (partType === "patch") { const paths = Array.isArray(part.files) ? part.files : []; const files = paths .slice(0, 2_000) .flatMap((value): Record[] => { const path = text(value).replaceAll("\\", "/"); if (!path || path.startsWith("/") || path.split("/").includes("..")) return []; return [ { path, operation: "modify", previousPath: null, additions: null, deletions: null, binary: false, diff: null, }, ]; }); if (files.length > 0) { const previous = this.#workspaceChangesByTurn.get(turnId); const payload = { schema: "paperclip.workspace.diff.v1", changeSetId: `${turnId}:workspace`, revision: Number(record(previous).revision ?? 0) + 1, source: "harness_reported", complete: false, files, totals: { files: files.length, additions: null, deletions: null }, patchArtifactRef: null, }; this.#workspaceChangesByTurn.set(turnId, payload); this.#emit("workspace.change.updated", payload, { turnId, itemId: `${turnId}:workspace`, }); } } const content = text(part.text, text(part.output)); const previous = this.#partText.get(partId) ?? ""; this.#partText.set(partId, content); if (partType === "text" && content.length > 0) { for (const reference of paperclipWorkspaceFileReferencesFromText( this.#workingDirectory, content, turnId, )) { if (this.#emittedFileReferences.has(reference.referenceId)) continue; this.#emittedFileReferences.add(reference.referenceId); this.#emit( "workspace.file.referenced", { ...reference }, { turnId, itemId: reference.referenceId }, ); } } const delta = content.startsWith(previous) ? content.slice(previous.length) : content; if (delta) { // OpenCode calls assistant prose a `text` part, but `text` is not a PRP // item identity and is consequently ignored by the task projection. // Until settlement selects one completed part as the final response, // every assistant text update is canonical progress/commentary. const assistantDelta = partType === "text"; const reasoningDelta = partType === "reasoning"; this.#emit( "item.delta", { kind: assistantDelta ? "agentMessage" : reasoningDelta ? "reasoning" : partType, ...(assistantDelta ? { channel: "progress", providerPhase: "commentary" } : reasoningDelta ? { channel: "detail", providerPhase: "reasoning" } : {}), text: delta, item: bounded( assistantDelta ? { ...part, type: "agentMessage", channel: "progress", phase: "commentary", text: delta, } : reasoningDelta ? { ...part, type: "reasoning", channel: "detail", phase: "reasoning", text: delta, } : part, ), }, { turnId, itemId: partId }, ); } const completedAt = record(part.time).end; if ( partType === "reasoning" && content.trim().length > 0 && Number.isFinite(completedAt) && !this.#completedReasoningPartIds.has(partId) ) { this.#completedReasoningPartIds.add(partId); this.#emit( "item.completed", { kind: "reasoning", channel: "detail", providerPhase: "reasoning", text: content, item: bounded({ ...part, type: "reasoning", channel: "detail", phase: "reasoning", text: content, }), }, { turnId, itemId: partId }, ); } if ( partType === "text" && content.trim().length > 0 && Number.isFinite(completedAt) && !this.#completedTextPartIds.has(partId) ) { this.#completedTextPartIds.add(partId); this.#completedTextParts.push({ partId, messageId, text: content, item: bounded({ ...part, type: "agentMessage", phase: "final_answer", text: content, }), observedSourceSeq: this.#sourceSequence, }); } } #emitSettledFinalAgentMessage(turnId: string): void { // OpenCode labels every assistant text part as plain `text`; unlike Codex, // it does not provide commentary/final-answer channels. A single native // assistant message can therefore contain an opening progress note, many // work tools, and the terminal MCP call. Do not promote that opening note // into the settled response merely because it shares the terminal call's // message id. Only text observed after the last non-terminal work tool can // be a final response. When no such text exists, emit no final agentMessage // and let the accepted semantic summary resolve the durable reply. const afterLastWorkTool = (part: { observedSourceSeq: number }) => part.observedSourceSeq > this.#lastNonTerminalToolSourceSeq; const eligibleText = this.#completedTextParts.filter(afterLastWorkTool); // OpenCode exposes no prose channel, so final selection must use provider // structure rather than text length or content. When OpenCode reports the // terminal tool part, prefer prose completed afterward in that exact // provider message. If that message contains only pre-tool commentary, // the first later assistant message is the compatible terminal response. // Without a reported tool-message identity, a completed pre-tool message // is more authoritative than a later acknowledgement; otherwise the first // post-tool message is the only available compatibility fallback. const indexed = eligibleText.map((part) => ({ part, index: this.#completedTextParts.indexOf(part), })); const boundary = this.#semanticResultTextBoundary; const beforeResult = indexed.filter( ({ index }) => boundary !== null && index < boundary, ); const afterResult = indexed.filter( ({ index }) => boundary === null || index >= boundary, ); const correlatedAfterResult = this.#semanticResultProviderMessageId === null ? [] : afterResult.filter( ({ part }) => part.messageId === this.#semanticResultProviderMessageId, ); const uncorrelatedAfterResult = this.#semanticResultProviderMessageId === null ? afterResult : afterResult.filter( ({ part }) => part.messageId !== this.#semanticResultProviderMessageId, ); const selected = correlatedAfterResult.at(-1)?.part ?? (this.#semanticResultProviderMessageId === null ? beforeResult.at(-1)?.part : uncorrelatedAfterResult[0]?.part) ?? beforeResult.at(-1)?.part ?? afterResult[0]?.part ?? null; if (!selected) return; this.#emit( "item.completed", { kind: "agentMessage", channel: "final", providerPhase: "final_answer", text: selected.text, item: selected.item, }, { turnId, itemId: selected.partId }, ); } #emit( eventType: PrpEvent["eventType"], payload: Record, refs: { turnId?: string; itemId?: string } = {}, options?: { bypassTerminalTurnGate?: boolean }, ): void { if ( !options?.bypassTerminalTurnGate && eventType !== "harness.diagnostic" && refs.turnId !== undefined && refs.turnId !== this.#activeTurnId ) { // The provider sent this frame for a turn that is not the current // active turn, so that turn already reached a terminal state: turns // run strictly one at a time (`startTurn` and `attachRun` both refuse // to proceed while `#activeTurnId` is set), and a turn id is never // reused. Comparing directly against `#activeTurnId` needs no history // of past turns, so the gate stays correct and its memory stays O(1) // no matter how many turns a long-lived session runs. The queue stays // open across turns, so a silent drop here would let a stale frame // reach the next turn's consumer. Report it instead of discarding it // without a trace. this.#emit("harness.diagnostic", { code: "opencode_late_terminal_turn_event_dropped", message: `OpenCode sent a ${eventType} event for a turn that already reached a terminal state.`, droppedEventType: eventType, turnId: refs.turnId, }); return; } const sourceSeq = ++this.#sourceSequence; const event: PrpEvent = { schema: "paperclip.prp.event.v1", sourceEventId: `${this.#runnerInstanceId}:${this.#runId}:${sourceSeq}`, sourceSeq, sourceInstanceId: this.#runnerInstanceId, sourceKind: "runner", runId: this.#runId, normalizedSessionId: this.#normalizedSessionId, ...(refs.turnId ? { turnId: refs.turnId } : {}), ...(refs.itemId ? { itemId: refs.itemId } : {}), eventType, schemaVersion: 1, priority: eventType === "run.result.proposed" ? 0 : 1, emittedAt: this.#now().toISOString(), payload, }; if (this.#activeTraceFrameId !== null) { this.#activeTraceEmittedEventIds.push(event.sourceEventId); } this.#transcript.push(structuredClone(event)); this.#events.push(event); } } /** Retain the reusable gateway key in the runner; the harness gets a session-scoped capability. */ async function startOpenCodeProviderProxy(baseUrl: string, key: string, model: string, environment: NodeJS.ProcessEnv) { const upstreamUrl = new URL(`${baseUrl.replace(/\/+$/, "")}/chat/completions`); const token = randomBytes(32).toString("base64url"); // Agent-local settings keep one runtime's transport configuration out of other sessions. const proxyEnv = { HTTP_PROXY: environment.http_proxy ?? environment.HTTP_PROXY ?? environment.all_proxy ?? environment.ALL_PROXY, HTTPS_PROXY: environment.https_proxy ?? environment.HTTPS_PROXY ?? environment.http_proxy ?? environment.HTTP_PROXY ?? environment.all_proxy ?? environment.ALL_PROXY, NO_PROXY: environment.no_proxy ?? environment.NO_PROXY, }; const ca = [...getCACertificates("default")]; if (environment.SSL_CERT_FILE) ca.push(await readFile(environment.SSL_CERT_FILE, "utf8")); if (environment.SSL_CERT_DIR) { for (const entry of await readdir(environment.SSL_CERT_DIR, { withFileTypes: true })) { if (!entry.isDirectory()) { const certificate = await readFile(join(environment.SSL_CERT_DIR, entry.name), "utf8"); if (certificate.includes("-----BEGIN CERTIFICATE-----")) ca.push(certificate); } } } const agent = upstreamUrl.protocol === "https:" ? new HttpsAgent({ proxyEnv, ca }) : new HttpAgent({ proxyEnv }); const controllers = new Set(); const server = createHttpServer((request, response) => { const controller = new AbortController(); controllers.add(controller); response.once("close", () => controller.abort()); void (async () => { if (request.headers.authorization !== `Bearer ${token}`) { response.writeHead(401).end(); return; } if (request.method !== "POST" || request.url !== "/v1/chat/completions") { response.writeHead(404).end(); return; } const chunks: Buffer[] = []; let bytes = 0; for await (const chunk of request) { bytes += chunk.length; if (bytes > 16 * 1024 * 1024) { response.writeHead(413).end(); return; } chunks.push(Buffer.from(chunk)); } const body = Buffer.concat(chunks); let payload: Record; try { payload = JSON.parse(body.toString("utf8")); } catch { response.writeHead(400).end(); return; } if (!payload || typeof payload !== "object" || payload.model !== model) { response.writeHead(400).end(); return; } const upstream = await new Promise((resolve, reject) => { const outgoing = (upstreamUrl.protocol === "https:" ? requestHttps : requestHttp)(upstreamUrl, { method: "POST", agent, headers: { "Content-Type": "application/json", "Content-Length": body.length, ...(key ? { Authorization: `Bearer ${key}` } : {}) }, signal: AbortSignal.any([controller.signal, AbortSignal.timeout(600_000)]), }, resolve); outgoing.once("error", reject); outgoing.end(body); }); // Never forward authentication to a redirect destination. const status = upstream.statusCode ?? 502; if (status >= 300 && status < 400) { upstream.destroy(); throw new Error("Provider redirects are not supported"); } const headers: Record = {}; for (const name of ["content-type", "content-encoding", "cache-control", "retry-after"]) { const value = upstream.headers[name]; if (typeof value === "string") headers[name] = value; } response.writeHead(status, headers); await pipeline(upstream, response); })().catch(() => { if (!response.headersSent) response.writeHead(502).end("Provider request failed"); else response.destroy(); }).finally(() => controllers.delete(controller)); }); try { await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => { server.off("error", reject); resolve(); }); }); } catch (error) { agent.destroy(); throw error; } const address = server.address(); if (!address || typeof address === "string") { server.close(); agent.destroy(); throw new Error("Could not bind OpenCode provider proxy"); } return { baseURL: `http://127.0.0.1:${address.port}/v1`, token, close: async () => { for (const controller of controllers) controller.abort(); agent.destroy(); await new Promise((resolve) => { server.close(() => resolve()); server.closeAllConnections(); }); }, }; } async function startRuntime(input: { options: OpenCodeServerDriverOptions; root: string; cwd: string; trace: ProviderTraceFileSink | null; dispatch: (call: { tool: string; callId: string; arguments: unknown; }) => Promise; }): Promise { const port = await reservePort(); const password = randomBytes(32).toString("base64url"); const username = "paperclip"; const authHeader = `Basic ${Buffer.from(`${username}:${password}`).toString("base64")}`; const configHome = join(input.root, "config"); const dataHome = join(input.root, "data"); const cacheHome = join(input.root, "cache"); await Promise.all([ mkdir(join(configHome, "opencode"), { recursive: true, mode: 0o700 }), mkdir(dataHome, { recursive: true, mode: 0o700 }), mkdir(cacheHome, { recursive: true, mode: 0o700 }), ]); // HOME contains a read-only skill snapshot, whose destination must be fresh. // Keep OpenCode's XDG data stable for provider-session recovery while giving // every launch (including retries and recovery) a new isolated HOME. const isolatedHome = await mkdtemp(join(input.root, "home-")); await chmod(isolatedHome, 0o700); try { await materializeNativeRuntimeSkills( input.options.runtimeContext ?? null, join(isolatedHome, ".claude", "skills"), ); } catch (error) { await rm(isolatedHome, { recursive: true, force: true }).catch( () => undefined, ); throw error; } const bridge = await startOpenCodeMcpBridge({ tools: input.options.dynamicTools, handler: input.dispatch, }).catch(async (error) => { await rm(isolatedHome, { recursive: true, force: true }).catch( () => undefined, ); throw error; }); const assignedMcp = nativeMcpLaunchBinding( input.options.environment ?? process.env, ); const identityKey = input.options.environment?.PAPERCLIP_AGENT_PRIVATE_KEY; const identityValues = identityKey ? [identityKey, JSON.stringify(identityKey).slice(1, -1), ...identityKey.split(/\r?\n/).filter(line => line && !line.startsWith("-----"))] : []; const sensitiveValues = [ ...identityValues, password, authHeader, bridge.secret, assignedMcp?.token, input.options.environment?.OPENROUTER_API_KEY, input.options.environment?.PAPERCLIP_AI_PROVIDER_KEY, ].filter((value): value is string => Boolean(value)); input.trace?.addSensitiveValues(sensitiveValues); const instructionRoot = input.options.runtimeContext?.instructions.bundle.rootPath; // OpenCode canonicalizes tool paths (for example /var -> /private/var on // macOS). Permit the assigned workspace under either spelling; operations // within it still obey the selected allow/ask/deny permission mode. const externalDirectories: Record = { "*": "deny" }; for (const root of new Set([input.cwd, await realpath(input.cwd)])) { externalDirectories[root] = "allow"; externalDirectories[`${root}/**`] = "allow"; } if (instructionRoot) externalDirectories[`${instructionRoot}/**`] = "allow"; const [modelProvider, ...modelIdParts] = input.options.model.split("/"); const providerModelId = modelIdParts.join("/"); const providerProxy = modelProvider === "paperclip" && input.options.environment?.PAPERCLIP_AI_PROVIDER_URL ? await startOpenCodeProviderProxy(input.options.environment.PAPERCLIP_AI_PROVIDER_URL, input.options.environment.PAPERCLIP_AI_PROVIDER_KEY ?? "", providerModelId, input.options.environment).catch(async error => { await bridge.close().catch(() => {}); await rm(isolatedHome, { recursive: true, force: true }).catch(() => {}); throw error; }) : null; if (providerProxy) { sensitiveValues.push(providerProxy.token); input.trace?.addSensitiveValues([providerProxy.token]); } let child: ChildProcess | undefined; try { const config = { $schema: "https://opencode.ai/config.json", model: input.options.model, small_model: input.options.model, share: "disabled", // The configured entry is already composed exactly once into the session // system prompt; siblings remain available through the read-only root. instructions: [], plugin: [], // OpenCode's bundled models.dev snapshot can lag behind OpenRouter's live // catalog. Bind the already-qualified exact model slug into the built-in // provider instead of silently falling back or rejecting a newer model. provider: { [modelProvider!]: { ...(providerProxy ? { npm: "@ai-sdk/openai-compatible", name: "Paperclip connection", options: { baseURL: providerProxy.baseURL, apiKey: providerProxy.token, }, } : {}), models: { [providerModelId]: { name: providerModelId }, }, }, }, tools: { question: true, }, permission: { "*": input.options.permissionMode ?? "allow", question: "allow", "paperclip_*": "allow", "mcp__paperclip__*": "allow", external_directory: externalDirectories, }, mcp: { paperclip: { type: "remote", url: bridge.url, enabled: true, oauth: false, headers: { Authorization: `Bearer ${bridge.secret}` }, timeout: 30_000, }, ...(assignedMcp ? { [assignedMcp.name]: { type: "remote", url: assignedMcp.url, enabled: true, oauth: false, headers: { Authorization: `Bearer ${assignedMcp.token}` }, timeout: 30_000, }, } : {}), }, }; await writeFile( join(configHome, "opencode", "opencode.json"), `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }, ); const environment = sanitizedEnvironment( input.options.environment ?? process.env, { HOME: isolatedHome, XDG_CONFIG_HOME: configHome, XDG_DATA_HOME: dataHome, XDG_CACHE_HOME: cacheHome, OPENCODE_DISABLE_PROJECT_CONFIG: "true", OPENCODE_SERVER_USERNAME: username, OPENCODE_SERVER_PASSWORD: password, ...(providerProxy ? { NO_PROXY: [input.options.environment?.no_proxy ?? input.options.environment?.NO_PROXY, "127.0.0.1", "localhost"].filter(Boolean).join(",") } : {}), }, ); const isolateProcessGroup = input.options.isolateProcessGroup ?? true; const stdio: Array<"ignore" | "pipe" | number> = ["ignore", "ignore", "pipe"]; if (input.options.commandFd !== undefined) { while (stdio.length <= input.options.commandFd) stdio.push("ignore"); stdio[input.options.commandFd] = input.options.commandFd; } input.options.commandLifecycle?.beforeSpawn(); child = spawn( input.options.command ?? resolvePinnedOpenCodeCommand(), ["serve", "--hostname", "127.0.0.1", "--port", String(port)], { cwd: input.cwd, env: environment, stdio, detached: globalThis.process.platform !== "win32" && isolateProcessGroup, }, ); if (child.pid !== undefined) { try { input.options.commandLifecycle?.afterSpawn(); } catch (error) { child.kill("SIGKILL"); throw error; } } let diagnostics = ""; child.stderr?.on("data", (chunk) => { const raw = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); const redactedDiagnostic = redact(raw.toString("utf8"), sensitiveValues); diagnostics = `${diagnostics}${redactedDiagnostic}`.slice(-8_192); const frameId = input.trace?.frame({ direction: "provider_stderr", raw, transport: "process_stderr", nativeMethod: "opencode serve stderr", }); if (frameId) { input.trace?.interpretation({ frameId, stage: "typescript_opencode_process_transport", ruleId: "opencode.stderr", disposition: "operator_only", reason: "OpenCode stderr is retained only in the restricted trace sidecar", }); } input.options.onDiagnostic?.(redactedDiagnostic); }); const providerChild = child; await new Promise((resolve, reject) => { providerChild.once("spawn", resolve); providerChild.once("error", reject); }); if (child.pid) await input.options.onSpawn?.({ pid: child.pid, processGroupId: globalThis.process.platform === "win32" || !isolateProcessGroup ? null : child.pid, startedAt: new Date().toISOString(), }); const baseUrl = `http://127.0.0.1:${port}`; const health = await waitForHealth( baseUrl, authHeader, input.options.fetch ?? globalThis.fetch, child, () => diagnostics, input.trace, ); const version = text(record(health).version); if (!/^\d+\.\d+\.\d+$/.test(version)) throw new Error("OpenCode health response omitted a semantic version"); const qualifiedComparison = compareVersion( version, QUALIFIED_OPENCODE_VERSION, ); if (qualifiedComparison !== 0) { throw new Error( `OpenCode ${version} is not the question-conformance-qualified ${QUALIFIED_OPENCODE_VERSION}`, ); } return { baseUrl, authHeader, version, permissionMode: input.options.permissionMode ?? "allow", process: child, bridge, trace: input.trace, sensitiveValues, close: async (closeInput = {}) => { await providerProxy?.close(); await bridge.close().catch(() => {}); if (providerChild.exitCode === null && providerChild.signalCode === null && providerChild.pid) { try { if (globalThis.process.platform === "win32" || !isolateProcessGroup) providerChild.kill("SIGTERM"); else globalThis.process.kill(-providerChild.pid, "SIGTERM"); } catch { providerChild.kill("SIGTERM"); } } await waitForExit(providerChild, 2_000); if (providerChild.exitCode === null && providerChild.signalCode === null && providerChild.pid) { try { if (globalThis.process.platform === "win32" || !isolateProcessGroup) providerChild.kill("SIGKILL"); else globalThis.process.kill(-providerChild.pid, "SIGKILL"); } catch { providerChild.kill("SIGKILL"); } } await rm(join(configHome, "opencode", "opencode.json"), { force: true, }).catch(() => undefined); await rm(isolatedHome, { recursive: true, force: true }).catch( () => undefined, ); if (closeInput.finalizeTrace !== false) { await input.trace?.finish({ reason: closeInput.reason ?? null }); } }, }; } catch (error) { await providerProxy?.close(); await bridge.close().catch(() => {}); child?.kill("SIGKILL"); await rm(join(configHome, "opencode", "opencode.json"), { force: true, }).catch(() => undefined); await rm(isolatedHome, { recursive: true, force: true }).catch( () => undefined, ); throw error; } } export function normalizeOpenCodeQuestionSet( nativeQuestions: Record[], metadata: Record = {}, ): PaperclipQuestionSet { const questions = nativeQuestions.map( (question, index): PaperclipQuestion => { const options = (Array.isArray(question.options) ? question.options : []) .map(record) .slice(0, 128) .map((option, optionIndex) => ({ id: text(option.id, `option-${optionIndex + 1}`).slice(0, 160), label: text( option.label, text(option.value, `Option ${optionIndex + 1}`), ).slice(0, 1_000), ...(text(option.description) ? { description: text(option.description).slice(0, 4_000) } : {}), })); return { id: openCodeQuestionId(question, index), ...(text(question.header) ? { header: text(question.header).slice(0, 1_000) } : {}), prompt: text( question.question, text(question.prompt, `Question ${index + 1}`), ).slice(0, 4_000), ...(text(question.description) ? { helpText: text(question.description).slice(0, 4_000) } : {}), required: question.required === true, answerMode: options.length === 0 ? "text" : question.multiple === true ? "multi_select" : "single_select", ...(options.length > 0 ? { options } : {}), ...(question.custom === true || question.allowCustom === true ? { customAnswer: { enabled: true, label: "Other", placeholder: "Enter another answer", }, } : {}), }; }, ); return parsePaperclipQuestionSet({ schema: PAPERCLIP_QUESTION_SET_SCHEMA, title: text(metadata.title, "OpenCode needs your input").slice(0, 1_000), ...(text(metadata.description) ? { description: text(metadata.description).slice(0, 4_000) } : {}), submitLabel: text(metadata.submitLabel, "Submit answers").slice(0, 200), questions, }); } function openCodeAnswers( pending: { request: HarnessRuntimeRequest; nativeQuestions: Record[]; }, response: PaperclipQuestionResponse, ): string[][] { return pending.nativeQuestions.map((nativeQuestion, index) => { const questionId = openCodeQuestionId(nativeQuestion, index); const question = pending.request.input?.questions.find( (candidate) => candidate.id === questionId, ); const answer = response.answers[questionId]; if (!question || !answer) return []; const values = (answer.selectedOptionIds ?? []) .map( (optionId) => question.options?.find((option) => option.id === optionId)?.label, ) .filter((value): value is string => typeof value === "string"); if (answer.text !== undefined) values.push(answer.text); if (answer.customText !== undefined) values.push(answer.customText); return values; }); } function openCodeQuestionId( question: Record, index: number, ): string { const nativeId = text(question.id).trim(); if (nativeId) return nativeId.slice(0, 160); const header = text(question.header) .trim() .toLowerCase() .replace(/[^a-z0-9._:-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 160); return header || `question-${index + 1}`; } async function api( fetcher: typeof globalThis.fetch, runtime: OpenCodeRuntime, path: string, init: RequestInit = {}, ): Promise { const timeout = AbortSignal.timeout(20_000); const signal = init.signal ? AbortSignal.any([init.signal, timeout]) : timeout; const method = String(init.method ?? "GET").toUpperCase(); const requestRaw = typeof init.body === "string" ? init.body : init.body instanceof Uint8Array ? init.body : ""; const requestFrameId = runtime.trace?.frame({ direction: "client_to_provider", raw: requestRaw, transport: "http_json", nativeMethod: `${method} ${path}`, }); if (requestFrameId) { runtime.trace?.interpretation({ frameId: requestFrameId, stage: "typescript_opencode_http_transport", ruleId: `opencode.http.${method}.${safeTraceRulePath(path)}`, disposition: "operator_only", reason: "Sent an exact HTTP request body to the OpenCode app server", }); } let response: Response; try { response = await fetcher(`${runtime.baseUrl}${path}`, { ...init, signal, headers: { Authorization: runtime.authHeader, "Content-Type": "application/json", ...init.headers, }, }); } catch (error) { throw new Error( `OpenCode API ${path} request failed: ${redact(String(error), runtime.sensitiveValues)}`, ); } const responseRaw = await response.text(); const responseFrameId = runtime.trace?.frame({ direction: "provider_to_client", raw: responseRaw, transport: "http_json", nativeMethod: `${method} ${path} ${response.status}`, }); if (!response.ok) { if (responseFrameId) { runtime.trace?.interpretation({ frameId: responseFrameId, stage: "typescript_opencode_http_parse", ruleId: `opencode.http.error.${response.status}`, disposition: "rejected", reason: `OpenCode API returned HTTP ${response.status}`, }); } throw new Error( `OpenCode API ${path} returned HTTP ${response.status}: ${redact(responseRaw, runtime.sensitiveValues)}`, ); } if (response.status === 204 || !responseRaw) { if (responseFrameId) { runtime.trace?.interpretation({ frameId: responseFrameId, stage: "typescript_opencode_http_parse", ruleId: "opencode.http.empty_success", disposition: "operator_only", reason: "OpenCode API returned a successful empty response", }); } return null; } try { const parsed = JSON.parse(responseRaw) as unknown; if (responseFrameId) { runtime.trace?.interpretation({ frameId: responseFrameId, stage: "typescript_opencode_http_parse", ruleId: "opencode.http.json_success", disposition: "operator_only", fieldMappings: [ { inputPath: "$raw", outputPath: "$response", action: "normalized", reason: "Decoded the exact OpenCode HTTP response body as JSON", }, ], reason: "OpenCode HTTP response parsed as JSON", }); } return parsed; } catch (error) { if (responseFrameId) { runtime.trace?.interpretation({ frameId: responseFrameId, stage: "typescript_opencode_http_parse", ruleId: "opencode.http.invalid_json", disposition: "rejected", reason: `OpenCode response was not valid JSON: ${String(error).slice(0, 400)}`, }); } throw error; } } function retryableOpenCodeStartupError(error: unknown): boolean { const message = String(error); return ( message.includes("request failed") || message.includes("did not become healthy") || message.includes("exited during startup") || message.includes("provider_initialize_timeout") || message.includes("provider_process_exited") || message.includes("HTTP 408") || message.includes("HTTP 425") || message.includes("HTTP 429") || /HTTP 5\d\d/.test(message) ); } async function waitForHealth( baseUrl: string, authHeader: string, fetcher: typeof globalThis.fetch, process: ChildProcess, diagnostics: () => string, trace: ProviderTraceFileSink | null, ): Promise { const deadline = Date.now() + 10_000; while (Date.now() < deadline) { if (process.exitCode !== null || process.signalCode !== null) { const detail = redact(diagnostics()); throw new Error( `provider_process_exited: provider=opencode stage=health exitCode=${process.exitCode ?? "null"} signal=${process.signalCode ?? "null"}${detail ? ` stderrTail=${detail}` : ""}`, ); } try { const requestFrameId = trace?.frame({ direction: "client_to_provider", raw: "", transport: "http_json", nativeMethod: "GET /global/health", }); if (requestFrameId) { trace?.interpretation({ frameId: requestFrameId, stage: "typescript_opencode_http_transport", ruleId: "opencode.http.GET_global_health", disposition: "operator_only", reason: "Probed the local OpenCode app-server health endpoint", }); } const response = await fetcher(`${baseUrl}/global/health`, { headers: { Authorization: authHeader }, signal: AbortSignal.timeout(1_000), }); const raw = await response.text(); const responseFrameId = trace?.frame({ direction: "provider_to_client", raw, transport: "http_json", nativeMethod: `GET /global/health ${response.status}`, }); if (response.ok) { const parsed = JSON.parse(raw) as unknown; if (responseFrameId) { trace?.interpretation({ frameId: responseFrameId, stage: "typescript_opencode_http_parse", ruleId: "opencode.http.health_success", disposition: "operator_only", reason: "OpenCode health response parsed successfully", }); } return parsed; } } catch { /* server is still starting */ } await new Promise((resolve) => setTimeout(resolve, 50)); } const detail = redact(diagnostics()); throw new Error( `provider_initialize_timeout: provider=opencode stage=health${detail ? ` stderrTail=${detail}` : ""}`, ); } async function reservePort(): Promise { const server = createServer(); await new Promise((resolve, reject) => { server.once("error", reject); server.listen(0, "127.0.0.1", () => resolve()); }); const address = server.address(); if (!address || typeof address === "string") throw new Error("Unable to reserve OpenCode port"); const port = address.port; await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ); return port; } async function* parseSseFrames( stream: ReadableStream, ): AsyncIterable<{ raw: string; data: string }> { const decoder = new TextDecoder(); let buffer = ""; for await (const chunk of stream as unknown as AsyncIterable) { buffer += decoder.decode(chunk, { stream: true }); if (buffer.length > 1_048_576) throw new Error("OpenCode SSE event exceeded the retained payload limit"); let boundary: RegExpExecArray | null; while ((boundary = /\r?\n\r?\n/.exec(buffer)) !== null) { const rawFrame = buffer.slice(0, boundary.index); const raw = buffer.slice(0, boundary.index + boundary[0].length); const frame = rawFrame.replaceAll("\r", ""); buffer = buffer.slice(boundary.index + boundary[0].length); const data = frame .split("\n") .filter((line) => line.startsWith("data:")) .map((line) => line.slice(5).trimStart()) .join("\n"); if (!data || data === "[DONE]") continue; yield { raw, data }; } } } async function* parseSse( stream: ReadableStream, ): AsyncIterable { for await (const frame of parseSseFrames(stream)) yield JSON.parse(frame.data); } function sanitizedEnvironment( source: NodeJS.ProcessEnv, overrides: Record, ): NodeJS.ProcessEnv { const allowed = [ "PAPERCLIP_AGENT_KEY_ID", "PAPERCLIP_AGENT_PUBLIC_KEY", "PAPERCLIP_AGENT_PRIVATE_KEY", "PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "OPENROUTER_API_KEY", ]; const result: NodeJS.ProcessEnv = {}; for (const key of allowed) if (source[key] !== undefined) result[key] = source[key]; return { ...result, ...overrides }; } function sanitizedEnvironmentKeys(): string[] { return [ "PATH", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "ALL_PROXY", "SSL_CERT_FILE", "SSL_CERT_DIR", "OPENROUTER_API_KEY", ]; } function sessionRoot( runtimeDirectory: string, normalizedSessionId: string, ): string { const safe = normalizedSessionId .replace(/[^a-zA-Z0-9._-]/g, "_") .slice(0, 120); if (!safe || safe === "." || safe === "..") throw new Error("Invalid normalized OpenCode session id"); return join(resolve(runtimeDirectory), safe); } function validateWorkspace(value: string): string { const cwd = resolve(value); if (!value.trim() || cwd === dirname(cwd)) throw new Error("OpenCode working directory must not be a filesystem root"); return cwd; } function validModel(value: string): boolean { const slash = value.indexOf("/"); return slash > 0 && slash < value.length - 1; } function compareVersion(left: string, right: string): number { const a = left.split(".").map(Number); const b = right.split(".").map(Number); for (let index = 0; index < 3; index += 1) { if (a[index] !== b[index]) return (a[index] ?? 0) - (b[index] ?? 0); } return 0; } function bounded(value: unknown): Record { const serialized = JSON.stringify(value); if (!serialized || serialized.length > 64 * 1024) return { omitted: true, reason: "payload_limit" }; const parsed = JSON.parse(serialized) as unknown; return isRecord(parsed) ? parsed : { value: parsed }; } function record(value: unknown): Record { return isRecord(value) ? value : {}; } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function text(value: unknown, fallback = ""): string { return typeof value === "string" ? value : fallback; } function hasPositiveNumber(value: unknown): boolean { if (typeof value === "number") return Number.isFinite(value) && value > 0; if (Array.isArray(value)) return value.some(hasPositiveNumber); if (isRecord(value)) return Object.values(value).some(hasPositiveNumber); return false; } function safeTraceRulePath(value: string): string { return value .replace(/\/[A-Za-z0-9_-]{12,}/g, "/:id") .replace(/[^A-Za-z0-9_.:/-]/g, "_") .replaceAll("/", "_") .slice(0, 120); } function redact( value: string, sensitiveValues: readonly (string | undefined)[] = [], ): string { let redacted = value; for (const sensitive of sensitiveValues) { if (sensitive && sensitive.length >= 4) redacted = redacted.split(sensitive).join("[REDACTED]"); } return redacted .replace( /(OPENROUTER_API_KEY|authorization|password|token|secret)\s*[:=]\s*[^\s,}\]]+/gi, "$1=[REDACTED]", ) .slice(0, 8_192); } function canonicalJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; if (isRecord(value)) return `{${Object.keys(value) .sort() .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) .join(",")}}`; return JSON.stringify(value) ?? "undefined"; } async function waitForExit( process: ChildProcess, timeoutMs: number, ): Promise { if (process.exitCode !== null || process.signalCode !== null) return; await new Promise((resolve) => { const timer = setTimeout(resolve, timeoutMs); timer.unref?.(); process.once("exit", () => { clearTimeout(timer); resolve(); }); }); } export const openCodeServerDriverInternals = { parseSse }; class AsyncQueue implements AsyncIterable { #items: T[] = []; #waiters: Array<{ resolve: (result: IteratorResult) => void; reject: (error: unknown) => void; }> = []; #closed = false; #error: unknown = null; push(item: T): void { if (this.#closed) return; const waiter = this.#waiters.shift(); if (waiter) waiter.resolve({ done: false, value: item }); else this.#items.push(item); } close(): void { if (this.#closed) return; this.#closed = true; for (const waiter of this.#waiters.splice(0)) waiter.resolve({ done: true, value: undefined }); } fail(error: unknown): void { if (this.#closed) return; this.#closed = true; this.#error = error; for (const waiter of this.#waiters.splice(0)) waiter.reject(error); } [Symbol.asyncIterator](): AsyncIterator { return { next: () => { const item = this.#items.shift(); if (item !== undefined) return Promise.resolve({ done: false, value: item }); if (this.#error !== null) return Promise.reject(this.#error); if (this.#closed) return Promise.resolve({ done: true, value: undefined }); return new Promise((resolve, reject) => this.#waiters.push({ resolve, reject }), ); }, }; } }