@sanqianx/claude-ai-workbench 0.13.2 → 0.13.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +156 -156
  3. package/apps/agent-terminal/app.css +636 -613
  4. package/apps/agent-terminal/app.js +125 -65
  5. package/apps/agent-terminal/conversation-composer.js +44 -0
  6. package/apps/agent-terminal/index.html +171 -170
  7. package/apps/agent-terminal/panel-host-proof.html +152 -152
  8. package/apps/agent-terminal/panel-proof-content.html +41 -41
  9. package/apps/agent-terminal/right-workspace-dock.css +187 -159
  10. package/apps/agent-terminal/right-workspace-dock.js +623 -519
  11. package/apps/ai-profile-settings-host/index.html +25 -25
  12. package/apps/ai-profile-settings-host/page.css +1 -1
  13. package/apps/ai-profile-settings-host/page.js +113 -113
  14. package/apps/minimal-test-host/README.md +15 -15
  15. package/apps/minimal-test-host/host.css +2 -2
  16. package/apps/minimal-test-host/host.js +35 -35
  17. package/apps/minimal-test-host/index.html +58 -58
  18. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
  19. package/docs/sdk/FILE_LAYOUT.md +31 -31
  20. package/docs/sdk/INTEGRATION.md +126 -126
  21. package/docs/sdk/README.md +21 -21
  22. package/docs/sdk/SESSION_EVENTS.md +13 -13
  23. package/docs/sdk/openapi.yaml +8 -1
  24. package/package.json +1 -1
  25. package/packages/client/README.md +29 -29
  26. package/packages/client/index.d.ts +122 -122
  27. package/packages/client/index.js +260 -260
  28. package/packages/client/package.json +2 -2
  29. package/packages/contracts/README.md +9 -9
  30. package/packages/contracts/index.d.ts +519 -517
  31. package/packages/contracts/index.js +312 -312
  32. package/packages/contracts/package.json +1 -1
  33. package/packages/core/README.md +8 -8
  34. package/packages/core/index.d.ts +76 -76
  35. package/packages/core/index.js +222 -222
  36. package/packages/core/package.json +2 -2
  37. package/packages/server/README.md +17 -17
  38. package/packages/server/bin/agent-terminal-server.js +42 -42
  39. package/packages/server/bin/claude-workbench.js +225 -225
  40. package/packages/server/index.d.ts +171 -171
  41. package/packages/server/index.js +230 -230
  42. package/packages/server/lib/agent-discovery.js +133 -133
  43. package/packages/server/lib/agent-registry.js +143 -143
  44. package/packages/server/lib/agent-spawn-env.js +17 -17
  45. package/packages/server/lib/attachment-store.js +78 -78
  46. package/packages/server/lib/bridges/app-server-bridge.js +373 -373
  47. package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
  48. package/packages/server/lib/capture-settings-store.js +35 -35
  49. package/packages/server/lib/claude-history-recovery.js +87 -87
  50. package/packages/server/lib/cli-session-recovery.js +28 -28
  51. package/packages/server/lib/context-resolver.js +55 -55
  52. package/packages/server/lib/conversation-capture.js +51 -51
  53. package/packages/server/lib/delegation-phase-store.js +357 -357
  54. package/packages/server/lib/delegation-store.js +474 -468
  55. package/packages/server/lib/diagnostics.js +32 -32
  56. package/packages/server/lib/drivers/cli-json-runner.js +974 -974
  57. package/packages/server/lib/drivers/codex-driver.js +1442 -1442
  58. package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
  59. package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
  60. package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
  61. package/packages/server/lib/event-stream.js +138 -138
  62. package/packages/server/lib/folder-picker.js +623 -623
  63. package/packages/server/lib/http-handler.js +339 -339
  64. package/packages/server/lib/json-file.js +26 -26
  65. package/packages/server/lib/knowledge-context.js +33 -33
  66. package/packages/server/lib/model-detection.js +295 -295
  67. package/packages/server/lib/parent-context.js +44 -0
  68. package/packages/server/lib/profile-store.js +283 -283
  69. package/packages/server/lib/project-store.js +102 -102
  70. package/packages/server/lib/runtime-loader.js +120 -120
  71. package/packages/server/lib/session-archive-store.js +53 -53
  72. package/packages/server/lib/session-history.js +612 -612
  73. package/packages/server/lib/system-doctor.js +125 -125
  74. package/packages/server/lib/ui-preferences-store.js +48 -48
  75. package/packages/server/lib/workbench-service.js +1696 -1675
  76. package/packages/server/package.json +2 -2
  77. package/packages/ui/README.md +30 -30
  78. package/packages/ui/ai-profile-settings.d.ts +45 -45
  79. package/packages/ui/ai-profile-settings.js +283 -283
  80. package/packages/ui/claude-terminal.d.ts +31 -31
  81. package/packages/ui/claude-terminal.js +343 -343
  82. package/packages/ui/index.d.ts +18 -18
  83. package/packages/ui/index.js +51 -51
  84. package/packages/ui/package.json +2 -2
  85. package/packages/ui/theme.css +13 -13
  86. package/src/assets/claude-prompts.json +21 -21
  87. package/src/backend/http/ai-profile-routes.js +61 -61
  88. package/src/backend/http/claude-routes.js +170 -170
  89. package/src/backend/http/node-helpers.js +45 -45
  90. package/src/backend/index.js +21 -21
  91. package/src/backend/lib/ai-adapter.js +144 -144
  92. package/src/backend/lib/ai-profile-store.js +259 -259
  93. package/src/backend/lib/ai-vendor-presets.js +83 -83
  94. package/src/backend/lib/ai-workspace.js +140 -140
  95. package/src/backend/lib/automation-config.js +253 -253
  96. package/src/backend/lib/claude-cli-runner.js +2392 -2392
  97. package/src/backend/lib/data-dir.js +169 -169
  98. package/src/backend/lib/llm-client.js +176 -176
  99. package/src/backend/lib/model-context-windows.js +82 -82
  100. package/src/backend/lib/native-task-control.js +217 -217
  101. package/src/backend/lib/native-task-history-index.js +475 -475
  102. package/src/backend/lib/native-task-history-reader.js +526 -526
  103. package/src/backend/lib/native-task-messages.js +457 -457
  104. package/src/backend/lib/native-task-registry.js +325 -325
  105. package/src/backend/lib/native-task-runtime.js +242 -242
  106. package/src/backend/lib/native-task-trace.js +440 -440
  107. package/src/backend/lib/native-task-transcript.js +393 -393
  108. package/src/backend/lib/prompt-registry.js +166 -166
  109. package/src/backend/lib/runtime-config.js +121 -121
  110. package/src/backend/lib/sdk-session-stream.js +143 -143
  111. package/src/backend/lib/sdk-turn-stream.js +16 -16
  112. package/src/backend/lib/store-scope.js +131 -131
  113. package/src/backend/lib/task-identity.js +104 -104
  114. package/src/backend/lib/task-state-projection.js +58 -58
  115. package/src/index.js +7 -7
  116. package/src/sdk-wave-a.ts +1 -1
  117. package/src/sdk-wave-b.ts +1 -1
  118. package/src/sdk-wave-c.ts +1 -1
  119. package/src/sdk-wave-d.ts +1 -1
  120. package/src/sdk-wave-e.ts +1 -1
