@yemi33/minions 0.1.2438 → 0.1.2440

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.
@@ -760,6 +760,8 @@ function ccSwitchTab(id) {
760
760
  ccRenderTabBar();
761
761
  ccUpdateSessionIndicator();
762
762
  ccSaveState();
763
+ _ccRestoreQueue(tab); // load persisted queue once (W-ms4o3gtm005z91c9)
764
+ _renderQueueIndicator(); // surface any in-memory queued bubbles for this tab
763
765
  var input = document.getElementById('cc-input');
764
766
  if (input) input.focus();
765
767
  }
@@ -914,6 +916,7 @@ function ccRestoreMessages() {
914
916
  var el = document.getElementById('cc-messages');
915
917
  var tab = _ccActiveTab();
916
918
  if (!tab) return;
919
+ _ccRestoreQueue(tab);
917
920
  if (el.children.length > 0) return;
918
921
  if (tab.messages.length === 0) {
919
922
  _ccRenderSuggestedPrompts();
@@ -1072,6 +1075,7 @@ async function ccSend(options) {
1072
1075
  if (tab._sending) {
1073
1076
  tab._queue.push({ message: message, intentMetadata: intentMetadata });
1074
1077
  _renderQueueIndicator();
1078
+ ccQueueSave(originTabId, tab._queue); // persist so the queue survives a refresh (W-ms4o3gtm005z91c9)
1075
1079
  return;
1076
1080
  }
1077
1081
  var wasAborted = await _ccDoSend(message, false, originTabId, intentMetadata);
@@ -1085,6 +1089,7 @@ async function ccSend(options) {
1085
1089
  var nextMessage = typeof next === 'string' ? next : next.message;
1086
1090
  var nextIntentMetadata = typeof next === 'string' ? null : (next.intentMetadata || null);
1087
1091
  _renderQueueIndicator();
1092
+ ccQueueSave(originTabId, tab._queue); // persist the post-drain remainder (W-ms4o3gtm005z91c9)
1088
1093
  wasAborted = await _ccDoSend(nextMessage, false, originTabId, nextIntentMetadata);
1089
1094
  }
1090
1095
  }
@@ -1107,6 +1112,24 @@ function _renderQueueIndicator() {
1107
1112
  if (msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 150) msgs.scrollTop = msgs.scrollHeight;
1108
1113
  }
1109
1114
 
1115
+ // Restore a tab's persisted queued messages after a hard refresh / new session
1116
+ // (W-ms4o3gtm005z91c9). Loaded once per tab (guarded by _queueRestored). The
1117
+ // queue is restored as greyed 'queued' bubbles but is NOT auto-drained on load:
1118
+ // the existing drain loop only runs off the user's next ccSend, so restored
1119
+ // bubbles sit idle until the user acts (never silently auto-firing on reload).
1120
+ // No-op when the tab already has an in-memory queue (a live session).
1121
+ function _ccRestoreQueue(tab) {
1122
+ if (!tab || !tab.id || tab._queueRestored) return;
1123
+ tab._queueRestored = true;
1124
+ ccQueueLoad(tab.id).then(function(loaded) {
1125
+ if (!loaded || !loaded.length) return;
1126
+ if (!tab._queue) tab._queue = [];
1127
+ if (tab._queue.length) return;
1128
+ for (var i = 0; i < loaded.length; i++) tab._queue.push(loaded[i]);
1129
+ if (tab.id === _ccActiveTabId) _renderQueueIndicator();
1130
+ });
1131
+ }
1132
+
1110
1133
  async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1111
1134
  // Client-side /pin and /unpin — no LLM round-trip needed
1112
1135
  var pinMatch = message.match(/^\/(pin|unpin)\s+(.+)/i);
@@ -1618,7 +1641,10 @@ function ccRetryLast(tabId, retryId) {
1618
1641
  while (retryTab && retryTab._queue && retryTab._queue.length > 0) {
1619
1642
  var next = retryTab._queue.shift();
1620
1643
  _renderQueueIndicator();
1621
- await _ccDoSend(next, false, retryTab.id);
1644
+ ccQueueSave(retryTab.id, retryTab._queue); // persist the post-drain remainder (W-ms4o3gtm005z91c9)
1645
+ var retryMsg = typeof next === 'string' ? next : next.message;
1646
+ var retryMeta = typeof next === 'string' ? null : (next.intentMetadata || null);
1647
+ await _ccDoSend(retryMsg, false, retryTab.id, retryMeta);
1622
1648
  }
1623
1649
  });
1624
1650
  }
@@ -74,7 +74,7 @@ function cmdKeyDown(e) {
74
74
  items[cmdMentionIdx]?.scrollIntoView({ block: 'nearest' });
75
75
  return;
76
76
  }
77
- if (e.key === 'Enter' && !e.ctrlKey) {
77
+ if (e.key === 'Enter' && !isSendShortcutEvent(e)) {
78
78
  e.preventDefault();
79
79
  e.stopPropagation();
80
80
  const active = items[cmdMentionIdx >= 0 ? cmdMentionIdx : 0];
@@ -124,13 +124,30 @@ function cmdKeyDown(e) {
124
124
  }
125
125
  }
126
126
 
127
- // Ctrl+Enter to submit
128
- if (e.key === 'Enter' && e.ctrlKey) {
127
+ // Platform submit chord — Command+Enter on macOS, Ctrl+Enter elsewhere
128
+ if (isSendShortcutEvent(e)) {
129
129
  e.preventDefault();
130
130
  cmdSubmit();
131
131
  }
132
132
  }
133
133
 
134
+ // Paint the send button's visible hint + accessible name with the platform
135
+ // chord. The markup ships the Ctrl+Enter default so non-JS/pre-boot renders
136
+ // still read correctly; this only rewrites it on macOS.
137
+ function cmdApplySendShortcutHint() {
138
+ const btn = document.getElementById('cmd-send-btn');
139
+ if (!btn) return;
140
+ const kbd = document.getElementById('cmd-send-kbd');
141
+ if (kbd) kbd.textContent = sendShortcutLabel();
142
+ btn.setAttribute('aria-label', 'Send (' + sendShortcutAriaLabel() + ')');
143
+ btn.setAttribute('aria-keyshortcuts', sendShortcutAriaKeys());
144
+ }
145
+
146
+ if (typeof document !== 'undefined' && document.addEventListener) {
147
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', cmdApplySendShortcutHint);
148
+ else cmdApplySendShortcutHint();
149
+ }
150
+
134
151
  async function cmdSubmit() {
135
152
  const input = document.getElementById('cmd-input');
136
153
  const raw = input.value.trim();
@@ -432,30 +432,59 @@ async function archiveWorkItem(id, source) {
432
432
  } catch (e) { clearDeleted('wi:' + id); showToast('cmd-toast', 'Archive error: ' + e.message, false); refresh(); }
433
433
  }
434
434
 
435
+ // The dashboard is an app-shell layout: html/body/.page-layout are all
436
+ // overflow:hidden at a fixed height, so window.scrollY never moves and the only
437
+ // scrollable ancestor is #page-content. "See Archive" therefore used to reveal a
438
+ // panel that, on any work list taller than the fold, landed outside the scroll
439
+ // viewport (measured at 1440x900: panel top 1072 in a 900px viewport) while the
440
+ // trigger label stayed "See Archive". The handler ran, the API returned 200 and
441
+ // the DOM really did change — but nothing a human could perceive did, so the
442
+ // control looked dead.
443
+ //
444
+ // The repair is the disclosure contract itself, not a special case: the trigger
445
+ // reports its own state, and revealing the panel brings it into view.
446
+ function _setWiArchiveToggleState(expanded) {
447
+ const btn = document.getElementById('work-archive-toggle');
448
+ if (!btn) return;
449
+ btn.setAttribute('aria-expanded', expanded ? 'true' : 'false');
450
+ btn.textContent = expanded ? 'Hide Archive' : 'See Archive';
451
+ }
452
+
435
453
  let wiArchiveVisible = false;
436
454
  async function toggleWorkItemArchive() {
437
455
  const el = document.getElementById('work-items-archive');
456
+ if (!el) return;
438
457
  wiArchiveVisible = !wiArchiveVisible;
458
+ _setWiArchiveToggleState(wiArchiveVisible);
439
459
  if (!wiArchiveVisible) { el.style.display = 'none'; return; }
440
460
  el.style.display = 'block';
441
461
  el.innerHTML = '<p class="empty">Loading archive...</p>';
442
462
  try {
443
463
  const items = await fetch('/api/work-items/archive').then(r => r.json());
444
- if (!items.length) { el.innerHTML = '<p class="empty">No archived work items.</p>'; return; }
445
- // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: archived work item id/title/type/status/agent)
446
- el.innerHTML = '<div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:6px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">Archived (' + items.length + ')</div>' +
447
- '<div class="pr-table-wrap"><table class="pr-table"><thead><tr><th>ID</th><th>Title</th><th>Type</th><th>Status</th><th>Agent</th><th>Archived</th></tr></thead><tbody>' +
448
- items.map(function(i) {
449
- return '<tr style="opacity:0.6">' +
450
- '<td><span class="pr-id">' + escapeHtml(i.id || '') + '</span></td>' +
451
- '<td style="min-width:240px;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(i.title || '') + '</td>' +
452
- '<td><span class="dispatch-type ' + (i.type || '') + '">' + escapeHtml(i.type || '') + '</span></td>' +
453
- '<td style="color:' + (i.status === 'done' ? 'var(--green)' : 'var(--red)') + '">' + escapeHtml(i.status || '') + '</td>' +
454
- '<td>' + escapeHtml(i.dispatched_to || '') + '</td>' +
455
- '<td class="pr-date">' + shortTime(i.archivedAt) + '</td>' +
456
- '</tr>';
457
- }).join('') + '</tbody></table></div>';
464
+ if (!items.length) {
465
+ el.innerHTML = '<p class="empty">No archived work items.</p>';
466
+ } else {
467
+ // eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escapeHtml() (fields: archived work item id/title/type/status/agent)
468
+ el.innerHTML = '<div style="font-size:var(--text-sm);color:var(--muted);margin-bottom:6px;font-weight:600;text-transform:uppercase;letter-spacing:0.5px">Archived (' + items.length + ')</div>' +
469
+ '<div class="pr-table-wrap"><table class="pr-table"><thead><tr><th>ID</th><th>Title</th><th>Type</th><th>Status</th><th>Agent</th><th>Archived</th></tr></thead><tbody>' +
470
+ items.map(function(i) {
471
+ return '<tr style="opacity:0.6">' +
472
+ '<td><span class="pr-id">' + escapeHtml(i.id || '') + '</span></td>' +
473
+ '<td style="min-width:240px;max-width:280px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(i.title || '') + '</td>' +
474
+ '<td><span class="dispatch-type ' + (i.type || '') + '">' + escapeHtml(i.type || '') + '</span></td>' +
475
+ '<td style="color:' + (i.status === 'done' ? 'var(--green)' : 'var(--red)') + '">' + escapeHtml(i.status || '') + '</td>' +
476
+ '<td>' + escapeHtml(i.dispatched_to || '—') + '</td>' +
477
+ '<td class="pr-date">' + shortTime(i.archivedAt) + '</td>' +
478
+ '</tr>';
479
+ }).join('') + '</tbody></table></div>';
480
+ }
458
481
  } catch (e) { el.innerHTML = '<p class="empty">Failed to load archive.</p>'; }
482
+ // Scroll only after the panel has its final height, so a tall archive lands
483
+ // properly instead of being nudged into view while still showing "Loading".
484
+ // block:'nearest' scrolls the nearest scrollable ancestor (#page-content here)
485
+ // and is a no-op when the panel is already on screen, so tall viewports and
486
+ // short work lists are unaffected.
487
+ try { el.scrollIntoView({ block: 'nearest' }); } catch { /* older browsers */ }
459
488
  }
460
489
 
461
490
  async function retryWorkItem(id, source) {
@@ -1960,4 +1960,25 @@ window.MinionsSettings = { openSettings, saveSettings, addProject, removeProject
1960
1960
  } else {
1961
1961
  setTimeout(openWhenReady, 0);
1962
1962
  }
1963
+ // FRESHNESS ON REOPEN (W-ms4ogdc800aa6931): the slim parent now CACHES this
1964
+ // embedded /settings frame (created once, shown/hidden) instead of cold-booting
1965
+ // it per open, so the iframe `load` — and thus the one-shot openSettings() boot
1966
+ // above — fires only ONCE. Re-pull /api/settings + /api/features whenever the
1967
+ // parent re-shows us (visible:true) so a value changed elsewhere isn't stale. A
1968
+ // full openSettings() re-render is cheap here (the SPA is already booted — just
1969
+ // two localhost fetches + a DOM render, no cold boot), so we prefer it over
1970
+ // ad-hoc partial refresh. Reuses the SAME {source:'minions-slim-embed',
1971
+ // type:'visibility'} channel refresh.js already honors (no second postMessage
1972
+ // bus). Embed-mode gated by the isSettingsPath+isEmbed early return above, so a
1973
+ // standalone /settings visit is completely unaffected.
1974
+ try {
1975
+ window.addEventListener('message', function(ev) {
1976
+ if (!ev || ev.origin !== window.location.origin) return;
1977
+ var d = ev.data;
1978
+ if (!d || d.source !== 'minions-slim-embed' || d.type !== 'visibility') return;
1979
+ if (d.visible && typeof openSettings === 'function') {
1980
+ try { openSettings(); } catch (e) { /* transient — next show retries */ }
1981
+ }
1982
+ });
1983
+ } catch (e) { /* defensive — never block the embed boot on this hook */ }
1963
1984
  })();
