@yemi33/minions 0.1.2044 → 0.1.2046

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
@@ -11,8 +11,8 @@ Inspired by and initially scaffolded from [Brady Gaster's Squad](https://bradyga
11
11
  ## Prerequisites
12
12
 
13
13
  - **Node.js** 18+ (LTS recommended)
14
- - **Claude Code CLI** — install with `npm install -g @anthropic-ai/claude-code`
15
- - **Anthropic API key** or Claude Max subscription (agents spawn Claude Code sessions)
14
+ - **A supported runtime CLI** — Minions defaults to GitHub Copilot CLI (`npm install -g @github/copilot`). Claude Code CLI (`npm install -g @anthropic-ai/claude-code`) is also supported; switch with `minions config set-cli claude` or per-agent `cli` overrides.
15
+ - **Auth for your runtime** GitHub Copilot subscription (Copilot CLI handles its own auth) or an Anthropic API key / Claude Max subscription
16
16
  - **Git** — agents create worktrees for all code changes
17
17
 
18
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.
@@ -194,6 +194,35 @@ function _ccIsNewDashboardInstance(preRestartDashId, newDashId, clickTimeMs) {
194
194
  return Number.isFinite(parsed) && parsed > clickTimeMs;
195
195
  }
196
196
 
197
+ // Shown when ccRestartMinions's POST to /api/dashboard/restart can't be
198
+ // delivered — i.e. the dashboard process itself is dead, not just stale. In
199
+ // that state the in-browser button cannot bring anything back up: a detached
200
+ // `minions restart` child is only spawned if the POST reaches the dashboard.
201
+ // The recovery path lives outside the browser. Surface that fact explicitly
202
+ // instead of polling for a new dashboardStartedAt that never arrives.
203
+ function _ccShowDashboardDeadFallback(btn, reason) {
204
+ var humanReason = reason || 'connection refused';
205
+ var msg = 'The dashboard process appears to be down — the Restart button can\'t reach it (' + humanReason + ').\n\nRun this in your terminal to recover:\n\n minions restart';
206
+ if (btn) {
207
+ try {
208
+ btn.disabled = false;
209
+ btn.textContent = 'Run `minions restart` in terminal';
210
+ } catch {}
211
+ }
212
+ var copyOk = function() {
213
+ if (typeof showToast === 'function') showToast('cmd-toast', '`minions restart` copied — paste in your terminal', true);
214
+ };
215
+ var copyFail = function() {
216
+ try { window.prompt('Copy this command, then run it in your terminal:', 'minions restart'); }
217
+ catch { try { alert(msg); } catch {} }
218
+ };
219
+ try {
220
+ if (navigator.clipboard && navigator.clipboard.writeText) {
221
+ navigator.clipboard.writeText('minions restart').then(copyOk).catch(copyFail);
222
+ } else { copyFail(); }
223
+ } catch { copyFail(); }
224
+ }
225
+
197
226
  // Triggered by the CC "Restart Minions" recovery button when a stale dashboard
198
227
  // connection is killing CC streams with "Failed to fetch". Spawns the same
199
228
  // `minions restart` flow as the CLI command (kills + respawns engine AND
@@ -242,14 +271,42 @@ async function ccRestartMinions(btn) {
242
271
  } catch { /* best-effort — clickTime fallback inside helper still covers us */ }
243
272
  }
244
273
 
