@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,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 };