@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.
@@ -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 &quot;Why this is blocked&quot; below">&#x26A0; 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.
@@ -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>
@@ -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 > 1e6) {
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
- reject(new Error('Too large'));
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
- const body = await readBody(req);
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) { _releaseCCTab(tabId); return jsonReply(res, e.statusCode || 500, { error: e.message, code: 'handler-exception', retriable: false }); }
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
- const body = await readBody(req);
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: () => getArchivedPrds(),
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,
@@ -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`.
@@ -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 prunes old consolidation sections, keeping the header and last 8 consolidations. This prevents the file from growing unbounded while retaining recent institutional knowledge.
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 | Auto-prunes old sections above this |
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
 
@@ -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
- return spawn(
112
- 'copilot',
113
- ['--acp', '--allow-all', '--max-autopilot-continues', '1'],
114
- {
115
- stdio: ['pipe', 'pipe', 'pipe'],
116
- cwd: cwd || process.cwd(),
117
- windowsHide: true,
118
- // Don't pass shell:true — Copilot is a binary on PATH and a shell
119
- // wrapper would swallow stdin/stdout framing.
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) { try { console.error(`[shutdown] flushMetricsBuffer failed: ${err.message}`); } catch {} }
1149
+ catch (err) { console.error(`[shutdown] flushMetricsBuffer failed: ${err.message}`); }
1150
1150
  try { shared.flushLogs(); }
1151
- catch (err) { try { console.error(`[shutdown] flushLogs failed: ${err.message}`); } catch {} }
1151
+ catch (err) { console.error(`[shutdown] flushLogs failed: ${err.message}`); }
1152
1152
  }
1153
1153
 
1154
1154
  // Graceful shutdown — wait for active agents before exiting
@@ -29,10 +29,8 @@ function emitStateEvent(topic, payload) {
29
29
  // without spamming on every write.
30
30
  if (!_logged) {
31
31
  _logged = true;
32
- try {
33
- // eslint-disable-next-line no-console
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
  }
@@ -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
  }
@@ -2972,7 +2972,15 @@ async function handlePostMerge(pr, project, config, newStatus) {
2972
2972
 
2973
2973
  const prNum = shared.getPrNumber(pr);
2974
2974
 
2975
- if (pr.branch && project) {
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 separately
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
- return String(promptText == null ? '' : promptText);
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
@@ -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
- try { original(`[${_ts()}]`, ...args); }
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) return classified;
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 { failureClass: classifyFailureFallback(code, fallbackStdout, stderr) };
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 = 1024 * 1024; // 1MB
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.2212",
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"