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. > - Native agents receive task constraints and completion tools from Paperclip. > - Completion tools already define the procedure for reporting a result. > - Repeated procedure text adds instructions to each full task turn. > - The final reply must still explain a blocker and link a saved document. > - This pull request removes repeated procedure text and keeps these visible outcome requirements explicit. > - A document receipt supplies the exact link, and stricter evals check the persisted reply and browser navigation. ## Linked Issues or Issue Description Refs: #14961. Related: #14948 and #15007. **What happened?** Native task envelopes repeat completion procedure text. A reduced envelope needs explicit final-reply requirements. The `write_document` receipt also lacks a canonical document link. **Expected behavior** Keep the completion tools as the source of procedure details. Require one accepted completion result before the final reply. A blocked reply must explain the reason, owner and unblock action. A document reply must contain a working link to the saved document. **Steps to reproduce** 1. Run the native assigned-skill document case and native blocker case. 2. Inspect the run-attributed provider final and its persisted comment. 3. Check the blocker explanation or open the final reply's document link. ## What Changed - Remove repeated completion procedure text from the native task constraints and backend instructions. - Keep explicit blocker and document-link requirements in full task turns. - Return a company/task-scoped `documentHref` from `write_document`. Preserve the link in the idempotent mutation receipt. - Repeat canonical links for this run's current saved revisions in accepted completion feedback. Give blocked providers final-response guidance for the cause, owner and unblock action. - Keep internal document/comment anchors when Markdown issue links load cached issue details. - Add a manual six-cell comparison suite with strict source, build, default-instruction and budget admission. - Capture eighteen shared runnerd RPC projections and six direct OpenCode HTTP projections across start, resume and continuation phases, using scripted local transports and no provider execution. - Apply v3 checks only to the manual instruction comparison; preserve v2 checks for the existing native completion suite. Check the actual persisted blocker reason and exact saved-document link. Click the rendered document link and check the original content marker in the classic document card or the new document tab. - Forward exact OpenCode finishing calls through the controller. Wait for acceptance, keep accepted feedback and concrete rejection text, and reject malformed responses. Preserve ordinary dynamic-tool response handling. - Settle the completion decision and tool response before mapping a racing idle/error/abort event or handling explicit close/interruption. Reject a concurrent finishing call before controller admission. - Add a provider-free regression through real runnerd, the OpenCode proxy and a fake provider. Reject the first completion, accept the corrected report in the same turn, and propose one result. - Keep all original verdicts unchanged. Treat replay under new checks as separate diagnostics. ## Verification - `pnpm -r typecheck` and `pnpm build` pass locally. - Native document-authority tests pass, including company/run authorization and idempotent replay. - Native runtime-context, backend and measurement tests pass. - Final-answer calibration, protocol scoring, source-admission and catalog tests pass. Wrong reasons, absent links and wrong link targets fail. - `pnpm test:e2e:runner:typecheck` passes. Discovery lists exactly six single-attempt local cells with the declared models. - Exported `prepareNativeInstructionPreflight` then `verifyNativeInstructionPreflight` pass on this clean committed source. They build locally and make zero provider calls. - Corrective live confirmation is incomplete. Source3a7349dpassed both Claude and both Codex cases. OpenCode saved the correct document but omitted its final link; its blocker case was canceled before paid execution. Preserve this failure. Thee171282confirmation was stopped during build after fresh review found a completion-settlement race; it executed zero providers. Sourcec3e0cb303fixes that race. Two affected OpenCode cases await fresh review and one bounded confirmation; earlier results remain attributed to their original source. - OpenCode proxy parsing, driver, factory and input tests: 81 pass across retained focused runs, including six settlement races. Evaluator/scoring/admission checks: 122 pass. The real proxy regression passes. Fresh local prepare then verify passes with 18 shared and 6 direct scripted captures, fresh SDK/Rust builds and zero providers. - The full local suite recorded two failures: a webhook timeout and a Git-scan load count mismatch. Both files pass in isolation with unchanged assertions/time budgets; preserve the original failure log. Freshc3e0cb303CI and review are pending. This PR remains draft. ## Risks - Final-answer wording can vary by provider. The checks cover the declared release-access blocker and saved document fixture, not general answer quality. - A single trial does not establish general equivalence, cause, speed, cost or live resume behavior. - `documentHref` is an additive receipt field. It points to the current saved document, not an immutable historical revision. Replaying an older receipt does not fabricate a new link. - The correction adds four production paths for document receipts, accepted completion feedback and UI navigation, plus four OpenCode controller/proxy paths, beyond the original three instruction paths. Completion rejection must remain repairable; the production-boundary regression covers it. - Preserve the frozen comparison context for live measurement. A merge-tree check against current master is clean. Do not relabel earlier live results as results from a later source tree. ## Model Used - OpenAI Codex, GPT-6 family. The exact serving model ID and context window are unavailable in this session. Capabilities used: reasoning, code editing, shell execution, test authoring and evidence review. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (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 - [ ] 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
486 lines
16 KiB
JavaScript
486 lines
16 KiB
JavaScript
#!/usr/bin/env node
|
|
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,
|
|
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";
|
|
|
|
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;
|
|
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: process.env.PAPERCLIP_RUN_ID ?? "runnerd-run",
|
|
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: process.env.PAPERCLIP_RUN_ID ?? "runnerd-run",
|
|
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);
|
|
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") {
|
|
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.32" },
|
|
};
|
|
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);
|
|
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 },
|
|
});
|
|
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");
|
|
result = {
|
|
thread: {
|
|
id: session.ids().driverSessionId,
|
|
cwd,
|
|
turns: activeTurnId
|
|
? [{ id: activeTurnId, status: "inProgress" }]
|
|
: [],
|
|
},
|
|
transcript: await session.read?.(),
|
|
};
|
|
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(() => {});
|
|
})().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();
|
|
});
|