mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 17:51:58 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work.
> - Paperclip Runner runs OpenCode agents through runnerd and the
OpenCode app-server proxy
(`packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts`).
> - When runnerd restores an OpenCode session, it sends `thread/resume`
and then `thread/read`. It uses `thread/read` only to find an active
provider turn.
> - The proxy answered `thread/read` with the full retained OpenCode
message history (`GET /session/{id}/message`) in one JSON-RPC line.
> - runnerd rejects a provider stdout line that is larger than 4 MiB
(`CODEX_APP_SERVER_MAX_FRAME_BYTES`). A long OpenCode session therefore
can never resume. Each restore fails with `harness stdout frame exceeded
4194304 bytes`, and the run stops with `provider_frame_too_large`.
> - This pull request makes the proxy return turn metadata only, the
same as the Codex metadata-only read.
> - The benefit is that long OpenCode sessions resume again, and resume
no longer reads the full message history.
## Linked Issues or Issue Description
**What happened?**
A Paperclip Runner OpenCode task on staging tried to resume its session
in a new sandbox. The restored OpenCode state was approximately 41.5 MB.
The `run.attach` command failed with `failed to resume OpenCode
provider: failed to start opencode provider: harness stdout frame
exceeded 4194304 bytes`. The server recorded
`native_session_recovery_failed` with error code
`provider_frame_too_large`, and it stopped automatic replay. Each new
attempt has the same failure, because the message history only grows.
**Steps to reproduce**
1. Run an OpenCode task until its session holds more than 4 MiB of
message parts.
2. Let the run continue in a new runner process, so that runnerd must
send `thread/resume` and then `thread/read`.
3. The proxy writes the full message history in the `thread/read`
response. runnerd rejects the frame and the resume fails.
**Expected behavior**
The resume succeeds. The size of the session history does not change the
size of the `thread/read` response.
**Root cause**
`thread/read` in the OpenCode proxy added `transcript: await
session.read?.()`. No consumer reads this field. The Rust provider reads
only `thread.turns` (`latest_active_turn_id`). The Codex path already
avoids message hydration for the same check ("Never hydrate message
contents just to decide whether this thread has an active turn").
## What Changed
- `opencode-app-server-proxy.ts`: `thread/read` now returns `thread.id`,
`thread.cwd` and `thread.turns` only. It does not call `session.read()`.
- `test/fixtures/fake-opencode-server.mjs`: the fake OpenCode server can
now return a large retained message history. The test enables this with
a `fake-message-history-bytes` file in `XDG_DATA_HOME`. This follows the
existing file-switch pattern of the fixture. Without the file, the
response is `[]`, as before.
- `src/cli/opencode-proxy-thread-read.test.ts`: a new regression test.
It builds the real bundled proxy entrypoint, starts a session, stops the
proxy, gives the persisted session a 5 MiB history, then starts a new
proxy and sends `thread/resume` and `thread/read` (the runnerd restore
sequence). It asserts that all stdout frames stay below 4 MiB and that
`thread/read` still reports the thread id and turns.
## Verification
- Red before the fix: `npx vitest run
src/cli/opencode-proxy-thread-read.test.ts` failed with `expected
5243183 to be less than 4194304`. The 5,243,183-byte frame was the
`thread/read` response.
- Green after the fix: the same command passes.
- `npx vitest run src/cli/opencode-app-server-proxy.test.ts
src/cli/opencode-proxy-thread-read.test.ts`: 6 passed.
- `npx vitest run src/cli/opencode-proxy
src/backends/native-instruction-measurement.test.ts
src/drivers/opencode`: 158 passed, 1 failed. The failure
(`opencode-server-driver.test.ts` > "uses provider structure rather than
prose length to select a post-tool answer") also fails on the unchanged
base commit in this environment. This change does not touch that path.
- `tsc -p tsconfig.json --noEmit` for `packages/paperclip-runner`:
passed.
- Safety checks:
- Consumers: only the runnerd Rust provider (`read_thread`) calls
`thread/read` on this proxy. It reads `thread.turns`. No TypeScript or
Rust code reads `transcript`.
- Active-turn detection does not change. The `turns` logic is the same
code.
- Resume now does less work: one `GET /session/{id}/message` request
less.
- Not run locally: the Rust tests and a full runnerd plus real OpenCode
resume. This environment has no Rust toolchain. The Rust code does not
change. CI runs the Rust suites.
## Risks
- Low risk. The change removes one unused response field.
- If an external tool read `transcript` from the proxy's `thread/read`,
it does not get it now. I found no such consumer in this repository.
- Tasks that already failed with `provider_frame_too_large` continue
only after this proxy reaches the runner provider pack and the task runs
again.
## Model Used
- Provider: Anthropic. Model: Claude Opus 5.5 (`claude-opus-5-5`). Used
through Claude Code with tool use (shell, file edit, GitHub connector)
and standard reasoning.
## 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 (related: #16401 kept OpenCode turns alive through oversized
SSE events. It does not change `thread/read`.)
- [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 (none
required: no documented contract includes `transcript`)
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [ ] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
517 lines
18 KiB
JavaScript
517 lines
18 KiB
JavaScript
#!/usr/bin/env node
|
|
import { parseOpenCodeReasoningMode } from "../drivers/opencode/reasoning-mode.js";
|
|
import { createInterface } from "node:readline";
|
|
import { readFileSync } from "node:fs";
|
|
import { resolve } from "node:path";
|
|
|
|
import type {
|
|
HarnessRuntimeRequestResolution,
|
|
HarnessSession,
|
|
PersistedHarnessSession,
|
|
} from "../contracts/harness-driver.js";
|
|
import { OpenCodeServerDriver } from "../drivers/opencode/opencode-server-driver.js";
|
|
import { parseNativeRuntimeContext } from "../contracts/runtime-context.js";
|
|
import { openCodeProxyTaskEnvelope } from "./opencode-proxy-task-envelope.js";
|
|
import {
|
|
openCodeProxyItemNotification,
|
|
openCodeProxyToolNotification,
|
|
openCodeProxyTerminalNotification,
|
|
shouldAnnounceOpenCodeProxyTurn,
|
|
shouldForwardOpenCodeProxyItem,
|
|
} from "./opencode-proxy-events.js";
|
|
import { enqueueOpenCodeProxyInput, openCodeProxyCompletionFeedback } from "./opencode-proxy-input.js";
|
|
import {
|
|
assertOpenCodeProxyCollaborationMode,
|
|
openCodeProxyCollaborationModes,
|
|
} from "./opencode-proxy-collaboration-mode.js";
|
|
import { parseOpenCodeProxyPermissionMode } from "./opencode-proxy-permission-mode.js";
|
|
import {
|
|
trustedOpenCodeLaunchBinding,
|
|
withoutAmbientOpenCodeCommand,
|
|
} from "./opencode-proxy-command.js";
|
|
import { OpenCodeProxyUsageLedger } from "./opencode-proxy-usage.js";
|
|
import { parseProviderPerformanceSpans, PROVIDER_PERFORMANCE_DIAGNOSTIC } from "../contracts/provider-performance.js";
|
|
|
|
type RpcMessage = {
|
|
id?: string | number;
|
|
method?: string;
|
|
params?: unknown;
|
|
result?: unknown;
|
|
error?: unknown;
|
|
};
|
|
|
|
const input = createInterface({ input: process.stdin, crlfDelay: Infinity });
|
|
const pending = new Map<
|
|
string,
|
|
{ resolve(value: unknown): void; reject(error: Error): void; retainEnvelope: boolean }
|
|
>();
|
|
let controllerInputClosed = false;
|
|
let nextServerRequestId = 1;
|
|
let driver: OpenCodeServerDriver | null = null;
|
|
let session: HarnessSession | null = null;
|
|
let eventPump: Promise<void> | null = null;
|
|
let cwd = "";
|
|
let activeModel = "";
|
|
let activeTurnId: string | null = null;
|
|
let activeRunId = process.env.PAPERCLIP_RUN_ID ?? "runnerd-run";
|
|
const announcedTurnIds = new Set<string>();
|
|
const launchBinding = trustedOpenCodeLaunchBinding(process.argv.slice(2));
|
|
const usageLedger = new OpenCodeProxyUsageLedger();
|
|
|
|
function send(value: unknown): void {
|
|
process.stdout.write(`${JSON.stringify(value)}\n`);
|
|
}
|
|
|
|
function requestController(method: string, params: unknown, retainEnvelope = false): Promise<unknown> {
|
|
if (controllerInputClosed) return Promise.reject(new Error("OpenCode controller input is closed"));
|
|
const id = `opencode-${nextServerRequestId++}`;
|
|
send({ id, method, params });
|
|
return new Promise((resolveValue, reject) =>
|
|
pending.set(id, { resolve: resolveValue, reject, retainEnvelope }),
|
|
);
|
|
}
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
? (value as Record<string, unknown>)
|
|
: {};
|
|
}
|
|
|
|
function text(value: unknown, fallback = ""): string {
|
|
return typeof value === "string" ? value : fallback;
|
|
}
|
|
|
|
function runtimeDirectory(): string {
|
|
const configured = process.env.PAPERCLIP_OPENCODE_RUNTIME_DIR?.trim();
|
|
if (!configured)
|
|
throw new Error("PAPERCLIP_OPENCODE_RUNTIME_DIR is required");
|
|
return resolve(configured);
|
|
}
|
|
|
|
async function open(
|
|
params: Record<string, unknown>,
|
|
resume: boolean,
|
|
): Promise<Record<string, unknown>> {
|
|
if (session)
|
|
return threadResponse(
|
|
session.ids().providerSessionId ?? session.ids().driverSessionId,
|
|
);
|
|
cwd = resolve(text(params.cwd, process.cwd()));
|
|
const model = text(params.model);
|
|
if (!model.includes("/"))
|
|
throw new Error("OpenCode proxy requires model in provider/model form");
|
|
activeModel = model;
|
|
const dynamicTools = Array.isArray(params.dynamicTools)
|
|
? params.dynamicTools.map(record)
|
|
: [];
|
|
const runtimeContextPath =
|
|
process.env.PAPERCLIP_NATIVE_RUNTIME_CONTEXT_PATH?.trim();
|
|
const runtimeContext = runtimeContextPath
|
|
? parseNativeRuntimeContext(
|
|
JSON.parse(readFileSync(runtimeContextPath, "utf8")),
|
|
)
|
|
: null;
|
|
driver = new OpenCodeServerDriver({
|
|
model,
|
|
permissionMode: parseOpenCodeProxyPermissionMode(
|
|
process.env.PAPERCLIP_OPENCODE_PERMISSION_MODE,
|
|
),
|
|
command: launchBinding.command,
|
|
commandFd: launchBinding.commandFd,
|
|
commandLifecycle: launchBinding.commandLifecycle,
|
|
runtimeDirectory: runtimeDirectory(),
|
|
environment: withoutAmbientOpenCodeCommand(process.env),
|
|
runnerInstanceId:
|
|
process.env.PAPERCLIP_RUNNER_INSTANCE_ID ?? "paperclip-runnerd-opencode",
|
|
taskEnvelope: openCodeProxyTaskEnvelope(params),
|
|
conversationMode:
|
|
params.conversationMode === "prepared" ? "prepared" : "task",
|
|
systemInstructions: text(
|
|
params.baseInstructions,
|
|
"Complete only the supplied task.",
|
|
),
|
|
runtimeContext,
|
|
dynamicTools,
|
|
completionFeedback: openCodeProxyCompletionFeedback((method, params) => requestController(method, params, true)),
|
|
dynamicToolHandler: async (call) =>
|
|
requestController("item/tool/call", {
|
|
threadId: session?.ids().driverSessionId ?? "opening",
|
|
turnId: call.turnId,
|
|
callId: call.callId,
|
|
tool: call.tool,
|
|
arguments: call.arguments,
|
|
}),
|
|
onDiagnostic: (message) => process.stderr.write(`[opencode] ${message}\n`),
|
|
// runnerd gives this proxy its own process group. Keep `opencode serve` in
|
|
// that same group so runnerd's TERM/KILL fallback cannot orphan it.
|
|
isolateProcessGroup: false,
|
|
});
|
|
if (resume) {
|
|
const threadId = text(params.threadId);
|
|
const snapshot: PersistedHarnessSession = {
|
|
driverKind: "opencode_server",
|
|
driverSessionId: threadId,
|
|
providerSessionId: threadId,
|
|
runId: activeRunId,
|
|
normalizedSessionId:
|
|
process.env.PAPERCLIP_NORMALIZED_SESSION_ID ?? threadId,
|
|
activeTurnId: null,
|
|
lastSourceSequence: 0,
|
|
};
|
|
const recovered = await driver.recoverSession(snapshot);
|
|
if (!recovered.recovered || !recovered.session)
|
|
throw new Error(recovered.reason ?? "OpenCode recovery failed");
|
|
session = recovered.session;
|
|
} else {
|
|
session = await driver.openSession({
|
|
runId: activeRunId,
|
|
normalizedSessionId:
|
|
process.env.PAPERCLIP_NORMALIZED_SESSION_ID ?? `runnerd-${Date.now()}`,
|
|
workingDirectory: cwd,
|
|
});
|
|
}
|
|
eventPump = pumpEvents(session);
|
|
void eventPump.catch((error) => failProxy(error));
|
|
return threadResponse(
|
|
session.ids().providerSessionId ?? session.ids().driverSessionId,
|
|
);
|
|
}
|
|
|
|
function threadResponse(id: string): Record<string, unknown> {
|
|
return { thread: { id, sessionId: id, cwd }, model: activeModel };
|
|
}
|
|
|
|
function announceTurnStarted(opened: HarnessSession, turnId: string): void {
|
|
if (!shouldAnnounceOpenCodeProxyTurn(announcedTurnIds, turnId)) return;
|
|
send({
|
|
method: "turn/started",
|
|
params: {
|
|
threadId: opened.ids().driverSessionId,
|
|
turnId,
|
|
turn: { id: turnId, status: "inProgress" },
|
|
},
|
|
});
|
|
}
|
|
|
|
async function pumpEvents(opened: HarnessSession): Promise<void> {
|
|
for await (const event of opened.events()) {
|
|
const payload = record(event.payload);
|
|
const toolActivity = openCodeProxyToolNotification({ eventType: event.eventType,
|
|
threadId: opened.ids().driverSessionId, turnId: event.turnId, payload });
|
|
if (toolActivity) {
|
|
send(toolActivity);
|
|
} else if (event.eventType === "turn.started") {
|
|
if (typeof event.turnId === "string")
|
|
announceTurnStarted(opened, event.turnId);
|
|
} else if (
|
|
event.eventType === "item.started" ||
|
|
event.eventType === "item.delta" ||
|
|
event.eventType === "item.completed"
|
|
) {
|
|
// The inner OpenCode driver publishes session-scoped model metadata as
|
|
// an item before any turn exists. The outer Codex protocol facade emits
|
|
// its own model item, so forwarding this unbound duplicate would quite
|
|
// correctly trip the facade's strict turn-binding validator.
|
|
if (
|
|
!shouldForwardOpenCodeProxyItem({
|
|
turnId: event.turnId,
|
|
kind: payload.kind,
|
|
})
|
|
)
|
|
continue;
|
|
send(
|
|
openCodeProxyItemNotification({
|
|
eventType: event.eventType,
|
|
threadId: opened.ids().driverSessionId,
|
|
turnId: event.turnId,
|
|
itemId: event.itemId,
|
|
payload,
|
|
}),
|
|
);
|
|
if (payload.kind === "usage") {
|
|
const usage = usageLedger.update({
|
|
turnId: text(event.turnId),
|
|
messageId: text(payload.usageMessageId),
|
|
usage: payload.usage,
|
|
});
|
|
send({
|
|
method: "thread/tokenUsage/updated",
|
|
params: {
|
|
threadId: opened.ids().driverSessionId,
|
|
turnId: event.turnId,
|
|
tokenUsage: usage,
|
|
},
|
|
});
|
|
}
|
|
} else if (event.eventType === "run.result.proposed") {
|
|
send({
|
|
method: "paperclip/runResult",
|
|
params: {
|
|
threadId: opened.ids().driverSessionId,
|
|
turnId: event.turnId,
|
|
itemId: event.itemId ?? "semantic-result",
|
|
result: payload,
|
|
},
|
|
});
|
|
} else if (event.eventType === "runtime_request.created") {
|
|
const request = record(payload.request);
|
|
const requestId = text(request.requestId);
|
|
const turnId = text(
|
|
request.turnId,
|
|
text(event.turnId, activeTurnId ?? ""),
|
|
);
|
|
if (!requestId || !turnId || !opened.resolveRuntimeRequest) {
|
|
throw new Error("OpenCode runtime request is not resolvable");
|
|
}
|
|
// Keep consuming OpenCode SSE while the controller waits for the user.
|
|
// If the underlying provider disappears, the stream can then fail the
|
|
// proxy and runnerd will expire the still-pending canonical request.
|
|
void (async () => {
|
|
const controllerResponse = record(
|
|
await requestController("paperclip/runtimeRequest", {
|
|
request,
|
|
}),
|
|
);
|
|
await opened.resolveRuntimeRequest!({
|
|
requestId,
|
|
turnId,
|
|
resolution: record(
|
|
controllerResponse.resolution,
|
|
) as HarnessRuntimeRequestResolution,
|
|
});
|
|
})().catch((error) => failProxy(error));
|
|
} else if (event.eventType === "run.attached") {
|
|
// OpenCode can retain one provider session across governed runs. Usage
|
|
// totals are run-scoped, so a new attachment must not inherit the prior
|
|
// run's completed-turn ledger.
|
|
usageLedger.reset();
|
|
} else if (
|
|
[
|
|
"turn.completed",
|
|
"turn.failed",
|
|
"turn.interrupted",
|
|
"turn.cancelled",
|
|
].includes(event.eventType)
|
|
) {
|
|
if (typeof event.turnId === "string")
|
|
usageLedger.completeTurn(event.turnId);
|
|
send(
|
|
openCodeProxyTerminalNotification({
|
|
eventType: event.eventType as
|
|
| "turn.completed"
|
|
| "turn.failed"
|
|
| "turn.interrupted"
|
|
| "turn.cancelled",
|
|
threadId: opened.ids().driverSessionId,
|
|
turnId: event.turnId,
|
|
payload,
|
|
}),
|
|
);
|
|
activeTurnId = null;
|
|
} else if (event.eventType === "harness.diagnostic") {
|
|
if (payload.code === PROVIDER_PERFORMANCE_DIAGNOSTIC) {
|
|
send({method: "paperclip/providerPerformance", params: {
|
|
threadId: opened.ids().driverSessionId, turnId: event.turnId,
|
|
spans: parseProviderPerformanceSpans(payload, Date.now()),
|
|
}});
|
|
continue;
|
|
}
|
|
send({
|
|
method: "warning",
|
|
params: {
|
|
threadId: opened.ids().driverSessionId,
|
|
message: payload.message ?? payload.code,
|
|
},
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async function handle(message: RpcMessage): Promise<void> {
|
|
if (message.method === undefined && message.id !== undefined) {
|
|
const waiter = pending.get(String(message.id));
|
|
if (!waiter) return;
|
|
pending.delete(String(message.id));
|
|
if (message.error !== undefined)
|
|
waiter.reject(new Error(JSON.stringify(message.error)));
|
|
else {
|
|
const contentItems = record(message.result).contentItems;
|
|
waiter.resolve(
|
|
!waiter.retainEnvelope && Array.isArray(contentItems)
|
|
? (contentItems[0] ?? message.result)
|
|
: message.result,
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
if (!message.method || message.id === undefined) return;
|
|
const params = record(message.params);
|
|
let result: unknown;
|
|
switch (message.method) {
|
|
case "initialize":
|
|
result = {
|
|
user: { sessionId: "opencode" },
|
|
serverInfo: { name: "opencode", version: "1.18.34" },
|
|
};
|
|
break;
|
|
case "thread/start":
|
|
result = await open(params, false);
|
|
break;
|
|
case "thread/resume":
|
|
result = await open(params, true);
|
|
break;
|
|
case "collaborationMode/list":
|
|
result = openCodeProxyCollaborationModes(activeModel);
|
|
break;
|
|
case "turn/start": {
|
|
if (!session) throw new Error("OpenCode thread is not open");
|
|
assertOpenCodeProxyCollaborationMode(params);
|
|
if (params.paperclipRunId !== undefined) {
|
|
const runId = params.paperclipRunId;
|
|
if (typeof runId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(runId))
|
|
throw new Error("Invalid Paperclip run identity");
|
|
if (runId !== activeRunId) {
|
|
if (!session.attachRun) throw new Error("OpenCode run attachment unavailable");
|
|
await session.attachRun({ runId });
|
|
activeRunId = runId;
|
|
}
|
|
}
|
|
const inputItems = Array.isArray(params.input)
|
|
? params.input.map(record)
|
|
: [];
|
|
const messageText = inputItems
|
|
.map((entry) => text(entry.text))
|
|
.filter(Boolean)
|
|
.join("\n");
|
|
const turn = await session.startTurn({
|
|
message: { role: "user", text: messageText },
|
|
...(params.reasoningMode === undefined ? {} : { reasoningMode: parseOpenCodeReasoningMode(params.reasoningMode) }),
|
|
});
|
|
activeTurnId = turn.turnId;
|
|
// OpenCode normally publishes session/turn startup over SSE, but a fast
|
|
// completion can make the synchronous prompt response the only place the
|
|
// authoritative turn id is observed. Emit the normalized notification
|
|
// after this request's JSON-RPC response when SSE did not already announce
|
|
// it. runnerd buffers notifications received while awaiting a response,
|
|
// so replaying an earlier SSE announcement would violate strict turn
|
|
// binding at the outer driver.
|
|
queueMicrotask(() => {
|
|
announceTurnStarted(session!, turn.turnId);
|
|
});
|
|
result = { turn: { id: turn.turnId, status: "inProgress" } };
|
|
break;
|
|
}
|
|
case "turn/interrupt":
|
|
if (!session?.interrupt)
|
|
throw new Error("OpenCode interruption is unavailable");
|
|
await session.interrupt({
|
|
turnId: text(params.turnId, activeTurnId ?? ""),
|
|
});
|
|
result = true;
|
|
break;
|
|
case "thread/read":
|
|
if (!session) throw new Error("OpenCode thread is not open");
|
|
// runnerd reads only turn metadata after resume. Hydrating the retained
|
|
// OpenCode message history here grows with the session and can exceed
|
|
// runnerd's bounded stdout frame, which permanently blocks resume.
|
|
result = {
|
|
thread: {
|
|
id: session.ids().driverSessionId,
|
|
cwd,
|
|
turns: activeTurnId
|
|
? [{ id: activeTurnId, status: "inProgress" }]
|
|
: [],
|
|
},
|
|
};
|
|
break;
|
|
default:
|
|
throw new Error(`Unsupported OpenCode proxy method ${message.method}`);
|
|
}
|
|
send({ id: message.id, result });
|
|
}
|
|
|
|
let pendingInput = Promise.resolve();
|
|
let bootstrapFailure: Error | null = null;
|
|
input.on("line", (line) => {
|
|
if (!line.trim()) return;
|
|
let message: RpcMessage;
|
|
try {
|
|
message = JSON.parse(line) as RpcMessage;
|
|
} catch (error) {
|
|
process.stderr.write(`Invalid JSON-RPC input: ${String(error)}\n`);
|
|
return;
|
|
}
|
|
if (message.method === undefined && message.id !== undefined) {
|
|
// A command may await this bound response. Queueing the response behind
|
|
// that command would deadlock completion, interruption and shutdown.
|
|
void handle(message).catch(failProxy);
|
|
return;
|
|
}
|
|
pendingInput = enqueueOpenCodeProxyInput(
|
|
pendingInput,
|
|
async () => {
|
|
if (bootstrapFailure) {
|
|
throw new Error(
|
|
`OpenCode provider bootstrap failed before ${message.method ?? "the dependent command"}: ${bootstrapFailure.message}`,
|
|
);
|
|
}
|
|
await handle(message);
|
|
},
|
|
(error) => {
|
|
const normalized =
|
|
error instanceof Error ? error : new Error(String(error));
|
|
if (
|
|
message.method === "initialize" ||
|
|
message.method === "thread/start" ||
|
|
message.method === "thread/resume"
|
|
) {
|
|
bootstrapFailure ??= normalized;
|
|
}
|
|
send({
|
|
id: message.id,
|
|
error: { code: -32000, message: normalized.message },
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
let shutdownPromise: Promise<void> | null = null;
|
|
function failProxy(error: unknown): void {
|
|
process.stderr.write(
|
|
`[opencode] fatal proxy error: ${error instanceof Error ? error.message : String(error)}\n`,
|
|
);
|
|
void shutdown(1);
|
|
}
|
|
|
|
function shutdown(exitCode = 0): Promise<void> {
|
|
shutdownPromise ??= (async () => {
|
|
for (const waiter of pending.values())
|
|
waiter.reject(new Error("OpenCode proxy is shutting down"));
|
|
pending.clear();
|
|
await session
|
|
?.close({ reason: "proxy_shutdown", force: true })
|
|
.catch(() => {});
|
|
await eventPump?.catch(() => {});
|
|
// A failed turn can be the last frame before a fatal exit. Pipe writes
|
|
// are asynchronous on some platforms, so let it reach runnerd first.
|
|
await new Promise<void>((resolve) => process.stdout.write("", () => resolve()));
|
|
})().finally(() => process.exit(exitCode));
|
|
return shutdownPromise;
|
|
}
|
|
|
|
input.on("close", () => {
|
|
controllerInputClosed = true;
|
|
for (const waiter of pending.values())
|
|
waiter.reject(new Error("OpenCode controller input closed before its response"));
|
|
pending.clear();
|
|
// Preserve command order and the bootstrap drain. A queued command cannot
|
|
// create a new unanswered controller request after EOF.
|
|
void pendingInput.then(() => shutdown());
|
|
});
|
|
process.on("SIGTERM", () => {
|
|
void shutdown();
|
|
});
|
|
process.on("SIGINT", () => {
|
|
void shutdown();
|
|
});
|