@@ -902,4 +902,32 @@ async function submitBugReport() {
902
902
  }
903
903
  }
904
904
 
905
- window.MinionsUtils = { wakeEngine, markDeleted, clearDeleted, escapeHtml, escHtml, renderMd, normalizePlanFile, timeAgo, statusColor, shouldIgnoreSelectionClick, llmCopyBtn, copyLlmText, openBugReport, submitBugReport };
905
+ // ── Send-shortcut helpers (Classic UX) ──────────────────────────────────
906
+ // macOS submits with Command+Enter; Windows/Linux keep Control+Enter. These are
907
+ // the single source of truth for both the key handler and the visible hint so
908
+ // the two can never drift apart.
909
+
910
+ function isMacPlatform() {
911
+ try {
912
+ if (typeof navigator === 'undefined' || !navigator) return false;
913
+ var uaData = navigator.userAgentData;
914
+ var platform = String((uaData && uaData.platform) || navigator.platform || '');
915
+ if (/mac/i.test(platform)) return true;
916
+ return /Mac OS X|Macintosh/i.test(String(navigator.userAgent || ''));
917
+ } catch { return false; }
918
+ }
919
+
920
+ // True only for the platform submit chord. Shift/Alt and the non-platform
921
+ // modifier are rejected so unrelated combinations never trigger a send.
922
+ function isSendShortcutEvent(e) {
923
+ if (!e || e.key !== 'Enter') return false;
924
+ if (e.shiftKey || e.altKey) return false;
925
+ return isMacPlatform() ? (!!e.metaKey && !e.ctrlKey) : (!!e.ctrlKey && !e.metaKey);
926
+ }
927
+
928
+ function sendShortcutLabel() { return isMacPlatform() ? '⌘+Enter' : 'Ctrl+Enter'; }
929
+ function sendShortcutAriaLabel() { return isMacPlatform() ? 'Command+Enter' : 'Control+Enter'; }
930
+ // aria-keyshortcuts uses the DOM modifier names, so macOS reports "Meta".
931
+ function sendShortcutAriaKeys() { return isMacPlatform() ? 'Meta+Enter' : 'Control+Enter'; }
932
+
933
+ window.MinionsUtils = { wakeEngine, markDeleted, clearDeleted, escapeHtml, escHtml, renderMd, normalizePlanFile, timeAgo, statusColor, shouldIgnoreSelectionClick, llmCopyBtn, copyLlmText, openBugReport, submitBugReport, isMacPlatform, isSendShortcutEvent, sendShortcutLabel, sendShortcutAriaLabel, sendShortcutAriaKeys };
@@ -4,7 +4,7 @@
4
4
  <div class="cmd-highlight-layer" id="cmd-highlight" aria-hidden="true"></div>
5
5
  <textarea id="cmd-input" rows="1" placeholder='What do you need? e.g. "Fix the auth bug @dallas", "explain the dispatch flow", or "/note always use feature flags"'
6
6
  oninput="cmdInputChanged()" onkeydown="cmdKeyDown(event)" onscroll="syncHighlightScroll()" onpaste="cmdHandlePaste(event)"></textarea>
7
- <button class="cmd-send-btn" id="cmd-send-btn" onclick="cmdSubmit()">Send <kbd>Ctrl+Enter</kbd></button>
7
+ <button class="cmd-send-btn" id="cmd-send-btn" onclick="cmdSubmit()" aria-label="Send (Control+Enter)" aria-keyshortcuts="Control+Enter">Send <kbd id="cmd-send-kbd">Ctrl+Enter</kbd></button>
8
8
  </div>
9
9
  <div class="cmd-mention-popup" id="cmd-mention-popup"></div>
10
10
  <div class="cmd-meta" id="cmd-meta" style="display:none"></div>
@@ -1,7 +1,7 @@
1
1
  <section id="work-items-section" style="overflow:visible">
2
2
  <h2>Work Items <span class="count" id="wi-count">0</span>
3
3
  <button class="btn-add" style="margin-left:8px" onclick="openCreateWorkItemModal()">+ New</button>
4
- <button class="pr-pager-btn" style="font-size:var(--text-sm);padding:2px 8px;margin-left:4px" onclick="toggleWorkItemArchive()">See Archive</button>
4
+ <button id="work-archive-toggle" type="button" class="pr-pager-btn" style="font-size:var(--text-sm);padding:2px 8px;margin-left:4px" onclick="toggleWorkItemArchive()" aria-expanded="false" aria-controls="work-items-archive">See Archive</button>
5
5
  <span style="font-size:var(--text-sm);color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">tasks dispatched to agents — auto-created from PRDs or added manually</span>
6
6
  </h2>
7
7
  <div id="work-items-content"><p class="empty">No work items yet.</p></div>
