@yemi33/minions 0.1.2382 → 0.1.2384

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 (51) hide show
  1. package/bin/minions.js +1 -0
  2. package/dashboard/js/refresh.js +2 -1
  3. package/dashboard/js/settings.js +1 -1
  4. package/dashboard.js +37 -19
  5. package/docs/completion-reports.md +20 -1
  6. package/docs/keep-processes.md +7 -2
  7. package/docs/live-checkout-mode.md +7 -7
  8. package/docs/managed-spawn.md +14 -1
  9. package/docs/runtime-adapters.md +61 -26
  10. package/docs/skills.md +2 -0
  11. package/docs/workspace-manifests.md +1 -1
  12. package/docs/worktree-lifecycle.md +36 -0
  13. package/engine/acp-transport.js +273 -58
  14. package/engine/ado-comment.js +3 -1
  15. package/engine/agent-worker-pool.js +278 -54
  16. package/engine/cc-worker-pool.js +12 -3
  17. package/engine/cleanup.js +15 -11
  18. package/engine/cli.js +56 -28
  19. package/engine/comment-format.js +51 -14
  20. package/engine/create-pr-worktree.js +57 -79
  21. package/engine/dispatch.js +7 -2
  22. package/engine/execution-model.js +68 -0
  23. package/engine/gh-comment.js +6 -3
  24. package/engine/github.js +6 -7
  25. package/engine/keep-process-sweep.js +131 -9
  26. package/engine/lifecycle.js +126 -45
  27. package/engine/live-checkout.js +4 -2
  28. package/engine/llm.js +59 -83
  29. package/engine/managed-spawn.js +112 -5
  30. package/engine/memory-store.js +3 -1
  31. package/engine/playbook.js +4 -6
  32. package/engine/pooled-agent-process.js +512 -45
  33. package/engine/pr-action.js +6 -8
  34. package/engine/process-utils.js +154 -18
  35. package/engine/runtimes/claude.js +94 -25
  36. package/engine/runtimes/codex.js +1 -0
  37. package/engine/runtimes/contract.js +239 -0
  38. package/engine/runtimes/copilot.js +97 -9
  39. package/engine/runtimes/index.js +37 -9
  40. package/engine/shared.js +349 -82
  41. package/engine/spawn-agent.js +85 -116
  42. package/engine/spawn-phase-watchdog.js +8 -37
  43. package/engine/timeout.js +14 -10
  44. package/engine/worktree-gc.js +55 -46
  45. package/engine.js +418 -241
  46. package/package.json +1 -1
  47. package/playbooks/fix.md +20 -0
  48. package/playbooks/review.md +2 -0
  49. package/playbooks/shared-rules.md +2 -2
  50. package/prompts/cc-system.md +11 -11
  51. package/skills/check-self-authored-review-comment/SKILL.md +34 -0
@@ -2,12 +2,12 @@
2
2
  * engine/acp-transport.js — Shared ACP (Agent Client Protocol) transport layer
3
3
  * (P-4c6e8a72, extracted from engine/cc-worker-pool.js).
4
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):
5
+ * ACP-capable harnesses expose a long-lived JSON-RPC server over stdin/stdout.
6
+ * This module owns protocol plumbing that is identical regardless of which
7
+ * adapter or consumer is using the worker:
8
8
  *
9
- * - process spawn (`spawnAcp`) — resolves the `copilot` binary via the
10
- * runtime adapter and launches it with `--acp`.
9
+ * - process spawn (`spawnAcp`) — delegates binary and argv construction to
10
+ * the selected runtime adapter.
11
11
  * - newline-delimited JSON-RPC framing, request/response correlation, and
12
12
  * `session/update` notification dispatch (the `Worker` class).
13
13
  * - typed error envelope (`ERROR_CODES` / `typedError`).
@@ -45,6 +45,12 @@
45
45
 
46
46
  const { spawn } = require('child_process');
