@yemi33/minions 0.1.2437 → 0.1.2439

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();
@@ -205,17 +205,32 @@ async function kbSweep() {
205
205
  }
206
206
 
207
207
  let _memorySearchUiLoading = false;
208
+ // Lazily loads /assets/memory-search.js, then opens the modal.
209
+ //
210
+ // Both failure paths matter (W-ms5dlone012i457a-c):
211
+ // - script.onerror -> the network/404 case
212
+ // - script.onload but window.MinionsMemorySearch still undefined -> the
213
+ // script was served but did not register (e.g. a truncated/empty body, or
214
+ // it threw while evaluating). Dereferencing .open() here used to raise an
215
+ // uncaught TypeError.
216
+ // In every path the loading latch must be cleared, otherwise the early-return
217
+ // below permanently and silently bricks the button for the rest of the session.
208
218
  function openMemorySearchModal() {
209
219
  if (window.MinionsMemorySearch) return window.MinionsMemorySearch.open();
210
220
  if (_memorySearchUiLoading) return;
211
221
  _memorySearchUiLoading = true;
212
222
  const script = document.createElement('script');
213
223
  script.src = '/assets/memory-search.js';
214
- script.onload = () => window.MinionsMemorySearch.open();
215
- script.onerror = () => {
224
+ const fail = () => {
216
225
  _memorySearchUiLoading = false;
217
226
  showToast('kb-sweep-toast', 'Memory search UI failed to load', false);
218
227
  };
228
+ script.onload = () => {
229
+ _memorySearchUiLoading = false;
230
+ if (!window.MinionsMemorySearch) return fail();
231
+ window.MinionsMemorySearch.open();
232
+ };
233
+ script.onerror = fail;
219
234
  document.head.appendChild(script);
220
235
  }
221
236
 
@@ -262,7 +277,24 @@ async function submitKbEntry(e) {
262
277
 
263
278
  async function kbOpenItem(category, file) {
264
279
  try {
265
- const content = await fetch('/api/knowledge/' + category + '/' + encodeURIComponent(file)).then(r => r.text());
280
+ const res = await fetch('/api/knowledge/' + category + '/' + encodeURIComponent(file));
281
+ // Without this check a 404/500 body was rendered as if it were the entry,
282
+ // and a 200-with-empty-body opened a completely blank modal with no error
283
+ // (W-ms5dlone012i457a-c). Surface the failure instead of rendering nothing.
284
+ if (!res.ok) {
285
+ document.getElementById('modal-title').textContent = file;
286
+ const failBody = document.getElementById('modal-body');
287
+ failBody.replaceChildren();
288
+ const msg = document.createElement('p');
289
+ msg.className = 'empty';
290
+ msg.textContent = res.status === 404
291
+ ? 'This knowledge entry no longer exists on disk (404). It may have been renamed or swept.'
292
+ : 'Could not load this knowledge entry (HTTP ' + res.status + ').';
293
+ failBody.appendChild(msg);
294
+ document.getElementById('modal').classList.add('open');
295
+ return;
296
+ }
297
+ const content = await res.text();
266
298
  const display = content.replace(/^---[\s\S]*?---\n*/m, '');
267
299
  document.getElementById('modal-title').textContent = file;
268
300
  const modalBody = document.getElementById('modal-body');
@@ -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) {
@@ -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
  }
@@ -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
@@ -802,6 +802,13 @@
802
802
  classic screen, so the iframe height leaves room for it (vs the padless
803
803
  full-height embeds above). Reuses the .kn-tab pill primitive. */
804
804
  .slim-automation-tabs { padding: 12px 16px 0; }
805
+ /* Persistent Automation composite host (W-ms4r1t5q003h341a): like the single-
806
+ iframe embed host above, the tabbed Watches·Schedules·Pipelines composite +
807
+ its lazy iframes are built once and shown/hidden across opens rather than
808
+ rebuilt (which would cold-reload all three classic screens on every open).
809
+ Hidden via the [hidden] attribute when another tile is shown. */
810
+ #slim-tile-automation-host[hidden] { display: none; }
811
+ #slim-tile-automation-host .slim-automation-panel[hidden] { display: none; }
805
812
  .slim-automation-embed {
806
813
  display: block; width: 100%; height: calc(100vh - 148px); min-height: 320px;
807
814
  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
@@ -9080,7 +9080,11 @@ const server = http.createServer(async (req, res) => {
9080
9080
  MINIONS_DIR,
9081
9081
  );
9082
9082
  const kbCatDir = path.join(MINIONS_DIR, 'knowledge', cat);
9083
- const content = safeRead(path.join(kbCatDir, file));
9083
+ // safeReadOrNull (not safeRead): safeRead collapses ENOENT to '', which
9084
+ // made this 404 guard dead code and served a missing entry as 200 with an
9085
+ // empty body — render-kb.js#kbOpenItem then opened a blank modal with no
9086
+ // error. Present-but-empty still reads as '' and correctly returns 200.
9087
+ const content = safeReadOrNull(path.join(kbCatDir, file));
9084
9088
  if (content === null) return jsonReply(res, 404, { error: 'not found', code: 'not-found' });
9085
9089
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
9086
9090
  res.end(content);
@@ -11083,6 +11087,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11083
11087
  const sessions = _filterCcTabSessions(raw);
11084
11088
  return sessions.filter(s => s.id !== id);
11085
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 */ }
11086
11093
  // Sub-task C of W-mp2w003600196c51: tear down the persistent ACP worker
11087
11094
  // for this tab so we don't leak a Copilot process after the user closes
11088
11095
  // the tab. closeTab is a no-op when the pool has no entry for the tabId,
@@ -11091,6 +11098,56 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11091
11098
  return jsonReply(res, 200, { ok: true });
11092
11099
  }
11093
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
+
11094
11151
  // Trigger a process-spawn + initialize + session/new (including MCP init)
11095
11152
  // in the background so the user's first message skips the ~18-21 s Copilot
11096
11153
  // cold-spawn. Runtime-gated: a no-op (200 skipped) when the pool is off, so
@@ -15825,7 +15882,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
15825
15882
  catch { return jsonReply(res, 400, { error: 'invalid path' }); }
15826
15883
  const agentsDir = path.join(MINIONS_DIR, 'agents');
15827
15884
  if (!safePath.startsWith(agentsDir + path.sep)) return jsonReply(res, 400, { error: 'path must be within agents/' });
15828
- const content = _agentApiCall('readOutputFile', safeRead, safePath);
15885
+ // safeReadOrNull (not safeRead) so a missing log 404s instead of being
15886
+ // served as 200 with an empty body — see the KB read above.
15887
+ const content = _agentApiCall('readOutputFile', safeReadOrNull, safePath);
15829
15888
  if (content === null) return jsonReply(res, 404, { error: 'not found' });
15830
15889
  res.setHeader('Content-Type', 'text/plain; charset=utf-8');
15831
15890
  res.setHeader('Cache-Control', 'no-cache');
@@ -15834,7 +15893,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
15834
15893
 
15835
15894
  // Knowledge base
15836
15895
  { method: 'GET', path: '/assets/memory-search.js', desc: 'Serve the lazy-loaded structured memory search UI', handler: (req, res) => {
15837
- const content = safeRead(path.join(MINIONS_DIR, 'dashboard', 'js', 'memory-search.js'));
15896
+ // safeReadOrNull (not safeRead): a 200 with an empty body still fires
15897
+ // the loader's script.onload, leaving window.MinionsMemorySearch
15898
+ // undefined so openMemorySearchModal() throws. 404 lets script.onerror
15899
+ // surface the intended "failed to load" toast instead.
15900
+ const content = safeReadOrNull(path.join(MINIONS_DIR, 'dashboard', 'js', 'memory-search.js'));
15838
15901
  if (content == null) { res.statusCode = 404; return res.end('not found'); }
15839
15902
  res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
15840
15903
  res.setHeader('Cache-Control', 'no-cache');
@@ -15921,6 +15984,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
15921
15984
  { method: 'GET', path: '/api/cc-sessions', desc: 'List CC session metadata for all tabs', handler: handleCCSessionsList },
15922
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 },
15923
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 },
15924
15990
 
15925
15991
  // Schedules
15926
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.2437",
3
+ "version": "0.1.2439",
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