@sanqianx/claude-ai-workbench 0.13.2 → 0.13.4

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 (122) hide show
  1. package/LICENSE +201 -201
  2. package/README.md +156 -156
  3. package/apps/agent-terminal/app.css +652 -613
  4. package/apps/agent-terminal/app.js +143 -67
  5. package/apps/agent-terminal/conversation-composer.js +44 -0
  6. package/apps/agent-terminal/index.html +172 -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 +189 -159
  10. package/apps/agent-terminal/right-workspace-dock.js +652 -521
  11. package/apps/agent-terminal/user-question-card.js +68 -0
  12. package/apps/ai-profile-settings-host/index.html +25 -25
  13. package/apps/ai-profile-settings-host/page.css +1 -1
  14. package/apps/ai-profile-settings-host/page.js +113 -113
  15. package/apps/minimal-test-host/README.md +15 -15
  16. package/apps/minimal-test-host/host.css +2 -2
  17. package/apps/minimal-test-host/host.js +35 -35
  18. package/apps/minimal-test-host/index.html +58 -58
  19. package/apps/minimal-test-host/optional-mock/fault-injection.js +15 -15
  20. package/docs/sdk/FILE_LAYOUT.md +31 -31
  21. package/docs/sdk/INTEGRATION.md +126 -126
  22. package/docs/sdk/README.md +21 -21
  23. package/docs/sdk/SESSION_EVENTS.md +15 -13
  24. package/docs/sdk/openapi.yaml +16 -1
  25. package/package.json +1 -1
  26. package/packages/client/README.md +29 -29
  27. package/packages/client/index.d.ts +122 -122
  28. package/packages/client/index.js +260 -260
  29. package/packages/client/package.json +2 -2
  30. package/packages/contracts/README.md +9 -9
  31. package/packages/contracts/index.d.ts +519 -517
  32. package/packages/contracts/index.js +312 -312
  33. package/packages/contracts/package.json +1 -1
  34. package/packages/core/README.md +8 -8
  35. package/packages/core/index.d.ts +76 -76
  36. package/packages/core/index.js +222 -222
  37. package/packages/core/package.json +2 -2
  38. package/packages/server/README.md +17 -17
  39. package/packages/server/bin/agent-terminal-server.js +42 -42
  40. package/packages/server/bin/claude-workbench.js +225 -225
  41. package/packages/server/index.d.ts +171 -171
  42. package/packages/server/index.js +230 -230
  43. package/packages/server/lib/agent-discovery.js +133 -133
  44. package/packages/server/lib/agent-registry.js +143 -143
  45. package/packages/server/lib/agent-spawn-env.js +17 -17
  46. package/packages/server/lib/attachment-store.js +78 -78
  47. package/packages/server/lib/bridges/app-server-bridge.js +373 -373
  48. package/packages/server/lib/bridges/opencode-server-bridge.js +506 -506
  49. package/packages/server/lib/capture-settings-store.js +35 -35
  50. package/packages/server/lib/claude-history-recovery.js +87 -87
  51. package/packages/server/lib/cli-session-recovery.js +28 -28
  52. package/packages/server/lib/context-resolver.js +55 -55
  53. package/packages/server/lib/conversation-capture.js +51 -51
  54. package/packages/server/lib/delegation-phase-store.js +357 -357
  55. package/packages/server/lib/delegation-store.js +474 -468
  56. package/packages/server/lib/diagnostics.js +32 -32
  57. package/packages/server/lib/drivers/cli-json-runner.js +974 -974
  58. package/packages/server/lib/drivers/codex-driver.js +1461 -1442
  59. package/packages/server/lib/drivers/opencode-driver.js +1554 -1554
  60. package/packages/server/lib/drivers/zcode-driver.js +1250 -1250
  61. package/packages/server/lib/drivers/zcode-profile-runtime.js +71 -71
  62. package/packages/server/lib/event-stream.js +138 -138
  63. package/packages/server/lib/folder-picker.js +623 -623
  64. package/packages/server/lib/http-handler.js +339 -339
  65. package/packages/server/lib/json-file.js +26 -26
  66. package/packages/server/lib/knowledge-context.js +33 -33
  67. package/packages/server/lib/model-detection.js +295 -295
  68. package/packages/server/lib/parent-context.js +44 -0
  69. package/packages/server/lib/profile-store.js +283 -283
  70. package/packages/server/lib/project-store.js +102 -102
  71. package/packages/server/lib/runtime-loader.js +120 -120
  72. package/packages/server/lib/session-archive-store.js +53 -53
  73. package/packages/server/lib/session-history.js +612 -612
  74. package/packages/server/lib/system-doctor.js +125 -125
  75. package/packages/server/lib/ui-preferences-store.js +48 -48
  76. package/packages/server/lib/user-questions.js +67 -0
  77. package/packages/server/lib/workbench-service.js +1696 -1675
  78. package/packages/server/package.json +2 -2
  79. package/packages/ui/README.md +30 -30
  80. package/packages/ui/ai-profile-settings.d.ts +45 -45
  81. package/packages/ui/ai-profile-settings.js +283 -283
  82. package/packages/ui/claude-terminal.d.ts +31 -31
  83. package/packages/ui/claude-terminal.js +343 -343
  84. package/packages/ui/index.d.ts +18 -18
  85. package/packages/ui/index.js +51 -51
  86. package/packages/ui/package.json +2 -2
  87. package/packages/ui/theme.css +13 -13
  88. package/src/assets/claude-prompts.json +21 -21
  89. package/src/backend/http/ai-profile-routes.js +61 -61
  90. package/src/backend/http/claude-routes.js +170 -170
  91. package/src/backend/http/node-helpers.js +45 -45
  92. package/src/backend/index.js +21 -21
  93. package/src/backend/lib/ai-adapter.js +144 -144
  94. package/src/backend/lib/ai-profile-store.js +259 -259
  95. package/src/backend/lib/ai-vendor-presets.js +83 -83
  96. package/src/backend/lib/ai-workspace.js +140 -140
  97. package/src/backend/lib/automation-config.js +253 -253
  98. package/src/backend/lib/claude-cli-runner.js +2408 -2392
  99. package/src/backend/lib/data-dir.js +169 -169
  100. package/src/backend/lib/llm-client.js +176 -176
  101. package/src/backend/lib/model-context-windows.js +82 -82
  102. package/src/backend/lib/native-task-control.js +217 -217
  103. package/src/backend/lib/native-task-history-index.js +475 -475
  104. package/src/backend/lib/native-task-history-reader.js +526 -526
  105. package/src/backend/lib/native-task-messages.js +457 -457
  106. package/src/backend/lib/native-task-registry.js +325 -325
  107. package/src/backend/lib/native-task-runtime.js +242 -242
  108. package/src/backend/lib/native-task-trace.js +440 -440
  109. package/src/backend/lib/native-task-transcript.js +393 -393
  110. package/src/backend/lib/prompt-registry.js +166 -166
  111. package/src/backend/lib/runtime-config.js +121 -121
  112. package/src/backend/lib/sdk-session-stream.js +143 -143
  113. package/src/backend/lib/sdk-turn-stream.js +16 -16
  114. package/src/backend/lib/store-scope.js +131 -131
  115. package/src/backend/lib/task-identity.js +104 -104
  116. package/src/backend/lib/task-state-projection.js +58 -58
  117. package/src/index.js +7 -7
  118. package/src/sdk-wave-a.ts +1 -1
  119. package/src/sdk-wave-b.ts +1 -1
  120. package/src/sdk-wave-c.ts +1 -1
  121. package/src/sdk-wave-d.ts +1 -1
  122. package/src/sdk-wave-e.ts +1 -1