@@ -0,0 +1,71 @@
1
+ // dashboard/shared/cc-queue-store.js
2
+ //
3
+ // Shared client helper to persist a Command Center tab's undrained queued
4
+ // (follow-up) messages to SQLite so they survive a hard refresh / new browser
5
+ // session (W-ms4o3gtm005z91c9). Used by BOTH the classic dashboard
6
+ // (dashboard/js/command-center.js) and Slim (dashboard/slim/js/chat.js +
7
+ // command-send.js) — do not fork this logic.
8
+ //
9
+ // The in-memory queue differs per surface: classic stores
10
+ // { message, intentMetadata } objects on tab._queue; slim stores plain message
11
+ // strings in queues[tabId]. Both normalize to { message, intentMetadata } for
12
+ // the wire, and ccQueueLoad always hands back that object shape so each surface
13
+ // can re-hydrate its own representation.
14
+ //
15
+ // Every helper is fire-and-forget and never throws: persistence is a
16
+ // best-effort convenience, so a failed request must never break the live queue.
17
+
18
+ function _ccQueueNormalizeEntry(entry) {
19
+ if (typeof entry === 'string') {
20
+ return entry ? { message: entry, intentMetadata: null } : null;
21
+ }
22
+ if (entry && typeof entry === 'object' && typeof entry.message === 'string' && entry.message) {
23
+ var meta = (entry.intentMetadata && typeof entry.intentMetadata === 'object'
24
+ && !Array.isArray(entry.intentMetadata)) ? entry.intentMetadata : null;
25
+ return { message: entry.message, intentMetadata: meta };
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function _ccQueueUrl(tabId) {
31
+ return '/api/cc-sessions/' + encodeURIComponent(tabId) + '/queue';
32
+ }
33
+
34
+ // Replace the persisted queue for a tab with the current in-memory queue.
35
+ // Accepts an array of strings and/or { message, intentMetadata } objects.
36
+ function ccQueueSave(tabId, queue) {
37
+ if (!tabId) return Promise.resolve();
38
+ var messages = (Array.isArray(queue) ? queue : [])
39
+ .map(_ccQueueNormalizeEntry)
40
+ .filter(Boolean);
41
+ try {
42
+ return fetch(_ccQueueUrl(tabId), {
43
+ method: 'PUT',
44
+ headers: { 'Content-Type': 'application/json' },
45
+ body: JSON.stringify({ messages: messages }),
46
+ }).catch(function() {});
47
+ } catch (_e) { return Promise.resolve(); }
48
+ }
49
+
50
+ // Load the persisted queue for a tab. Resolves to an array of
51
+ // { message, intentMetadata } objects (empty array on any failure).
52
+ function ccQueueLoad(tabId) {
53
+ if (!tabId) return Promise.resolve([]);
54
+ try {
55
+ return fetch(_ccQueueUrl(tabId))
56
+ .then(function(res) { return (res && res.ok) ? res.json() : null; })
57
+ .then(function(data) {
58
+ var arr = data && Array.isArray(data.messages) ? data.messages : [];
59
+ return arr.map(_ccQueueNormalizeEntry).filter(Boolean);
60
+ })
61
+ .catch(function() { return []; });
62
+ } catch (_e) { return Promise.resolve([]); }
63
+ }
64
+
65
+ // Clear the persisted queue for a tab.
66
+ function ccQueueClear(tabId) {
67
+ if (!tabId) return Promise.resolve();
68
+ try {
69
+ return fetch(_ccQueueUrl(tabId), { method: 'DELETE' }).catch(function() {});
70
+ } catch (_e) { return Promise.resolve(); }
71
+ }
@@ -435,6 +435,7 @@
435
435
  var qq = _getQueue();
436
436
  qq.splice(i, 1);
437
437
  renderQueue();
438
+ ccQueueSave(tabId, qq); // persist the shrunk queue (W-ms4o3gtm005z91c9)
438
439
  });
439
440
  row.appendChild(dismiss);
440
441
  queueEl.appendChild(row);
@@ -442,7 +443,23 @@
442
443
  scrollToBottom();
443
444
  }
444
445
 
445
- // Render a tool invocation as a one-line string. `full` keeps the complete
446
+ // Restore a tab's persisted queued messages after a hard refresh / new
447
+ // session (W-ms4o3gtm005z91c9). The queue is restored as greyed 'queued'
448
+ // bubbles but auto-drain is SUSPENDED — the user resumes explicitly via the
449
+ // "Send queued (N)" control (mirrors the post-abort state), so a restored
450
+ // queue never silently auto-fires on page load. No-op when the tab already
451
+ // has an in-memory queue (a live session we must not clobber or duplicate).
452
+ function _restoreQueueFor(id) {
453
+ if (!id) return;
454
+ ccQueueLoad(id).then(function(loaded) {
455
+ if (!loaded || !loaded.length) return;
456
+ var q = queues[id] || (queues[id] = []);
457
+ if (q.length) return;
458
+ loaded.forEach(function(entry) { q.push(entry.message); });
459
+ queueSuspended[id] = true;
460
+ if (id === tabId) renderQueue();
461
+ });
462
+ }
446
463
  // command/args (used by the tool-calls modal); otherwise long values are
447
464
  // clipped for the collapsed 3-line panel.
448
465
  function formatTool(name, input, full) {
@@ -723,6 +740,7 @@
723
740
  renderTabBar();
724
741
  _refreshComposerUI();
725
742
  inputEl.focus();
743
+ _restoreQueueFor(id);
726
744
  }
727
745
 
728
746
  function closeSlimTab(id) {
@@ -755,3 +773,4 @@
755
773
  loadState();
756
774
  rerenderHistory();
757
775
  renderTabBar();
776
+ _restoreQueueFor(tabId);
@@ -44,6 +44,7 @@
44
44
  function _enqueue(text) {
45
45
  _getQueue().push(text);
46
46
  renderQueue();
47
+ ccQueueSave(tabId, _getQueue()); // persist so the queue survives a refresh (W-ms4o3gtm005z91c9)
47
48
  }
48
49
 
49
50
  // Promote the next queued message for the active tab.
@@ -58,6 +59,7 @@
58
59
  var q = queues[id];
59
60
  if (!q || !q.length) return;
60
61
  var next = q.shift();
62
+ ccQueueSave(id, q); // persist the post-drain remainder (W-ms4o3gtm005z91c9)
61
63
  if (id === tabId) renderQueue();
62
64
  _performSend(next, id);
63
65
  }
@@ -19,6 +19,9 @@
19
19
  // card container. Guarded with typeof so the source-extracted unit-test copy
20
20
  // of this fn (which lacks the embed helpers/host) stays a safe no-op.
21
21
  if (typeof _pauseActiveEmbed === 'function') _pauseActiveEmbed();
22
+ // The Automation composite is likewise cached + kept mounted (W-ms4r1t5q…);
23
+ // hide it and suspend its tab iframes' pollers rather than tearing it down.
24
+ if (typeof _hideAutomationComposite === 'function') _hideAutomationComposite();
22
25
  var transient = document.getElementById('slim-tile-transient');
23
26
  if (transient) transient.textContent = '';
24
27
  }
@@ -133,6 +136,22 @@
133
136
  } catch (e) { /* defensive — same-origin means this should never throw */ }
134
137
  }
135
138
 
139
+ // Re-post a cached embed iframe's LIVE visibility once it has actually
140
+ // loaded. A visibility message posted BEFORE the iframe navigates lands on
141
+ // its throwaway about:blank document and is dropped when the real document
142
+ // replaces it — and the embedded classic SPA boots with its poll UNSUSPENDED
143
+ // (refresh.js `_embedPollSuspended = false`), so a frame that was hidden
144
+ // while still loading would poll /api/status every 4s forever in the
145
+ // background. `isVisible` is evaluated at LOAD time, not bind time, so the
146
+ // frame lands in whatever state the tile is in by then. Shared by every
147
+ // cached-iframe path (single-iframe embed tiles + Automation tabs).
148
+ function _bindEmbedVisibilityOnLoad(frame, isVisible) {
149
+ if (!frame || typeof frame.addEventListener !== 'function') return;
150
+ frame.addEventListener('load', function() {
151
+ _postEmbedVisibility(frame, !!isVisible());
152
+ });
153
+ }
154
+
136
155
  // Ensure the persistent host + transient containers exist inside the tile
137
156
  // modal body (created once, never wiped). Returns { host, transient }.
138
157
  function _ensureTileContainers() {
@@ -170,9 +189,7 @@
170
189
  frame.src = spec[0];
171
190
  frame.title = spec[1];
172
191
  frame.hidden = true;
173
- frame.addEventListener('load', function() {
174
- _postEmbedVisibility(frame, _activeEmbedKey === key);
175
- });
192
+ _bindEmbedVisibilityOnLoad(frame, function() { return _activeEmbedKey === key; });
176
193
  containers.host.appendChild(frame);
177
194
  _embedFrames[key] = frame;
178
195
  return frame;
@@ -295,22 +312,58 @@
295
312
  _showReusableEmbed('prs');
296
313
  }
297
314
 
