@yemi33/minions 0.1.2212 → 0.1.2213
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 +161 -4
- package/dashboard/js/command-input.js +21 -1
- package/dashboard/js/render-work-items.js +9 -0
- package/dashboard/layout.html +5 -3
- package/dashboard/pages/home.html +1 -1
- package/dashboard/styles.css +14 -0
- package/dashboard.js +186 -29
- package/docs/command-center.md +39 -0
- package/docs/self-improvement.md +2 -2
- package/engine/cc-worker-pool.js +28 -11
- package/engine/cli.js +2 -2
- package/engine/db-events.js +2 -4
- package/engine/dispatch.js +1 -0
- package/engine/lifecycle.js +9 -1
- package/engine/llm.js +59 -2
- package/engine/runtimes/claude.js +51 -4
- package/engine/runtimes/codex.js +3 -0
- package/engine/runtimes/copilot.js +5 -0
- package/engine/shared.js +1 -0
- package/engine/stdio-timestamps.js +1 -2
- package/engine.js +45 -3
- package/package.json +1 -1
|
@@ -17,6 +17,158 @@ var _ccSending = false; // true if active tab is sending (UI indicator only)
|
|
|
17
17
|
// Clear stale sending state on page load — SSE streams don't survive refresh
|
|
18
18
|
try { localStorage.removeItem('cc-sending'); } catch {}
|
|
19
19
|
|
|
20
|
+
// ── P-2a6d8e74 — Image attachments (paste / drag-drop) ──────────────────────
|
|
21
|
+
// Pending images for the NEXT CC turn. Each entry: { mimeType, dataBase64,
|
|
22
|
+
// filename, dataUrl }. dataBase64 is the raw (prefix-stripped) base64 sent to
|
|
23
|
+
// the server; dataUrl (full data: URI) is for the local thumbnail only.
|
|
24
|
+
// Cleared after every dispatch (v1 sends images for the current turn only).
|
|
25
|
+
var _ccAttachments = [];
|
|
26
|
+
// Client-side limits MIRROR the server (dashboard.js _validateCcImages) — they
|
|
27
|
+
// are UX-only pre-screening; the server remains the single trust boundary.
|
|
28
|
+
var CC_IMG_MAX_COUNT = 4;
|
|
29
|
+
var CC_IMG_MAX_BYTES = 5 * 1024 * 1024; // 5 MB decoded, per image
|
|
30
|
+
var CC_IMG_MIME_ALLOW = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
31
|
+
|
|
32
|
+
// Read one File/Blob into the attachment shape. Resolves { error } on a
|
|
33
|
+
// client-side limit miss so the caller can toast and skip it.
|
|
34
|
+
function _ccReadImageFile(file) {
|
|
35
|
+
return new Promise(function(resolve) {
|
|
36
|
+
var mime = String(file && file.type || '').toLowerCase();
|
|
37
|
+
if (CC_IMG_MIME_ALLOW.indexOf(mime) === -1) { resolve({ error: 'Unsupported image type' + (mime ? ': ' + mime : '') }); return; }
|
|
38
|
+
if (file.size > CC_IMG_MAX_BYTES) { resolve({ error: (file.name || 'image') + ' is too large (max 5 MB)' }); return; }
|
|
39
|
+
var reader = new FileReader();
|
|
40
|
+
reader.onload = function() {
|
|
41
|
+
var result = String(reader.result || '');
|
|
42
|
+
var comma = result.indexOf(',');
|
|
43
|
+
var dataBase64 = comma >= 0 ? result.slice(comma + 1) : '';
|
|
44
|
+
if (!dataBase64) { resolve({ error: 'Could not read image data' }); return; }
|
|
45
|
+
resolve({ mimeType: mime, dataBase64: dataBase64, filename: file.name || 'pasted-image.png', dataUrl: result });
|
|
46
|
+
};
|
|
47
|
+
reader.onerror = function() { resolve({ error: 'Could not read ' + (file.name || 'image') }); };
|
|
48
|
+
reader.readAsDataURL(file);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Add image files (from paste, drop, or the command bar). Non-images are
|
|
53
|
+
// ignored; over-limit / oversized files are toasted and skipped. Exposed so
|
|
54
|
+
// command-input.js can forward clipboard images into the CC drawer.
|
|
55
|
+
async function ccAddImageFiles(fileList) {
|
|
56
|
+
var files = [];
|
|
57
|
+
for (var i = 0; i < (fileList ? fileList.length : 0); i++) {
|
|
58
|
+
var f = fileList[i];
|
|
59
|
+
if (f && String(f.type || '').indexOf('image/') === 0) files.push(f);
|
|
60
|
+
}
|
|
61
|
+
if (!files.length) return false;
|
|
62
|
+
var added = 0;
|
|
63
|
+
for (var j = 0; j < files.length; j++) {
|
|
64
|
+
if (_ccAttachments.length >= CC_IMG_MAX_COUNT) { showToast('cmd-toast', 'Max ' + CC_IMG_MAX_COUNT + ' images per message', false); break; }
|
|
65
|
+
var res = await _ccReadImageFile(files[j]);
|
|
66
|
+
if (res.error) { showToast('cmd-toast', res.error, false); continue; }
|
|
67
|
+
_ccAttachments.push(res);
|
|
68
|
+
added++;
|
|
69
|
+
}
|
|
70
|
+
if (added) _ccRenderAttachments();
|
|
71
|
+
return added > 0;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function ccRemoveAttachment(idx) {
|
|
75
|
+
if (idx >= 0 && idx < _ccAttachments.length) { _ccAttachments.splice(idx, 1); _ccRenderAttachments(); }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function _ccClearAttachments() {
|
|
79
|
+
if (!_ccAttachments.length) return;
|
|
80
|
+
_ccAttachments = [];
|
|
81
|
+
_ccRenderAttachments();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Render the thumbnail chips above the input. Built with createElement +
|
|
85
|
+
// textContent / .src (no innerHTML) so untrusted filenames and base64 data
|
|
86
|
+
// can never become an XSS sink and the no-unsanitized lint gate stays clean.
|
|
87
|
+
function _ccRenderAttachments() {
|
|
88
|
+
var box = document.getElementById('cc-attachments');
|
|
89
|
+
if (!box) return;
|
|
90
|
+
box.textContent = '';
|
|
91
|
+
if (!_ccAttachments.length) { box.style.display = 'none'; return; }
|
|
92
|
+
box.style.display = 'flex';
|
|
93
|
+
_ccAttachments.forEach(function(att, i) {
|
|
94
|
+
var chip = document.createElement('div');
|
|
95
|
+
chip.className = 'cc-attach-chip';
|
|
96
|
+
var img = document.createElement('img');
|
|
97
|
+
img.className = 'cc-attach-thumb';
|
|
98
|
+
img.src = att.dataUrl;
|
|
99
|
+
img.alt = att.filename;
|
|
100
|
+
var name = document.createElement('span');
|
|
101
|
+
name.className = 'cc-attach-name';
|
|
102
|
+
name.textContent = att.filename;
|
|
103
|
+
name.title = att.filename;
|
|
104
|
+
var rm = document.createElement('button');
|
|
105
|
+
rm.className = 'cc-attach-remove';
|
|
106
|
+
rm.type = 'button';
|
|
107
|
+
rm.title = 'Remove';
|
|
108
|
+
rm.textContent = '×';
|
|
109
|
+
rm.onclick = function() { ccRemoveAttachment(i); };
|
|
110
|
+
chip.appendChild(img);
|
|
111
|
+
chip.appendChild(name);
|
|
112
|
+
chip.appendChild(rm);
|
|
113
|
+
box.appendChild(chip);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Snapshot the pending attachments as the server payload shape
|
|
118
|
+
// ([{ mimeType, dataBase64, filename }] — no local-only dataUrl).
|
|
119
|
+
function _ccImagesPayload() {
|
|
120
|
+
return _ccAttachments.map(function(a) { return { mimeType: a.mimeType, dataBase64: a.dataBase64, filename: a.filename }; });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Small "📎 N image(s) attached" note appended to the user bubble so a sent
|
|
124
|
+
// turn visibly records its attachments. n is a number — safe to interpolate.
|
|
125
|
+
function _ccAttachmentBadge(n) {
|
|
126
|
+
if (!n) return '';
|
|
127
|
+
return '<div class="cc-msg-attach-note">📎 ' + n + ' image' + (n === 1 ? '' : 's') + ' attached</div>';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Inline event handlers wired from layout.html. Paste intercepts image
|
|
131
|
+
// clipboard items (and lets normal text paste through); drag/drop accepts
|
|
132
|
+
// image files dropped anywhere on the drawer.
|
|
133
|
+
function ccHandlePaste(e) {
|
|
134
|
+
var items = (e.clipboardData && e.clipboardData.items) || [];
|
|
135
|
+
var imgs = [];
|
|
136
|
+
for (var i = 0; i < items.length; i++) {
|
|
137
|
+
if (items[i] && items[i].kind === 'file' && String(items[i].type || '').indexOf('image/') === 0) {
|
|
138
|
+
var f = items[i].getAsFile();
|
|
139
|
+
if (f) imgs.push(f);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!imgs.length) return; // no image — allow the default text paste
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
ccAddImageFiles(imgs);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function ccHandleDragOver(e) {
|
|
148
|
+
var types = (e.dataTransfer && e.dataTransfer.types) || [];
|
|
149
|
+
if (Array.prototype.indexOf.call(types, 'Files') === -1) return;
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy';
|
|
152
|
+
var dz = document.getElementById('cc-drawer');
|
|
153
|
+
if (dz) dz.classList.add('cc-dragover');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function ccHandleDragLeave() {
|
|
157
|
+
var dz = document.getElementById('cc-drawer');
|
|
158
|
+
if (dz) dz.classList.remove('cc-dragover');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function ccHandleDrop(e) {
|
|
162
|
+
var dz = document.getElementById('cc-drawer');
|
|
163
|
+
if (dz) dz.classList.remove('cc-dragover');
|
|
164
|
+
var files = (e.dataTransfer && e.dataTransfer.files) || [];
|
|
165
|
+
var hasImage = false;
|
|
166
|
+
for (var i = 0; i < files.length; i++) { if (String(files[i].type || '').indexOf('image/') === 0) { hasImage = true; break; } }
|
|
167
|
+
if (!hasImage) return; // let non-image drops fall through
|
|
168
|
+
e.preventDefault();
|
|
169
|
+
ccAddImageFiles(files);
|
|
170
|
+
}
|
|
171
|
+
|
|
20
172
|
function _ccStripActionBlockFromText(value) {
|
|
21
173
|
var text = value || '';
|
|
22
174
|
if (!text) return text;
|
|
@@ -947,6 +1099,11 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
|
|
|
947
1099
|
var activeTabId = forceTabId || _ccActiveTabId;
|
|
948
1100
|
var activeTab = _ccTabs.find(function(t) { return t.id === activeTabId; }) || _ccActiveTab();
|
|
949
1101
|
if (!activeTab) return;
|
|
1102
|
+
// P-2a6d8e74 — snapshot + clear pending image attachments for this turn.
|
|
1103
|
+
// Only fresh user sends carry images (retries/programmatic sends set
|
|
1104
|
+
// skipUserMsg and reuse the already-dispatched text). v1 = current turn only.
|
|
1105
|
+
var sendImages = (!skipUserMsg && _ccAttachments.length) ? _ccImagesPayload() : [];
|
|
1106
|
+
if (sendImages.length) _ccClearAttachments();
|
|
950
1107
|
activeTab._sending = true;
|
|
951
1108
|
activeTab._sendStartedAt = Date.now();
|
|
952
1109
|
activeTab._abortController = new AbortController();
|
|
@@ -959,7 +1116,7 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
|
|
|
959
1116
|
// Scoped helper — always targets the originating tab, even if user switches tabs
|
|
960
1117
|
function addMsg(role, html, skipSave, meta) { ccAddMessage(role, html, skipSave, activeTabId, meta); }
|
|
961
1118
|
|
|
962
|
-
if (!skipUserMsg) addMsg('user', escHtml(message));
|
|
1119
|
+
if (!skipUserMsg) addMsg('user', escHtml(message) + _ccAttachmentBadge(sendImages.length));
|
|
963
1120
|
|
|
964
1121
|
// Remove queue indicator before processing (it'll be re-added if more queued)
|
|
965
1122
|
var existingQueueEl = document.getElementById('cc-queue-indicator');
|
|
@@ -1067,7 +1224,7 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
|
|
|
1067
1224
|
if (!isReconnect && res.status === 429 && (!activeTab._429retries || activeTab._429retries < 3)) {
|
|
1068
1225
|
activeTab._429retries = (activeTab._429retries || 0) + 1;
|
|
1069
1226
|
await new Promise(function(r) { setTimeout(r, 1500); });
|
|
1070
|
-
return await _ccConsumeStream({ message: message, tabId: activeTabId, sessionId: activeTab.sessionId || null, transcript: _ccBuildTranscript(activeTab), intentMetadata: intentMetadata || null }, false);
|
|
1227
|
+
return await _ccConsumeStream({ message: message, tabId: activeTabId, sessionId: activeTab.sessionId || null, images: sendImages.length ? sendImages : undefined, transcript: _ccBuildTranscript(activeTab), intentMetadata: intentMetadata || null }, false);
|
|
1071
1228
|
}
|
|
1072
1229
|
activeTab._429retries = 0;
|
|
1073
1230
|
var errText = await res.text();
|
|
@@ -1263,7 +1420,7 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
|
|
|
1263
1420
|
while (true) {
|
|
1264
1421
|
var consume = await _ccConsumeStream(
|
|
1265
1422
|
reconnectAttempts === 0
|
|
1266
|
-
? { message: message, tabId: activeTabId, sessionId: activeTab.sessionId || null, transcript: _ccBuildTranscript(activeTab), intentMetadata: intentMetadata || null }
|
|
1423
|
+
? { message: message, tabId: activeTabId, sessionId: activeTab.sessionId || null, transcript: _ccBuildTranscript(activeTab), intentMetadata: intentMetadata || null, images: sendImages.length ? sendImages : undefined }
|
|
1267
1424
|
: { tabId: activeTabId, sessionId: activeTab.sessionId || null, reconnect: true },
|
|
1268
1425
|
reconnectAttempts > 0
|
|
1269
1426
|
);
|
|
@@ -2193,4 +2350,4 @@ if (document.readyState === 'loading') {
|
|
|
2193
2350
|
ccInitResize();
|
|
2194
2351
|
}
|
|
2195
2352
|
|
|
2196
|
-
window.MinionsCC = { toggleCommandCenter, ccNewSession, ccNewTab, ccSwitchTab, ccCloseTab, ccRenderTabBar, ccRestoreMessages, ccSaveState, ccUpdateSessionIndicator, ccAddMessage, ccSend, ccAbort, ccExecuteAction, ccPrActionFollowup };
|
|
2353
|
+
window.MinionsCC = { toggleCommandCenter, ccNewSession, ccNewTab, ccSwitchTab, ccCloseTab, ccRenderTabBar, ccRestoreMessages, ccSaveState, ccUpdateSessionIndicator, ccAddMessage, ccSend, ccAbort, ccExecuteAction, ccPrActionFollowup, ccAddImageFiles, ccRemoveAttachment, ccHandlePaste, ccHandleDragOver, ccHandleDragLeave, ccHandleDrop };
|
|
@@ -146,6 +146,26 @@ async function cmdSubmit() {
|
|
|
146
146
|
return;
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
+
// P-2a6d8e74 — paste a screenshot into the top command bar: open the Command
|
|
150
|
+
// Center drawer and forward the clipboard images into its attachment tray.
|
|
151
|
+
// Non-image pastes fall through to the default textarea behavior.
|
|
152
|
+
function cmdHandlePaste(e) {
|
|
153
|
+
const items = (e.clipboardData && e.clipboardData.items) || [];
|
|
154
|
+
const imgs = [];
|
|
155
|
+
for (let i = 0; i < items.length; i++) {
|
|
156
|
+
if (items[i] && items[i].kind === 'file' && String(items[i].type || '').indexOf('image/') === 0) {
|
|
157
|
+
const f = items[i].getAsFile();
|
|
158
|
+
if (f) imgs.push(f);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!imgs.length) return; // text paste — leave it to the textarea
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
if (!_ccOpen) toggleCommandCenter();
|
|
164
|
+
if (window.MinionsCC && typeof window.MinionsCC.ccAddImageFiles === 'function') {
|
|
165
|
+
window.MinionsCC.ccAddImageFiles(imgs);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
149
169
|
// Render the parsed meta chips below input
|
|
150
170
|
function cmdRenderMeta() {
|
|
151
171
|
const el = document.getElementById('cmd-meta');
|
|
@@ -272,4 +292,4 @@ function cmdInsertPopupItem(id) {
|
|
|
272
292
|
cmdRenderMeta();
|
|
273
293
|
}
|
|
274
294
|
|
|
275
|
-
window.MinionsCmdInput = { cmdAutoResize, cmdUpdateHighlight, syncHighlightScroll, cmdInputChanged, cmdKeyDown, cmdSubmit, cmdRenderMeta, cmdShowMentions, cmdShowProjects, cmdHidePopup, cmdInsertPopupItem };
|
|
295
|
+
window.MinionsCmdInput = { cmdAutoResize, cmdUpdateHighlight, syncHighlightScroll, cmdInputChanged, cmdKeyDown, cmdSubmit, cmdRenderMeta, cmdShowMentions, cmdShowProjects, cmdHidePopup, cmdInsertPopupItem, cmdHandlePaste };
|
|
@@ -699,6 +699,9 @@ function _wiRenderDetail(item) {
|
|
|
699
699
|
return ' <span class="pr-badge needs-attention" style="font-size:var(--text-xs);margin-left:4px" title="See "Why this is blocked" below">⚠ Needs attention</span>';
|
|
700
700
|
})() +
|
|
701
701
|
'</div>';
|
|
702
|
+
// Work item id — shown in the modal body (not just the title) so operators can
|
|
703
|
+
// read/copy the canonical id (W-… or sched-…) when deeplinking in from a note.
|
|
704
|
+
html += field('ID', '<code style="font-size:var(--text-sm);background:var(--surface2);padding:2px 6px;border-radius:var(--radius-sm)">' + escapeHtml(item.id || '—') + '</code>');
|
|
702
705
|
// W-mq08kuog001110a6 — "Why this is blocked" section, rendered FIRST so the
|
|
703
706
|
// human eye lands on the unblocking reason before scrolling through agent,
|
|
704
707
|
// source, dates, etc. _preDispatchEval reasons are evaluator prose and can
|
|
@@ -1073,6 +1076,12 @@ function openInboxNote(filename) {
|
|
|
1073
1076
|
titleEl.textContent = filename + ' (archived)';
|
|
1074
1077
|
// eslint-disable-next-line no-unsanitized/property -- reason: renderMd() escapes note content before assembling HTML; filename is set via textContent
|
|
1075
1078
|
bodyEl.innerHTML = '<div style="font-size:var(--text-md);line-height:1.7;color:var(--muted)">' + renderMd(content) + '</div>';
|
|
1079
|
+
// Wire Doc Chat for the deeplinked archive note, mirroring the live-inbox
|
|
1080
|
+
// openModal() path (render-prs.js). Without this the archive branch opened
|
|
1081
|
+
// the note as plain text with no Q&A panel, so deeplinking into a note
|
|
1082
|
+
// from a (completed) work item sometimes silently dropped doc-chat.
|
|
1083
|
+
_modalDocContext = { title: filename + ' (archived)', content: content, selection: '' };
|
|
1084
|
+
_modalFilePath = 'notes/archive/' + filename; showModalQa();
|
|
1076
1085
|
document.getElementById('modal').classList.add('open');
|
|
1077
1086
|
// P-34fa5d79 — Source-WI chip for archive notes. Same convention as the
|
|
1078
1087
|
// live-inbox branch above; parsed off the freshly-fetched content.
|
package/dashboard/layout.html
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
|
|
45
45
|
<!-- Command Center Drawer -->
|
|
46
46
|
<div id="cc-overlay" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.4);z-index:340" onclick="toggleCommandCenter()"></div>
|
|
47
|
-
<div id="cc-drawer" style="display:none;position:fixed;top:0;right:0;bottom:0;width:420px;background:var(--surface);border-left:1px solid var(--border);z-index:350;flex-direction:column;overscroll-behavior:contain">
|
|
47
|
+
<div id="cc-drawer" style="display:none;position:fixed;top:0;right:0;bottom:0;width:420px;background:var(--surface);border-left:1px solid var(--border);z-index:350;flex-direction:column;overscroll-behavior:contain" ondragover="ccHandleDragOver(event)" ondragleave="ccHandleDragLeave(event)" ondrop="ccHandleDrop(event)">
|
|
48
48
|
<div id="cc-resize-handle" class="cc-resize-handle"></div>
|
|
49
49
|
<div style="padding:12px 16px;border-bottom:1px solid var(--border);display:flex;align-items:center;justify-content:space-between">
|
|
50
50
|
<div style="display:flex;align-items:center;gap:8px">
|
|
@@ -59,11 +59,13 @@
|
|
|
59
59
|
<div id="cc-tab-bar" style="display:flex;gap:8px;padding:4px 12px;border-bottom:1px solid var(--border);overflow:hidden;align-items:center;flex-shrink:0"></div>
|
|
60
60
|
<div id="cc-messages" style="flex:1;overflow-y:auto;padding:12px 16px;display:flex;flex-direction:column;gap:10px"></div>
|
|
61
61
|
<div style="padding:12px 16px;border-top:1px solid var(--border)">
|
|
62
|
+
<!-- P-2a6d8e74 — image attachment thumbnail chips (paste / drag-drop); hidden until images are attached -->
|
|
63
|
+
<div id="cc-attachments" style="display:none;flex-wrap:wrap;gap:6px;margin-bottom:8px"></div>
|
|
62
64
|
<div style="display:flex;gap:8px">
|
|
63
|
-
<textarea id="cc-input" rows="2" placeholder="Ask anything or give a command..." style="flex:1;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:var(--text-md);resize:none;font-family:inherit" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();ccSend()}"></textarea>
|
|
65
|
+
<textarea id="cc-input" rows="2" placeholder="Ask anything or give a command... (paste or drop an image)" style="flex:1;padding:8px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:var(--text-md);resize:none;font-family:inherit" onpaste="ccHandlePaste(event)" onkeydown="if(event.key==='Enter'&&!event.shiftKey){event.preventDefault();ccSend()}"></textarea>
|
|
64
66
|
<button onclick="ccSend()" style="background:var(--blue);color:#fff;border:none;border-radius:6px;padding:8px 14px;font-size:var(--text-md);font-weight:600;cursor:pointer;align-self:flex-end">Send</button>
|
|
65
67
|
</div>
|
|
66
|
-
<div id="cc-powered-by" style="font-size:var(--text-xs);color:var(--muted);margin-top:4px">Full minions context. Enter to send, Shift+Enter for newline.</div>
|
|
68
|
+
<div id="cc-powered-by" style="font-size:var(--text-xs);color:var(--muted);margin-top:4px">Full minions context. Enter to send, Shift+Enter for newline. Paste or drop images to attach.</div>
|
|
67
69
|
</div>
|
|
68
70
|
</div>
|
|
69
71
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
<div class="cmd-input-wrap" id="cmd-input-wrap">
|
|
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
|
-
oninput="cmdInputChanged()" onkeydown="cmdKeyDown(event)" onscroll="syncHighlightScroll()"></textarea>
|
|
6
|
+
oninput="cmdInputChanged()" onkeydown="cmdKeyDown(event)" onscroll="syncHighlightScroll()" onpaste="cmdHandlePaste(event)"></textarea>
|
|
7
7
|
<button class="cmd-send-btn" id="cmd-send-btn" onclick="cmdSubmit()">Send <kbd>Ctrl+Enter</kbd></button>
|
|
8
8
|
</div>
|
|
9
9
|
<div class="cmd-mention-popup" id="cmd-mention-popup"></div>
|
package/dashboard/styles.css
CHANGED
|
@@ -1146,6 +1146,20 @@
|
|
|
1146
1146
|
.cc-resize-handle:hover::after, .cc-resize-handle.active::after { opacity: 1; background: var(--blue); }
|
|
1147
1147
|
body.cc-resizing { cursor: col-resize !important; user-select: none !important; }
|
|
1148
1148
|
|
|
1149
|
+
/* ── P-2a6d8e74 — CC image attachment chips + drag-drop affordance ───── */
|
|
1150
|
+
#cc-drawer.cc-dragover { outline: 2px dashed var(--blue); outline-offset: -2px; }
|
|
1151
|
+
.cc-attach-chip { display: inline-flex; align-items: center; gap: 6px; max-width: 180px;
|
|
1152
|
+
padding: 4px 6px; background: var(--surface2); border: 1px solid var(--border);
|
|
1153
|
+
border-radius: var(--radius-sm); font-size: var(--text-xs); }
|
|
1154
|
+
.cc-attach-thumb { width: 28px; height: 28px; object-fit: cover; border-radius: 3px;
|
|
1155
|
+
flex-shrink: 0; background: var(--bg); }
|
|
1156
|
+
.cc-attach-name { color: var(--muted); white-space: nowrap; overflow: hidden;
|
|
1157
|
+
text-overflow: ellipsis; }
|
|
1158
|
+
.cc-attach-remove { flex-shrink: 0; background: none; border: none; color: var(--muted);
|
|
1159
|
+
cursor: pointer; font-size: var(--text-md); line-height: 1; padding: 0 2px; }
|
|
1160
|
+
.cc-attach-remove:hover { color: var(--red); }
|
|
1161
|
+
.cc-msg-attach-note { margin-top: 4px; font-size: var(--text-xs); opacity: 0.85; }
|
|
1162
|
+
|
|
1149
1163
|
.modal-body { padding: var(--space-7) var(--space-8); overflow-y: auto; overflow-x: auto; white-space: pre-wrap; font-size: var(--text-md); line-height: 1.7; color: var(--muted); font-family: Consolas, monospace; }
|
|
1150
1164
|
|
|
1151
1165
|
/* ── Settings: left-rail tabbed layout (W-mpmwxkcn000646cc) ─────────────
|
package/dashboard.js
CHANGED
|
@@ -719,14 +719,6 @@ function createWorkItemWithDedup(wiPath, item, options = {}) {
|
|
|
719
719
|
return result || { created: false, item: null };
|
|
720
720
|
}
|
|
721
721
|
|
|
722
|
-
function formatUnknownProjectError(projectName, projects = []) {
|
|
723
|
-
return shared.formatUnknownProjectError(projectName, projects);
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
function findProjectByName(projects, projectName) {
|
|
727
|
-
return shared.findProjectByName(projects, projectName);
|
|
728
|
-
}
|
|
729
|
-
|
|
730
722
|
function resolveManualPrLinkProject(url, projectName, projects = PROJECTS) {
|
|
731
723
|
const explicitProjectName = String(projectName || '').trim();
|
|
732
724
|
if (explicitProjectName) {
|
|
@@ -1815,7 +1807,6 @@ function getVerifyGuides() {
|
|
|
1815
1807
|
return guides;
|
|
1816
1808
|
}
|
|
1817
1809
|
|
|
1818
|
-
function getArchivedPrds() { return []; }
|
|
1819
1810
|
function getEngineState() { return queries.getControl(); }
|
|
1820
1811
|
|
|
1821
1812
|
let _worktreeCountCache = 0;
|
|
@@ -3111,6 +3102,10 @@ const CC_ERROR_CODES = Object.freeze([
|
|
|
3111
3102
|
'worker-spawn-failed',
|
|
3112
3103
|
'acp-handshake-failed',
|
|
3113
3104
|
'worker-died',
|
|
3105
|
+
// P-9c5f1a83 — server-side image-attachment validation rejection. Every
|
|
3106
|
+
// images[] violation (too many / too large / bad mime / malformed base64)
|
|
3107
|
+
// surfaces this typed code via _buildCcErrorEnvelope, never a 500.
|
|
3108
|
+
'invalid-image',
|
|
3114
3109
|
]);
|
|
3115
3110
|
function _buildCcErrorEnvelope({ message, code, retryable, ...extra } = {}) {
|
|
3116
3111
|
const normalizedCode = CC_ERROR_CODES.includes(code) ? code : 'crash';
|
|
@@ -3122,6 +3117,99 @@ function _buildCcErrorEnvelope({ message, code, retryable, ...extra } = {}) {
|
|
|
3122
3117
|
...extra,
|
|
3123
3118
|
};
|
|
3124
3119
|
}
|
|
3120
|
+
|
|
3121
|
+
// ── P-9c5f1a83 — Command Center image-attachment server-side validation ──
|
|
3122
|
+
// ALL limits are enforced here, never trusting the client. The frontend
|
|
3123
|
+
// (P-2a6d8e74) may pre-screen for UX, but this is the only trust boundary.
|
|
3124
|
+
// Contract matches the spike decision (knowledge/conventions/…cli-image-input…):
|
|
3125
|
+
// the adapter `images` opt is `[{ mimeType, dataBase64 }]` and only the Claude
|
|
3126
|
+
// runtime accepts it (capability-gated downstream in engine/llm.js).
|
|
3127
|
+
const CC_IMAGE_MAX_COUNT = 4;
|
|
3128
|
+
const CC_IMAGE_MAX_DECODED_BYTES = 5 * 1024 * 1024; // 5 MB decoded, per image
|
|
3129
|
+
const CC_IMAGE_MIME_ALLOWLIST = Object.freeze(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
3130
|
+
|
|
3131
|
+
// Max request-body size (bytes ≈ chars) for the image-accepting CC endpoints.
|
|
3132
|
+
// readBody's default 1 MB cap is far below a legitimate image turn: the
|
|
3133
|
+
// documented per-image limit is 5 MB decoded and up to 4 images, so a maxed
|
|
3134
|
+
// turn is ~20 MB decoded which base64-encodes to ~27 MB. Without a raised cap
|
|
3135
|
+
// readBody rejected the body as "too large" BEFORE _validateCcImages ever ran,
|
|
3136
|
+
// making the 5 MB-per-image limit unreachable (any image over ~740 KB tripped
|
|
3137
|
+
// the 1 MB cap) and surfacing a generic 500 instead of the typed envelope.
|
|
3138
|
+
// Sized to the worst case plus ~2 MB headroom for the JSON envelope + message
|
|
3139
|
+
// text. Non-image endpoints keep readBody's conservative 1 MB default.
|
|
3140
|
+
const CC_IMAGE_REQUEST_MAX_BYTES =
|
|
3141
|
+
Math.ceil((CC_IMAGE_MAX_COUNT * CC_IMAGE_MAX_DECODED_BYTES * 4) / 3) + (2 * 1024 * 1024);
|
|
3142
|
+
|
|
3143
|
+
function _ccImageRejection(message) {
|
|
3144
|
+
return _buildCcErrorEnvelope({ message, code: 'invalid-image', retryable: false });
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
// Map readBody's payload-too-large rejection (body exceeded
|
|
3148
|
+
// CC_IMAGE_REQUEST_MAX_BYTES) onto the documented typed 'invalid-image'
|
|
3149
|
+
// envelope, so an over-budget image turn surfaces the same red error bubble
|
|
3150
|
+
// (4xx) as every other image rejection instead of a generic 500 crash.
|
|
3151
|
+
function _ccPayloadTooLargeRejection() {
|
|
3152
|
+
const mb = Math.round(CC_IMAGE_MAX_DECODED_BYTES / (1024 * 1024));
|
|
3153
|
+
return _ccImageRejection(`Request too large — reduce image size or count (max ${CC_IMAGE_MAX_COUNT} images, ${mb} MB each).`);
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
// Returns { images, error }:
|
|
3157
|
+
// - absent / empty → { images: undefined, error: null } (text-only, today's behavior)
|
|
3158
|
+
// - all valid → { images: [{ mimeType, dataBase64, filename? }], error: null }
|
|
3159
|
+
// - any violation → { images: undefined, error: <typed envelope, code:'invalid-image'> }
|
|
3160
|
+
// Uses only Node's built-in Buffer for base64 decode (zero-dep invariant).
|
|
3161
|
+
function _validateCcImages(images) {
|
|
3162
|
+
if (images == null) return { images: undefined, error: null };
|
|
3163
|
+
if (!Array.isArray(images)) {
|
|
3164
|
+
return { images: undefined, error: _ccImageRejection('images must be an array of { mimeType, dataBase64 }') };
|
|
3165
|
+
}
|
|
3166
|
+
if (images.length === 0) return { images: undefined, error: null };
|
|
3167
|
+
if (images.length > CC_IMAGE_MAX_COUNT) {
|
|
3168
|
+
return { images: undefined, error: _ccImageRejection(`Too many images: ${images.length} (max ${CC_IMAGE_MAX_COUNT})`) };
|
|
3169
|
+
}
|
|
3170
|
+
const normalized = [];
|
|
3171
|
+
for (let i = 0; i < images.length; i += 1) {
|
|
3172
|
+
const label = `Image ${i + 1}`;
|
|
3173
|
+
const img = images[i];
|
|
3174
|
+
if (!img || typeof img !== 'object' || Array.isArray(img)) {
|
|
3175
|
+
return { images: undefined, error: _ccImageRejection(`${label} is not an object`) };
|
|
3176
|
+
}
|
|
3177
|
+
const mimeType = typeof img.mimeType === 'string' ? img.mimeType.toLowerCase().trim() : '';
|
|
3178
|
+
if (!CC_IMAGE_MIME_ALLOWLIST.includes(mimeType)) {
|
|
3179
|
+
return { images: undefined, error: _ccImageRejection(`${label} has unsupported mime type ${JSON.stringify(img.mimeType)} (allowed: ${CC_IMAGE_MIME_ALLOWLIST.join(', ')})`) };
|
|
3180
|
+
}
|
|
3181
|
+
if (typeof img.dataBase64 !== 'string' || img.dataBase64.length === 0) {
|
|
3182
|
+
return { images: undefined, error: _ccImageRejection(`${label} is missing dataBase64`) };
|
|
3183
|
+
}
|
|
3184
|
+
// Defensively strip an optional data: URI prefix and any whitespace before
|
|
3185
|
+
// validating — canvas.toDataURL() yields a clean payload, but a forgiving
|
|
3186
|
+
// strip keeps a stray prefix from being mis-flagged as malformed.
|
|
3187
|
+
const raw = img.dataBase64.replace(/^data:[^;,]*;base64,/i, '').replace(/\s+/g, '');
|
|
3188
|
+
// Buffer.from(..., 'base64') is lenient (silently drops invalid chars), so
|
|
3189
|
+
// validate the charset ourselves first — a malformed payload must be
|
|
3190
|
+
// rejected, not silently truncated.
|
|
3191
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(raw)) {
|
|
3192
|
+
return { images: undefined, error: _ccImageRejection(`${label} has malformed base64 data`) };
|
|
3193
|
+
}
|
|
3194
|
+
let decoded;
|
|
3195
|
+
try { decoded = Buffer.from(raw, 'base64'); }
|
|
3196
|
+
catch { return { images: undefined, error: _ccImageRejection(`${label} has malformed base64 data`) }; }
|
|
3197
|
+
// Canonical round-trip: re-encoding the decoded bytes must reproduce the
|
|
3198
|
+
// input (ignoring padding). This catches non-base64 junk that the charset
|
|
3199
|
+
// regex let through (e.g. a length that isn't a valid base64 quantum).
|
|
3200
|
+
const canon = (s) => s.replace(/=+$/, '');
|
|
3201
|
+
if (decoded.length === 0 || canon(decoded.toString('base64')) !== canon(raw)) {
|
|
3202
|
+
return { images: undefined, error: _ccImageRejection(`${label} has malformed base64 data`) };
|
|
3203
|
+
}
|
|
3204
|
+
if (decoded.length > CC_IMAGE_MAX_DECODED_BYTES) {
|
|
3205
|
+
return { images: undefined, error: _ccImageRejection(`${label} is too large: ${decoded.length} bytes decoded (max ${CC_IMAGE_MAX_DECODED_BYTES})`) };
|
|
3206
|
+
}
|
|
3207
|
+
const entry = { mimeType, dataBase64: raw };
|
|
3208
|
+
if (typeof img.filename === 'string' && img.filename) entry.filename = img.filename;
|
|
3209
|
+
normalized.push(entry);
|
|
3210
|
+
}
|
|
3211
|
+
return { images: normalized, error: null };
|
|
3212
|
+
}
|
|
3125
3213
|
function _releaseCCTab(tabId) { ccInFlightTabs.delete(tabId); ccInFlightAborts.delete(tabId); }
|
|
3126
3214
|
function _getCcLiveStream(tabId) {
|
|
3127
3215
|
return ccLiveStreams.get(tabId) || null;
|
|
@@ -4349,7 +4437,7 @@ function _invokeDocChatViaPool({ prompt, model, effort, engineConfig, systemProm
|
|
|
4349
4437
|
* conversation for the same file. The legacy direct path is unaffected — freshSession
|
|
4350
4438
|
* semantics there are owned by ccDocCall, which deletes docSessions before invoking.
|
|
4351
4439
|
*/
|
|
4352
|
-
async function ccCall(message, { store = 'cc', sessionKey, extraContext, label = 'command-center', timeout = CC_CALL_TIMEOUT_MS, maxTurns, allowedTools = 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch', skipStatePreamble = false, skipPreflight = false, model, onAbortReady, systemPrompt = CC_STATIC_SYSTEM_PROMPT, transcript, turnId, freshSession = false } = {}) {
|
|
4440
|
+
async function ccCall(message, { store = 'cc', sessionKey, extraContext, label = 'command-center', timeout = CC_CALL_TIMEOUT_MS, maxTurns, allowedTools = 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch', skipStatePreamble = false, skipPreflight = false, model, onAbortReady, systemPrompt = CC_STATIC_SYSTEM_PROMPT, transcript, turnId, freshSession = false, images } = {}) {
|
|
4353
4441
|
if (!maxTurns) maxTurns = CONFIG.engine?.ccMaxTurns || shared.ENGINE_DEFAULTS.ccMaxTurns;
|
|
4354
4442
|
if (!model) model = CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel;
|
|
4355
4443
|
const ccEffort = CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort;
|
|
@@ -4433,7 +4521,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
|
|
|
4433
4521
|
outOfBandOnly: !resumeNeedsCarryover,
|
|
4434
4522
|
}), '', {
|
|
4435
4523
|
timeout, label, model, maxTurns, allowedTools, sessionId, effort: ccEffort, direct: true,
|
|
4436
|
-
engineConfig: CONFIG.engine,
|
|
4524
|
+
engineConfig: CONFIG.engine, images,
|
|
4437
4525
|
});
|
|
4438
4526
|
if (onAbortReady) onAbortReady(p1.abort);
|
|
4439
4527
|
result = await p1;
|
|
@@ -4471,7 +4559,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
|
|
|
4471
4559
|
const freshPrompt = buildPrompt({ includeCarryover: freshNeedsCarryover });
|
|
4472
4560
|
const p2 = llm.callLLM(freshPrompt, systemPrompt, {
|
|
4473
4561
|
timeout, label, model, maxTurns, allowedTools, effort: ccEffort, direct: true,
|
|
4474
|
-
engineConfig: CONFIG.engine,
|
|
4562
|
+
engineConfig: CONFIG.engine, images,
|
|
4475
4563
|
});
|
|
4476
4564
|
if (onAbortReady) onAbortReady(p2.abort);
|
|
4477
4565
|
result = await p2;
|
|
@@ -4489,7 +4577,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
|
|
|
4489
4577
|
await new Promise(r => setTimeout(r, 2000));
|
|
4490
4578
|
const p3 = llm.callLLM(freshPrompt, systemPrompt, {
|
|
4491
4579
|
timeout, label, model, maxTurns, allowedTools, effort: ccEffort, direct: true,
|
|
4492
|
-
engineConfig: CONFIG.engine,
|
|
4580
|
+
engineConfig: CONFIG.engine, images,
|
|
4493
4581
|
});
|
|
4494
4582
|
if (onAbortReady) onAbortReady(p3.abort);
|
|
4495
4583
|
result = await p3;
|
|
@@ -5245,7 +5333,12 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
|
|
|
5245
5333
|
|
|
5246
5334
|
// -- POST helpers --
|
|
5247
5335
|
|
|
5248
|
-
function readBody(req) {
|
|
5336
|
+
function readBody(req, opts) {
|
|
5337
|
+
// Per-endpoint body-size cap. Defaults to 1 MB (the conservative OOM guard
|
|
5338
|
+
// for every JSON endpoint); the image-accepting CC endpoints pass a larger
|
|
5339
|
+
// CC_IMAGE_REQUEST_MAX_BYTES so a legitimate paste-screenshot turn reaches
|
|
5340
|
+
// _validateCcImages instead of being rejected here first.
|
|
5341
|
+
const maxBytes = (opts && Number.isFinite(opts.maxBytes) && opts.maxBytes > 0) ? opts.maxBytes : 1e6;
|
|
5249
5342
|
return new Promise((resolve, reject) => {
|
|
5250
5343
|
let body = '';
|
|
5251
5344
|
// P-c1read-7b3c: aborted closure flag prevents OOM from a misbehaving local
|
|
@@ -5262,13 +5355,19 @@ function readBody(req) {
|
|
|
5262
5355
|
req.on('data', chunk => {
|
|
5263
5356
|
if (aborted) return;
|
|
5264
5357
|
body += chunk;
|
|
5265
|
-
if (body.length >
|
|
5358
|
+
if (body.length > maxBytes) {
|
|
5266
5359
|
// Order matters: set aborted first so any in-flight chunk early-returns,
|
|
5267
5360
|
// then clear the timer, tear down the socket, and surface the failure.
|
|
5268
5361
|
aborted = true;
|
|
5269
5362
|
clearTimeout(timeout);
|
|
5270
5363
|
req.destroy();
|
|
5271
|
-
|
|
5364
|
+
// Carry a typed code + 4xx status so callers can map this onto a
|
|
5365
|
+
// proper envelope (e.g. CC image endpoints → typed 'invalid-image'
|
|
5366
|
+
// 400) instead of falling through to a generic 500 handler-exception.
|
|
5367
|
+
const err = new Error('Request body too large');
|
|
5368
|
+
err.statusCode = 413;
|
|
5369
|
+
err.code = 'payload-too-large';
|
|
5370
|
+
reject(err);
|
|
5272
5371
|
}
|
|
5273
5372
|
});
|
|
5274
5373
|
req.on('end', () => {
|
|
@@ -8919,9 +9018,21 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8919
9018
|
if (checkRateLimit('command-center', 10)) return jsonReply(res, 429, { error: 'Rate limited — max 10 requests/minute' });
|
|
8920
9019
|
let tabId;
|
|
8921
9020
|
try {
|
|
8922
|
-
|
|
9021
|
+
// Image turns can carry up to 4×5 MB base64 blobs, so use the larger CC
|
|
9022
|
+
// image cap — readBody's 1 MB default would reject any non-trivial
|
|
9023
|
+
// screenshot before _validateCcImages could enforce the real limits.
|
|
9024
|
+
const body = await readBody(req, { maxBytes: CC_IMAGE_REQUEST_MAX_BYTES });
|
|
8923
9025
|
if (!body.message) return jsonReply(res, 400, { error: 'message required' });
|
|
8924
9026
|
|
|
9027
|
+
// P-9c5f1a83 — server-side image validation. Reject with a typed
|
|
9028
|
+
// 'invalid-image' envelope (4xx, never a 500) before doing any work.
|
|
9029
|
+
const _imgCheck = _validateCcImages(body.images);
|
|
9030
|
+
if (_imgCheck.error) {
|
|
9031
|
+
llm.trackEngineError('command-center', _imgCheck.error.code);
|
|
9032
|
+
return jsonReply(res, 400, _imgCheck.error);
|
|
9033
|
+
}
|
|
9034
|
+
const ccImages = _imgCheck.images;
|
|
9035
|
+
|
|
8925
9036
|
// Per-tab concurrency guard
|
|
8926
9037
|
tabId = body.tabId || 'default';
|
|
8927
9038
|
if (_ccTabIsInFlight(tabId)) {
|
|
@@ -8965,7 +9076,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8965
9076
|
timeoutMs: turnTimeoutMs, label: 'command-center',
|
|
8966
9077
|
}, (registerAbort) => ccCall(body.message, {
|
|
8967
9078
|
store: 'cc', transcript: body.transcript, systemPrompt: turnSystemPrompt, turnId: ccTurnId,
|
|
8968
|
-
onAbortReady: registerAbort,
|
|
9079
|
+
onAbortReady: registerAbort, images: ccImages,
|
|
8969
9080
|
}));
|
|
8970
9081
|
|
|
8971
9082
|
// W-mpmwxni2000c25c7-b — typed-error envelope path. Any failure that
|
|
@@ -9011,7 +9122,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9011
9122
|
} finally {
|
|
9012
9123
|
_releaseCCTab(tabId);
|
|
9013
9124
|
}
|
|
9014
|
-
} catch (e) {
|
|
9125
|
+
} catch (e) {
|
|
9126
|
+
_releaseCCTab(tabId);
|
|
9127
|
+
// An over-budget image payload trips readBody's size cap; surface it as
|
|
9128
|
+
// the documented typed 'invalid-image' 400 envelope, not a generic 500.
|
|
9129
|
+
if (e && e.code === 'payload-too-large') {
|
|
9130
|
+
const env = _ccPayloadTooLargeRejection();
|
|
9131
|
+
llm.trackEngineError('command-center', env.code);
|
|
9132
|
+
return jsonReply(res, 400, env);
|
|
9133
|
+
}
|
|
9134
|
+
return jsonReply(res, e.statusCode || 500, { error: e.message, code: 'handler-exception', retriable: false });
|
|
9135
|
+
}
|
|
9015
9136
|
}
|
|
9016
9137
|
|
|
9017
9138
|
// W-mpxq9sjq000z794b — Watch-driven CC invocation. Internal-only endpoint
|
|
@@ -9116,8 +9237,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9116
9237
|
* lives on the per-dispatch _spawnProcess model in engine.js (regression
|
|
9117
9238
|
* test enforces engine.js does not import cc-worker-pool).
|
|
9118
9239
|
*/
|
|
9119
|
-
function _invokeCcStream({ prompt, sessionId, liveState, toolUses, model, effort, maxTurns, engineConfig, systemPrompt = CC_STATIC_SYSTEM_PROMPT, tabId }) {
|
|
9240
|
+
function _invokeCcStream({ prompt, sessionId, liveState, toolUses, model, effort, maxTurns, engineConfig, systemPrompt = CC_STATIC_SYSTEM_PROMPT, tabId, images }) {
|
|
9120
9241
|
if (shared.resolveCcUseWorkerPool(engineConfig)) {
|
|
9242
|
+
// The ACP worker pool is copilot-only (shared.resolveCcUseWorkerPool
|
|
9243
|
+
// forces it OFF for non-copilot CC) and copilot has imageInput:false, so
|
|
9244
|
+
// image turns never reach this branch — drop `images` here intentionally.
|
|
9121
9245
|
return _invokeCcStreamViaPool({ prompt, liveState, toolUses, model, effort, engineConfig, systemPrompt, tabId });
|
|
9122
9246
|
}
|
|
9123
9247
|
const { callLLMStreaming } = require('./engine/llm');
|
|
@@ -9125,7 +9249,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9125
9249
|
timeout: CC_CALL_TIMEOUT_MS, label: 'command-center', model, maxTurns,
|
|
9126
9250
|
allowedTools: 'Bash,Read,Write,Edit,Glob,Grep,WebFetch,WebSearch',
|
|
9127
9251
|
sessionId, effort, direct: true,
|
|
9128
|
-
engineConfig,
|
|
9252
|
+
engineConfig, images,
|
|
9129
9253
|
onChunk: (text, segmentId) => {
|
|
9130
9254
|
_touchCcLiveStream(liveState);
|
|
9131
9255
|
liveState.text = text;
|
|
@@ -9498,12 +9622,25 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9498
9622
|
_logCcStreamEnd(_ccTelemetry, 'missing-runtime');
|
|
9499
9623
|
};
|
|
9500
9624
|
try {
|
|
9501
|
-
|
|
9625
|
+
// Larger cap for image turns (see handleCommandCenter) so a screenshot
|
|
9626
|
+
// paste reaches _validateCcImages rather than tripping readBody's 1 MB
|
|
9627
|
+
// default and surfacing as a generic crash.
|
|
9628
|
+
const body = await readBody(req, { maxBytes: CC_IMAGE_REQUEST_MAX_BYTES });
|
|
9502
9629
|
if (!body.message && !body.reconnect) {
|
|
9503
9630
|
res.statusCode = 400; res.end('message required');
|
|
9504
9631
|
_logCcStreamEnd(_ccTelemetry, 'bad-request');
|
|
9505
9632
|
return;
|
|
9506
9633
|
}
|
|
9634
|
+
// P-9c5f1a83 — server-side image validation BEFORE SSE headers are sent,
|
|
9635
|
+
// so a rejection returns a clean typed 4xx JSON envelope (code
|
|
9636
|
+
// 'invalid-image', never a 500) rather than an `event: error` mid-stream.
|
|
9637
|
+
const _imgCheck = _validateCcImages(body.images);
|
|
9638
|
+
if (_imgCheck.error) {
|
|
9639
|
+
llm.trackEngineError('command-center', _imgCheck.error.code);
|
|
9640
|
+
_logCcStreamEnd(_ccTelemetry, 'invalid-image');
|
|
9641
|
+
return jsonReply(res, 400, _imgCheck.error);
|
|
9642
|
+
}
|
|
9643
|
+
const ccImages = _imgCheck.images;
|
|
9507
9644
|
tabId = body.tabId || 'default';
|
|
9508
9645
|
_ccTelemetry.tabId = tabId;
|
|
9509
9646
|
_ccTelemetry.sessionId = body.sessionId || null;
|
|
@@ -9724,7 +9861,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9724
9861
|
model: streamModel, effort: streamEffort, maxTurns: ccMaxTurns,
|
|
9725
9862
|
engineConfig: CONFIG.engine,
|
|
9726
9863
|
systemPrompt: turnSystemPrompt,
|
|
9727
|
-
tabId,
|
|
9864
|
+
tabId, images: ccImages,
|
|
9728
9865
|
});
|
|
9729
9866
|
_ccStreamAbort = llmPromise.abort;
|
|
9730
9867
|
liveState.abortFn = _ccStreamAbort;
|
|
@@ -9748,7 +9885,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9748
9885
|
model: streamModel, effort: streamEffort, maxTurns: ccMaxTurns,
|
|
9749
9886
|
engineConfig: CONFIG.engine,
|
|
9750
9887
|
systemPrompt: turnSystemPrompt,
|
|
9751
|
-
tabId,
|
|
9888
|
+
tabId, images: ccImages,
|
|
9752
9889
|
});
|
|
9753
9890
|
_ccStreamAbort = retryPromise.abort;
|
|
9754
9891
|
liveState.abortFn = _ccStreamAbort;
|
|
@@ -9866,6 +10003,18 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9866
10003
|
// If SSE headers haven't been sent yet (e.g. readBody guard fired), respond with the
|
|
9867
10004
|
// intended HTTP status (400 for prototype-pollution rejection) instead of an SSE event.
|
|
9868
10005
|
if (!res.headersSent) {
|
|
10006
|
+
// An over-budget image payload trips readBody's size cap before SSE
|
|
10007
|
+
// headers are sent; surface it as the documented typed 'invalid-image'
|
|
10008
|
+
// 400 envelope, matching the non-streaming handler.
|
|
10009
|
+
if (e && e.code === 'payload-too-large') {
|
|
10010
|
+
const env = _ccPayloadTooLargeRejection();
|
|
10011
|
+
llm.trackEngineError('command-center', env.code);
|
|
10012
|
+
res.statusCode = 400;
|
|
10013
|
+
res.setHeader('Content-Type', 'application/json');
|
|
10014
|
+
try { res.end(JSON.stringify(env)); } catch {}
|
|
10015
|
+
_logCcStreamEnd(_ccTelemetry, 'invalid-image');
|
|
10016
|
+
return;
|
|
10017
|
+
}
|
|
9869
10018
|
res.statusCode = e.statusCode || 500;
|
|
9870
10019
|
res.setHeader('Content-Type', 'application/json');
|
|
9871
10020
|
// W-mpmwxni2000c25c7-d — non-2xx response carries the same envelope
|
|
@@ -12362,7 +12511,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12362
12511
|
return serveFreshJson(req, res, {
|
|
12363
12512
|
tag: 'archived-prds',
|
|
12364
12513
|
inputs: [path.join(PRD_DIR, 'archive')],
|
|
12365
|
-
builder: () =>
|
|
12514
|
+
builder: () => [],
|
|
12366
12515
|
});
|
|
12367
12516
|
}},
|
|
12368
12517
|
{ method: 'GET', path: '/api/verify-guides', desc: 'List of generated verify-guide files under prd/guides/', handler: (req, res) => {
|
|
@@ -12659,8 +12808,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12659
12808
|
const explicitProjectName = String(body.project || '').trim();
|
|
12660
12809
|
if (explicitProjectName) {
|
|
12661
12810
|
const projects = shared.getProjects(CONFIG);
|
|
12662
|
-
if (!findProjectByName(projects, explicitProjectName)) {
|
|
12663
|
-
return jsonReply(res, 400, { error: formatUnknownProjectError(explicitProjectName, projects) }, req);
|
|
12811
|
+
if (!shared.findProjectByName(projects, explicitProjectName)) {
|
|
12812
|
+
return jsonReply(res, 400, { error: shared.formatUnknownProjectError(explicitProjectName, projects) }, req);
|
|
12664
12813
|
}
|
|
12665
12814
|
}
|
|
12666
12815
|
const adoTarget = parseAdoPrMetadataTarget(url);
|
|
@@ -12969,8 +13118,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
12969
13118
|
reloadConfig();
|
|
12970
13119
|
const projects = shared.getProjects(CONFIG);
|
|
12971
13120
|
for (const name of projectNames) {
|
|
12972
|
-
if (!findProjectByName(projects, name)) {
|
|
12973
|
-
return jsonReply(res, 400, { error: formatUnknownProjectError(name, projects) });
|
|
13121
|
+
if (!shared.findProjectByName(projects, name)) {
|
|
13122
|
+
return jsonReply(res, 400, { error: shared.formatUnknownProjectError(name, projects) });
|
|
12974
13123
|
}
|
|
12975
13124
|
}
|
|
12976
13125
|
}
|
|
@@ -13649,6 +13798,14 @@ module.exports = {
|
|
|
13649
13798
|
_buildHarnessDiagnostics,
|
|
13650
13799
|
// exported for testing — see test/unit/plans-archive-warnings.test.js
|
|
13651
13800
|
_archivePrdPostProcess,
|
|
13801
|
+
// P-9c5f1a83 — CC image-attachment validation surface (test seams)
|
|
13802
|
+
_validateCcImages,
|
|
13803
|
+
_buildCcErrorEnvelope,
|
|
13804
|
+
CC_ERROR_CODES,
|
|
13805
|
+
CC_IMAGE_MAX_COUNT,
|
|
13806
|
+
CC_IMAGE_MAX_DECODED_BYTES,
|
|
13807
|
+
CC_IMAGE_MIME_ALLOWLIST,
|
|
13808
|
+
CC_IMAGE_REQUEST_MAX_BYTES,
|
|
13652
13809
|
// Per-CC-turn correlation surface
|
|
13653
13810
|
_ccTurnCreations,
|
|
13654
13811
|
_recordCcTurnCreation,
|
package/docs/command-center.md
CHANGED
|
@@ -31,6 +31,45 @@ Canonical envelope (`_buildCcErrorEnvelope` in `dashboard.js`):
|
|
|
31
31
|
|
|
32
32
|
**No auto-retry policy.** The backend never re-spawns the LLM after an error envelope. The client never silently resends the user's turn. Retry is a single-click manual action — guards against silent budget burn on `budget-exceeded`, infinite loops on `auth-failure`, and accidental re-charges on `context-limit`. The 429 + reconnect paths (rate-limited fetch retry, SSE reconnect-after-disconnect) remain — those are transport-level, not error-envelope-level.
|
|
33
33
|
|
|
34
|
+
## Image attachments (PL-cc-image-attachments)
|
|
35
|
+
|
|
36
|
+
Command Center accepts image attachments alongside the text turn. The dashboard paste/drag-drop UI (`dashboard/js/command-center.js`, fed by `command-input.js`) base64-encodes each file and adds an `images` array to the request body. (Doc-Chat does **not** accept images yet — `dashboard/js/modal-qa.js` never sends an `images` array and `handleDocChat`/`ccDocCall` never read one; wiring it is a possible follow-up.)
|
|
37
|
+
|
|
38
|
+
**Request shape.** `POST /api/command-center` and `/api/command-center/stream` take an optional `images` field:
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{ "message": "what's wrong with this layout?",
|
|
42
|
+
"images": [
|
|
43
|
+
{ "mimeType": "image/png", "dataBase64": "iVBORw0KGgo…", "filename": "screenshot.png" }
|
|
44
|
+
]
|
|
45
|
+
}
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Each entry is `{ mimeType, dataBase64, filename? }`. `filename` is optional; an absent / empty / non-array `images` is the normal text-only turn.
|
|
49
|
+
|
|
50
|
+
**Server-side validation (`_validateCcImages` in `dashboard.js`).** Uses only Node's built-in `Buffer` (zero-dep invariant). Enforced limits:
|
|
51
|
+
|
|
52
|
+
- count ≤ `CC_IMAGE_MAX_COUNT` (**4**)
|
|
53
|
+
- decoded size ≤ `CC_IMAGE_MAX_DECODED_BYTES` (**5 MB**) per image
|
|
54
|
+
- `mimeType` in `CC_IMAGE_MIME_ALLOWLIST` — `image/png`, `image/jpeg`, `image/gif`, `image/webp`
|
|
55
|
+
- `dataBase64` well-formed (an optional `data:…;base64,` prefix and whitespace are stripped, then a `Buffer.from(…,'base64')` round-trip must reproduce the input — catches junk the lenient decoder would otherwise silently drop)
|
|
56
|
+
|
|
57
|
+
Any violation rejects the **whole turn** with a typed error envelope (`code: 'invalid-image'`, a member of the `CC_ERROR_CODES` allowlist) — there is no partial send. The dashboard renders it as the standard red `.cc-error` bubble with a manual Retry (see the [Error surfacing contract](#error-surfacing-contract-w-mpmwxni2000c25c7-d) above).
|
|
58
|
+
|
|
59
|
+
**Request-body cap (`CC_IMAGE_REQUEST_MAX_BYTES`).** `readBody`'s default body cap is 1 MB — below a single non-trivial screenshot once base64-inflated (~4/3), so it would reject an image turn *before* `_validateCcImages` could enforce the real per-image limit. The two CC endpoints therefore call `readBody(req, { maxBytes: CC_IMAGE_REQUEST_MAX_BYTES })`, sized for the worst legitimate payload (4 images × 5 MB decoded ≈ 27 MB base64) plus headroom. A body over that cap is surfaced as the same typed `invalid-image` **400** envelope (`_ccPayloadTooLargeRejection`), never a generic 500. Non-image endpoints keep the conservative 1 MB default.
|
|
60
|
+
|
|
61
|
+
**Per-runtime support (typed degradation, never a silent drop).** Support is gated on the adapter `imageInput` capability flag:
|
|
62
|
+
|
|
63
|
+
| Runtime | `imageInput` | Behavior |
|
|
64
|
+
|---------|--------------|----------|
|
|
65
|
+
| claude | `true` | Images delivered as an Anthropic Messages `content[]` envelope of base64 blocks over stream-json (`claude.buildPrompt` + `--input-format stream-json` in `claude.buildArgs`). |
|
|
66
|
+
| copilot | `false` | Degraded — `engine/llm.js` `_resolveImageOpts` returns a typed `model-unavailable` envelope ("switch ccCli to a runtime with imageInput"). The Copilot worker-pool path also drops images. |
|
|
67
|
+
| codex | `false` | Degraded — same typed `model-unavailable` envelope. |
|
|
68
|
+
|
|
69
|
+
`_resolveImageOpts` (in `engine/llm.js`) is pure and unit-tested: it forwards the `images` list only when `runtime.capabilities.imageInput` is truthy, otherwise returns the typed error so CC/doc-chat surface the envelope instead of a silently-text-only reply. `_spawnProcess` re-applies the same gate (`if (!caps.imageInput) adapterOpts.images = undefined`) as defense in depth. Image **filenames** are run through the untrusted-input fence (`buildSource('cc-image-filename', …)`) before they reach prompt text, since they are user-supplied.
|
|
70
|
+
|
|
71
|
+
**No new `engine.*` flag.** The limits above are module-level constants in `dashboard.js` (`CC_IMAGE_MAX_COUNT`, `CC_IMAGE_MAX_DECODED_BYTES`, `CC_IMAGE_MIME_ALLOWLIST`), not config keys; `engine/shared.js` `ENGINE_DEFAULTS` was not touched. Per CLAUDE.md Best Practice #9 (Settings parity), no Settings toggle is added because no new `engine.*` flag was introduced. Runtime selection that determines whether images are accepted is the existing `engine.ccCli` / `ccModel` override, which already has a Settings control.
|
|
72
|
+
|
|
34
73
|
## Per-turn surfacing pipeline
|
|
35
74
|
|
|
36
75
|
CC handler generates `ccTurnId = 'cct-' + shared.uid()` per request; injected into sysprompt AND prompt body via `_ccTurnHeaderPart(turnId)` (load-bearing: on resumed sessions `engine/llm.js` skips re-sending the sysprompt, so without body injection CC keeps the stale turn ID). Handler reads via `_readCcTurnIdHeader(req)` and calls `_recordCcTurnCreation(turnId, ...)` on success. End-of-turn: `_buildSyntheticActionResultsForTurn` produces synthetic `{action, result}` pairs (`_serverExecuted: true`). Client renders as standalone `role='action'` messages outside the assistant bubble. TTL: 5 min. Endpoints wired: `/api/work-items`, `/api/notes`, `/api/plan`, `/api/knowledge`, `/api/watches`.
|
package/docs/self-improvement.md
CHANGED
|
@@ -83,7 +83,7 @@ Fallback entries are labeled `**By:** Engine (regex fallback)` so you can tell w
|
|
|
83
83
|
|
|
84
84
|
### Auto-Pruning
|
|
85
85
|
|
|
86
|
-
When `notes.md` exceeds 50KB, the engine
|
|
86
|
+
When `notes.md` exceeds 50KB, the engine trims it to the cap (keeping the header and last 8 consolidations) but **does not silently discard** the older sections — every dropped section is appended to `notes/archive/notes-overflow.md` so nothing is lost (data-loss guard `_capNotesPreservingOverflow` in `engine/consolidation.js`, #218). This keeps the live file bounded while retaining recent institutional knowledge and preserving the rest in the archive.
|
|
87
87
|
|
|
88
88
|
## 2. Per-Agent History
|
|
89
89
|
|
|
@@ -328,7 +328,7 @@ When a git merge or rebase produces conflicts in yarn.lock.
|
|
|
328
328
|
| Consolidation model | Haiku | LLM used for summarization (fast, cheap) |
|
|
329
329
|
| LLM process kill timeout | 3 min (180,000ms) | Max time for LLM call before killing process and falling back to regex |
|
|
330
330
|
| Stale flag auto-reset | 5 min (300,000ms) | `_consolidationInFlight` flag auto-resets after this duration to unblock future runs |
|
|
331
|
-
| notes.md max size | 50KB |
|
|
331
|
+
| notes.md max size | 50KB | Trims to cap above this; overflow archived to `notes/archive/notes-overflow.md` (not discarded) |
|
|
332
332
|
| Agent history entries | 20 | Max entries kept in history.md |
|
|
333
333
|
| Metrics file | `engine/metrics.json` | Auto-created on first completion |
|
|
334
334
|
|
package/engine/cc-worker-pool.js
CHANGED
|
@@ -108,17 +108,34 @@ const WARM_MAX_CONCURRENT = 3;
|
|
|
108
108
|
// test/unit/cc-worker-pool.test.js can stub spawn/now/killImmediate.
|
|
109
109
|
const _internals = {
|
|
110
110
|
spawnAcp({ cwd } = {}) {
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
111
|
+
// Resolve `copilot` through the adapter so the pool gets the SAME
|
|
112
|
+
// native/leadingArgs contract as the only other spawn site
|
|
113
|
+
// (engine/spawn-agent.js ~line 595). A bare `spawn('copilot', {shell:false})`
|
|
114
|
+
// cannot exec a `.cmd`/`.ps1`/extensionless npm shim on Windows — it ENOENTs
|
|
115
|
+
// instantly (copilot issue #2370 / W-mqid8uev). resolveBinary() resolves the
|
|
116
|
+
// real JS entry (node_modules/@github/copilot/index.js) and reports whether
|
|
117
|
+
// to spawn it directly (native) or under the current Node process (shim).
|
|
118
|
+
const copilot = require('./runtimes/copilot');
|
|
119
|
+
const resolved = copilot.resolveBinary({ env: process.env });
|
|
120
|
+
if (!resolved || !resolved.bin) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`copilot binary not resolvable -- ${copilot.installHint || 'install the GitHub Copilot CLI'}`
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
const { bin, native, leadingArgs = [] } = resolved;
|
|
126
|
+
const acpArgs = [...leadingArgs, '--acp', '--allow-all', '--max-autopilot-continues', '1'];
|
|
127
|
+
// Mirror engine/spawn-agent.js: native binaries run directly; a non-native
|
|
128
|
+
// (JS entry / npm shim) runs under process.execPath as `node <bin> ...`.
|
|
129
|
+
const execBin = native ? bin : process.execPath;
|
|
130
|
+
const execArgs = native ? acpArgs : [bin, ...acpArgs];
|
|
131
|
+
return spawn(execBin, execArgs, {
|
|
132
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
133
|
+
cwd: cwd || process.cwd(),
|
|
134
|
+
windowsHide: true,
|
|
135
|
+
// Don't pass shell:true — the native/leadingArgs handling above already
|
|
136
|
+
// produces a directly-spawnable argv, and a shell wrapper would swallow
|
|
137
|
+
// the stdin/stdout JSON-RPC framing.
|
|
138
|
+
});
|
|
122
139
|
},
|
|
123
140
|
killImmediate(proc) {
|
|
124
141
|
// Lazy require so unit tests that don't load engine/shared still work.
|
package/engine/cli.js
CHANGED
|
@@ -1146,9 +1146,9 @@ const commands = {
|
|
|
1146
1146
|
// export) doesn't go silent.
|
|
1147
1147
|
function drainBuffers() {
|
|
1148
1148
|
try { require('./llm').flushMetricsBuffer(); }
|
|
1149
|
-
catch (err) {
|
|
1149
|
+
catch (err) { console.error(`[shutdown] flushMetricsBuffer failed: ${err.message}`); }
|
|
1150
1150
|
try { shared.flushLogs(); }
|
|
1151
|
-
catch (err) {
|
|
1151
|
+
catch (err) { console.error(`[shutdown] flushLogs failed: ${err.message}`); }
|
|
1152
1152
|
}
|
|
1153
1153
|
|
|
1154
1154
|
// Graceful shutdown — wait for active agents before exiting
|
package/engine/db-events.js
CHANGED
|
@@ -29,10 +29,8 @@ function emitStateEvent(topic, payload) {
|
|
|
29
29
|
// without spamming on every write.
|
|
30
30
|
if (!_logged) {
|
|
31
31
|
_logged = true;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
console.warn(`[db-events] state event emission disabled: ${e.message}`);
|
|
35
|
-
} catch { /* console unavailable in some test seams */ }
|
|
32
|
+
// eslint-disable-next-line no-console
|
|
33
|
+
console.warn(`[db-events] state event emission disabled: ${e.message}`);
|
|
36
34
|
}
|
|
37
35
|
}
|
|
38
36
|
}
|
package/engine/dispatch.js
CHANGED
|
@@ -518,6 +518,7 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
|
|
|
518
518
|
FAILURE_CLASS.MANAGED_SPAWN_HEALTHCHECK_FAILED, // W-mpbhxg3b000u8411 — healthcheck timed out; agent must fix the spec or the service it spawned
|
|
519
519
|
FAILURE_CLASS.INJECTION_FLAGGED, // F5 (W-mpeklod3000we69c) — agent spotted a prompt-injection attempt in spliced untrusted content; a human must review the source before re-dispatch
|
|
520
520
|
FAILURE_CLASS.LIVE_CHECKOUT_DIRTY, // P-a3f9b204 — live-checkout refused to spawn because operator localPath is dirty; mechanical retry won't fix it (operator must commit/stash/discard)
|
|
521
|
+
FAILURE_CLASS.OUTPUT_TRUNCATED, // P-8e4c2a17 — agent stdout exceeded the hard capture cap before the terminal result event; mechanical retry just reproduces the overflow (agent must reduce output volume or the task must be split)
|
|
521
522
|
]);
|
|
522
523
|
if (neverRetry.has(failureClass)) return false;
|
|
523
524
|
}
|
package/engine/lifecycle.js
CHANGED
|
@@ -2972,7 +2972,15 @@ async function handlePostMerge(pr, project, config, newStatus) {
|
|
|
2972
2972
|
|
|
2973
2973
|
const prNum = shared.getPrNumber(pr);
|
|
2974
2974
|
|
|
2975
|
-
|
|
2975
|
+
// `project.localPath` guard: central/observed PRs (e.g. ADO PRs tracked in
|
|
2976
|
+
// ~/.minions/pull-requests.json with no configured project) are polled via a
|
|
2977
|
+
// *synthesized* project that has no localPath (engine/ado.js + engine/github.js
|
|
2978
|
+
// central-poll loops). There is no checkout to clean for those, and an
|
|
2979
|
+
// unguarded path.resolve(undefined) here threw "paths[0] argument must be of
|
|
2980
|
+
// type string" — caught upstream and logged as "failed to poll central PR".
|
|
2981
|
+
// Skip the worktree-cleanup block when there is no local clone; the rest of
|
|
2982
|
+
// handlePostMerge keys off MINIONS_DIR and still runs.
|
|
2983
|
+
if (pr.branch && project && project.localPath) {
|
|
2976
2984
|
const root = path.resolve(project.localPath);
|
|
2977
2985
|
const wtRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
|
|
2978
2986
|
let removedBranchWorktree = false;
|
package/engine/llm.js
CHANGED
|
@@ -272,6 +272,48 @@ function _resolvedCallResult(result) {
|
|
|
272
272
|
return promise;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
// P-7b2e4d01 — image-input gate. Decide whether a caller-supplied `images`
|
|
276
|
+
// array can be forwarded to the resolved runtime. Pure + side-effect free so it
|
|
277
|
+
// is unit-testable without spawning a process.
|
|
278
|
+
// - no images → { images: undefined, error: null } (today's text-only behavior)
|
|
279
|
+
// - images + caps.imageInput truthy → { images, error: null }
|
|
280
|
+
// - images + caps.imageInput falsy → { images: undefined, error: <typed> }
|
|
281
|
+
// The error `code` is aligned with dashboard CC_ERROR_CODES ('model-unavailable')
|
|
282
|
+
// so the CC/doc-chat envelope normalizes it cleanly — never a silent drop.
|
|
283
|
+
function _resolveImageOpts(images, caps = {}) {
|
|
284
|
+
const list = Array.isArray(images) ? images.filter(Boolean) : [];
|
|
285
|
+
if (!list.length) return { images: undefined, error: null };
|
|
286
|
+
if (caps && caps.imageInput) return { images: list, error: null };
|
|
287
|
+
return {
|
|
288
|
+
images: undefined,
|
|
289
|
+
error: {
|
|
290
|
+
message: 'The selected runtime cannot accept image input — switch ccCli to a runtime with imageInput (e.g. claude).',
|
|
291
|
+
code: 'model-unavailable',
|
|
292
|
+
retriable: false,
|
|
293
|
+
},
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Build a resolved (failed) call result for an image turn the runtime can't
|
|
298
|
+
// serve. Mirrors _missingRuntimeResult's shape so existing CC/doc-chat callers
|
|
299
|
+
// surface the typed `error` envelope instead of an empty hanging reply.
|
|
300
|
+
function _imageUnsupportedResult(runtime, error) {
|
|
301
|
+
return {
|
|
302
|
+
text: error.message,
|
|
303
|
+
usage: null,
|
|
304
|
+
sessionId: null,
|
|
305
|
+
code: MISSING_RUNTIME_EXIT_CODE,
|
|
306
|
+
stderr: error.message,
|
|
307
|
+
raw: '',
|
|
308
|
+
toolUses: [],
|
|
309
|
+
runtime: runtime?.name || null,
|
|
310
|
+
errorClass: error.code,
|
|
311
|
+
errorMessage: error.message,
|
|
312
|
+
error: { message: error.message, code: error.code, retriable: error.retriable === true },
|
|
313
|
+
ok: false,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
275
317
|
function _resolveRuntimeNameFor(callOpts = {}) {
|
|
276
318
|
let runtimeName = callOpts.cli;
|
|
277
319
|
if (!runtimeName && callOpts.engineConfig) runtimeName = resolveCcCli(callOpts.engineConfig);
|
|
@@ -346,6 +388,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
|
|
|
346
388
|
direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
|
|
347
389
|
maxBudget, bare, fallbackModel,
|
|
348
390
|
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
|
|
391
|
+
images,
|
|
349
392
|
} = callOpts;
|
|
350
393
|
|
|
351
394
|
const id = uid();
|
|
@@ -361,6 +404,7 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
|
|
|
361
404
|
model, maxTurns, allowedTools, effort, sessionId,
|
|
362
405
|
maxBudget, bare, fallbackModel,
|
|
363
406
|
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
|
|
407
|
+
images,
|
|
364
408
|
};
|
|
365
409
|
// Capability-gate per-flag opts before prompt construction so adapters can
|
|
366
410
|
// make resume-aware prompt decisions from the same opts used for argv.
|
|
@@ -369,6 +413,10 @@ function _spawnProcess(promptText, sysPromptText, callOpts) {
|
|
|
369
413
|
if (!caps.budgetCap) adapterOpts.maxBudget = undefined;
|
|
370
414
|
if (!caps.bareMode) adapterOpts.bare = undefined;
|
|
371
415
|
if (!caps.fallbackModel) adapterOpts.fallbackModel = undefined;
|
|
416
|
+
// P-7b2e4d01 — defense in depth: never forward images to a runtime that
|
|
417
|
+
// can't read them (callLLM/callLLMStreaming already gate + surface a typed
|
|
418
|
+
// error upstream; this protects any other _spawnProcess caller).
|
|
419
|
+
if (!caps.imageInput) adapterOpts.images = undefined;
|
|
372
420
|
const finalPrompt = runtime.buildPrompt(promptText, sysPromptText, adapterOpts);
|
|
373
421
|
|
|
374
422
|
// ── Direct path ──
|
|
@@ -701,6 +749,8 @@ function callLLM(promptText, sysPromptText, opts = {}) {
|
|
|
701
749
|
// Cross-runtime + Copilot opts:
|
|
702
750
|
maxBudget, bare, fallbackModel,
|
|
703
751
|
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
|
|
752
|
+
// P-7b2e4d01 — optional image attachments: [{ mimeType, dataBase64, filename? }]
|
|
753
|
+
images,
|
|
704
754
|
} = opts;
|
|
705
755
|
|
|
706
756
|
const unavailable = _runtimeUnavailableResult({ cli: cliOverride, engineConfig });
|
|
@@ -708,6 +758,8 @@ function callLLM(promptText, sysPromptText, opts = {}) {
|
|
|
708
758
|
|
|
709
759
|
const runtime = _resolveRuntimeFor({ cli: cliOverride, engineConfig });
|
|
710
760
|
const model = _resolveModelForRuntime(runtime, { model: modelOverride, engineConfig });
|
|
761
|
+
const imageGate = _resolveImageOpts(images, runtime && runtime.capabilities);
|
|
762
|
+
if (imageGate.error) return _resolvedCallResult(_imageUnsupportedResult(runtime, imageGate.error));
|
|
711
763
|
const runtimeFeatureOpts = _resolveRuntimeFeatureOpts({
|
|
712
764
|
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries, engineConfig,
|
|
713
765
|
});
|
|
@@ -717,7 +769,7 @@ function callLLM(promptText, sysPromptText, opts = {}) {
|
|
|
717
769
|
const _startMs = Date.now();
|
|
718
770
|
const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
|
|
719
771
|
direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
|
|
720
|
-
maxBudget, bare, fallbackModel,
|
|
772
|
+
maxBudget, bare, fallbackModel, images: imageGate.images,
|
|
721
773
|
...runtimeFeatureOpts,
|
|
722
774
|
});
|
|
723
775
|
let resolved = false;
|
|
@@ -822,6 +874,8 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
|
|
|
822
874
|
model: modelOverride, cli: cliOverride, engineConfig,
|
|
823
875
|
maxBudget, bare, fallbackModel,
|
|
824
876
|
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries,
|
|
877
|
+
// P-7b2e4d01 — optional image attachments: [{ mimeType, dataBase64, filename? }]
|
|
878
|
+
images,
|
|
825
879
|
} = opts;
|
|
826
880
|
|
|
827
881
|
const unavailable = _runtimeUnavailableResult({ cli: cliOverride, engineConfig });
|
|
@@ -829,6 +883,8 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
|
|
|
829
883
|
|
|
830
884
|
const runtime = _resolveRuntimeFor({ cli: cliOverride, engineConfig });
|
|
831
885
|
const model = _resolveModelForRuntime(runtime, { model: modelOverride, engineConfig });
|
|
886
|
+
const imageGate = _resolveImageOpts(images, runtime && runtime.capabilities);
|
|
887
|
+
if (imageGate.error) return _resolvedCallResult(_imageUnsupportedResult(runtime, imageGate.error));
|
|
832
888
|
const runtimeFeatureOpts = _resolveRuntimeFeatureOpts({
|
|
833
889
|
stream, disableBuiltinMcps, suppressAgentsMd, reasoningSummaries, engineConfig,
|
|
834
890
|
});
|
|
@@ -838,7 +894,7 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
|
|
|
838
894
|
const _startMs = Date.now();
|
|
839
895
|
const { proc, cleanupFiles, cleanupDirs } = _spawnProcess(promptText, sysPromptText, {
|
|
840
896
|
direct, label, runtime, model, maxTurns, allowedTools, effort, sessionId,
|
|
841
|
-
maxBudget, bare, fallbackModel,
|
|
897
|
+
maxBudget, bare, fallbackModel, images: imageGate.images,
|
|
842
898
|
...runtimeFeatureOpts,
|
|
843
899
|
});
|
|
844
900
|
let resolved = false;
|
|
@@ -1028,6 +1084,7 @@ module.exports = {
|
|
|
1028
1084
|
_resetBinCache,
|
|
1029
1085
|
_resetMetricsBufferForTest,
|
|
1030
1086
|
_resolveRuntimeFor,
|
|
1087
|
+
_resolveImageOpts,
|
|
1031
1088
|
_resolveModelFor,
|
|
1032
1089
|
_resolveModelForRuntime,
|
|
1033
1090
|
_resolveRuntimeFeatureOpts,
|
|
@@ -29,6 +29,7 @@ const fs = require('fs');
|
|
|
29
29
|
const os = require('os');
|
|
30
30
|
const path = require('path');
|
|
31
31
|
const { FAILURE_CLASS, safeWrite, ts, resolveEngineCacheDir } = require('../shared');
|
|
32
|
+
const { wrapUntrusted, buildSource } = require('../untrusted-fence');
|
|
32
33
|
|
|
33
34
|
const ENGINE_DIR = __dirname.replace(/[\\/]runtimes$/, '');
|
|
34
35
|
const MINIONS_DIR = path.resolve(ENGINE_DIR, '..');
|
|
@@ -223,10 +224,15 @@ function buildArgs(opts = {}) {
|
|
|
223
224
|
maxBudget,
|
|
224
225
|
bare = false,
|
|
225
226
|
fallbackModel,
|
|
227
|
+
images,
|
|
226
228
|
} = opts;
|
|
227
229
|
|
|
228
230
|
const args = ['-p', '--output-format', outputFormat];
|
|
229
231
|
if (outputFormat === 'stream-json') args.push('--include-partial-messages');
|
|
232
|
+
// P-7b2e4d01: image turns are delivered as a stream-json user envelope over
|
|
233
|
+
// stdin (see buildPrompt). The CLI requires --input-format=stream-json, which
|
|
234
|
+
// in turn requires --output-format=stream-json — already the default above.
|
|
235
|
+
if (Array.isArray(images) && images.length) args.push('--input-format', 'stream-json');
|
|
230
236
|
if (maxTurns != null) args.push('--max-turns', String(maxTurns));
|
|
231
237
|
if (model) args.push('--model', String(model));
|
|
232
238
|
if (verbose) args.push('--verbose');
|
|
@@ -404,11 +410,48 @@ function classifyFailure({ code, stdout = '', stderr = '', fallback } = {}) {
|
|
|
404
410
|
/**
|
|
405
411
|
* Build the final prompt text delivered to the Claude CLI. Claude takes the
|
|
406
412
|
* system prompt via `--system-prompt-file` and the user prompt via stdin, so
|
|
407
|
-
* `buildPrompt()` is a passthrough — `sysPromptText` is delivered
|
|
408
|
-
* by the spawn wrapper, not embedded in the user prompt.
|
|
413
|
+
* `buildPrompt()` is normally a passthrough — `sysPromptText` is delivered
|
|
414
|
+
* separately by the spawn wrapper, not embedded in the user prompt.
|
|
415
|
+
*
|
|
416
|
+
* P-7b2e4d01 — image input. When `opts.images` is a non-empty array of
|
|
417
|
+
* `{ mimeType, dataBase64, filename? }`, return the single-line stream-json
|
|
418
|
+
* user-message envelope the CLI reads under `--input-format stream-json`
|
|
419
|
+
* (a true multimodal Anthropic Messages `content[]` shape) instead of the
|
|
420
|
+
* bare string. base64 content-blocks are preferred over `@path` references
|
|
421
|
+
* (no temp-file lifecycle, no Read-tool round-trip — per spike P-3f8a1c92).
|
|
422
|
+
* Any caller-supplied `filename` is untrusted and is wrapped via the
|
|
423
|
+
* untrusted-fence before it reaches the text block.
|
|
409
424
|
*/
|
|
410
|
-
function buildPrompt(promptText, /* sysPromptText */ _sys) {
|
|
411
|
-
|
|
425
|
+
function buildPrompt(promptText, /* sysPromptText */ _sys, opts = {}) {
|
|
426
|
+
const text = String(promptText == null ? '' : promptText);
|
|
427
|
+
const images = Array.isArray(opts && opts.images) ? opts.images.filter(Boolean) : [];
|
|
428
|
+
if (!images.length) return text;
|
|
429
|
+
|
|
430
|
+
// Fence any filename that reaches prompt text — image filenames are
|
|
431
|
+
// attacker-controlled (pasted/dropped in the browser) and must be treated
|
|
432
|
+
// as data, not instructions.
|
|
433
|
+
const named = images.filter(img => img && img.filename != null && String(img.filename).trim());
|
|
434
|
+
let textBlock = text;
|
|
435
|
+
if (named.length) {
|
|
436
|
+
const fenced = named
|
|
437
|
+
.map(img => wrapUntrusted(String(img.filename), buildSource('cc-image-filename', { mimeType: img.mimeType || '' })))
|
|
438
|
+
.filter(Boolean)
|
|
439
|
+
.join('\n');
|
|
440
|
+
if (fenced) textBlock = `${text}\n\nAttached image filename(s):\n${fenced}`;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const content = [
|
|
444
|
+
{ type: 'text', text: textBlock },
|
|
445
|
+
...images.map(img => ({
|
|
446
|
+
type: 'image',
|
|
447
|
+
source: {
|
|
448
|
+
type: 'base64',
|
|
449
|
+
media_type: String(img.mimeType || 'image/png'),
|
|
450
|
+
data: String(img.dataBase64 == null ? '' : img.dataBase64),
|
|
451
|
+
},
|
|
452
|
+
})),
|
|
453
|
+
];
|
|
454
|
+
return JSON.stringify({ type: 'user', message: { role: 'user', content } });
|
|
412
455
|
}
|
|
413
456
|
|
|
414
457
|
// ── Output Parsing ───────────────────────────────────────────────────────────
|
|
@@ -759,6 +802,10 @@ const capabilities = {
|
|
|
759
802
|
resumeBookkeepingTurn: true,
|
|
760
803
|
// Adapter implements createStreamConsumer(ctx) — required by llm.js accumulator
|
|
761
804
|
streamConsumer: true,
|
|
805
|
+
// P-7b2e4d01: accepts image input via stream-json base64 content blocks
|
|
806
|
+
// (proven empirically in spike P-3f8a1c92). Engine code gates on this flag,
|
|
807
|
+
// never on runtime.name.
|
|
808
|
+
imageInput: true,
|
|
762
809
|
};
|
|
763
810
|
|
|
764
811
|
// Install hint surfaced when `resolveBinary()` returns null. Consumed by
|
package/engine/runtimes/codex.js
CHANGED
|
@@ -832,6 +832,9 @@ const capabilities = {
|
|
|
832
832
|
sessionPersistenceControl: false,
|
|
833
833
|
resumePromptCarryover: true,
|
|
834
834
|
streamConsumer: true,
|
|
835
|
+
// P-7b2e4d01: no image-input mechanism (CLI not installed during spike
|
|
836
|
+
// P-3f8a1c92). Engine code gates on this flag, never on runtime.name.
|
|
837
|
+
imageInput: false,
|
|
835
838
|
};
|
|
836
839
|
|
|
837
840
|
const INSTALL_HINT = 'install Codex CLI with `npm install -g @openai/codex` or `brew install --cask codex`, then run `codex login`.';
|
|
@@ -1214,6 +1214,11 @@ const capabilities = {
|
|
|
1214
1214
|
resumePromptCarryover: true,
|
|
1215
1215
|
// Adapter implements createStreamConsumer(ctx) — required by llm.js accumulator
|
|
1216
1216
|
streamConsumer: true,
|
|
1217
|
+
// P-7b2e4d01: `--attachment <path>` is documented for non-interactive mode
|
|
1218
|
+
// but the headless smoke test was inconclusive (spike P-3f8a1c92). Stub false
|
|
1219
|
+
// until a clean re-test passes; enabling it also needs temp-file materializing
|
|
1220
|
+
// of the base64 payload (a different adapter-local path than Claude's inline).
|
|
1221
|
+
imageInput: false,
|
|
1217
1222
|
};
|
|
1218
1223
|
|
|
1219
1224
|
// Install hint surfaced when `resolveBinary()` returns null. Covers all
|
package/engine/shared.js
CHANGED
|
@@ -4198,6 +4198,7 @@ const FAILURE_CLASS = {
|
|
|
4198
4198
|
WORKSPACE_MANIFEST_TOOL: 'workspace-manifest-tool-forbidden', // W-mq07avbk000m5543: out-of-scope tool call (manifest enforcement at the runtime gate). Non-retryable as-is.
|
|
4199
4199
|
WORKSPACE_MANIFEST_URL: 'workspace-manifest-url-forbidden', // W-mq07avbk000m5543: out-of-scope external URL fetch. Non-retryable as-is.
|
|
4200
4200
|
SPAWN_PHASE_STALL: 'spawn-phase-stall', // W-mq0e2dae000a003d: process spawned and ran startup-only events (MCP init / hooks) but never emitted real task progress; CPU usage stayed below threshold past the grace window. Engine kills the wedged child and treats this as retryable (fresh-session) so a re-spawn can clear a transient MCP wedge.
|
|
4201
|
+
OUTPUT_TRUNCATED: 'output-truncated', // P-8e4c2a17: the agent streamed more stdout than the engine's hard capture cap (engine.js AGENT_OUTPUT_CAP_BYTES, 1MB) BEFORE the terminal `result` event arrived. The result (session id, completion block, final text) lives at the END of the stream, so it fell outside the captured window and parseOutput found nothing — the dispatch would otherwise fail as an opaque UNKNOWN and retry to death. Surfaced loudly with an actionable message; non-retryable (mechanical retry just reproduces the overflow — the agent must reduce output volume or the task must be split).
|
|
4201
4202
|
UNKNOWN: 'unknown', // Unclassified failure
|
|
4202
4203
|
};
|
|
4203
4204
|
const ESCALATION_POLICY = {
|
|
@@ -18,8 +18,7 @@ function _ts() { return new Date().toISOString(); }
|
|
|
18
18
|
function _wrap(method) {
|
|
19
19
|
const original = console[method].bind(console);
|
|
20
20
|
return (...args) => {
|
|
21
|
-
|
|
22
|
-
catch { original(...args); }
|
|
21
|
+
original(`[${_ts()}]`, ...args);
|
|
23
22
|
};
|
|
24
23
|
}
|
|
25
24
|
|
package/engine.js
CHANGED
|
@@ -498,9 +498,37 @@ function _stdoutForFallbackClassification(rawStdout, failureSignal) {
|
|
|
498
498
|
return 'agent produced non-empty stdout without a terminal runtime error signal';
|
|
499
499
|
}
|
|
500
500
|
|
|
501
|
+
// P-8e4c2a17 — Hard cap on captured agent stdout/stderr (chars). The capture
|
|
502
|
+
// handlers in spawnAgent slice incoming chunks so the in-memory buffers never
|
|
503
|
+
// exceed this length. The terminal `result` event (session id, usage,
|
|
504
|
+
// completion block, final text) arrives at the END of the stream, so when a run
|
|
505
|
+
// streams past this cap the result falls OUTSIDE the captured window and
|
|
506
|
+
// parseOutput finds nothing — see _isOutputTruncated / _classifyAgentFailure.
|
|
507
|
+
const AGENT_OUTPUT_CAP_BYTES = 1024 * 1024; // 1MB
|
|
508
|
+
|
|
509
|
+
// P-8e4c2a17 — Detect the "output overflowed before the terminal result" case.
|
|
510
|
+
// True when (a) the captured stdout reached the hard cap AND (b) no terminal
|
|
511
|
+
// `result` event survived the window (the runtime adapter's parseOutput can
|
|
512
|
+
// recover neither a session id nor usage — both of which come ONLY from the
|
|
513
|
+
// result event). A run whose result WAS captured returns false, so this never
|
|
514
|
+
// misfires on a normally-terminated agent.
|
|
515
|
+
function _isOutputTruncated(runtime, stdout) {
|
|
516
|
+
const text = stdout == null ? '' : String(stdout);
|
|
517
|
+
if (text.length < AGENT_OUTPUT_CAP_BYTES) return false; // never hit the cap
|
|
518
|
+
if (runtime && typeof runtime.parseOutput === 'function') {
|
|
519
|
+
try {
|
|
520
|
+
const parsed = runtime.parseOutput(text) || {};
|
|
521
|
+
// sessionId + usage are populated only by the terminal result event.
|
|
522
|
+
if (parsed.sessionId || parsed.usage) return false;
|
|
523
|
+
} catch { /* unparseable at the cap → treat as truncated */ }
|
|
524
|
+
}
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
501
528
|
function _classifyAgentFailure(runtime, code, stdout, stderr) {
|
|
502
529
|
const failureSignal = _collectAgentFailureSignal(stdout);
|
|
503
530
|
const fallbackStdout = _stdoutForFallbackClassification(stdout, failureSignal);
|
|
531
|
+
let result = null;
|
|
504
532
|
if (runtime && typeof runtime.classifyFailure === 'function') {
|
|
505
533
|
const classified = runtime.classifyFailure({
|
|
506
534
|
code,
|
|
@@ -509,9 +537,22 @@ function _classifyAgentFailure(runtime, code, stdout, stderr) {
|
|
|
509
537
|
fallback: (fallbackCode, _stdout, fallbackStderr) =>
|
|
510
538
|
classifyFailureFallback(fallbackCode, fallbackStdout, fallbackStderr),
|
|
511
539
|
});
|
|
512
|
-
if (classified && classified.failureClass)
|
|
540
|
+
if (classified && classified.failureClass) result = classified;
|
|
541
|
+
}
|
|
542
|
+
if (!result) result = { failureClass: classifyFailureFallback(code, fallbackStdout, stderr) };
|
|
543
|
+
|
|
544
|
+
// P-8e4c2a17 — Upgrade an opaque UNKNOWN to OUTPUT_TRUNCATED when the capture
|
|
545
|
+
// hit the hard cap and the terminal result event never made it into the
|
|
546
|
+
// window. Only the UNKNOWN case is upgraded so explicit signals that DID land
|
|
547
|
+
// mid-stream (auth, context-limit, build failure, …) keep their classes.
|
|
548
|
+
if (result.failureClass === FAILURE_CLASS.UNKNOWN && _isOutputTruncated(runtime, stdout)) {
|
|
549
|
+
return {
|
|
550
|
+
failureClass: FAILURE_CLASS.OUTPUT_TRUNCATED,
|
|
551
|
+
retryable: false,
|
|
552
|
+
message: `Agent stdout exceeded the ${AGENT_OUTPUT_CAP_BYTES}-byte capture cap before the terminal result event arrived — the session id, completion report, and final text were streamed past the window and lost. Mechanical retry will just reproduce the overflow. Reduce output volume (quieter tools, fewer full-file dumps) or split the task into smaller work items.`,
|
|
553
|
+
};
|
|
513
554
|
}
|
|
514
|
-
return
|
|
555
|
+
return result;
|
|
515
556
|
}
|
|
516
557
|
|
|
517
558
|
function ackPendingSteeringFiles(agentId, procInfo, rawOutput, observedAtMs = Date.now()) {
|
|
@@ -3636,7 +3677,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3636
3677
|
log('info', `[spawn-timing] id=${id} agent=${agentId} type=${type} runtime=${runtimeName} branch=${branchName || '-'} deps=${_depCountForLog} ${JSON.stringify(timings)}`);
|
|
3637
3678
|
} catch { /* telemetry is best-effort */ }
|
|
3638
3679
|
|
|
3639
|
-
const MAX_OUTPUT =
|
|
3680
|
+
const MAX_OUTPUT = AGENT_OUTPUT_CAP_BYTES; // P-8e4c2a17: 1MB hard capture cap (module const)
|
|
3640
3681
|
let stdout = '';
|
|
3641
3682
|
let stderr = '';
|
|
3642
3683
|
let steeringAckStdout = '';
|
|
@@ -9702,6 +9743,7 @@ module.exports = {
|
|
|
9702
9743
|
findExistingWorktree, // exported for testing
|
|
9703
9744
|
probeBranchOnRemote, // exported for testing (W-mphnm6a1000281b8)
|
|
9704
9745
|
_maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
|
|
9746
|
+
_isOutputTruncated, AGENT_OUTPUT_CAP_BYTES, // P-8e4c2a17: exported for testing (OUTPUT_TRUNCATED detection)
|
|
9705
9747
|
promoteCheckpointSteeringForClose, // exported for testing
|
|
9706
9748
|
normalizePrBranch, resolvePrBranch, prCausePart, getPrCauseHead, getPrCauseBase, getPrAutomationCauseKey, getPrAutomationDispatchKey, // exported for testing
|
|
9707
9749
|
ensurePrBranchForDispatch, isWithinLinkGraceWindow, PR_LINK_GRACE_WINDOW_MS, // exported for testing (W-mphm0kt0000cebc3)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2213",
|
|
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"
|