@@ -1,373 +1,373 @@
1
- 'use strict';
2
-
3
- const { spawn: realSpawn } = require('child_process');
4
- const { knowledgeRootEnv } = require('../agent-spawn-env');
5
-
6
- // Generic persistent app-server bridge: manages one long-lived child process
7
- // speaking line-delimited JSON-RPC over stdio (the dialect `codex app-server`
8
- // uses — messages may omit the `jsonrpc` field in both directions).
9
- //
10
- // Responsibilities:
11
- // - process lifecycle: lazy single-flight start, crash auto-restart with
12
- // backoff (bounded), idle shutdown after a configurable quiet period,
13
- // terminal close();
14
- // - JSON-RPC client: id-correlated request/response promises, client
15
- // notifications, server notification fan-out (onEvent) and server-initiated
16
- // requests answered through the onServerRequest callback;
17
- // - line-buffered stdio parsing (same framing as the CLI JSON runners).
18
- //
19
- // Everything is injectable for tests: pass `spawn` to substitute the child.
20
- //
21
- // config:
22
- // command, args, env, cwd child invocation (env/cwd are merged/defaults)
23
- // spawn injectable spawn(command, args, options)
24
- // onEvent ({ method, params }) => void — notifications
25
- // onServerRequest async (method, params) => result — throws to send an error response
26
- // onUnexpectedExit (error) => void — child died outside idle/close (turns may hang)
27
- // requestTimeoutMs per-request default timeout (0 disables; default 120000)
28
- // idleTimeoutMs quiet period before shutdown (0 disables; default 600000)
29
- // maxRestarts crash restart budget before giving up (default 3)
30
- // restartBackoffMs array of escalating delays (default [250, 500, 1000])
31
- // log (message, meta) => void diagnostics hook
32
- //
33
- // States: idle -> starting -> ready -> (restarting -> ready)* -> idle|crashed|closed
34
-
35
- const DEFAULT_REQUEST_TIMEOUT_MS = 120000;
36
- const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
37
- const DEFAULT_MAX_RESTARTS = 3;
38
- const DEFAULT_RESTART_BACKOFF_MS = [250, 500, 1000];
39
-
40
- function createAppServerBridge(config = {}) {
41
- const spawnFn = config.spawn || realSpawn;
42
- const onEvent = typeof config.onEvent === 'function' ? config.onEvent : () => {};
43
- const onUnexpectedExit = typeof config.onUnexpectedExit === 'function' ? config.onUnexpectedExit : () => {};
44
- const onReady = typeof config.onReady === 'function' ? config.onReady : () => {};
45
- const onServerRequest = config.onServerRequest;
46
- const log = typeof config.log === 'function' ? config.log : () => {};
47
- const requestTimeoutMs = config.requestTimeoutMs !== undefined ? config.requestTimeoutMs : DEFAULT_REQUEST_TIMEOUT_MS;
48
- const idleTimeoutMs = config.idleTimeoutMs !== undefined ? config.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
49
- const maxRestarts = config.maxRestarts !== undefined ? config.maxRestarts : DEFAULT_MAX_RESTARTS;
50
- const restartBackoffMs = config.restartBackoffMs || DEFAULT_RESTART_BACKOFF_MS;
51
-
52
- let state = 'idle';
53
- let child = null;
54
- let generation = 0; // bumped on every successful (re)launch of the child
55
- let bootPromise = null;
56
- let restartTimer = null;
57
- let idleTimer = null;
58
- let restartsUsed = 0;
59
- let intentionalStop = false;
60
- let closed = false;
61
- let nextRequestId = 1;
62
- let bootSettle = null; // { resolve, reject } of the in-flight launch promise
63
- let inFlightServerRequests = 0; // unanswered server requests are activity
64
- const pending = new Map(); // id -> { resolve, reject, timer }
65
-
66
- function safeWrite(message) {
67
- const line = `${JSON.stringify(message)}\n`;
68
- if (!child || !child.stdin || child.stdin.destroyed) throw bridgeError('bridge child stdin is not writable', 503, 'BRIDGE_NOT_READY');
69
- child.stdin.write(line);
70
- }
71
-
72
- function touchActivity() {
73
- if (idleTimeoutMs > 0 && state === 'ready') {
74
- clearTimeout(idleTimer);
75
- idleTimer = setTimeout(idleShutdown, idleTimeoutMs);
76
- idleTimer.unref && idleTimer.unref();
77
- }
78
- }
79
-
80
- function idleShutdown() {
81
- if (closed || pending.size > 0 || inFlightServerRequests > 0) { touchActivity(); return; }
82
- log('bridge idle shutdown', { idleTimeoutMs });
83
- intentionalStop = true;
84
- killChild('idle-timeout');
85
- state = 'idle';
86
- bootPromise = null;
87
- restartsUsed = 0;
88
- }
89
-
90
- function killChild() {
91
- if (!child) return;
92
- const target = child;
93
- child = null;
94
- try {
95
- // npm shims spawn a node child on Windows; killing the shell alone
96
- // orphans it. Fakes injected by tests have no pid and skip taskkill.
97
- if (process.platform === 'win32' && Number.isInteger(target.pid)) {
98
- realSpawn('taskkill', ['/pid', String(target.pid), '/T', '/F'], { windowsHide: true });
99
- }
100
- } catch { /* already gone */ }
101
- try { target.kill(); } catch { /* already gone */ }
102
- }
103
-
104
- function failPending(error) {
105
- for (const entry of pending.values()) {
106
- clearTimeout(entry.timer);
107
- entry.reject(error);
108
- }
109
- pending.clear();
110
- }
111
-
112
- function wire(childInstance) {
113
- child = childInstance;
114
- let buffer = '';
115
- childInstance.stdout.setEncoding('utf8');
116
- childInstance.stdout.on('data', chunk => {
117
- buffer += chunk;
118
- let index;
119
- while ((index = buffer.indexOf('\n')) >= 0) {
120
- const line = buffer.slice(0, index).trim();
121
- buffer = buffer.slice(index + 1);
122
- if (line) handleMessage(line);
123
- }
124
- });
125
- childInstance.stderr.setEncoding && childInstance.stderr.setEncoding('utf8');
126
- childInstance.stderr.on('data', chunk => {
127
- const text = String(chunk).trim();
128
- if (text) log(`bridge stderr: ${text.slice(0, 400)}`);
129
- });
130
- childInstance.on('error', error => {
131
- log(`bridge child error: ${error.message}`);
132
- handleExit(error);
133
- });
134
- childInstance.on('close', code => {
135
- log(`bridge child exited code=${code}`);
136
- handleExit(null);
137
- });
138
- }
139
-
140
- function handleMessage(line) {
141
- touchActivity();
142
- let message;
143
- try { message = JSON.parse(line); } catch {
144
- log(`bridge discarded unparseable line: ${line.slice(0, 200)}`);
145
- return;
146
- }
147
- if (!message || typeof message !== 'object') return;
148
- if (message.method && message.id !== undefined && message.id !== null) {
149
- handleServerRequest(message);
150
- } else if (message.method) {
151
- try { onEvent({ method: message.method, params: message.params }); } catch (error) {
152
- log(`bridge onEvent handler failed for ${message.method}: ${error.message}`);
153
- }
154
- } else if (message.id !== undefined && message.id !== null) {
155
- const entry = pending.get(message.id);
156
- if (!entry) return; // response to an unknown/aborted request
157
- pending.delete(message.id);
158
- clearTimeout(entry.timer);
159
- if (restartsUsed > 0) restartsUsed = 0;
160
- if (message.error) entry.reject(rpcError(message.error));
161
- else entry.resolve(message.result !== undefined ? message.result : null);
162
- }
163
- // anything else is ignored
164
- }
165
-
166
- async function handleServerRequest(message) {
167
- const id = message.id;
168
- // An unanswered server request (e.g. an approval) is bridge activity:
169
- // it must keep the child alive and never be silently idled away.
170
- inFlightServerRequests += 1;
171
- try {
172
- if (!onServerRequest) throw bridgeError(`no onServerRequest handler for ${message.method}`, 501, 'BRIDGE_UNSUPPORTED');
173
- const result = await onServerRequest(message.method, message.params);
174
- safeWrite({ id, result: result === undefined ? null : result });
175
- } catch (error) {
176
- try {
177
- safeWrite({ id, error: { code: error.rpcCode || -32000, message: String(error && error.message || error).slice(0, 1000) } });
178
- } catch { /* child already gone; nothing to answer */ }
179
- } finally {
180
- inFlightServerRequests -= 1;
181
- touchActivity();
182
- }
183
- }
184
-
185
- function handleExit(spawnError) {
186
- if (!child) return;
187
- const wasReady = state === 'ready';
188
- child = null;
189
- bootPromise = null;
190
- clearTimeout(idleTimer);
191
- if (closed || intentionalStop) return;
192
- const error = spawnError
193
- ? bridgeError(`bridge child failed: ${spawnError.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: spawnError })
194
- : bridgeError('bridge child exited unexpectedly', 503, 'BRIDGE_CRASHED');
195
- failPending(error);
196
- try { onUnexpectedExit(error); } catch (hookError) { log(`bridge onUnexpectedExit hook failed: ${hookError.message}`); }
197
- if (!wasReady) {
198
- // The boot promise never resolved; fail it so start() callers see why.
199
- state = 'idle';
200
- if (bootSettle) {
201
- const settle = bootSettle;
202
- bootSettle = null;
203
- settle.reject(spawnError
204
- ? bridgeError(`bridge child failed to start: ${spawnError.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: spawnError })
205
- : bridgeError('bridge child exited before becoming ready', 503, 'BRIDGE_SPAWN_FAILED'));
206
- }
207
- return;
208
- }
209
- if (restartsUsed < maxRestarts) {
210
- const backoff = restartBackoffMs[Math.min(restartsUsed, restartBackoffMs.length - 1)];
211
- restartsUsed += 1;
212
- state = 'restarting';
213
- log(`bridge crashed; restarting (${restartsUsed}/${maxRestarts}) in ${backoff}ms`);
214
- bootPromise = new Promise((resolve, reject) => {
215
- // Register the restart promise's settle so a close() during the
216
- // backoff window cannot leave start() callers hanging forever.
217
- bootSettle = { resolve, reject };
218
- restartTimer = setTimeout(() => {
219
- launchChild().then(resolve, reject);
220
- }, backoff);
221
- });
222
- // This promise is held internally until someone calls start(); without
223
- // a handler a close()-time rejection would surface as unhandled.
224
- bootPromise.catch(() => {});
225
- } else {
226
- state = 'crashed';
227
- log('bridge exhausted its restart budget');
228
- }
229
- }
230
-
231
- function launchChild() {
232
- state = 'starting';
233
- return new Promise((resolve, reject) => {
234
- bootSettle = { resolve, reject };
235
- let instance;
236
- let command = config.command;
237
- let args = config.args || [];
238
- try {
239
- // Windows bare commands (npm shims, PATH lookups) don't spawn without
240
- // shell:true; resolve to the exact executable like the CLI runner
241
- // does. ENOENT here would permanently demote the driver to
242
- // per-turn-spawn CLI mode, which is exactly the "every turn is slow"
243
- // failure mode.
244
- if (process.platform === 'win32' && config.resolveInvocation) {
245
- const invocation = config.resolveInvocation(command, args);
246
- command = invocation.command;
247
- args = invocation.args;
248
- }
249
- instance = spawnFn(command, args, {
250
- cwd: config.cwd,
251
- windowsHide: true,
252
- env: { ...process.env, ...(config.env || {}), ...knowledgeRootEnv() },
253
- });
254
- } catch (error) {
255
- state = 'idle';
256
- bootSettle = null;
257
- reject(bridgeError(`failed to start bridge child: ${error.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: error }));
258
- return;
259
- }
260
- wire(instance);
261
- const markReady = () => {
262
- if (!child || closed) return;
263
- bootSettle = null;
264
- generation += 1;
265
- state = 'ready';
266
- intentionalStop = false;
267
- touchActivity();
268
- try { onReady(generation); } catch (error) { log(`bridge onReady hook failed: ${error.message}`); }
269
- resolve();
270
- };
271
- if (instance.pid === undefined) {
272
- // Fake children (EventEmitter stand-ins) never emit 'spawn'.
273
- setImmediate(markReady);
274
- } else {
275
- // Real children signal readiness through 'spawn'; a fallback timer
276
- // covers exotic platforms that are slow to emit it.
277
- if (typeof instance.once === 'function') instance.once('spawn', markReady);
278
- const fallback = setTimeout(markReady, 500);
279
- fallback.unref && fallback.unref();
280
- }
281
- });
282
- }
283
-
284
- function start() {
285
- if (closed) return Promise.reject(bridgeError('bridge is closed', 503, 'BRIDGE_CLOSED'));
286
- if (state === 'ready' && bootPromise) return bootPromise;
287
- if (state === 'crashed') return Promise.reject(bridgeError('bridge exhausted its restart budget', 503, 'BRIDGE_CRASHED'));
288
- if (!bootPromise) bootPromise = launchChild();
289
- return bootPromise;
290
- }
291
-
292
- function request(method, params, options = {}) {
293
- if (closed) return Promise.reject(bridgeError('bridge is closed', 503, 'BRIDGE_CLOSED'));
294
- if (state === 'crashed') return Promise.reject(bridgeError('bridge exhausted its restart budget', 503, 'BRIDGE_CRASHED'));
295
- const timeoutMs = options.timeoutMs !== undefined ? options.timeoutMs : requestTimeoutMs;
296
- return start().then(() => new Promise((resolve, reject) => {
297
- const id = nextRequestId++;
298
- const entry = { resolve, reject, timer: null };
299
- if (timeoutMs > 0) {
300
- entry.timer = setTimeout(() => {
301
- pending.delete(id);
302
- reject(bridgeError(`bridge request ${method} timed out after ${timeoutMs}ms`, 504, 'BRIDGE_TIMEOUT'));
303
- }, timeoutMs);
304
- entry.timer.unref && entry.timer.unref();
305
- }
306
- pending.set(id, entry);
307
- try {
308
- // The app-server dialect omits the jsonrpc field; we mirror that.
309
- safeWrite(params !== undefined ? { id, method, params } : { id, method });
310
- touchActivity();
311
- } catch (error) {
312
- pending.delete(id);
313
- clearTimeout(entry.timer);
314
- reject(error);
315
- }
316
- }));
317
- }
318
-
319
- function notify(method, params) {
320
- if (closed) throw bridgeError('bridge is closed', 503, 'BRIDGE_CLOSED');
321
- return start().then(() => {
322
- safeWrite(params !== undefined ? { method, params } : { method });
323
- touchActivity();
324
- });
325
- }
326
-
327
- function close() {
328
- if (closed) return Promise.resolve();
329
- closed = true;
330
- state = 'closed';
331
- clearTimeout(restartTimer);
332
- clearTimeout(idleTimer);
333
- intentionalStop = true;
334
- killChild();
335
- failPending(bridgeError('bridge closed', 503, 'BRIDGE_CLOSED'));
336
- // A close() while still starting (or in a restart backoff) must settle
337
- // the boot promise, otherwise every awaiting start() caller hangs.
338
- if (bootSettle) {
339
- const settle = bootSettle;
340
- bootSettle = null;
341
- settle.reject(bridgeError('bridge closed', 503, 'BRIDGE_CLOSED'));
342
- }
343
- return Promise.resolve();
344
- }
345
-
346
- return {
347
- command: config.command,
348
- args: config.args || [],
349
- env: config.env || {},
350
- get state() { return state; },
351
- get pendingCount() { return pending.size; },
352
- get restartsUsed() { return restartsUsed; },
353
- get generation() { return generation; },
354
- start,
355
- request,
356
- notify,
357
- close,
358
- };
359
- }
360
-
361
- function bridgeError(message, status, code, extra = {}) {
362
- return Object.assign(new Error(message), { status, code, ...extra });
363
- }
364
-
365
- function rpcError(rpc) {
366
- return Object.assign(new Error(String(rpc && rpc.message || 'bridge rpc error').slice(0, 1000)), {
367
- status: 502,
368
- code: 'BRIDGE_RPC_ERROR',
369
- rpcCode: rpc && rpc.code,
370
- });
371
- }
372
-
373
- module.exports = { createAppServerBridge };
1
+ 'use strict';
2
+
3
+ const { spawn: realSpawn } = require('child_process');
4
+ const { knowledgeRootEnv } = require('../agent-spawn-env');
5
+
6
+ // Generic persistent app-server bridge: manages one long-lived child process
7
+ // speaking line-delimited JSON-RPC over stdio (the dialect `codex app-server`
8
+ // uses — messages may omit the `jsonrpc` field in both directions).
9
+ //
10
+ // Responsibilities:
11
+ // - process lifecycle: lazy single-flight start, crash auto-restart with
12
+ // backoff (bounded), idle shutdown after a configurable quiet period,
13
+ // terminal close();
14
+ // - JSON-RPC client: id-correlated request/response promises, client
15
+ // notifications, server notification fan-out (onEvent) and server-initiated
16
+ // requests answered through the onServerRequest callback;
17
+ // - line-buffered stdio parsing (same framing as the CLI JSON runners).
18
+ //
19
+ // Everything is injectable for tests: pass `spawn` to substitute the child.
20
+ //
21
+ // config:
22
+ // command, args, env, cwd child invocation (env/cwd are merged/defaults)
23
+ // spawn injectable spawn(command, args, options)
24
+ // onEvent ({ method, params }) => void — notifications
25
+ // onServerRequest async (method, params) => result — throws to send an error response
26
+ // onUnexpectedExit (error) => void — child died outside idle/close (turns may hang)
27
+ // requestTimeoutMs per-request default timeout (0 disables; default 120000)
28
+ // idleTimeoutMs quiet period before shutdown (0 disables; default 600000)
29
+ // maxRestarts crash restart budget before giving up (default 3)
30
+ // restartBackoffMs array of escalating delays (default [250, 500, 1000])
31
+ // log (message, meta) => void diagnostics hook
32
+ //
33
+ // States: idle -> starting -> ready -> (restarting -> ready)* -> idle|crashed|closed
34
+
35
+ const DEFAULT_REQUEST_TIMEOUT_MS = 120000;
36
+ const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
37
+ const DEFAULT_MAX_RESTARTS = 3;
38
+ const DEFAULT_RESTART_BACKOFF_MS = [250, 500, 1000];
39
+
40
+ function createAppServerBridge(config = {}) {
41
+ const spawnFn = config.spawn || realSpawn;
42
+ const onEvent = typeof config.onEvent === 'function' ? config.onEvent : () => {};
43
+ const onUnexpectedExit = typeof config.onUnexpectedExit === 'function' ? config.onUnexpectedExit : () => {};
44
+ const onReady = typeof config.onReady === 'function' ? config.onReady : () => {};
45
+ const onServerRequest = config.onServerRequest;
46
+ const log = typeof config.log === 'function' ? config.log : () => {};
47
+ const requestTimeoutMs = config.requestTimeoutMs !== undefined ? config.requestTimeoutMs : DEFAULT_REQUEST_TIMEOUT_MS;
48
+ const idleTimeoutMs = config.idleTimeoutMs !== undefined ? config.idleTimeoutMs : DEFAULT_IDLE_TIMEOUT_MS;
49
+ const maxRestarts = config.maxRestarts !== undefined ? config.maxRestarts : DEFAULT_MAX_RESTARTS;
50
+ const restartBackoffMs = config.restartBackoffMs || DEFAULT_RESTART_BACKOFF_MS;
51
+
52
+ let state = 'idle';
53
+ let child = null;
54
+ let generation = 0; // bumped on every successful (re)launch of the child
55
+ let bootPromise = null;
56
+ let restartTimer = null;
57
+ let idleTimer = null;
58
+ let restartsUsed = 0;
59
+ let intentionalStop = false;
60
+ let closed = false;
61
+ let nextRequestId = 1;
62
+ let bootSettle = null; // { resolve, reject } of the in-flight launch promise
63
+ let inFlightServerRequests = 0; // unanswered server requests are activity
64
+ const pending = new Map(); // id -> { resolve, reject, timer }
65
+
66
+ function safeWrite(message) {
67
+ const line = `${JSON.stringify(message)}\n`;
68
+ if (!child || !child.stdin || child.stdin.destroyed) throw bridgeError('bridge child stdin is not writable', 503, 'BRIDGE_NOT_READY');
69
+ child.stdin.write(line);
70
+ }
71
+
72
+ function touchActivity() {
73
+ if (idleTimeoutMs > 0 && state === 'ready') {
74
+ clearTimeout(idleTimer);
75
+ idleTimer = setTimeout(idleShutdown, idleTimeoutMs);
76
+ idleTimer.unref && idleTimer.unref();
77
+ }
78
+ }
79
+
80
+ function idleShutdown() {
81
+ if (closed || pending.size > 0 || inFlightServerRequests > 0) { touchActivity(); return; }
82
+ log('bridge idle shutdown', { idleTimeoutMs });
83
+ intentionalStop = true;
84
+ killChild('idle-timeout');
85
+ state = 'idle';
86
+ bootPromise = null;
87
+ restartsUsed = 0;
88
+ }
89
+
90
+ function killChild() {
91
+ if (!child) return;
92
+ const target = child;
93
+ child = null;
94
+ try {
95
+ // npm shims spawn a node child on Windows; killing the shell alone
96
+ // orphans it. Fakes injected by tests have no pid and skip taskkill.
97
+ if (process.platform === 'win32' && Number.isInteger(target.pid)) {
98
+ realSpawn('taskkill', ['/pid', String(target.pid), '/T', '/F'], { windowsHide: true });
99
+ }
100
+ } catch { /* already gone */ }
101
+ try { target.kill(); } catch { /* already gone */ }
102
+ }
103
+
104
+ function failPending(error) {
105
+ for (const entry of pending.values()) {
106
+ clearTimeout(entry.timer);
107
+ entry.reject(error);
108
+ }
109
+ pending.clear();
110
+ }
111
+
112
+ function wire(childInstance) {
113
+ child = childInstance;
114
+ let buffer = '';
115
+ childInstance.stdout.setEncoding('utf8');
116
+ childInstance.stdout.on('data', chunk => {
117
+ buffer += chunk;
118
+ let index;
119
+ while ((index = buffer.indexOf('\n')) >= 0) {
120
+ const line = buffer.slice(0, index).trim();
121
+ buffer = buffer.slice(index + 1);
122
+ if (line) handleMessage(line);
123
+ }
124
+ });
125
+ childInstance.stderr.setEncoding && childInstance.stderr.setEncoding('utf8');
126
+ childInstance.stderr.on('data', chunk => {
127
+ const text = String(chunk).trim();
128
+ if (text) log(`bridge stderr: ${text.slice(0, 400)}`);
129
+ });
130
+ childInstance.on('error', error => {
131
+ log(`bridge child error: ${error.message}`);
132
+ handleExit(error);
133
+ });
134
+ childInstance.on('close', code => {
135
+ log(`bridge child exited code=${code}`);
136
+ handleExit(null);
137
+ });
138
+ }
139
+
140
+ function handleMessage(line) {
141
+ touchActivity();
142
+ let message;
143
+ try { message = JSON.parse(line); } catch {
144
+ log(`bridge discarded unparseable line: ${line.slice(0, 200)}`);
145
+ return;
146
+ }
147
+ if (!message || typeof message !== 'object') return;
148
+ if (message.method && message.id !== undefined && message.id !== null) {
149
+ handleServerRequest(message);
150
+ } else if (message.method) {
151
+ try { onEvent({ method: message.method, params: message.params }); } catch (error) {
152
+ log(`bridge onEvent handler failed for ${message.method}: ${error.message}`);
153
+ }
154
+ } else if (message.id !== undefined && message.id !== null) {
155
+ const entry = pending.get(message.id);
156
+ if (!entry) return; // response to an unknown/aborted request
157
+ pending.delete(message.id);
158
+ clearTimeout(entry.timer);
159
+ if (restartsUsed > 0) restartsUsed = 0;
160
+ if (message.error) entry.reject(rpcError(message.error));
161
+ else entry.resolve(message.result !== undefined ? message.result : null);
162
+ }
163
+ // anything else is ignored
164
+ }
165
+
166
+ async function handleServerRequest(message) {
167
+ const id = message.id;
168
+ // An unanswered server request (e.g. an approval) is bridge activity:
169
+ // it must keep the child alive and never be silently idled away.
170
+ inFlightServerRequests += 1;
171
+ try {
172
+ if (!onServerRequest) throw bridgeError(`no onServerRequest handler for ${message.method}`, 501, 'BRIDGE_UNSUPPORTED');
173
+ const result = await onServerRequest(message.method, message.params);
174
+ safeWrite({ id, result: result === undefined ? null : result });
175
+ } catch (error) {
176
+ try {
177
+ safeWrite({ id, error: { code: error.rpcCode || -32000, message: String(error && error.message || error).slice(0, 1000) } });
178
+ } catch { /* child already gone; nothing to answer */ }
179
+ } finally {
180
+ inFlightServerRequests -= 1;
181
+ touchActivity();
182
+ }
183
+ }
184
+
185
+ function handleExit(spawnError) {
186
+ if (!child) return;
187
+ const wasReady = state === 'ready';
188
+ child = null;
189
+ bootPromise = null;
190
+ clearTimeout(idleTimer);
191
+ if (closed || intentionalStop) return;
192
+ const error = spawnError
193
+ ? bridgeError(`bridge child failed: ${spawnError.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: spawnError })
194
+ : bridgeError('bridge child exited unexpectedly', 503, 'BRIDGE_CRASHED');
195
+ failPending(error);
196
+ try { onUnexpectedExit(error); } catch (hookError) { log(`bridge onUnexpectedExit hook failed: ${hookError.message}`); }
197
+ if (!wasReady) {
198
+ // The boot promise never resolved; fail it so start() callers see why.
199
+ state = 'idle';
200
+ if (bootSettle) {
201
+ const settle = bootSettle;
202
+ bootSettle = null;
203
+ settle.reject(spawnError
204
+ ? bridgeError(`bridge child failed to start: ${spawnError.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: spawnError })
205
+ : bridgeError('bridge child exited before becoming ready', 503, 'BRIDGE_SPAWN_FAILED'));
206
+ }
207
+ return;
208
+ }
209
+ if (restartsUsed < maxRestarts) {
210
+ const backoff = restartBackoffMs[Math.min(restartsUsed, restartBackoffMs.length - 1)];
211
+ restartsUsed += 1;
212
+ state = 'restarting';
213
+ log(`bridge crashed; restarting (${restartsUsed}/${maxRestarts}) in ${backoff}ms`);
214
+ bootPromise = new Promise((resolve, reject) => {
215
+ // Register the restart promise's settle so a close() during the
216
+ // backoff window cannot leave start() callers hanging forever.
217
+ bootSettle = { resolve, reject };
218
+ restartTimer = setTimeout(() => {
219
+ launchChild().then(resolve, reject);
220
+ }, backoff);
221
+ });
222
+ // This promise is held internally until someone calls start(); without
223
+ // a handler a close()-time rejection would surface as unhandled.
224
+ bootPromise.catch(() => {});
225
+ } else {
226
+ state = 'crashed';
227
+ log('bridge exhausted its restart budget');
228
+ }
229
+ }
230
+
231
+ function launchChild() {
232
+ state = 'starting';
233
+ return new Promise((resolve, reject) => {
234
+ bootSettle = { resolve, reject };
235
+ let instance;
236
+ let command = config.command;
237
+ let args = config.args || [];
238
+ try {
239
+ // Windows bare commands (npm shims, PATH lookups) don't spawn without
240
+ // shell:true; resolve to the exact executable like the CLI runner
241
+ // does. ENOENT here would permanently demote the driver to
242
+ // per-turn-spawn CLI mode, which is exactly the "every turn is slow"
243
+ // failure mode.
244
+ if (process.platform === 'win32' && config.resolveInvocation) {
245
+ const invocation = config.resolveInvocation(command, args);
246
+ command = invocation.command;
247
+ args = invocation.args;
248
+ }
249
+ instance = spawnFn(command, args, {
250
+ cwd: config.cwd,
251
+ windowsHide: true,
252
+ env: { ...process.env, ...(config.env || {}), ...knowledgeRootEnv() },
253
+ });
254
+ } catch (error) {
255
+ state = 'idle';
256
+ bootSettle = null;
257
+ reject(bridgeError(`failed to start bridge child: ${error.message}`, 503, 'BRIDGE_SPAWN_FAILED', { cause: error }));
258
+ return;
259
+ }
260
+ wire(instance);
261
+ const markReady = () => {
262
+ if (!child || closed) return;
263
+ bootSettle = null;
264
+ generation += 1;
265
+ state = 'ready';
266
+ intentionalStop = false;
267
+ touchActivity();
268
+ try { onReady(generation); } catch (error) { log(`bridge onReady hook failed: ${error.message}`); }
269
+ resolve();
270
+ };
271
+ if (instance.pid === undefined) {
272
+ // Fake children (EventEmitter stand-ins) never emit 'spawn'.
273
+ setImmediate(markReady);
274
+ } else {
275
+ // Real children signal readiness through 'spawn'; a fallback timer
276
+ // covers exotic platforms that are slow to emit it.
277
+ if (typeof instance.once === 'function') instance.once('spawn', markReady);
278
+ const fallback = setTimeout(markReady, 500);
279
+ fallback.unref && fallback.unref();
280
+ }
281
+ });
282
+ }
283
+
284
+ function start() {
285
+ if (closed) return Promise.reject(bridgeError('bridge is closed', 503, 'BRIDGE_CLOSED'));
286
+ if (state === 'ready' && bootPromise) return bootPromise;
287
+ if (state === 'crashed') return Promise.reject(bridgeError('bridge exhausted its restart budget', 503, 'BRIDGE_CRASHED'));
288
+ if (!bootPromise) bootPromise = launchChild();
289
+ return bootPromise;
290
+ }
291
+
292
+ function request(method, params, options = {}) {
293
+ if (closed) return Promise.reject(bridgeError('bridge is closed', 503, 'BRIDGE_CLOSED'));
294
+ if (state === 'crashed') return Promise.reject(bridgeError('bridge exhausted its restart budget', 503, 'BRIDGE_CRASHED'));
295
+ const timeoutMs = options.timeoutMs !== undefined ? options.timeoutMs : requestTimeoutMs;
296
+ return start().then(() => new Promise((resolve, reject) => {
297
+ const id = nextRequestId++;
298
+ const entry = { resolve, reject, timer: null };
299
+ if (timeoutMs > 0) {
300
+ entry.timer = setTimeout(() => {
301
+ pending.delete(id);
302
+ reject(bridgeError(`bridge request ${method} timed out after ${timeoutMs}ms`, 504, 'BRIDGE_TIMEOUT'));
303
+ }, timeoutMs);
304
+ entry.timer.unref && entry.timer.unref();
305
+ }
306
+ pending.set(id, entry);
307
+ try {
308
+ // The app-server dialect omits the jsonrpc field; we mirror that.
309
+ safeWrite(params !== undefined ? { id, method, params } : { id, method });
310
+ touchActivity();
311
+ } catch (error) {
312
+ pending.delete(id);
313
+ clearTimeout(entry.timer);
314
+ reject(error);
315
+ }
316
+ }));
317
+ }
318
+
319
+ function notify(method, params) {
320
+ if (closed) throw bridgeError('bridge is closed', 503, 'BRIDGE_CLOSED');
321
+ return start().then(() => {
322
+ safeWrite(params !== undefined ? { method, params } : { method });
323
+ touchActivity();
324
+ });
325
+ }
326
+
327
+ function close() {
328
+ if (closed) return Promise.resolve();
329
+ closed = true;
330
+ state = 'closed';
331
+ clearTimeout(restartTimer);
332
+ clearTimeout(idleTimer);
333
+ intentionalStop = true;
334
+ killChild();
335
+ failPending(bridgeError('bridge closed', 503, 'BRIDGE_CLOSED'));
336
+ // A close() while still starting (or in a restart backoff) must settle
337
+ // the boot promise, otherwise every awaiting start() caller hangs.
338
+ if (bootSettle) {
339
+ const settle = bootSettle;
340
+ bootSettle = null;
341
+ settle.reject(bridgeError('bridge closed', 503, 'BRIDGE_CLOSED'));
342
+ }
343
+ return Promise.resolve();
344
+ }
345
+
346
+ return {
347
+ command: config.command,
348
+ args: config.args || [],
349
+ env: config.env || {},
350
+ get state() { return state; },
351
+ get pendingCount() { return pending.size; },
352
+ get restartsUsed() { return restartsUsed; },
353
+ get generation() { return generation; },
354
+ start,
355
+ request,
356
+ notify,
357
+ close,
358
+ };
359
+ }
360
+
361
+ function bridgeError(message, status, code, extra = {}) {
362
+ return Object.assign(new Error(message), { status, code, ...extra });
363
+ }
364
+
365
+ function rpcError(rpc) {
366
+ return Object.assign(new Error(String(rpc && rpc.message || 'bridge rpc error').slice(0, 1000)), {
367
+ status: 502,
368
+ code: 'BRIDGE_RPC_ERROR',
369
+ rpcCode: rpc && rpc.code,
370
+ });
371
+ }
372
+
373
+ module.exports = { createAppServerBridge };