298
- // Automation tile body — a tabbed composite (Watches · Schedules · Pipelines)
299
- // that reuses the LITERAL classic dashboard screens (/watches, /schedule,
300
- // /pipelines) instead of a slim-specific list, so there is one Automation UI,
301
- // not two (W-mrz0x2a400030f90 / "reuse, don't fork"mirrors renderPlansBody
302
- // for the Plans tile and renderPrsBody for the PRs tile). Each tab embeds the
303
- // real classic screen in an iframe with the chrome-off ?embed=1 mode; the
304
- // iframed page IS the classic screen — full create/edit/pause/run + row
305
- // actions, backed by the same /api/{watches,schedules,pipelines} endpoints —
306
- // with zero duplicated rendering logic. Classic is reachable at each route
307
- // even with slim-ux ON (only / is taken over). Iframes are LAZY: each tab's
308
- // frame is mounted only on first activation (default Watches), so opening the
309
- // modal never triple-loads all three screens. Built with createElement (no
315
+ // Persistent Automation composite (W-ms4r1t5q003h341a): the tabbed
316
+ // Watches·Schedules·Pipelines composite + its lazy iframes are built ONCE into
317
+ // #slim-tile-automation-host and shown/hidden across opens mirroring the
318
+ // _embedFrames cache used by the single-iframe tilesso reopening the
319
+ // Automation tile is an instant show, NOT a rebuild that cold-reloads all three
320
+ // classic screens into the wiped transient container. { activate, frames,
321
+ // activeKey } or null until first open.
322
+ var _automationComposite = null;
323
+
324
+ // Ensure the persistent Automation composite host exists inside the tile modal
325
+ // body (created once, never wiped). Sibling of #slim-tile-embed-host and
326
+ // #slim-tile-transient; shown only while the Automation tile is open.
327
+ function _ensureAutomationHost() {
328
+ var body = document.getElementById('slim-tile-body');
329
+ if (!body) return null;
330
+ var host = document.getElementById('slim-tile-automation-host');
331
+ if (!host) {
332
+ host = document.createElement('div');
333
+ host.id = 'slim-tile-automation-host';
334
+ host.className = 'slim-tile-automation-host';
335
+ host.hidden = true;
336
+ body.appendChild(host);
337
+ }
338
+ return host;
339
+ }
340
+
341
+ // Hide the cached Automation composite and suspend all its embedded tab
342
+ // pollers. A hidden (display:none) iframe still reports visibilityState
343
+ // 'visible', so without this the cached /watches·/schedule·/pipelines screens
344
+ // would keep polling /api/status in the background forever (refresh.js honors
345
+ // the visibility message). Called when another tile opens and on modal close.
346
+ // Guarded/no-op before the composite is built.
347
+ function _hideAutomationComposite() {
348
+ var host = document.getElementById('slim-tile-automation-host');
349
+ if (host) host.hidden = true;
350
+ if (_automationComposite && _automationComposite.frames) {
351
+ Object.keys(_automationComposite.frames).forEach(function(k) {
352
+ if (typeof _postEmbedVisibility === 'function') _postEmbedVisibility(_automationComposite.frames[k], false);
353
+ });
354
+ }
355
+ }
356
+
357
+ // Build the tabbed Watches·Schedules·Pipelines composite ONCE into the
358
+ // persistent host. Returns { activate, frames, activeKey }. Each tab's iframe
359
+ // is LAZY (mounted only on first activation) and CACHED (kept mounted so
360
+ // reopening never rebuilds/reloads it). Hidden tabs' pollers are suspended via
361
+ // the same visibility message the single-iframe embeds use, so a cached-but-
362
+ // inactive tab isn't polling in the background. Built with createElement (no
310
363
  // innerHTML) to satisfy the dashboard no-unsanitized lint gate; tab buttons
311
364
  // reuse the shared .kn-tab pill primitive and carry role="tab"/aria-selected
312
365
  // for keyboard/AT parity with the Knowledge modal tabs.
313
- function renderAutomationTileBody(body) {
366
+ function _buildAutomationComposite(host) {
314
367
  var TABS = [
315
368
  { key: 'watches', label: 'Watches', src: '/watches?embed=1' },
316
369
  { key: 'schedule', label: 'Schedules', src: '/schedule?embed=1' },
@@ -327,6 +380,7 @@
327
380
  var btns = {};
328
381
  var panels = {};
329
382
  var frames = {};
383
+ var composite = { activate: null, frames: frames, activeKey: null };
330
384
 
331
385
  function activate(key) {
332
386
  TABS.forEach(function(t) {
@@ -343,11 +397,24 @@
343
397
  frame.className = 'slim-automation-embed';
344
398
  frame.src = t.src;
345
399
  frame.title = t.label;
400
+ // A visibility message posted before this frame navigates is dropped
401
+ // on about:blank, so re-post the live state once it has loaded —
402
+ // otherwise a tab hidden mid-load becomes a permanent background
403
+ // /api/status poller. Same contract as _getOrCreateEmbedFrame.
404
+ _bindEmbedVisibilityOnLoad(frame, function() {
405
+ return composite.activeKey === t.key && !host.hidden;
406
+ });
346
407
  panel.appendChild(frame);
347
408
  frames[t.key] = frame;
348
409
  }
410
+ // Suspend hidden cached frames' pollers; resume the shown one.
411
+ if (frames[t.key] && typeof _postEmbedVisibility === 'function') {
412
+ _postEmbedVisibility(frames[t.key], isActive);
413
+ }
349
414
  });
415
+ composite.activeKey = key;
350
416
  }
417
+ composite.activate = activate;
351
418
 
352
419
  TABS.forEach(function(t) {
353
420
  var btn = document.createElement('button');
@@ -371,9 +438,36 @@
371
438
  panes.appendChild(panel);
372
439
  });
373
440
 
374
- body.appendChild(tablist);
375
- body.appendChild(panes);
376
- activate('watches'); // default to the Watches tab
441
+ host.appendChild(tablist);
442
+ host.appendChild(panes);
443
+ return composite;
444
+ }
445
+
446
+ // Automation tile body — a tabbed composite (Watches · Schedules · Pipelines)
447
+ // that reuses the LITERAL classic dashboard screens (/watches, /schedule,
448
+ // /pipelines) instead of a slim-specific list, so there is one Automation UI,
449
+ // not two (W-mrz0x2a400030f90 / "reuse, don't fork" — mirrors renderPlansBody
450
+ // for the Plans tile and renderPrsBody for the PRs tile). Each tab embeds the
451
+ // real classic screen in an iframe with the chrome-off ?embed=1 mode; the
452
+ // iframed page IS the classic screen — full create/edit/pause/run + row
453
+ // actions, backed by the same /api/{watches,schedules,pipelines} endpoints —
454
+ // with zero duplicated rendering logic. Classic is reachable at each route even
455
+ // with slim-ux ON (only / is taken over). The composite + its lazy iframes are
456
+ // CACHED in the persistent #slim-tile-automation-host and SHOWN/HIDDEN across
457
+ // opens (W-ms4r1t5q003h341a) rather than rebuilt into the wiped transient
458
+ // container — so reopening the tile is an instant show, not a rebuild that
459
+ // cold-reloads all three classic screens. Mirrors the _embedFrames reuse the
460
+ // single-iframe tiles already use. The `body` arg (transient) is intentionally
461
+ // ignored; the composite lives in its own persistent host.
462
+ function renderAutomationTileBody(/* body */) {
463
+ var host = _ensureAutomationHost();
464
+ if (!host) return;
465
+ if (!_automationComposite) {
466
+ _automationComposite = _buildAutomationComposite(host);
467
+ }
468
+ host.hidden = false;
469
+ // Reopen on the last-active tab (default Watches on first open).
470
+ _automationComposite.activate(_automationComposite.activeKey || 'watches');
377
471
  }
378
472
 
379
473
  // tile key -> { title, render }. Adding a tile is a one-line table edit.
@@ -423,6 +517,10 @@
423
517
  // into the transient container, so pause/hide any shown embed behind them.
424
518
  var isReusableEmbed = Object.prototype.hasOwnProperty.call(_EMBED_TILES, key);
425
519
  if (!isReusableEmbed) _pauseActiveEmbed();
520
+ // The Automation composite lives in its own persistent, cached host; hide it
521
+ // (suspending its tab iframes' pollers) whenever a different tile is shown.
522
+ // renderAutomationTileBody unhides + reactivates it when Automation opens.
523
+ if (key !== 'automation') _hideAutomationComposite();
426
524
  view.render(target, lastStatusData || {});
427
525
  modal.classList.add('open');
428
526
  }
@@ -18,16 +18,44 @@
18
18
  // — two stacked "Settings" bars was the duplicate-menu-bar bug
19
19
  // (W-mrpbzke4000ie609). Closing is driven from the embedded modal's own close
20
20
  // X (wired below to the parent), plus backdrop-click and Escape.
