Files
PaperClipAI/packages/paperclip-runner/src/cli/opencode-app-server-proxy.ts
T
Devin FoleyandClaude Opus 5.5 16816d45e6 fix(runner): keep OpenCode thread/read within runnerd's frame limit (#18218)
## 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>
2026-10-29 15:13:29 -07:00

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();
});