@@ -1,506 +1,506 @@
1
- 'use strict';
2
-
3
- const net = require('net');
4
- const { spawn: realSpawn } = require('child_process');
5
- const { resolveCliInvocation } = require('../drivers/cli-json-runner');
6
- const { knowledgeRootEnv } = require('../agent-spawn-env');
7
-
8
- // Persistent `opencode serve` bridge (HTTP + SSE), the HTTP sibling of the
9
- // stdio app-server bridge. Protocol verified live against opencode 1.18.5:
10
- //
11
- // - `opencode serve --port N --hostname 127.0.0.1` binds a real port
12
- // (--port 0 means "the default 4096", NOT a random port, so the bridge
13
- // allocates a free port itself and passes it explicitly).
14
- // - Readiness: poll `GET /global/health` -> { healthy, version }.
15
- // - Auth: none by default (localhost, no OPENCODE_SERVER_PASSWORD).
16
- // - `OPENCODE_CONFIG_CONTENT` env var is honored (custom provider injection
17
- // works exactly like the CLI transport).
18
- // - SSE: `GET /global/event` carries every project's events wrapped as
19
- // { directory, project, workspace, payload: { id, type, properties } }.
20
- // (`GET /event` is scoped to the serve process cwd and misses sessions in
21
- // other directories.) First frame is server.connected; server.heartbeat
22
- // frames arrive every ~10-15s. There is NO Last-Event-ID replay: after a
23
- // reconnect the consumer must reconcile missed state through REST
24
- // (onSseGap fires for exactly that).
25
- // - `message.part.delta` events are true incremental text deltas; tool
26
- // parts stream pending -> running -> completed state snapshots.
27
- //
28
- // Responsibilities:
29
- // - child lifecycle: lazy single-flight start with health handshake, crash
30
- // auto-restart with backoff (bounded), idle shutdown (heartbeats do NOT
31
- // count as activity), terminal close();
32
- // - HTTP client: JSON requests with per-request timeouts against the live
33
- // port (fetch is injectable for tests);
34
- // - SSE subscription: frame parsing, payload unwrapping, heartbeat
35
- // staleness watchdog and self-healing reconnect with onSseGap notification.
36
- //
37
- // config:
38
- // command, args, env, cwd child invocation (args appended after 'serve')
39
- // spawn injectable spawn(command, args, options)
40
- // fetch injectable fetch(input, init) for HTTP/SSE
41
- // allocatePort injectable async () => port number
42
- // hostname serve bind address (default 127.0.0.1)
43
- // onEvent (payload) => void — unwrapped global events
44
- // onSseGap () => void — the SSE stream dropped; reconcile
45
- // onUnexpectedExit (error) => void — child died outside idle/close
46
- // onReady (generation) => void
47
- // healthTimeoutMs startup handshake budget (default 15000)
48
- // requestTimeoutMs per-request default (default 30000)
49
- // idleTimeoutMs quiet period before shutdown (default 600000)
50
- // maxRestarts / restartBackoffMs crash restart budget (3 / [250,500,1000])
51
- // sseReconnectMs SSE reconnect backoff steps (default [250,500,1000,2000])
52
- // heartbeatTimeoutMs no-frame watchdog (default 75000; 0 disables)
53
- // log (message, meta) => void
54
- //
55
- // States: idle -> starting -> ready -> (restarting -> ready)* -> idle|crashed|closed
56
-
57
- const DEFAULTS = {
58
- healthTimeoutMs: 15000,
59
- requestTimeoutMs: 30000,
60
- idleTimeoutMs: 10 * 60 * 1000,
61
- maxRestarts: 3,
62
- restartBackoffMs: [250, 500, 1000],
63
- sseReconnectMs: [250, 500, 1000, 2000],
64
- heartbeatTimeoutMs: 75000,
65
- hostname: '127.0.0.1',
66
- };
67
-
68
- function createOpenCodeServerBridge(config = {}) {
69
- // Config keys that are present-but-undefined (e.g. an options pass-through
70
- // from the driver) must not shadow the defaults.
71
- const present = {};
72
- for (const [key, value] of Object.entries(config)) {
73
- if (value !== undefined) present[key] = value;
74
- }
75
- const opts = { ...DEFAULTS, ...present };
76
- const spawnFn = opts.spawn || realSpawn;
77
- const fetchFn = opts.fetch || (typeof fetch === 'function' ? fetch : null);
78
- const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : () => {};
79
- const onSseGap = typeof opts.onSseGap === 'function' ? opts.onSseGap : () => {};
80
- const onUnexpectedExit = typeof opts.onUnexpectedExit === 'function' ? opts.onUnexpectedExit : () => {};
81
- const onReady = typeof opts.onReady === 'function' ? opts.onReady : () => {};
82
- const log = typeof opts.log === 'function' ? opts.log : () => {};
83
-
84
- let state = 'idle';
85
- let child = null;
86
- let port = null;
87
- let generation = 0; // bumped on every successful (re)launch
88
- let bootPromise = null;
89
- let bootSettle = null; // { resolve, reject } of the in-flight launch
90
- let healthAbort = null;
91
- let restartTimer = null;
92
- let idleTimer = null;
93
- let restartsUsed = 0;
94
- let intentionalStop = false;
95
- let closed = false;
96
- let sseAbort = null; // AbortController of the live SSE fetch
97
- let sseStaleTimer = null;
98
- let sseReconnectTimer = null;
99
- let sseAttempt = 0;
100
- let lastFrameAt = 0;
101
- let inFlightRequests = 0;
102
-
103
- if (!fetchFn) throw bridgeError('no fetch implementation available for the opencode server bridge', 503, 'BRIDGE_NO_FETCH');
104
-
105
- const base = () => `http://${opts.hostname}:${port}`;
106
-
107
- function touchActivity() {
108
- if (opts.idleTimeoutMs > 0 && state === 'ready') {
109
- clearTimeout(idleTimer);
110
- idleTimer = setTimeout(idleShutdown, opts.idleTimeoutMs);
111
- idleTimer.unref && idleTimer.unref();
112
- }
113
- }
114
-
115
- function idleShutdown() {
116
- if (closed || inFlightRequests > 0) { touchActivity(); return; }
117
- log('opencode serve bridge idle shutdown', { idleTimeoutMs: opts.idleTimeoutMs });
118
- intentionalStop = true;
119
- stopSse();
120
- killChild();
121
- state = 'idle';
122
- bootPromise = null;
123
- port = null;
124
- restartsUsed = 0;
125
- }
126
-
127
- function killChild() {
128
- if (!child) return;
129
- const target = child;
130
- child = null;
131
- try {
132
- // npm shims spawn a node child on Windows; killing the shell alone
133
- // orphans it. Fakes injected by tests have no pid and skip taskkill.
134
- if (process.platform === 'win32' && Number.isInteger(target.pid)) {
135
- realSpawn('taskkill', ['/pid', String(target.pid), '/T', '/F'], { windowsHide: true });
136
- }
137
- } catch { /* already gone */ }
138
- try { target.kill(); } catch { /* already gone */ }
139
- }
140
-
141
- function stopSse() {
142
- clearTimeout(sseReconnectTimer);
143
- clearTimeout(sseStaleTimer);
144
- sseReconnectTimer = null;
145
- if (sseAbort) {
146
- const abort = sseAbort;
147
- sseAbort = null;
148
- try { abort.abort(); } catch { /* already aborted */ }
149
- }
150
- }
151
-
152
- // ---- HTTP client ---------------------------------------------------------
153
-
154
- async function request(method, path, options = {}) {
155
- if (closed) throw bridgeError('opencode serve bridge is closed', 503, 'BRIDGE_CLOSED');
156
- if (state === 'crashed') throw bridgeError('opencode serve bridge exhausted its restart budget', 503, 'BRIDGE_CRASHED');
157
- if (!port) await start();
158
- const timeoutMs = options.timeoutMs !== undefined ? options.timeoutMs : opts.requestTimeoutMs;
159
- const controller = new AbortController();
160
- const timer = setTimeout(() => controller.abort(), timeoutMs);
161
- timer.unref && timer.unref();
162
- inFlightRequests += 1;
163
- try {
164
- const response = await fetchFn(`${base()}${path}`, {
165
- method,
166
- signal: controller.signal,
167
- headers: options.body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
168
- body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
169
- });
170
- if (!response.ok) {
171
- let detail = '';
172
- try {
173
- const parsed = await response.json();
174
- detail = parsed && (parsed.message || parsed.error && parsed.error.message) || '';
175
- } catch { /* non-JSON error body */ }
176
- throw bridgeError(`opencode serve ${method} ${path} failed: ${response.status} ${String(detail).slice(0, 300)}`,
177
- 502, 'BRIDGE_HTTP_ERROR', { httpStatus: response.status });
178
- }
179
- if (response.status === 204) return null;
180
- const text = await response.text();
181
- touchActivity();
182
- return text ? JSON.parse(text) : null;
183
- } catch (error) {
184
- if (error && (error.code === 'BRIDGE_HTTP_ERROR' || error.code === 'BRIDGE_CLOSED')) throw error;
185
- if (controller.signal.aborted) {
186
- throw bridgeError(`opencode serve ${method} ${path} timed out after ${timeoutMs}ms`, 504, 'BRIDGE_TIMEOUT');
187
- }
188
- throw bridgeError(`opencode serve ${method} ${path} failed: ${error && error.message || error}`,
189
- 503, 'BRIDGE_REQUEST_FAILED', { cause: error });
190
- } finally {
191
- clearTimeout(timer);
192
- inFlightRequests -= 1;
193
- touchActivity();
194
- }
195
- }
196
-
197
- // ---- SSE subscription ----------------------------------------------------
198
-
199
- function handleSseFrame(raw) {
200
- const dataLines = [];
201
- for (const line of raw.split('\n')) {
202
- if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''));
203
- }
204
- if (!dataLines.length) return;
205
- let parsed;
206
- try { parsed = JSON.parse(dataLines.join('\n')); } catch {
207
- log(`opencode serve bridge discarded unparseable SSE frame: ${raw.slice(0, 160)}`);
208
- return;
209
- }
210
- const payload = parsed && parsed.payload && typeof parsed.payload === 'object' ? parsed.payload : null;
211
- if (!payload || !payload.type) return; // heartbeat frames still refresh liveness below
212
- // Heartbeats prove the stream is alive but must not keep the child from
213
- // idling away — every other event (and each request) is real activity.
214
- if (payload.type !== 'server.heartbeat') touchActivity();
215
- try { onEvent(payload); } catch (error) {
216
- log(`opencode serve bridge onEvent handler failed for ${payload.type}: ${error.message}`);
217
- }
218
- }
219
-
220
- async function sseLoop() {
221
- const myGeneration = generation;
222
- while (!closed && child && generation === myGeneration) {
223
- const controller = new AbortController();
224
- sseAbort = controller;
225
- try {
226
- const response = await fetchFn(`${base()}/global/event`, { signal: controller.signal });
227
- if (!response.ok || !response.body || typeof response.body.getReader !== 'function') {
228
- throw new Error(`event subscription failed: HTTP ${response.status}`);
229
- }
230
- sseAttempt = 0; // a healthy subscription resets the backoff
231
- lastFrameAt = Date.now();
232
- armStalenessWatchdog();
233
- const reader = response.body.getReader();
234
- const decoder = new TextDecoder();
235
- let buffer = '';
236
- while (true) {
237
- const { done, value } = await reader.read();
238
- if (done) break;
239
- lastFrameAt = Date.now();
240
- buffer += decoder.decode(value, { stream: true });
241
- let index;
242
- while ((index = buffer.indexOf('\n\n')) >= 0) {
243
- const frame = buffer.slice(0, index);
244
- buffer = buffer.slice(index + 2);
245
- if (frame.trim()) handleSseFrame(frame);
246
- }
247
- }
248
- } catch (error) {
249
- if (closed || !child || generation !== myGeneration) return;
250
- if (!controller.signal.aborted) log(`opencode serve event stream dropped: ${error && error.message || error}`);
251
- } finally {
252
- clearTimeout(sseStaleTimer);
253
- }
254
- if (closed || !child || generation !== myGeneration) return;
255
- // The stream ended (server closed it, watchdog aborted it, network cut
256
- // it): notify once per gap so the consumer reconciles missed events via
257
- // REST, then reconnect with capped backoff while the child lives.
258
- sseAbort = null;
259
- try { onSseGap(); } catch (error) { log(`onSseGap handler failed: ${error.message}`); }
260
- const backoff = opts.sseReconnectMs[Math.min(sseAttempt, opts.sseReconnectMs.length - 1)];
261
- sseAttempt += 1;
262
- await new Promise(resolve => {
263
- sseReconnectTimer = setTimeout(resolve, backoff);
264
- sseReconnectTimer.unref && sseReconnectTimer.unref();
265
- });
266
- sseReconnectTimer = null;
267
- if (closed || !child || generation !== myGeneration) return;
268
- }
269
- }
270
-
271
- function armStalenessWatchdog() {
272
- if (!opts.heartbeatTimeoutMs) return;
273
- clearTimeout(sseStaleTimer);
274
- sseStaleTimer = setTimeout(() => {
275
- // Heartbeats arrive every ~10-15s; silence means a dead stream that the
276
- // OS has not reported yet — abort the fetch to force the reconnect path.
277
- log(`opencode serve event stream stale for ${opts.heartbeatTimeoutMs}ms; forcing reconnect`);
278
- if (sseAbort) {
279
- try { sseAbort.abort(); } catch { /* already aborted */ }
280
- }
281
- }, opts.heartbeatTimeoutMs);
282
- sseStaleTimer.unref && sseStaleTimer.unref();
283
- }
284
-
285
- // ---- child lifecycle -----------------------------------------------------
286
-
287
- function allocatePort() {
288
- if (typeof opts.allocatePort === 'function') return Promise.resolve(opts.allocatePort());
289
- return new Promise((resolve, reject) => {
290
- const server = net.createServer();
291
- server.unref();
292
- server.on('error', reject);
293
- server.listen(0, opts.hostname, () => {
294
- const { port: freePort } = server.address();
295
- server.close(() => resolve(freePort));
296
- });
297
- });
298
- }
299
-
300
- async function waitForHealth(deadline) {
301
- const controller = new AbortController();
302
- healthAbort = controller;
303
- // A loop deadline alone cannot bound a connection stalled at headers or
304
- // JSON body. The same budget must cancel the in-flight health request.
305
- const timer = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now()));
306
- try {
307
- while (Date.now() < deadline && !controller.signal.aborted) {
308
- if (!child) throw bridgeError('opencode serve child exited during startup', 503, 'BRIDGE_SPAWN_FAILED');
309
- try {
310
- const response = await fetchFn(`${base()}/global/health`, { signal: controller.signal });
311
- if (response.ok) {
312
- const parsed = await response.json().catch(() => null);
313
- if (!parsed || parsed.healthy !== true) throw new Error('health endpoint reported unhealthy');
314
- return parsed;
315
- }
316
- await response.body?.cancel?.();
317
- } catch (error) {
318
- if (closed) throw bridgeError('opencode serve bridge is closed', 503, 'BRIDGE_CLOSED');
319
- if (!child) throw bridgeError('opencode serve child exited during startup', 503, 'BRIDGE_SPAWN_FAILED', { cause: error });
320
- }
321
- if (!controller.signal.aborted) await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - Date.now()))));
322
- }
323
- throw bridgeError(`opencode serve did not become healthy within ${opts.healthTimeoutMs}ms`, 504, 'BRIDGE_HEALTH_TIMEOUT');
324
- } finally {
325
- clearTimeout(timer);
326
- controller.abort();
327
- if (healthAbort === controller) healthAbort = null;
328
- }
329
- }
330
-
331
- function wire(childInstance) {
332
- child = childInstance;
333
- if (childInstance.stdout && childInstance.stdout.setEncoding) {
334
- childInstance.stdout.setEncoding('utf8');
335
- childInstance.stdout.on('data', chunk => {
336
- const text = String(chunk).trim();
337
- if (text) log(`opencode serve stdout: ${text.slice(0, 200)}`);
338
- });
339
- }
340
- if (childInstance.stderr && childInstance.stderr.setEncoding) {
341
- childInstance.stderr.setEncoding('utf8');
342
- childInstance.stderr.on('data', chunk => {
343
- const text = String(chunk).trim();
344
- if (text) log(`opencode serve stderr: ${text.slice(0, 200)}`);
345
- });
346
- }
347
- childInstance.on('error', error => {
348
- log(`opencode serve child error: ${error.message}`);
349
- handleExit(error);
350
- });
351
- childInstance.on('close', code => {
352
- log(`opencode serve child exited code=${code}`);
353
- handleExit(null);
354
- });
355
- }
356
-
357
- function handleExit(spawnError) {
358
- if (!child) return;
359
- const wasReady = state === 'ready';
360
- child = null;
361
- port = null;
362
- bootPromise = null;
363
- clearTimeout(idleTimer);
364
- stopSse();
365
- if (closed || intentionalStop) return;
366
- const error = spawnError
367
- ? bridgeError(`opencode serve child failed: ${spawnError.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: spawnError })
368
- : bridgeError('opencode serve child exited unexpectedly', 503, 'BRIDGE_CRASHED');
369
- try { onUnexpectedExit(error); } catch (hookError) { log(`onUnexpectedExit hook failed: ${hookError.message}`); }
370
- if (!wasReady) {
371
- state = 'idle';
372
- if (bootSettle) {
373
- const settle = bootSettle;
374
- bootSettle = null;
375
- settle.reject(spawnError
376
- ? bridgeError(`opencode serve child failed to start: ${spawnError.message}`, 503, 'BRIDGE_SPAWN_FAILED')
377
- : bridgeError('opencode serve child exited before becoming healthy', 503, 'BRIDGE_SPAWN_FAILED'));
378
- }
379
- return;
380
- }
381
- if (restartsUsed < opts.maxRestarts) {
382
- const backoff = opts.restartBackoffMs[Math.min(restartsUsed, opts.restartBackoffMs.length - 1)];
383
- restartsUsed += 1;
384
- state = 'restarting';
385
- log(`opencode serve crashed; restarting (${restartsUsed}/${opts.maxRestarts}) in ${backoff}ms`);
386
- bootPromise = new Promise((resolve, reject) => {
387
- bootSettle = { resolve, reject };
388
- restartTimer = setTimeout(() => {
389
- launchChild().then(resolve, reject);
390
- }, backoff);
391
- restartTimer.unref && restartTimer.unref();
392
- });
393
- bootPromise.catch(() => {}); // held internally until the next start()
394
- } else {
395
- state = 'crashed';
396
- log('opencode serve bridge exhausted its restart budget');
397
- }
398
- }
399
-
400
- function launchChild() {
401
- state = 'starting';
402
- return allocatePort().then(allocatedPort => new Promise((resolve, reject) => {
403
- bootSettle = { resolve, reject };
404
- port = allocatedPort;
405
- const args = ['serve', '--port', String(allocatedPort), '--hostname', opts.hostname, ...(config.args || [])];
406
- let invocation;
407
- try {
408
- // Real children must skip npm .cmd shims on Windows (plain spawn of a
409
- // shim name is ENOENT); injected test doubles pass through as-is.
410
- invocation = config.spawn || !config.command
411
- ? { command: config.command || 'opencode', args }
412
- : resolveCliInvocation(config.command, args);
413
- } catch (error) {
414
- state = 'idle';
415
- port = null;
416
- bootSettle = null;
417
- reject(bridgeError(`failed to resolve the opencode executable: ${error.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: error }));
418
- return;
419
- }
420
- let instance;
421
- try {
422
- instance = spawnFn(invocation.command, invocation.args, {
423
- cwd: config.cwd,
424
- windowsHide: true,
425
- env: { ...process.env, ...(config.env || {}), ...knowledgeRootEnv() },
426
- });
427
- } catch (error) {
428
- state = 'idle';
429
- port = null;
430
- bootSettle = null;
431
- reject(bridgeError(`failed to start opencode serve: ${error.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: error }));
432
- return;
433
- }
434
- wire(instance);
435
- const deadline = Date.now() + opts.healthTimeoutMs;
436
- waitForHealth(deadline).then(health => {
437
- if (!child || closed) throw bridgeError('bridge closed during startup', 503, 'BRIDGE_CLOSED');
438
- bootSettle = null;
439
- generation += 1;
440
- state = 'ready';
441
- intentionalStop = false;
442
- touchActivity();
443
- sseAttempt = 0;
444
- sseLoop(); // fire and forget; the loop owns its own error path
445
- try { onReady(generation, { port, version: health && health.version }); } catch (error) {
446
- log(`onReady hook failed: ${error.message}`);
447
- }
448
- resolve({ port: allocatedPort, version: health && health.version });
449
- }, error => {
450
- if (bootSettle) {
451
- const settle = bootSettle;
452
- bootSettle = null;
453
- settle.reject(error);
454
- }
455
- // A failed handshake must not leave a doomed child running.
456
- intentionalStop = true;
457
- stopSse();
458
- killChild();
459
- state = closed ? 'closed' : 'idle';
460
- port = null;
461
- });
462
- }));
463
- }
464
-
465
- function start() {
466
- if (closed) return Promise.reject(bridgeError('opencode serve bridge is closed', 503, 'BRIDGE_CLOSED'));
467
- if (state === 'ready' && bootPromise) return bootPromise;
468
- if (state === 'crashed') return Promise.reject(bridgeError('opencode serve bridge exhausted its restart budget', 503, 'BRIDGE_CRASHED'));
469
- if (!bootPromise) bootPromise = launchChild();
470
- return bootPromise;
471
- }
472
-
473
- function close() {
474
- if (closed) return Promise.resolve();
475
- closed = true;
476
- state = 'closed';
477
- clearTimeout(restartTimer);
478
- clearTimeout(idleTimer);
479
- intentionalStop = true;
480
- healthAbort?.abort();
481
- stopSse();
482
- killChild();
483
- if (bootSettle) {
484
- const settle = bootSettle;
485
- bootSettle = null;
486
- settle.reject(bridgeError('opencode serve bridge is closed', 503, 'BRIDGE_CLOSED'));
487
- }
488
- return Promise.resolve();
489
- }
490
-
491
- return {
492
- get state() { return state; },
493
- get port() { return port; },
494
- get generation() { return generation; },
495
- get sseConnected() { return Boolean(sseAbort); },
496
- start,
497
- request,
498
- close,
499
- };
500
- }
501
-
502
- function bridgeError(message, status, code, extra = {}) {
503
- return Object.assign(new Error(message), { status, code, ...extra });
504
- }
505
-
506
- module.exports = { createOpenCodeServerBridge };
1
+ 'use strict';
2
+
3
+ const net = require('net');
4
+ const { spawn: realSpawn } = require('child_process');
5
+ const { resolveCliInvocation } = require('../drivers/cli-json-runner');
6
+ const { knowledgeRootEnv } = require('../agent-spawn-env');
7
+
8
+ // Persistent `opencode serve` bridge (HTTP + SSE), the HTTP sibling of the
9
+ // stdio app-server bridge. Protocol verified live against opencode 1.18.5:
10
+ //
11
+ // - `opencode serve --port N --hostname 127.0.0.1` binds a real port
12
+ // (--port 0 means "the default 4096", NOT a random port, so the bridge
13
+ // allocates a free port itself and passes it explicitly).
14
+ // - Readiness: poll `GET /global/health` -> { healthy, version }.
15
+ // - Auth: none by default (localhost, no OPENCODE_SERVER_PASSWORD).
16
+ // - `OPENCODE_CONFIG_CONTENT` env var is honored (custom provider injection
17
+ // works exactly like the CLI transport).
18
+ // - SSE: `GET /global/event` carries every project's events wrapped as
19
+ // { directory, project, workspace, payload: { id, type, properties } }.
20
+ // (`GET /event` is scoped to the serve process cwd and misses sessions in
21
+ // other directories.) First frame is server.connected; server.heartbeat
22
+ // frames arrive every ~10-15s. There is NO Last-Event-ID replay: after a
23
+ // reconnect the consumer must reconcile missed state through REST
24
+ // (onSseGap fires for exactly that).
25
+ // - `message.part.delta` events are true incremental text deltas; tool
26
+ // parts stream pending -> running -> completed state snapshots.
27
+ //
28
+ // Responsibilities:
29
+ // - child lifecycle: lazy single-flight start with health handshake, crash
30
+ // auto-restart with backoff (bounded), idle shutdown (heartbeats do NOT
31
+ // count as activity), terminal close();
32
+ // - HTTP client: JSON requests with per-request timeouts against the live
33
+ // port (fetch is injectable for tests);
34
+ // - SSE subscription: frame parsing, payload unwrapping, heartbeat
35
+ // staleness watchdog and self-healing reconnect with onSseGap notification.
36
+ //
37
+ // config:
38
+ // command, args, env, cwd child invocation (args appended after 'serve')
39
+ // spawn injectable spawn(command, args, options)
40
+ // fetch injectable fetch(input, init) for HTTP/SSE
41
+ // allocatePort injectable async () => port number
42
+ // hostname serve bind address (default 127.0.0.1)
43
+ // onEvent (payload) => void — unwrapped global events
44
+ // onSseGap () => void — the SSE stream dropped; reconcile
45
+ // onUnexpectedExit (error) => void — child died outside idle/close
46
+ // onReady (generation) => void
47
+ // healthTimeoutMs startup handshake budget (default 15000)
48
+ // requestTimeoutMs per-request default (default 30000)
49
+ // idleTimeoutMs quiet period before shutdown (default 600000)
50
+ // maxRestarts / restartBackoffMs crash restart budget (3 / [250,500,1000])
51
+ // sseReconnectMs SSE reconnect backoff steps (default [250,500,1000,2000])
52
+ // heartbeatTimeoutMs no-frame watchdog (default 75000; 0 disables)
53
+ // log (message, meta) => void
54
+ //
55
+ // States: idle -> starting -> ready -> (restarting -> ready)* -> idle|crashed|closed
56
+
57
+ const DEFAULTS = {
58
+ healthTimeoutMs: 15000,
59
+ requestTimeoutMs: 30000,
60
+ idleTimeoutMs: 10 * 60 * 1000,
61
+ maxRestarts: 3,
62
+ restartBackoffMs: [250, 500, 1000],
63
+ sseReconnectMs: [250, 500, 1000, 2000],
64
+ heartbeatTimeoutMs: 75000,
65
+ hostname: '127.0.0.1',
66
+ };
67
+
68
+ function createOpenCodeServerBridge(config = {}) {
69
+ // Config keys that are present-but-undefined (e.g. an options pass-through
70
+ // from the driver) must not shadow the defaults.
71
+ const present = {};
72
+ for (const [key, value] of Object.entries(config)) {
73
+ if (value !== undefined) present[key] = value;
74
+ }
75
+ const opts = { ...DEFAULTS, ...present };
76
+ const spawnFn = opts.spawn || realSpawn;
77
+ const fetchFn = opts.fetch || (typeof fetch === 'function' ? fetch : null);
78
+ const onEvent = typeof opts.onEvent === 'function' ? opts.onEvent : () => {};
79
+ const onSseGap = typeof opts.onSseGap === 'function' ? opts.onSseGap : () => {};
80
+ const onUnexpectedExit = typeof opts.onUnexpectedExit === 'function' ? opts.onUnexpectedExit : () => {};
81
+ const onReady = typeof opts.onReady === 'function' ? opts.onReady : () => {};
82
+ const log = typeof opts.log === 'function' ? opts.log : () => {};
83
+
84
+ let state = 'idle';
85
+ let child = null;
86
+ let port = null;
87
+ let generation = 0; // bumped on every successful (re)launch
88
+ let bootPromise = null;
89
+ let bootSettle = null; // { resolve, reject } of the in-flight launch
90
+ let healthAbort = null;
91
+ let restartTimer = null;
92
+ let idleTimer = null;
93
+ let restartsUsed = 0;
94
+ let intentionalStop = false;
95
+ let closed = false;
96
+ let sseAbort = null; // AbortController of the live SSE fetch
97
+ let sseStaleTimer = null;
98
+ let sseReconnectTimer = null;
99
+ let sseAttempt = 0;
100
+ let lastFrameAt = 0;
101
+ let inFlightRequests = 0;
102
+
103
+ if (!fetchFn) throw bridgeError('no fetch implementation available for the opencode server bridge', 503, 'BRIDGE_NO_FETCH');
104
+
105
+ const base = () => `http://${opts.hostname}:${port}`;
106
+
107
+ function touchActivity() {
108
+ if (opts.idleTimeoutMs > 0 && state === 'ready') {
109
+ clearTimeout(idleTimer);
110
+ idleTimer = setTimeout(idleShutdown, opts.idleTimeoutMs);
111
+ idleTimer.unref && idleTimer.unref();
112
+ }
113
+ }
114
+
115
+ function idleShutdown() {
116
+ if (closed || inFlightRequests > 0) { touchActivity(); return; }
117
+ log('opencode serve bridge idle shutdown', { idleTimeoutMs: opts.idleTimeoutMs });
118
+ intentionalStop = true;
119
+ stopSse();
120
+ killChild();
121
+ state = 'idle';
122
+ bootPromise = null;
123
+ port = null;
124
+ restartsUsed = 0;
125
+ }
126
+
127
+ function killChild() {
128
+ if (!child) return;
129
+ const target = child;
130
+ child = null;
131
+ try {
132
+ // npm shims spawn a node child on Windows; killing the shell alone
133
+ // orphans it. Fakes injected by tests have no pid and skip taskkill.
134
+ if (process.platform === 'win32' && Number.isInteger(target.pid)) {
135
+ realSpawn('taskkill', ['/pid', String(target.pid), '/T', '/F'], { windowsHide: true });
136
+ }
137
+ } catch { /* already gone */ }
138
+ try { target.kill(); } catch { /* already gone */ }
139
+ }
140
+
141
+ function stopSse() {
142
+ clearTimeout(sseReconnectTimer);
143
+ clearTimeout(sseStaleTimer);
144
+ sseReconnectTimer = null;
145
+ if (sseAbort) {
146
+ const abort = sseAbort;
147
+ sseAbort = null;
148
+ try { abort.abort(); } catch { /* already aborted */ }
149
+ }
150
+ }
151
+
152
+ // ---- HTTP client ---------------------------------------------------------
153
+
154
+ async function request(method, path, options = {}) {
155
+ if (closed) throw bridgeError('opencode serve bridge is closed', 503, 'BRIDGE_CLOSED');
156
+ if (state === 'crashed') throw bridgeError('opencode serve bridge exhausted its restart budget', 503, 'BRIDGE_CRASHED');
157
+ if (!port) await start();
158
+ const timeoutMs = options.timeoutMs !== undefined ? options.timeoutMs : opts.requestTimeoutMs;
159
+ const controller = new AbortController();
160
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
161
+ timer.unref && timer.unref();
162
+ inFlightRequests += 1;
163
+ try {
164
+ const response = await fetchFn(`${base()}${path}`, {
165
+ method,
166
+ signal: controller.signal,
167
+ headers: options.body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
168
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
169
+ });
170
+ if (!response.ok) {
171
+ let detail = '';
172
+ try {
173
+ const parsed = await response.json();
174
+ detail = parsed && (parsed.message || parsed.error && parsed.error.message) || '';
175
+ } catch { /* non-JSON error body */ }
176
+ throw bridgeError(`opencode serve ${method} ${path} failed: ${response.status} ${String(detail).slice(0, 300)}`,
177
+ 502, 'BRIDGE_HTTP_ERROR', { httpStatus: response.status });
178
+ }
179
+ if (response.status === 204) return null;
180
+ const text = await response.text();
181
+ touchActivity();
182
+ return text ? JSON.parse(text) : null;
183
+ } catch (error) {
184
+ if (error && (error.code === 'BRIDGE_HTTP_ERROR' || error.code === 'BRIDGE_CLOSED')) throw error;
185
+ if (controller.signal.aborted) {
186
+ throw bridgeError(`opencode serve ${method} ${path} timed out after ${timeoutMs}ms`, 504, 'BRIDGE_TIMEOUT');
187
+ }
188
+ throw bridgeError(`opencode serve ${method} ${path} failed: ${error && error.message || error}`,
189
+ 503, 'BRIDGE_REQUEST_FAILED', { cause: error });
190
+ } finally {
191
+ clearTimeout(timer);
192
+ inFlightRequests -= 1;
193
+ touchActivity();
194
+ }
195
+ }
196
+
197
+ // ---- SSE subscription ----------------------------------------------------
198
+
199
+ function handleSseFrame(raw) {
200
+ const dataLines = [];
201
+ for (const line of raw.split('\n')) {
202
+ if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''));
203
+ }
204
+ if (!dataLines.length) return;
205
+ let parsed;
206
+ try { parsed = JSON.parse(dataLines.join('\n')); } catch {
207
+ log(`opencode serve bridge discarded unparseable SSE frame: ${raw.slice(0, 160)}`);
208
+ return;
209
+ }
210
+ const payload = parsed && parsed.payload && typeof parsed.payload === 'object' ? parsed.payload : null;
211
+ if (!payload || !payload.type) return; // heartbeat frames still refresh liveness below
212
+ // Heartbeats prove the stream is alive but must not keep the child from
213
+ // idling away — every other event (and each request) is real activity.
214
+ if (payload.type !== 'server.heartbeat') touchActivity();
215
+ try { onEvent(payload); } catch (error) {
216
+ log(`opencode serve bridge onEvent handler failed for ${payload.type}: ${error.message}`);
217
+ }
218
+ }
219
+
220
+ async function sseLoop() {
221
+ const myGeneration = generation;
222
+ while (!closed && child && generation === myGeneration) {
223
+ const controller = new AbortController();
224
+ sseAbort = controller;
225
+ try {
226
+ const response = await fetchFn(`${base()}/global/event`, { signal: controller.signal });
227
+ if (!response.ok || !response.body || typeof response.body.getReader !== 'function') {
228
+ throw new Error(`event subscription failed: HTTP ${response.status}`);
229
+ }
230
+ sseAttempt = 0; // a healthy subscription resets the backoff
231
+ lastFrameAt = Date.now();
232
+ armStalenessWatchdog();
233
+ const reader = response.body.getReader();
234
+ const decoder = new TextDecoder();
235
+ let buffer = '';
236
+ while (true) {
237
+ const { done, value } = await reader.read();
238
+ if (done) break;
239
+ lastFrameAt = Date.now();
240
+ buffer += decoder.decode(value, { stream: true });
241
+ let index;
242
+ while ((index = buffer.indexOf('\n\n')) >= 0) {
243
+ const frame = buffer.slice(0, index);
244
+ buffer = buffer.slice(index + 2);
245
+ if (frame.trim()) handleSseFrame(frame);
246
+ }
247
+ }
248
+ } catch (error) {
249
+ if (closed || !child || generation !== myGeneration) return;
250
+ if (!controller.signal.aborted) log(`opencode serve event stream dropped: ${error && error.message || error}`);
251
+ } finally {
252
+ clearTimeout(sseStaleTimer);
253
+ }
254
+ if (closed || !child || generation !== myGeneration) return;
255
+ // The stream ended (server closed it, watchdog aborted it, network cut
256
+ // it): notify once per gap so the consumer reconciles missed events via
257
+ // REST, then reconnect with capped backoff while the child lives.
258
+ sseAbort = null;
259
+ try { onSseGap(); } catch (error) { log(`onSseGap handler failed: ${error.message}`); }
260
+ const backoff = opts.sseReconnectMs[Math.min(sseAttempt, opts.sseReconnectMs.length - 1)];
261
+ sseAttempt += 1;
262
+ await new Promise(resolve => {
263
+ sseReconnectTimer = setTimeout(resolve, backoff);
264
+ sseReconnectTimer.unref && sseReconnectTimer.unref();
265
+ });
266
+ sseReconnectTimer = null;
267
+ if (closed || !child || generation !== myGeneration) return;
268
+ }
269
+ }
270
+
271
+ function armStalenessWatchdog() {
272
+ if (!opts.heartbeatTimeoutMs) return;
273
+ clearTimeout(sseStaleTimer);
274
+ sseStaleTimer = setTimeout(() => {
275
+ // Heartbeats arrive every ~10-15s; silence means a dead stream that the
276
+ // OS has not reported yet — abort the fetch to force the reconnect path.
277
+ log(`opencode serve event stream stale for ${opts.heartbeatTimeoutMs}ms; forcing reconnect`);
278
+ if (sseAbort) {
279
+ try { sseAbort.abort(); } catch { /* already aborted */ }
280
+ }
281
+ }, opts.heartbeatTimeoutMs);
282
+ sseStaleTimer.unref && sseStaleTimer.unref();
283
+ }
284
+
285
+ // ---- child lifecycle -----------------------------------------------------
286
+
287
+ function allocatePort() {
288
+ if (typeof opts.allocatePort === 'function') return Promise.resolve(opts.allocatePort());
289
+ return new Promise((resolve, reject) => {
290
+ const server = net.createServer();
291
+ server.unref();
292
+ server.on('error', reject);
293
+ server.listen(0, opts.hostname, () => {
294
+ const { port: freePort } = server.address();
295
+ server.close(() => resolve(freePort));
296
+ });
297
+ });
298
+ }
299
+
300
+ async function waitForHealth(deadline) {
301
+ const controller = new AbortController();
302
+ healthAbort = controller;
303
+ // A loop deadline alone cannot bound a connection stalled at headers or
304
+ // JSON body. The same budget must cancel the in-flight health request.
305
+ const timer = setTimeout(() => controller.abort(), Math.max(0, deadline - Date.now()));
306
+ try {
307
+ while (Date.now() < deadline && !controller.signal.aborted) {
308
+ if (!child) throw bridgeError('opencode serve child exited during startup', 503, 'BRIDGE_SPAWN_FAILED');
309
+ try {
310
+ const response = await fetchFn(`${base()}/global/health`, { signal: controller.signal });
311
+ if (response.ok) {
312
+ const parsed = await response.json().catch(() => null);
313
+ if (!parsed || parsed.healthy !== true) throw new Error('health endpoint reported unhealthy');
314
+ return parsed;
315
+ }
316
+ await response.body?.cancel?.();
317
+ } catch (error) {
318
+ if (closed) throw bridgeError('opencode serve bridge is closed', 503, 'BRIDGE_CLOSED');
319
+ if (!child) throw bridgeError('opencode serve child exited during startup', 503, 'BRIDGE_SPAWN_FAILED', { cause: error });
320
+ }
321
+ if (!controller.signal.aborted) await new Promise(resolve => setTimeout(resolve, Math.min(100, Math.max(0, deadline - Date.now()))));
322
+ }
323
+ throw bridgeError(`opencode serve did not become healthy within ${opts.healthTimeoutMs}ms`, 504, 'BRIDGE_HEALTH_TIMEOUT');
324
+ } finally {
325
+ clearTimeout(timer);
326
+ controller.abort();
327
+ if (healthAbort === controller) healthAbort = null;
328
+ }
329
+ }
330
+
331
+ function wire(childInstance) {
332
+ child = childInstance;
333
+ if (childInstance.stdout && childInstance.stdout.setEncoding) {
334
+ childInstance.stdout.setEncoding('utf8');
335
+ childInstance.stdout.on('data', chunk => {
336
+ const text = String(chunk).trim();
337
+ if (text) log(`opencode serve stdout: ${text.slice(0, 200)}`);
338
+ });
339
+ }
340
+ if (childInstance.stderr && childInstance.stderr.setEncoding) {
341
+ childInstance.stderr.setEncoding('utf8');
342
+ childInstance.stderr.on('data', chunk => {
343
+ const text = String(chunk).trim();
344
+ if (text) log(`opencode serve stderr: ${text.slice(0, 200)}`);
345
+ });
346
+ }
347
+ childInstance.on('error', error => {
348
+ log(`opencode serve child error: ${error.message}`);
349
+ handleExit(error);
350
+ });
351
+ childInstance.on('close', code => {
352
+ log(`opencode serve child exited code=${code}`);
353
+ handleExit(null);
354
+ });
355
+ }
356
+
357
+ function handleExit(spawnError) {
358
+ if (!child) return;
359
+ const wasReady = state === 'ready';
360
+ child = null;
361
+ port = null;
362
+ bootPromise = null;
363
+ clearTimeout(idleTimer);
364
+ stopSse();
365
+ if (closed || intentionalStop) return;
366
+ const error = spawnError
367
+ ? bridgeError(`opencode serve child failed: ${spawnError.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: spawnError })
368
+ : bridgeError('opencode serve child exited unexpectedly', 503, 'BRIDGE_CRASHED');
369
+ try { onUnexpectedExit(error); } catch (hookError) { log(`onUnexpectedExit hook failed: ${hookError.message}`); }
370
+ if (!wasReady) {
371
+ state = 'idle';
372
+ if (bootSettle) {
373
+ const settle = bootSettle;
374
+ bootSettle = null;
375
+ settle.reject(spawnError
376
+ ? bridgeError(`opencode serve child failed to start: ${spawnError.message}`, 503, 'BRIDGE_SPAWN_FAILED')
377
+ : bridgeError('opencode serve child exited before becoming healthy', 503, 'BRIDGE_SPAWN_FAILED'));
378
+ }
379
+ return;
380
+ }
381
+ if (restartsUsed < opts.maxRestarts) {
382
+ const backoff = opts.restartBackoffMs[Math.min(restartsUsed, opts.restartBackoffMs.length - 1)];
383
+ restartsUsed += 1;
384
+ state = 'restarting';
385
+ log(`opencode serve crashed; restarting (${restartsUsed}/${opts.maxRestarts}) in ${backoff}ms`);
386
+ bootPromise = new Promise((resolve, reject) => {
387
+ bootSettle = { resolve, reject };
388
+ restartTimer = setTimeout(() => {
389
+ launchChild().then(resolve, reject);
390
+ }, backoff);
391
+ restartTimer.unref && restartTimer.unref();
392
+ });
393
+ bootPromise.catch(() => {}); // held internally until the next start()
394
+ } else {
395
+ state = 'crashed';
396
+ log('opencode serve bridge exhausted its restart budget');
397
+ }
398
+ }
399
+
400
+ function launchChild() {
401
+ state = 'starting';
402
+ return allocatePort().then(allocatedPort => new Promise((resolve, reject) => {
403
+ bootSettle = { resolve, reject };
404
+ port = allocatedPort;
405
+ const args = ['serve', '--port', String(allocatedPort), '--hostname', opts.hostname, ...(config.args || [])];
406
+ let invocation;
407
+ try {
408
+ // Real children must skip npm .cmd shims on Windows (plain spawn of a
409
+ // shim name is ENOENT); injected test doubles pass through as-is.
410
+ invocation = config.spawn || !config.command
411
+ ? { command: config.command || 'opencode', args }
412
+ : resolveCliInvocation(config.command, args);
413
+ } catch (error) {
414
+ state = 'idle';
415
+ port = null;
416
+ bootSettle = null;
417
+ reject(bridgeError(`failed to resolve the opencode executable: ${error.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: error }));
418
+ return;
419
+ }
420
+ let instance;
421
+ try {
422
+ instance = spawnFn(invocation.command, invocation.args, {
423
+ cwd: config.cwd,
424
+ windowsHide: true,
425
+ env: { ...process.env, ...(config.env || {}), ...knowledgeRootEnv() },
426
+ });
427
+ } catch (error) {
428
+ state = 'idle';
429
+ port = null;
430
+ bootSettle = null;
431
+ reject(bridgeError(`failed to start opencode serve: ${error.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: error }));
432
+ return;
433
+ }
434
+ wire(instance);
435
+ const deadline = Date.now() + opts.healthTimeoutMs;
436
+ waitForHealth(deadline).then(health => {
437
+ if (!child || closed) throw bridgeError('bridge closed during startup', 503, 'BRIDGE_CLOSED');
438
+ bootSettle = null;
439
+ generation += 1;
440
+ state = 'ready';
441
+ intentionalStop = false;
442
+ touchActivity();
443
+ sseAttempt = 0;
444
+ sseLoop(); // fire and forget; the loop owns its own error path
445
+ try { onReady(generation, { port, version: health && health.version }); } catch (error) {
446
+ log(`onReady hook failed: ${error.message}`);
447
+ }
448
+ resolve({ port: allocatedPort, version: health && health.version });
449
+ }, error => {
450
+ if (bootSettle) {
451
+ const settle = bootSettle;
452
+ bootSettle = null;
453
+ settle.reject(error);
454
+ }
455
+ // A failed handshake must not leave a doomed child running.
456
+ intentionalStop = true;
457
+ stopSse();
458
+ killChild();
459
+ state = closed ? 'closed' : 'idle';
460
+ port = null;
461
+ });
462
+ }));
463
+ }
464
+
465
+ function start() {
466
+ if (closed) return Promise.reject(bridgeError('opencode serve bridge is closed', 503, 'BRIDGE_CLOSED'));
467
+ if (state === 'ready' && bootPromise) return bootPromise;
468
+ if (state === 'crashed') return Promise.reject(bridgeError('opencode serve bridge exhausted its restart budget', 503, 'BRIDGE_CRASHED'));
469
+ if (!bootPromise) bootPromise = launchChild();
470
+ return bootPromise;
471
+ }
472
+
473
+ function close() {
474
+ if (closed) return Promise.resolve();
475
+ closed = true;
476
+ state = 'closed';
477
+ clearTimeout(restartTimer);
478
+ clearTimeout(idleTimer);
479
+ intentionalStop = true;
480
+ healthAbort?.abort();
481
+ stopSse();
482
+ killChild();
483
+ if (bootSettle) {
484
+ const settle = bootSettle;
485
+ bootSettle = null;
486
+ settle.reject(bridgeError('opencode serve bridge is closed', 503, 'BRIDGE_CLOSED'));
487
+ }
488
+ return Promise.resolve();
489
+ }
490
+
491
+ return {
492
+ get state() { return state; },
493
+ get port() { return port; },
494
+ get generation() { return generation; },
495
+ get sseConnected() { return Boolean(sseAbort); },
496
+ start,
497
+ request,
498
+ close,
499
+ };
500
+ }
501
+
502
+ function bridgeError(message, status, code, extra = {}) {
503
+ return Object.assign(new Error(message), { status, code, ...extra });
504
+ }
505
+
506
+ module.exports = { createOpenCodeServerBridge };