@yemi33/minions 0.1.1909 → 0.1.1911

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.
package/README.md CHANGED
@@ -15,6 +15,8 @@ Inspired by and initially scaffolded from [Brady Gaster's Squad](https://bradyga
15
15
  - **Anthropic API key** or Claude Max subscription (agents spawn Claude Code sessions)
16
16
  - **Git** — agents create worktrees for all code changes
17
17
 
18
+ > **Note:** you do **not** need to configure your CLI for "autopilot" / "bypass permissions" / "dangerous mode". Minions passes the right bypass flag per spawn (`--dangerously-skip-permissions` for Claude; `--autopilot --allow-all --no-ask-user` for Copilot), independent of your global CLI config. Run `minions doctor` to verify your installed CLI accepts those flags.
19
+
18
20
  ## Installation
19
21
 
20
22
  ```bash
@@ -0,0 +1,512 @@
1
+ /**
2
+ * engine/cc-worker-pool.js — Persistent per-tab Copilot worker pool (W-mp3ioyh300056f36).
3
+ *
4
+ * Sub-task B of W-mp2w003600196c51 ("CC perf: persistent per-tab Copilot
5
+ * worker"). Wraps `copilot --acp` (Agent Client Protocol — long-lived JSON-RPC
6
+ * server over stdin/stdout) so that a Command Center / doc-chat tab keeps a
7
+ * single warm Copilot process across N turns instead of paying the
8
+ * spawn + initialize + session/new (~14 s) cost on every turn.
9
+ *
10
+ * Public API:
11
+ *
12
+ * pool.getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd })
13
+ * → SessionHandle: {
14
+ * sessionId,
15
+ * stream(promptText, { onChunk, onDone, onError, signal, systemPromptText }) → Promise,
16
+ * cancel(), // session/cancel notification
17
+ * close(), // tear down this tab
18
+ * }
19
+ * pool.closeTab(tabId) // cancel inflight, kill proc, drop entry
20
+ * pool.shutdown() // close every tab and stop the idle reaper
21
+ *
22
+ * Internal model (one process per tab):
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
+ * }
29
+ *
30
+ * TODO (sub-task C/D follow-up): multiplex N tabs onto one ACP process
31
+ * (`session/new` per tab, single `proc`). ACP supports it; left as a
32
+ * follow-up perf win once one-process-per-tab is proven in the dashboard.
33
+ *
34
+ * Eviction rules:
35
+ * - mcpServers changes → kill proc entirely + respawn
36
+ * - systemPromptHash change → keep proc, send a fresh `session/new`
37
+ * - model / effort change → no eviction (state updated in place)
38
+ * - closeTab → `session/cancel` if inflight, then kill proc
39
+ * - Idle > 10 min → kill proc (reaper sweep every 60 s)
40
+ *
41
+ * Auth precheck: when `copilot --acp` exits before the initialize handshake
42
+ * completes, surface
43
+ * "copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login
44
+ * is complete (...)"
45
+ * so the dashboard can surface a precise reason instead of a generic timeout.
46
+ *
47
+ * No new npm deps — Node built-ins only (child_process, events, timers, crypto).
48
+ *
49
+ * See `notes/archive/2026-05-13-ripley-W-mp3h2wq2000e64f7-2026-05-13-0301.md`
50
+ * for the ACP protocol probe (line framing, request/response correlation,
51
+ * cold-vs-warm timings, cancel semantics, configOptions surface).
52
+ */
53
+
54
+ const { spawn } = require('child_process');
55
+ const crypto = require('crypto');
56
+
57
+ // 10 minutes — matches the work-item spec.
58
+ const IDLE_REAPER_MS = 10 * 60 * 1000;
59
+ // Reaper sweep cadence. Not exposed as ENGINE_DEFAULTS to keep the pool
60
+ // dependency-free; sub-task C/D can plumb a config knob if needed.
61
+ const REAPER_INTERVAL_MS = 60 * 1000;
62
+
63
+ // Test seam — every external side effect goes through `_internals` so
64
+ // test/unit/cc-worker-pool.test.js can stub spawn/now/killImmediate.
65
+ const _internals = {
66
+ spawnAcp({ cwd } = {}) {
67
+ return spawn(
68
+ 'copilot',
69
+ ['--acp', '--allow-all', '--max-autopilot-continues', '1'],
70
+ {
71
+ stdio: ['pipe', 'pipe', 'pipe'],
72
+ cwd: cwd || process.cwd(),
73
+ windowsHide: true,
74
+ // Don't pass shell:true — Copilot is a binary on PATH and a shell
75
+ // wrapper would swallow stdin/stdout framing.
76
+ }
77
+ );
78
+ },
79
+ killImmediate(proc) {
80
+ // Lazy require so unit tests that don't load engine/shared still work.
81
+ try {
82
+ const shared = require('./shared');
83
+ shared.killImmediate(proc);
84
+ } catch {
85
+ try { proc.kill('SIGKILL'); } catch { /* already dead */ }
86
+ }
87
+ },
88
+ now() { return Date.now(); },
89
+ };
90
+
91
+ const _tabs = new Map();
92
+ let _reaperTimer = null;
93
+
94
+ function _hashMcpServers(mcpServers) {
95
+ // Stable hash via JSON.stringify; mcpServers is an array of plain objects
96
+ // in practice (name/command/env) so the natural key order is fine.
97
+ const json = mcpServers == null ? '[]' : JSON.stringify(mcpServers);
98
+ return crypto.createHash('sha256').update(json).digest('hex');
99
+ }
100
+
101
+ class Worker {
102
+ constructor({ tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd }) {
103
+ this.tabId = tabId;
104
+ this.model = model;
105
+ this.effort = effort;
106
+ this.mcpServers = mcpServers || [];
107
+ this.mcpServersHash = mcpServersHash;
108
+ this.systemPromptHash = systemPromptHash;
109
+ this.cwd = cwd || process.cwd();
110
+
111
+ this.proc = null;
112
+ this.sessionId = null;
113
+ this.pending = new Map(); // id → { resolve, reject }
114
+ this.inflight = null; // current { id, sessionId, onChunk, onDone, onError, signal, signalHandler, settled }
115
+ this.readBuf = '';
116
+ this.nextReqId = 1;
117
+ this.lastUsedAt = _internals.now();
118
+ this.killed = false;
119
+ this.spawnError = null;
120
+ this.firstSystemPromptSent = false;
121
+ }
122
+
123
+ // ── Spawn + initialize handshake ────────────────────────────────────────
124
+ async _spawnAndInit() {
125
+ let proc;
126
+ try {
127
+ proc = _internals.spawnAcp({ cwd: this.cwd });
128
+ } catch (err) {
129
+ throw new Error(
130
+ `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`
131
+ );
132
+ }
133
+ this.proc = proc;
134
+
135
+ proc.stdout.on('data', (chunk) => this._onStdout(chunk));
136
+ // stderr is captured for diagnostics but we don't act on it.
137
+ if (proc.stderr) proc.stderr.on('data', () => { /* ignore */ });
138
+
139
+ // Race the init handshake against an early process exit so an auth
140
+ // failure (which kills the daemon before initialize completes) surfaces
141
+ // a clear error instead of hanging forever.
142
+ let earlyExitReject;
143
+ const earlyExitPromise = new Promise((_, reject) => {
144
+ earlyExitReject = (code) => {
145
+ this.killed = true;
146
+ const err = new Error(
147
+ `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (exit ${code})`
148
+ );
149
+ this.spawnError = err;
150
+ this._failAllPending(err);
151
+ reject(err);
152
+ };
153
+ });
154
+ const earlyExitHandler = (code) => earlyExitReject(code);
155
+ proc.once('exit', earlyExitHandler);
156
+
157
+ const errorHandler = (err) => {
158
+ const wrapped = new Error(
159
+ `copilot --acp failed -- ensure copilot CLI >=1.0.46 and copilot login is complete (${err.message})`
160
+ );
161
+ this.spawnError = wrapped;
162
+ this.killed = true;
163
+ this._failAllPending(wrapped);
164
+ };
165
+ proc.on('error', errorHandler);
166
+
167
+ try {
168
+ await Promise.race([
169
+ this._call('initialize', { protocolVersion: 1, clientCapabilities: {} }),
170
+ earlyExitPromise,
171
+ ]);
172
+ const result = await Promise.race([
173
+ this._call('session/new', { cwd: this.cwd, mcpServers: this.mcpServers }),
174
+ earlyExitPromise,
175
+ ]);
176
+ this.sessionId = result && result.sessionId;
177
+ if (!this.sessionId) {
178
+ throw new Error('copilot --acp failed -- session/new returned no sessionId');
179
+ }
180
+ } finally {
181
+ // Either the handshake finished (swap to a persistent exit handler that
182
+ // just marks killed) or it failed (proc is dying anyway).
183
+ proc.removeListener('exit', earlyExitHandler);
184
+ }
185
+ proc.on('exit', () => {
186
+ this.killed = true;
187
+ const err = new Error('copilot --acp process exited');
188
+ this._failAllPending(err);
189
+ // Settle inflight too if it's still hanging
190
+ if (this.inflight && !this.inflight.settled) {
191
+ const cb = this.inflight.onError;
192
+ this.inflight.settled = true;
193
+ this.inflight = null;
194
+ if (cb) try { cb(err); } catch { /* swallow */ }
195
+ }
196
+ });
197
+ }
198
+
199
+ _failAllPending(err) {
200
+ for (const { reject } of this.pending.values()) {
201
+ try { reject(err); } catch { /* swallow */ }
202
+ }
203
+ this.pending.clear();
204
+ }
205
+
206
+ // ── Newline-delimited JSON-RPC line framing ────────────────────────────
207
+ _onStdout(chunk) {
208
+ this.readBuf += chunk.toString('utf8');
209
+ let i;
210
+ while ((i = this.readBuf.indexOf('\n')) >= 0) {
211
+ const line = this.readBuf.slice(0, i).trim();
212
+ this.readBuf = this.readBuf.slice(i + 1);
213
+ if (!line) continue;
214
+ let obj;
215
+ try { obj = JSON.parse(line); } catch { continue; }
216
+ this._handleMessage(obj);
217
+ }
218
+ }
219
+
220
+ _handleMessage(obj) {
221
+ // Response (matches an outbound id)
222
+ if (obj.id != null && this.pending.has(obj.id)) {
223
+ const { resolve, reject } = this.pending.get(obj.id);
224
+ this.pending.delete(obj.id);
225
+ if (obj.error) reject(new Error(obj.error.message || 'ACP error'));
226
+ else resolve(obj.result);
227
+ return;
228
+ }
229
+ // Notification (no id) — only `session/update` matters for streaming.
230
+ if (obj.method === 'session/update' && obj.params && this.inflight) {
231
+ if (obj.params.sessionId !== this.inflight.sessionId) return;
232
+ const update = obj.params.update;
233
+ if (!update) return;
234
+ if (update.sessionUpdate === 'agent_message_chunk') {
235
+ const text = _extractChunkText(update.content);
236
+ if (text && this.inflight.onChunk) {
237
+ try { this.inflight.onChunk(text); } catch { /* swallow */ }
238
+ }
239
+ }
240
+ // Other update kinds (available_commands_update, tool_call, ...) are
241
+ // ignored in sub-task B. Sub-task C/D will surface tool_call to the
242
+ // dashboard's onToolUse callback.
243
+ }
244
+ }
245
+
246
+ _writeFrame(obj) {
247
+ if (this.killed) return;
248
+ if (!this.proc || !this.proc.stdin || this.proc.stdin.destroyed) return;
249
+ try { this.proc.stdin.write(JSON.stringify(obj) + '\n'); }
250
+ catch { /* pipe may be broken; exit handler will fire */ }
251
+ }
252
+
253
+ _call(method, params) {
254
+ return new Promise((resolve, reject) => {
255
+ const id = this.nextReqId++;
256
+ this.pending.set(id, { resolve, reject });
257
+ this._writeFrame({ jsonrpc: '2.0', id, method, params });
258
+ });
259
+ }
260
+
261
+ _notify(method, params) {
262
+ this._writeFrame({ jsonrpc: '2.0', method, params });
263
+ }
264
+
265
+ // ── Stream a single turn ───────────────────────────────────────────────
266
+ stream(promptText, opts = {}) {
267
+ const { onChunk, onDone, onError, signal, systemPromptText } = opts;
268
+ if (this.killed) {
269
+ const err = new Error('cc-worker-pool: tab is closed');
270
+ if (onError) try { onError(err); } catch { /* swallow */ }
271
+ return Promise.resolve();
272
+ }
273
+ if (this.inflight) {
274
+ const err = new Error('cc-worker-pool: a prompt is already in flight on this tab');
275
+ if (onError) try { onError(err); } catch { /* swallow */ }
276
+ return Promise.resolve();
277
+ }
278
+ this.lastUsedAt = _internals.now();
279
+
280
+ // Inject the <system> block on the first prompt of a session — Copilot
281
+ // ACP has no in-protocol "set system prompt" method (per Ripley's note),
282
+ // so the per-session adapter pattern of <system>...</system> prepended
283
+ // to the first user message remains correct.
284
+ let prompt = promptText;
285
+ if (!this.firstSystemPromptSent && systemPromptText) {
286
+ prompt = `<system>\n${systemPromptText}\n</system>\n\n${promptText}`;
287
+ }
288
+ this.firstSystemPromptSent = true;
289
+
290
+ const id = this.nextReqId++;
291
+ const inflight = {
292
+ id,
293
+ sessionId: this.sessionId,
294
+ onChunk,
295
+ onDone,
296
+ onError,
297
+ signal,
298
+ signalHandler: null,
299
+ settled: false,
300
+ };
301
+ this.inflight = inflight;
302
+
303
+ if (signal && typeof signal.addEventListener === 'function') {
304
+ inflight.signalHandler = () => this.cancel();
305
+ try { signal.addEventListener('abort', inflight.signalHandler); } catch { /* swallow */ }
306
+ }
307
+
308
+ return new Promise((resolve) => {
309
+ const finalize = (err, result) => {
310
+ if (inflight.settled) return;
311
+ inflight.settled = true;
312
+ if (this.inflight === inflight) this.inflight = null;
313
+ this.lastUsedAt = _internals.now();
314
+ if (signal && inflight.signalHandler) {
315
+ try { signal.removeEventListener('abort', inflight.signalHandler); }
316
+ catch { /* swallow */ }
317
+ }
318
+ if (err) {
319
+ if (onError) try { onError(err); } catch { /* swallow */ }
320
+ } else {
321
+ if (onDone) try { onDone(result); } catch { /* swallow */ }
322
+ }
323
+ resolve();
324
+ };
325
+
326
+ this.pending.set(id, {
327
+ resolve: (result) => finalize(null, result),
328
+ reject: (err) => finalize(err, null),
329
+ });
330
+ this._writeFrame({
331
+ jsonrpc: '2.0',
332
+ id,
333
+ method: 'session/prompt',
334
+ params: { sessionId: this.sessionId, prompt: [{ type: 'text', text: prompt }] },
335
+ });
336
+ });
337
+ }
338
+
339
+ cancel() {
340
+ if (!this.inflight || this.killed) return;
341
+ // session/cancel is a JSON-RPC notification (no id). The in-flight
342
+ // session/prompt response will arrive with stopReason=cancelled per the
343
+ // ACP spec — we don't synthesize a fake response client-side.
344
+ this._notify('session/cancel', { sessionId: this.inflight.sessionId });
345
+ }
346
+
347
+ async newSession({ mcpServers, systemPromptHash }) {
348
+ // Cancel any inflight before swapping the underlying session.
349
+ if (this.inflight) {
350
+ this.cancel();
351
+ // Wait briefly for the cancelled response to settle so we don't leak
352
+ // a stale inflight reference into the new session.
353
+ const inflight = this.inflight;
354
+ await new Promise((resolve) => {
355
+ const start = _internals.now();
356
+ const wait = () => {
357
+ if (!inflight || inflight.settled || _internals.now() - start > 1000) resolve();
358
+ else setTimeout(wait, 5);
359
+ };
360
+ wait();
361
+ });
362
+ }
363
+ const result = await this._call('session/new', {
364
+ cwd: this.cwd,
365
+ mcpServers: mcpServers || [],
366
+ });
367
+ this.sessionId = result && result.sessionId;
368
+ this.systemPromptHash = systemPromptHash;
369
+ this.firstSystemPromptSent = false;
370
+ }
371
+
372
+ close() {
373
+ if (this.killed) return;
374
+ this.killed = true;
375
+ // Best-effort cancel of an inflight prompt so the daemon doesn't keep
376
+ // generating into a torn-down session before we kill the proc.
377
+ if (this.inflight) {
378
+ try { this._notify('session/cancel', { sessionId: this.inflight.sessionId }); }
379
+ catch { /* swallow */ }
380
+ }
381
+ if (this.proc) {
382
+ try { _internals.killImmediate(this.proc); } catch { /* already dead */ }
383
+ }
384
+ this._failAllPending(new Error('cc-worker-pool: worker closed'));
385
+ if (this.inflight && !this.inflight.settled) {
386
+ const cb = this.inflight.onError;
387
+ this.inflight.settled = true;
388
+ this.inflight = null;
389
+ if (cb) try { cb(new Error('cc-worker-pool: worker closed')); } catch { /* swallow */ }
390
+ }
391
+ }
392
+ }
393
+
394
+ function _extractChunkText(content) {
395
+ if (content == null) return '';
396
+ if (typeof content === 'string') return content;
397
+ if (Array.isArray(content)) {
398
+ let out = '';
399
+ for (const block of content) {
400
+ if (block && typeof block.text === 'string') out += block.text;
401
+ }
402
+ return out;
403
+ }
404
+ if (typeof content === 'object' && typeof content.text === 'string') return content.text;
405
+ return '';
406
+ }
407
+
408
+ // ── Public API ────────────────────────────────────────────────────────────
409
+
410
+ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash, cwd } = {}) {
411
+ if (!tabId) throw new Error('cc-worker-pool.getSession: tabId is required');
412
+ const mcpServersHash = _hashMcpServers(mcpServers);
413
+ let worker = _tabs.get(tabId);
414
+
415
+ if (worker) {
416
+ if (worker.killed) {
417
+ _tabs.delete(tabId);
418
+ worker = null;
419
+ } else if (worker.mcpServersHash !== mcpServersHash) {
420
+ // mcpServers shape changed → must respawn the proc; the daemon
421
+ // resolves MCP server config at process boot, not per session.
422
+ _tabs.delete(tabId);
423
+ worker.close();
424
+ worker = null;
425
+ } else if (worker.systemPromptHash !== systemPromptHash) {
426
+ // System prompt changed → keep the warm process, drop the session
427
+ // and create a fresh one. Saves the ~2.1 s initialize handshake.
428
+ await worker.newSession({ mcpServers, systemPromptHash });
429
+ worker.model = model;
430
+ worker.effort = effort;
431
+ worker.lastUsedAt = _internals.now();
432
+ } else {
433
+ // Warm reuse — only update bookkeeping. model/effort changes on a
434
+ // warm session are tracked here but not pushed via configOptions in
435
+ // sub-task B; sub-task C/D will wire that to ACP `session/configure`
436
+ // (or equivalent) once the dashboard exposes the toggle live.
437
+ worker.model = model;
438
+ worker.effort = effort;
439
+ worker.lastUsedAt = _internals.now();
440
+ }
441
+ }
442
+
443
+ if (!worker) {
444
+ worker = new Worker({
445
+ tabId, model, effort, mcpServers, mcpServersHash, systemPromptHash, cwd,
446
+ });
447
+ _tabs.set(tabId, worker);
448
+ try {
449
+ await worker._spawnAndInit();
450
+ } catch (err) {
451
+ _tabs.delete(tabId);
452
+ try { worker.close(); } catch { /* already torn down */ }
453
+ throw err;
454
+ }
455
+ }
456
+
457
+ _ensureReaper();
458
+
459
+ return {
460
+ sessionId: worker.sessionId,
461
+ stream: (promptText, opts) => worker.stream(promptText, opts),
462
+ cancel: () => worker.cancel(),
463
+ close: () => closeTab(tabId),
464
+ };
465
+ }
466
+
467
+ function closeTab(tabId) {
468
+ const worker = _tabs.get(tabId);
469
+ if (!worker) return;
470
+ _tabs.delete(tabId);
471
+ worker.close();
472
+ }
473
+
474
+ function shutdown() {
475
+ for (const worker of _tabs.values()) {
476
+ try { worker.close(); } catch { /* swallow */ }
477
+ }
478
+ _tabs.clear();
479
+ if (_reaperTimer) {
480
+ clearInterval(_reaperTimer);
481
+ _reaperTimer = null;
482
+ }
483
+ }
484
+
485
+ function _ensureReaper() {
486
+ if (_reaperTimer) return;
487
+ _reaperTimer = setInterval(_reapIdleTabs, REAPER_INTERVAL_MS);
488
+ if (typeof _reaperTimer.unref === 'function') _reaperTimer.unref();
489
+ }
490
+
491
+ function _reapIdleTabs() {
492
+ const now = _internals.now();
493
+ for (const [tabId, worker] of [..._tabs]) {
494
+ if (worker.inflight) continue;
495
+ if (now - worker.lastUsedAt > IDLE_REAPER_MS) {
496
+ _tabs.delete(tabId);
497
+ try { worker.close(); } catch { /* already torn down */ }
498
+ }
499
+ }
500
+ }
501
+
502
+ module.exports = {
503
+ getSession,
504
+ closeTab,
505
+ shutdown,
506
+ // Exposed for unit tests; engine code MUST go through the public API.
507
+ _internals,
508
+ _tabs,
509
+ _reapIdleTabs,
510
+ IDLE_REAPER_MS,
511
+ REAPER_INTERVAL_MS,
512
+ };
@@ -10,7 +10,7 @@
10
10
 
11
11
  const fs = require('fs');
12
12
  const path = require('path');
13
- const { execSync } = require('child_process');
13
+ const { execSync, execFileSync } = require('child_process');
14
14
 
15
15
  /**
16
16
  * Resolve the Claude Code CLI binary path. Legacy helper preserved for back-
@@ -421,6 +421,115 @@ async function _modelDiscoveryResults(config) {
421
421
  return results;
422
422
  }
423
423
 
424
+ /**
425
+ * Spawn `<resolved.bin> --help` and return the combined stdout/stderr, or null
426
+ * if the binary couldn't be invoked at all. Used only by the bypass-flag check
427
+ * in `_bypassFlagResults`; never on the hot path of `runPreflight` because
428
+ * it adds a subprocess invocation per runtime.
429
+ *
430
+ * Many CLIs emit `--help` to stderr or return a non-zero code when invoked in
431
+ * a non-TTY context, so we tolerate both — `execFileSync` populates `stdout`
432
+ * and `stderr` on the thrown error and we read them back.
433
+ */
434
+ function _fetchCliHelpText(resolved, { timeoutMs = 5000 } = {}) {
435
+ if (!resolved || !resolved.bin) return null;
436
+ const leading = Array.isArray(resolved.leadingArgs) ? resolved.leadingArgs : [];
437
+ let cmd;
438
+ let args;
439
+ if (resolved.native === false) {
440
+ // Node shim (e.g. claude/cli.js). Invoke via the current Node so we don't
441
+ // depend on a `node` on PATH and we honor the same execPath the engine
442
+ // uses for spawn-agent.
443
+ cmd = process.execPath;
444
+ args = [resolved.bin, ...leading, '--help'];
445
+ } else {
446
+ cmd = resolved.bin;
447
+ args = [...leading, '--help'];
448
+ }
449
+ try {
450
+ const out = execFileSync(cmd, args, {
451
+ encoding: 'utf8',
452
+ windowsHide: true,
453
+ timeout: timeoutMs,
454
+ stdio: ['ignore', 'pipe', 'pipe'],
455
+ });
456
+ return String(out || '');
457
+ } catch (e) {
458
+ const stdout = e && e.stdout ? String(e.stdout) : '';
459
+ const stderr = e && e.stderr ? String(e.stderr) : '';
460
+ const combined = stdout + stderr;
461
+ return combined.length > 0 ? combined : null;
462
+ }
463
+ }
464
+
465
+ /**
466
+ * Pure helper — takes the adapter and the help text and returns a doctor
467
+ * result entry. Splitting this from the spawn keeps the helper unit-testable
468
+ * without mocking `execFileSync`. The check is `warn`-level (not critical) so
469
+ * a false positive from a CLI that renames flags or paginates help doesn't
470
+ * block existing installs; the engine spawns will still error loudly if the
471
+ * flag really isn't honored.
472
+ */
473
+ function _checkBypassFlagSupported(runtimeName, adapter, helpText) {
474
+ const name = `Permission bypass: ${runtimeName}`;
475
+ const flags = Array.isArray(adapter && adapter.permissionBypassFlags)
476
+ ? adapter.permissionBypassFlags.filter(f => typeof f === 'string' && f.length > 0)
477
+ : [];
478
+ if (flags.length === 0) {
479
+ return {
480
+ name,
481
+ ok: 'warn',
482
+ message: `adapter did not declare permissionBypassFlags — cannot verify ${runtimeName} CLI accepts headless bypass`,
483
+ };
484
+ }
485
+ if (helpText == null || helpText === '') {
486
+ return {
487
+ name,
488
+ ok: 'warn',
489
+ message: `could not invoke ${runtimeName} --help to verify ${flags.join(' ')} support — Minions will still pass the flag(s) but you may see permission prompts if the CLI doesn't accept them`,
490
+ };
491
+ }
492
+ const missing = flags.filter(f => !helpText.includes(f));
493
+ if (missing.length > 0) {
494
+ return {
495
+ name,
496
+ ok: 'warn',
497
+ message: `${runtimeName} --help does not list expected flag(s): ${missing.join(', ')} — your CLI may be outdated. Agents will hang on permission prompts. Update with the CLI's package manager (npm i -g @anthropic-ai/claude-code for Claude; winget upgrade Microsoft.CopilotCLI for Copilot)`,
498
+ };
499
+ }
500
+ return {
501
+ name,
502
+ ok: true,
503
+ message: `${flags.join(' ')} accepted`,
504
+ };
505
+ }
506
+
507
+ /**
508
+ * Build the per-runtime bypass-flag verification entries for `minions doctor`.
509
+ * One entry per distinct configured runtime. Skipped silently when the binary
510
+ * itself didn't resolve (the upstream `Runtime: <name>` check already surfaces
511
+ * that failure — no point shouting twice).
512
+ */
513
+ function _bypassFlagResults(config) {
514
+ const results = [];
515
+ if (!config || typeof config !== 'object') return results;
516
+ let registry;
517
+ try { registry = require('./runtimes'); } catch { return results; }
518
+ const runtimes = _distinctRuntimes(config);
519
+ for (const runtimeName of runtimes) {
520
+ let adapter;
521
+ try { adapter = registry.resolveRuntime(runtimeName); }
522
+ catch { continue; }
523
+ let resolved = null;
524
+ try { resolved = adapter.resolveBinary({ env: process.env }); }
525
+ catch { /* upstream Runtime check already reported this */ }
526
+ if (!resolved || !resolved.bin) continue;
527
+ const helpText = _fetchCliHelpText(resolved);
528
+ results.push(_checkBypassFlagSupported(runtimeName, adapter, helpText));
529
+ }
530
+ return results;
531
+ }
532
+
424
533
  /**
425
534
  * Run extended doctor checks (preflight + runtime health + fleet summary +
426
535
  * per-runtime model discovery).
@@ -552,6 +661,11 @@ function doctor(minionsHome) {
552
661
  runtimeResults.push(...fleetSummary);
553
662
  const modelResults = await _modelDiscoveryResults(preflightConfig);
554
663
  runtimeResults.push(...modelResults);
664
+ // Verify each runtime CLI still recognizes the headless bypass flags the
665
+ // adapters inject. Catches "user installed an outdated CLI" before the
666
+ // first agent silently hangs on a permission prompt.
667
+ const bypassResults = _bypassFlagResults(preflightConfig);
668
+ runtimeResults.push(...bypassResults);
555
669
 
556
670
  // Print all
557
671
  const allResults = [...results, ...runtimeResults];
@@ -584,4 +698,7 @@ module.exports = {
584
698
  _warmModelCache,
585
699
  _fleetSummaryResults,
586
700
  _modelDiscoveryResults,
701
+ _fetchCliHelpText,
702
+ _checkBypassFlagSupported,
703
+ _bypassFlagResults,
587
704
  };
@@ -689,6 +689,14 @@ function createStreamConsumer(ctx) {
689
689
  return { consume, reset };
690
690
  }
691
691
 
692
+ // ── Permission Bypass ───────────────────────────────────────────────────────
693
+ //
694
+ // Flags the engine relies on for headless operation. `preflight.doctor()`
695
+ // shells out to `<bin> --help` and verifies each literal appears in the help
696
+ // text — surfaces "your CLI is too old / has been renamed" before agents
697
+ // silently hang on permission prompts. Must stay in sync with `buildArgs`.
698
+ const PERMISSION_BYPASS_FLAGS = ['--dangerously-skip-permissions'];
699
+
692
700
  // ── Capability Block ────────────────────────────────────────────────────────
693
701
 
694
702
  const capabilities = {
@@ -806,6 +814,7 @@ module.exports = {
806
814
  parseStreamChunk,
807
815
  parseError,
808
816
  createStreamConsumer,
817
+ permissionBypassFlags: PERMISSION_BYPASS_FLAGS,
809
818
  // Exposed for unit tests — never imported by engine code
810
819
  _CLAUDE_SHORTHANDS,
811
820
  THINKING_BLOCK_TYPES,
@@ -1062,6 +1062,14 @@ function createStreamConsumer(ctx) {
1062
1062
  return { consume, reset };
1063
1063
  }
1064
1064
 
1065
+ // ── Permission Bypass ───────────────────────────────────────────────────────
1066
+ //
1067
+ // Flags the engine relies on for headless operation. `preflight.doctor()`
1068
+ // shells out to `<bin> --help` and verifies each literal appears in the help
1069
+ // text — surfaces "your CLI is too old / has been renamed" before agents
1070
+ // silently hang on permission prompts. Must stay in sync with `buildArgs`.
1071
+ const PERMISSION_BYPASS_FLAGS = ['--autopilot', '--allow-all', '--no-ask-user'];
1072
+
1065
1073
  // ── Capability Block ────────────────────────────────────────────────────────
1066
1074
 
1067
1075
  const capabilities = {
@@ -1170,6 +1178,7 @@ module.exports = {
1170
1178
  parseStreamChunk,
1171
1179
  parseError,
1172
1180
  createStreamConsumer,
1181
+ permissionBypassFlags: PERMISSION_BYPASS_FLAGS,
1173
1182
  // Exposed for unit tests — engine code MUST go through resolveRuntime + the
1174
1183
  // adapter contract; never reach into these helpers directly.
1175
1184
  _MINIONS_MODEL_ALIASES,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1909",
3
+ "version": "0.1.1911",
4
4
  "description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
5
5
  "bin": {
6
6
  "minions": "bin/minions.js"