21
- function openSlimSettings() {
22
- var modal = document.getElementById('slim-settings-modal');
21
+ // CACHED settings iframe (W-ms4ogdc800aa6931). The old code discarded the
22
+ // frame on close and appended a BRAND-NEW `/settings?embed=1` iframe on every
23
+ // open, so each open cold-booted the entire classic SPA + full Settings render
24
+ // (/api/settings + /api/features + per-agent runtime dropdowns + projects +
25
+ // routing, plus the SPA's 4s /api/status poll) — the multi-second stall. We
26
+ // now mirror the cockpit-tile reuse contract (modals-tiles.js
27
+ // #_getOrCreateEmbedFrame / _showReusableEmbed): create the frame ONCE, keep
28
+ // it mounted (hidden), and show/hide it. A single background boot replaces N
29
+ // per-open cold boots; while hidden the embedded SPA's poll is suspended via
30
+ // the shared {source:'minions-slim-embed',type:'visibility'} message
31
+ // (refresh.js honors it in embed mode) so the cached frame doesn't poll
32
+ // /api/status forever off-screen.
33
+ var _slimSettingsFrame = null;
34
+
35
+ // Create (once) the cached settings iframe and return it. Built with
36
+ // createElement (no innerHTML) to satisfy the dashboard no-unsanitized lint
37
+ // gate. Starts hidden; a load-time visibility message suspends its poll until
38
+ // it is shown. Because the frame is cached, its `load` handler fires exactly
39
+ // ONCE — so the close/Escape wiring is installed here and persists across all
40
+ // hide/show cycles.
41
+ function _getOrCreateSettingsFrame() {
42
+ if (_slimSettingsFrame) return _slimSettingsFrame;
23
43
  var body = document.getElementById('slim-settings-body');
24
- if (!modal || !body) return;
25
- body.textContent = '';
44
+ if (!body) return null;
45
+ body.textContent = ''; // drop the "Loading settings…" placeholder (once)
26
46
  var frame = document.createElement('iframe');
27
47
  frame.className = 'slim-settings-embed';
28
48
  frame.src = '/settings?embed=1';
29
49
  frame.title = 'Settings';
50
+ frame.hidden = true;
30
51
  frame.addEventListener('load', function() {
52
+ // Suspend/resume the embedded SPA poll to match current visibility. On a
53
+ // visible:true the embedded classic Settings also re-pulls /api/settings +
54
+ // /api/features (see dashboard/js/settings.js#bootSettingsEmbed) — the
55
+ // same single postMessage channel, no second bus.
56
+ var modal = document.getElementById('slim-settings-modal');
57
+ var shown = !!(modal && modal.classList.contains('open'));
58
+ if (typeof _postEmbedVisibility === 'function') _postEmbedVisibility(frame, shown);
31
59
  var frameDocument = frame.contentDocument;
32
60
  if (!frameDocument) return;
33
61
  // Capture before the classic modal handler so only the parent modal closes.
@@ -53,20 +81,50 @@
53
81
  }
54
82
  });
55
83
  body.appendChild(frame);
84
+ _slimSettingsFrame = frame;
85
+ return frame;
86
+ }
87
+
88
+ function openSlimSettings() {
89
+ var modal = document.getElementById('slim-settings-modal');
90
+ var frame = _getOrCreateSettingsFrame();
91
+ if (!modal || !frame) return;
92
+ frame.hidden = false;
93
+ // Resume the embedded SPA poll + trigger a fresh /api/settings+/api/features
94
+ // re-pull (freshness on reopen — the cached frame's `load` fired only once,
95
+ // so without this a value changed elsewhere would show stale).
96
+ if (typeof _postEmbedVisibility === 'function') _postEmbedVisibility(frame, true);
56
97
  modal.classList.add('open');
57
98
  }
58
99
 
59
100
  function closeSlimSettings() {
60
- document.getElementById('slim-settings-modal').classList.remove('open');
101
+ var modal = document.getElementById('slim-settings-modal');
102
+ if (!modal) return;
103
+ // Keep the frame MOUNTED for an instant reopen; just hide it and suspend its
104
+ // poll (so the cached frame stops polling /api/status in the background).
105
+ if (_slimSettingsFrame) {
106
+ if (typeof _postEmbedVisibility === 'function') _postEmbedVisibility(_slimSettingsFrame, false);
107
+ _slimSettingsFrame.hidden = true;
108
+ }
109
+ modal.classList.remove('open');
61
110
  }
62
111
 
