Files
PaperClipAI/packages/paperclip-runner/src/drivers/opencode/opencode-server-driver.ts
T
DottaandPaperclip 6080daf262 fix(runner): start native OpenCode without postinstall (#15702)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip Runner starts a qualified OpenCode runtime for native
tasks.
> - The default launch path uses a file populated by OpenCode's
postinstall script.
> - Package managers can skip that script and leave a text placeholder.
Native task startup then fails with `spawn ENOEXEC` before the model
request.
> - This pull request resolves the declared platform package directly
and includes the pinned runtime in the published server dependency
graph.
> - Users can run native OpenCode tasks after a normal install without
global commands or manual dependency repair.

## Linked Issues or Issue Description

**What happened?**

Native OpenCode tasks fail at session startup when dependency
postinstall is skipped. The Runner selects
`opencode-ai/bin/opencode.exe`, which contains a text placeholder. The
published server also lacks the OpenCode runtime dependency used by its
vendored Runner.

**Expected behavior**

Paperclip starts the qualified platform binary installed by the package
manager. macOS uses a Mac binary. No manual postinstall command is
needed.

**Steps to reproduce**

1. Install dependencies with lifecycle scripts disabled.
2. Configure a native OpenCode agent with an OpenRouter model and valid
credentials.
3. Assign a task. The previous default fails before any model request.

Related work: #15682 fixes HTTP transport registration. #15422 includes
compatible OpenCode resolution in a broader agent setup change. This PR
isolates the runtime repair for current users.

## What Changed

- Resolve pinned OpenCode platform packages through the module
dependency graph in source, standalone, and vendored server layouts.
- Check package identity, version, platform, executable containment, and
execute permission. Preserve verified executable launch profiles.
- Use the resolver for new and resumed native sessions. Reject missing
remote pack executables before controller dependency lookup.
- Declare `opencode-ai@1.18.34` as a published server dependency. Leave
platform selection to the consumer's package manager.
- Check remote paths for the selected provider only. An unused OpenCode
path must not reject a Pi run on macOS.
- Add skipped-postinstall and invalid artifact regressions. Document
automatic runtime selection.

## Verification

- Regression tests fail with the previous default path and pass with
platform resolution.
- Source, standalone, and vendored layout fixtures cover Apple Silicon,
Intel Mac, and Linux x64 baseline packages.
- A fresh npm install with `--ignore-scripts` resolves the macOS
executable and reports `1.18.34`. The placeholder remains unchanged.
- Live browser test on Apple Silicon: a native
OpenCode/OpenRouter/DeepSeek task ran shell arithmetic, created and
uploaded a verified 23-byte file, and completed. A follow-up ran shell
arithmetic and completed using the same session. Both runs committed
successfully while the wrapper remained broken.
- `pnpm -r typecheck` and `pnpm build` passed. All 269 affected Runner
tests and 8 server packaging tests passed. `pnpm test:run` encountered
15 initial database-heavy failures; all 15 passed on an isolated group
rerun. The redundant local run was then stopped after the matching
server and workspace CI suites passed. All final-head checks passed (56
passed, 2 skipped); its unrelated chat-retry browser assertion passed on
one rerun. An additional local repeat could not start its temporary
server within the startup timeout. Greptile reviewed the final head at
5/5 with no open findings.

## Risks

- Native runtime resolution requires the pinned optional platform
package. Installations that omit optional dependencies fail with an
explicit runtime error.
- Supported native targets remain Apple Silicon, Intel Mac, and Linux
x64. This change does not qualify another platform.
- Published server installations now install the OpenCode dependency.
Remote provider packs retain their verified executable paths and
digests.
- No schema changes or agent configuration migration.
- Repository policy assigns `pnpm-lock.yaml` to CI. PR CI refreshes
dependency resolution before frozen installs, and the master workflow
updates the tracked lockfile. This PR leaves the tracked lockfile
unchanged.

## Model Used

- OpenAI GPT-6 through Codex, with reasoning, tool use, and code
execution. The session does not expose the exact GPT-6 variant or
context window.
- Live verification used OpenRouter `deepseek/deepseek-v4-flash-0731`;
it did not author this patch.

## 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 (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-09 11:46:15 -05:00

2993 lines
105 KiB
TypeScript

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<string, string[]> = {
"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<unknown>;
export type OpenCodeCompletionFeedback = (result: PrpStructuredRunResult, call: {
tool: string;
callId: string;
threadId: string;
turnId: string;
}) => Promise<string>;
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<Record<string, unknown>>[];
dynamicToolHandler?: DynamicToolHandler;
completionFeedback?: OpenCodeCompletionFeedback;
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,
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<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,
this.#options.environment?.PAPERCLIP_AI_PROVIDER_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.",
}),
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<PrpEvent>();
readonly #transcript: PrpEvent[] = [];
readonly #terminalTurns = new Map<string, string>();
readonly #seenProviderEvents = new Set<string>();
// 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<string, 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 #completedReasoningPartIds = 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;
readonly #conversationMode: "task" | "prepared";
#sendFullContext: boolean;
#closed = false;
#completionSettlement: Promise<void> | 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<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,
// 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<void> {
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<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;
// 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<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) {
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<void>(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<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;
}
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<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) {
// 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<string, unknown>,
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<AbortController>();
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<string, unknown>;
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<IncomingMessage>((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<string, string> = {};
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<void>((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<void>((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<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,
);
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<string, string> = { "*": "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<void>((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<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 = [
"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<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 }),
);
},
};
}
}