@yemi33/minions 0.1.2371 → 0.1.2373

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.
@@ -21,18 +21,19 @@
21
21
  *
22
22
  * Internal model (one process per tab):
23
23
  *
24
- * tabs: Map<tabId, Worker> where Worker = {
25
- * proc, sessionId, model, effort, mcpServers, mcpServersHash,
26
- * systemPromptHash, cwd, lastUsedAt,
27
- * pending: Map<id, { resolve, reject }>, inflight, readBuf, nextReqId
28
- * }
24
+ * tabs: Map<tabId, Worker> Worker (engine/acp-transport.js) owns the
25
+ * spawn + JSON-RPC framing; this module owns per-tab lifecycle policy:
26
+ * eviction rules, idle reaping, and the warm-prewarm FIFO queue.
29
27
  *
30
28
  * TODO (sub-task C/D follow-up): multiplex N tabs onto one ACP process
31
29
  * (`session/new` per tab, single `proc`). ACP supports it; left as a
32
30
  * follow-up perf win once one-process-per-tab is proven in the dashboard.
33
31
  *
34
32
  * Eviction rules:
35
- * - mcpServers changes → kill proc entirely + respawn
33
+ * - mcpServers changes → kill proc entirely + respawn (confirmed
34
+ * necessary for LOCAL/stdio MCP servers, which
35
+ * are resolved from $COPILOT_HOME/mcp-config.json
36
+ * at process boot — see P-7a1c2e4f spike, (c)).
36
37
  * - systemPromptHash change → keep proc, send a fresh `session/new`
37
38
  * - model / effort change → no eviction (state updated in place)
38
39
  * - closeTab → `session/cancel` if inflight, then kill proc
@@ -49,54 +50,37 @@
49
50
  * is complete (...)"
50
51
  * so the dashboard can surface a precise reason instead of a generic timeout.
51
52
  *
52
- * No new npm deps — Node built-ins only (child_process, events, timers, crypto).
53
+ * No new npm deps — Node built-ins only (events, timers).
53
54
  *
54
55
  * See `notes/archive/2026-05-13-ripley-W-mp3h2wq2000e64f7-2026-05-13-0301.md`
55
56
  * for the ACP protocol probe (line framing, request/response correlation,
56
- * cold-vs-warm timings, cancel semantics, configOptions surface).
57
+ * cold-vs-warm timings, cancel semantics, configOptions surface) and
58
+ * `notes/archive/2026-07-10-ripley-P-7a1c2e4f-2026-07-10-0141.md` for the
59
+ * follow-up spike confirming cancel-then-reuse is safe and the `stopReason`
60
+ * on a cancelled turn is `end_turn` (not `cancelled`) on copilot CLI 1.0.70.
57
61
  */
58
62
 
59
- const { spawn } = require('child_process');
60
- const crypto = require('crypto');
61
-
62
- // W-mpmwxni2000c25c7-c — typed error codes the pool emits through every
63
- // failure exit so the consumer (CC streaming handler / doc-chat pool
64
- // wrapper / SSE writer) can render a structured error envelope instead of
65
- // parsing the stderr string. Matches the `{ message, code, retriable }`
66
- // shape sub-item b standardized on for the dashboard's SSE envelope and
67
- // the runtime adapter parseError() contract (engine/runtimes/*.js).
68
- const ERROR_CODES = Object.freeze({
69
- // spawn() threw synchronously OR the child process emitted an 'error'
70
- // event (binary missing on PATH, exec failed, EPERM, etc.). Retriable
71
- // because a transient PATH / fs glitch may recover.
72
- WORKER_SPAWN_FAILED: 'worker-spawn-failed',
73
- // The worker process exited DURING the ACP handshake (initialize or
74
- // session/new) — usually `copilot login` is incomplete or the CLI
75
- // version is too old. Also fires when session/new returns no
76
- // sessionId. Retriable: the engine swaps to a fallback model / a re-auth
77
- // may unblock the next attempt.
78
- ACP_HANDSHAKE_FAILED: 'acp-handshake-failed',
79
- // The worker process exited AFTER a successful handshake (the daemon
80
- // died mid-turn). Retriable — the next call cold-spawns a fresh worker.
81
- WORKER_DIED: 'worker-died',
82
- // The consumer's per-turn timeout fired before the ACP session/prompt
83
- // resolved. Owned by the dashboard pool wrappers (cc-worker-pool itself
84
- // has no turn timeout) but exported here so all callers stringify the
85
- // same constant. Retriable — most timeouts are transient.
86
- CC_TURN_TIMEOUT: 'cc-turn-timeout',
87
- });
63
+ const transport = require('./acp-transport');
64
+ const {
65
+ ERROR_CODES,
66
+ typedError: _typedError,
67
+ hashMcpServers: _hashMcpServers,
68
+ buildSessionNewParams: _buildSessionNewParams,
69
+ Worker,
70
+ WARM_MAX_CONCURRENT,
71
+ } = transport;
88
72
 