63
112
  (function bindSettingsUi() {
64
113
  var btn = document.getElementById('slim-settings-btn');
65
114
  var backdrop = document.getElementById('slim-settings-modal');
66
- if (btn) btn.addEventListener('click', openSlimSettings);
115
+ if (btn) {
116
+ btn.addEventListener('click', openSlimSettings);
117
+ // PREDICTIVE prewarm: the first hover / focus / press of the gear boots the
118
+ // cached settings frame before the click lands, so the open is an instant
119
+ // show. Fires at most once (the frame is cached thereafter).
120
+ var predictivePrewarm = function() { _getOrCreateSettingsFrame(); };
121
+ btn.addEventListener('pointerenter', predictivePrewarm, { once: true });
122
+ btn.addEventListener('pointerdown', predictivePrewarm, { once: true });
123
+ btn.addEventListener('focus', predictivePrewarm, { once: true });
124
+ }
67
125
  // No outer close button: the embedded classic Settings modal's own close X
68
- // (wired to the parent in openSlimSettings) is the single close affordance,
69
- // alongside backdrop-click and Escape below (W-mrpbzke4000ie609).
126
+ // (wired to the parent in _getOrCreateSettingsFrame) is the single close
127
+ // affordance, alongside backdrop-click and Escape below (W-mrpbzke4000ie609).
70
128
  if (backdrop) {
71
129
  backdrop.addEventListener('click', function(ev) {
72
130
  if (ev.target === backdrop) closeSlimSettings();
@@ -78,6 +136,22 @@
78
136
  });
79
137
  })();
80
138
 
139
+ // Deferred prewarm: boot the cached settings frame AFTER the cockpit's own hot
140
+ // embed prewarm (modals-tiles.js schedules that at ~800ms) so it never competes
141
+ // with slim cockpit initial paint. Idle-callback when available, else a delayed
142
+ // timeout — either way it runs well after first paint. First open then becomes
143
+ // an instant show instead of a cold boot.
144
+ (function scheduleSettingsPrewarm() {
145
+ var warm = function() { try { _getOrCreateSettingsFrame(); } catch (e) { /* skip prewarm */ } };
146
+ try {
147
+ if (typeof requestIdleCallback === 'function') {
148
+ requestIdleCallback(function() { setTimeout(warm, 1200); }, { timeout: 4000 });
149
+ } else {
150
+ setTimeout(warm, 1600);
151
+ }
152
+ } catch (e) { /* no timers available — skip prewarm, first open cold-boots */ }
153
+ })();
154
+
81
155
  // POST a feature-flag toggle; throws on a non-2xx so callers can branch on
82
156
  // failure. Used by the one-click returnToClassicDashboard().
83
157
  async function postFeatureToggle(id, enabled) {
@@ -21,6 +21,10 @@
21
21
  // — only deltas from a previously-seen number trigger the fade, so opening
22
22
  // the dashboard doesn't animate every tile.
23
23
  var lastTileValues = Object.create(null);
24
+ // Last (count|detail|lit) signature painted onto the Automation cockpit tile,
25
+ // so applyStatus can skip a redundant updateTile on every 5s status poll when
26
+ // nothing changed (W-ms4r1t5q003h341a).
27
+ var _automationTileSig = null;
24
28
 
25
29
  // Apply lit-state and value/detail text to a tile.
26
30
  function updateTile(key, value, detail, lit) {
@@ -230,14 +234,19 @@
230
234
  // counts are not carried by the status poll, and the task explicitly
231
235
  // says not to invent new API calls just for the tile count. The detail
232
236
  // modal (renderAutomationTileBody) embeds all three classic screens.
237
+ // This runs on EVERY 5s status poll, so gate the updateTile DOM work
238
+ // (querySelectors + textContent + classList churn) behind a change-check —
239
+ // repaint only when the count / detail / lit-state actually changed
240
+ // (render-cache-busting parity with the classic dashboard's _changed()).
233
241
  var watches = Array.isArray(data.watches) ? data.watches : [];
234
242
  var activeWatches = watches.filter(function(w) { return w && w.status === 'active'; });
235
- updateTile(
236
- 'automation',
237
- activeWatches.length,
238
- activeWatches.length ? 'monitoring' : 'no automation set',
239
- activeWatches.length ? 'blue' : null
240
- );
243
+ var autoDetail = activeWatches.length ? 'monitoring' : 'no automation set';
244
+ var autoLit = activeWatches.length ? 'blue' : null;
245
+ var autoSig = activeWatches.length + '|' + autoDetail + '|' + (autoLit || '');
246
+ if (_automationTileSig !== autoSig) {
247
+ _automationTileSig = autoSig;
248
+ updateTile('automation', activeWatches.length, autoDetail, autoLit);
249
+ }
241
250
 
242
251
  // ── Knowledge tile (Pinned Context + Notes + KB) ──────────────
243
252
  // Pinned count is live from the poll; notes/KB counts are fetched lazily
@@ -797,11 +797,24 @@
797
797
  outranks the class rules (no !important needed). */
798
798
  .slim-tile-embed-host, .slim-tile-transient { display: block; }
799
799
  #slim-tile-embed-host iframe[hidden] { display: none; }
800
+ /* Cached slim Settings frame (W-ms4ogdc800aa6931): like the tile embeds it is
801
+ created once and shown/hidden rather than cold-booted per open. The
802
+ .slim-settings-embed class rule above sets display:block, which outranks
803
+ [hidden]'s UA display:none — so a hidden cached frame must be forced back
804
+ to none. The id+attr selector outranks the class rule (no !important). */
805
+ #slim-settings-body iframe[hidden] { display: none; }
800
806
  /* Automation tile (Watches · Schedules · Pipelines) — a tabbed composite
801
807
  inside the shared wide tile-modal. The tab bar sits above the embedded
802
808
  classic screen, so the iframe height leaves room for it (vs the padless
803
809
  full-height embeds above). Reuses the .kn-tab pill primitive. */
804
810
  .slim-automation-tabs { padding: 12px 16px 0; }
811
+ /* Persistent Automation composite host (W-ms4r1t5q003h341a): like the single-
812
+ iframe embed host above, the tabbed Watches·Schedules·Pipelines composite +
813
+ its lazy iframes are built once and shown/hidden across opens rather than
814
+ rebuilt (which would cold-reload all three classic screens on every open).
815
+ Hidden via the [hidden] attribute when another tile is shown. */
816
+ #slim-tile-automation-host[hidden] { display: none; }
817
+ #slim-tile-automation-host .slim-automation-panel[hidden] { display: none; }
805
818
  .slim-automation-embed {
806
819
  display: block; width: 100%; height: calc(100vh - 148px); min-height: 320px;
807
820
  border: 0; background: var(--bg);
@@ -8,7 +8,7 @@ const shared = require('./engine/shared');
8
8
  const { safeRead } = shared;
9
9
 
10
10
  const MINIONS_DIR = __dirname;
11
- const DASHBOARD_SHARED_JS = ['pr-merge-state', 'pr-filters', 'cc-suggestions', 'model-display', 'watches-source', 'project-git-summary', 'welcome-popup'];
11
+ const DASHBOARD_SHARED_JS = ['pr-merge-state', 'pr-filters', 'cc-suggestions', 'cc-queue-store', 'model-display', 'watches-source', 'project-git-summary', 'welcome-popup'];
12
12
 
13
13
  // ── Canonical classic-dashboard assembly manifest ──────────────────────────
14
14
  // Single source of truth, consumed by BOTH assemblers: buildDashboardHtml()
package/dashboard.js CHANGED
@@ -11087,6 +11087,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11087
11087
  const sessions = _filterCcTabSessions(raw);
11088
11088
  return sessions.filter(s => s.id !== id);
11089
11089
  });
11090
+ // Evict any persisted queued messages for the closed tab (mirrors the
11091
+ // in-memory tab._queue / queues[id] cleanup on both surfaces).
11092
+ try { smallStateStore.clearCcQueue(id); } catch { /* SQL unavailable — best effort */ }
11090
11093
  // Sub-task C of W-mp2w003600196c51: tear down the persistent ACP worker
11091
11094
  // for this tab so we don't leak a Copilot process after the user closes
11092
11095
  // the tab. closeTab is a no-op when the pool has no entry for the tabId,
@@ -11095,6 +11098,56 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11095
11098
  return jsonReply(res, 200, { ok: true });
11096
11099
  }
11097
11100
 
11101
+ // GET /api/cc-sessions/:id/queue — load a tab's persisted queued messages so
11102
+ // the greyed-out 'queued' bubbles can be restored after a hard refresh
11103
+ // (W-ms4o3gtm005z91c9). Read-only; returns { messages: [{message, intentMetadata}] }.
11104
+ async function handleCCSessionQueueGet(req, res, match) {
11105
+ const id = match?.[1];
11106
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
11107
+ try {
11108
+ ccApiValidation.validateCcSessionPathId(id);
11109
+ const messages = smallStateStore.readCcQueue(id);
11110
+ return jsonReply(res, 200, { messages });
11111
+ } catch (e) {
11112
+ if (apiValidation.isApiInputError(e)) return apiErrorReply(res, e, req);
11113
+ return jsonReply(res, e.statusCode || 500, { error: e.message });
11114
+ }
11115
+ }
11116
+
11117
+ // PUT /api/cc-sessions/:id/queue — replace a tab's persisted queued messages.
11118
+ // The client re-sends the full queue on every mutation (enqueue, dismiss,
11119
+ // drain) so the persisted state always mirrors the live in-memory queue.
11120
+ async function handleCCSessionQueuePut(req, res, match) {
11121
+ const id = match?.[1];
11122
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
11123
+ try {
11124
+ ccApiValidation.validateCcSessionPathId(id);
11125
+ const body = await readBody(req, { maxBytes: CC_IMAGE_REQUEST_MAX_BYTES });
11126
+ ccApiValidation.validateCcQueueReplaceRequest(body);
11127
+ const result = smallStateStore.replaceCcQueue(id, body.messages);
11128
+ return jsonReply(res, 200, { ok: true, count: result.count });
11129
+ } catch (e) {
11130
+ if (apiValidation.isApiInputError(e)) return apiErrorReply(res, e, req);
11131
+ return jsonReply(res, e.statusCode || 500, { error: e.message });
11132
+ }
11133
+ }
11134
+
11135
+ // DELETE /api/cc-sessions/:id/queue — clear a tab's persisted queued messages
11136
+ // (e.g. after the queue fully drains). Bodyless.
11137
+ async function handleCCSessionQueueDelete(req, res, match) {
11138
+ const id = match?.[1];
11139
+ if (!id) return jsonReply(res, 400, { error: 'id required' });
11140
+ try {
11141
+ ccApiValidation.validateCcSessionPathId(id);
11142
+ ccApiValidation.validateNoRequestBody(req?.[REQUEST_BODY_CACHE]?.value);
11143
+ smallStateStore.clearCcQueue(id);
11144
+ return jsonReply(res, 200, { ok: true });
11145
+ } catch (e) {
11146
+ if (apiValidation.isApiInputError(e)) return apiErrorReply(res, e, req);
11147
+ return jsonReply(res, e.statusCode || 500, { error: e.message });
11148
+ }
11149
+ }
11150
+
11098
11151
  // Trigger a process-spawn + initialize + session/new (including MCP init)
11099
11152
  // in the background so the user's first message skips the ~18-21 s Copilot
11100
11153
  // cold-spawn. Runtime-gated: a no-op (200 skipped) when the pool is off, so
@@ -15931,6 +15984,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
15931
15984
  { method: 'GET', path: '/api/cc-sessions', desc: 'List CC session metadata for all tabs', handler: handleCCSessionsList },
15932
15985
  { method: 'POST', path: '/api/cc-sessions/warm', desc: 'Pre-warm the worker pool for a CC tab (process + MCP init, no LLM call). No-op when pool is off.', params: 'tabId', handler: handleCcSessionWarm },
15933
15986
  { method: 'DELETE', path: /^\/api\/cc-sessions\/([\w-]+)$/, template: '/api/cc-sessions/:id', desc: 'Delete a CC session by tab ID', handler: handleCCSessionDelete },
15987
+ { method: 'GET', path: /^\/api\/cc-sessions\/([\w-]+)\/queue$/, template: '/api/cc-sessions/:id/queue', desc: 'Load a CC tab\'s persisted queued (undrained) messages', handler: handleCCSessionQueueGet },
15988
+ { method: 'PUT', path: /^\/api\/cc-sessions\/([\w-]+)\/queue$/, template: '/api/cc-sessions/:id/queue', desc: 'Replace a CC tab\'s persisted queued (undrained) messages', params: 'messages[]', handler: handleCCSessionQueuePut },
15989
+ { method: 'DELETE', path: /^\/api\/cc-sessions\/([\w-]+)\/queue$/, template: '/api/cc-sessions/:id/queue', desc: 'Clear a CC tab\'s persisted queued (undrained) messages', handler: handleCCSessionQueueDelete },
15934
15990
 
15935
15991
  // Schedules
15936
15992
  { method: 'POST', path: '/api/schedules/parse-natural', desc: 'Parse natural language schedule text into cron expression', params: 'text', handler: handleSchedulesParseNatural },
@@ -98,10 +98,14 @@ module.exports = {
98
98
  'GET /api/cc-sessions',
99
99
  'POST /api/cc-sessions/warm',
100
100
  'DELETE /api/cc-sessions/<id>',
101
+ 'GET /api/cc-sessions/<id>/queue',
102
+ 'PUT /api/cc-sessions/<id>/queue',
103
+ 'DELETE /api/cc-sessions/<id>/queue',
101
104
  ],
102
105
  bodyless: [
103
106
  'POST /api/command-center/new-session',
104
107
  'DELETE /api/cc-sessions/<id>',
108
+ 'DELETE /api/cc-sessions/<id>/queue',
105
109
  ],
106
110
  overrides: {
107
111
  'GET /api/cc-sessions': { noInput: true },
@@ -215,5 +219,57 @@ module.exports = {
215
219
  { strategy: 'send-unexpected-body', expectedStatus: 400 },
216
220
  ],
217
221
  },
222
+ 'GET /api/cc-sessions/<id>/queue': {
223
+ audit: 'audited',
224
+ path: {
225
+ policy: 'required',
226
+ fields: [
227
+ { name: 'id', type: 'string', required: true, maxLength: CC_API_LIMITS.tabIdMaxChars },
228
+ ],
229
+ },
230
+ query: { policy: 'none', fields: [] },
231
+ headers: { policy: 'none', fields: [] },
232
+ },
233
+ 'PUT /api/cc-sessions/<id>/queue': {
234
+ audit: 'audited',
235
+ path: {
236
+ policy: 'required',
237
+ fields: [
238
+ { name: 'id', type: 'string', required: true, maxLength: CC_API_LIMITS.tabIdMaxChars },
239
+ ],
240
+ },
241
+ body: {
242
+ policy: 'required',
243
+ fields: [
244
+ {
245
+ name: 'messages',
246
+ type: 'array',
247
+ required: true,
248
+ maxItems: CC_API_LIMITS.queueMaxMessages,
249
+ itemType: {
250
+ type: 'object',
251
+ fields: [
252
+ { name: 'message', type: 'string', required: true, maxLength: CC_API_LIMITS.messageMaxChars },
253
+ { name: 'intentMetadata', type: 'object', required: false, maxBytes: CC_API_LIMITS.metadataMaxBytes },
254
+ ],
255
+ },
256
+ },
257
+ ],
258
+ },
259
+ negativeTests: commonNegativeTests,
260
+ },
261
+ 'DELETE /api/cc-sessions/<id>/queue': {
262
+ audit: 'audited',
263
+ path: {
264
+ policy: 'required',
265
+ fields: [
266
+ { name: 'id', type: 'string', required: true, maxLength: CC_API_LIMITS.tabIdMaxChars },
267
+ ],
268
+ },
269
+ negativeTests: [
270
+ { strategy: 'use-oversized-path-id', expectedStatus: 400 },
271
+ { strategy: 'send-unexpected-body', expectedStatus: 400 },
272
+ ],
273
+ },
218
274
  },
