@yemi33/minions 0.1.2303 → 0.1.2305
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 +3 -1
- package/dashboard/js/refresh.js +3 -2
- package/dashboard/js/render-work-items.js +2 -1
- package/dashboard/js/settings.js +9 -0
- package/dashboard/layout.html +13 -0
- package/dashboard/slim/js/chat.js +19 -0
- package/dashboard/slim/js/command-send.js +10 -0
- package/dashboard/slim/js/modals-tiles.js +21 -130
- package/dashboard/slim/styles.css +21 -14
- package/dashboard/styles.css +21 -0
- package/dashboard.js +39 -2
- package/docs/command-center.md +2 -0
- package/engine/cc-worker-pool.js +60 -5
- package/engine/live-checkout.js +163 -73
- package/engine/shared.js +1 -0
- package/package.json +1 -1
|
@@ -1300,7 +1300,9 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
|
|
|
1300
1300
|
_cleanupStreamDiv();
|
|
1301
1301
|
if (evt.sessionReset) {
|
|
1302
1302
|
var resetText;
|
|
1303
|
-
if (evt.sessionResetReason === '
|
|
1303
|
+
if (evt.sessionResetReason === 'idleReap') {
|
|
1304
|
+
resetText = 'Context window cleared after inactivity — fresh session started.';
|
|
1305
|
+
} else if (evt.sessionResetReason === 'runtimeChanged' && evt.previousRuntime && evt.currentRuntime) {
|
|
1304
1306
|
resetText = 'Runtime switched (' + escHtml(evt.previousRuntime) + ' → ' + escHtml(evt.currentRuntime) + ') — started a fresh session and carried over recent history.';
|
|
1305
1307
|
} else if (evt.sessionResetReason === 'promptChanged') {
|
|
1306
1308
|
resetText = 'Minions was updated — started a fresh session and carried over recent history.';
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -147,7 +147,7 @@ const RENDER_VERSIONS = {
|
|
|
147
147
|
dispatch: 2,
|
|
148
148
|
engineLog: 2,
|
|
149
149
|
metrics: 1,
|
|
150
|
-
workItems:
|
|
150
|
+
workItems: 6,
|
|
151
151
|
skills: 1,
|
|
152
152
|
commands: 1,
|
|
153
153
|
mcpServers: 1,
|
|
@@ -166,7 +166,8 @@ const RENDER_VERSIONS = {
|
|
|
166
166
|
// Bumped to 2 by W-mpmwxkcn000646cc (left-rail tabbed Settings layout).
|
|
167
167
|
// Bumped to 3 by W-mqk2s8q1 (Advanced → Diagnostics: relocated the diag button).
|
|
168
168
|
// Bumped to 4 by W-mqrdavob (Agents table: per-agent charter editor column).
|
|
169
|
-
|
|
169
|
+
// Bumped to 5 by W-mr0qs0vw (Worker Pool: CC session idle-timeout field).
|
|
170
|
+
settings: 5,
|
|
170
171
|
};
|
|
171
172
|
const _sectionCache = {};
|
|
172
173
|
const _lastValueByKey = {};
|
|
@@ -42,6 +42,7 @@ const _WI_ENRICHMENT_FIELDS = [
|
|
|
42
42
|
// * already_dispatched — engine reconciles on next tick
|
|
43
43
|
// * branch_locked — wait for the holding dispatch
|
|
44
44
|
// * dependency_unmet — auto-clears when upstream dep finishes (engine discovery loop + DEPENDENCY_MET watch)
|
|
45
|
+
// * live_checkout_busy — one live-mode dispatch at a time; clears when running dispatch finishes
|
|
45
46
|
//
|
|
46
47
|
// Returns { kind, short, full } when the item needs attention, else null.
|
|
47
48
|
// Pure helper (no DOM / escapeHtml deps) so it can be unit-tested in Node.
|
|
@@ -219,7 +220,7 @@ function wiRow(item) {
|
|
|
219
220
|
+ escapeHtml(((item._managedSpawnPartial.healthy || []).length) + '/' + (((item._managedSpawnPartial.healthy || []).length) + item._managedSpawnPartial.failed.length))
|
|
220
221
|
+ ' healthy</span>'
|
|
221
222
|
: '') +
|
|
222
|
-
(item._skipReason && item.status === 'pending' ? ' <span style="font-size:var(--text-xs);color:var(--yellow);margin-left:4px" title="Dispatch blocked: ' + escapeHtml(item._skipReason) + (item._blockedBy ? ' (by ' + escapeHtml(item._blockedBy) + ')' : '') + '">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + (item._blockedBy ? ' <span style="color:var(--muted)">(' + escapeHtml(item._blockedBy) + ')</span>' : '') + '</span>' : '') +
|
|
223
|
+
(item._skipReason && item.status === 'pending' && item._skipReason !== 'live_checkout_busy' ? ' <span style="font-size:var(--text-xs);color:var(--yellow);margin-left:4px" title="Dispatch blocked: ' + escapeHtml(item._skipReason) + (item._blockedBy ? ' (by ' + escapeHtml(item._blockedBy) + ')' : '') + '">' + escapeHtml(item._skipReason.replace(/_/g, ' ')) + (item._blockedBy ? ' <span style="color:var(--muted)">(' + escapeHtml(item._blockedBy) + ')</span>' : '') + '</span>' : '') +
|
|
223
224
|
(item.status === 'failed' ? ' ' + wiRetryBtn(item) : '') +
|
|
224
225
|
'</td>' +
|
|
225
226
|
'<td>' +
|
package/dashboard/js/settings.js
CHANGED
|
@@ -387,6 +387,7 @@ async function openSettings() {
|
|
|
387
387
|
'When ON, a dirty or broken live-checkout (in-place) tree is force-recovered before dispatch via `git fetch origin` + `git reset --hard origin/<branch>` instead of failing the dispatch with non-retryable LIVE_CHECKOUT_DIRTY. This is the FLEET-WIDE fallback; a per-project `liveCheckoutAutoReset` in config.json overrides it. Default OFF because the reset is DESTRUCTIVE — it discards the operator\'s uncommitted changes in the live checkout (a `live-checkout-autoreset-<workItem>` inbox note records exactly what was discarded for reflog recovery). Only affects projects running in live/in-place checkout mode.') +
|
|
388
388
|
'</div>' +
|
|
389
389
|
'<div class="settings-grid-2">' +
|
|
390
|
+
settingsField('CC Session Idle Timeout', 'set-ccWorkerIdleTimeoutMs', Math.round((e.ccWorkerIdleTimeoutMs || 1800000) / 60000), 'minutes', 'How long a persistent `copilot --acp` CC worker stays warm with no activity before the idle reaper kills it. After a reap the next message cold-spawns a FRESH session with no memory of prior turns and CC shows a "context cleared after inactivity" notice. Shorter = less idle memory/process footprint; longer = more context durability across gaps between messages. Only affects the Copilot worker-pool path (CC Worker Pool ON). Clamped 1–480 minutes.') +
|
|
390
391
|
settingsField('Worktree Create Timeout', 'set-worktreeCreateTimeout', e.worktreeCreateTimeout || 300000, 'ms', 'Timeout for git worktree add (increase for large repos/Windows)') +
|
|
391
392
|
settingsField('Worktree Create Retries', 'set-worktreeCreateRetries', e.worktreeCreateRetries || 1, '', 'Retry count for transient worktree add failures (0-3)') +
|
|
392
393
|
settingsField('Worktree Root', 'set-worktreeRoot', e.worktreeRoot || '../worktrees', '', 'Relative or absolute path for git worktrees; on Windows prefer a short path like C:\\wt') +
|
|
@@ -1091,6 +1092,14 @@ async function saveSettings() {
|
|
|
1091
1092
|
ccModel: (document.getElementById('set-ccModel')?.value ?? '').trim(),
|
|
1092
1093
|
ccEffort: document.getElementById('set-ccEffort').value || null,
|
|
1093
1094
|
ccTurnTimeoutMs: document.getElementById('set-ccTurnTimeoutMs')?.value,
|
|
1095
|
+
// Settings UI presents the idle timeout in minutes for readability; the
|
|
1096
|
+
// engine stores ms (ENGINE_DEFAULTS.ccWorkerIdleTimeoutMs). Convert here;
|
|
1097
|
+
// the server clamps to [60000, 28800000] ms. undefined when blank so the
|
|
1098
|
+
// server keeps the prior value rather than zeroing it.
|
|
1099
|
+
ccWorkerIdleTimeoutMs: (function () {
|
|
1100
|
+
var m = Number(document.getElementById('set-ccWorkerIdleTimeoutMs')?.value);
|
|
1101
|
+
return (Number.isFinite(m) && m > 0) ? Math.round(m * 60000) : undefined;
|
|
1102
|
+
})(),
|
|
1094
1103
|
claudeBareMode: !!document.getElementById('set-claudeBareMode')?.checked,
|
|
1095
1104
|
claudeFallbackModel: (document.getElementById('set-claudeFallbackModel')?.value ?? '').trim(),
|
|
1096
1105
|
copilotFallbackModel: (document.getElementById('set-copilotFallbackModel')?.value ?? '').trim(),
|
package/dashboard/layout.html
CHANGED
|
@@ -17,6 +17,19 @@
|
|
|
17
17
|
document.documentElement.setAttribute('data-font-size', (v && valid[v]) ? v : 'small');
|
|
18
18
|
} catch (e) { /* private mode / disabled storage — fall through to default */ }
|
|
19
19
|
})();
|
|
20
|
+
// W-mqrdggys000f94a4 — chrome-off "embed" mode. When the SPA is loaded with
|
|
21
|
+
// ?embed=1 (e.g. inside the slim cockpit's Queued-work iframe pointed at
|
|
22
|
+
// /work?embed=1), tag <html> so the embed CSS hides the global chrome
|
|
23
|
+
// (header, sidebar, projects bar, banners) and the iframed page renders as
|
|
24
|
+
// just its panel. Applied in <head> so the first paint already omits the
|
|
25
|
+
// chrome (no flash of the full layout).
|
|
26
|
+
(function() {
|
|
27
|
+
try {
|
|
28
|
+
if (/[?&]embed=1(?:&|$)/.test(window.location.search)) {
|
|
29
|
+
document.documentElement.classList.add('embed');
|
|
30
|
+
}
|
|
31
|
+
} catch (e) { /* defensive — never block boot on a URL parse quirk */ }
|
|
32
|
+
})();
|
|
20
33
|
</script>
|
|
21
34
|
<style>/* __CSS__ */</style>
|
|
22
35
|
</head>
|
|
@@ -347,6 +347,24 @@
|
|
|
347
347
|
scrollToBottom();
|
|
348
348
|
return div;
|
|
349
349
|
}
|
|
350
|
+
// W-mr0qs0vw — centered, muted system notice row (e.g. a session reset after
|
|
351
|
+
// the CC worker pool's idle reaper fires). Mirrors the classic dashboard's
|
|
352
|
+
// session-reset banner so both surfaces read identically. Used live from
|
|
353
|
+
// command-send.js and replayed on restore via rerenderHistory.
|
|
354
|
+
function _sessionResetNoticeText(reason) {
|
|
355
|
+
if (reason === 'idleReap') return 'Context window cleared after inactivity — fresh session started.';
|
|
356
|
+
if (reason === 'promptChanged') return 'Minions was updated — fresh session started.';
|
|
357
|
+
if (reason === 'runtimeChanged') return 'Runtime switched — fresh session started.';
|
|
358
|
+
return 'Session reset — fresh session started.';
|
|
359
|
+
}
|
|
360
|
+
function appendSystemNotice(text) {
|
|
361
|
+
var div = document.createElement('div');
|
|
362
|
+
div.className = 'chat-system-notice';
|
|
363
|
+
div.textContent = text;
|
|
364
|
+
_appendMsgEl(div);
|
|
365
|
+
scrollToBottom();
|
|
366
|
+
return div;
|
|
367
|
+
}
|
|
350
368
|
|
|
351
369
|
// ── Queued-message rendering (W-mqayw3x6) ──────────────────────────
|
|
352
370
|
function _getQueue() {
|
|
@@ -617,6 +635,7 @@
|
|
|
617
635
|
for (var i = 0; i < messages.length; i++) {
|
|
618
636
|
var m = messages[i];
|
|
619
637
|
if (m.role === 'action') appendActionStatus(m.severity || '', m.text || '');
|
|
638
|
+
else if (m.role === 'system') appendSystemNotice(m.text || '');
|
|
620
639
|
else appendBubble(m.role, m.text || '', m.toolCalls);
|
|
621
640
|
}
|
|
622
641
|
}
|
|
@@ -190,6 +190,16 @@
|
|
|
190
190
|
if (doneEvt.actions && doneEvt.actions.length > 0) {
|
|
191
191
|
renderActionResults(turnTabId, doneEvt.actions, doneEvt.actionResults || []);
|
|
192
192
|
}
|
|
193
|
+
// W-mr0qs0vw — the CC worker pool's idle reaper (or a prompt/runtime
|
|
194
|
+
// change) can reset the session between turns. Surface the same centered
|
|
195
|
+
// system notice the classic dashboard shows so slim users aren't left
|
|
196
|
+
// silently losing context. Persist it (role:'system') so it replays on
|
|
197
|
+
// restore; render live only when this turn's tab is the visible one.
|
|
198
|
+
if (doneEvt.sessionReset) {
|
|
199
|
+
var resetMsg = _sessionResetNoticeText(doneEvt.sessionResetReason);
|
|
200
|
+
if (turnTabId === tabId) appendSystemNotice(resetMsg);
|
|
201
|
+
recordTabMessage(turnTabId, { role: 'system', text: resetMsg });
|
|
202
|
+
}
|
|
193
203
|
// Refresh status + history opportunistically — actions probably
|
|
194
204
|
// changed something.
|
|
195
205
|
scheduleStatusRefresh(800);
|
|
@@ -123,135 +123,23 @@
|
|
|
123
123
|
});
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
head.setAttribute('tabindex', '0');
|
|
144
|
-
head.setAttribute('aria-expanded', 'false');
|
|
145
|
-
var caret = document.createElement('span');
|
|
146
|
-
caret.className = 'tile-caret';
|
|
147
|
-
caret.textContent = '▸';
|
|
148
|
-
var label = document.createElement('span');
|
|
149
|
-
label.className = 'tile-completed-section-title';
|
|
150
|
-
label.textContent = 'Completed work (' + completed.length + ')';
|
|
151
|
-
head.appendChild(caret);
|
|
152
|
-
head.appendChild(label);
|
|
153
|
-
|
|
154
|
-
var listWrap = document.createElement('div');
|
|
155
|
-
listWrap.className = 'tile-completed-section-body';
|
|
156
|
-
listWrap.hidden = true;
|
|
157
|
-
|
|
158
|
-
if (!completed.length) {
|
|
159
|
-
tileEmpty(listWrap, 'No completed work yet.');
|
|
160
|
-
} else {
|
|
161
|
-
completed.forEach(function(c) {
|
|
162
|
-
var who = c.agentName || c.agent || c.dispatched_to || '—';
|
|
163
|
-
var when = c.completed_at || c.completedAt || c.endedAt || c.started_at || c.startedAt;
|
|
164
|
-
var status = completionStatus(c);
|
|
165
|
-
var chip = status === 'ok' ? { text: 'done', cls: 'green' }
|
|
166
|
-
: status === 'warn' ? { text: 'partial', cls: 'amber' }
|
|
167
|
-
: { text: 'failed', cls: 'red' };
|
|
168
|
-
listWrap.appendChild(tileCard({
|
|
169
|
-
title: completionTitle(c),
|
|
170
|
-
meta: who + (when ? ' · completed ' + relTime(when) : ''),
|
|
171
|
-
chip: chip,
|
|
172
|
-
}));
|
|
173
|
-
});
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
function toggle() {
|
|
177
|
-
var open = !section.classList.toggle('collapsed');
|
|
178
|
-
head.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
179
|
-
caret.textContent = open ? '▾' : '▸';
|
|
180
|
-
listWrap.hidden = !open;
|
|
181
|
-
}
|
|
182
|
-
head.addEventListener('click', toggle);
|
|
183
|
-
head.addEventListener('keydown', function(ev) {
|
|
184
|
-
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); toggle(); }
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
section.appendChild(head);
|
|
188
|
-
section.appendChild(listWrap);
|
|
189
|
-
body.appendChild(section);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Collapsible "Pending work items" section appended to the queued popout
|
|
193
|
-
// (W-mqv1xbcm). Sources data.workItems filtered to status === 'pending' — the
|
|
194
|
-
// same /api/work-items slice the slim poll merges onto the snapshot — so it
|
|
195
|
-
// adds no new API calls and refreshes whenever the popout is re-opened.
|
|
196
|
-
// Expanded by DEFAULT (more prominent than the collapsed Completed section)
|
|
197
|
-
// since these are the queued WIs the operator most wants to scan. Mirrors
|
|
198
|
-
// renderCompletedSection's structure + tileCard usage; all text via
|
|
199
|
-
// textContent (no innerHTML) to keep the SEC-03 baseline clean. Each card
|
|
200
|
-
// shows title, id, project (project || _source), type, and the engine's
|
|
201
|
-
// _pendingReason as a chip when set.
|
|
202
|
-
function renderPendingSection(body, data) {
|
|
203
|
-
var pending = (Array.isArray(data.workItems) ? data.workItems : []).filter(function(w) {
|
|
204
|
-
return w && w.status === 'pending';
|
|
205
|
-
});
|
|
206
|
-
|
|
207
|
-
var section = document.createElement('div');
|
|
208
|
-
section.className = 'tile-completed-section';
|
|
209
|
-
|
|
210
|
-
var head = document.createElement('div');
|
|
211
|
-
head.className = 'tile-completed-section-head';
|
|
212
|
-
head.setAttribute('role', 'button');
|
|
213
|
-
head.setAttribute('tabindex', '0');
|
|
214
|
-
head.setAttribute('aria-expanded', 'true');
|
|
215
|
-
var caret = document.createElement('span');
|
|
216
|
-
caret.className = 'tile-caret';
|
|
217
|
-
caret.textContent = '▾';
|
|
218
|
-
var label = document.createElement('span');
|
|
219
|
-
label.className = 'tile-completed-section-title';
|
|
220
|
-
label.textContent = 'Pending work items (' + pending.length + ')';
|
|
221
|
-
head.appendChild(caret);
|
|
222
|
-
head.appendChild(label);
|
|
223
|
-
|
|
224
|
-
var listWrap = document.createElement('div');
|
|
225
|
-
listWrap.className = 'tile-completed-section-body';
|
|
226
|
-
|
|
227
|
-
if (!pending.length) {
|
|
228
|
-
tileEmpty(listWrap, 'No pending work items.');
|
|
229
|
-
} else {
|
|
230
|
-
pending.forEach(function(w) {
|
|
231
|
-
var project = w.project || w._source;
|
|
232
|
-
var meta = [w.id || null, project || null, w.type || null].filter(Boolean).join(' · ');
|
|
233
|
-
var card = { title: w.title || w.id || '(untitled)', meta: meta };
|
|
234
|
-
if (w._pendingReason) {
|
|
235
|
-
card.chip = { text: String(w._pendingReason).replace(/_/g, ' '), cls: 'amber' };
|
|
236
|
-
}
|
|
237
|
-
listWrap.appendChild(tileCard(card));
|
|
238
|
-
});
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function toggle() {
|
|
242
|
-
var open = !section.classList.toggle('collapsed');
|
|
243
|
-
head.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
244
|
-
caret.textContent = open ? '▾' : '▸';
|
|
245
|
-
listWrap.hidden = !open;
|
|
246
|
-
}
|
|
247
|
-
head.addEventListener('click', toggle);
|
|
248
|
-
head.addEventListener('keydown', function(ev) {
|
|
249
|
-
if (ev.key === 'Enter' || ev.key === ' ') { ev.preventDefault(); toggle(); }
|
|
250
|
-
});
|
|
251
|
-
|
|
252
|
-
section.appendChild(head);
|
|
253
|
-
section.appendChild(listWrap);
|
|
254
|
-
body.appendChild(section);
|
|
126
|
+
// Queued-work tile body — reuses the LITERAL classic dashboard Work Items
|
|
127
|
+
// screen instead of a slim-specific list, so there is one work-items UI, not
|
|
128
|
+
// two (W-mqrdggys000f94a4 / "reuse, don't fork"). Slim and classic are two
|
|
129
|
+
// separate IIFE bundles with no shared scope, so we can't call the classic
|
|
130
|
+
// renderWorkItems() in-process; instead we embed the real /work screen in an
|
|
131
|
+
// iframe with the chrome-off ?embed=1 mode. The iframed page IS the classic
|
|
132
|
+
// screen — full filter/sort + every row action (edit/cancel/retry/archive/
|
|
133
|
+
// delete/feedback), backed by the same /api/work-items data + endpoints —
|
|
134
|
+
// with zero duplicated rendering logic. Classic is reachable at /work even
|
|
135
|
+
// with slim-ux ON (only / is taken over). Built with createElement (no
|
|
136
|
+
// innerHTML) to satisfy the dashboard no-unsanitized lint gate.
|
|
137
|
+
function renderQueuedWorkBody(body) {
|
|
138
|
+
var frame = document.createElement('iframe');
|
|
139
|
+
frame.className = 'slim-work-embed';
|
|
140
|
+
frame.src = '/work?embed=1';
|
|
141
|
+
frame.title = 'Work Items';
|
|
142
|
+
body.appendChild(frame);
|
|
255
143
|
}
|
|
256
144
|
|
|
257
145
|
function renderPrTileBody(body, data) {
|
|
@@ -375,7 +263,7 @@
|
|
|
375
263
|
var TILE_VIEWS = {
|
|
376
264
|
engine: { title: 'Engine', render: renderEngineTileBody },
|
|
377
265
|
dispatches: { title: 'Active dispatches', render: function(body, data) { renderDispatchTileBody(body, data, true); } },
|
|
378
|
-
queued: { title: 'Queued work', render: function(body
|
|
266
|
+
queued: { title: 'Queued work', render: function(body) { renderQueuedWorkBody(body); } },
|
|
379
267
|
prs: { title: 'Pull requests', render: renderPrTileBody },
|
|
380
268
|
watches: { title: 'Watches', render: renderWatchTileBody },
|
|
381
269
|
};
|
|
@@ -400,6 +288,9 @@
|
|
|
400
288
|
// The "+ Link PR" header chip only applies to the PR list.
|
|
401
289
|
var headerChip = document.getElementById('slim-tile-modal-linkpr');
|
|
402
290
|
if (headerChip) headerChip.style.display = (key === 'prs') ? '' : 'none';
|
|
291
|
+
// The queued tile embeds the full classic /work screen in an iframe — widen
|
|
292
|
+
// the modal + drop the body padding so the embedded table gets real estate.
|
|
293
|
+
modal.classList.toggle('tile-modal--work', key === 'queued');
|
|
403
294
|
view.render(body, lastStatusData || {});
|
|
404
295
|
modal.classList.add('open');
|
|
405
296
|
}
|
|
@@ -680,6 +680,15 @@
|
|
|
680
680
|
/* Cockpit-tile detail modal: list of dispatches / PRs / watches, or a
|
|
681
681
|
key/value engine readout (reuses .agent-detail-row). */
|
|
682
682
|
#slim-tile-modal .modal { width: 720px; max-width: calc(100vw - 32px); }
|
|
683
|
+
/* W-mqrdggys000f94a4 — the Queued-work tile embeds the full classic /work
|
|
684
|
+
screen in an iframe; widen the modal + remove body padding so the
|
|
685
|
+
embedded work-items table gets the full width/height. */
|
|
686
|
+
#slim-tile-modal.tile-modal--work .modal { width: 1180px; }
|
|
687
|
+
#slim-tile-modal.tile-modal--work .modal-body { padding: 0; }
|
|
688
|
+
.slim-work-embed {
|
|
689
|
+
display: block; width: 100%; height: calc(100vh - 160px); min-height: 360px;
|
|
690
|
+
border: 0; background: var(--bg);
|
|
691
|
+
}
|
|
683
692
|
.tile-item {
|
|
684
693
|
display: block;
|
|
685
694
|
text-decoration: none;
|
|
@@ -712,20 +721,6 @@
|
|
|
712
721
|
.tile-chip.blue { background: rgba(88, 166, 255, 0.12); color: var(--blue); border-color: var(--blue); }
|
|
713
722
|
.tile-empty { color: var(--muted); font-style: italic; font-size: var(--text-md); }
|
|
714
723
|
|
|
715
|
-
/* Collapsible "Completed work" section in the queued popout. Collapsed by
|
|
716
|
-
default; the caret rotates and the body unhides on toggle. */
|
|
717
|
-
.tile-completed-section { margin-top: 12px; border-top: 1px solid var(--border); padding-top: 10px; }
|
|
718
|
-
.tile-completed-section-head {
|
|
719
|
-
display: flex; align-items: center; gap: 6px;
|
|
720
|
-
cursor: pointer; user-select: none;
|
|
721
|
-
font-size: var(--text-md); font-weight: 600; color: var(--text);
|
|
722
|
-
padding: 2px 0;
|
|
723
|
-
}
|
|
724
|
-
.tile-completed-section-head:hover { color: var(--blue); }
|
|
725
|
-
.tile-completed-section-head:focus-visible { outline: 1px solid var(--blue); outline-offset: 2px; border-radius: var(--radius); }
|
|
726
|
-
.tile-caret { font-size: var(--text-sm); color: var(--muted); width: 12px; display: inline-block; text-align: center; }
|
|
727
|
-
.tile-completed-section-body { margin-top: 8px; }
|
|
728
|
-
|
|
729
724
|
/* Collapsible PR sections (Active / Merged-Completed / Abandoned). Uses
|
|
730
725
|
native <details>/<summary> — no JS wiring needed for the toggle. */
|
|
731
726
|
.tile-section { margin-bottom: 10px; }
|
|
@@ -898,6 +893,18 @@
|
|
|
898
893
|
.chat-action.ok { color: var(--green); }
|
|
899
894
|
.chat-action.warn { color: var(--amber); }
|
|
900
895
|
.chat-action.err { color: var(--red); }
|
|
896
|
+
/* W-mr0qs0vw — centered, muted session-reset notice (idle-reap / prompt /
|
|
897
|
+
runtime change). Mirrors the classic dashboard's surface2 banner. */
|
|
898
|
+
.chat-system-notice {
|
|
899
|
+
align-self: center;
|
|
900
|
+
max-width: 90%;
|
|
901
|
+
text-align: center;
|
|
902
|
+
padding: var(--space-1) var(--space-3);
|
|
903
|
+
border-radius: 6px;
|
|
904
|
+
font-size: var(--text-base);
|
|
905
|
+
background: var(--surface2);
|
|
906
|
+
color: var(--muted);
|
|
907
|
+
}
|
|
901
908
|
|
|
902
909
|
/* Lightweight markdown styling inside assistant bubbles. */
|
|
903
910
|
.chat-msg.assistant code,
|
package/dashboard/styles.css
CHANGED
|
@@ -1547,3 +1547,24 @@
|
|
|
1547
1547
|
.cc-error strong { color: var(--red); }
|
|
1548
1548
|
.cc-error code { font-family: monospace; font-size: var(--text-xs);
|
|
1549
1549
|
color: var(--muted); }
|
|
1550
|
+
|
|
1551
|
+
/* W-mqrdggys000f94a4 — chrome-off "embed" mode (?embed=1). Strips the global
|
|
1552
|
+
dashboard chrome so a single page (e.g. /work?embed=1) can be iframed inside
|
|
1553
|
+
the slim cockpit's Queued-work modal and render as just its panel. The
|
|
1554
|
+
html.embed class is applied in layout.html's head before first paint. */
|
|
1555
|
+
html.embed body > header,
|
|
1556
|
+
html.embed #projects-bar,
|
|
1557
|
+
html.embed #setup-banner,
|
|
1558
|
+
html.embed #fre-banner,
|
|
1559
|
+
html.embed .engine-alert,
|
|
1560
|
+
html.embed .paused-banner,
|
|
1561
|
+
html.embed .sidebar,
|
|
1562
|
+
html.embed #cc-drawer,
|
|
1563
|
+
html.embed #cc-overlay { display: none !important; }
|
|
1564
|
+
/* Do NOT override .page-layout to display:block here — that detaches
|
|
1565
|
+
.page-content from its bounded-height flex parent, so it grows to full
|
|
1566
|
+
content height and the fixed-height iframe clips it with no scrollbar (the
|
|
1567
|
+
Queued-work popup "doesn't scroll"). The sidebar is hidden above, so the
|
|
1568
|
+
base .page-layout flex row already gives .page-content full width plus a
|
|
1569
|
+
bounded height to scroll within. */
|
|
1570
|
+
html.embed .page-content { padding: 12px 16px; overflow-y: auto; }
|
package/dashboard.js
CHANGED
|
@@ -254,6 +254,14 @@ function reloadConfig() {
|
|
|
254
254
|
CONFIG = queries.getConfig();
|
|
255
255
|
PROJECTS = _getProjects(CONFIG);
|
|
256
256
|
ensureConfiguredProjectStateFiles();
|
|
257
|
+
// Keep the ACP worker pool's idle-reaper window in sync with config so a
|
|
258
|
+
// Settings change takes effect without a dashboard restart (W-mr0qs0vw). The
|
|
259
|
+
// pool stays dependency-free at load and relies on this push.
|
|
260
|
+
try {
|
|
261
|
+
const idleMs = Number(CONFIG.engine?.ccWorkerIdleTimeoutMs)
|
|
262
|
+
|| shared.ENGINE_DEFAULTS.ccWorkerIdleTimeoutMs;
|
|
263
|
+
ccWorkerPool.setIdleTimeoutMs(idleMs);
|
|
264
|
+
} catch { /* invalid value or pool unavailable — keep prior window */ }
|
|
257
265
|
}
|
|
258
266
|
ensureConfiguredProjectStateFiles();
|
|
259
267
|
|
|
@@ -4676,7 +4684,7 @@ async function _preflightModelCheck({ runtime: cliOverride, model: modelOverride
|
|
|
4676
4684
|
* Why we deliberately skip `docSessions` updates on this path: pool ACP
|
|
4677
4685
|
* session IDs are valid only inside the live worker process. Persisting
|
|
4678
4686
|
* them risks the next call seeing a "session unchanged + _docHash
|
|
4679
|
-
* matches → skip document content" path after the idle reaper (
|
|
4687
|
+
* matches → skip document content" path after the idle reaper (30 min)
|
|
4680
4688
|
* kills the worker, silently starving the new ACP session of the
|
|
4681
4689
|
* document body. Always re-sending extraContext is correctness-safe; the
|
|
4682
4690
|
* pool's warm-process saving is preserved regardless.
|
|
@@ -5275,7 +5283,7 @@ function _docChatResultLooksSuccessful(result) {
|
|
|
5275
5283
|
// usePool: when true (caller is on the worker-pool path), force `existing=null`
|
|
5276
5284
|
// so the docUnchanged optimization never fires. Pool ACP session IDs are valid
|
|
5277
5285
|
// only inside the live worker process; when the idle reaper kills the worker
|
|
5278
|
-
// (
|
|
5286
|
+
// (30 min), the server restarts, or the operator flips ccUseWorkerPool with
|
|
5279
5287
|
// prior legacy history on disk, a stale docSessions._docHash matching the
|
|
5280
5288
|
// current doc would otherwise emit docUnchanged:true → _formatDocChatContext
|
|
5281
5289
|
// replaces the doc body with "unchanged from the previous turn" → the fresh
|
|
@@ -10326,6 +10334,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
10326
10334
|
tabSessionId = tabEntry.sessionId;
|
|
10327
10335
|
}
|
|
10328
10336
|
}
|
|
10337
|
+
// W-mr0qs0vw — if the persistent ACP worker for this tab was killed by
|
|
10338
|
+
// the idle reaper since the last turn, the next message cold-spawns a
|
|
10339
|
+
// fresh session with NO memory of prior turns. Drain the pool's one-shot
|
|
10340
|
+
// reap flag and classify as 'idleReap' so CC surfaces a "context
|
|
10341
|
+
// cleared after inactivity" notice. A stronger reset reason already set
|
|
10342
|
+
// this turn (prompt/runtime change) wins; we still drain so the flag
|
|
10343
|
+
// never lingers into a later turn.
|
|
10344
|
+
let _wasIdleReaped = false;
|
|
10345
|
+
try { _wasIdleReaped = ccWorkerPool.consumeIdleReapNotice(body.tabId || 'default'); } catch { /* pool optional */ }
|
|
10346
|
+
if (_wasIdleReaped && !sessionResetReason) {
|
|
10347
|
+
tabSessionId = null;
|
|
10348
|
+
sessionReset = true;
|
|
10349
|
+
sessionResetReason = 'idleReap';
|
|
10350
|
+
}
|
|
10329
10351
|
const wasResume = !!tabSessionId;
|
|
10330
10352
|
const sessionId = tabSessionId || null;
|
|
10331
10353
|
const resumeNeedsCarryover = wasResume && _ccRuntimeNeedsResumeCarryover(currentRuntime);
|
|
@@ -11019,6 +11041,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
11019
11041
|
// larger models); max 1h (matches CC_CALL_TIMEOUT_MS so the watchdog
|
|
11020
11042
|
// never outlives the outer abort).
|
|
11021
11043
|
ccTurnTimeoutMs: [10000, 3600000],
|
|
11044
|
+
// W-mr0qs0vw — ACP worker idle-reaper window. 1min floor (anything
|
|
11045
|
+
// shorter would reap a warm worker between a user's own messages and
|
|
11046
|
+
// thrash cold-spawns); 8h ceiling (a config typo shouldn't pin a warm
|
|
11047
|
+
// copilot process in memory indefinitely). Stored in ms; the Settings
|
|
11048
|
+
// UI presents minutes and converts.
|
|
11049
|
+
ccWorkerIdleTimeoutMs: [60000, 28800000],
|
|
11022
11050
|
// W-mq9acoo800177bcb — bounded-concurrency for pre-dispatch validator.
|
|
11023
11051
|
// 1 floor (sequential fallback) and 20 ceiling (above this the LLM
|
|
11024
11052
|
// provider's per-second rate limits dominate; throughput gains taper).
|
|
@@ -14435,6 +14463,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
14435
14463
|
"img-src 'self' data:; " +
|
|
14436
14464
|
"connect-src 'self'"
|
|
14437
14465
|
);
|
|
14466
|
+
// The Slim UX "Queued work" tile (W-mqrdggys000f94a4) embeds this classic SPA
|
|
14467
|
+
// at /work?embed=1 in a SAME-ORIGIN iframe. buildSecurityHeaders() stamped
|
|
14468
|
+
// X-Frame-Options: DENY on every response above, which makes the browser
|
|
14469
|
+
// refuse to render the iframe ("localhost refused to connect"). Relax to
|
|
14470
|
+
// SAMEORIGIN for the chrome-off embed request only — the standalone classic
|
|
14471
|
+
// dashboard keeps DENY. Mirrors serveSlimUx's embeddable relaxation for /slim.
|
|
14472
|
+
if (/[?&]embed=1(?:&|$)/.test(req.url)) {
|
|
14473
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
14474
|
+
}
|
|
14438
14475
|
if (req.headers['if-none-match'] === HTML_ETAG) {
|
|
14439
14476
|
res.statusCode = 304;
|
|
14440
14477
|
res.end();
|
package/docs/command-center.md
CHANGED
|
@@ -31,6 +31,8 @@ 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
|
+
**Session-reset notices (`sessionReset` / `sessionResetReason`).** The streaming `done` event can carry `sessionReset: true` with a `sessionResetReason` of `promptChanged` (Minions updated its CC prompt template), `runtimeChanged` (CC runtime switched), or `idleReap` (W-mr0qs0vw — the persistent `copilot --acp` worker was killed by the idle reaper after `engine.ccWorkerIdleTimeoutMs` of inactivity, so the next message cold-spawns a fresh session with no memory). Both classic (`dashboard/js/command-center.js`) and slim (`dashboard/slim/js/command-send.js` + `chat.js#appendSystemNotice`) render a centered, muted system notice; for `idleReap` the text is "Context window cleared after inactivity — fresh session started." The idle-reap flag is a one-shot drained per turn via `ccWorkerPool.consumeIdleReapNotice(tabId)`.
|
|
35
|
+
|
|
34
36
|
## Image attachments (PL-cc-image-attachments)
|
|
35
37
|
|
|
36
38
|
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.)
|
package/engine/cc-worker-pool.js
CHANGED
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
* - systemPromptHash change → keep proc, send a fresh `session/new`
|
|
37
37
|
* - model / effort change → no eviction (state updated in place)
|
|
38
38
|
* - closeTab → `session/cancel` if inflight, then kill proc
|
|
39
|
-
* - Idle >
|
|
39
|
+
* - Idle > timeout → kill proc (reaper sweep every 60 s; window is
|
|
40
|
+
* ENGINE_DEFAULTS.ccWorkerIdleTimeoutMs, default
|
|
41
|
+
* 30 min, pushed in via setIdleTimeoutMs())
|
|
40
42
|
*
|
|
41
43
|
* Auth precheck: when `copilot --acp` exits before the initialize handshake
|
|
42
44
|
* completes, surface
|
|
@@ -93,8 +95,14 @@ function _typedError(message, code, retriable = true) {
|
|
|
93
95
|
return err;
|
|
94
96
|
}
|
|
95
97
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
+
// 30 minutes default — matches ENGINE_DEFAULTS.ccWorkerIdleTimeoutMs. This is
|
|
99
|
+
// now a runtime-configurable knob (W-mr0qs0vw): the pool stays dependency-free
|
|
100
|
+
// at module load (no static `require('./shared')`), so the dashboard pushes the
|
|
101
|
+
// configured value in via `setIdleTimeoutMs()` on every reloadConfig(). Shorter
|
|
102
|
+
// = less idle memory/process footprint; longer = more context durability across
|
|
103
|
+
// gaps between messages.
|
|
104
|
+
const DEFAULT_IDLE_REAPER_MS = 30 * 60 * 1000;
|
|
105
|
+
let _idleReaperMs = DEFAULT_IDLE_REAPER_MS;
|
|
98
106
|
// Reaper sweep cadence. Not exposed as ENGINE_DEFAULTS to keep the pool
|
|
99
107
|
// dependency-free; sub-task C/D can plumb a config knob if needed.
|
|
100
108
|
const REAPER_INTERVAL_MS = 60 * 1000;
|
|
@@ -152,6 +160,40 @@ const _internals = {
|
|
|
152
160
|
const _tabs = new Map();
|
|
153
161
|
let _reaperTimer = null;
|
|
154
162
|
|
|
163
|
+
// W-mr0qs0vw — idle-reap notice tracking. When `_reapIdleTabs` kills a warm
|
|
164
|
+
// worker for inactivity it records the tabId here (tabId → reapedAt epoch ms).
|
|
165
|
+
// The dashboard's per-turn session-reset detection drains this via
|
|
166
|
+
// `consumeIdleReapNotice(tabId)` so the next message after a reap is classified
|
|
167
|
+
// `sessionResetReason: 'idleReap'` and CC surfaces a "context cleared" notice
|
|
168
|
+
// exactly once. Cleared on explicit closeTab so a deliberate close never
|
|
169
|
+
// masquerades as an idle reap. Survives the subsequent cold-spawn (the user
|
|
170
|
+
// still lost context) — only the consume call or a closeTab clears it.
|
|
171
|
+
const _reapedTabs = new Map();
|
|
172
|
+
|
|
173
|
+
// Update the idle-reaper window at runtime. Called by dashboard.js on every
|
|
174
|
+
// reloadConfig() with `engine.ccWorkerIdleTimeoutMs`. Ignores non-finite /
|
|
175
|
+
// non-positive values so a config typo can't disable reaping or set a negative
|
|
176
|
+
// window; the floor of 1000ms keeps a misconfigured tiny value from thrashing.
|
|
177
|
+
function setIdleTimeoutMs(ms) {
|
|
178
|
+
const n = Number(ms);
|
|
179
|
+
if (!Number.isFinite(n) || n <= 0) return _idleReaperMs;
|
|
180
|
+
_idleReaperMs = Math.max(1000, n);
|
|
181
|
+
return _idleReaperMs;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getIdleTimeoutMs() {
|
|
185
|
+
return _idleReaperMs;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Returns true (and clears the notice) if `tabId`'s warm worker was killed by
|
|
189
|
+
// the idle reaper since the last consume. One-shot: the dashboard calls this
|
|
190
|
+
// once per turn while resolving session-reset state.
|
|
191
|
+
function consumeIdleReapNotice(tabId) {
|
|
192
|
+
if (!_reapedTabs.has(tabId)) return false;
|
|
193
|
+
_reapedTabs.delete(tabId);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
|
|
155
197
|
// CC_POOL_TRACE-gated structured trace logger. Off by default; enable via
|
|
156
198
|
// `CC_POOL_TRACE=1 minions restart` to dump every getSession lifecycle
|
|
157
199
|
// transition, stream sessionId capture, and session/update notification
|
|
@@ -759,6 +801,10 @@ async function getSession({ tabId, model, effort, mcpServers, systemPromptHash,
|
|
|
759
801
|
}
|
|
760
802
|
|
|
761
803
|
function closeTab(tabId) {
|
|
804
|
+
// An explicit close is not an idle reap — drop any pending reap notice so the
|
|
805
|
+
// next turn doesn't spuriously show the "context cleared after inactivity"
|
|
806
|
+
// banner (W-mr0qs0vw).
|
|
807
|
+
_reapedTabs.delete(tabId);
|
|
762
808
|
const worker = _tabs.get(tabId);
|
|
763
809
|
if (!worker) return;
|
|
764
810
|
_tabs.delete(tabId);
|
|
@@ -816,6 +862,7 @@ function shutdown() {
|
|
|
816
862
|
try { worker.close(); } catch { /* swallow */ }
|
|
817
863
|
}
|
|
818
864
|
_tabs.clear();
|
|
865
|
+
_reapedTabs.clear();
|
|
819
866
|
if (_reaperTimer) {
|
|
820
867
|
clearInterval(_reaperTimer);
|
|
821
868
|
_reaperTimer = null;
|
|
@@ -832,8 +879,11 @@ function _reapIdleTabs() {
|
|
|
832
879
|
const now = _internals.now();
|
|
833
880
|
for (const [tabId, worker] of [..._tabs]) {
|
|
834
881
|
if (worker.inflight) continue;
|
|
835
|
-
if (now - worker.lastUsedAt >
|
|
882
|
+
if (now - worker.lastUsedAt > _idleReaperMs) {
|
|
836
883
|
_tabs.delete(tabId);
|
|
884
|
+
// Record the reap so the next turn on this tab can surface a
|
|
885
|
+
// "context cleared after inactivity" notice (W-mr0qs0vw).
|
|
886
|
+
_reapedTabs.set(tabId, now);
|
|
837
887
|
try { worker.close(); } catch { /* already torn down */ }
|
|
838
888
|
}
|
|
839
889
|
}
|
|
@@ -845,12 +895,17 @@ module.exports = {
|
|
|
845
895
|
closeTab,
|
|
846
896
|
cancelInflight,
|
|
847
897
|
shutdown,
|
|
898
|
+
// W-mr0qs0vw — configurable idle-reaper window + idle-reap notice plumbing.
|
|
899
|
+
setIdleTimeoutMs,
|
|
900
|
+
getIdleTimeoutMs,
|
|
901
|
+
consumeIdleReapNotice,
|
|
848
902
|
// Exposed for unit tests; engine code MUST go through the public API.
|
|
849
903
|
_internals,
|
|
850
904
|
_tabs,
|
|
905
|
+
_reapedTabs,
|
|
851
906
|
_reapIdleTabs,
|
|
852
907
|
_buildSessionNewParams,
|
|
853
|
-
|
|
908
|
+
DEFAULT_IDLE_REAPER_MS,
|
|
854
909
|
REAPER_INTERVAL_MS,
|
|
855
910
|
WARM_MAX_CONCURRENT,
|
|
856
911
|
// W-mpmwxni2000c25c7-c — typed-error envelope contract. Exported so the
|
package/engine/live-checkout.js
CHANGED
|
@@ -132,6 +132,62 @@ function _isPartialCloneBlobError(message = '') {
|
|
|
132
132
|
);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// W-mqva907q — auto-clean safe untracked build artifacts before a live-checkout
|
|
136
|
+
// dirty bail. A `git status --porcelain` line for a stray `__pycache__/` (or
|
|
137
|
+
// other regenerable cache) should NOT block dispatch the way a real edit does.
|
|
138
|
+
// These patterns describe untracked-only paths that are always safe to delete
|
|
139
|
+
// because the build/test toolchain regenerates them. Matching is intentionally
|
|
140
|
+
// implemented with plain string checks (no glob dep — Minions ships zero runtime
|
|
141
|
+
// deps). dist/ and build/ are deliberately EXCLUDED: they can hold real,
|
|
142
|
+
// non-regenerable output, so auto-deleting them would be unsafe.
|
|
143
|
+
const LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS = Object.freeze([
|
|
144
|
+
'**/__pycache__/',
|
|
145
|
+
'**/*.pyc',
|
|
146
|
+
'**/*.pyo',
|
|
147
|
+
'**/.pytest_cache/',
|
|
148
|
+
'**/node_modules/.cache/',
|
|
149
|
+
'**/*.egg-info/',
|
|
150
|
+
'.tox/',
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
// Returns true when a single untracked path (relative, git-porcelain form) is a
|
|
154
|
+
// known regenerable build artifact safe to delete. Normalizes separators and a
|
|
155
|
+
// trailing slash, and conservatively unquotes git's C-style quoting for paths
|
|
156
|
+
// with special chars. Directory artifacts match on their final path segment so
|
|
157
|
+
// nested copies (e.g. a/b/__pycache__/) are covered like the `**/` globs above.
|
|
158
|
+
function _isAutoCleanableArtifact(rawPath) {
|
|
159
|
+
if (!rawPath || typeof rawPath !== 'string') return false;
|
|
160
|
+
let p = rawPath.trim();
|
|
161
|
+
if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) {
|
|
162
|
+
try { p = JSON.parse(p); } catch { /* keep raw on parse failure */ }
|
|
163
|
+
}
|
|
164
|
+
p = p.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
165
|
+
if (!p) return false;
|
|
166
|
+
if (p.endsWith('.pyc') || p.endsWith('.pyo')) return true;
|
|
167
|
+
if (p.endsWith('.egg-info')) return true;
|
|
168
|
+
if (p === 'node_modules/.cache' || p.endsWith('/node_modules/.cache')) return true;
|
|
169
|
+
const seg = p.split('/').pop();
|
|
170
|
+
if (seg === '__pycache__' || seg === '.pytest_cache' || seg === '.tox') return true;
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Classifies the porcelain dirty lines for auto-clean. Returns the list of
|
|
175
|
+
// untracked artifact paths ONLY when EVERY dirty entry is an untracked (`?? `)
|
|
176
|
+
// line matching a safe artifact pattern; returns null otherwise (a tracked
|
|
177
|
+
// modification, a staged/deleted change, or any untracked path that is NOT a
|
|
178
|
+
// known artifact). Null means "do not auto-clean — fail dirty as before", which
|
|
179
|
+
// enforces the all-or-nothing contract: partial dirt is never silently cleaned.
|
|
180
|
+
function _collectAutoCleanablePaths(dirtyFiles) {
|
|
181
|
+
const cleanable = [];
|
|
182
|
+
for (const line of dirtyFiles) {
|
|
183
|
+
if (!line.startsWith('?? ')) return null;
|
|
184
|
+
const p = line.slice(3);
|
|
185
|
+
if (!_isAutoCleanableArtifact(p)) return null;
|
|
186
|
+
cleanable.push(p);
|
|
187
|
+
}
|
|
188
|
+
return cleanable.length > 0 ? cleanable : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
135
191
|
// W-mqvejug6000eeb20 — production default for resolving the live-checkout
|
|
136
192
|
// auto-reset decision when the caller (engine.js spawnAgent) does not pass an
|
|
137
193
|
// explicit `autoReset` boolean. engine.js is intentionally not threading the
|
|
@@ -178,6 +234,7 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
178
234
|
// defers to `_resolveAutoReset`.
|
|
179
235
|
_git, // private injection for testing — defaults to shared.shellSafeGit
|
|
180
236
|
_exists, // private injection for testing — defaults to fs.existsSync
|
|
237
|
+
_rm, // private injection for testing — defaults to fs.rmSync (recursive, force)
|
|
181
238
|
_resolveAutoReset, // private injection for testing — defaults to config-based resolver
|
|
182
239
|
_writeInboxNote, // private injection for testing — defaults to dispatch.writeInboxAlert
|
|
183
240
|
} = opts;
|
|
@@ -206,6 +263,8 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
206
263
|
|
|
207
264
|
const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
|
|
208
265
|
const exists = (typeof _exists === 'function') ? _exists : fs.existsSync;
|
|
266
|
+
const rm = (typeof _rm === 'function') ? _rm : ((absPath) => fs.rmSync(absPath, { recursive: true, force: true }));
|
|
267
|
+
const logFn = (typeof log === 'function') ? log : () => {};
|
|
209
268
|
// W-mqvejug6000eeb20 — default maxBuffer (50 MB) so a large `git status
|
|
210
269
|
// --porcelain` never overflows execFile's 1 MB ceiling. A caller-supplied
|
|
211
270
|
// gitOpts.maxBuffer still wins (spread after the default).
|
|
@@ -230,91 +289,114 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
230
289
|
// before the dirtyFiles check so callers receive it as `branchInfo`.
|
|
231
290
|
if (!skipDirtyCheck) {
|
|
232
291
|
const statusRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
292
|
+
const parsePorcelain = (raw) => {
|
|
293
|
+
const lines = (typeof raw === 'string' ? raw : '')
|
|
294
|
+
.split(/\r?\n/)
|
|
295
|
+
.map((line) => line.replace(/\s+$/, ''))
|
|
296
|
+
.filter((line) => line.length > 0);
|
|
297
|
+
return {
|
|
298
|
+
branchInfo: lines.find((line) => line.startsWith('## ')) || '',
|
|
299
|
+
dirtyFiles: lines.filter((line) => !line.startsWith('## ')),
|
|
300
|
+
};
|
|
301
|
+
};
|
|
302
|
+
let { branchInfo, dirtyFiles } = parsePorcelain(statusRaw);
|
|
240
303
|
if (dirtyFiles.length > 0) {
|
|
241
|
-
// W-
|
|
242
|
-
//
|
|
243
|
-
// and
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
304
|
+
// W-mqva907q — auto-clean safe untracked build artifacts BEFORE bailing. This
|
|
305
|
+
// runs unconditionally (no config flag — deleting regenerable caches is always
|
|
306
|
+
// safe) and BEFORE the spawnAgent auto-stash path. Only fires when EVERY dirty
|
|
307
|
+
// entry is an untracked artifact (all-or-nothing); a single tracked edit or
|
|
308
|
+
// non-artifact untracked file means zero cleaning and the original dirty bail.
|
|
309
|
+
const cleanablePaths = _collectAutoCleanablePaths(dirtyFiles);
|
|
310
|
+
if (cleanablePaths) {
|
|
311
|
+
for (const rel of cleanablePaths) {
|
|
312
|
+
const abs = path.isAbsolute(rel) ? rel : path.join(localPath, rel);
|
|
313
|
+
rm(abs);
|
|
314
|
+
}
|
|
315
|
+
logFn(`[live-checkout] auto-cleaned ${cleanablePaths.length} artifact path(s) before dirty check: ${cleanablePaths.join(', ')}`, 'debug');
|
|
316
|
+
// Re-run the dirty check. If cleaning fully resolved the tree we fall
|
|
317
|
+
// through and proceed; if anything remains (e.g. a race re-created a file)
|
|
318
|
+
// we still bail dirty with the post-clean snapshot.
|
|
319
|
+
const reRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
|
|
320
|
+
({ branchInfo, dirtyFiles } = parsePorcelain(reRaw));
|
|
258
321
|
}
|
|
322
|
+
if (dirtyFiles.length > 0) {
|
|
323
|
+
// W-mqvejug6000eeb20 — opt-in auto-reset. Default behavior is to bail with
|
|
324
|
+
// reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
|
|
325
|
+
// and alerts the operator). When auto-reset is enabled — either the caller
|
|
326
|
+
// passed an explicit `autoReset` boolean, or the config-based resolver says
|
|
327
|
+
// so — we DISCARD the dirty state via `git fetch origin` + `git reset --hard
|
|
328
|
+
// origin/<branch>` and continue. This is destructive (the operator’s
|
|
329
|
+
// uncommitted work is gone), which is why it is strictly opt-in.
|
|
330
|
+
let wantAutoReset = false;
|
|
331
|
+
if (typeof autoReset === 'boolean') {
|
|
332
|
+
wantAutoReset = autoReset;
|
|
333
|
+
} else {
|
|
334
|
+
const resolver = (typeof _resolveAutoReset === 'function') ? _resolveAutoReset : _defaultResolveAutoReset;
|
|
335
|
+
try { wantAutoReset = !!resolver(localPath); } catch { wantAutoReset = false; }
|
|
336
|
+
}
|
|
337
|
+
if (!wantAutoReset) {
|
|
338
|
+
return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
|
|
339
|
+
}
|
|
259
340
|
|
|
260
|
-
if (typeof log === 'function') {
|
|
261
|
-
log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
|
|
262
|
-
}
|
|
263
|
-
let resetOk = true;
|
|
264
|
-
try {
|
|
265
|
-
await git(['fetch', 'origin'], baseOpts);
|
|
266
|
-
await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
|
|
267
|
-
} catch (e) {
|
|
268
|
-
resetOk = false;
|
|
269
341
|
if (typeof log === 'function') {
|
|
270
|
-
log(`live-checkout auto-reset
|
|
342
|
+
log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
|
|
271
343
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
// Re-run the porcelain preflight once. If the tree is now clean we proceed;
|
|
275
|
-
// otherwise (reset failed, or something is still dirty) we fall back to the
|
|
276
|
-
// safe dirty refusal so we never dispatch onto an unexpected tree.
|
|
277
|
-
let stillDirty = dirtyFiles;
|
|
278
|
-
if (resetOk) {
|
|
344
|
+
let resetOk = true;
|
|
279
345
|
try {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
stillDirty = recheckStr
|
|
283
|
-
.split(/\r?\n/)
|
|
284
|
-
.map((line) => line.replace(/\s+$/, ''))
|
|
285
|
-
.filter((line) => line.length > 0 && !line.startsWith('## '));
|
|
346
|
+
await git(['fetch', 'origin'], baseOpts);
|
|
347
|
+
await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
|
|
286
348
|
} catch (e) {
|
|
287
349
|
resetOk = false;
|
|
288
350
|
if (typeof log === 'function') {
|
|
289
|
-
log(`live-checkout auto-reset
|
|
351
|
+
log(`live-checkout auto-reset FAILED (fetch/reset): ${e && e.message ? e.message : e}`);
|
|
290
352
|
}
|
|
291
353
|
}
|
|
292
|
-
}
|
|
293
354
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
355
|
+
// Re-run the porcelain preflight once. If the tree is now clean we proceed;
|
|
356
|
+
// otherwise (reset failed, or something is still dirty) we fall back to the
|
|
357
|
+
// safe dirty refusal so we never dispatch onto an unexpected tree.
|
|
358
|
+
let stillDirty = dirtyFiles;
|
|
359
|
+
if (resetOk) {
|
|
360
|
+
try {
|
|
361
|
+
const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
|
|
362
|
+
const recheckStr = typeof recheckRaw === 'string' ? recheckRaw : '';
|
|
363
|
+
stillDirty = recheckStr
|
|
364
|
+
.split(/\r?\n/)
|
|
365
|
+
.map((line) => line.replace(/\s+$/, ''))
|
|
366
|
+
.filter((line) => line.length > 0 && !line.startsWith('## '));
|
|
367
|
+
} catch (e) {
|
|
368
|
+
resetOk = false;
|
|
369
|
+
if (typeof log === 'function') {
|
|
370
|
+
log(`live-checkout auto-reset re-check FAILED: ${e && e.message ? e.message : e}`);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
297
374
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
''
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
375
|
+
if (!resetOk || stillDirty.length > 0) {
|
|
376
|
+
return { ok: false, reason: 'dirty', dirtyFiles: stillDirty, branchInfo };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Audit trail: record the discarded paths so the operator can recover from
|
|
380
|
+
// reflog / understand why their tree changed. Best-effort, never throws.
|
|
381
|
+
try {
|
|
382
|
+
const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
|
|
383
|
+
const slug = `live-checkout-autoreset-${wiId || dispatchId || 'unknown'}`;
|
|
384
|
+
const body = [
|
|
385
|
+
`# Live-checkout auto-reset on '${branchName}'`,
|
|
386
|
+
'',
|
|
387
|
+
`⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
|
|
388
|
+
'`liveCheckoutAutoReset` is enabled, so it was force-reset to',
|
|
389
|
+
`\`origin/${branchName}\`. **The following uncommitted changes were DISCARDED**`,
|
|
390
|
+
'(recover from `git reflog` / `git fsck --lost-found` if needed):',
|
|
391
|
+
'',
|
|
392
|
+
'```',
|
|
393
|
+
...dirtyFiles,
|
|
394
|
+
'```',
|
|
395
|
+
].join('\n');
|
|
396
|
+
writeNote(slug, body);
|
|
397
|
+
} catch { /* best-effort audit note */ }
|
|
398
|
+
// Fall through — tree is now clean, continue with normal preparation.
|
|
399
|
+
}
|
|
318
400
|
}
|
|
319
401
|
}
|
|
320
402
|
|
|
@@ -817,4 +899,12 @@ async function maybeRestoreLiveCheckoutFromRecord(opts = {}) {
|
|
|
817
899
|
}
|
|
818
900
|
}
|
|
819
901
|
|
|
820
|
-
module.exports = {
|
|
902
|
+
module.exports = {
|
|
903
|
+
prepareLiveCheckout,
|
|
904
|
+
restoreLiveCheckoutAtDispatchEnd,
|
|
905
|
+
maybeRestoreLiveCheckoutFromRecord,
|
|
906
|
+
_isPartialCloneBlobError,
|
|
907
|
+
_isAutoCleanableArtifact,
|
|
908
|
+
_collectAutoCleanablePaths,
|
|
909
|
+
LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS,
|
|
910
|
+
};
|
package/engine/shared.js
CHANGED
|
@@ -3261,6 +3261,7 @@ const ENGINE_DEFAULTS = {
|
|
|
3261
3261
|
liveCheckoutAutoReset: false,
|
|
3262
3262
|
orphanHolderScanTimeoutMs: 5000, // 5s ceiling for the cross-platform holder scan (PowerShell / /proc walk / lsof)
|
|
3263
3263
|
ccMaxTurns: 50, // max tool-use turns per CC/doc-chat call before CLI stops (per response, not per session)
|
|
3264
|
+
ccWorkerIdleTimeoutMs: 30 * 60 * 1000, // W-mr0qs0vw: idle-reaper window for the persistent `copilot --acp` worker pool (engine/cc-worker-pool.js). After this much inactivity with no in-flight turn the warm ACP process is killed; the next message cold-spawns a fresh session with NO memory of prior turns (CC shows a "context cleared after inactivity" notice). Tradeoff: shorter = less idle memory/process footprint, longer = more context durability across gaps between messages. Wired into the pool via ccWorkerPool.setIdleTimeoutMs() on every reloadConfig(); clamped to [60000, 28800000] (1min–8h) in the settings POST handler.
|
|
3264
3265
|
ccTurnTimeoutMs: 300000, // W-mpmwxni2000c25c7-b/-d: 5min per-turn no-progress watchdog. The window resets on every liveness signal — token chunk, tool-call notification, tool-update — so an actively-streaming CC/doc-chat turn (long shell command, deep search, sub-agent loop) survives indefinitely up to the outer CC_CALL_TIMEOUT_MS (~1h) ceiling. Only true silence past this window with no progress fires the cancel: the in-flight LLM call is aborted and the handler surfaces `{code:'cc-turn-timeout', retryable:true}` via the typed error envelope so the UI can stop the spinner and offer Retry. Clamped to [10000, 3600000] in the settings POST handler. Independent of CC_CALL_TIMEOUT_MS. Non-streaming doc-chat is the lone wall-clock exception (no progress hooks); see _raceCcDocChatTimeout in dashboard.js for the dual factory/promise shape.
|
|
3265
3266
|
docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity (LRU; sessions are non-expiring otherwise)
|
|
3266
3267
|
ccLiveStreamMaxAgeMs: 30 * 60 * 1000, // hard cap reconnect buffers if abort/cleanup stalls
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2305",
|
|
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"
|