89
- // Build a typed Error carrying the `{ message, code, retriable }` envelope
90
- // fields the consumer expects. Plain Errors flow through unchanged; the
91
- // helper only stamps the extra metadata. Keep retriable defaulting to
92
- // `true` so a caller that forgets to set it still gets the safe default
93
- // (the legacy pre-typed-error code path treated every failure as retriable).
94
- function _typedError(message, code, retriable = true) {
95
- const err = new Error(message);
96
- err.code = code;
97
- err.retriable = retriable;
98
- return err;
99
- }
73
+ // Test seam every external side effect goes through `_internals` so
74
+ // test/unit/cc-worker-pool.test.js can stub spawn/now/killImmediate. This
75
+ // object is passed to each Worker constructed by this pool (constructor
76
+ // injection, engine/acp-transport.js#Worker) rather than the transport
77
+ // reaching for a module-level singleton that keeps this pool's test stubs
78
+ // isolated from engine/agent-worker-pool.js's own `_internals`.
79
+ const _internals = {
80
+ spawnAcp: transport.spawnAcp,
81
+ killImmediate: transport.killImmediate,
82
+ now() { return Date.now(); },
83
+ };
100
84
 
101
85
  // 30 minutes default — matches ENGINE_DEFAULTS.ccWorkerIdleTimeoutMs. This is
102
86
  // now a runtime-configurable knob (W-mr0qs0vw): the pool stays dependency-free
@@ -109,56 +93,6 @@ let _idleReaperMs = DEFAULT_IDLE_REAPER_MS;
109
93
  // Reaper sweep cadence. Not exposed as ENGINE_DEFAULTS to keep the pool
110
94
  // dependency-free; sub-task C/D can plumb a config knob if needed.
111
95
  const REAPER_INTERVAL_MS = 60 * 1000;
112
- // Cap concurrent warm spawns triggered by tab/modal open. Without a cap, a
113
- // user spamming new tabs would fan out N parallel cold spawns; with one, the
114
- // excess waits in FIFO order while the first few warm. The actual user-driven
115
- // getSession() path is NOT throttled — only the pre-warm path is.
116
- const WARM_MAX_CONCURRENT = 3;
117
-
118
- // Test seam — every external side effect goes through `_internals` so
119
- // test/unit/cc-worker-pool.test.js can stub spawn/now/killImmediate.
120
- const _internals = {
121
- spawnAcp({ cwd } = {}) {
122
- // Resolve `copilot` through the adapter so the pool gets the SAME
123
- // native/leadingArgs contract as the only other spawn site
124
- // (engine/spawn-agent.js ~line 595). A bare `spawn('copilot', {shell:false})`
125
- // cannot exec a `.cmd`/`.ps1`/extensionless npm shim on Windows — it ENOENTs
126
- // instantly (copilot issue #2370 / W-mqid8uev). resolveBinary() resolves the
127
- // real JS entry (node_modules/@github/copilot/index.js) and reports whether
128
- // to spawn it directly (native) or under the current Node process (shim).
129
- const copilot = require('./runtimes/copilot');
130
- const resolved = copilot.resolveBinary({ env: process.env });
131
- if (!resolved || !resolved.bin) {
132
- throw new Error(
133
- `copilot binary not resolvable -- ${copilot.installHint || 'install the GitHub Copilot CLI'}`
134
- );
135
- }
136
- const { bin, native, leadingArgs = [] } = resolved;
137
- const acpArgs = [...leadingArgs, '--acp', '--allow-all', '--max-autopilot-continues', '1'];
138
- // Mirror engine/spawn-agent.js: native binaries run directly; a non-native
139
- // (JS entry / npm shim) runs under process.execPath as `node <bin> ...`.
140
- const execBin = native ? bin : process.execPath;
141
- const execArgs = native ? acpArgs : [bin, ...acpArgs];
142
- return spawn(execBin, execArgs, {
143
- stdio: ['pipe', 'pipe', 'pipe'],
144
- cwd: cwd || process.cwd(),
145
- windowsHide: true,
146
- // Don't pass shell:true — the native/leadingArgs handling above already
147
- // produces a directly-spawnable argv, and a shell wrapper would swallow
148
- // the stdin/stdout JSON-RPC framing.
149
- });
150
- },
151
- killImmediate(proc) {
152
- // Lazy require so unit tests that don't load engine/shared still work.
153
- try {
154
- const shared = require('./shared');
155
- shared.killImmediate(proc);
156
- } catch {
157
- try { proc.kill('SIGKILL'); } catch { /* already dead */ }
158
- }
159
- },
160
- now() { return Date.now(); },
161
- };
162
96
 