245
- // Fire-and-forget the restart POST. We do NOT await it the dashboard often
246
- // kills its own process before the response is flushed, so the fetch throws
247
- // even though the restart child (a detached `minions restart`) is happily
248
- // running. The polling loop is the source of truth for completion.
274
+ // Briefly await the POST so we can distinguish two failure shapes that look
275
+ // identical from a fire-and-forget callsite:
276
+ //
277
+ // (a) Dashboard alive when POST landed but died mid-response. The spawned
278
+ // restart child is running — polling /api/status WILL eventually see
279
+ // a new dashboardStartedAt and the existing reload path works.
280
+ //
281
+ // (b) Dashboard already dead when the user clicked. ECONNREFUSED before
282
+ // any byte hits the wire. No restart child was spawned, polling would
283
+ // wait the full 90 s and time out into a doomed reload (browser
284
+ // lands on a port-not-listening error). This is the case operators
285
+ // most often hit — the user just witnessed it today.
286
+ //
287
+ // 4 s is enough for the POST to deliver headers on a healthy box. If we
288
+ // saw EITHER res.ok OR a delayed-disconnect-with-headers-seen, assume the
289
+ // spawned child is on its way and start the polling loop. Otherwise fall
290
+ // back to the terminal-runnable command.
291
+ var postCtl = new AbortController();
292
+ var postTimer = setTimeout(function() { postCtl.abort(); }, 4000);
293
+ var postDelivered = false;
294
+ var postError = null;
249
295
  try {
250
- fetch('/api/dashboard/restart', { method: 'POST', headers: { 'Content-Type': 'application/json' } })
251
- .catch(function() { /* dashboard process likely killed mid-response — expected */ });
252
- } catch { /* network layer threw before fetch even queued — also expected */ }
296
+ var postRes = await fetch('/api/dashboard/restart', { method: 'POST', headers: { 'Content-Type': 'application/json' }, signal: postCtl.signal });
297
+ if (postRes && postRes.ok) postDelivered = true;
298
+ else postError = 'HTTP ' + (postRes ? postRes.status : '?');
299
+ } catch (e) {
300
+ postError = String((e && e.message) || e);
301
+ } finally {
302
+ clearTimeout(postTimer);
303
+ }
304
+
305
+ if (!postDelivered) {
306
+ _ccShowDashboardDeadFallback(btn, postError);
307
+ return;
308
+ }
309
+
253
310
  if (btn) { try { btn.textContent = 'Restarting Minions — waiting for new dashboard...'; } catch {} }
254
311
 
255
312
  var startedAt = Date.now();