47
47
  const crypto = require('crypto');
48
+ const { normalizeExecutionModel } = require('./execution-model');
49
+ const {
50
+ resolveRuntime,
51
+ resolveRuntimeByCapability,
52
+ buildWorkerArgs,
53
+ } = require('./runtimes');
48
54
 
49
55
  // W-mpmwxni2000c25c7-c — typed error codes the transport emits through every
50
56
  // failure exit so a consumer (CC streaming handler / doc-chat pool wrapper /
@@ -56,8 +62,7 @@ const ERROR_CODES = Object.freeze({
56
62
  // because a transient PATH / fs glitch may recover.
57
63
  WORKER_SPAWN_FAILED: 'worker-spawn-failed',
58
64
  // 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
65
+ // session/new), or session/new returned no
61
66
  // sessionId. Retriable: the caller swaps to a fallback model / a re-auth
62
67
  // may unblock the next attempt.
63
68
  ACP_HANDSHAKE_FAILED: 'acp-handshake-failed',
@@ -88,26 +93,37 @@ function typedError(message, code, retriable = true) {
88
93
  // consumers that pre-warm (like cc-worker-pool.js's warmTab) can share the
89
94
  // same knob instead of hardcoding their own copy.
90
95
  const WARM_MAX_CONCURRENT = 3;
96
+ const ACP_HANDSHAKE_TIMEOUT_MS = 60000;
97
+ const SESSION_LOAD_RETRY_ATTEMPTS = 10;
98
+ const SESSION_LOAD_RETRY_DELAY_MS = 100;
99
+ const SESSION_ROTATION_CANCEL_WAIT_MS = 1000;
100
+
101
+ function resolveWorkerRuntime(runtime) {
102
+ const adapter = typeof runtime === 'string'
103
+ ? resolveRuntime(runtime)
104
+ : (runtime || resolveRuntimeByCapability('acpWorkerPool'));
105
+ if (adapter?.capabilities?.acpWorkerPool !== true) {
106
+ throw new Error(`Runtime "${adapter?.name || '<unknown>'}" does not support ACP workers`);
107
+ }
108
+ return adapter;
109
+ }
91
110
 
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, env } = {}) {
101
- const copilot = require('./runtimes/copilot');
111
+ // Real spawn implementation. Binary resolution and worker argv are both
112
+ // adapter-owned so harness CLI flag changes do not leak into the transport.
113
+ function spawnAcp({ runtime, cwd, env, workerOptions } = {}) {
114
+ const adapter = resolveWorkerRuntime(runtime);
102
115
  const workerEnv = env || process.env;
103
- const resolved = copilot.resolveBinary({ env: workerEnv });
116
+ const resolved = adapter.resolveBinary({ env: workerEnv });
104
117
  if (!resolved || !resolved.bin) {
105
118
  throw new Error(
106
- `copilot binary not resolvable -- ${copilot.installHint || 'install the GitHub Copilot CLI'}`
119
+ `${adapter.name} binary not resolvable -- ${adapter.installHint || `install the ${adapter.name} CLI`}`
107
120
  );
108
121
  }
109
122
  const { bin, native, leadingArgs = [] } = resolved;
110
- const acpArgs = [...leadingArgs, '--acp', '--allow-all', '--max-autopilot-continues', '1'];
123
+ const acpArgs = [
124
+ ...leadingArgs,
125
+ ...buildWorkerArgs(adapter, { ...(workerOptions || {}), cwd, env: workerEnv }),
126
+ ];
111
127
  // Mirror engine/spawn-agent.js: native binaries run directly; a non-native
112
128
  // (JS entry / npm shim) runs under process.execPath as `node <bin> ...`.
113
129
  const execBin = native ? bin : process.execPath;
@@ -134,6 +150,10 @@ function killImmediate(proc) {
134
150
  }
135
151
  }
136
152
 
153
+ function killRoot(proc) {
154
+ try { proc.kill('SIGKILL'); } catch { /* already dead */ }
155
+ }
156
+
137
157
  function hashMcpServers(mcpServers) {
138
158
  // Stable hash via JSON.stringify; mcpServers is an array of plain objects
139
159
  // in practice (name/command/env) so the natural key order is fine.
@@ -141,18 +161,31 @@ function hashMcpServers(mcpServers) {
141
161
  return crypto.createHash('sha256').update(json).digest('hex');
142
162
  }
143
163
 
144
- function _stableObject(value) {
145
- if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
164
+ function _stableValue(value) {
165
+ if (Array.isArray(value)) return value.map(_stableValue);
166
+ if (!value || typeof value !== 'object') return value;
146
167
  return Object.fromEntries(
147
168
  Object.entries(value)
148
169
  .filter(([, entry]) => entry !== undefined)
149
170
  .sort(([a], [b]) => a.localeCompare(b))
171
+ .map(([key, entry]) => [key, _stableValue(entry)])
150
172
  );
151
173
  }
152
174
 
153
- function hashProcessContext(processEnv) {
175
+ function _stableObject(value) {
176
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
177
+ return _stableValue(value);
178
+ }
179
+
180
+ function hashProcessContext(processEnv, processOptions) {
181
+ const context = processOptions === undefined
182
+ ? _stableObject(processEnv)
183
+ : {
184
+ env: _stableObject(processEnv),
185
+ options: _stableValue(processOptions),
186
+ };
154
187
  return crypto.createHash('sha256')
155
- .update(JSON.stringify(_stableObject(processEnv)))
188
+ .update(JSON.stringify(context))
156
189
  .digest('hex');
157
190
  }
158
191
 
@@ -181,6 +214,23 @@ function buildSessionNewParams({ cwd, mcpServers, model, effort }) {
181
214
  return params;
182
215
  }
183
216
 
217
+ function extractSessionModel(result) {
218
+ for (const value of [
219
+ result?.models?.currentModelId,
220
+ result?.currentModelId,
221
+ result?.modelId,
222
+ result?.model,
223
+ ]) {
224
+ const model = normalizeExecutionModel(value);
225
+ if (model) return model;
226
+ }
227
+ return null;
228
+ }
229
+
230
+ function buildSessionLoadParams({ sessionId, cwd, mcpServers }) {
231
+ return { sessionId, cwd, mcpServers: mcpServers || [] };
232
+ }
233
+
184
234
  function extractChunkText(content) {
185
235
  if (content == null) return '';
186
236
  if (typeof content === 'string') return content;
@@ -246,9 +296,11 @@ function mapAcpToolCallToToolUse(update) {
246
296
  */
247
297
  class Worker {
248
298
  constructor({
299
+ runtime,
249
300
  id, model, effort, mcpServers, mcpServersHash, processEnv, processContextHash,
250
- systemPromptHash, cwd, internals, trace,
301
+ workerOptions, resumeSessionId, systemPromptHash, cwd, processCwd, internals, trace,
251
302
  }) {
303
+ this.runtime = resolveWorkerRuntime(runtime);
252
304
  this.id = id;
253
305
  this.model = model;
254
306
  this.effort = effort;
@@ -256,12 +308,16 @@ class Worker {
256
308
  this.mcpServersHash = mcpServersHash;
257
309
  this.processEnv = processEnv;
258
310
  this.processContextHash = processContextHash;
311
+ this.workerOptions = workerOptions && typeof workerOptions === 'object' ? { ...workerOptions } : {};
312
+ this.resumeSessionId = resumeSessionId || null;
259
313
  this.systemPromptHash = systemPromptHash;
260
314
  this.cwd = cwd || process.cwd();
315
+ this.processCwd = processCwd || this.cwd;
261
316
  this._internals = internals;
262
317
  this._trace = typeof trace === 'function' ? trace : () => {};
263
318
 
264
319
  this.proc = null;
320
+ this.processStartedAt = 0;
265
321
  this.sessionId = null;
266
322
  this.pending = new Map(); // id → { resolve, reject }
267
323
  this.inflight = null; // current { id, sessionId, onChunk, onDone, onError, signal, signalHandler, settled }
@@ -271,6 +327,7 @@ class Worker {
271
327
  this.killed = false;
272
328
  this.spawnError = null;
273
329
  this.firstSystemPromptSent = false;
330
+ this.preservedDescendantPids = new Set();
274
331
  // In-flight spawn+initialize+session/new promise. Set by the consumer
275
332
  // before the worker is registered in its own pool bookkeeping, cleared
276
333
  // after the handshake settles. Racing callers await this to avoid the
@@ -283,13 +340,16 @@ class Worker {
283
340
  async _spawnAndInit() {
284
341
  let proc;
285
342
  try {
286
- proc = this._internals.spawnAcp({ cwd: this.cwd, env: this.processEnv });
343
+ proc = this._internals.spawnAcp({
344
+ runtime: this.runtime,
345
+ cwd: this.processCwd,
346
+ env: this.processEnv,
347
+ workerOptions: this.workerOptions,
348
+ });
287
349
  } catch (err) {
288
- // spawn() threw synchronously typically ENOENT (copilot binary not
289
- // on PATH) or EACCES. Surface as worker-spawn-failed so the consumer
290
- // can show "install the CLI / fix PATH" guidance.
350
+ const hint = this.runtime.workerErrorHint || `${this.runtime.name} persistent worker failed`;
291
351
  throw typedError(
292
- `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
352
+ `${hint} (${err.message})`,
293
353
  ERROR_CODES.WORKER_SPAWN_FAILED,
294
354
  true
295
355
  );
@@ -307,11 +367,9 @@ class Worker {
307
367
  const earlyExitPromise = new Promise((_, reject) => {
308
368
  earlyExitReject = (code) => {
309
369
  this.killed = true;
310
- // Early exit DURING the handshake = acp-handshake-failed (almost
311
- // always missing `copilot login`, stale CLI, or daemon crash on
312
- // boot). Retriable so re-auth or a CLI upgrade can recover.
370
+ const hint = this.runtime.workerErrorHint || `${this.runtime.name} persistent worker failed`;
313
371
  const err = typedError(
314
- `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (exit ${code})`,
372
+ `${hint} (exit ${code})`,
315
373
  ERROR_CODES.ACP_HANDSHAKE_FAILED,
316
374
  true
317
375
  );
@@ -327,8 +385,9 @@ class Worker {
327
385
  // proc 'error' event fires when the OS can't actually start the child
328
386
  // (ENOENT after a successful spawn() call, etc.). Treat as a spawn
329
387
  // failure even though we made it past the synchronous spawn() above.
388
+ const hint = this.runtime.workerErrorHint || `${this.runtime.name} persistent worker failed`;
330
389
  const wrapped = typedError(
331
- `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`,
390
+ `${hint} (${err.message})`,
332
391
  ERROR_CODES.WORKER_SPAWN_FAILED,
333
392
  true
334
393
  );
@@ -338,28 +397,63 @@ class Worker {
338
397
  };
339
398
  proc.on('error', errorHandler);
340
399
 
400
+ const configuredHandshakeTimeout = Number(this._internals.handshakeTimeoutMs);
401
+ const handshakeTimeoutMs = Number.isFinite(configuredHandshakeTimeout) && configuredHandshakeTimeout > 0
402
+ ? configuredHandshakeTimeout
403
+ : ACP_HANDSHAKE_TIMEOUT_MS;
404
+ let handshakeTimer;
405
+ const handshakeTimeoutPromise = new Promise((_, reject) => {
406
+ handshakeTimer = setTimeout(() => {
407
+ reject(typedError(
408
+ `copilot --acp handshake timed out after ${handshakeTimeoutMs}ms`,
409
+ ERROR_CODES.ACP_HANDSHAKE_FAILED,
410
+ true
411
+ ));
412
+ }, handshakeTimeoutMs);
413
+ });
414
+
341
415
  try {
342
416
  await Promise.race([
343
417
  this._call('initialize', { protocolVersion: 1, clientCapabilities: {} }),
344
418
  earlyExitPromise,
419
+ handshakeTimeoutPromise,
345
420
  ]);
421
+ const sessionMethod = this.resumeSessionId ? 'session/load' : 'session/new';
422
+ const sessionParams = this.resumeSessionId
423
+ ? buildSessionLoadParams({
424
+ sessionId: this.resumeSessionId,
425
+ cwd: this.cwd,
426
+ mcpServers: this.mcpServers,
427
+ })
428
+ : buildSessionNewParams({
429
+ cwd: this.cwd,
430
+ mcpServers: this.mcpServers,
431
+ model: this.model,
432
+ effort: this.effort,
433
+ });
434
+ const sessionPromise = sessionMethod === 'session/load'
435
+ ? this._loadSession(sessionParams)
436
+ : this._call(sessionMethod, sessionParams);
346
437
  const result = await Promise.race([
347
- this._call('session/new', buildSessionNewParams({
348
- cwd: this.cwd, mcpServers: this.mcpServers, model: this.model, effort: this.effort,
349
- })),
438
+ sessionPromise,
350
439
  earlyExitPromise,
440
+ handshakeTimeoutPromise,
351
441
  ]);
352
- this.sessionId = result && result.sessionId;
442
+ this.sessionId = (result && result.sessionId) || this.resumeSessionId;
443
+ this.currentModel = extractSessionModel(result) || normalizeExecutionModel(this.model);
353
444
  if (!this.sessionId) {
354
445
  // Handshake completed without an error but the daemon didn't hand
355
446
  // back a sessionId — protocol violation or partial init failure.
447
+ const hint = this.runtime.workerErrorHint || `${this.runtime.name} persistent worker failed`;
356
448
  throw typedError(
357
- 'copilot --acp failed -- session/new returned no sessionId',
449
+ `${hint} (${sessionMethod} returned no sessionId)`,
358
450
  ERROR_CODES.ACP_HANDSHAKE_FAILED,
359
451
  true
360
452
  );
361
453
  }
454
+ this.firstSystemPromptSent = !!this.resumeSessionId;
362
455
  } finally {
456
+ clearTimeout(handshakeTimer);
363
457
  // Either the handshake finished (swap to a persistent exit handler that
364
458
  // just marks killed) or it failed (proc is dying anyway).
365
459
  proc.removeListener('exit', earlyExitHandler);
@@ -369,7 +463,7 @@ class Worker {
369
463
  // Post-handshake exit = the daemon died mid-conversation. Retriable
370
464
  // because the next call will cold-spawn a fresh worker.
371
465
  const err = typedError(
372
- 'copilot --acp process exited',
466
+ `${this.runtime.name} persistent worker process exited`,
373
467
  ERROR_CODES.WORKER_DIED,
374
468
  true
375
469
  );
@@ -408,9 +502,13 @@ class Worker {
408
502
  _handleMessage(obj) {
409
503
  // Response (matches an outbound id)
410
504
  if (obj.id != null && this.pending.has(obj.id)) {
411
- const { resolve, reject } = this.pending.get(obj.id);
505
+ const { resolve, reject, method } = this.pending.get(obj.id);
412
506
  this.pending.delete(obj.id);
413
- if (obj.error) reject(new Error(obj.error.message || 'ACP error'));
507
+ if (obj.error) {
508
+ const err = new Error(obj.error.message || 'ACP error');
509
+ err.acpMethod = method || null;
510
+ reject(err);
511
+ }
414
512
  else resolve(obj.result);
415
513
  return;
416
514
  }
@@ -460,10 +558,35 @@ class Worker {
460
558
  catch { /* pipe may be broken; exit handler will fire */ }
461
559
  }
462
560
 
463
- _call(method, params) {
561
+ _call(method, params, timeoutMs) {
562
+ if (this.killed || !this.proc?.stdin || this.proc.stdin.destroyed) {
563
+ return Promise.reject(new Error('acp-transport: worker is closed'));
564
+ }
464
565
  return new Promise((resolve, reject) => {
465
566
  const id = this.nextReqId++;
466
- this.pending.set(id, { resolve, reject });
567
+ let timer;
568
+ const settle = (callback, value) => {
569
+ if (timer) clearTimeout(timer);
570
+ callback(value);
571
+ };
572
+ this.pending.set(id, {
573
+ resolve: (value) => settle(resolve, value),
574
+ reject: (err) => settle(reject, err),
575
+ method,
576
+ });
577
+ const parsedTimeout = Number(timeoutMs);
578
+ if (Number.isFinite(parsedTimeout) && parsedTimeout > 0) {
579
+ timer = setTimeout(() => {
580
+ if (!this.pending.delete(id)) return;
581
+ const err = typedError(
582
+ `acp-transport: ${method} timed out after ${parsedTimeout}ms`,
583
+ ERROR_CODES.ACP_HANDSHAKE_FAILED,
584
+ true
585
+ );
586
+ err.acpMethod = method;
587
+ reject(err);
588
+ }, parsedTimeout);
589
+ }
467
590
  this._writeFrame({ jsonrpc: '2.0', id, method, params });
468
591
  });
469
592
  }
@@ -472,6 +595,20 @@ class Worker {
472
595
  this._writeFrame({ jsonrpc: '2.0', method, params });
473
596
  }
474
597
 
598
+ async _loadSession(params) {
599
+ let lastError;
600
+ for (let attempt = 0; attempt < SESSION_LOAD_RETRY_ATTEMPTS; attempt++) {
601
+ try {
602
+ return await this._call('session/load', params, ACP_HANDSHAKE_TIMEOUT_MS);
603
+ } catch (err) {
604
+ lastError = err;
605
+ if (!/already loaded/i.test(String(err && err.message))) throw err;
606
+ await new Promise((resolve) => setTimeout(resolve, SESSION_LOAD_RETRY_DELAY_MS));
607
+ }
608
+ }
609
+ throw lastError;
610
+ }
611
+
475
612
  // ── Stream a single turn ───────────────────────────────────────────────
476
613
  stream(promptText, opts = {}) {
477
614
  const {
@@ -496,8 +633,11 @@ class Worker {
496
633
  // message remains correct.
497
634
  let prompt = promptText;
498
635
  const trustedContextText = buildTrustedSessionContext(trustedSessionContext);
499
- const trustedSystemText = [systemPromptText, trustedContextText].filter(Boolean).join('\n\n');
500
- if (!this.firstSystemPromptSent && trustedSystemText) {
636
+ const trustedSystemText = [
637
+ !this.firstSystemPromptSent ? systemPromptText : null,
638
+ trustedContextText,
639
+ ].filter(Boolean).join('\n\n');
640
+ if (trustedSystemText) {
501
641
  prompt = `<system>\n${trustedSystemText}\n</system>\n\n${promptText}`;
502
642
  }
503
643
  if (!deferSystemPrompt) this.firstSystemPromptSent = true;
@@ -563,7 +703,8 @@ class Worker {
563
703
  this._notify('session/cancel', { sessionId: this.inflight.sessionId });
564
704
  }
565
705
 
566
- async newSession({ mcpServers, systemPromptHash, model, effort, cwd }) {
706
+ async _prepareSession(options = {}) {
707
+ const { sessionId, mcpServers, systemPromptHash, model, effort, cwd } = options;
567
708
  // Cancel any inflight before swapping the underlying session.
568
709
  if (this.inflight) {
569
710
  this.cancel();
@@ -573,28 +714,91 @@ class Worker {
573
714
  await new Promise((resolve) => {
574
715
  const start = this._internals.now();
575
716
  const wait = () => {
576
- if (!inflight || inflight.settled || this._internals.now() - start > 1000) resolve();
717
+ if (!inflight || inflight.settled
718
+ || this._internals.now() - start > SESSION_ROTATION_CANCEL_WAIT_MS) resolve();
577
719
  else setTimeout(wait, 5);
578
720
  };
579
721
  wait();
580
722
  });
723
+ if (!inflight.settled) {
724
+ throw new Error('acp-transport: cancelled prompt did not settle before session rotation');
725
+ }
581
726
  }
582
727
  // Bug B (issue #2479): if the caller is rotating the session because the
583
728
  // system prompt changed, they may also be passing a fresh model/effort —
584
729
  // update bookkeeping BEFORE session/new so the new fields land on the
585
- // daemon. Falls through to whatever we already had when callers omit them.
586
- if (model !== undefined) this.model = model;
730
+ // daemon. An explicit `model: undefined` clears a prior pooled override and
731
+ // lets the new session select its own default.
732
+ if (Object.prototype.hasOwnProperty.call(options, 'model')) this.model = model;
587
733
  if (effort !== undefined) this.effort = effort;
588
734
  // agent-worker-pool.js reuses a warm worker across dispatches with
589
735
  // different cwds (unlike cc-worker-pool.js, whose tabs never change
590
736
  // cwd) — allow the caller to rotate it in on a session swap.
591
- if (cwd !== undefined && cwd !== null && cwd !== '') this.cwd = cwd;
592
- const result = await this._call('session/new', buildSessionNewParams({
593
- cwd: this.cwd, mcpServers: mcpServers || [], model: this.model, effort: this.effort,
594
- }));
595
- this.sessionId = result && result.sessionId;
737
+ const nextCwd = cwd !== undefined && cwd !== null && cwd !== '' ? cwd : this.cwd;
738
+ if (sessionId && this.sessionId === sessionId) {
739
+ if (nextCwd !== this.cwd) {
740
+ const err = new Error('acp-transport: loaded session cannot change cwd without a worker respawn');
741
+ err.acpMethod = 'session/load';
742
+ throw err;
743
+ }
744
+ this.systemPromptHash = systemPromptHash;
745
+ this.firstSystemPromptSent = true;
746
+ return;
747
+ }
748
+ this.cwd = nextCwd;
749
+ const method = sessionId ? 'session/load' : 'session/new';
750
+ const params = sessionId
751
+ ? buildSessionLoadParams({ sessionId, cwd: this.cwd, mcpServers: mcpServers || [] })
752
+ : buildSessionNewParams({
753
+ cwd: this.cwd,
754
+ mcpServers: mcpServers || [],
755
+ model: this.model,
756
+ effort: this.effort,
757
+ });
758
+ const result = method === 'session/load'
759
+ ? await this._loadSession(params)
760
+ : await this._call(method, params, ACP_HANDSHAKE_TIMEOUT_MS);
761
+ this.sessionId = (result && result.sessionId) || sessionId;
762
+ if (!this.sessionId) {
763
+ const hint = this.runtime.workerErrorHint || `${this.runtime.name} persistent worker failed`;
764
+ throw typedError(
765
+ `${hint} (${method} returned no sessionId)`,
766
+ ERROR_CODES.ACP_HANDSHAKE_FAILED,
767
+ true
768
+ );
769
+ }
770
+ this.currentModel = extractSessionModel(result) || normalizeExecutionModel(this.model);
596
771
  this.systemPromptHash = systemPromptHash;
597
- this.firstSystemPromptSent = false;
772
+ this.firstSystemPromptSent = !!sessionId;
773
+ }
774
+
775
+ async newSession(opts) {
776
+ return this._prepareSession({ ...opts, sessionId: null });
777
+ }
778
+
779
+ async loadSession(sessionId, opts) {
780
+ return this._prepareSession({ ...opts, sessionId });
781
+ }
782
+
783
+ preserveDescendants(pids) {
784
+ for (const pid of pids || []) {
785
+ const value = Number(pid);
786
+ if (Number.isInteger(value) && value > 0) this.preservedDescendantPids.add(value);
787
+ }
788
+ }
789
+
790
+ abandon() {
791
+ if (this.killed) return;
792
+ this.killed = true;
793
+ try { this.proc?.stdin?.end(); } catch { /* pipe already closed */ }
794
+ const err = new Error('acp-transport: unverified worker abandoned');
795
+ this._failAllPending(err);
796
+ if (this.inflight && !this.inflight.settled) {
797
+ const cb = this.inflight.onError;
798
+ this.inflight.settled = true;
799
+ this.inflight = null;
800
+ if (cb) try { cb(err); } catch { /* swallow */ }
801
+ }
598
802
  }
599
803
 
600
804
  close() {
@@ -607,7 +811,13 @@ class Worker {
607
811
  catch { /* swallow */ }
608
812
  }
609
813
  if (this.proc) {
610
- try { this._internals.killImmediate(this.proc); } catch { /* already dead */ }
814
+ try {
815
+ if (this.preservedDescendantPids.size > 0 && typeof this._internals.killRoot === 'function') {
816
+ this._internals.killRoot(this.proc);
817
+ } else {
818
+ this._internals.killImmediate(this.proc);
819
+ }
820
+ } catch { /* already dead */ }
611
821
  }
612
822
  this._failAllPending(new Error('acp-transport: worker closed'));
613
823
  if (this.inflight && !this.inflight.settled) {
@@ -623,13 +833,18 @@ module.exports = {
623
833
  ERROR_CODES,
624
834
  typedError,
625
835
  spawnAcp,
836
+ resolveWorkerRuntime,
626
837
  killImmediate,
838
+ killRoot,
627
839
  hashMcpServers,
628
840
  hashProcessContext,
629
841
  buildTrustedSessionContext,
630
842
  buildSessionNewParams,
843
+ extractSessionModel,
844
+ buildSessionLoadParams,
631
845
  extractChunkText,
632
846
  mapAcpToolCallToToolUse,
633
847
  Worker,
634
848
  WARM_MAX_CONCURRENT,
849
+ ACP_HANDSHAKE_TIMEOUT_MS,
635
850
  };
@@ -106,6 +106,7 @@ async function _defaultAcquireToken() {
106
106
  * @param {string} args.agentId minions agent id (marker)
107
107
  * @param {string} args.kind comment kind (marker)
108
108
  * @param {string} [args.workItemId] originating work-item id (marker)
109
+ * @param {string} [args.model] concrete runtime execution model
109
110
  * @param {number} [args.timeoutMs=30000]
110
111
  * @param {Function} [args.acquireToken] () => Promise<string> — injectable token source
111
112
  * @param {Function} [args.fetchImpl=fetch] injectable fetch
@@ -120,6 +121,7 @@ async function postAdoPrComment({
120
121
  agentId,
121
122
  kind,
122
123
  workItemId,
124
+ model,
123
125
  resolved = false,
124
126
  timeoutMs = 30000,
125
127
  acquireToken = _defaultAcquireToken,
@@ -131,7 +133,7 @@ async function postAdoPrComment({
131
133
  _validatePrNumber(prNumber);
132
134
 
133
135
  // buildMinionsCommentBody validates marker fields and throws on bad input.
134
- const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, body });
136
+ const finalBody = buildMinionsCommentBody({ agentId, kind, workItemId, model, body });
135
137
 
136
138
  const token = await acquireToken();
137
139
  if (!token || typeof token !== 'string') {