163
97
  const _tabs = new Map();
164
98
  let _reaperTimer = null;
@@ -207,9 +141,7 @@ function consumeIdleReapNotice(tabId) {
207
141
  // CC_POOL_TRACE-gated structured trace logger. Off by default; enable via
208
142
  // `CC_POOL_TRACE=1 minions restart` to dump every getSession lifecycle
209
143
  // transition, stream sessionId capture, and session/update notification
210
- // match/mismatch to stderr. Added for W-mpdavudb000v8446 follow-up so the
211
- // next investigation cycle can correlate engine state with the user-perceived
212
- // first-message hang. NO PII — only tabId (caller-supplied), sessionIds
144
+ // match/mismatch to stderr. NO PII only tabId (caller-supplied), sessionIds
213
145
  // (opaque ACP ids), and protocol flags. Safe to leave on in dev/staging.
214
146
  function _trace(...parts) {
215
147
  if (!process.env.CC_POOL_TRACE) return;
@@ -217,466 +149,6 @@ function _trace(...parts) {
217
149
  catch { /* swallow telemetry errors */ }
218
150
  }
219
151
 
220
- function _hashMcpServers(mcpServers) {
221
- // Stable hash via JSON.stringify; mcpServers is an array of plain objects
222
- // in practice (name/command/env) so the natural key order is fine.
223
- const json = mcpServers == null ? '[]' : JSON.stringify(mcpServers);
224
- return crypto.createHash('sha256').update(json).digest('hex');
225
- }
226
-
227
- // Bug B (issue #2479): `model` and `effort` were stored on the Worker but
228
- // never forwarded to ACP `session/new`, so any `ccModel` override was silently
229
- // dropped on the pool path. Build the params object here and only include
230
- // fields that are defined — sending `model: undefined` would force the daemon
231
- // onto its built-in default instead of letting it pick whatever the user has
232
- // configured globally.
233
- function _buildSessionNewParams({ cwd, mcpServers, model, effort }) {
234
- const params = { cwd, mcpServers: mcpServers || [] };
235
- if (model !== undefined && model !== null && model !== '') params.model = model;
236
- if (effort !== undefined && effort !== null && effort !== '') params.effort = effort;
237
- return params;
238
- }
239
-
240
- class Worker {
241
- constructor({ tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd }) {
242
- this.tabId = tabId;
243
- this.model = model;
244
- this.effort = effort;
245
- this.mcpServers = mcpServers || [];
246
- this.mcpServersHash = mcpServersHash;
247
- this.systemPromptHash = systemPromptHash;
248
- this.cwd = cwd || process.cwd();
249
-
250
- this.proc = null;
251
- this.sessionId = null;
252
- this.pending = new Map(); // id → { resolve, reject }
253
- this.inflight = null; // current { id, sessionId, onChunk, onDone, onError, signal, signalHandler, settled }
254
- this.readBuf = '';
255
- this.nextReqId = 1;
256
- this.lastUsedAt = _internals.now();
257
- this.killed = false;
258
- this.spawnError = null;
259
- this.firstSystemPromptSent = false;
260
- // In-flight spawn+initialize+session/new promise. Set by getSession()
261
- // before the worker is registered in _tabs, cleared after the handshake
262
- // settles. Racing getSession() callers await this to avoid the
263
- // "warm-reuse path returns sessionId=null while init is still pending"
264
- // hang on first message of a freshly-warmed tab (W-mpd45blx00072f04).
265
- //
266
- // Follow-up investigation (W-mpdavudb000v8446) verified the post-ab141995
267
- // engine path holds the necessary invariants (see
268
- // test/unit/cc-worker-pool-fresh-tab-race.test.js):
269
- // * after `await worker.initPromise`, worker.sessionId is the real id
270
- // * Worker.stream sets inflight.sessionId to that same real id
271
- // * session/prompt is written with sessionId === inflight.sessionId
272
- // When the symptom recurs (intermittent first-message hang despite the
273
- // fix), it's almost certainly downstream of the pool — SSE delivery,
274
- // browser-side render, or telemetry overstating delivery. Set
275
- // `CC_POOL_TRACE=1` to dump every state transition + sessionId snapshot
276
- // through the pool to stderr so the next investigation can correlate
277
- // engine state with the user-perceived hang.
278
- this.initPromise = null;
279
- }
280
-
281
- // ── Spawn + initialize handshake ────────────────────────────────────────
282
- async _spawnAndInit() {
283
- let proc;
284
- try {
285
- proc = _internals.spawnAcp({ cwd: this.cwd });
286
- } catch (err) {
287
- // spawn() threw synchronously — typically ENOENT (copilot binary not
288
- // on PATH) or EACCES. Surface as worker-spawn-failed so the consumer
289
- // can show "install the CLI / fix PATH" guidance.
290
- throw _typedError(
291
- `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
292
- ERROR_CODES.WORKER_SPAWN_FAILED,
293
- true
294
- );
295
- }
296
- this.proc = proc;
297
-
298
- proc.stdout.on('data', (chunk) => this._onStdout(chunk));
299
- // stderr is captured for diagnostics but we don't act on it.
300
- if (proc.stderr) proc.stderr.on('data', () => { /* ignore */ });
301
-
302
- // Race the init handshake against an early process exit so an auth
303
- // failure (which kills the daemon before initialize completes) surfaces
304
- // a clear error instead of hanging forever.
305
- let earlyExitReject;
306
- const earlyExitPromise = new Promise((_, reject) => {
307
- earlyExitReject = (code) => {
308
- this.killed = true;
309
- // Early exit DURING the handshake = acp-handshake-failed (almost
310
- // always missing `copilot login`, stale CLI, or daemon crash on
311
- // boot). Retriable so re-auth or a CLI upgrade can recover.
312
- const err = _typedError(
313
- `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (exit ${code})`,
314
- ERROR_CODES.ACP_HANDSHAKE_FAILED,
315
- true
316
- );
317
- this.spawnError = err;
318
- this._failAllPending(err);
319
- reject(err);
320
- };
321
- });
322
- const earlyExitHandler = (code) => earlyExitReject(code);
323
- proc.once('exit', earlyExitHandler);
324
-
325
- const errorHandler = (err) => {
326
- // proc 'error' event fires when the OS can't actually start the child
327
- // (ENOENT after a successful spawn() call, etc.). Treat as a spawn
328
- // failure even though we made it past the synchronous spawn() above.
329
- const wrapped = _typedError(
330
- `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
331
- ERROR_CODES.WORKER_SPAWN_FAILED,
332
- true
333
- );
334
- this.spawnError = wrapped;
335
- this.killed = true;
336
- this._failAllPending(wrapped);
337
- };
338
- proc.on('error', errorHandler);
339
-
340
- try {
341
- await Promise.race([
342
- this._call('initialize', { protocolVersion: 1, clientCapabilities: {} }),
343
- earlyExitPromise,
344
- ]);
345
- const result = await Promise.race([
346
- this._call('session/new', _buildSessionNewParams({
347
- cwd: this.cwd, mcpServers: this.mcpServers, model: this.model, effort: this.effort,
348
- })),
349
- earlyExitPromise,
350
- ]);
351
- this.sessionId = result && result.sessionId;
352
- if (!this.sessionId) {
353
- // Handshake completed without an error but the daemon didn't hand
354
- // back a sessionId — protocol violation or partial init failure.
355
- throw _typedError(
356
- 'copilot --acp failed -- session/new returned no sessionId',
357
- ERROR_CODES.ACP_HANDSHAKE_FAILED,
358
- true
359
- );
360
- }
361
- } finally {
362
- // Either the handshake finished (swap to a persistent exit handler that
363
- // just marks killed) or it failed (proc is dying anyway).
364
- proc.removeListener('exit', earlyExitHandler);
365
- }
366
- proc.on('exit', () => {
367
- this.killed = true;
368
- // Post-handshake exit = the daemon died mid-conversation. Retriable
369
- // because the next call will cold-spawn a fresh worker.
370
- const err = _typedError(
371
- 'copilot --acp process exited',
372
- ERROR_CODES.WORKER_DIED,
373
- true
374
- );
375
- this._failAllPending(err);
376
- // Settle inflight too if it's still hanging
377
- if (this.inflight && !this.inflight.settled) {
378
- const cb = this.inflight.onError;
379
- this.inflight.settled = true;
380
- this.inflight = null;
381
- if (cb) try { cb(err); } catch { /* swallow */ }
382
- }
383
- });
384
- }
385
-
386
- _failAllPending(err) {
387
- for (const { reject } of this.pending.values()) {
388
- try { reject(err); } catch { /* swallow */ }
389
- }
390
- this.pending.clear();
391
- }
392
-
393
- // ── Newline-delimited JSON-RPC line framing ────────────────────────────
394
- _onStdout(chunk) {
395
- this.readBuf += chunk.toString('utf8');
396
- let i;
397
- while ((i = this.readBuf.indexOf('\n')) >= 0) {
398
- const line = this.readBuf.slice(0, i).trim();
399
- this.readBuf = this.readBuf.slice(i + 1);
400
- if (!line) continue;
401
- let obj;
402
- try { obj = JSON.parse(line); } catch { continue; }
403
- this._handleMessage(obj);
404
- }
405
- }
406
-
407
- _handleMessage(obj) {
408
- // Response (matches an outbound id)
409
- if (obj.id != null && this.pending.has(obj.id)) {
410
- const { resolve, reject } = this.pending.get(obj.id);
411
- this.pending.delete(obj.id);
412
- if (obj.error) reject(new Error(obj.error.message || 'ACP error'));
413
- else resolve(obj.result);
414
- return;
415
- }
416
- // Notification (no id) — only `session/update` matters for streaming.
417
- if (obj.method === 'session/update' && obj.params) {
418
- // Trace EVERY session/update notification, including drops — this is
419
- // exactly where the W-mpd45blx00072f04 hang manifested (chunks dropped
420
- // because inflight.sessionId was null). Logging both the notification
421
- // sid and the inflight sid lets the next investigation cycle prove
422
- // whether the engine still drops chunks. (W-mpdavudb000v8446)
423
- const notifSid = obj.params.sessionId;
424
- const inflightSid = this.inflight ? this.inflight.sessionId : null;
425
- const updKind = obj.params.update && obj.params.update.sessionUpdate;
426
- if (!this.inflight) {
427
- _trace(`tab=${this.tabId} session/update dropped: no inflight (notifSid=${notifSid} kind=${updKind})`);
428
- return;
429
- }
430
- if (notifSid !== inflightSid) {
431
- _trace(`tab=${this.tabId} session/update dropped: sid mismatch (notifSid=${notifSid} inflightSid=${inflightSid} kind=${updKind})`);
432
- return;
433
- }
434
- _trace(`tab=${this.tabId} session/update delivered: sid=${notifSid} kind=${updKind}`);
435
- const update = obj.params.update;
436
- if (!update) return;
437
- if (update.sessionUpdate === 'agent_message_chunk') {
438
- const text = _extractChunkText(update.content);
439
- if (text && this.inflight.onChunk) {
440
- try { this.inflight.onChunk(text); } catch { /* swallow */ }
441
- }
442
- } else if (update.sessionUpdate === 'tool_call' && this.inflight.onToolUse) {
443
- // ACP `tool_call` (pending) → chip label via _mapAcpToolCallToToolUse.
444
- const mapped = _mapAcpToolCallToToolUse(update);
445
- if (mapped) {
446
- try { this.inflight.onToolUse(mapped.name, mapped.input, update.toolCallId); }
447
- catch { /* swallow */ }
448
- }
449
- } else if (update.sessionUpdate === 'tool_call_update' && this.inflight.onToolUpdate) {
450
- // Terminal-state updates only (completed/failed). In-progress updates
451
- // exist in the ACP spec but add chip churn without actionable info.
452
- if (update.status === 'completed' || update.status === 'failed') {
453
- try { this.inflight.onToolUpdate(update.toolCallId, update.status); }
454
- catch { /* swallow */ }
455
- }
456
- }
457
- }
458
- }
459
-
460
- _writeFrame(obj) {
461
- if (this.killed) return;
462
- if (!this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
463
- try { this.proc.stdin.write(JSON.stringify(obj) + '\n'); }
464
- catch { /* pipe may be broken; exit handler will fire */ }
465
- }
466
-
467
- _call(method, params) {
468
- return new Promise((resolve, reject) => {
469
- const id = this.nextReqId++;
470
- this.pending.set(id, { resolve, reject });
471
- this._writeFrame({ jsonrpc: '2.0', id, method, params });
472
- });
473
- }
474
-
475
- _notify(method, params) {
476
- this._writeFrame({ jsonrpc: '2.0', method, params });
477
- }
478
-
479
- // ── Stream a single turn ───────────────────────────────────────────────
480
- stream(promptText, opts = {}) {
481
- const { onChunk, onToolUse, onToolUpdate, onDone, onError, signal, systemPromptText } = opts;
482
- if (this.killed) {
483
- const err = new Error('cc-worker-pool: tab is closed');
484
- if (onError) try { onError(err); } catch { /* swallow */ }
485
- return Promise.resolve();
486
- }
487
- if (this.inflight) {
488
- const err = new Error('cc-worker-pool: a prompt is already in flight on this tab');
489
- if (onError) try { onError(err); } catch { /* swallow */ }
490
- return Promise.resolve();
491
- }
492
- this.lastUsedAt = _internals.now();
493
-
494
- // Inject the <system> block on the first prompt of a session — Copilot
495
- // ACP has no in-protocol "set system prompt" method (per Ripley's note),
496
- // so the per-session adapter pattern of <system>...</system> prepended
497
- // to the first user message remains correct.
498
- let prompt = promptText;
499
- if (!this.firstSystemPromptSent && systemPromptText) {
500
- prompt = `<system>\n${systemPromptText}\n</system>\n\n${promptText}`;
501
- }
502
- this.firstSystemPromptSent = true;
503
-
504
- const id = this.nextReqId++;
505
- const inflight = {
506
- id,
507
- sessionId: this.sessionId,
508
- onChunk,
509
- onToolUse,
510
- onToolUpdate,
511
- onDone,
512
- onError,
513
- signal,
514
- signalHandler: null,
515
- settled: false,
516
- };
517
- this.inflight = inflight;
518
- // W-mpdavudb000v8446 — trace the sessionId captured by inflight at the
519
- // exact moment Worker.stream commits to a write. inflight.sessionId is
520
- // the value session/update notifications must match against in
521
- // _handleMessage; if it's ever null, every chunk for this turn is silently
522
- // dropped (the ab141995 hang signature). Pair with the [cc-pool] dispatch
523
- // log on the dashboard side to correlate engine state with user-perceived
524
- // delivery.
525
- _trace(`tab=${this.tabId} stream begin: worker.sessionId=${this.sessionId} inflight.sessionId=${inflight.sessionId} reqId=${id}`);
526
-
527
- if (signal && typeof signal.addEventListener === 'function') {
528
- inflight.signalHandler = () => this.cancel();
529
- try { signal.addEventListener('abort', inflight.signalHandler); } catch { /* swallow */ }
530
- }
531
-
532
- return new Promise((resolve) => {
533
- const finalize = (err, result) => {
534
- if (inflight.settled) return;
535
- inflight.settled = true;
536
- if (this.inflight === inflight) this.inflight = null;
537
- this.lastUsedAt = _internals.now();
538
- if (signal && inflight.signalHandler) {
539
- try { signal.removeEventListener('abort', inflight.signalHandler); }
540
- catch { /* swallow */ }
541
- }
542
- if (err) {
543
- if (onError) try { onError(err); } catch { /* swallow */ }
544
- } else {
545
- if (onDone) try { onDone(result); } catch { /* swallow */ }
546
- }
547
- resolve();
548
- };
549
-
550
- this.pending.set(id, {
551
- resolve: (result) => finalize(null, result),
552
- reject: (err) => finalize(err, null),
553
- });
554
- this._writeFrame({
555
- jsonrpc: '2.0',
556
- id,
557
- method: 'session/prompt',
558
- params: { sessionId: this.sessionId, prompt: [{ type: 'text', text: prompt }] },
559
- });
560
- });
561
- }
562
-
563
- cancel() {
564
- if (!this.inflight || this.killed) return;
565
- // session/cancel is a JSON-RPC notification (no id). The in-flight
566
- // session/prompt response will arrive with stopReason=cancelled per the
567
- // ACP spec — we don't synthesize a fake response client-side.
568
- this._notify('session/cancel', { sessionId: this.inflight.sessionId });
569
- }
570
-
571
- async newSession({ mcpServers, systemPromptHash, model, effort }) {
572
- // Cancel any inflight before swapping the underlying session.
573
- if (this.inflight) {
574
- this.cancel();
575
- // Wait briefly for the cancelled response to settle so we don't leak
576
- // a stale inflight reference into the new session.
577
- const inflight = this.inflight;
578
- await new Promise((resolve) => {
579
- const start = _internals.now();
580
- const wait = () => {
581
- if (!inflight || inflight.settled || _internals.now() - start > 1000) resolve();
582
- else setTimeout(wait, 5);
583
- };
584
- wait();
585
- });
586
- }
587
- // Bug B (issue #2479): if the caller is rotating the session because the
588
- // system prompt changed, they may also be passing a fresh model/effort —
589
- // update bookkeeping BEFORE session/new so the new fields land on the
590
- // daemon. Falls through to whatever we already had when callers omit them.
591
- if (model !== undefined) this.model = model;
592
- if (effort !== undefined) this.effort = effort;
593
- const result = await this._call('session/new', _buildSessionNewParams({
594
- cwd: this.cwd, mcpServers: mcpServers || [], model: this.model, effort: this.effort,
595
- }));
596
- this.sessionId = result && result.sessionId;
597
- this.systemPromptHash = systemPromptHash;
598
- this.firstSystemPromptSent = false;
599
- }
600
-
601
- close() {
602
- if (this.killed) return;
603
- this.killed = true;
604
- // Best-effort cancel of an inflight prompt so the daemon doesn't keep
605
- // generating into a torn-down session before we kill the proc.
606
- if (this.inflight) {
607
- try { this._notify('session/cancel', { sessionId: this.inflight.sessionId }); }
608
- catch { /* swallow */ }
609
- }
610
- if (this.proc) {
611
- try { _internals.killImmediate(this.proc); } catch { /* already dead */ }
612
- }
613
- this._failAllPending(new Error('cc-worker-pool: worker closed'));
614
- if (this.inflight && !this.inflight.settled) {
615
- const cb = this.inflight.onError;
616
- this.inflight.settled = true;
617
- this.inflight = null;
618
- if (cb) try { cb(new Error('cc-worker-pool: worker closed')); } catch { /* swallow */ }
619
- }
620
- }
621
- }
622
-
623
- function _extractChunkText(content) {
624
- if (content == null) return '';
625
- if (typeof content === 'string') return content;
626
- if (Array.isArray(content)) {
627
- let out = '';
628
- for (const block of content) {
629
- if (block && typeof block.text === 'string') out += block.text;
630
- }
631
- return out;
632
- }
633
- if (typeof content === 'object' && typeof content.text === 'string') return content.text;
634
- return '';
635
- }
636
-
637
- // Map an ACP `tool_call` session/update notification to the {name, input} shape
638
- // the dashboard's formatToolSummary already understands. ACP's `kind` is a
639
- // coarse category (execute|read|edit|search|fetch|think|other); we translate to
640
- // the closest Claude tool name so the existing chip formatters keep working
641
- // (Bash → "$ <cmd>", Read → "Reading <path>", etc.). Unknown kinds fall back
642
- // to ACP's human-readable `title` with the raw input attached, which renders
643
- // through the default `<title>(<key>: <val>)` formatter.
644
- // Map an ACP `tool_call` notification to a chip label. Strategy:
645
- //
646
- // 1. Detect well-known shapes by rawInput field presence and route through
647
- // formatToolSummary's Claude-tool formatters (Bash → "$ <cmd>",
648
- // Grep → "Searching `pat` in path", Read → "Reading <path>"). Preserves
649
- // the granular detail you actually want when reading the chip list.
650
- //
651
- // 2. Fall back to ACP's human-curated `title` for anything that doesn't
652
- // match. Title is always populated and immune to field-name drift, so
653
- // the empty-`$` failure mode (kind:execute with no `command`) becomes
654
- // a clean "Find work item W-..." chip instead of a broken stub.
655
- //
656
- // Kind-based routing is intentionally NOT used — ACP overloads `kind:read`
657
- // for both file-view and grep, and `kind:execute` sometimes arrives without
658
- // a `command`. Field-detection on rawInput is more reliable.
659
- function _mapAcpToolCallToToolUse(update) {
660
- if (!update || update.sessionUpdate !== 'tool_call') return null;
661
- const rawInput = (update.rawInput && typeof update.rawInput === 'object') ? update.rawInput : {};
662
- const title = update.title || update.kind || 'Tool';
663
-
664
- if (typeof rawInput.command === 'string' && rawInput.command) {
665
- return { name: 'Bash', input: { command: rawInput.command } };
666
- }
667
- if (typeof rawInput.pattern === 'string' && rawInput.pattern) {
668
- return { name: 'Grep', input: { pattern: rawInput.pattern, path: rawInput.paths || rawInput.path || '.' } };
669
- }
670
- if (typeof rawInput.path === 'string' && rawInput.path) {
671
- const isEdit = String(update.kind || '').toLowerCase() === 'edit';
672
- return { name: isEdit ? 'Edit' : 'Read', input: { file_path: rawInput.path } };
673
- }
674
- if (typeof rawInput.url === 'string' && rawInput.url) {
675
- return { name: 'WebFetch', input: { url: rawInput.url } };
676
- }
677
- return { name: title, input: {} };
678
- }
679
-
680
152
  // ── Public API ────────────────────────────────────────────────────────────
681
153
 
682
154
  async function getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd } = {}) {
@@ -750,7 +222,8 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
750
222
 
751
223
  if (!worker) {
752
224
  worker = new Worker({
753
- tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd,
225
+ id: tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd,
226
+ internals: _internals, trace: _trace,
754
227
  });
755
228
  _tabs.set(tabId, worker);
756
229
  // Set initPromise BEFORE awaiting so concurrent getSession() callers
@@ -923,7 +396,9 @@ module.exports = {
923
396
  // W-mpmwxni2000c25c7-c — typed-error envelope contract. Exported so the
924
397
  // dashboard pool wrappers (and their tests) reference the same string
925
398
  // constants and so the doc-chat timeout path can stamp the same
926
- // `{ message, code, retriable }` shape the pool itself emits.
399
+ // `{ message, code, retriable }` shape the pool itself emits. Re-exported
400
+ // from engine/acp-transport.js (P-4c6e8a72) — this pool no longer defines
401
+ // its own copy.
927
402
  ERROR_CODES,
928
403
  _typedError,
929
404
  };