@yemi33/minions 0.1.1984 → 0.1.1986

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/bin/minions.js CHANGED
@@ -27,6 +27,7 @@
27
27
  * minions kill Kill active agents and reset to pending
28
28
  * minions complete <dispatch-id> Mark a dispatch completed
29
29
  * minions config set-cli <R> [--model M] Persist default runtime/model
30
+ * minions bridge <subcmd> Constellation bridge: status|health|enable|disable
30
31
  * minions plan <file|text> [proj] Run a plan
31
32
  * minions mcp-sync Sync MCP servers from ~/.claude.json
32
33
  * minions nuke --confirm Factory reset runtime state/config
@@ -681,7 +682,7 @@ const engineCmds = new Set([
681
682
  'start', 'stop', 'status', 'pause', 'resume',
682
683
  'queue', 'sources', 'discover', 'dispatch',
683
684
  'spawn', 'work', 'cleanup', 'mcp-sync', 'plan',
684
- 'kill', 'complete', 'config', 'pr',
685
+ 'kill', 'complete', 'config', 'pr', 'bridge',
685
686
  ]);
686
687
 
687
688
  if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
@@ -714,6 +715,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
714
715
  minions complete <dispatch-id> Manually mark a dispatch as completed
715
716
  minions config set-cli <R> [--model M]
716
717
  Persist default runtime/model without starting
718
+ minions bridge <subcmd> Constellation bridge: status|health|enable|disable
717
719
  minions mcp-sync Sync MCP servers from ~/.claude.json
718
720
  minions cleanup Clean temp files, worktrees, zombies
719
721
  minions pr comment <repo> <n> Post a marker-prepended PR comment via gh
