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