@yemi33/minions 0.1.2386 → 0.1.2388

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/dashboard.js CHANGED
@@ -3570,18 +3570,23 @@ function _getCcLiveStream(tabId) {
3570
3570
  function _touchCcLiveStream(state) {
3571
3571
  if (!state) return;
3572
3572
  state.updatedAt = Date.now();
3573
- // W-mpmwxni2000c25c7-b — every CC streaming progress event (onChunk,
3574
- // onToolUse, onToolUpdate in both _invokeCcStream paths) already routes
3575
- // through here for the stall detector. Piggy-back the per-turn watchdog's
3576
- // bumpTimer on the same heartbeat so a turn that's actively streaming
3577
- // tokens or running long tools can't be killed by stale inactivity. The
3578
- // watchdog installs `_bumpTimer` for the duration of the turn and removes
3579
- // it in `finally`, so late progress callbacks delivered after resolution
3580
- // are a no-op.
3573
+ // W-mpmwxni2000c25c7-b — every CC streaming progress event routes through
3574
+ // here for the stall detector. The direct path also reports startup and
3575
+ // reasoning events through callLLMStreaming.onProgress, while the pool path
3576
+ // reports chunks and tools. Piggy-back the per-turn watchdog's bumpTimer on
3577
+ // the same heartbeat so active work cannot be killed by stale inactivity.
3578
+ // The watchdog removes `_bumpTimer` in `finally`, so late callbacks are no-op.
3581
3579
  if (typeof state._bumpTimer === 'function') {
3582
3580
  try { state._bumpTimer(); } catch { /* swallow */ }
3583
3581
  }
3584
3582
  }
3583
+ function _resolveCcDirectStreamSessionId({ sessionId, hasImages, useWorkerPool }) {
3584
+ // ACP session IDs belong to the live worker process. A one-off image CLI
3585
+ // cannot resume that session while the worker owns it, so rely on the
3586
+ // transcript carryover already included in the direct prompt.
3587
+ if (hasImages && useWorkerPool) return null;
3588
+ return sessionId || null;
3589
+ }
3585
3590
  function _clearCcLiveTimers(tabId) {
3586
3591
  const state = _getCcLiveStream(tabId);
3587
3592
  if (!state) return;
@@ -9804,9 +9809,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9804
9809
  // already-working --attachment path below, while image-less turns keep
9805
9810
  // using the fast warm pool.
9806
9811
  const hasImages = Array.isArray(images) && images.length > 0;
9807
- if (!hasImages && shared.resolveCcUseWorkerPool(engineConfig)) {
9812
+ const useWorkerPool = shared.resolveCcUseWorkerPool(engineConfig);
9813
+ if (!hasImages && useWorkerPool) {
9808
9814
  return _invokeCcStreamViaPool({ prompt, liveState, toolUses, model, effort, engineConfig, systemPrompt, tabId });
9809
9815
  }
9816
+ const directSessionId = _resolveCcDirectStreamSessionId({ sessionId, hasImages, useWorkerPool });
9810
9817
  const setStatus = (message) => {
9811
9818
  liveState.statusMessage = message;
9812
9819
  if (liveState.writer) liveState.writer({ type: 'status', message });
@@ -9819,10 +9826,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9819
9826
  return callLLMStreaming(prompt, systemPrompt, {
9820
9827
  timeout: CC_CALL_TIMEOUT_MS, label: 'command-center', model, maxTurns,
9821
9828
  allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
9822
- sessionId, effort, direct: true,
9829
+ sessionId: directSessionId, effort, direct: true,
9823
9830
  engineConfig, images,
9831
+ onProgress: () => _touchCcLiveStream(liveState),
9824
9832
  onChunk: (text, segmentId) => {
9825
- _touchCcLiveStream(liveState);
9826
9833
  if (liveState.statusMessage) {
9827
9834
  setStatus('');
9828
9835
  }
@@ -9830,7 +9837,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9830
9837
  if (liveState.writer) liveState.writer({ type: 'chunk', text, segmentId });
9831
9838
  },
9832
9839
  onToolUse: (name, input, id) => {
9833
- _touchCcLiveStream(liveState);
9834
9840
  if (liveState.statusMessage) {
9835
9841
  setStatus('');
9836
9842
  }
@@ -9842,7 +9848,6 @@ What would you like to discuss or change? When you're happy, say "approve" and I
9842
9848
  if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input), id: id || null });
9843
9849
  },
9844
9850
  onToolUpdate: (id, status) => {
9845
- _touchCcLiveStream(liveState);
9846
9851
  // toolUses and liveState.tools share entry references — patch once so a
9847
9852
  // reconnect replays the resolved (green/red) state, not just pending.
9848
9853
  const entry = toolUses.find((e) => e && e.id === id);
@@ -10476,13 +10481,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10476
10481
  timeoutMs: turnTimeoutMs, label: 'command-center-stream',
10477
10482
  }, async (registerAbort, bumpTimer) => {
10478
10483
  // W-mpmwxni2000c25c7-b — no-progress semantics: install bumpTimer
10479
- // on liveState so _touchCcLiveStream (called from every onChunk /
10480
- // onToolUse / onToolUpdate in both _invokeCcStream paths) resets
10481
- // the watchdog on every liveness signal. A turn that's actively
10482
- // streaming tokens or running long tools survives indefinitely
10483
- // up to the outer CC_CALL_TIMEOUT_MS ceiling. Cleared in
10484
- // `finally` so a late progress event after resolution can't
10485
- // re-arm against a stale abort target.
10484
+ // on liveState so _touchCcLiveStream resets the watchdog on every
10485
+ // liveness signal. Direct calls include runtime startup/reasoning
10486
+ // events; pool calls include chunks and tool updates. A productive
10487
+ // turn survives up to the outer CC_CALL_TIMEOUT_MS ceiling. Cleared
10488
+ // in `finally` so late progress cannot re-arm a stale abort target.
10486
10489
  liveState._bumpTimer = bumpTimer;
10487
10490
  try {
10488
10491
  const llmPromise = _invokeCcStream({
@@ -14814,6 +14817,7 @@ module.exports = {
14814
14817
  _ccRuntimeNeedsResumeCarryover,
14815
14818
  _ccRuntimeNeedsResumeBookkeepingGuard,
14816
14819
  _joinCcPromptParts,
14820
+ _resolveCcDirectStreamSessionId, // exported for testing
14817
14821
  _captureApiRoutesMeta,
14818
14822
  _resetPublicApiRoutesCacheForTest,
14819
14823
  _formatCcApiRoutesIndex,
@@ -70,7 +70,7 @@ Any violation rejects the **whole turn** with a typed error envelope (`code: 'in
70
70
 
71
71
  **Worker-pool interaction (W-mray463s001zcee2).** The opt-in Copilot ACP worker pool (`engine.ccUseWorkerPool: true`, default ON for Copilot CC) is a **separate protocol path** from the table above — `engine/cc-worker-pool.js`'s `session/prompt` call only ever sends a single text content block (`[{ type: 'text', text: prompt }]`) and has no image/attachment content-block support today. This used to mean image turns were silently dropped whenever the pool was active, with a comment incorrectly blaming a Copilot capability gap (`imageInput:false`) that doesn't exist — Copilot's `imageInput` is `true` and fully supports images on the direct spawn path. The fix: `_invokeCcStream` (dashboard.js) now checks for a non-empty `images` array **before** routing through `resolveCcUseWorkerPool` — image-bearing turns bypass the warm pool for that turn only and cold-spawn a one-off CLI process via the working `--attachment` path (same as the non-pooled path always used), while image-less turns keep using the fast warm pool. If `engine/cc-worker-pool.js`'s ACP session protocol is later extended to carry attachments (Option A), this per-turn bypass in `_invokeCcStream` should be revisited/removed.
72
72
 
73
- Because that bypass can spend tens of seconds starting a one-off process before the first model token, the SSE stream shows a user-focused “Looking at the image…” status instead of exposing the cold-start implementation detail or leaving an apparently stalled spinner.
73
+ Because that bypass can spend tens of seconds starting a one-off process before the first model token, the SSE stream shows a user-focused “Looking at the image…” status instead of exposing the cold-start implementation detail or leaving an apparently stalled spinner. The one-off process does not resume the live worker's process-scoped ACP session ID: it starts a transport-compatible fresh session and receives prior conversation context through the existing transcript carryover. Every parsed direct-runtime event, including startup and reasoning events, resets the five-minute silence watchdog; the safety bound therefore still catches a truly silent process without killing an image turn that is making progress.
74
74
 
75
75
  `_resolveImageOpts` (in `engine/llm.js`) is pure and unit-tested: it forwards the `images` list only when `runtime.capabilities.imageInput` is truthy, otherwise returns the typed error so CC/doc-chat surface the envelope instead of a silently-text-only reply. `_spawnProcess` re-applies the same gate (`if (!caps.imageInput) adapterOpts.images = undefined`) as defense in depth. Image **filenames** are run through the untrusted-input fence (`buildSource('cc-image-filename', …)`) before they reach prompt text, since they are user-supplied.
76
76
 
package/engine/llm.js CHANGED
@@ -482,6 +482,7 @@ function _createStreamAccumulator({
482
482
  onTaskComplete = null,
483
483
  onTerminalResult = null,
484
484
  onThinking = null,
485
+ onProgress = null,
485
486
  }) {
486
487
  if (!runtime?.capabilities?.streamConsumer || typeof runtime.createStreamConsumer !== 'function') {
487
488
  throw new Error(`runtime ${runtime?.name || '<unknown>'} missing createStreamConsumer (capabilities.streamConsumer)`);
@@ -600,6 +601,11 @@ function _createStreamAccumulator({
600
601
  };
601
602
 
602
603
  const consumer = runtime.createStreamConsumer(ctx);
604
+ function consumeEvent(ev) {
605
+ if (!ev) return;
606
+ if (onProgress) onProgress(ev);
607
+ consumer.consume(ev);
608
+ }
603
609
 
604
610
  function ingestStdout(chunk) {
605
611
  const str = chunk == null ? '' : chunk.toString();
@@ -608,8 +614,7 @@ function _createStreamAccumulator({
608
614
  const lines = lineBuf.split('\n');
609
615
  lineBuf = lines.pop() || '';
610
616
  for (const line of lines) {
611
- const ev = runtime.parseStreamChunk(line);
612
- if (ev) consumer.consume(ev);
617
+ consumeEvent(runtime.parseStreamChunk(line));
613
618
  }
614
619
  }
615
620
 
@@ -619,10 +624,7 @@ function _createStreamAccumulator({
619
624
 
620
625
  function finalize() {
621
626
  const trimmed = lineBuf.trim();
622
- if (trimmed) {
623
- const ev = runtime.parseStreamChunk(trimmed);
624
- if (ev) consumer.consume(ev);
625
- }
627
+ if (trimmed) consumeEvent(runtime.parseStreamChunk(trimmed));
626
628
  if (!text && lastTaskCompleteSummary) text = lastTaskCompleteSummary;
627
629
  // Reconciliation: always let the runtime adapter re-parse the complete
628
630
  // stdout. Stream consumers emit live progress, but parseOutput() owns the
@@ -851,11 +853,14 @@ function callLLM(promptText, sysPromptText, opts = {}) {
851
853
  * onChunk(text, segmentIndex) is called for each assistant text block as it
852
854
  * arrives; segmentIndex increments across distinct text blocks so consumers can
853
855
  * keep them visually separate instead of welding them together.
856
+ * onProgress(event) is called for every parsed runtime event, including startup
857
+ * and reasoning events that do not produce a text or tool callback.
854
858
  */
855
859
  function callLLMStreaming(promptText, sysPromptText, opts = {}) {
856
860
  const {
857
861
  timeout = 120000, label = 'llm', maxTurns = 1, allowedTools = '',
858
862
  sessionId = null, onChunk = () => {}, onToolUse = null, onToolUpdate = null,
863
+ onProgress = null,
859
864
  effort = null, direct = false,
860
865
  model: modelOverride, cli: cliOverride, engineConfig,
861
866
  maxBudget, bare, fallbackModel,
@@ -903,6 +908,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
903
908
  // 'exit'/'close' when an inherited pipe keeps the parent's FDs open.
904
909
  onTerminalResult: () => scheduleExitFallback(exitCode != null ? exitCode : 0),
905
910
  onThinking: opts.onThinking || null,
911
+ onProgress,
906
912
  });
907
913
 
908
914
  _abort = () => { shared.killImmediate(proc); };
@@ -507,7 +507,39 @@ function _isRespawnEligibleEngineState(state) {
507
507
  return state === 'running' || state === 'degraded';
508
508
  }
509
509
 
510
- function checkEngine(now) {
510
+ const ENGINE_WATCHDOG_OPERATION_NAMES = [
511
+ 'isPidAlive',
512
+ 'reapStrayProcesses',
513
+ 'spawnEngine',
514
+ 'recordCrashLoopRespawn',
515
+ ];
516
+
517
+ // Isolated state does not isolate this module's source checkout, so tests must
518
+ // provide every OS/process side effect before an engine respawn is possible.
519
+ function _resolveEngineWatchdogOperations(injected) {
520
+ const provided = injected && typeof injected === 'object' ? injected : {};
521
+ if (process.env.MINIONS_TEST_DIR) {
522
+ const missing = ENGINE_WATCHDOG_OPERATION_NAMES
523
+ .filter(name => typeof provided[name] !== 'function');
524
+ if (missing.length > 0) {
525
+ throw new Error(
526
+ `MINIONS_TEST_DIR is set; inject engine watchdog operations instead of using real process control: ${missing.join(', ')}`,
527
+ );
528
+ }
529
+ }
530
+ return {
531
+ isPidAlive: typeof provided.isPidAlive === 'function' ? provided.isPidAlive : isPidAlive,
532
+ reapStrayProcesses: typeof provided.reapStrayProcesses === 'function'
533
+ ? provided.reapStrayProcesses
534
+ : reapStrayProcesses,
535
+ spawnEngine: typeof provided.spawnEngine === 'function' ? provided.spawnEngine : spawnEngine,
536
+ recordCrashLoopRespawn: typeof provided.recordCrashLoopRespawn === 'function'
537
+ ? provided.recordCrashLoopRespawn
538
+ : _recordCrashLoopRespawn,
539
+ };
540
+ }
541
+
542
+ function checkEngine(now, operations) {
511
543
  if (now - _lastEngineRespawnAt < POST_SPAWN_GRACE_MS) return;
512
544
  const control = safeReadJson(CONTROL_PATH_FN());
513
545
  // Respawn when control.json says "running" (PID died unexpectedly) or
@@ -515,7 +547,6 @@ function checkEngine(now) {
515
547
  // above). "paused"/"stopped"/"stopping" are legitimate user-intent states the
516
548
  // supervisor must not override.
517
549
  if (!control || !_isRespawnEligibleEngineState(control.state)) return;
518
- if (control.pid && isPidAlive(control.pid)) return;
519
550
 
520
551
  // Cross-watchdog race guard: dashboard.js's in-process engine watchdog
521
552
  // writes `{pid: null, restarted_at}` BEFORE spawning a new engine. During
@@ -530,13 +561,16 @@ function checkEngine(now) {
530
561
  }
531
562
  }
532
563
 
564
+ const ops = _resolveEngineWatchdogOperations(operations);
565
+ if (control.pid && ops.isPidAlive(control.pid)) return;
566
+
533
567
  console.log(`[supervisor] Engine PID ${control.pid || '(none)'} is dead — respawning...`);
534
568
  // Reap any orphan engines first so a stale control.json PID can't snowball
535
569
  // into an unbounded pile of contending engine processes.
536
- reapStrayProcesses(path.join(MINIONS_DIR, 'engine.js'), 'engine');
537
- const newPid = spawnEngine();
570
+ ops.reapStrayProcesses(path.join(MINIONS_DIR, 'engine.js'), 'engine');
571
+ const newPid = ops.spawnEngine();
538
572
  _lastEngineRespawnAt = now;
539
- _recordCrashLoopRespawn('supervisor:dead-pid');
573
+ ops.recordCrashLoopRespawn('supervisor:dead-pid');
540
574
  console.log(`[supervisor] Engine respawned (new PID: ${newPid})`);
541
575
  }
542
576
 
@@ -546,7 +580,7 @@ function checkEngine(now) {
546
580
  // is alive but control.heartbeat has not advanced for SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS.
547
581
  // The heartbeat is written every ~15s by a dedicated timer in engine/cli.js; if
548
582
  // it goes stale while the PID persists, the event loop is stuck.
549
- function checkEngineHung(now) {
583
+ function checkEngineHung(now, operations) {
550
584
  if (now - _lastEngineRespawnAt < POST_SPAWN_GRACE_MS) return;
551
585
  const control = safeReadJson(CONTROL_PATH_FN());
552
586
  // Include 'degraded' (see _isRespawnEligibleEngineState) so a permanently
@@ -554,8 +588,6 @@ function checkEngineHung(now) {
554
588
  // re-enters) still gets force-restarted once its own heartbeat goes stale,
555
589
  // instead of being ignored forever the moment the state flips to 'degraded'.
556
590
  if (!control || !_isRespawnEligibleEngineState(control.state)) return;
557
- // Only acts when the PID is alive — a dead PID is handled by checkEngine().
558
- if (!control.pid || !isPidAlive(control.pid)) return;
559
591
 
560
592
  const heartbeat = Number(control.heartbeat);
561
593
  if (!heartbeat || !Number.isFinite(heartbeat)) return; // no heartbeat yet (fresh start)
@@ -563,15 +595,20 @@ function checkEngineHung(now) {
563
595
  const heartbeatAge = now - heartbeat;
564
596
  if (heartbeatAge < SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS) return;
565
597
 
598
+ // Only acts when the PID is alive — a dead PID is handled by checkEngine().
599
+ if (!control.pid) return;
600
+ const ops = _resolveEngineWatchdogOperations(operations);
601
+ if (!ops.isPidAlive(control.pid)) return;
602
+
566
603
  console.log(
567
604
  `[supervisor] Engine PID ${control.pid} is alive but heartbeat is stale ` +
568
605
  `(${Math.round(heartbeatAge / 1000)}s old, threshold ${SUPERVISOR_STALE_ENGINE_HEARTBEAT_MS / 1000}s) — ` +
569
606
  `event loop likely frozen; restarting engine`,
570
607
  );
571
- reapStrayProcesses(path.join(MINIONS_DIR, 'engine.js'), 'engine');
572
- const newPid = spawnEngine();
608
+ ops.reapStrayProcesses(path.join(MINIONS_DIR, 'engine.js'), 'engine');
609
+ const newPid = ops.spawnEngine();
573
610
  _lastEngineRespawnAt = now;
574
- _recordCrashLoopRespawn('supervisor:stale-heartbeat');
611
+ ops.recordCrashLoopRespawn('supervisor:stale-heartbeat');
575
612
  console.log(`[supervisor] Engine restarted due to stale heartbeat (new PID: ${newPid})`);
576
613
  }
577
614
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2386",
3
+ "version": "0.1.2388",
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"