@@ -0,0 +1,53 @@
1
+ // dashboard/js/qa.js — QA tab wiring (W-mpd5ewhj000oc5c5).
2
+ //
3
+ // The QA tab is the canonical home for long-running test/validation surfaces.
4
+ // Phase 1 (this WI) mounts the managed-spawn + keep-processes panels into the
5
+ // QA page using the shared mount API on render-managed.js + render-other.js.
6
+ // The Engine page keeps its own mount registered eagerly inside those modules
7
+ // so dual-render works (engine.html and qa.html show the same data, fed by
8
+ // the same poll loop in refresh.js — no extra fetches, no extra SSE streams).
9
+ //
10
+ // SSE log streaming is lazy: openManagedLog() opens a single EventSource on
11
+ // user click and closeManagedLog() aborts it. The modal is a singleton, so
12
+ // opening from either tab doesn't multiply connections (cf. render-managed.js
13
+ // "single-stream invariant").
14
+ //
15
+ // Out of scope: actual runbook dispatch wiring. The placeholder card with the
16
+ // disabled "+ New runbook" button is the only UX hook for the next phase.
17
+
18
+ (function () {
19
+ function _registerQaMounts() {
20
+ if (typeof mountManagedProcessesPanel === 'function') {
21
+ mountManagedProcessesPanel({
22
+ contentId: 'qa-managed-processes-content',
23
+ countId: 'qa-managed-processes-count',
24
+ });
25
+ }
26
+ if (typeof mountKeepProcessesPanel === 'function') {
27
+ mountKeepProcessesPanel({
28
+ contentId: 'qa-keep-processes-content',
29
+ countId: 'qa-keep-processes-count',
30
+ });
31
+ }
32
+ }
33
+
34
+ // The page fragment is in the DOM at script-load time (all .page divs are
35
+ // assembled into layout.html at build), so registering immediately is safe.
36
+ // The mount API is no-op when the QA fragment is missing (defensive).
37
+ _registerQaMounts();
38
+
39
+ // Close any open managed-log SSE stream when the user navigates away from a
40
+ // page that triggered it — the modal otherwise floats over the new page and
41
+ // the EventSource keeps streaming. Hooks into the existing switchPage()
42
+ // function from state.js without changing its signature.
43
+ if (typeof switchPage === 'function' && !switchPage.__qaWrapped) {
44
+ const _origSwitchPage = switchPage;
45
+ window.switchPage = function (page, pushState) {
46
+ try { if (typeof closeManagedLog === 'function') closeManagedLog(); } catch {}
47
+ return _origSwitchPage(page, pushState);
48
+ };
49
+ window.switchPage.__qaWrapped = true;
50
+ }
51
+
52
+ window.MinionsQA = { _registerQaMounts };
53
+ })();
@@ -103,11 +103,13 @@ function _processStatusUpdate(data) {
103
103
  prunePrdRequeueState(window._lastWorkItems);
104
104
  if (_changed('engineLog', data.engineLog)) renderEngineLog(data.engineLog || []);
105
105
  if (_changed('metrics', data.metrics)) renderMetrics(data.metrics || {});
106
- // keep_processes panel only relevant when on engine page; cheap call (one fetch)
106
+ // keep_processes panel renders on every page where its mount is in the DOM
107
+ // (Engine page + QA page — W-mpd5ewhj000oc5c5). Cheap call (one fetch); the
108
+ // renderer iterates all registered mounts and skips when none are present.
107
109
  if (typeof renderKeepProcesses === 'function') {
108
110
  try { renderKeepProcesses(); } catch {}
109
111
  }
110
- // managed-processes panel — same engine-page-only pattern, ETag-gated so
112
+ // managed-processes panel — same mount-point pattern, ETag-gated so
111
113
  // unchanged ticks return 304 with no body (P-6e2a8b13).
112
114
  if (typeof renderManagedProcesses === 'function') {
113
115
  try { renderManagedProcesses(); } catch {}
@@ -21,6 +21,30 @@ let _managedProcessesEtag = null;
21
21
  let _managedProcessesLastItems = null;
22
22
  let _managedLogES = null;
23
23
 
24
+ // Mount-point registry (W-mpd5ewhj000oc5c5 — QA tab dual-render).
25
+ // Each mount = { contentId, countId } pointing at a DOM container to populate.
26
+ // Both the Engine page and the QA page register their own mount, and
27
+ // renderManagedProcesses() writes to every mount that's currently in the DOM.
28
+ // The Engine page mount is registered eagerly here so the existing engine.html
29
+ // behavior keeps working without an explicit mount call.
30
+ const _managedMounts = [
31
+ { contentId: 'managed-processes-content', countId: 'managed-processes-count' },
32
+ ];
33
+
34
+ function mountManagedProcessesPanel(opts) {
35
+ if (!opts || !opts.contentId) return;
36
+ for (const m of _managedMounts) {
37
+ if (m.contentId === opts.contentId) return; // already registered
38
+ }
39
+ _managedMounts.push({ contentId: opts.contentId, countId: opts.countId || null });
40
+ }
41
+
42
+ function unmountManagedProcessesPanel(contentId) {
43
+ for (let i = _managedMounts.length - 1; i >= 0; i--) {
44
+ if (_managedMounts[i].contentId === contentId) _managedMounts.splice(i, 1);
45
+ }
46
+ }
47
+
24
48
  function _fmtAgo(ms) {
25
49
  if (!ms || ms < 0) return '0s';
26
50
  const s = Math.floor(ms / 1000);
@@ -85,9 +109,12 @@ function _renderManagedTable(items) {
85
109
  }
86
110
 
87
111
  async function renderManagedProcesses() {
88
- const root = document.getElementById('managed-processes-content');
89
- const countEl = document.getElementById('managed-processes-count');
90
- if (!root) return;
112
+ // Resolve every registered mount that's currently in the DOM (Engine page +
113
+ // QA page when present). Skip the fetch entirely when nothing's mounted.
114
+ const liveMounts = _managedMounts
115
+ .map(m => ({ ...m, root: document.getElementById(m.contentId), countEl: m.countId ? document.getElementById(m.countId) : null }))
116
+ .filter(m => m.root);
117
+ if (!liveMounts.length) return;
91
118
  let items;
92
119
  let fetchErr = null;
93
120
  try {
@@ -112,14 +139,15 @@ async function renderManagedProcesses() {
112
139
  fetchErr = e;
113
140
  }
114
141
  let html;
142
+ let countText;
115
143
  if (fetchErr) {
116
- if (countEl) countEl.textContent = '?';
144
+ countText = '?';
117
145
  html = '<span style="color:var(--red)">Failed to load: ' + escHtml(fetchErr.message) + '</span>';
118
146
  } else if (!items || !items.length) {
119
- if (countEl) countEl.textContent = '0';
147
+ countText = '0';
120
148
  html = '<p class="empty">No managed processes. Agents declare them via <code>agents/&lt;id&gt;/managed-spawn.json</code>.</p>';
121
149
  } else {
122
- if (countEl) countEl.textContent = String(items.length);
150
+ countText = String(items.length);
123
151
  // Group by owner_project (empty/missing groups under "(unassigned)").
124
152
  const groups = {};
125
153
  for (const s of items) {
@@ -140,9 +168,13 @@ async function renderManagedProcesses() {
140
168
  }
141
169
  // DocumentFragment instead of innerHTML — keeps the file out of the
142
170
  // dynamic-innerHTML regression gate (cf. render-other.js renderKeepProcesses).
143
- const range = document.createRange();
144
- const frag = range.createContextualFragment(html);
145
- root.replaceChildren(frag);
171
+ // Build the fragment once per mount (DocumentFragment nodes get adopted on
172
+ // first append so each mount needs its own copy).
173
+ for (const m of liveMounts) {
174
+ if (m.countEl) m.countEl.textContent = countText;
175
+ const frag = document.createRange().createContextualFragment(html);
176
+ m.root.replaceChildren(frag);
177
+ }
146
178
  }
147
179
 
148
180
  async function killManagedSpec(name) {
@@ -268,4 +300,6 @@ window.MinionsManagedProcesses = {
268
300
  restartManagedSpec,
269
301
  openManagedLog,
270
302
  closeManagedLog,
303
+ mountManagedProcessesPanel,
304
+ unmountManagedProcessesPanel,
271
305
  };
@@ -488,12 +488,38 @@ window.MinionsOther = { renderProjects, optimisticallyAddProject, projectChipRem
488
488
  // Polls /api/keep-processes every refresh (engine page only) and renders a
489
489
  // table of active agents/<id>/keep-pids.json declarations with one-click
490
490
  // "Kill PID" buttons that hit POST /api/keep-processes/kill.
491
+ //
492
+ // Mount-point registry (W-mpd5ewhj000oc5c5 — QA tab dual-render). Both the
493
+ // Engine page and the QA page register a mount; renderKeepProcesses() writes
494
+ // to every mount that's currently in the DOM. The Engine page mount is
495
+ // registered eagerly so the existing engine.html behavior is unchanged.
496
+ const _keepProcessesMounts = [
497
+ { contentId: 'keep-processes-content', countId: 'keep-processes-count' },
498
+ ];
499
+
500
+ function mountKeepProcessesPanel(opts) {
501
+ if (!opts || !opts.contentId) return;
502
+ for (const m of _keepProcessesMounts) {
503
+ if (m.contentId === opts.contentId) return;
504
+ }
505
+ _keepProcessesMounts.push({ contentId: opts.contentId, countId: opts.countId || null });
506
+ }
507
+
508
+ function unmountKeepProcessesPanel(contentId) {
509
+ for (let i = _keepProcessesMounts.length - 1; i >= 0; i--) {
510
+ if (_keepProcessesMounts[i].contentId === contentId) _keepProcessesMounts.splice(i, 1);
511
+ }
512
+ }
491
513
 
492
514
  async function renderKeepProcesses() {
493
- const root = document.getElementById('keep-processes-content');
494
- const countEl = document.getElementById('keep-processes-count');
495
- if (!root) return;
515
+ // Resolve every registered mount currently in the DOM (Engine + QA pages).
516
+ // Skip the fetch entirely when nothing is mounted.
517
+ const liveMounts = _keepProcessesMounts
518
+ .map(m => ({ ...m, root: document.getElementById(m.contentId), countEl: m.countId ? document.getElementById(m.countId) : null }))
519
+ .filter(m => m.root);
520
+ if (!liveMounts.length) return;
496
521
  let html;
522
+ let countText;
497
523
  let items;
498
524
  let fetchErr = null;
499
525
  try {
@@ -504,13 +530,13 @@ async function renderKeepProcesses() {
504
530
  fetchErr = e;
505
531
  }
506
532
  if (fetchErr) {
507
- if (countEl) countEl.textContent = '?';
533
+ countText = '?';
508
534
  html = '<span style="color:var(--red)">Failed to load: ' + escHtml(fetchErr.message) + '</span>';
509
535
  } else if (!items.length) {
510
- if (countEl) countEl.textContent = '0';
536
+ countText = '0';
511
537
  html = '<p class="empty">No agents have left processes running. Set <code>meta.keep_processes: true</code> on a work item to enable.</p>';
512
538
  } else {
513
- if (countEl) countEl.textContent = String(items.length);
539
+ countText = String(items.length);
514
540
  html = items.map(function (it) {
515
541
  if (!it.valid) {
516
542
  return '<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:8px;background:var(--surface2)">' +
@@ -548,10 +574,14 @@ async function renderKeepProcesses() {
548
574
  // Use DocumentFragment instead of innerHTML assignment to keep this
549
575
  // function out of the dynamic-innerHTML regression gate (see
550
576
  // test/unit.test.js DYNAMIC_INNERHTML_BASELINE). All embedded
551
- // user-controlled fields above are wrapped in escHtml().
552
- const range = document.createRange();
553
- const frag = range.createContextualFragment(html);
554
- root.replaceChildren(frag);
577
+ // user-controlled fields above are wrapped in escHtml(). Build the
578
+ // fragment once per mount (DocumentFragment nodes get adopted on first
579
+ // append so each mount needs its own copy).
580
+ for (const m of liveMounts) {
581
+ if (m.countEl) m.countEl.textContent = countText;
582
+ const frag = document.createRange().createContextualFragment(html);
583
+ m.root.replaceChildren(frag);
584
+ }
555
585
  }
556
586
 
557
587
  async function killKeepPid(agentId, pid) {
@@ -573,5 +603,5 @@ async function killKeepPid(agentId, pid) {
573
603
  renderKeepProcesses();
574
604
  }
575
605
 
576
- window.MinionsKeepProcesses = { renderKeepProcesses, killKeepPid };
606
+ window.MinionsKeepProcesses = { renderKeepProcesses, killKeepPid, mountKeepProcessesPanel, unmountKeepProcessesPanel };
577
607
 
@@ -75,6 +75,7 @@
75
75
  <a class="sidebar-link" data-page="watches" href="/watches" title="Persistent watches that monitor PRs, work items, and branches">Watches</a>
76
76
  <a class="sidebar-link" data-page="pipelines" href="/pipelines" title="Multi-stage workflows — chain tasks, meetings, plans with dependencies">Pipelines</a>
77
77
  <a class="sidebar-link" data-page="meetings" href="/meetings">Meetings</a>
78
+ <a class="sidebar-link" data-page="qa" href="/qa" title="QA — live processes + validation runbooks (managed-spawn + keep-processes)">QA</a>
78
79
  <a class="sidebar-link" data-page="engine" href="/engine">Engine</a>
79
80
  </nav>
80
81
  <div class="page-content" id="page-content"><!-- __PAGES__ --></div>
@@ -0,0 +1,23 @@
1
+ <section>
2
+ <h2>Live Processes <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">canonical home for managed instances + agent-left processes (W-mpd5ewhj000oc5c5)</span></h2>
3
+ <p class="empty" style="margin:4px 0 12px 0">The QA tab is the foundation for human-driven and agent-driven validation against running managed instances. Phase 1 surfaces the live process inventory; runbook dispatch lands in a follow-up WI.</p>
4
+ </section>
5
+ <section id="qa-managed-processes-section">
6
+ <h2>Managed Processes <span class="count" id="qa-managed-processes-count">0</span>
7
+ <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">engine-managed long-running services (P-6e2a8b13 — managed-spawn primitive)</span>
8
+ </h2>
9
+ <div id="qa-managed-processes-content"><p class="empty">No managed processes. Agents declare them via <code>agents/&lt;id&gt;/managed-spawn.json</code>.</p></div>
10
+ </section>
11
+ <section id="qa-keep-processes-section">
12
+ <h2>Keep-Processes <span class="count" id="qa-keep-processes-count">0</span>
13
+ <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">processes left running by agents (W-mp68q6ke0010de68 — opt-in keep_processes flag)</span>
14
+ </h2>
15
+ <div id="qa-keep-processes-content"><p class="empty">No agents have left processes running. Set <code>meta.keep_processes: true</code> on a work item to enable.</p></div>
16
+ </section>
17
+ <section id="qa-runbooks-section">
18
+ <h2>Validation Runbooks <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">human or agent-driven smoke / E2E flows against the live instances above</span></h2>
19
+ <div style="border:1px dashed var(--border);border-radius:6px;padding:16px;background:var(--surface2);text-align:center">
20
+ <p class="empty" style="margin:0 0 12px 0">Validation runbooks will live here. Coming soon: dispatch a human or agent to validate a running instance.</p>
21
+ <button id="qa-new-runbook-btn" disabled title="Coming soon — runbook schema lands in the next WI" style="padding:6px 14px;background:var(--surface);color:var(--muted);border:1px solid var(--border);border-radius:4px;cursor:not-allowed;font-size:12px">+ New runbook</button>
22
+ </div>
23
+ </section>
@@ -20,7 +20,7 @@ function buildDashboardHtml() {
20
20
  const layout = safeRead(layoutPath);
21
21
  const css = safeRead(path.join(dashDir, 'styles.css'));
22
22
 
23
- const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'engine'];
23
+ const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'qa', 'engine'];
24
24
  let pageHtml = '';
25
25
  for (const p of pages) {
26
26
  const content = safeRead(path.join(dashDir, 'pages', p + '.html'));
@@ -34,7 +34,7 @@ function buildDashboardHtml() {
34
34
  'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
35
35
  'render-other', 'render-managed', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
36
36
  'command-parser', 'command-input', 'command-center', 'command-history',
37
- 'modal', 'modal-qa', 'settings', 'refresh'
37
+ 'modal', 'modal-qa', 'settings', 'qa', 'refresh'
38
38
  ];
39
39
  let jsHtml = '';
40
40
  for (const f of jsFiles) {
package/dashboard.js CHANGED
@@ -95,6 +95,21 @@ function reloadConfig() {
95
95
  }
96
96
  ensureConfiguredProjectStateFiles();
97
97
 
98
+ // Pre-warm git-status cache for every configured project so the first
99
+ // /api/status after dashboard boot already has branch/dirty data — without
100
+ // blocking the event loop on the per-project git shell-outs. Fire-and-forget,
101
+ // boot-only: re-warming on every reloadConfig() would spawn 4× git probes on
102
+ // every 10s status-poll cache miss. Project add/remove handlers explicitly
103
+ // invoke this when the project list actually changes.
104
+ function warmProjectGitStatusCache() {
105
+ for (const p of PROJECTS) {
106
+ if (p && p.localPath) {
107
+ try { queries.warmProjectGitStatus(p.localPath); } catch { /* swallow — warming is opportunistic */ }
108
+ }
109
+ }
110
+ }
111
+ warmProjectGitStatusCache();
112
+
98
113
  function resolveScheduleProjectValue(project, projects = PROJECTS) {
99
114
  if (project === undefined) return { project: undefined };
100
115
  const target = shared.resolveConfiguredProject(project, projects);
@@ -860,7 +875,7 @@ function buildDashboardHtml() {
860
875
  // Assemble page fragments. Each wrapper gets a `.page-toast` inline slot at
861
876
  // the top — showToast('cmd-toast', …) auto-routes here when a page is active,
862
877
  // so feedback lands near the action instead of the floating top-right toast.
863
- const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'engine'];
878
+ const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'qa', 'engine'];
864
879
  const pageToast = ' <div class="cmd-toast cmd-toast-inline page-toast" style="margin:6px 16px"></div>\n';
865
880
  let pageHtml = '';
866
881
  for (const p of pages) {
@@ -876,7 +891,7 @@ function buildDashboardHtml() {
876
891
  'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
877
892
  'render-other', 'render-managed', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
878
893
  'command-parser', 'command-input', 'command-center', 'command-history',
879
- 'modal', 'modal-qa', 'settings', 'refresh'
894
+ 'modal', 'modal-qa', 'settings', 'qa', 'refresh'
880
895
  ];
881
896
  let jsHtml = '';
882
897
  for (const f of jsFiles) {
@@ -1581,6 +1596,8 @@ const CC_CALL_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour — long-running CC orchest
1581
1596
  const CC_INFLIGHT_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes — auto-release if request hangs
1582
1597
  const CC_LOCK_WAIT_MS = 200; // grace period for previous handler's finally to release lock
1583
1598
  const CC_STREAM_HEARTBEAT_MS = 15000; // keep streaming responses alive across proxies/restart races
1599
+ const CC_STREAM_STALL_THRESHOLD_MS = 3000; // log [cc-stall] when heartbeat fires >3s late (event-loop blocked)
1600
+ const CC_LOG_ERROR_MAX_LEN = 80; // truncate exception messages in [cc-stream] log lines
1584
1601
  const CC_STREAM_REATTACH_GRACE_MS = 60000; // keep CC job alive briefly after disconnect so the UI can reattach
1585
1602
  const CC_STREAM_DONE_RETENTION_MS = 30000; // retain final payload briefly so reconnect can still receive it
1586
1603
  const CC_LIVE_STREAM_MAX_AGE_MS = shared.ENGINE_DEFAULTS.ccLiveStreamMaxAgeMs;
@@ -1704,6 +1721,32 @@ function _ccTabIsInFlight(tabId) {
1704
1721
  return true;
1705
1722
  }
1706
1723
 
1724
+ // Emits exactly one structured log line per CC SSE stream termination, so
1725
+ // "Failed to fetch" reports can be matched to a server-side reason (restart,
1726
+ // abort, timeout, rate-limit, write-failed, stall, ...). Idempotent via the
1727
+ // telemetry object's `_logged` flag — first call wins, subsequent calls no-op.
1728
+ function _logCcStreamEnd(telemetry, reason, extra) {
1729
+ if (!telemetry || telemetry._logged) return;
1730
+ telemetry._logged = true;
1731
+ const durationMs = telemetry.startedAt ? (Date.now() - telemetry.startedAt) : 0;
1732
+ const parts = [
1733
+ `tab=${telemetry.tabId || 'unknown'}`,
1734
+ `session=${telemetry.sessionId || 'none'}`,
1735
+ `reason=${reason || 'unknown'}`,
1736
+ `duration=${durationMs}ms`,
1737
+ `chunks=${telemetry.chunks || 0}`,
1738
+ `tools=${telemetry.tools || 0}`,
1739
+ `bytes=${telemetry.bytes || 0}`,
1740
+ ];
1741
+ if (extra && typeof extra === 'object') {
1742
+ for (const [k, v] of Object.entries(extra)) {
1743
+ if (v === undefined || v === null) continue;
1744
+ parts.push(`${k}=${v}`);
1745
+ }
1746
+ }
1747
+ console.log(`[cc-stream] ${parts.join(' ')}`);
1748
+ }
1749
+
1707
1750
  // _ccPromptHash computed after CC_STATIC_SYSTEM_PROMPT is defined (see below)
1708
1751
 
1709
1752
  function ccSessionValid() {
@@ -6268,6 +6311,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6268
6311
  });
6269
6312
  if (duplicate) return jsonReply(res, 400, { error: 'Project already linked at ' + target });
6270
6313
  reloadConfig(); // Update in-memory project list immediately
6314
+ warmProjectGitStatusCache(); // Probe the new project's git status in the background
6271
6315
  // includeSlow: PROJECTS lives in the slow-state cache (60s TTL); without
6272
6316
  // flushing it, /api/status keeps returning the previous project list for
6273
6317
  // up to a minute after the add. Matches handleProjectsRemove's behavior.
@@ -6713,6 +6757,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6713
6757
  }
6714
6758
 
6715
6759
  async function handleCommandCenterStream(req, res) {
6760
+ // Per-stream telemetry — _logCcStreamEnd is idempotent so callers don't
6761
+ // need to coordinate. Augmented by writeCcEvent (chunks, bytes) and the
6762
+ // heartbeat stall detector below.
6763
+ const _ccTelemetry = { startedAt: Date.now(), tabId: null, sessionId: null, chunks: 0, tools: 0, bytes: 0, _logged: false, _stallLogged: false };
6716
6764
  // SSE Origin gate (belt-and-suspenders: the top-level dispatcher has
6717
6765
  // already rejected disallowed origins on POST, but validate again here
6718
6766
  // before res.writeHead(200, text/event-stream) so any future refactor
@@ -6723,16 +6771,29 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6723
6771
  res.statusCode = 403;
6724
6772
  res.setHeader('Content-Type', 'application/json');
6725
6773
  res.end(JSON.stringify({ error: 'Origin not allowed' }));
6774
+ _logCcStreamEnd(_ccTelemetry, 'origin-rejected', { origin: _origin });
6775
+ return;
6776
+ }
6777
+ if (checkRateLimit('command-center', 10)) {
6778
+ res.statusCode = 429; res.end('Rate limited');
6779
+ _logCcStreamEnd(_ccTelemetry, 'rate-limited-pre-stream');
6726
6780
  return;
6727
6781
  }
6728
- if (checkRateLimit('command-center', 10)) { res.statusCode = 429; res.end('Rate limited'); return; }
6729
6782
  let tabId;
6730
6783
  let _ccStreamAbort = null;
6731
6784
  let _ccStreamEnded = false;
6732
6785
  let _ccHeartbeatTimer = null;
6786
+ let _ccLastHeartbeatAt = Date.now();
6733
6787
  const writeCcEvent = (payload) => {
6734
6788
  try {
6735
- res.write('data: ' + JSON.stringify(payload) + '\n\n');
6789
+ const wire = 'data: ' + JSON.stringify(payload) + '\n\n';
6790
+ res.write(wire);
6791
+ if (payload && payload.type === 'chunk') {
6792
+ _ccTelemetry.chunks++;
6793
+ _ccTelemetry.bytes += Buffer.byteLength(String(payload.text || ''), 'utf8');
6794
+ } else if (payload && payload.type === 'tool') {
6795
+ _ccTelemetry.tools++;
6796
+ }
6736
6797
  return true;
6737
6798
  } catch {
6738
6799
  return false;
@@ -6744,29 +6805,66 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6744
6805
  _ccHeartbeatTimer = null;
6745
6806
  }
6746
6807
  };
6808
+ // Event-loop-stall detector — piggybacks on the heartbeat interval.
6809
+ // setInterval timers fire late when the loop is blocked; if the gap
6810
+ // between fires exceeds HEARTBEAT_MS + STALL_THRESHOLD_MS, the loop was
6811
+ // stuck (sync I/O, large JSON.parse, shelled-out command, ...). Log once
6812
+ // per stream to avoid spamming.
6813
+ const _checkStall = () => {
6814
+ const now = Date.now();
6815
+ const expected = _ccLastHeartbeatAt + CC_STREAM_HEARTBEAT_MS;
6816
+ const drift = now - expected;
6817
+ _ccLastHeartbeatAt = now;
6818
+ if (drift > CC_STREAM_STALL_THRESHOLD_MS && !_ccTelemetry._stallLogged) {
6819
+ _ccTelemetry._stallLogged = true;
6820
+ console.warn(`[cc-stall] tab=${_ccTelemetry.tabId || 'unknown'} drift=${drift}ms heartbeatExpected=${CC_STREAM_HEARTBEAT_MS}ms — event loop blocked`);
6821
+ }
6822
+ };
6823
+ // Start the heartbeat + stall-detector interval. Both SSE paths (reconnect
6824
+ // and normal stream) used to duplicate this block; factor it out so the
6825
+ // stall check and write-failure log-line stay in one place.
6826
+ const _startCcHeartbeat = (writeFailedReason) => {
6827
+ _ccLastHeartbeatAt = Date.now();
6828
+ _ccHeartbeatTimer = setInterval(() => {
6829
+ if (_ccStreamEnded) {
6830
+ stopCcHeartbeat();
6831
+ return;
6832
+ }
6833
+ _checkStall();
6834
+ if (!writeCcEvent({ type: 'heartbeat' })) {
6835
+ stopCcHeartbeat();
6836
+ if (writeFailedReason) _logCcStreamEnd(_ccTelemetry, writeFailedReason);
6837
+ }
6838
+ }, CC_STREAM_HEARTBEAT_MS);
6839
+ };
6747
6840
  const finishMissingRuntime = (result, liveState) => {
6748
6841
  const text = result.text || result.stderr || 'Minions runtime is not installed or configured.';
6749
6842
  liveState.donePayload = { type: 'done', text, actions: [], sessionId: null, missingRuntime: true };
6750
6843
  if (liveState.writer) liveState.writer(liveState.donePayload);
6751
6844
  if (liveState.endResponse) liveState.endResponse();
6752
6845
  _scheduleCcLiveCleanup(tabId);
6846
+ _logCcStreamEnd(_ccTelemetry, 'missing-runtime');
6753
6847
  };
6754
6848
  try {
6755
6849
  const body = await readBody(req);
6756
- if (!body.message && !body.reconnect) { res.statusCode = 400; res.end('message required'); return; }
6850
+ if (!body.message && !body.reconnect) {
6851
+ res.statusCode = 400; res.end('message required');
6852
+ _logCcStreamEnd(_ccTelemetry, 'bad-request');
6853
+ return;
6854
+ }
6757
6855
  tabId = body.tabId || 'default';
6856
+ _ccTelemetry.tabId = tabId;
6857
+ _ccTelemetry.sessionId = body.sessionId || null;
6758
6858
  if (body.reconnect) {
6759
6859
  const live = _getCcLiveStream(tabId);
6760
- if (!live) { res.statusCode = 409; res.end('No live command-center response to reconnect'); return; }
6860
+ if (!live) {
6861
+ res.statusCode = 409; res.end('No live command-center response to reconnect');
6862
+ _logCcStreamEnd(_ccTelemetry, 'reconnect-not-found');
6863
+ return;
6864
+ }
6761
6865
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
6762
6866
  writeCcEvent({ type: 'heartbeat' });
6763
- _ccHeartbeatTimer = setInterval(() => {
6764
- if (_ccStreamEnded) {
6765
- stopCcHeartbeat();
6766
- return;
6767
- }
6768
- if (!writeCcEvent({ type: 'heartbeat' })) stopCcHeartbeat();
6769
- }, CC_STREAM_HEARTBEAT_MS);
6867
+ _startCcHeartbeat();
6770
6868
  let reconnectDone;
6771
6869
  const reconnectDonePromise = new Promise(resolve => { reconnectDone = resolve; });
6772
6870
  _attachCcLiveStream(tabId, writeCcEvent, () => {
@@ -6774,6 +6872,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6774
6872
  _ccStreamEnded = true;
6775
6873
  stopCcHeartbeat();
6776
6874
  try { res.end(); } catch {}
6875
+ _logCcStreamEnd(_ccTelemetry, 'reconnect-attached-done');
6777
6876
  reconnectDone();
6778
6877
  });
6779
6878
  req.on('close', () => {
@@ -6781,6 +6880,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6781
6880
  stopCcHeartbeat();
6782
6881
  _detachCcLiveStream(tabId, writeCcEvent);
6783
6882
  _scheduleCcLiveAbort(tabId);
6883
+ _logCcStreamEnd(_ccTelemetry, 'reconnect-client-disconnect');
6784
6884
  reconnectDone();
6785
6885
  });
6786
6886
  for (const tool of live.tools || []) {
@@ -6793,6 +6893,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6793
6893
  stopCcHeartbeat();
6794
6894
  try { res.end(); } catch {}
6795
6895
  _scheduleCcLiveCleanup(tabId);
6896
+ _logCcStreamEnd(_ccTelemetry, 'reconnect-replayed-done');
6796
6897
  return;
6797
6898
  }
6798
6899
  await reconnectDonePromise;
@@ -6804,7 +6905,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6804
6905
  if (prevAbort) { prevAbort(); }
6805
6906
  await new Promise(r => setTimeout(r, CC_LOCK_WAIT_MS)); // let previous finally run and release the lock
6806
6907
  if (_ccTabIsInFlight(tabId)) {
6807
- res.statusCode = 429; res.end('This tab is already processing'); return;
6908
+ res.statusCode = 429; res.end('This tab is already processing');
6909
+ _logCcStreamEnd(_ccTelemetry, 'tabid-collision');
6910
+ return;
6808
6911
  }
6809
6912
  }
6810
6913
  ccInFlightTabs.set(tabId, Date.now());
@@ -6818,13 +6921,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6818
6921
 
6819
6922
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
6820
6923
  writeCcEvent({ type: 'heartbeat' }); // flush headers quickly and keep intermediaries from idling out
6821
- _ccHeartbeatTimer = setInterval(() => {
6822
- if (_ccStreamEnded) {
6823
- stopCcHeartbeat();
6824
- return;
6825
- }
6826
- if (!writeCcEvent({ type: 'heartbeat' })) stopCcHeartbeat();
6827
- }, CC_STREAM_HEARTBEAT_MS);
6924
+ _startCcHeartbeat('heartbeat-write-failed');
6828
6925
  // Kill LLM process immediately if client disconnects mid-stream.
6829
6926
  // Keep the LLM alive briefly after disconnect so the UI can reattach to the same in-flight turn.
6830
6927
  req.on('close', () => {
@@ -6832,6 +6929,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6832
6929
  stopCcHeartbeat();
6833
6930
  _detachCcLiveStream(tabId, writeCcEvent);
6834
6931
  _scheduleCcLiveAbort(tabId);
6932
+ _logCcStreamEnd(_ccTelemetry, 'client-disconnect');
6835
6933
  }
6836
6934
  });
6837
6935
 
@@ -6955,7 +7053,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6955
7053
  return;
6956
7054
  }
6957
7055
  if (!result.text) {
6958
- if (req.destroyed) { _ccStreamEnded = true; return; } // client already gone — nothing to send
7056
+ if (req.destroyed) {
7057
+ _ccStreamEnded = true;
7058
+ _logCcStreamEnd(_ccTelemetry, 'llm-empty-client-gone', { code: result.code });
7059
+ return;
7060
+ }
6959
7061
  const debugInfo = result.code !== 0 ? `(exit code ${result.code})` : '(empty response)';
6960
7062
  const stderrTail = (result.stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
6961
7063
  console.error(`[CC-stream] Failed: code=${result.code}, stderr=${(result.stderr || '').slice(0, 500)}, stdout_tail=${(result.raw || '').slice(-500)}`);
@@ -6964,6 +7066,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6964
7066
  if (liveState.writer) liveState.writer(liveState.donePayload);
6965
7067
  if (liveState.endResponse) liveState.endResponse();
6966
7068
  _scheduleCcLiveCleanup(tabId);
7069
+ _logCcStreamEnd(_ccTelemetry, 'llm-failed-fallback-sent', { code: result.code });
6967
7070
  return;
6968
7071
  }
6969
7072
 
@@ -7013,6 +7116,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7013
7116
 
7014
7117
  if (liveState.endResponse) liveState.endResponse();
7015
7118
  _scheduleCcLiveCleanup(tabId);
7119
+ _ccTelemetry.sessionId = responseSessionId || _ccTelemetry.sessionId;
7120
+ _logCcStreamEnd(_ccTelemetry, 'done', { actions: actions.length });
7016
7121
  } finally {
7017
7122
  stopCcHeartbeat();
7018
7123
  _releaseCCTab(tabId);
@@ -7026,9 +7131,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
7026
7131
  res.statusCode = e.statusCode || 500;
7027
7132
  res.setHeader('Content-Type', 'application/json');
7028
7133
  try { res.end(JSON.stringify({ error: e.message })); } catch {}
7134
+ _logCcStreamEnd(_ccTelemetry, 'error-pre-stream', { error: (e && e.message ? e.message.slice(0, CC_LOG_ERROR_MAX_LEN) : 'unknown') });
7029
7135
  } else {
7030
7136
  writeCcEvent({ type: 'error', error: e.message });
7031
7137
  _ccStreamEnded = true; try { res.end(); } catch {}
7138
+ _logCcStreamEnd(_ccTelemetry, 'error-mid-stream', { error: (e && e.message ? e.message.slice(0, CC_LOG_ERROR_MAX_LEN) : 'unknown') });
7032
7139
  }
7033
7140
  }
7034
7141
  }
@@ -8520,8 +8627,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
8520
8627
  } },
8521
8628
  { method: 'GET', path: '/api/agent-output', desc: 'Read agent output log file', params: 'file', handler: async (req, res) => {
8522
8629
  const file = new URL(req.url, 'http://localhost').searchParams.get('file');
8523
- if (!file || file.includes('..') || file.includes('\0') || !file.startsWith('agents/')) return jsonReply(res, 400, { error: 'invalid file' });
8524
- const content = safeRead(path.join(MINIONS_DIR, file));
8630
+ let safePath;
8631
+ try { safePath = shared.sanitizePath(file, MINIONS_DIR); }
8632
+ catch { return jsonReply(res, 400, { error: 'invalid path' }); }
8633
+ const agentsDir = path.join(MINIONS_DIR, 'agents');
8634
+ if (!safePath.startsWith(agentsDir + path.sep)) return jsonReply(res, 400, { error: 'path must be within agents/' });
8635
+ const content = safeRead(safePath);
8525
8636
  if (content === null) return jsonReply(res, 404, { error: 'not found' });
8526
8637
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
8527
8638
  res.setHeader('Cache-Control', 'no-cache');
package/docs/.nojekyll ADDED
File without changes