-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathopencode-server.mjs
More file actions
522 lines (473 loc) · 17.9 KB
/
opencode-server.mjs
File metadata and controls
522 lines (473 loc) · 17.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
// OpenCode HTTP API client.
// Unlike codex-plugin-cc which uses JSON-RPC over stdin/stdout,
// OpenCode exposes a REST API + SSE. This module wraps that API.
//
// Modified by JohnnyVicious (2026):
// - `ensureServer` spawns opencode with `stdio: "ignore"` instead of
// piping stdout/stderr that nothing reads. The piped streams were
// ref'd handles on the parent event loop, which deadlocked any
// long-lived parent (e.g. `node:test`) once opencode wrote enough
// log output to fill the pipe buffer. In normal CLI usage the
// deadlock was masked because the companion script exited before
// the buffer filled.
// - `ensureServer` also threads `OPENCODE_CONFIG_DIR` into the spawned
// server so our bundled `opencode-config/agent/review.md` custom
// agent is discovered. We prefer a dedicated read-only agent over
// OpenCode's built-in `plan` agent for reviews: `plan` injects a
// synthetic user-message directive ("Plan mode ACTIVE... produce an
// implementation plan") that overrides our review prompt and causes
// OpenCode to return plans instead of reviews.
// (Apache License 2.0 §4(b) modification notice — see NOTICE.)
import crypto from "node:crypto";
import os from "node:os";
import { spawn } from "node:child_process";
import http from "node:http";
import fs from "node:fs";
import path from "node:path";
import { platformShellOption, isProcessAlive as isProcessAliveWithToken } from "./process.mjs";
import { loadState } from "./state.mjs";
const DEFAULT_PORT = 4096;
const DEFAULT_HOST = "127.0.0.1";
const SERVER_START_TIMEOUT = 30_000;
const SERVER_REAP_IDLE_TIMEOUT = 5 * 60 * 1000; // 5 minutes
// Wall-clock budget for a single `sendPrompt` call. Must be longer than
// any single model turn the user can realistically wait for, but bounded
// so a wedged socket can't hang the companion forever. Overridable via
// OPENCODE_COMPANION_PROMPT_TIMEOUT_MS. The tracked-jobs layer has its
// own 30 min hard timer (OPENCODE_COMPANION_JOB_TIMEOUT_MS) on top of
// this — this constant is the per-HTTP-call fallback, not the
// authoritative cap.
const DEFAULT_PROMPT_TIMEOUT_MS = 30 * 60 * 1000;
function resolvePromptTimeoutMs() {
const fromEnv = Number(process.env.OPENCODE_COMPANION_PROMPT_TIMEOUT_MS);
if (Number.isFinite(fromEnv) && fromEnv > 0) return fromEnv;
return DEFAULT_PROMPT_TIMEOUT_MS;
}
/**
* POST a JSON body via `node:http` and return the parsed response.
*
* We deliberately avoid `fetch()` here because Node's bundled undici
* imposes a 300_000 ms default `bodyTimeout` that surfaces as
* `TypeError: terminated` when the OpenCode server holds the connection
* open mid-body for longer than 5 minutes — which is the normal case
* for long adversarial reviews against slow/free models. `node:http`
* has no such default, so this helper only enforces the explicit
* wall-clock timer we pass in.
*
* See issue: "OpenCode adversarial review failed: terminated" (fetch
* failed ~4.5 min into a run). The outer `AbortSignal.timeout()` we
* used before was a wall-clock abort, not a dispatcher-level body
* timeout, so it did not prevent undici from killing the socket first.
*
* @param {string} urlString Absolute URL to POST to.
* @param {Record<string,string>} headers
* @param {unknown} bodyObj JSON-serializable body.
* @param {{ timeoutMs?: number }} [opts]
* @returns {Promise<{ status: number, body: string }>}
*/
function httpPostJson(urlString, headers, bodyObj, opts = {}) {
const timeoutMs = Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
? opts.timeoutMs
: resolvePromptTimeoutMs();
const url = new URL(urlString);
const payload = Buffer.from(JSON.stringify(bodyObj), "utf8");
return new Promise((resolve, reject) => {
let settled = false;
const finish = (fn, val) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn(val);
};
const req = http.request(
{
protocol: url.protocol,
host: url.hostname,
port: url.port || (url.protocol === "https:" ? 443 : 80),
method: "POST",
path: `${url.pathname}${url.search}`,
headers: {
...headers,
"Content-Length": payload.length,
},
},
(res) => {
let data = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
data += chunk;
});
res.on("end", () => {
finish(resolve, { status: res.statusCode ?? 0, body: data });
});
res.on("error", (err) => finish(reject, err));
}
);
req.on("error", (err) => finish(reject, err));
const timer = setTimeout(() => {
finish(
reject,
new Error(
`OpenCode prompt exceeded ${Math.round(timeoutMs / 1000)}s wall-clock timeout ` +
`(set OPENCODE_COMPANION_PROMPT_TIMEOUT_MS to raise)`
)
);
req.destroy();
}, timeoutMs);
req.write(payload);
req.end();
});
}
function serverStatePath(workspacePath) {
const key = typeof workspacePath === "string" && workspacePath.length > 0 ? workspacePath : process.cwd();
const hash = crypto.createHash("sha256").update(key).digest("hex").slice(0, 16);
const pluginDataDir = process.env.CLAUDE_PLUGIN_DATA || path.join(os.tmpdir(), "opencode-companion");
return path.join(pluginDataDir, "state", hash, "server.json");
}
function loadServerState(workspacePath) {
try {
const p = serverStatePath(workspacePath);
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : {};
} catch {
return {};
}
}
function saveServerState(workspacePath, data) {
try {
const p = serverStatePath(workspacePath);
fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
fs.writeFileSync(p, JSON.stringify(data, null, 2), "utf8");
} catch {
// best-effort
}
}
function isProcessAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Resolve the bundled opencode config directory shipped inside the plugin.
* This is what we pass as OPENCODE_CONFIG_DIR so the custom `review` agent
* (at `opencode-config/agent/review.md`) gets discovered.
* @returns {string|null}
*/
export function getBundledConfigDir() {
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
if (!pluginRoot) return null;
const configDir = path.join(pluginRoot, "opencode-config");
try {
if (fs.existsSync(configDir)) return configDir;
} catch {}
return null;
}
/**
* Check if an OpenCode server is already running on the given port.
* @param {string} host
* @param {number} port
* @returns {Promise<boolean>}
*/
export async function isServerRunning(host = DEFAULT_HOST, port = DEFAULT_PORT) {
try {
const res = await fetch(`http://${host}:${port}/global/health`, {
signal: AbortSignal.timeout(3000),
});
return res.ok;
} catch {
return false;
}
}
/**
* Start the OpenCode server if not already running.
* @param {object} opts
* @param {string} [opts.host]
* @param {number} [opts.port]
* @param {string} [opts.cwd]
* @returns {Promise<{ url: string, pid?: number, alreadyRunning: boolean }>}
*/
export async function ensureServer(opts = {}) {
const host = opts.host ?? DEFAULT_HOST;
const port = opts.port ?? DEFAULT_PORT;
const url = `http://${host}:${port}`;
if (await isServerRunning(host, port)) {
// A server is already on the port. Only clear tracked state if the
// tracked pid is dead (stale from a prior run) — otherwise it may be
// a plugin-owned server from a previous session that reapServerIfOurs
// should still be able to identify on SessionEnd.
const state = loadServerState(opts.cwd);
if (state.lastServerPid && !isProcessAlive(state.lastServerPid)) {
delete state.lastServerPid;
delete state.lastServerStartedAt;
saveServerState(opts.cwd, state);
}
return { url, alreadyRunning: true };
}
// Start the server.
// `stdio: "ignore"` is critical: piping stdout/stderr without draining
// them creates ref'd file descriptors on the parent that prevent any
// long-lived parent (notably `node:test`) from exiting cleanly once
// opencode writes enough output to fill the pipe buffer.
//
// `OPENCODE_CONFIG_DIR` points opencode at our bundled config dir so
// the custom `review` agent is discovered. We only set it when we
// actually spawn the server — if the user already has a server
// running, they get whatever config that server was started with, and
// the caller is expected to fall back to `build` when `review` is
// unavailable.
const env = { ...process.env };
const bundledConfigDir = getBundledConfigDir();
if (bundledConfigDir) {
env.OPENCODE_CONFIG_DIR = bundledConfigDir;
}
const proc = spawn("opencode", ["serve", "--port", String(port)], {
stdio: "ignore",
detached: true,
cwd: opts.cwd,
env,
shell: platformShellOption(),
windowsHide: true,
});
let spawnError = null;
let earlyExit = null;
proc.once("error", (err) => {
spawnError = err;
});
proc.once("exit", (code, signal) => {
earlyExit = { code, signal };
});
proc.unref();
// Wait for the server to become ready. Poll every 250ms rather than
// spinning hot — a tight loop would hammer fetch() and burn CPU for up
// to SERVER_START_TIMEOUT.
const deadline = Date.now() + SERVER_START_TIMEOUT;
while (Date.now() < deadline) {
if (spawnError) {
throw new Error(`Failed to start OpenCode server: ${spawnError.message}`);
}
if (earlyExit) {
const detail = earlyExit.signal
? `signal ${earlyExit.signal}`
: `exit code ${earlyExit.code ?? "unknown"}`;
throw new Error(`OpenCode server process exited before becoming ready (${detail}).`);
}
if (await isServerRunning(host, port)) {
const state = loadServerState(opts.cwd);
state.lastServerPid = proc.pid;
state.lastServerStartedAt = Date.now();
saveServerState(opts.cwd, state);
return { url, alreadyRunning: false, pid: proc.pid };
}
await new Promise((r) => setTimeout(r, 250));
}
throw new Error(`OpenCode server failed to start within ${SERVER_START_TIMEOUT / 1000}s`);
}
/**
* Create an API client bound to a running OpenCode server.
* @param {string} baseUrl
* @param {object} [opts]
* @param {string} [opts.directory] - workspace directory for x-opencode-directory header
* @returns {OpenCodeClient}
*/
export function createClient(baseUrl, opts = {}) {
const headers = {
"Content-Type": "application/json",
};
if (opts.directory) {
headers["x-opencode-directory"] = opts.directory;
}
if (process.env.OPENCODE_SERVER_PASSWORD) {
const user = process.env.OPENCODE_SERVER_USERNAME ?? "opencode";
const cred = Buffer.from(`${user}:${process.env.OPENCODE_SERVER_PASSWORD}`).toString("base64");
headers["Authorization"] = `Basic ${cred}`;
}
async function request(method, path, body) {
const res = await fetch(`${baseUrl}${path}`, {
method,
headers,
body: body != null ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(300_000),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`OpenCode API ${method} ${path} returned ${res.status}: ${text}`);
}
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) {
return res.json();
}
return res.text();
}
return {
baseUrl,
// Health
health: () => request("GET", "/global/health"),
// Sessions
listSessions: () => request("GET", "/session"),
createSession: (opts = {}) => request("POST", "/session", opts),
getSession: (id) => request("GET", `/session/${id}`),
deleteSession: (id) => request("DELETE", `/session/${id}`),
abortSession: (id) => request("POST", `/session/${id}/abort`),
getSessionStatus: () => request("GET", "/session/status"),
getSessionDiff: (id) => request("GET", `/session/${id}/diff`),
// Messages
getMessages: (sessionId, opts = {}) => {
const params = new URLSearchParams();
if (opts.limit) params.set("limit", String(opts.limit));
if (opts.before) params.set("before", opts.before);
const qs = params.toString();
return request("GET", `/session/${sessionId}/message${qs ? "?" + qs : ""}`);
},
/**
* Send a prompt (synchronous / streaming).
* Returns the full response text from SSE stream.
*
* Uses `node:http` directly (via `httpPostJson`) instead of `fetch()`
* because Node's bundled undici has a hidden 300_000 ms default
* `bodyTimeout` that surfaces as `TypeError: terminated` on long
* reviews — see the helper for details.
*/
sendPrompt: async (sessionId, promptText, opts = {}) => {
const body = {
parts: [{ type: "text", text: promptText }],
};
if (opts.agent) body.agent = opts.agent;
if (opts.model) body.model = opts.model;
if (opts.system) body.system = opts.system;
// `tools` is a per-call override map: `{ write: false, edit: false, ... }`.
// Used by the review fallback path to enforce read-only behavior when
// the custom `review` agent isn't available on a pre-running server.
if (opts.tools) body.tools = opts.tools;
const { status, body: responseText } = await httpPostJson(
`${baseUrl}/session/${sessionId}/message`,
headers,
body
);
if (status < 200 || status >= 300) {
throw new Error(`OpenCode prompt failed ${status}: ${responseText}`);
}
try {
return JSON.parse(responseText);
} catch (err) {
throw new Error(
`OpenCode prompt returned non-JSON response (${status}): ${err.message}`
);
}
},
/**
* Send a prompt asynchronously (returns immediately).
*/
sendPromptAsync: (sessionId, promptText, opts = {}) => {
const body = {
parts: [{ type: "text", text: promptText }],
};
if (opts.agent) body.agent = opts.agent;
if (opts.model) body.model = opts.model;
return request("POST", `/session/${sessionId}/prompt_async`, body);
},
// Agents
listAgents: () => request("GET", "/agent"),
// Providers
listProviders: () => request("GET", "/provider"),
getProviderAuth: () => request("GET", "/provider/auth"),
// Config
getConfig: () => request("GET", "/config"),
// Events (SSE) - returns a ReadableStream
subscribeEvents: async () => {
const res = await fetch(`${baseUrl}/event`, {
headers: { ...headers, Accept: "text/event-stream" },
});
return res.body;
},
};
}
/**
* Connect to OpenCode: ensure server is running, create client.
* @param {object} opts
* @param {string} [opts.cwd]
* @param {number} [opts.port]
* @returns {Promise<ReturnType<typeof createClient> & { serverInfo: object }>}
*/
export async function connect(opts = {}) {
const { url, alreadyRunning } = await ensureServer(opts);
const client = createClient(url, { directory: opts.cwd });
return { ...client, serverInfo: { url, alreadyRunning } };
}
/**
* Reap the plugin-spawned OpenCode server on SessionEnd.
*
* Only kills what we started (tracked via server.json `lastServerPid`),
* and only when the plugin has no in-flight tracked-jobs. The previous
* implementation gated solely on `now - startedAt > 5 min`, which would
* SIGTERM the OpenCode server out from under an actively-streaming
* `sendPrompt` call if a SessionEnd happened to fire during a long
* rescue or review. Callers would see the socket drop as `terminated`
* with no timeout error in our own code path.
*
* The guard is now two conditions:
* 1. server uptime is above SERVER_REAP_IDLE_TIMEOUT, and
* 2. no tracked job is in `running` state with a live companion PID.
*
* @param {string} workspacePath
* @param {{ port?: number, host?: string }} [opts]
* @returns {Promise<boolean>} true if a server was reaped
*/
export async function reapServerIfOurs(workspacePath, opts = {}) {
const host = opts.host ?? DEFAULT_HOST;
const port = opts.port ?? DEFAULT_PORT;
const state = loadServerState(workspacePath);
if (!state.lastServerPid || !Number.isFinite(state.lastServerPid)) return false;
const pid = state.lastServerPid;
const startedAt = state.lastServerStartedAt;
if (!isProcessAlive(pid)) {
saveServerState(workspacePath, { lastServerPid: null, lastServerStartedAt: null });
return false;
}
const uptimeMs = startedAt ? Date.now() - startedAt : Infinity;
if (uptimeMs < SERVER_REAP_IDLE_TIMEOUT) return false;
// Do not kill the server if any tracked job is still running against
// it. Orphaned `running` jobs (process crashed without marking failed)
// are filtered out via the shared token-aware liveness check, so a
// stale state entry cannot permanently block reaping.
if (hasInFlightTrackedJob(workspacePath)) return false;
try {
process.kill(pid, "SIGTERM");
} catch {
// best-effort — PID may have just exited
}
await new Promise((r) => setTimeout(r, 1000));
const stillRunning = await isServerRunning(host, port);
if (!stillRunning) {
saveServerState(workspacePath, { lastServerPid: null, lastServerStartedAt: null });
return true;
}
return false;
}
/**
* Returns true if any tracked job is in `running` state with a
* companion PID that is still alive. Stale `running` entries from a
* crashed companion are treated as not-in-flight so the reaper can make
* progress.
* @param {string} workspacePath
* @returns {boolean}
*/
function hasInFlightTrackedJob(workspacePath) {
let jobsState;
try {
jobsState = loadState(workspacePath);
} catch {
// If the job state file is unreadable, fail safe: assume in-flight
// work may exist and keep the server alive. The next SessionEnd
// will retry.
return true;
}
const jobs = Array.isArray(jobsState?.jobs) ? jobsState.jobs : [];
for (const job of jobs) {
if (job?.status !== "running") continue;
if (!job.pid) continue;
if (isProcessAliveWithToken(job.pid, job.pidStartToken)) return true;
}
return false;
}