@@ -80,9 +80,10 @@ function renderFre(statusOrProjects) {
80
80
 
81
81
  // Resolve the currently-configured runtime CLI for the explainer copy.
82
82
  // /api/status surfaces this as autoMode.defaultCli (resolveAgentCli(null, engine)).
83
- // Fall back to autoMode.ccCli (also defaultCli-derived when ccCli unset) then 'claude'.
83
+ // Fall back to autoMode.ccCli (also defaultCli-derived when ccCli unset) then 'copilot'
84
+ // (matches ENGINE_DEFAULTS.defaultCli — W-mpmwxkk40007c995).
84
85
  const auto = (status && status.autoMode) || {};
85
- const runtimeCli = String(auto.defaultCli || auto.ccCli || 'claude');
86
+ const runtimeCli = String(auto.defaultCli || auto.ccCli || 'copilot');
86
87
 
87
88
  const cardStyle = [
88
89
  'margin:12px 24px',
@@ -71,7 +71,7 @@ const RENDER_VERSIONS = {
71
71
  projects: 1,
72
72
  notes: 1,
73
73
  prd: 1,
74
- prs: 1,
74
+ prs: 2,
75
75
  archivedPrds: 1,
76
76
  engine: 2,
77
77
  version: 1,
@@ -206,7 +206,16 @@ function _processStatusUpdate(data) {
206
206
 
207
207
 
208
208
  // Render only changed sections
209
- if (_changed('agents', data.agents)) { renderAgents(data.agents); cmdUpdateAgentList(data.agents); }
209
+ // Agents is exempt from the _changed gate: real-time status correctness on
210
+ // the Minions Members grid (status badge, running timer, Last-run line)
211
+ // beats the cost of re-rendering 5 cards every poll tick. The gate was
212
+ // causing visible staleness when the ref-eq / JSON-stringify short-circuit
213
+ // falsely matched across ticks (W-mpn7keq9000302c9). Still call _changed
214
+ // here so the _lastChangedFlags diag ring-buffer keeps recording whether
215
+ // the agents payload actually moved this tick.
216
+ _changed('agents', data.agents);
217
+ renderAgents(data.agents);
218
+ cmdUpdateAgentList(data.agents);
210
219
  if (_changed('prdProgress', data.prdProgress) || _changed('prdPrs', data.pullRequests?.length)) { renderPrdProgress(data.prdProgress); _cachePrdItems(data.prdProgress); }
211
220
  if (_changed('inbox', data.inbox)) renderInbox(data.inbox || []);
212
221
  if (_changed('projects', data.projects)) { cmdUpdateProjectList(data.projects || []); renderProjects(data.projects || []); }
@@ -343,6 +352,116 @@ let _lastStatusData = null;
343
352
  // fresh state anyway.
344
353
  let _refreshInFlight = false;
345
354
 
355
+ // ── Dashboard-unreachable detector ───────────────────────────────────────
356
+ // When the dashboard process dies, /api/status throws or 5xxs and the
357
+ // existing catch block just console.errors — the page keeps painting the
358
+ // last successful snapshot. Operators have reported the resulting symptom
359
+ // many times: badge says "running", CC POST throws "Failed to fetch", and
360
+ // it's not obvious the dashboard itself is dead (vs. wedged, vs. the
361
+ // engine being down).
362
+ //
363
+ // Trip conditions: 2 consecutive failed polls OR >12 s since the last
364
+ // success (3× the 4 s poll cadence, so a single flaky tick doesn't fire).
365
+ // On trip we show a sticky red banner with two recovery actions:
366
+ // 1. "Restart Minions" → ccRestartMinions (works when dashboard is
367
+ // alive-but-stale; falls through to copy-to-clipboard when the POST
368
+ // itself fails)
369
+ // 2. "Copy minions restart" → terminal fallback for the dashboard-is-
370
+ // truly-dead case the in-browser button can't fix on its own
371
+ // Also overrides the engine badge to UNKNOWN/muted so the misleading
372
+ // "RUNNING" pill stops showing while data is frozen.
373
+ let _lastStatusOkAt = Date.now();
374
+ let _consecutiveStatusFails = 0;
375
+ let _unreachableSince = 0; // 0 = currently reachable
376
+ let _unreachableAgeTimer = null;
377
+ const _UNREACHABLE_FAIL_THRESHOLD = 2;
378
+ const _UNREACHABLE_AGE_MS = 12000;
379
+
380
+ function _formatAge(ms) {
381
+ if (ms < 1000) return 'just now';
382
+ const s = Math.round(ms / 1000);
383
+ if (s < 60) return s + 's ago';
384
+ const m = Math.floor(s / 60);
385
+ const rem = s % 60;
386
+ return rem ? m + 'm ' + rem + 's ago' : m + 'm ago';
387
+ }
388
+
389
+ function _refreshUnreachableAgeText() {
390
+ if (!_unreachableSince) return;
391
+ // The age span is rendered into the shared #engine-alert element by
392
+ // _markDashboardUnreachable; lookup by ID survives any inner-HTML rebuild
393
+ // as long as the markup keeps the span around (it does — single source).
394
+ const el = document.getElementById('dashboard-unreachable-age');
395
+ if (el) el.textContent = _formatAge(Date.now() - _lastStatusOkAt);
396
+ }
397
+
398
+ function _markDashboardUnreachable(err) {
399
+ if (_unreachableSince) {
400
+ _refreshUnreachableAgeText();
401
+ return;
402
+ }
403
+ _unreachableSince = Date.now();
404
+ window._dashboardUnreachable = {
405
+ since: _unreachableSince,
406
+ lastSuccessAt: _lastStatusOkAt,
407
+ lastError: String(err && err.message || err || 'unknown'),
408
+ };
409
+ // Reuse the existing #engine-alert surface (red-tinted banner already wired
410
+ // for engine-stale, ado-throttle, gh-throttle) instead of introducing a
411
+ // second red banner. Engine-stale and dashboard-unreachable are mutually
412
+ // exclusive in practice: engine-stale needs a fresh heartbeat from a
413
+ // successful poll, and a successful poll means the dashboard IS reachable.
414
+ // When the dashboard recovers, _markDashboardReachable hides this element
415
+ // explicitly; the next renderEngineAlert pass (driven by the recovered
416
+ // poll's engine state) then takes over normally.
417
+ const el = document.getElementById('engine-alert');
418
+ if (el) {
419
+ el.innerHTML =
420
+ '<span class="engine-alert-msg">&#x26A0;&#xFE0F; Dashboard unreachable &mdash; stale <span id="dashboard-unreachable-age">just now</span></span>' +
421
+ '<span class="engine-alert-action" onclick="ccRestartMinions(this)">Restart Minions</span>';
422
+ el.style.display = 'flex';
423
+ }
424
+ _refreshUnreachableAgeText();
425
+ if (_unreachableAgeTimer) clearInterval(_unreachableAgeTimer);
426
+ _unreachableAgeTimer = setInterval(_refreshUnreachableAgeText, 1000);
427
+ // Override engine badge so the cached "RUNNING" doesn't keep misleading
428
+ // the user. renderEngineStatus reads engine state from the next
429
+ // successful poll — when reachable recovers, the override clears.
430
+ const badge = document.getElementById('engine-badge');
431
+ if (badge) {
432
+ badge.className = 'engine-badge stopped';
433
+ badge.textContent = 'UNKNOWN';
434
+ badge.title = 'Dashboard unreachable — engine state is unknown. UI data is stale.';
435
+ }
436
+ console.warn('Dashboard unreachable:', window._dashboardUnreachable.lastError);
437
+ }
438
+
439
+ function _markDashboardReachable() {
440
+ if (!_unreachableSince) return;
441
+ const downForMs = Date.now() - _unreachableSince;
442
+ _unreachableSince = 0;
443
+ delete window._dashboardUnreachable;
444
+ if (_unreachableAgeTimer) { clearInterval(_unreachableAgeTimer); _unreachableAgeTimer = null; }
445
+ // Hand #engine-alert back to renderEngineAlert. We can't leave our content
446
+ // sitting in there because the next renderEngineAlert call only runs when
447
+ // `_changed('engine', data.engine)` returns true — if engine state hasn't
448
+ // shifted, our stale banner would persist past recovery.
449
+ const el = document.getElementById('engine-alert');
450
+ if (el) { el.style.display = 'none'; el.innerHTML = ''; }
451
+ console.log('Dashboard recovered after', _formatAge(downForMs).replace(' ago', ''));
452
+ // Badge restoration happens automatically on the next renderEngineStatus
453
+ // call (triggered by the successful refresh that brought us here).
454
+ }
455
+
456
+ // Test seam — reset detector state between scenarios.
457
+ window._resetDashboardUnreachableForTest = function() {
458
+ _lastStatusOkAt = Date.now();
459
+ _consecutiveStatusFails = 0;
460
+ _unreachableSince = 0;
461
+ if (_unreachableAgeTimer) { clearInterval(_unreachableAgeTimer); _unreachableAgeTimer = null; }
462
+ delete window._dashboardUnreachable;
463
+ };
464
+
346
465
  // ── Refresh diagnostics (W-mphejzx100081972) ─────────────────────────────
347
466
  // Ring buffer capturing the last 50 /api/status poll cycles so a user
348
467
  // reporting "the dashboard didn't auto-update when X changed" can paste
@@ -418,6 +537,11 @@ async function refresh() {
418
537
  const headers = {};
419
538
  if (_lastStatusEtag) headers['If-None-Match'] = _lastStatusEtag;
420
539
  const res = await safeFetch('/api/status', { headers });
540
+ if (!res || (!res.ok && res.status !== 304)) {
541
+ // Dashboard responded but with an error status. Treat as a fail tick so
542
+ // a 5xx-storm trips the unreachable banner just like a network error.
543
+ throw new Error('HTTP ' + (res ? res.status : '?'));
544
+ }
421
545
  let data;
422
546
  if (res.status === 304 && _lastStatusData) {
423
547
  // Cache hit — reuse last payload, skip parsing entirely.
@@ -425,6 +549,12 @@ async function refresh() {
425
549
  if (_diagEntry) {
426
550
  _diagEntry.response_status = '304';
427
551
  _diagEntry.bytes_received = 0;
552
+ // D2: capture etag on 304 so the diag table can show whether the
553
+ // server's ETag advanced even when we're reusing the cached body.
554
+ // Without this the "etag↓" column is blank on every 304 row and the
555
+ // operator can't tell server-side advancement from a pinned cache.
556
+ const etag304 = res.headers && (res.headers.get ? res.headers.get('etag') : null);
557
+ if (etag304) _diagEntry.etag_received = etag304;
428
558
  }
429
559
  } else {
430
560
  data = await res.json();
@@ -458,6 +588,12 @@ async function refresh() {
458
588
  return;
459
589
  }
460
590
  if (buildId) _knownDashboardBuildId = buildId;
591
+ // Successful poll — clear unreachable state. Placed AFTER the reload
592
+ // guards above so a dashboard restart still triggers location.reload()
593
+ // instead of just dismissing the banner.
594
+ _lastStatusOkAt = Date.now();
595
+ _consecutiveStatusFails = 0;
596
+ if (_unreachableSince) _markDashboardReachable();
461
597
  const _renderStart = _diagOn ? Date.now() : 0;
462
598
  let _diagChanges = null;
463
599
  if (_diagOn) {
@@ -484,6 +620,11 @@ async function refresh() {
484
620
  _diagEntry.response_status = (e && e.name === 'AbortError') ? 'abort' : 'error';
485
621
  _diagEntry.error_message = String((e && e.message) || e);
486
622
  }
623
+ _consecutiveStatusFails++;
624
+ const ageMs = Date.now() - _lastStatusOkAt;
625
+ if (_consecutiveStatusFails >= _UNREACHABLE_FAIL_THRESHOLD || ageMs > _UNREACHABLE_AGE_MS) {
626
+ _markDashboardUnreachable(e);
627
+ }
487
628
  }
488
629
  finally {
489
630
  _refreshInFlight = false;
@@ -51,22 +51,56 @@ function prRow(pr) {
51
51
  var followupChip = followupCount > 0
52
52
  ? ' <span class="pr-badge draft" style="font-size:8px" title="' + followupCount + ' follow-up work item(s) dispatched from comments on this PR">+' + followupCount + ' follow-up' + (followupCount === 1 ? '' : 's') + '</span>'
53
53
  : '';
54
+ const titleText = pr.title || 'Untitled';
55
+ const agentText = pr.agent || '—';
56
+ const reviewerCell = sq.reviewer && sq.status !== 'waiting'
57
+ ? '<span class="pr-agent" title="' + escapeHtml(sq.note || sq.reviewer) + '">' + escapeHtml(sq.reviewer) + '</span>'
58
+ : sq.reviewer && sq.status === 'waiting'
59
+ ? '<span class="pr-agent" style="color:var(--muted)" title="Vote pending confirmation">' + escapeHtml(sq.reviewer) + '…</span>'
60
+ : pr.reviewedBy && pr.reviewedBy.length
61
+ ? '<span class="pr-agent" title="' + escapeHtml(pr.reviewedBy.join(', ')) + '">' + escapeHtml(pr.reviewedBy.join(', ')) + '</span>'
62
+ : '<span style="color:var(--muted);font-size:11px">—</span>';
63
+ const createdLabel = (pr.created || '—').slice(0, 16).replace('T', ' ');
64
+ // Title attrs live on the inner element (link/span/badge) so hovering the
65
+ // ellipsis-truncated content reveals the full text. Cell tags stay bare so
66
+ // the header-to-cell count assertion in test/unit.test.js continues to
67
+ // balance.
54
68
  return '<tr>' +
55
- '<td><span class="pr-id">' + escapeHtml(String(prId)) + '</span></td>' +
56
- '<td><a class="pr-title" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(pr.title || 'Untitled') + '</a>' + followupChip + (pr.description ? '<div class="pr-desc">' + escapeHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
57
- '<td><span class="pr-agent">' + escapeHtml(pr.agent || '') + '</span></td>' +
69
+ '<td><span class="pr-id" title="' + escapeHtml(String(prId)) + '">' + escapeHtml(String(prId)) + '</span></td>' +
70
+ '<td><a class="pr-title" title="' + escapeHtml(titleText) + '" href="' + escapeHtml(safeUrl(url)) + '" target="_blank" rel="noopener">' + escapeHtml(titleText) + '</a>' + followupChip + (pr.description ? '<div class="pr-desc" title="' + escapeHtml(pr.description) + '">' + escapeHtml(pr.description.length > 120 ? pr.description.slice(0, 120) + '...' : pr.description) + '</div>' : '') + '</td>' +
71
+ '<td><span class="pr-agent" title="' + escapeHtml(agentText) + '">' + escapeHtml(agentText) + '</span></td>' +
58
72
  '<td><span class="' + branchClass + '" title="' + escapeHtml(branchError || branchLabel) + '">' + escapeHtml(branchLabel) + '</span>' + pendingReasonHtml + '</td>' +
59
- '<td><span class="pr-badge ' + reviewClass + '"' + (reviewTitle ? ' title="' + escapeHtml(reviewTitle) + '"' : '') + '>' + escapeHtml(reviewLabel) + '</span></td>' +
60
- '<td>' + (sq.reviewer && sq.status !== 'waiting' ? '<span class="pr-agent" title="' + escapeHtml(sq.note || '') + '">' + escapeHtml(sq.reviewer) + '</span>' : sq.reviewer && sq.status === 'waiting' ? '<span class="pr-agent" style="color:var(--muted)" title="Vote pending confirmation">' + escapeHtml(sq.reviewer) + '…</span>' : pr.reviewedBy && pr.reviewedBy.length ? '<span class="pr-agent">' + escapeHtml(pr.reviewedBy.join(', ')) + '</span>' : '<span style="color:var(--muted);font-size:11px">—</span>') + '</td>' +
61
- '<td><span class="pr-badge ' + buildClass + '"' + (buildTitle ? ' title="' + escapeHtml(buildTitle) + '"' : '') + '>' + escapeHtml(buildLabel) + '</span></td>' +
62
- '<td><span class="pr-badge ' + statusClass + '">' + escapeHtml(statusLabel) + '</span></td>' +
63
- '<td><span class="pr-date">' + escapeHtml((pr.created || '').slice(0, 16).replace('T', ' ')) + '</span></td>' +
73
+ '<td><span class="pr-badge ' + reviewClass + '" title="' + escapeHtml(reviewTitle || reviewLabel) + '">' + escapeHtml(reviewLabel) + '</span></td>' +
74
+ '<td>' + reviewerCell + '</td>' +
75
+ '<td><span class="pr-badge ' + buildClass + '" title="' + escapeHtml(buildTitle || buildLabel) + '">' + escapeHtml(buildLabel) + '</span></td>' +
76
+ '<td><span class="pr-badge ' + statusClass + '" title="' + escapeHtml(statusLabel) + '">' + escapeHtml(statusLabel) + '</span></td>' +
77
+ '<td><span class="pr-date" title="' + escapeHtml(createdLabel) + '">' + escapeHtml(createdLabel) + '</span></td>' +
64
78
  '<td><button class="pr-pager-btn" style="font-size:9px;padding:1px 5px;color:var(--red);border-color:var(--red)" data-pr-id="' + escapeHtml(String(prId)) + '" onclick="event.stopPropagation();unlinkPr(this.dataset.prId)" title="Remove from tracking">x</button></td>' +
65
79
  '</tr>';
66
80
  }
67
81
 
82
+ // Explicit per-column widths keep the PR table from ballooning when titles or
83
+ // branches are long. Total ≈1420px → table grows past viewport on narrow
84
+ // windows and the .pr-table-wrap--prs container scrolls horizontally inside
85
+ // the viewport (sticky scrollbar — see styles.css).
86
+ const PRS_COLGROUP =
87
+ '<colgroup>' +
88
+ '<col style="width:75px">' + // PR id
89
+ '<col style="width:320px">' + // Title
90
+ '<col style="width:140px">' + // Agent
91
+ '<col style="width:200px">' + // Branch
92
+ '<col style="width:130px">' + // Review
93
+ '<col style="width:140px">' + // Signed Off By
94
+ '<col style="width:130px">' + // Build
95
+ '<col style="width:110px">' + // Status
96
+ '<col style="width:130px">' + // Created
97
+ '<col style="width:50px">' + // Actions
98
+ '</colgroup>';
99
+
68
100
  function prTableHtml(rows) {
69
- return '<div class="pr-table-wrap"><table class="pr-table"><thead><tr>' +
101
+ return '<div class="pr-table-wrap pr-table-wrap--prs"><table class="pr-table pr-table--prs">' +
102
+ PRS_COLGROUP +
103
+ '<thead><tr>' +
70
104
  '<th>PR</th><th>Title</th><th>Agent</th><th>Branch</th><th>Review</th><th>Signed Off By</th><th>Build</th><th>Status</th><th>Created</th><th></th>' +
71
105
  '</tr></thead><tbody>' + rows + '</tbody></table></div>';
72
106
  }
@@ -49,7 +49,7 @@ async function openSettings() {
49
49
  // Per-agent override placeholders surface the inherited fleet defaults as
50
50
  // muted text — operators see exactly what each agent will resolve to without
51
51
  // chasing config files. Empty input clears the override → re-inherit fleet.
52
- const fleetCliLabel = e.defaultCli || 'claude';
52
+ const fleetCliLabel = e.defaultCli || 'copilot';
53
53
  const fleetModelLabel = e.defaultModel ? String(e.defaultModel) : 'CLI default';
54
54
  const agentRows = Object.entries(agents).map(function([id, a]) {
55
55
  return '<tr>' +
@@ -98,6 +98,7 @@ async function openSettings() {
98
98
  settingsToggle('Auto-decompose', 'set-autoDecompose', e.autoDecompose !== false, 'Large implement items are auto-split into sub-tasks') +
99
99
  settingsToggle('Allow Temp Agents', 'set-allowTempAgents', !!e.allowTempAgents, 'Spawn ephemeral agents when all permanent agents are busy') +
100
100
  settingsToggle('Auto-archive Plans', 'set-autoArchive', !!e.autoArchive, 'Automatically archive plans after verify completes (off = manual archive via dashboard)') +
101
+ settingsToggle('Auto-consolidate Memory', 'set-autoConsolidateMemory', !!e.autoConsolidateMemory, 'Periodically spawn the KB sweep (dedup + compress + normalize knowledge/) from the engine tick on a 4h cadence. Inbox→notes consolidation already runs every tick (gated by the Consolidation Threshold above); this toggle controls only the KB sweep that was previously dashboard-button-only.') +
101
102
  settingsToggle('Auto-complete PRs', 'set-autoCompletePrs', !!e.autoCompletePrs, 'Auto-merge PRs when builds pass and review is approved (opt-in)') +
102
103
  settingsToggle('CC Worker Pool', 'set-ccUseWorkerPool', (e.ccUseWorkerPool === undefined ? ((e.ccCli || e.defaultCli) === 'copilot') : !!e.ccUseWorkerPool), 'Route Command Center / doc-chat through a persistent copilot --acp worker per tab instead of spawning a fresh CLI per turn. Copilot-only (Agent Client Protocol transport); Claude does not implement ACP, so this toggle has no effect when CC runtime is Claude. Default ON for copilot (cold-spawn ~20s on Windows); forced OFF for non-copilot CC runtimes regardless of this toggle.') +
103
104
  '</div>' +
@@ -127,6 +128,7 @@ async function openSettings() {
127
128
  '<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:16px">' +
128
129
  settingsField('Eval Max Cost', 'set-evalMaxCost', e.evalMaxCost === null || e.evalMaxCost === undefined ? '' : e.evalMaxCost, '$', 'USD ceiling per work item across all eval iterations (blank = no limit)') +
129
130
  settingsField('Agent Busy Reassign', 'set-agentBusyReassignMs', e.agentBusyReassignMs || 600000, 'ms', 'Reassign work to another agent after it waits this long on a busy agent') +
131
+ settingsField('Max Retries Per Agent', 'set-maxRetriesPerAgent', e.maxRetriesPerAgent ?? 2, '', 'After the same agent fails the same work item this many times, the next retry reassigns to a different eligible agent (consults routing.md + availability). Falls back to the same agent only when no alternate is available. Counted separately from total maxRetries (which still caps overall retries).') +
130
132
  settingsField('Version Check Interval', 'set-versionCheckInterval', e.versionCheckInterval || 3600000, 'ms', 'How often to check npm for updates (default: 1 hour)') +
131
133
  settingsField('Ignored Comment Authors', 'set-ignoredCommentAuthors', (e.ignoredCommentAuthors || []).join(', '), '', 'Comma-separated usernames — comments auto-closed, never trigger fixes') +
132
134
  '</div>' +
@@ -404,10 +406,10 @@ async function initRuntimeFleetUI(engineCfg, agentsCfg) {
404
406
  runtimes = Array.isArray(d.runtimes) ? d.runtimes : [];
405
407
  } catch { /* ignore — we'll surface a free-text-only path below */ }
406
408
 
407
- // Always include 'claude' as a fallback option even if /api/runtimes is empty;
409
+ // Always include 'copilot' as a fallback option even if /api/runtimes is empty;
408
410
  // legacy installs without the registry endpoint should still see something pickable.
409
- const names = runtimes.length ? runtimes.map(rt => rt.name) : ['claude'];
410
- const currentDefault = engineCfg.defaultCli || 'claude';
411
+ const names = runtimes.length ? runtimes.map(rt => rt.name) : ['copilot'];
412
+ const currentDefault = engineCfg.defaultCli || 'copilot';
411
413
  const currentCc = engineCfg.ccCli || '';
412
414
  cliSelect.innerHTML = names.map(n =>
413
415
  '<option value="' + escHtml(n) + '"' + (n === currentDefault ? ' selected' : '') + '>' + escHtml(n) + '</option>'
@@ -438,7 +440,7 @@ async function initRuntimeFleetUI(engineCfg, agentsCfg) {
438
440
  // this the input was free-text and a user could (and did) save an agent
439
441
  // with cli=claude + model=<some gpt> — invalid combination that crashed
440
442
  // dispatch. Refreshing on CLI change clears stale model values.
441
- const fleetDefaultCli = engineCfg.defaultCli || 'claude';
443
+ const fleetDefaultCli = engineCfg.defaultCli || 'copilot';
442
444
  for (const cell of cliCells) {
443
445
  const agentId = cell.getAttribute('data-runtime-cli');
444
446
  const agent = (agentsCfg || {})[agentId] || {};
@@ -606,6 +608,7 @@ async function saveSettings() {
606
608
  autoDecompose: document.getElementById('set-autoDecompose').checked,
607
609
  allowTempAgents: document.getElementById('set-allowTempAgents').checked,
608
610
  autoArchive: document.getElementById('set-autoArchive').checked,
611
+ autoConsolidateMemory: document.getElementById('set-autoConsolidateMemory').checked,
609
612
  autoApplyReviewVote: document.getElementById('set-autoApplyReviewVote').checked,
610
613
  autoFixBuilds: document.getElementById('set-autoFixBuilds').checked,
611
614
  autoFixConflicts: document.getElementById('set-autoFixConflicts').checked,
@@ -621,6 +624,7 @@ async function saveSettings() {
621
624
  prPollCommentsEvery: document.getElementById('set-prPollCommentsEvery').value,
622
625
  evalMaxCost: document.getElementById('set-evalMaxCost').value || null,
623
626
  agentBusyReassignMs: document.getElementById('set-agentBusyReassignMs').value,
627
+ maxRetriesPerAgent: document.getElementById('set-maxRetriesPerAgent').value,
624
628
  ignoredCommentAuthors: document.getElementById('set-ignoredCommentAuthors').value,
625
629
  versionCheckInterval: document.getElementById('set-versionCheckInterval').value,
626
630
  // Runtime fleet (P-7a5c1f8e). Empty strings are intentional — they signal
@@ -260,6 +260,27 @@
260
260
  .pr-table-wrap { overflow-x: auto; }
261
261
  .pr-table { width: 100%; border-collapse: collapse; font-size: var(--text-md); table-layout: auto; }
262
262
  .pr-table th:last-child, .pr-table td:last-child { width: 36px; min-width: 36px; text-align: center; }
263
+
264
+ /* PR-page table variant (W-mpmwxn9h000bd2c2): fixed column widths with
265
+ ellipsis overflow, and the horizontal scrollbar is pinned inside the
266
+ viewport via a bounded-height container on the standalone /prs page so
267
+ it stays reachable without scrolling to the bottom of a tall table. */
268
+ .pr-table--prs { table-layout: fixed; width: 100%; min-width: 1420px; }
269
+ .pr-table--prs th, .pr-table--prs td { overflow: hidden; text-overflow: ellipsis; }
270
+ .pr-table--prs th:last-child, .pr-table--prs td:last-child { width: auto; min-width: 0; }
271
+ .pr-table--prs .pr-title { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
272
+ .pr-table--prs .pr-agent { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; vertical-align: middle; white-space: nowrap; }
273
+ .pr-table--prs .pr-branch { max-width: 100%; }
274
+ .pr-table--prs .pr-desc { max-width: 100%; }
275
+ .pr-table--prs .pr-date { display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
276
+
277
+ /* Standalone /prs page only: bound the table-wrap height so the horizontal
278
+ scrollbar (bottom of the wrap) and the table header (sticky inside the
279
+ wrap) stay visible while the user is on this page. The modal "see all"
280
+ view uses the same colgroup but is unaffected — modal-body handles its
281
+ own scrolling. */
282
+ #pr-content .pr-table-wrap--prs { max-height: calc(100vh - 200px); overflow: auto; }
283
+ #pr-content .pr-table--prs thead th { position: sticky; top: 0; background: var(--surface); z-index: 1; }
263
284
  .pr-table th { text-align: left; color: var(--muted); font-weight: 500; font-size: var(--text-base); text-transform: uppercase; letter-spacing: 0.5px; padding: var(--space-4) var(--space-5); border-bottom: 1px solid var(--border); }
264
285
  .pr-table td { padding: var(--space-5); border-bottom: 1px solid var(--border); vertical-align: middle; white-space: nowrap; }
265
286
  .pr-table tr:last-child td { border-bottom: none; }