219
275
  };
@@ -26,6 +26,7 @@ const CC_API_LIMITS = Object.freeze({
26
26
  imageFilenameMaxChars: 255,
27
27
  imageMaxCount: 4,
28
28
  imageMaxDecodedBytes: 5 * 1024 * 1024,
29
+ queueMaxMessages: 50,
29
30
  });
30
31
 
31
32
  const TRANSCRIPT_ROLES = Object.freeze(['user', 'assistant', 'action', 'system']);
@@ -346,6 +347,58 @@ function validateCcSessionPathId(id) {
346
347
  return validateTabId(id, { required: true, path: ['path', 'id'] });
347
348
  }
348
349
 
350
+ // PUT /api/cc-sessions/:id/queue — replace a tab's persisted queued messages.
351
+ // Body shape: { messages: [{ message: string, intentMetadata?: object }, ...] }.
352
+ // The queue is bounded so a client cannot persist an unbounded blob.
353
+ function validateCcQueueReplaceRequest(body) {
354
+ validateBody(body);
355
+ apiValidation.validateArray(body.messages, {
356
+ field: 'messages',
357
+ path: ['body', 'messages'],
358
+ maxLength: CC_API_LIMITS.queueMaxMessages,
359
+ });
360
+ body.messages.forEach((entry, index) => {
361
+ const entryPath = ['body', 'messages', index];
362
+ apiValidation.validatePlainObject(entry, {
363
+ field: 'messages',
364
+ path: entryPath,
365
+ });
366
+ validateNonBlankString(entry.message, {
367
+ field: 'message',
368
+ path: [...entryPath, 'message'],
369
+ maxLength: CC_API_LIMITS.messageMaxChars,
370
+ });
371
+ if (entry.intentMetadata !== undefined && entry.intentMetadata !== null) {
372
+ apiValidation.validatePlainObject(entry.intentMetadata, {
373
+ field: 'intentMetadata',
374
+ path: [...entryPath, 'intentMetadata'],
375
+ });
376
+ let encoded;
377
+ try {
378
+ encoded = JSON.stringify(entry.intentMetadata);
379
+ } catch {
380
+ throwConstraint('intentMetadata must be JSON-serializable', {
381
+ field: 'intentMetadata',
382
+ path: [...entryPath, 'intentMetadata'],
383
+ rejectedValue: entry.intentMetadata,
384
+ expected: 'JSON-serializable plain object',
385
+ });
386
+ }
387
+ if (Object.keys(entry.intentMetadata).length > CC_API_LIMITS.metadataMaxKeys
388
+ || Buffer.byteLength(encoded || '', 'utf8') > CC_API_LIMITS.metadataMaxBytes) {
389
+ throwConstraint('intentMetadata is too large', {
390
+ field: 'intentMetadata',
391
+ path: [...entryPath, 'intentMetadata'],
392
+ rejectedValue: entry.intentMetadata,
393
+ expected: `plain object with at most ${CC_API_LIMITS.metadataMaxKeys} keys and ${CC_API_LIMITS.metadataMaxBytes} encoded bytes`,
394
+ extra: { max: CC_API_LIMITS.metadataMaxBytes },
395
+ });
396
+ }
397
+ }
398
+ });
399
+ return body;
400
+ }
401
+
349
402
  function realPath(targetPath) {
350
403
  return fs.realpathSync.native ? fs.realpathSync.native(targetPath) : fs.realpathSync(targetPath);
351
404
  }
@@ -577,6 +630,7 @@ module.exports = {
577
630
  validateAbortRequest,
578
631
  validateCcWarmRequest,
579
632
  validateCcSessionPathId,
633
+ validateCcQueueReplaceRequest,
580
634
  validateDocChatRequest,
581
635
  validateDocChatWarmRequest,
582
636
  validateBrowserPresenceRequest,
@@ -0,0 +1,29 @@
1
+ // engine/db/migrations/028-cc-queued-messages.js
2
+ //
3
+ // Persist each Command Center tab's undrained queued messages so they survive a
4
+ // hard refresh / new browser session (W-ms4o3gtm005z91c9). The queue was
5
+ // previously in-memory only (classic tab._queue, slim `queues[tabId]`).
6
+ //
7
+ // One row per queued message, keyed by (tab_id, position). `position` preserves
8
+ // FIFO order; `intent_metadata` carries the optional classic {intentMetadata}
9
+ // blob (slim entries are plain strings and store NULL). No backfill — there is
10
+ // no legacy on-disk source for an in-memory-only queue.
11
+
12
+ module.exports = {
13
+ version: 28,
14
+ description: 'cc_queued_messages — persist per-tab CC queued messages',
15
+ up(db) {
16
+ db.exec(`
17
+ CREATE TABLE cc_queued_messages (
18
+ tab_id TEXT NOT NULL,
19
+ position INTEGER NOT NULL,
20
+ message TEXT NOT NULL,
21
+ intent_metadata TEXT,
22
+ created_at INTEGER NOT NULL,
23
+ PRIMARY KEY (tab_id, position)
24
+ );
25
+
26
+ CREATE INDEX idx_cc_queued_messages_tab ON cc_queued_messages(tab_id);
27
+ `);
28
+ },
29
+ };
@@ -746,6 +746,90 @@ function applyCcGlobalSessionMutation(mutator) {
746
746
  });
747
747
  }
748
748
 
749
+ // ─── cc_queued_messages ──────────────────────────────────────────────────────
750
+ // Persist a CC tab's undrained queued follow-up messages so they survive a hard
751
+ // refresh (W-ms4o3gtm005z91c9). One row per message keyed by (tab_id, position);
752
+ // `position` preserves FIFO order. Entry shape: { message, intentMetadata }
753
+ // (slim enqueues plain strings → intentMetadata null).
754
+
755
+ function _normalizeCcQueueEntry(entry) {
756
+ if (typeof entry === 'string') {
757
+ const message = entry;
758
+ return message ? { message, intentMetadata: null } : null;
759
+ }
760
+ if (entry && typeof entry === 'object' && typeof entry.message === 'string' && entry.message) {
761
+ const intentMetadata = (entry.intentMetadata && typeof entry.intentMetadata === 'object'
762
+ && !Array.isArray(entry.intentMetadata))
763
+ ? entry.intentMetadata
764
+ : null;
765
+ return { message: entry.message, intentMetadata };
766
+ }
767
+ return null;
768
+ }
769
+
770
+ function readCcQueue(tabId) {
771
+ if (!tabId) return [];
772
+ const { getDb } = require('./db');
773
+ const rows = getDb()
774
+ .prepare('SELECT message, intent_metadata FROM cc_queued_messages WHERE tab_id = ? ORDER BY position')
775
+ .all(String(tabId));
776
+ const out = [];
777
+ for (const row of rows) {
778
+ let intentMetadata = null;
779
+ if (row.intent_metadata != null) {
780
+ try {
781
+ const parsed = JSON.parse(row.intent_metadata);
782
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) intentMetadata = parsed;
783
+ } catch { /* malformed — drop metadata, keep message */ }
784
+ }
785
+ out.push({ message: row.message, intentMetadata });
786
+ }
787
+ return out;
788
+ }
789
+
790
+ function replaceCcQueue(tabId, messages) {
791
+ if (!tabId) return { wrote: false, count: 0 };
792
+ const { getDb, withTransaction } = require('./db');
793
+ let db;
794
+ try { db = getDb(); }
795
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
796
+
797
+ const normalized = (Array.isArray(messages) ? messages : [])
798
+ .map(_normalizeCcQueueEntry)
799
+ .filter(Boolean);
800
+
801
+ return withTransaction(db, () => {
802
+ db.prepare('DELETE FROM cc_queued_messages WHERE tab_id = ?').run(String(tabId));
803
+ const ins = db.prepare(`
804
+ INSERT INTO cc_queued_messages (tab_id, position, message, intent_metadata, created_at)
805
+ VALUES (?, ?, ?, ?, ?)
806
+ `);
807
+ const now = Date.now();
808
+ normalized.forEach((entry, position) => {
809
+ ins.run(
810
+ String(tabId),
811
+ position,
812
+ entry.message,
813
+ entry.intentMetadata ? JSON.stringify(entry.intentMetadata) : null,
814
+ now,
815
+ );
816
+ });
817
+ return { wrote: true, count: normalized.length };
818
+ });
819
+ }
820
+
821
+ function clearCcQueue(tabId) {
822
+ if (!tabId) return { wrote: false };
823
+ const { getDb, withTransaction } = require('./db');
824
+ let db;
825
+ try { db = getDb(); }
826
+ catch (e) { throw new Error(`small-state-store: SQLite unavailable (${e.message})`); }
827
+ return withTransaction(db, () => {
828
+ const info = db.prepare('DELETE FROM cc_queued_messages WHERE tab_id = ?').run(String(tabId));
829
+ return { wrote: (info && info.changes > 0) || false };
830
+ });
831
+ }
832
+
749
833
  // ─── doc_sessions ──────────────────────────────────────────────────────────
750
834
  // Shape: { [filePath]: { sessionId, lastActiveAt, turnCount, ... } }
751
835
  // SQL: row per filePath key.
