mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-23 00:53:38 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Runner provides a durable execution boundary for supported providers. > - The current production runtime supports Codex but cannot execute OpenCode sessions. > - OpenCode needs a qualified transport, strict input mapping, and normalized events. > - This pull request adds the OpenCode runtime as one isolated provider unit. > - The benefit is a reviewable provider expansion that does not weaken the existing Codex path. ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: packages/paperclip-runner and the Codex-local adapter configuration contract. **Problem or motivation** Paperclip Runner has provider-neutral contracts, but the production backend factory cannot start a qualified OpenCode session. This blocks OpenCode from using the durable runner path. **Proposed solution** Add the qualified OpenCode app-server proxy, driver, MCP bridge, backend, fixtures, and factory wiring. Keep existing Codex behavior unchanged. **Alternatives considered** Keeping OpenCode only on the direct adapter path would avoid this runtime work, but it would not provide durable runner recovery or normalized provider events. **Roadmap alignment** ROADMAP.md does not list a conflicting provider-runtime project. This change extends the existing Paperclip Runner architecture. ## What Changed - Added the qualified OpenCode app-server proxy and input queue. - Added collaboration-mode and provider-event normalization. - Added the OpenCode MCP bridge and native session backend. - Added strict fixtures and focused unit coverage. - Added only the package exports and adapter configuration required by this runtime. - Kept deferred SDK, lab, eval, and public package surfaces out of this change. ## Verification - GitHub Actions is the authoritative verification environment for this PR. - Run the package type checks and focused OpenCode tests in CI. - Run repository typecheck, test, build, security, and policy gates through the stack-aware workflow. - Local tests were not run because this checkout is resource constrained. ## Risks - OpenCode protocol changes could affect event normalization or recovery. - The driver fails closed on malformed input and unsupported runtime behavior. - Existing Codex selection remains unchanged unless the stored provider is OpenCode. > For core feature work, check [ROADMAP.md](ROADMAP.md) first and discuss it in #dev before opening the PR. Feature PRs that overlap with planned core work may need to be redirected. ## Model Used OpenAI Codex, GPT-5.6, with repository tools, code execution, and parallel agent review. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either linked existing issues or described the issue in-PR following the relevant issue template - [x] I have not referenced internal or instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal Paperclip ticket id - [ ] I have run tests locally and they pass — GitHub Actions is authoritative for this resource-constrained checkout - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge ## Stack - Position: 1 of 4 - Base: master - Next: additional qualified provider runtimes
1856 lines
77 KiB
TypeScript
1856 lines
77 KiB
TypeScript
import { spawn, type ChildProcess } from "node:child_process";
|
|
import { randomBytes } from "node:crypto";
|
|
import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|
import { createServer } from "node:net";
|
|
import { dirname, join, resolve } 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.17" as const;
|
|
export const QUALIFIED_OPENCODE_MODEL = "openrouter/deepseek/deepseek-v4-flash-0731" as const;
|
|
|
|
type DynamicToolHandler = (call: {
|
|
tool: string;
|
|
callId: string;
|
|
threadId: string;
|
|
turnId: string;
|
|
arguments: unknown;
|
|
}) => Promise<unknown>;
|
|
|
|
export interface OpenCodeServerDriverOptions {
|
|
model: string;
|
|
permissionMode?: "allow" | "ask" | "deny";
|
|
taskEnvelope?: CodexTaskEnvelope;
|
|
runnerInstanceId?: string;
|
|
command?: string;
|
|
runtimeDirectory: string;
|
|
systemInstructions?: string;
|
|
runtimeContext?: NativeRuntimeContextSnapshot | null;
|
|
environment?: NodeJS.ProcessEnv;
|
|
dynamicTools?: readonly Readonly<Record<string, unknown>>[];
|
|
dynamicToolHandler?: DynamicToolHandler;
|
|
onSpawn?: (meta: { pid: number; processGroupId: number | null; startedAt: string }) => Promise<void>;
|
|
/** 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<void>;
|
|
}
|
|
|
|
const CAPABILITIES: NativeSessionCapabilities = {
|
|
resume: 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<HarnessDriverDescriptor> {
|
|
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<HarnessDriverConfigValidation> {
|
|
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<HarnessSession> {
|
|
return this.#open(input, null);
|
|
}
|
|
|
|
async recoverSession(snapshot: PersistedHarnessSession): Promise<HarnessSessionRecoveryResult> {
|
|
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]) };
|
|
}
|
|
}
|
|
|
|
async #readWorkspace(normalizedSessionId: string): Promise<string> {
|
|
const raw = await readFile(join(sessionRoot(this.#options.runtimeDirectory, normalizedSessionId), "workspace"), "utf8");
|
|
return raw.trim();
|
|
}
|
|
|
|
async #open(input: OpenHarnessSessionInput, snapshot: PersistedHarnessSession | null): Promise<HarnessSession> {
|
|
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." }),
|
|
systemInstructions: this.#options.systemInstructions ?? CODEX_SKILLLESS_BASE_INSTRUCTIONS,
|
|
dynamicToolHandler: this.#options.dynamicToolHandler,
|
|
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 #now: () => Date;
|
|
readonly #events = new AsyncQueue<PrpEvent>();
|
|
readonly #transcript: PrpEvent[] = [];
|
|
readonly #terminalTurns = new Map<string, string>();
|
|
readonly #seenProviderEvents = new Set<string>();
|
|
readonly #messageRoles = new Map<string, string>();
|
|
readonly #pendingMessageParts = new Map<string, Array<Record<string, unknown>>>();
|
|
readonly #partText = new Map<string, string>();
|
|
readonly #completedTextPartIds = new Set<string>();
|
|
readonly #completedTextParts: Array<{
|
|
partId: string;
|
|
messageId: string | null;
|
|
text: string;
|
|
item: Record<string, unknown>;
|
|
observedSourceSeq: number;
|
|
}> = [];
|
|
readonly #messageUsageFingerprints = new Map<string, string>();
|
|
readonly #workspaceChangesByTurn = new Map<string, Record<string, unknown>>();
|
|
readonly #emittedFileReferences = new Set<string>();
|
|
readonly #pendingRuntimeRequests = new Map<string, {
|
|
request: HarnessRuntimeRequest;
|
|
nativeQuestions: Record<string, unknown>[];
|
|
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<string, unknown> | null = null;
|
|
#sendFullContext: boolean;
|
|
#closed = false;
|
|
#abort = new AbortController();
|
|
|
|
constructor(input: {
|
|
runtime: OpenCodeRuntime;
|
|
fetcher: typeof globalThis.fetch;
|
|
runId: string;
|
|
normalizedSessionId: string;
|
|
providerSessionId: string;
|
|
workingDirectory: string;
|
|
runnerInstanceId: string;
|
|
model: string;
|
|
taskEnvelope: CodexTaskEnvelope;
|
|
systemInstructions: string;
|
|
dynamicToolHandler?: DynamicToolHandler;
|
|
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.#taskEnvelope = input.taskEnvelope;
|
|
this.#systemInstructions = input.systemInstructions;
|
|
this.#dynamicToolHandler = input.dynamicToolHandler;
|
|
this.#now = input.now;
|
|
this.#sendFullContext = input.snapshot === null;
|
|
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.#completedTextParts.length = 0;
|
|
this.#terminalTurns.clear();
|
|
this.#sendFullContext = false;
|
|
this.#emit("run.attached", { runId: input.runId, sameSession: true });
|
|
}
|
|
|
|
events(): AsyncIterable<PrpEvent> { 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,
|
|
// This exact OpenCode version passed the native-question conformance
|
|
// suite. question.asked is adapted into PRP v2 and its reply/reject API
|
|
// remains private to this driver.
|
|
tools: { question: true },
|
|
...(this.#sendFullContext ? { system: this.#systemInstructions } : {}),
|
|
parts: [{ type: "text", text: prompt }],
|
|
}),
|
|
});
|
|
this.#sendFullContext = false;
|
|
return { turnId };
|
|
}
|
|
|
|
async interrupt(input: { turnId?: string; reason?: string }): Promise<void> {
|
|
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<void> {
|
|
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<unknown>) => {
|
|
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<Record<string, unknown>> {
|
|
const messages = await api(this.#fetch, this.#runtime, `/session/${encodeURIComponent(this.#providerSessionId)}/message`);
|
|
return { sessionId: this.#providerSessionId, messages };
|
|
}
|
|
|
|
async reconcile(): Promise<Record<string, unknown>> {
|
|
const status = await api(this.#fetch, this.#runtime, "/session/status");
|
|
return { sessionId: this.#providerSessionId, status: record(status)[this.#providerSessionId] ?? null };
|
|
}
|
|
|
|
async usage(): Promise<Record<string, unknown> | null> { return this.#usage === null ? null : structuredClone(this.#usage); }
|
|
|
|
async transcript(): Promise<HarnessTranscriptSnapshot> {
|
|
return {
|
|
schema: "paperclip-runner/harness-transcript/v1",
|
|
complete: true,
|
|
eventCount: this.#transcript.length,
|
|
events: structuredClone(this.#transcript),
|
|
omissionReason: null,
|
|
};
|
|
}
|
|
|
|
async snapshot(): Promise<PersistedHarnessSession> {
|
|
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<void> {
|
|
if (this.#closed) return;
|
|
this.#closed = true;
|
|
this.#abort.abort();
|
|
for (const { request } of this.#pendingRuntimeRequests.values()) {
|
|
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 },
|
|
);
|
|
}
|
|
this.#pendingRuntimeRequests.clear();
|
|
this.#events.close();
|
|
await this.#runtime.close();
|
|
}
|
|
|
|
async dispatchTool(call: { tool: string; callId: string; arguments: unknown }): Promise<unknown> {
|
|
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) {
|
|
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 fingerprint = canonicalJson(validation.result);
|
|
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: "Semantic completion accepted." },
|
|
}, { turnId, itemId: call.callId });
|
|
return { accepted: true };
|
|
}
|
|
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<void> {
|
|
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<void> {
|
|
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<string, unknown>): 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<string, unknown>, 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<void> {
|
|
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;
|
|
}
|
|
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) {
|
|
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,
|
|
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, itemId: pending.request.itemId },
|
|
);
|
|
return;
|
|
}
|
|
if (type === "message.part.updated" && turnId) {
|
|
const part = record(properties.part);
|
|
const messageId = text(part.messageID, text(part.messageId));
|
|
if (!messageId) return;
|
|
const role = this.#messageRoles.get(messageId);
|
|
if (role === "assistant") this.#emitAssistantPart(part, turnId);
|
|
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" && turnId) {
|
|
const info = record(properties.info);
|
|
const messageId = text(info.id, text(info.messageID, text(info.messageId)));
|
|
const role = text(info.role);
|
|
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, turnId);
|
|
}
|
|
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 }, { turnId, itemId: `${turnId}: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` });
|
|
this.#activeTurnId = null;
|
|
this.#emit("turn.completed", { status: "completed" }, { turnId });
|
|
this.#events.close();
|
|
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.
|
|
this.#activeTurnId = null;
|
|
this.#emit("turn.cancelled", {
|
|
status: "cancelled",
|
|
error: bounded(properties.error ?? properties),
|
|
}, { turnId });
|
|
this.#terminalTurns.set(turnId, canonicalJson({ status: "cancelled" }));
|
|
this.#events.close();
|
|
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` });
|
|
this.#activeTurnId = null;
|
|
this.#emit("turn.failed", { status: "failed", error: bounded(properties.error ?? properties) }, { turnId });
|
|
this.#terminalTurns.set(turnId, canonicalJson({ status: "failed" }));
|
|
this.#events.close();
|
|
}
|
|
}
|
|
|
|
#emitAssistantPart(part: Record<string, unknown>, 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<string, unknown>[] => {
|
|
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) {
|
|
this.#emit("item.delta", { kind: partType, text: delta, item: bounded(part) }, { turnId, itemId: partId });
|
|
}
|
|
const completedAt = record(part.time).end;
|
|
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 can place terminal-tool troubleshooting text in the same
|
|
// native message as paperclip_finish, then emit the actual answer in a
|
|
// later message. It can also append a terse acknowledgement after a real
|
|
// answer. With no native commentary/final channel, prefer the most
|
|
// substantive eligible text and use structural correlation/order only as
|
|
// tie breakers. This does not cap, truncate, or regex-filter model prose.
|
|
const selected = eligibleText.reduce<(typeof eligibleText)[number] | null>((best, candidate) => {
|
|
if (best === null) return candidate;
|
|
const candidateLength = candidate.text.trim().length;
|
|
const bestLength = best.text.trim().length;
|
|
if (candidateLength !== bestLength) return candidateLength > bestLength ? candidate : best;
|
|
const candidateCorrelated = candidate.messageId === this.#semanticResultProviderMessageId;
|
|
const bestCorrelated = best.messageId === this.#semanticResultProviderMessageId;
|
|
if (candidateCorrelated !== bestCorrelated) return candidateCorrelated ? candidate : best;
|
|
const candidateBeforeResult = this.#semanticResultTextBoundary !== null
|
|
&& this.#completedTextParts.indexOf(candidate) < this.#semanticResultTextBoundary;
|
|
const bestBeforeResult = this.#semanticResultTextBoundary !== null
|
|
&& this.#completedTextParts.indexOf(best) < this.#semanticResultTextBoundary;
|
|
if (candidateBeforeResult !== bestBeforeResult) return candidateBeforeResult ? candidate : best;
|
|
return candidate.observedSourceSeq >= best.observedSourceSeq ? candidate : best;
|
|
}, 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<string, unknown>, refs: { turnId?: string; itemId?: string } = {}): void {
|
|
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);
|
|
}
|
|
}
|
|
|
|
async function startRuntime(input: {
|
|
options: OpenCodeServerDriverOptions;
|
|
root: string;
|
|
cwd: string;
|
|
trace: ProviderTraceFileSink | null;
|
|
dispatch: (call: { tool: string; callId: string; arguments: unknown }) => Promise<unknown>;
|
|
}): Promise<OpenCodeRuntime> {
|
|
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);
|
|
input.trace?.addSensitiveValues([
|
|
password,
|
|
authHeader,
|
|
bridge.secret,
|
|
assignedMcp?.token,
|
|
input.options.environment?.OPENROUTER_API_KEY,
|
|
]);
|
|
const instructionRoot = input.options.runtimeContext?.instructions.bundle.rootPath;
|
|
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: [],
|
|
tools: {
|
|
question: true,
|
|
},
|
|
permission: {
|
|
"*": input.options.permissionMode ?? "allow",
|
|
question: "allow",
|
|
"paperclip_*": "allow",
|
|
"mcp__paperclip__*": "allow",
|
|
external_directory: instructionRoot
|
|
? { "*": "deny", [`${instructionRoot}/**`]: "allow" }
|
|
: "deny",
|
|
},
|
|
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,
|
|
});
|
|
const isolateProcessGroup = input.options.isolateProcessGroup ?? true;
|
|
const child = spawn(input.options.command ?? "opencode", ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
|
|
cwd: input.cwd,
|
|
env: environment,
|
|
stdio: ["ignore", "ignore", "pipe"],
|
|
detached: globalThis.process.platform !== "win32" && isolateProcessGroup,
|
|
});
|
|
let diagnostics = "";
|
|
child.stderr?.on("data", (chunk) => {
|
|
const raw = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
const redactedDiagnostic = redact(raw.toString("utf8"), [
|
|
password,
|
|
input.options.environment?.OPENROUTER_API_KEY,
|
|
]);
|
|
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);
|
|
});
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
child.once("spawn", resolve);
|
|
child.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: [password, input.options.environment?.OPENROUTER_API_KEY].filter((value): value is string => Boolean(value)),
|
|
close: async (closeInput = {}) => {
|
|
await bridge.close().catch(() => {});
|
|
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
|
try {
|
|
if (globalThis.process.platform === "win32" || !isolateProcessGroup) child.kill("SIGTERM");
|
|
else globalThis.process.kill(-child.pid, "SIGTERM");
|
|
} catch { child.kill("SIGTERM"); }
|
|
}
|
|
await waitForExit(child, 2_000);
|
|
if (child.exitCode === null && child.signalCode === null && child.pid) {
|
|
try {
|
|
if (globalThis.process.platform === "win32" || !isolateProcessGroup) child.kill("SIGKILL");
|
|
else globalThis.process.kill(-child.pid, "SIGKILL");
|
|
} catch { child.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 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<string, unknown>[],
|
|
metadata: Record<string, unknown> = {},
|
|
): 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<string, unknown>[] },
|
|
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<string, unknown>, 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<unknown> {
|
|
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<unknown> {
|
|
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<number> {
|
|
const server = createServer();
|
|
await new Promise<void>((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<void>((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
|
|
return port;
|
|
}
|
|
|
|
async function* parseSseFrames(
|
|
stream: ReadableStream<Uint8Array>,
|
|
): AsyncIterable<{ raw: string; data: string }> {
|
|
const decoder = new TextDecoder();
|
|
let buffer = "";
|
|
for await (const chunk of stream as unknown as AsyncIterable<Uint8Array>) {
|
|
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<Uint8Array>): AsyncIterable<unknown> {
|
|
for await (const frame of parseSseFrames(stream)) yield JSON.parse(frame.data);
|
|
}
|
|
|
|
function sanitizedEnvironment(source: NodeJS.ProcessEnv, overrides: Record<string, string>): NodeJS.ProcessEnv {
|
|
const allowed = ["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<string, unknown> {
|
|
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<string, unknown> { return isRecord(value) ? value : {}; }
|
|
function isRecord(value: unknown): value is Record<string, unknown> { 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<void> {
|
|
if (process.exitCode !== null || process.signalCode !== null) return;
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setTimeout(resolve, timeoutMs);
|
|
timer.unref?.();
|
|
process.once("exit", () => { clearTimeout(timer); resolve(); });
|
|
});
|
|
}
|
|
|
|
export const openCodeServerDriverInternals = { parseSse };
|
|
|
|
class AsyncQueue<T> implements AsyncIterable<T> {
|
|
#items: T[] = [];
|
|
#waiters: Array<{ resolve: (result: IteratorResult<T>) => 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<T> {
|
|
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 }));
|
|
},
|
|
};
|
|
}
|
|
}
|