@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.
- package/dashboard/js/command-center.js +27 -1
- package/dashboard/js/command-input.js +20 -3
- package/dashboard/js/render-kb.js +35 -3
- package/dashboard/js/render-work-items.js +43 -14
- package/dashboard/js/utils.js +29 -1
- package/dashboard/pages/home.html +1 -1
- package/dashboard/pages/work.html +1 -1
- package/dashboard/shared/cc-queue-store.js +71 -0
- package/dashboard/slim/js/chat.js +20 -1
- package/dashboard/slim/js/command-send.js +2 -0
- package/dashboard/slim/js/modals-tiles.js +117 -19
- package/dashboard/slim/js/status.js +15 -6
- package/dashboard/slim/styles.css +7 -0
- package/dashboard-build.js +1 -1
- package/dashboard.js +69 -3
- package/engine/api-contracts/cc-ops.js +56 -0
- package/engine/cc-api-validation.js +54 -0
- package/engine/db/migrations/028-cc-queued-messages.js +29 -0
- package/engine/small-state-store.js +88 -0
- package/package.json +1 -1
- package/playbooks/_pr-description-audit.md +14 -0
- package/playbooks/build-fix-complex.md +1 -1
- package/playbooks/fix.md +2 -0
- package/playbooks/implement.md +2 -0
- package/playbooks/shared-rules.md +1 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
|
128
|
-
if (e
|
|
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
|
-
|
|
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
|
|
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) {
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
'<
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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) {
|
package/dashboard/js/utils.js
CHANGED
|
@@ -902,4 +902,32 @@ async function submitBugReport() {
|
|
|
902
902
|
}
|
|
903
903
|
}
|
|
904
904
|
|
|
905
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
//
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
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 tiles — so 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
|
|
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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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);
|
package/dashboard-build.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
`` 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.
|
|
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 `` 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).
|
package/playbooks/implement.md
CHANGED
|
@@ -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 `` 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
|
|