@@ -837,6 +921,10 @@ module.exports = {
837
921
  applyCcSessionsMutation,
838
922
  readCcGlobalSession,
839
923
  applyCcGlobalSessionMutation,
924
+ // cc_queued_messages
925
+ readCcQueue,
926
+ replaceCcQueue,
927
+ clearCcQueue,
840
928
  // doc_sessions
841
929
  readDocSessions,
842
930
  applyDocSessionsMutation,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2438",
3
+ "version": "0.1.2440",
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"
@@ -73,6 +73,20 @@ on the OPG `Visual evidence capture` CI bot (that bot stays as-is; this is
73
73
  additive PR-body evidence). When the existing description already embeds
74
74
  `![alt](url)` refs for a view this dispatch touched, refresh them the same way.
75
75
 
76
+ **MANDATORY GATE — UI-surface diffs REQUIRE screenshots.** Screenshot capture +
77
+ embed is a **required completion gate**, not a nicety, for any dispatch whose diff
78
+ modifies a **rendered UI surface** — e.g. files under `dashboard/`,
79
+ `dashboard/slim/`, `dashboard/pages/`, `dashboard/js/`, `dashboard/styles.css`, or
80
+ ANY change that alters what a dashboard page/route renders
81
+ (layout/CSS/markup/new UI). For such a diff you **MUST** capture and embed AFTER
82
+ screenshots (BEFORE/AFTER for visual FIXES, AFTER-only for NEW UI) for the PR to be
83
+ considered complete. Omitting screenshots on a UI-surface diff **without a recorded
84
+ hard-failure reason makes the PR incomplete** — "I judged it not visual enough" is
85
+ **NOT** a valid skip when the diff touches those paths. The ONLY legitimate skips are
86
+ genuine hard failures: the dev server will not start after a real attempt, Playwright
87
+ MCP is unavailable, the route 404s, or upload/attachment auth fails. Record any such
88
+ skip in `meta.descriptionAudit.result` as `screenshots-skipped (<specific-reason>)`.
89
+
76
90
  **Scope guard — which changes get screenshots.** Only meaningful visual/UI
77
91
  changes: dashboard pages, slim-ux, Settings, work-item/PR/plan views, badges,
78
92
  layout/CSS. Skip backend-only, engine-logic, text/prose-only, analysis, and
@@ -135,7 +135,7 @@ After you push commits to the PR's source branch and BEFORE you mark the work it
135
135
 
136
136
  **Out-of-scope guardrails**
137
137
 
138
- - Only screenshot **meaningful visual/UI changes** (dashboard pages, slim-ux, Settings, work-item/PR/plan views, badges, layout/CSS); skip backend/engine/text/analysis/docs changes. When a build fix does touch a visual surface, proactively capture + embed via the release-asset recipe in `_pr-description-audit.md` (BEFORE/AFTER for fixes, AFTER-only for new UI).
138
+ - Only screenshot **meaningful visual/UI changes** (dashboard pages, slim-ux, Settings, work-item/PR/plan views, badges, layout/CSS); skip backend/engine/text/analysis/docs changes. **MANDATORY GATE:** when a build fix's diff touches a **rendered UI surface** (files under `dashboard/`, `dashboard/slim/`, `dashboard/pages/`, `dashboard/js/`, `dashboard/styles.css`, or anything that alters what a dashboard page/route renders), you **MUST** capture + embed AFTER screenshots (BEFORE/AFTER for fixes, AFTER-only for new UI) via the release-asset recipe in `_pr-description-audit.md` for the PR to be complete — "not visual enough" is NOT a valid skip on those paths; only a recorded hard failure (dev server won't start, Playwright MCP unavailable, route 404s, upload auth fails) justifies skipping, recorded in `meta.descriptionAudit.result` as `screenshots-skipped (<specific-reason>)`.
139
139
  - Don't rewrite description prose beyond the targeted stale edits.
140
140
  - Don't modify the PR title; don't toggle draft state, close/reopen the PR.
141
141
  - Don't post a separate PR comment summarizing the description change.
package/playbooks/fix.md CHANGED
@@ -172,6 +172,8 @@ After you push commits to the PR's source branch and BEFORE you mark the work it
172
172
 
173
173
  For a PR that contains a **meaningful visual/UI change**, proactively CAPTURE screenshots and EMBED them directly in the PR description — do NOT rely only on the OPG `Visual evidence capture` CI bot (it stays as-is; this is additive PR-body evidence). When the description already embeds `![alt](url)` refs for a view this dispatch touched, refresh them the same way.
174
174
 
175
+ **MANDATORY GATE — UI-surface diffs REQUIRE screenshots.** Screenshot capture + embed is a **required completion gate**, not a nicety, for any dispatch whose diff modifies a **rendered UI surface** — e.g. files under `dashboard/`, `dashboard/slim/`, `dashboard/pages/`, `dashboard/js/`, `dashboard/styles.css`, or ANY change that alters what a dashboard page/route renders (layout/CSS/markup/new UI). For such a diff you **MUST** capture and embed AFTER screenshots (BEFORE/AFTER for visual FIXES, AFTER-only for NEW UI) for the PR to be considered complete. Omitting screenshots on a UI-surface diff **without a recorded hard-failure reason makes the PR incomplete** — "I judged it not visual enough" is **NOT** a valid skip when the diff touches those paths. The ONLY legitimate skips are genuine hard failures: the dev server will not start after a real attempt, Playwright MCP is unavailable, the route 404s, or upload/attachment auth fails. Record any such skip in `meta.descriptionAudit.result` as `screenshots-skipped (<specific-reason>)`.
176
+
175
177
  **Scope guard — which changes get screenshots.** Only meaningful visual/UI changes: dashboard pages, slim-ux, Settings, work-item/PR/plan views, badges, layout/CSS. Skip backend-only, engine-logic, text/prose-only, analysis, and docs changes — those get NO screenshots (they add no signal). BEFORE/AFTER pair for layout/visual FIXES; AFTER-only for NEW UI.
176
178
 
177
179
  1. If the project has a runnable dev server (detect via `package.json` scripts named `dev`, `start`, or `serve`) AND Playwright MCP is available, spin it up with a detached handoff per `shared-rules.md` → "Long-Running Commands" (record PID + log path + URL + stop command).
@@ -144,6 +144,8 @@ After you push commits to the PR's source branch and BEFORE you mark the work it
144
144
 
145
145
  For a PR that contains a **meaningful visual/UI change**, proactively CAPTURE screenshots and EMBED them directly in the PR description — do NOT rely only on the OPG `Visual evidence capture` CI bot (it stays as-is; this is additive PR-body evidence). When the description already embeds `![alt](url)` refs for a view this dispatch touched, refresh them the same way.
146
146
 
147
+ **MANDATORY GATE — UI-surface diffs REQUIRE screenshots.** Screenshot capture + embed is a **required completion gate**, not a nicety, for any dispatch whose diff modifies a **rendered UI surface** — e.g. files under `dashboard/`, `dashboard/slim/`, `dashboard/pages/`, `dashboard/js/`, `dashboard/styles.css`, or ANY change that alters what a dashboard page/route renders (layout/CSS/markup/new UI). For such a diff you **MUST** capture and embed AFTER screenshots (BEFORE/AFTER for visual FIXES, AFTER-only for NEW UI) for the PR to be considered complete. Omitting screenshots on a UI-surface diff **without a recorded hard-failure reason makes the PR incomplete** — "I judged it not visual enough" is **NOT** a valid skip when the diff touches those paths. The ONLY legitimate skips are genuine hard failures: the dev server will not start after a real attempt, Playwright MCP is unavailable, the route 404s, or upload/attachment auth fails. Record any such skip in `meta.descriptionAudit.result` as `screenshots-skipped (<specific-reason>)`.
148
+
147
149
  **Scope guard — which changes get screenshots.** Only meaningful visual/UI changes: dashboard pages, slim-ux, Settings, work-item/PR/plan views, badges, layout/CSS. Skip backend-only, engine-logic, text/prose-only, analysis, and docs changes — those get NO screenshots (they add no signal). BEFORE/AFTER pair for layout/visual FIXES; AFTER-only for NEW UI.
148
150
 
149
151
  1. If the project has a runnable dev server (detect via `package.json` scripts named `dev`, `start`, or `serve`) AND Playwright MCP is available, spin it up with a detached handoff per `shared-rules.md` → "Long-Running Commands" (record PID + log path + URL + stop command).
@@ -208,6 +208,7 @@ Concretely:
208
208
  - Do not sleep or busy-wait for `mergeStatus`, `buildStatus`, or any ADO/GitHub API to flip from `running` to `passing`.
209
209
  - If you skipped local validation, say so in the completion JSON (e.g. `tests: skipped — relying on PR pipeline`) and still exit.
210
210
  - Holding a slot to watch a pipeline is wasted capacity; the engine has its own pipeline-monitoring path.
211
+ - **Screenshots are a MANDATORY completion gate for UI-surface diffs.** Any PR whose diff changes a rendered dashboard/UI surface (files under `dashboard/`, `dashboard/slim/`, `dashboard/pages/`, `dashboard/js/`, `dashboard/styles.css`, or anything that alters what a page/route renders) MUST embed AFTER screenshots in the PR description (full recipe: `_pr-description-audit.md`) — skips are allowed only on a recorded hard failure (dev server won't start, Playwright MCP unavailable, route 404s, upload auth fails). Backend/engine/text/analysis/docs diffs get NO screenshots.
211
212
 
212
213
  ## Resolving Review Threads — No Silent Closures
213
214