@yemi33/minions 0.1.2372 → 0.1.2374

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.
@@ -0,0 +1,594 @@
1
+ /**
2
+ * engine/acp-transport.js — Shared ACP (Agent Client Protocol) transport layer
3
+ * (P-4c6e8a72, extracted from engine/cc-worker-pool.js).
4
+ *
5
+ * `copilot --acp` is a long-lived JSON-RPC server over stdin/stdout. This
6
+ * module owns the protocol-level plumbing that is identical regardless of
7
+ * WHO is pooling the worker (a CC/doc-chat tab vs. a fleet agent dispatch):
8
+ *
9
+ * - process spawn (`spawnAcp`) — resolves the `copilot` binary via the
10
+ * runtime adapter and launches it with `--acp`.
11
+ * - newline-delimited JSON-RPC framing, request/response correlation, and
12
+ * `session/update` notification dispatch (the `Worker` class).
13
+ * - typed error envelope (`ERROR_CODES` / `typedError`).
14
+ * - ACP `session/update` content helpers (`extractChunkText`,
15
+ * `mapAcpToolCallToToolUse`) and the `session/new` params builder
16
+ * (`buildSessionNewParams`).
17
+ *
18
+ * Consumers (engine/cc-worker-pool.js — one persistent worker per CC/doc-chat
19
+ * tab, permanent ownership; engine/agent-worker-pool.js — a fixed-size pool of
20
+ * workers leased/released per fleet agent dispatch) own their OWN pooling
21
+ * policy (eviction rules, idle reaping, FIFO acquire queues, mcpServersHash
22
+ * matching strategy) on top of this shared transport. Neither consumer
23
+ * duplicates the JSON-RPC framing code — both `require('./acp-transport')`.
24
+ *
25
+ * `Worker` takes its OS-process side effects (`spawnAcp`, `killImmediate`,
26
+ * `now`) via constructor-injected `internals` rather than a module-level
27
+ * singleton, so two independent pools (CC's tab pool and the fleet's agent
28
+ * pool) can each stub their own `_internals` for tests without stepping on
29
+ * each other's module state.
30
+ *
31
+ * See `notes/archive/2026-05-13-ripley-W-mp3h2wq2000e64f7-2026-05-13-0301.md`
32
+ * for the original ACP protocol probe (line framing, request/response
33
+ * correlation, cold-vs-warm timings, cancel semantics, configOptions surface)
34
+ * and `notes/archive/2026-07-10-ripley-P-7a1c2e4f-2026-07-10-0141.md` for the
35
+ * follow-up spike confirming: cancel is safe to reuse (no eviction needed
36
+ * purely for a cancel), `stopReason` is `end_turn` even after a cancel on
37
+ * copilot CLI 1.0.70 (NOT `cancelled`), and `session/new`'s `mcpServers`
38
+ * param only accepts remote http/sse servers — local/stdio MCP servers are
39
+ * resolved from `$COPILOT_HOME/mcp-config.json` at process boot, so a
40
+ * changed local-server set requires a full respawn (not just a fresh
41
+ * `session/new`).
42
+ *
43
+ * No new npm deps — Node built-ins only (child_process, crypto).
44
+ */
45
+
46
+ const { spawn } = require('child_process');
47
+ const crypto = require('crypto');
48
+
49
+ // W-mpmwxni2000c25c7-c — typed error codes the transport emits through every
50
+ // failure exit so a consumer (CC streaming handler / doc-chat pool wrapper /
51
+ // fleet agent-worker-pool / SSE writer) can render a structured error
52
+ // envelope instead of parsing the stderr string.
53
+ const ERROR_CODES = Object.freeze({
54
+ // spawn() threw synchronously OR the child process emitted an 'error'
55
+ // event (binary missing on PATH, exec failed, EPERM, etc.). Retriable
56
+ // because a transient PATH / fs glitch may recover.
57
+ WORKER_SPAWN_FAILED: 'worker-spawn-failed',
58
+ // The worker process exited DURING the ACP handshake (initialize or
59
+ // session/new) — usually `copilot login` is incomplete or the CLI
60
+ // version is too old. Also fires when session/new returns no
61
+ // sessionId. Retriable: the caller swaps to a fallback model / a re-auth
62
+ // may unblock the next attempt.
63
+ ACP_HANDSHAKE_FAILED: 'acp-handshake-failed',
64
+ // The worker process exited AFTER a successful handshake (the daemon
65
+ // died mid-turn). Retriable — the next call cold-spawns a fresh worker.
66
+ WORKER_DIED: 'worker-died',
67
+ // The consumer's per-turn timeout fired before the ACP session/prompt
68
+ // resolved. Owned by the consumer (the transport itself has no turn
69
+ // timeout) but exported here so all callers stringify the same constant.
70
+ // Retriable — most timeouts are transient.
71
+ CC_TURN_TIMEOUT: 'cc-turn-timeout',
72
+ });
73
+
74
+ // Build a typed Error carrying the `{ message, code, retriable }` envelope
75
+ // fields the consumer expects. Plain Errors flow through unchanged; the
76
+ // helper only stamps the extra metadata. Keep retriable defaulting to
77
+ // `true` so a caller that forgets to set it still gets the safe default.
78
+ function typedError(message, code, retriable = true) {
79
+ const err = new Error(message);
80
+ err.code = code;
81
+ err.retriable = retriable;
82
+ return err;
83
+ }
84
+
85
+ // Cap concurrent warm spawns triggered by a burst of acquisitions. Without a
86
+ // cap, a burst of callers would fan out N parallel cold spawns; with one,
87
+ // the excess waits in FIFO order while the first few warm. Exported so
88
+ // consumers that pre-warm (like cc-worker-pool.js's warmTab) can share the
89
+ // same knob instead of hardcoding their own copy.
90
+ const WARM_MAX_CONCURRENT = 3;
91
+
92
+ // Real spawn implementation — resolves `copilot` through the runtime adapter
93
+ // so every ACP transport consumer gets the SAME native/leadingArgs contract
94
+ // as the only other spawn site (engine/spawn-agent.js ~line 595). A bare
95
+ // `spawn('copilot', {shell:false})` cannot exec a `.cmd`/`.ps1`/extensionless
96
+ // npm shim on Windows — it ENOENTs instantly (copilot issue #2370 /
97
+ // W-mqid8uev). resolveBinary() resolves the real JS entry
98
+ // (node_modules/@github/copilot/index.js) and reports whether to spawn it
99
+ // directly (native) or under the current Node process (shim).
100
+ function spawnAcp({ cwd } = {}) {
101
+ const copilot = require('./runtimes/copilot');
102
+ const resolved = copilot.resolveBinary({ env: process.env });
103
+ if (!resolved || !resolved.bin) {
104
+ throw new Error(
105
+ `copilot binary not resolvable -- ${copilot.installHint || 'install the GitHub Copilot CLI'}`
106
+ );
107
+ }
108
+ const { bin, native, leadingArgs = [] } = resolved;
109
+ const acpArgs = [...leadingArgs, '--acp', '--allow-all', '--max-autopilot-continues', '1'];
110
+ // Mirror engine/spawn-agent.js: native binaries run directly; a non-native
111
+ // (JS entry / npm shim) runs under process.execPath as `node <bin> ...`.
112
+ const execBin = native ? bin : process.execPath;
113
+ const execArgs = native ? acpArgs : [bin, ...acpArgs];
114
+ return spawn(execBin, execArgs, {
115
+ stdio: ['pipe', 'pipe', 'pipe'],
116
+ cwd: cwd || process.cwd(),
117
+ windowsHide: true,
118
+ // Don't pass shell:true — the native/leadingArgs handling above already
119
+ // produces a directly-spawnable argv, and a shell wrapper would swallow
120
+ // the stdin/stdout JSON-RPC framing.
121
+ });
122
+ }
123
+
124
+ // Real kill implementation — lazy require so a caller that stubs its own
125
+ // _internals for tests doesn't need engine/shared loaded.
126
+ function killImmediate(proc) {
127
+ try {
128
+ const shared = require('./shared');
129
+ shared.killImmediate(proc);
130
+ } catch {
131
+ try { proc.kill('SIGKILL'); } catch { /* already dead */ }
132
+ }
133
+ }
134
+
135
+ function hashMcpServers(mcpServers) {
136
+ // Stable hash via JSON.stringify; mcpServers is an array of plain objects
137
+ // in practice (name/command/env) so the natural key order is fine.
138
+ const json = mcpServers == null ? '[]' : JSON.stringify(mcpServers);
139
+ return crypto.createHash('sha256').update(json).digest('hex');
140
+ }
141
+
142
+ // Bug B (issue #2479): `model` and `effort` were stored on the Worker but
143
+ // never forwarded to ACP `session/new`, so any model override was silently
144
+ // dropped. Build the params object here and only include fields that are
145
+ // defined — sending `model: undefined` would force the daemon onto its
146
+ // built-in default instead of letting it pick whatever the user has
147
+ // configured globally.
148
+ function buildSessionNewParams({ cwd, mcpServers, model, effort }) {
149
+ const params = { cwd, mcpServers: mcpServers || [] };
150
+ if (model !== undefined && model !== null && model !== '') params.model = model;
151
+ if (effort !== undefined && effort !== null && effort !== '') params.effort = effort;
152
+ return params;
153
+ }
154
+
155
+ function extractChunkText(content) {
156
+ if (content == null) return '';
157
+ if (typeof content === 'string') return content;
158
+ if (Array.isArray(content)) {
159
+ let out = '';
160
+ for (const block of content) {
161
+ if (block && typeof block.text === 'string') out += block.text;
162
+ }
163
+ return out;
164
+ }
165
+ if (typeof content === 'object' && typeof content.text === 'string') return content.text;
166
+ return '';
167
+ }
168
+
169
+ // Map an ACP `tool_call` session/update notification to the {name, input}
170
+ // shape the dashboard's formatToolSummary already understands. ACP's `kind`
171
+ // is a coarse category (execute|read|edit|search|fetch|think|other); we
172
+ // translate to the closest Claude tool name so the existing chip formatters
173
+ // keep working (Bash → "$ <cmd>", Read → "Reading <path>", etc.). Unknown
174
+ // kinds fall back to ACP's human-readable `title` with the raw input
175
+ // attached, which renders through the default `<title>(<key>: <val>)`
176
+ // formatter.
177
+ //
178
+ // Kind-based routing is intentionally NOT used — ACP overloads `kind:read`
179
+ // for both file-view and grep, and `kind:execute` sometimes arrives without
180
+ // a `command`. Field-detection on rawInput is more reliable.
181
+ function mapAcpToolCallToToolUse(update) {
182
+ if (!update || update.sessionUpdate !== 'tool_call') return null;
183
+ const rawInput = (update.rawInput && typeof update.rawInput === 'object') ? update.rawInput : {};
184
+ const title = update.title || update.kind || 'Tool';
185
+
186
+ if (typeof rawInput.command === 'string' && rawInput.command) {
187
+ return { name: 'Bash', input: { command: rawInput.command } };
188
+ }
189
+ if (typeof rawInput.pattern === 'string' && rawInput.pattern) {
190
+ return { name: 'Grep', input: { pattern: rawInput.pattern, path: rawInput.paths || rawInput.path || '.' } };
191
+ }
192
+ if (typeof rawInput.path === 'string' && rawInput.path) {
193
+ const isEdit = String(update.kind || '').toLowerCase() === 'edit';
194
+ return { name: isEdit ? 'Edit' : 'Read', input: { file_path: rawInput.path } };
195
+ }
196
+ if (typeof rawInput.url === 'string' && rawInput.url) {
197
+ return { name: 'WebFetch', input: { url: rawInput.url } };
198
+ }
199
+ return { name: title, input: {} };
200
+ }
201
+
202
+ /**
203
+ * Worker — one `copilot --acp` process, one ACP session, newline-delimited
204
+ * JSON-RPC framing over stdin/stdout.
205
+ *
206
+ * Construction does NOT spawn the process — call `_spawnAndInit()` (spawn +
207
+ * `initialize` + `session/new` handshake) to bring the worker up. Consumers
208
+ * own the higher-level lifecycle policy (permanent per-tab ownership for
209
+ * cc-worker-pool.js; lease/release for agent-worker-pool.js).
210
+ *
211
+ * `internals` is `{ spawnAcp({cwd}), killImmediate(proc), now() }` —
212
+ * constructor-injected so each consumer module can stub its OWN copy for
213
+ * tests (see cc-worker-pool.js's and agent-worker-pool.js's respective
214
+ * `_internals` objects) without the two pools' test stubs colliding on a
215
+ * shared module-level singleton. `trace(...parts)` is an optional structured
216
+ * logger (default no-op); consumers wire their own env-gated trace prefix.
217
+ */
218
+ class Worker {
219
+ constructor({ id, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd, internals, trace }) {
220
+ this.id = id;
221
+ this.model = model;
222
+ this.effort = effort;
223
+ this.mcpServers = mcpServers || [];
224
+ this.mcpServersHash = mcpServersHash;
225
+ this.systemPromptHash = systemPromptHash;
226
+ this.cwd = cwd || process.cwd();
227
+ this._internals = internals;
228
+ this._trace = typeof trace === 'function' ? trace : () => {};
229
+
230
+ this.proc = null;
231
+ this.sessionId = null;
232
+ this.pending = new Map(); // id → { resolve, reject }
233
+ this.inflight = null; // current { id, sessionId, onChunk, onDone, onError, signal, signalHandler, settled }
234
+ this.readBuf = '';
235
+ this.nextReqId = 1;
236
+ this.lastUsedAt = this._internals.now();
237
+ this.killed = false;
238
+ this.spawnError = null;
239
+ this.firstSystemPromptSent = false;
240
+ // In-flight spawn+initialize+session/new promise. Set by the consumer
241
+ // before the worker is registered in its own pool bookkeeping, cleared
242
+ // after the handshake settles. Racing callers await this to avoid the
243
+ // "warm-reuse path returns sessionId=null while init is still pending"
244
+ // hang on first message of a freshly-warmed worker (W-mpd45blx00072f04).
245
+ this.initPromise = null;
246
+ }
247
+
248
+ // ── Spawn + initialize handshake ────────────────────────────────────────
249
+ async _spawnAndInit() {
250
+ let proc;
251
+ try {
252
+ proc = this._internals.spawnAcp({ cwd: this.cwd });
253
+ } catch (err) {
254
+ // spawn() threw synchronously — typically ENOENT (copilot binary not
255
+ // on PATH) or EACCES. Surface as worker-spawn-failed so the consumer
256
+ // can show "install the CLI / fix PATH" guidance.
257
+ throw typedError(
258
+ `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
259
+ ERROR_CODES.WORKER_SPAWN_FAILED,
260
+ true
261
+ );
262
+ }
263
+ this.proc = proc;
264
+
265
+ proc.stdout.on('data', (chunk) => this._onStdout(chunk));
266
+ // stderr is captured for diagnostics but we don't act on it.
267
+ if (proc.stderr) proc.stderr.on('data', () => { /* ignore */ });
268
+
269
+ // Race the init handshake against an early process exit so an auth
270
+ // failure (which kills the daemon before initialize completes) surfaces
271
+ // a clear error instead of hanging forever.
272
+ let earlyExitReject;
273
+ const earlyExitPromise = new Promise((_, reject) => {
274
+ earlyExitReject = (code) => {
275
+ this.killed = true;
276
+ // Early exit DURING the handshake = acp-handshake-failed (almost
277
+ // always missing `copilot login`, stale CLI, or daemon crash on
278
+ // boot). Retriable so re-auth or a CLI upgrade can recover.
279
+ const err = typedError(
280
+ `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (exit ${code})`,
281
+ ERROR_CODES.ACP_HANDSHAKE_FAILED,
282
+ true
283
+ );
284
+ this.spawnError = err;
285
+ this._failAllPending(err);
286
+ reject(err);
287
+ };
288
+ });
289
+ const earlyExitHandler = (code) => earlyExitReject(code);
290
+ proc.once('exit', earlyExitHandler);
291
+
292
+ const errorHandler = (err) => {
293
+ // proc 'error' event fires when the OS can't actually start the child
294
+ // (ENOENT after a successful spawn() call, etc.). Treat as a spawn
295
+ // failure even though we made it past the synchronous spawn() above.
296
+ const wrapped = typedError(
297
+ `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
298
+ ERROR_CODES.WORKER_SPAWN_FAILED,
299
+ true
300
+ );
301
+ this.spawnError = wrapped;
302
+ this.killed = true;
303
+ this._failAllPending(wrapped);
304
+ };
305
+ proc.on('error', errorHandler);
306
+
307
+ try {
308
+ await Promise.race([
309
+ this._call('initialize', { protocolVersion: 1, clientCapabilities: {} }),
310
+ earlyExitPromise,
311
+ ]);
312
+ const result = await Promise.race([
313
+ this._call('session/new', buildSessionNewParams({
314
+ cwd: this.cwd, mcpServers: this.mcpServers, model: this.model, effort: this.effort,
315
+ })),
316
+ earlyExitPromise,
317
+ ]);
318
+ this.sessionId = result && result.sessionId;
319
+ if (!this.sessionId) {
320
+ // Handshake completed without an error but the daemon didn't hand
321
+ // back a sessionId — protocol violation or partial init failure.
322
+ throw typedError(
323
+ 'copilot --acp failed -- session/new returned no sessionId',
324
+ ERROR_CODES.ACP_HANDSHAKE_FAILED,
325
+ true
326
+ );
327
+ }
328
+ } finally {
329
+ // Either the handshake finished (swap to a persistent exit handler that
330
+ // just marks killed) or it failed (proc is dying anyway).
331
+ proc.removeListener('exit', earlyExitHandler);
332
+ }
333
+ proc.on('exit', () => {
334
+ this.killed = true;
335
+ // Post-handshake exit = the daemon died mid-conversation. Retriable
336
+ // because the next call will cold-spawn a fresh worker.
337
+ const err = typedError(
338
+ 'copilot --acp process exited',
339
+ ERROR_CODES.WORKER_DIED,
340
+ true
341
+ );
342
+ this._failAllPending(err);
343
+ // Settle inflight too if it's still hanging
344
+ if (this.inflight && !this.inflight.settled) {
345
+ const cb = this.inflight.onError;
346
+ this.inflight.settled = true;
347
+ this.inflight = null;
348
+ if (cb) try { cb(err); } catch { /* swallow */ }
349
+ }
350
+ });
351
+ }
352
+
353
+ _failAllPending(err) {
354
+ for (const { reject } of this.pending.values()) {
355
+ try { reject(err); } catch { /* swallow */ }
356
+ }
357
+ this.pending.clear();
358
+ }
359
+
360
+ // ── Newline-delimited JSON-RPC line framing ────────────────────────────
361
+ _onStdout(chunk) {
362
+ this.readBuf += chunk.toString('utf8');
363
+ let i;
364
+ while ((i = this.readBuf.indexOf('\n')) >= 0) {
365
+ const line = this.readBuf.slice(0, i).trim();
366
+ this.readBuf = this.readBuf.slice(i + 1);
367
+ if (!line) continue;
368
+ let obj;
369
+ try { obj = JSON.parse(line); } catch { continue; }
370
+ this._handleMessage(obj);
371
+ }
372
+ }
373
+
374
+ _handleMessage(obj) {
375
+ // Response (matches an outbound id)
376
+ if (obj.id != null && this.pending.has(obj.id)) {
377
+ const { resolve, reject } = this.pending.get(obj.id);
378
+ this.pending.delete(obj.id);
379
+ if (obj.error) reject(new Error(obj.error.message || 'ACP error'));
380
+ else resolve(obj.result);
381
+ return;
382
+ }
383
+ // Notification (no id) — only `session/update` matters for streaming.
384
+ if (obj.method === 'session/update' && obj.params) {
385
+ const notifSid = obj.params.sessionId;
386
+ const inflightSid = this.inflight ? this.inflight.sessionId : null;
387
+ const updKind = obj.params.update && obj.params.update.sessionUpdate;
388
+ if (!this.inflight) {
389
+ this._trace(`id=${this.id} session/update dropped: no inflight (notifSid=${notifSid} kind=${updKind})`);
390
+ return;
391
+ }
392
+ if (notifSid !== inflightSid) {
393
+ this._trace(`id=${this.id} session/update dropped: sid mismatch (notifSid=${notifSid} inflightSid=${inflightSid} kind=${updKind})`);
394
+ return;
395
+ }
396
+ this._trace(`id=${this.id} session/update delivered: sid=${notifSid} kind=${updKind}`);
397
+ const update = obj.params.update;
398
+ if (!update) return;
399
+ if (update.sessionUpdate === 'agent_message_chunk') {
400
+ const text = extractChunkText(update.content);
401
+ if (text && this.inflight.onChunk) {
402
+ try { this.inflight.onChunk(text); } catch { /* swallow */ }
403
+ }
404
+ } else if (update.sessionUpdate === 'tool_call' && this.inflight.onToolUse) {
405
+ // ACP `tool_call` (pending) → chip label via mapAcpToolCallToToolUse.
406
+ const mapped = mapAcpToolCallToToolUse(update);
407
+ if (mapped) {
408
+ try { this.inflight.onToolUse(mapped.name, mapped.input, update.toolCallId); }
409
+ catch { /* swallow */ }
410
+ }
411
+ } else if (update.sessionUpdate === 'tool_call_update' && this.inflight.onToolUpdate) {
412
+ // Terminal-state updates only (completed/failed). In-progress updates
413
+ // exist in the ACP spec but add chip churn without actionable info.
414
+ if (update.status === 'completed' || update.status === 'failed') {
415
+ try { this.inflight.onToolUpdate(update.toolCallId, update.status); }
416
+ catch { /* swallow */ }
417
+ }
418
+ }
419
+ }
420
+ }
421
+
422
+ _writeFrame(obj) {
423
+ if (this.killed) return;
424
+ if (!this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
425
+ try { this.proc.stdin.write(JSON.stringify(obj) + '\n'); }
426
+ catch { /* pipe may be broken; exit handler will fire */ }
427
+ }
428
+
429
+ _call(method, params) {
430
+ return new Promise((resolve, reject) => {
431
+ const id = this.nextReqId++;
432
+ this.pending.set(id, { resolve, reject });
433
+ this._writeFrame({ jsonrpc: '2.0', id, method, params });
434
+ });
435
+ }
436
+
437
+ _notify(method, params) {
438
+ this._writeFrame({ jsonrpc: '2.0', method, params });
439
+ }
440
+
441
+ // ── Stream a single turn ───────────────────────────────────────────────
442
+ stream(promptText, opts = {}) {
443
+ const { onChunk, onToolUse, onToolUpdate, onDone, onError, signal, systemPromptText } = opts;
444
+ if (this.killed) {
445
+ const err = new Error('acp-transport: worker is closed');
446
+ if (onError) try { onError(err); } catch { /* swallow */ }
447
+ return Promise.resolve();
448
+ }
449
+ if (this.inflight) {
450
+ const err = new Error('acp-transport: a prompt is already in flight on this worker');
451
+ if (onError) try { onError(err); } catch { /* swallow */ }
452
+ return Promise.resolve();
453
+ }
454
+ this.lastUsedAt = this._internals.now();
455
+
456
+ // Inject the <system> block on the first prompt of a session — Copilot
457
+ // ACP has no in-protocol "set system prompt" method, so the per-session
458
+ // adapter pattern of <system>...</system> prepended to the first user
459
+ // message remains correct.
460
+ let prompt = promptText;
461
+ if (!this.firstSystemPromptSent && systemPromptText) {
462
+ prompt = `<system>\n${systemPromptText}\n</system>\n\n${promptText}`;
463
+ }
464
+ this.firstSystemPromptSent = true;
465
+
466
+ const id = this.nextReqId++;
467
+ const inflight = {
468
+ id,
469
+ sessionId: this.sessionId,
470
+ onChunk,
471
+ onToolUse,
472
+ onToolUpdate,
473
+ onDone,
474
+ onError,
475
+ signal,
476
+ signalHandler: null,
477
+ settled: false,
478
+ };
479
+ this.inflight = inflight;
480
+ this._trace(`id=${this.id} stream begin: worker.sessionId=${this.sessionId} inflight.sessionId=${inflight.sessionId} reqId=${id}`);
481
+
482
+ if (signal && typeof signal.addEventListener === 'function') {
483
+ inflight.signalHandler = () => this.cancel();
484
+ try { signal.addEventListener('abort', inflight.signalHandler); } catch { /* swallow */ }
485
+ }
486
+
487
+ return new Promise((resolve) => {
488
+ const finalize = (err, result) => {
489
+ if (inflight.settled) return;
490
+ inflight.settled = true;
491
+ if (this.inflight === inflight) this.inflight = null;
492
+ this.lastUsedAt = this._internals.now();
493
+ if (signal && inflight.signalHandler) {
494
+ try { signal.removeEventListener('abort', inflight.signalHandler); }
495
+ catch { /* swallow */ }
496
+ }
497
+ if (err) {
498
+ if (onError) try { onError(err); } catch { /* swallow */ }
499
+ } else {
500
+ if (onDone) try { onDone(result); } catch { /* swallow */ }
501
+ }
502
+ resolve();
503
+ };
504
+
505
+ this.pending.set(id, {
506
+ resolve: (result) => finalize(null, result),
507
+ reject: (err) => finalize(err, null),
508
+ });
509
+ this._writeFrame({
510
+ jsonrpc: '2.0',
511
+ id,
512
+ method: 'session/prompt',
513
+ params: { sessionId: this.sessionId, prompt: [{ type: 'text', text: prompt }] },
514
+ });
515
+ });
516
+ }
517
+
518
+ cancel() {
519
+ if (!this.inflight || this.killed) return;
520
+ // session/cancel is a JSON-RPC notification (no id). Per the P-7a1c2e4f
521
+ // spike, the in-flight session/prompt response arrives with
522
+ // stopReason:"end_turn" (NOT "cancelled" on copilot CLI 1.0.70) — we
523
+ // don't branch on that field, only on the promise settling.
524
+ this._notify('session/cancel', { sessionId: this.inflight.sessionId });
525
+ }
526
+
527
+ async newSession({ mcpServers, systemPromptHash, model, effort, cwd }) {
528
+ // Cancel any inflight before swapping the underlying session.
529
+ if (this.inflight) {
530
+ this.cancel();
531
+ // Wait briefly for the cancelled response to settle so we don't leak
532
+ // a stale inflight reference into the new session.
533
+ const inflight = this.inflight;
534
+ await new Promise((resolve) => {
535
+ const start = this._internals.now();
536
+ const wait = () => {
537
+ if (!inflight || inflight.settled || this._internals.now() - start > 1000) resolve();
538
+ else setTimeout(wait, 5);
539
+ };
540
+ wait();
541
+ });
542
+ }
543
+ // Bug B (issue #2479): if the caller is rotating the session because the
544
+ // system prompt changed, they may also be passing a fresh model/effort —
545
+ // update bookkeeping BEFORE session/new so the new fields land on the
546
+ // daemon. Falls through to whatever we already had when callers omit them.
547
+ if (model !== undefined) this.model = model;
548
+ if (effort !== undefined) this.effort = effort;
549
+ // agent-worker-pool.js reuses a warm worker across dispatches with
550
+ // different cwds (unlike cc-worker-pool.js, whose tabs never change
551
+ // cwd) — allow the caller to rotate it in on a session swap.
552
+ if (cwd !== undefined && cwd !== null && cwd !== '') this.cwd = cwd;
553
+ const result = await this._call('session/new', buildSessionNewParams({
554
+ cwd: this.cwd, mcpServers: mcpServers || [], model: this.model, effort: this.effort,
555
+ }));
556
+ this.sessionId = result && result.sessionId;
557
+ this.systemPromptHash = systemPromptHash;
558
+ this.firstSystemPromptSent = false;
559
+ }
560
+
561
+ close() {
562
+ if (this.killed) return;
563
+ this.killed = true;
564
+ // Best-effort cancel of an inflight prompt so the daemon doesn't keep
565
+ // generating into a torn-down session before we kill the proc.
566
+ if (this.inflight) {
567
+ try { this._notify('session/cancel', { sessionId: this.inflight.sessionId }); }
568
+ catch { /* swallow */ }
569
+ }
570
+ if (this.proc) {
571
+ try { this._internals.killImmediate(this.proc); } catch { /* already dead */ }
572
+ }
573
+ this._failAllPending(new Error('acp-transport: worker closed'));
574
+ if (this.inflight && !this.inflight.settled) {
575
+ const cb = this.inflight.onError;
576
+ this.inflight.settled = true;
577
+ this.inflight = null;
578
+ if (cb) try { cb(new Error('acp-transport: worker closed')); } catch { /* swallow */ }
579
+ }
580
+ }
581
+ }
582
+
583
+ module.exports = {
584
+ ERROR_CODES,
585
+ typedError,
586
+ spawnAcp,
587
+ killImmediate,
588
+ hashMcpServers,
589
+ buildSessionNewParams,
590
+ extractChunkText,
591
+ mapAcpToolCallToToolUse,
592
+ Worker,
593
+ WARM_MAX_CONCURRENT,
594
+ };