@yemi33/minions 0.1.2436 → 0.1.2437

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.
@@ -1300,6 +1300,14 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
1300
1300
  var pendingEventName = '';
1301
1301
 
1302
1302
  async function _handleEvent(evt) {
1303
+ // W-ms4z164i01fo438d — terminal frames are idempotent per connection.
1304
+ // A failed turn used to put two `event: error` frames on the wire and
1305
+ // render two identical error bubbles; the backend now emits one, and
1306
+ // this guard keeps the UI correct against any future double-emit or a
1307
+ // terminal frame that arrives twice. `terminalEventSeen` is scoped to
1308
+ // one _ccConsumeStream call, so a reconnect that replays donePayload
1309
+ // still renders it exactly once on the new connection.
1310
+ if (terminalEventSeen && evt && (evt.type === 'done' || evt.type === 'error')) return;
1303
1311
  // First event after a reconnect: the server replays the full accumulated
1304
1312
  // snapshot (all prior tools, then the whole text as one chunk — see the
1305
1313
  // reconnect branch in dashboard.js). Swap the pre-reconnect segments out
@@ -10,6 +10,54 @@ const { safeRead } = shared;
10
10
  const MINIONS_DIR = __dirname;
11
11
  const DASHBOARD_SHARED_JS = ['pr-merge-state', 'pr-filters', 'cc-suggestions', 'model-display', 'watches-source', 'project-git-summary', 'welcome-popup'];
12
12
 
13
+ // ── Canonical classic-dashboard assembly manifest ──────────────────────────
14
+ // Single source of truth, consumed by BOTH assemblers: buildDashboardHtml()
15
+ // below and dashboard.js#buildDashboardHtml() (the one the real server ships).
16
+ // These used to be duplicated array literals inside each function; they drifted
17
+ // (dashboard.js lost 'memory-panel' and never gained sub-fragment substitution
18
+ // at all), which silently shipped an /engine page with no Memory panel and no
19
+ // working "Capture heap snapshot" button. Keep them here and import them —
20
+ // never re-declare a local copy. Pinned by test/unit/dashboard-assembly-parity.test.js.
21
+ const DASHBOARD_PAGES = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'qa', 'engine'];
22
+
23
+ // Sub-fragments substituted into a parent page at assembly time via
24
+ // <!-- __MARKER__ --> tokens. Lets large panels live in their own
25
+ // fragment file without inflating the parent page. P-d4e5f6a7 introduced
26
+ // engine-memory-panel.html as the first such sub-fragment; add more here
27
+ // by mapping page -> { marker -> fragment basename }.
28
+ const DASHBOARD_PAGE_SUB_FRAGMENTS = {
29
+ engine: { '<!-- __ENGINE_MEMORY_PANEL__ -->': 'engine-memory-panel' },
30
+ };
31
+
32
+ // JS module bundle (order matters: utils → state → renderers → commands → refresh).
33
+ // Every dashboard/js/*.js file belongs here unless it is lazy-loaded through a
34
+ // dedicated route (only memory-search.js today, served at /assets/memory-search.js).
35
+ const DASHBOARD_JS_FILES = [
36
+ 'utils', 'state', 'features-client', 'render-utils', 'charter-editor', 'detail-panel', 'live-stream',
37
+ 'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
38
+ 'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
39
+ 'render-other', 'render-managed', 'memory-panel', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
40
+ 'command-parser', 'command-input', 'command-center', 'command-history',
41
+ 'confirm-dialog', 'modal', 'modal-qa', 'settings', 'qa', 'fre', 'refresh',
42
+ ];
43
+
44
+ /**
45
+ * Read a page fragment and substitute any sub-fragment markers declared for it.
46
+ * Shared by both assemblers so a sub-fragment can never be wired into one only.
47
+ */
48
+ function readPageFragment(dashDir, page) {
49
+ let content = safeRead(path.join(dashDir, 'pages', page + '.html'));
50
+ const subs = DASHBOARD_PAGE_SUB_FRAGMENTS[page];
51
+ if (subs) {
52
+ for (const [marker, basename] of Object.entries(subs)) {
53
+ const fragment = safeRead(path.join(dashDir, 'pages', basename + '.html'));
54
+ // Function replacer: fragment text may contain `$&`/`$1` patterns.
55
+ content = content.replace(marker, () => fragment);
56
+ }
57
+ }
58
+ return content;
59
+ }
60
+
13
61
  function buildDashboardHtml() {
14
62
  const dashDir = path.join(MINIONS_DIR, 'dashboard');
15
63
  const layoutPath = path.join(dashDir, 'layout.html');
@@ -21,37 +69,15 @@ function buildDashboardHtml() {
21
69
  const layout = safeRead(layoutPath);
22
70
  const css = safeRead(path.join(dashDir, 'styles.css'));
23
71
 
24
- const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'qa', 'engine'];
25
- // Sub-fragments substituted into a parent page at assembly time via
26
- // <!-- __MARKER__ --> tokens. Lets large panels live in their own
27
- // fragment file without inflating the parent page. P-d4e5f6a7 introduced
28
- // engine-memory-panel.html as the first such sub-fragment; add more here
29
- // by mapping marker -> fragment basename.
30
- const pageSubFragments = {
31
- engine: { '<!-- __ENGINE_MEMORY_PANEL__ -->': 'engine-memory-panel' },
32
- };
72
+ const pages = DASHBOARD_PAGES;
33
73
  let pageHtml = '';
34
74
  for (const p of pages) {
35
- let content = safeRead(path.join(dashDir, 'pages', p + '.html'));
36
- const subs = pageSubFragments[p];
37
- if (subs) {
38
- for (const [marker, basename] of Object.entries(subs)) {
39
- const fragment = safeRead(path.join(dashDir, 'pages', basename + '.html'));
40
- content = content.replace(marker, () => fragment);
41
- }
42
- }
75
+ const content = readPageFragment(dashDir, p);
43
76
  const activeClass = p === 'home' ? ' active' : '';
44
77
  pageHtml += ` <div class="page${activeClass}" id="page-${p}">\n${content}\n </div>\n\n`;
45
78
  }
46
79
 
47
- const jsFiles = [
48
- 'utils', 'state', 'features-client', 'render-utils', 'charter-editor', 'detail-panel', 'live-stream',
49
- 'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
50
- 'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
51
- 'render-other', 'render-managed', 'memory-panel', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
52
- 'command-parser', 'command-input', 'command-center', 'command-history',
53
- 'confirm-dialog', 'modal', 'modal-qa', 'settings', 'qa', 'fre', 'refresh'
54
- ];
80
+ const jsFiles = DASHBOARD_JS_FILES;
55
81
  let jsHtml = '';
56
82
  for (const f of DASHBOARD_SHARED_JS) {
57
83
  const content = safeRead(path.join(dashDir, 'shared', f + '.js'));
@@ -175,6 +201,10 @@ module.exports = {
175
201
  buildDashboardHtml,
176
202
  buildSlimHtml,
177
203
  DASHBOARD_SHARED_JS,
204
+ DASHBOARD_PAGES,
205
+ DASHBOARD_PAGE_SUB_FRAGMENTS,
206
+ DASHBOARD_JS_FILES,
207
+ readPageFragment,
178
208
  SLIM_JS_ORDER,
179
209
  _resetSlimPartsCacheForTest,
180
210
  };
package/dashboard.js CHANGED
@@ -68,7 +68,12 @@ const prFixTarget = require('./engine/pr-fix-target');
68
68
  const prTrack = require('./engine/pr-track');
69
69
  const resolveArea = require('./engine/resolve-area');
70
70
  const features = require('./engine/features');
71
- const { DASHBOARD_SHARED_JS } = require('./dashboard-build');
71
+ const {
72
+ DASHBOARD_SHARED_JS,
73
+ DASHBOARD_PAGES,
74
+ DASHBOARD_JS_FILES,
75
+ readPageFragment,
76
+ } = require('./dashboard-build');
72
77
  const { normalizeExecutionModel } = require('./engine/execution-model');
73
78
  const ccWorkerPool = require('./engine/cc-worker-pool');
74
79
  const diagnosticsMemory = require('./engine/diagnostics-memory');
@@ -1920,30 +1925,23 @@ function buildDashboardHtml() {
1920
1925
  // Assemble page fragments. Each wrapper gets a `.page-toast` inline slot at
1921
1926
  // the top — showToast('cmd-toast', …) auto-routes here when a page is active,
1922
1927
  // so feedback lands near the action instead of the floating top-right toast.
1923
- const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'qa', 'engine'];
1928
+ // Page list, sub-fragment map and fragment reader all come from
1929
+ // dashboard-build.js so this assembler cannot drift from the standalone one.
1924
1930
  const pageToast = ' <div class="cmd-toast cmd-toast-inline page-toast" style="margin:6px 16px"></div>\n';
1925
1931
  let pageHtml = '';
1926
- for (const p of pages) {
1927
- const content = safeRead(path.join(dashDir, 'pages', p + '.html'));
1932
+ for (const p of DASHBOARD_PAGES) {
1933
+ const content = readPageFragment(dashDir, p);
1928
1934
  const activeClass = p === 'home' ? ' active' : '';
1929
1935
  pageHtml += ` <div class="page${activeClass}" id="page-${p}">\n${pageToast}${content}\n </div>\n\n`;
1930
1936
  }
1931
1937
 
1932
1938
  // Assemble JS modules (order matters: utils → state → renderers → commands → refresh)
1933
- const jsFiles = [
1934
- 'utils', 'state', 'features-client', 'render-utils', 'charter-editor', 'detail-panel', 'live-stream',
1935
- 'render-agents', 'render-dispatch', 'render-work-items', 'render-prd',
1936
- 'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
1937
- 'render-other', 'render-managed', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
1938
- 'command-parser', 'command-input', 'command-center', 'command-history',
1939
- 'confirm-dialog', 'modal', 'modal-qa', 'settings', 'qa', 'fre', 'refresh'
1940
- ];
1941
1939
  let jsHtml = '';
1942
1940
  for (const f of DASHBOARD_SHARED_JS) {
1943
1941
  const content = safeRead(path.join(dashDir, 'shared', f + '.js'));
1944
1942
  jsHtml += `\n// ─── shared/${f}.js ────────────────────────────────────────\n${content}\n`;
1945
1943
  }
1946
- for (const f of jsFiles) {
1944
+ for (const f of DASHBOARD_JS_FILES) {
1947
1945
  const content = safeRead(path.join(dashDir, 'js', f + '.js'));
1948
1946
  jsHtml += `\n// ─── ${f}.js ────────────────────────────────────────\n${content}\n`;
1949
1947
  }
@@ -3800,6 +3798,49 @@ const CC_ERROR_CODES = Object.freeze([
3800
3798
  // surfaces this typed code via _buildCcErrorEnvelope, never a 500.
3801
3799
  'invalid-image',
3802
3800
  ]);
3801
+ // W-ms4z164i01fo438d — a `done` or `error` frame is the LAST thing a CC or
3802
+ // doc-chat SSE consumer will ever see, so neither writer may shed one under
3803
+ // backpressure. Shared by writeCcEvent and writeDocEvent so the rule lives in
3804
+ // one place instead of being restated per stream.
3805
+ function _isTerminalSseFrameType(type) {
3806
+ return type === 'done' || type === 'error';
3807
+ }
3808
+
3809
+ // W-mpmwxni2000c25c7-d / W-ms4z164i01fo438d — render exactly ONE SSE frame.
3810
+ // Terminal error frames get a named `event: error` line so consumers using
3811
+ // addEventListener('error', …) (and tests matching the raw wire) can target
3812
+ // them; the JSON payload still carries `type: 'error'` so data-line-only
3813
+ // parsers keep working. Shared by writeCcEvent and writeDocEvent.
3814
+ //
3815
+ // One call == one frame. A caller that emits a terminal error through a writer
3816
+ // AND raw-writes its own `event: error` frame puts two frames on the wire, and
3817
+ // the CC client backfills `type` from the `event:` line — so both render as
3818
+ // error bubbles. That was the duplicate-error-bubble bug; keep this the single
3819
+ // wire-rendering seam.
3820
+ function _sseFrame(payload) {
3821
+ const eventLine = (payload && payload.type === 'error') ? 'event: error\n' : '';
3822
+ return eventLine + 'data: ' + JSON.stringify(payload) + '\n\n';
3823
+ }
3824
+
3825
+ // W-ms4z164i01fo438d — canonical terminal-error payload for a failed CC stream
3826
+ // turn. Stored on `liveState.donePayload` (so the reconnect branch replays the
3827
+ // exact same terminal frame to a reattaching client) and handed to the writer
3828
+ // once. `message` is the canonical envelope field; `error` is kept as a legacy
3829
+ // alias for clients predating the typed envelope. `stderr` carries the tail
3830
+ // that used to ride only on the now-removed raw frame.
3831
+ function _buildCcTerminalErrorPayload(envelope, stderrTail) {
3832
+ const message = (envelope && envelope.message) || 'Command Center reported an unknown error.';
3833
+ return {
3834
+ type: 'error',
3835
+ message,
3836
+ error: message,
3837
+ code: (envelope && envelope.code) || 'crash',
3838
+ retriable: !!(envelope && envelope.retriable),
3839
+ sessionId: null,
3840
+ ...(stderrTail ? { stderr: String(stderrTail).slice(0, 500) } : {}),
3841
+ };
3842
+ }
3843
+
3803
3844
  function _buildCcErrorEnvelope({ message, code, retryable, ...extra } = {}) {
3804
3845
  const normalizedCode = CC_ERROR_CODES.includes(code) ? code : 'crash';
3805
3846
  return {
@@ -10318,7 +10359,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10318
10359
  } catch { /* listener registration is best-effort */ }
10319
10360
  const writeDocEvent = (payload) => {
10320
10361
  const type = payload && payload.type;
10321
- const isTerminal = type === 'done' || type === 'error';
10362
+ const isTerminal = _isTerminalSseFrameType(type);
10322
10363
  const _logFail = (reason) => {
10323
10364
  try {
10324
10365
  shared.log('warn', `[doc-sse-fail] ${JSON.stringify({ doc: docKey || 'unknown', type, reason, destroyed: !!res.destroyed, writableEnded: !!res.writableEnded, streamEnded: _docStreamEnded })}`);
@@ -10330,14 +10371,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
10330
10371
  }
10331
10372
  let wire;
10332
10373
  try {
10333
- // W-mpmwxni2000c25c7-d / W-mqevl09s000i9989 — terminal error frames go
10334
- // out as a named SSE `event: error` frame so consumers using
10335
- // addEventListener('error', …) can target them, mirroring the
10336
- // handleCommandCenterStream contract. The JSON payload still carries
10337
- // `type: 'error'` for the data-line parser in modal-qa.js.
10338
- wire = (type === 'error')
10339
- ? `event: error\ndata: ${JSON.stringify(payload)}\n\n`
10340
- : `data: ${JSON.stringify(payload)}\n\n`;
10374
+ // W-mpmwxni2000c25c7-d / W-mqevl09s000i9989 — one frame per call via
10375
+ // the shared _sseFrame renderer (mirrors handleCommandCenterStream):
10376
+ // terminal errors go out as a named `event: error` frame, and the JSON
10377
+ // payload still carries `type: 'error'` for the data-line parser in
10378
+ // modal-qa.js.
10379
+ wire = _sseFrame(payload);
10341
10380
  } catch {
10342
10381
  _logFail('json-serialize-failed');
10343
10382
  return false;
@@ -11629,6 +11668,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11629
11668
  const writeCcEvent = (payload) => {
11630
11669
  const type = payload && payload.type;
11631
11670
  const isUserFacing = type === 'chunk' || type === 'done' || type === 'tool' || type === 'tool-update' || type === 'error';
11671
+ const isTerminal = _isTerminalSseFrameType(type);
11632
11672
  const _logFail = (reason, extra) => {
11633
11673
  if (!isUserFacing) return;
11634
11674
  try {
@@ -11651,14 +11691,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11651
11691
  }
11652
11692
  let wire;
11653
11693
  try {
11654
- // W-mpmwxni2000c25c7-d terminal error frames go out as `event: error`
11655
- // so SSE consumers using addEventListener('error', …) and tests
11656
- // matching the raw wire format can target them directly. The JSON
11657
- // payload still carries `type: 'error'` so the existing
11658
- // data-line parser (and any client code that only reads `data:`
11659
- // lines) keeps working.
11660
- const eventLine = (type === 'error') ? 'event: error\n' : '';
11661
- wire = eventLine + 'data: ' + JSON.stringify(payload) + '\n\n';
11694
+ // W-mpmwxni2000c25c7-d / W-ms4z164i01fo438d one frame per call via
11695
+ // the shared _sseFrame renderer. This is the ONLY place a CC terminal
11696
+ // error reaches the wire: callers must not also `res.write` their own
11697
+ // `event: error` frame, because the client backfills `type` from the
11698
+ // `event:` line and would render a second error bubble.
11699
+ wire = _sseFrame(payload);
11662
11700
  }
11663
11701
  catch (err) {
11664
11702
  _logFail('json-serialize-failed', { error: String((err && err.message) || err).slice(0, 200) });
@@ -11676,7 +11714,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
11676
11714
  // bump _ccTelemetry counters so the [cc-stream] outcome log line stays
11677
11715
  // truthful about what the orchestrator produced — only the wire was
11678
11716
  // shed, the work happened.
11679
- if (_queuedBytes > SSE_MAX_QUEUE_BYTES) {
11717
+ // W-ms4z164i01fo438d terminal `done` / `error` frames are NEVER shed
11718
+ // (mirrors writeDocEvent). The terminal error used to also go out via a
11719
+ // raw res.write that bypassed this cap entirely; now that the writer is
11720
+ // the single emitter, shedding it would drop the failure off the wire.
11721
+ if (!isTerminal && _queuedBytes > SSE_MAX_QUEUE_BYTES) {
11680
11722
  try {
11681
11723
  shared.log('warn', `[cc-sse-shed] tab=${tabId || _ccTelemetry.tabId || 'unknown'} type=${type} queuedBytes=${_queuedBytes} wireBytes=${wire.length}`);
11682
11724
  } catch { /* telemetry is best-effort */ }
@@ -12117,12 +12159,17 @@ What would you like to discuss or change? When you're happy, say "approve" and I
12117
12159
  trackErr('command-center', envelope.code);
12118
12160
  const stderrTail = (result.stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
12119
12161
  console.error(`[CC-stream] Failed code=${envelope.code} retriable=${envelope.retriable}: ${(result.stderr || '').slice(0, 500)}; stdout_tail=${(result.raw || '').slice(-500)}`);
12120
- // Emit `event: error` (named SSE frame), then a `done`-style frame
12121
- // for clients that only handle the default message channel, then
12122
- // close cleanly so the EventSource exits its read loop without
12123
- // throwing a connection-reset.
12124
- try { res.write(`event: error\ndata: ${JSON.stringify({ message: envelope.message, code: envelope.code, retriable: !!envelope.retriable, ...(stderrTail ? { stderr: stderrTail.slice(0, 500) } : {}) })}\n\n`); } catch {}
12125
- liveState.donePayload = { type: 'error', error: envelope.message, code: envelope.code, retriable: !!envelope.retriable, sessionId: null };
12162
+ // W-ms4z164i01fo438d emit exactly ONE terminal frame. writeCcEvent
12163
+ // prefixes `event: error\n` for `type: 'error'`, so this single call
12164
+ // satisfies both the named-SSE-frame contract and the `data:`-only
12165
+ // parser. This block previously ALSO did a raw
12166
+ // `res.write('event: error\ndata: …')` before calling the writer,
12167
+ // which put two `event: error` frames on the wire and rendered two
12168
+ // identical error bubbles (each with its own Retry / New Session
12169
+ // controls) in the Command Center. donePayload keeps carrying the
12170
+ // canonical envelope so the reconnect branch above replays the same
12171
+ // single terminal frame to a reattaching client.
12172
+ liveState.donePayload = _buildCcTerminalErrorPayload(envelope, stderrTail);
12126
12173
  if (liveState.writer) liveState.writer(liveState.donePayload);
12127
12174
  if (liveState.endResponse) liveState.endResponse();
12128
12175
  _scheduleCcLiveCleanup(tabId);
@@ -16340,6 +16387,9 @@ function _installCrashHandlers() {
16340
16387
  // Production entry points use the closures directly; tests import via require('./dashboard').
16341
16388
  module.exports = {
16342
16389
  getMcpServers,
16390
+ // W-ms5dlone012i457a-d — lets tests assert the HTML the SERVER actually ships,
16391
+ // not just dashboard-build.js's standalone copy (the two silently drifted).
16392
+ _buildDashboardHtmlForTest: buildDashboardHtml,
16343
16393
  _setPrRefVerifierForTest, // issue #246 — inject a fake loose-PR-ref verifier in handler tests
16344
16394
  _prRefVerifyCache, // W-mqtzrix100060c9f — test seam for cache size inspection
16345
16395
  _PR_REF_VERIFY_TTL_MS, // W-mqtzrix100060c9f — exported for test assertions
@@ -16390,6 +16440,11 @@ module.exports = {
16390
16440
  // P-9c5f1a83 — CC image-attachment validation surface (test seams)
16391
16441
  _validateCcImages,
16392
16442
  _buildCcErrorEnvelope,
16443
+ // W-ms4z164i01fo438d — SSE terminal-frame seam (exported for testing).
16444
+ // See test/unit/cc-error-envelopes.test.js.
16445
+ _sseFrame,
16446
+ _isTerminalSseFrameType,
16447
+ _buildCcTerminalErrorPayload,
16393
16448
  CC_ERROR_CODES,
16394
16449
  CC_IMAGE_MAX_COUNT,
16395
16450
  CC_IMAGE_MAX_DECODED_BYTES,
@@ -27,6 +27,16 @@ const AUTO_SWEEP_INTERVAL_MS = 4 * 60 * 60 * 1000;
27
27
  const AUTO_SWEEP_FAILURE_RETRY_MS = 15 * 60 * 1000;
28
28
  const KB_SWEPT_PATH = path.join(ENGINE_DIR, 'kb-swept.json');
29
29
  const COMPRESS_THRESHOLD_BYTES = 5000;
30
+ // Hard input ceiling for the per-entry LLM rewrite pass (W-ms25l4pr000kb9db).
31
+ // The rewrite pass sends an entry's FULL body to the LLM; a pathological entry
32
+ // (e.g. a live-checkout dirty alert that embedded a whole `git status` dump —
33
+ // observed at 10 MB each) can OOM/timeout/hang the call and, without per-pass
34
+ // isolation, abort the entire sweep before the cheap age-expiry pass reclaims
35
+ // space. Entries whose on-disk size exceeds this ceiling are skipped from the
36
+ // rewrite (left for age-expiry / structural consolidation to reclaim) so one
37
+ // giant entry can never choke the sweep. 20x the compress threshold (~100 KB)
38
+ // is far above every normal entry yet well below the multi-MB monsters.
39
+ const REWRITE_MAX_INPUT_BYTES = COMPRESS_THRESHOLD_BYTES * 20;
30
40
  const LLM_BATCH_SIZE = 30;
31
41
  const NORMALIZE_CONCURRENCY = 5;
32
42
  const SWEPT_FLAG_KEY = '_swept'; // frontmatter key — entries with this skip the rewrite pass
@@ -610,6 +620,13 @@ ${body}`;
610
620
  const fp = path.join(KB_DIR, e.category, e.file);
611
621
  const content = safeRead(fp);
612
622
  if (content == null) continue;
623
+ // Oversized-entry guard (W-ms25l4pr000kb9db): never send a multi-MB entry to
624
+ // the LLM — it risks OOM/timeout/hang. Skip it; age-expiry / consolidation
625
+ // reclaim it instead.
626
+ if (content.length > REWRITE_MAX_INPUT_BYTES) {
627
+ log('warn', `[kb-sweep] rewrite skip ${e.category}/${e.file}: ${content.length} bytes exceeds ${REWRITE_MAX_INPUT_BYTES}B ceiling — left for age-expiry/consolidation`);
628
+ continue;
629
+ }
613
630
  const { fm, body } = _parseFrontmatter(content);
614
631
  // Skip already-processed unless the file was modified after the sweep flag was set
615
632
  if (fm[SWEPT_FLAG_KEY]) {
@@ -984,9 +1001,19 @@ async function _runKbSweepImpl(opts = {}) {
984
1001
  }
985
1002
  if (manifest.length < 2) { summary.summary = 'nothing to sweep (< 2 unpinned entries)'; summary.durationMs = Date.now() - t0; return summary; }
986
1003
 
1004
+ // Every pass below is wrapped in its own try/catch (W-ms25l4pr000kb9db). A
1005
+ // throw/OOM/hang in one pass must NOT abort the whole sweep and starve the
1006
+ // later reclaimers — in particular the cheap age-expiry pass and
1007
+ // _pruneOldSwept must always get to run. Each pass degrades to "no-op on this
1008
+ // pass, carry the prior survivors forward".
1009
+
987
1010
  // 1. Hash-based dedup (cheap, catches cross-batch duplicates)
988
- const { survivors: afterHash, archived: hashArchived } = _hashDedup(manifest, opts);
989
- summary.hashDuplicatesArchived = hashArchived;
1011
+ let afterHash = manifest;
1012
+ try {
1013
+ const { survivors, archived } = _hashDedup(manifest, opts);
1014
+ afterHash = survivors;
1015
+ summary.hashDuplicatesArchived = archived;
1016
+ } catch (e) { log('warn', `[kb-sweep] hash-dedup pass failed: ${e && e.message}`); }
990
1017
 
991
1018
  // 1.5. Structural consolidation (W-mr973knu) — PRIMARY balloon fix. Collapse
992
1019
  // large groups of near-identical boilerplate notes (agent-failure dumps, no-op
@@ -994,47 +1021,70 @@ async function _runKbSweepImpl(opts = {}) {
994
1021
  // week). Runs on recent entries too, so it shrinks the KB continuously rather
995
1022
  // than merely aging files out. Downstream passes operate on the survivors so
996
1023
  // the LLM/rewrite passes no longer waste calls on the collapsed notes.
997
- const consolidateResult = _structuralConsolidate(afterHash, opts);
998
- summary.notesConsolidated = consolidateResult.consolidated;
999
- summary.consolidationGroups = consolidateResult.groupsCollapsed;
1000
- summary.digestsWritten = consolidateResult.digestsWritten;
1001
- const afterConsolidate = consolidateResult.survivors.filter(
1002
- e => e._digest || fs.existsSync(path.join(KB_DIR, e.category, e.file)),
1003
- );
1004
-
1005
- // 2. LLM batch sweep — within-batch dupes + reclassify + remove stale
1006
- // Only runs against survivors, but we need indices that match the LIST sent to the LLM
1007
- const llmManifest = afterConsolidate;
1008
- const { plan, scannedIdx: llmScannedIdx } = await _llmBatchSweep(llmManifest, callLLM, trackEngineUsage, opts);
1009
- const llmActions = _applyLlmPlan(plan, llmManifest, llmScannedIdx, opts);
1010
- summary.llmDuplicatesArchived = llmActions.merged;
1011
- summary.staleRemoved = llmActions.removed;
1012
- summary.reclassified = llmActions.reclassified;
1013
-
1014
- // 2.5. Age-based TTL expiry (W-mr48yth5) SECONDARY safety net. Archives
1015
- // transient-category entries older than their per-category TTL to
1016
- // knowledge/_swept/. Structural consolidation above is the primary reducer;
1017
- // this catches genuinely stale one-offs that never clustered into a digest.
1018
- // Durable categories (no TTL) are untouched.
1019
- const survivingAfterLlm = afterConsolidate.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
1020
- const ageResult = _expireByAge(survivingAfterLlm, opts);
1021
- summary.ageExpired = ageResult.expired;
1022
-
1023
- // 3. Per-entry rewrite (compress + normalize)
1024
- // Filter to entries that survived hash + consolidation + LLM + age passes (still on disk)
1025
- const stillOnDisk = afterConsolidate.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
1026
- const rewriteResult = await _rewritePass(stillOnDisk, callLLM, trackEngineUsage, opts);
1027
- summary.rewritten = rewriteResult.processed;
1028
- summary.rewriteBytesBefore = rewriteResult.bytesBefore;
1029
- summary.rewriteBytesAfter = rewriteResult.bytesAfter;
1024
+ let afterConsolidate = afterHash;
1025
+ try {
1026
+ const consolidateResult = _structuralConsolidate(afterHash, opts);
1027
+ summary.notesConsolidated = consolidateResult.consolidated;
1028
+ summary.consolidationGroups = consolidateResult.groupsCollapsed;
1029
+ summary.digestsWritten = consolidateResult.digestsWritten;
1030
+ afterConsolidate = consolidateResult.survivors.filter(
1031
+ e => e._digest || fs.existsSync(path.join(KB_DIR, e.category, e.file)),
1032
+ );
1033
+ } catch (e) { log('warn', `[kb-sweep] structural-consolidation pass failed: ${e && e.message}`); }
1034
+
1035
+ // 2. Age-based TTL expiry (W-mr48yth5) MOVED AHEAD of the LLM passes
1036
+ // (W-ms25l4pr000kb9db). This is the cheapest reducer AND the one that reclaims
1037
+ // the most space from oversized transient entries, so it MUST run before the
1038
+ // LLM batch sweep + rewrite pass — either of which can choke on a multi-MB
1039
+ // entry. Running pure aging first guarantees stale entries are reclaimed even
1040
+ // if a later LLM/rewrite pass throws or hangs. Archives transient-category
1041
+ // entries older than their per-category TTL to knowledge/_swept/; durable
1042
+ // categories (no TTL) and consolidation digests are untouched.
1043
+ let afterAge = afterConsolidate;
1044
+ try {
1045
+ const survivingForAge = afterConsolidate.filter(
1046
+ e => e._digest || fs.existsSync(path.join(KB_DIR, e.category, e.file)),
1047
+ );
1048
+ const ageResult = _expireByAge(survivingForAge, opts);
1049
+ summary.ageExpired = ageResult.expired;
1050
+ afterAge = ageResult.survivors;
1051
+ } catch (e) { log('warn', `[kb-sweep] age-expiry pass failed: ${e && e.message}`); }
1052
+
1053
+ // 3. LLM batch sweep within-batch dupes + reclassify + remove stale.
1054
+ // Only runs against survivors, but we need indices that match the LIST sent to
1055
+ // the LLM. Deferred LLM_SCAN_FLAG_KEY stamping (below) needs llmManifest +
1056
+ // llmScannedIdx in scope, so declare them outside the try.
1057
+ let llmManifest = afterAge.filter(e => e._digest || fs.existsSync(path.join(KB_DIR, e.category, e.file)));
1058
+ let llmScannedIdx = [];
1059
+ try {
1060
+ const { plan, scannedIdx } = await _llmBatchSweep(llmManifest, callLLM, trackEngineUsage, opts);
1061
+ llmScannedIdx = scannedIdx;
1062
+ const llmActions = _applyLlmPlan(plan, llmManifest, llmScannedIdx, opts);
1063
+ summary.llmDuplicatesArchived = llmActions.merged;
1064
+ summary.staleRemoved = llmActions.removed;
1065
+ summary.reclassified = llmActions.reclassified;
1066
+ } catch (e) { log('warn', `[kb-sweep] LLM batch-sweep pass failed: ${e && e.message}`); }
1067
+
1068
+ // 4. Per-entry rewrite (compress + normalize). Filter to entries that survived
1069
+ // the earlier passes (still on disk). Oversized entries are skipped inside
1070
+ // _rewritePass (REWRITE_MAX_INPUT_BYTES) so they never choke the LLM.
1071
+ try {
1072
+ const stillOnDisk = afterAge.filter(e => fs.existsSync(path.join(KB_DIR, e.category, e.file)));
1073
+ const rewriteResult = await _rewritePass(stillOnDisk, callLLM, trackEngineUsage, opts);
1074
+ summary.rewritten = rewriteResult.processed;
1075
+ summary.rewriteBytesBefore = rewriteResult.bytesBefore;
1076
+ summary.rewriteBytesAfter = rewriteResult.bytesAfter;
1077
+ } catch (e) { log('warn', `[kb-sweep] rewrite pass failed: ${e && e.message}`); }
1030
1078
 
1031
1079
  // Stamp LLM_SCAN_FLAG_KEY on entries analyzed by _llmBatchSweep this run —
1032
1080
  // deferred until after the rewrite pass so its content/mtime update doesn't
1033
1081
  // race the freshness marker we're about to write (see _markLlmScanned doc).
1034
- _markLlmScanned(llmManifest, llmScannedIdx, opts);
1082
+ try { _markLlmScanned(llmManifest, llmScannedIdx, opts); }
1083
+ catch (e) { log('warn', `[kb-sweep] mark-llm-scanned failed: ${e && e.message}`); }
1035
1084
 
1036
- // 4. Prune old swept files (>30 days)
1037
- summary.sweptArchivePruned = _pruneOldSwept();
1085
+ // 5. Prune old swept files (>30 days)
1086
+ try { summary.sweptArchivePruned = _pruneOldSwept(); }
1087
+ catch (e) { log('warn', `[kb-sweep] prune-old-swept failed: ${e && e.message}`); }
1038
1088
 
1039
1089
  // Final tallies — re-walk surviving entries for accurate bytesAfter
1040
1090
  const finalEntries = await queries.getKnowledgeBaseEntries();
@@ -1318,5 +1368,7 @@ module.exports = {
1318
1368
  LLM_SCAN_FLAG_KEY,
1319
1369
  CONSOLIDATION_DIGEST_PREFIX,
1320
1370
  COMPRESS_THRESHOLD_BYTES,
1371
+ REWRITE_MAX_INPUT_BYTES,
1321
1372
  SWEPT_FLAG_KEY,
1373
+ _rewritePass,
1322
1374
  };
@@ -287,12 +287,32 @@ function _collectAutoCleanablePaths(dirtyFiles) {
287
287
  // alert stays human-scannable and bounded. Pure/deterministic — exported for
288
288
  // unit testing.
289
289
  const DIRTY_ALERT_MAX_LINES = 200;
290
- function capDirtyFileLines(dirtyFiles, maxLines = DIRTY_ALERT_MAX_LINES) {
290
+ // Companion byte budget for the embedded dirty-file block. The line cap alone
291
+ // still leaves a hole: 200 arbitrarily-long paths (deeply-nested Windows paths,
292
+ // rename `->` pairs, quoted UTF-8 escapes) could each be hundreds of bytes, so
293
+ // bound the total bytes too. Belt-and-suspenders so a single alert body can
294
+ // never embed a multi-MB git-status dump under any input. 16 KiB is far above a
295
+ // realistic ≤200-file sample yet keeps the whole note human-scannable.
296
+ const DIRTY_ALERT_MAX_BYTES = 16 * 1024;
297
+ function capDirtyFileLines(dirtyFiles, maxLines = DIRTY_ALERT_MAX_LINES, maxBytes = DIRTY_ALERT_MAX_BYTES) {
291
298
  const lines = Array.isArray(dirtyFiles) ? dirtyFiles : [];
292
- const cap = Number.isInteger(maxLines) && maxLines > 0 ? maxLines : DIRTY_ALERT_MAX_LINES;
293
- if (lines.length <= cap) return lines.slice();
294
- const kept = lines.slice(0, cap);
295
- kept.push(`... and ${lines.length - cap} more (run \`git status --porcelain=v1 -b\` locally for the full list)`);
299
+ const lineCap = Number.isInteger(maxLines) && maxLines > 0 ? maxLines : DIRTY_ALERT_MAX_LINES;
300
+ const byteCap = Number.isInteger(maxBytes) && maxBytes > 0 ? maxBytes : DIRTY_ALERT_MAX_BYTES;
301
+ // First cap by line count, then enforce the byte budget over the kept lines
302
+ // (each line plus its joining newline). Either cap can trigger the notice.
303
+ const byLine = lines.slice(0, lineCap);
304
+ const kept = [];
305
+ let bytes = 0;
306
+ let byteLimited = false;
307
+ for (const line of byLine) {
308
+ const lineBytes = Buffer.byteLength(String(line), 'utf8') + 1;
309
+ if (bytes + lineBytes > byteCap) { byteLimited = true; break; }
310
+ bytes += lineBytes;
311
+ kept.push(line);
312
+ }
313
+ const omitted = lines.length - kept.length;
314
+ if (omitted <= 0 && !byteLimited) return lines.slice();
315
+ kept.push(`... and ${omitted} more (run \`git status --porcelain=v1 -b\` locally for the full list)`);
296
316
  return kept;
297
317
  }
298
318
 
@@ -920,6 +940,10 @@ async function prepareLiveCheckout(opts = {}) {
920
940
  if (autoResetAttempt.recheckSucceeded) {
921
941
  verifyOutcome = stillDirty.length === 0 ? 'clean' : `${stillDirty.length} dirty path(s) remain`;
922
942
  }
943
+ // The audit note embeds TWO dumps (before + after), so they share ONE
944
+ // body budget: capping each at the full DIRTY_ALERT_MAX_BYTES would let
945
+ // this note reach 2x the ceiling every other live-checkout alert obeys.
946
+ const blockMaxBytes = Math.floor(DIRTY_ALERT_MAX_BYTES / 2);
923
947
  const body = [
924
948
  `# Live-checkout auto-reset ${recoveryVerified ? 'completed' : 'incomplete'} before '${branchName}'`,
925
949
  '',
@@ -938,7 +962,7 @@ async function prepareLiveCheckout(opts = {}) {
938
962
  '## Changes present before the attempt',
939
963
  '',
940
964
  '```',
941
- ...dirtyFiles,
965
+ ...capDirtyFileLines(dirtyFiles, DIRTY_ALERT_MAX_LINES, blockMaxBytes),
942
966
  '```',
943
967
  ...(autoResetAttempt.recheckSucceeded
944
968
  ? [
@@ -946,7 +970,9 @@ async function prepareLiveCheckout(opts = {}) {
946
970
  '## Changes remaining after the attempt',
947
971
  '',
948
972
  '```',
949
- ...(stillDirty.length > 0 ? stillDirty : ['(clean)']),
973
+ ...(stillDirty.length > 0
974
+ ? capDirtyFileLines(stillDirty, DIRTY_ALERT_MAX_LINES, blockMaxBytes)
975
+ : ['(clean)']),
950
976
  '```',
951
977
  ]
952
978
  : []),
@@ -2093,5 +2119,6 @@ module.exports = {
2093
2119
  _maybeAutoFreshenLocalMain,
2094
2120
  capDirtyFileLines,
2095
2121
  DIRTY_ALERT_MAX_LINES,
2122
+ DIRTY_ALERT_MAX_BYTES,
2096
2123
  LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS,
2097
2124
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2436",
3
+ "version": "0.1.2437",
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"
@@ -57,15 +57,15 @@ You are primarily a dispatcher. Agents have full Claude Code + worktrees + MCP t
57
57
 
58
58
  ### Step 1 — Estimate difficulty before responding
59
59
  State the size in 3-4 words to yourself, then act:
60
- - **Small** (≤3 tool calls, 1-2 files, no cross-module reasoning): you MAY do it yourself.
61
- - **Medium** (4-10 tool calls, 3+ files, multi-file reasoning, real refactor): you MUST delegate.
60
+ - **Small** (≤3 tool calls, 1-2 files, no cross-module reasoning): you MAY do it yourself. A bounded read-only lookup or investigation may also be handled directly even when confirming the answer takes a few additional `GET`, search, or file-read calls.
61
+ - **Medium** (4-10 tool calls for mutating/open-ended work, 3+ files, multi-file reasoning, or a real refactor; excludes bounded read-only confirmation): you MUST delegate.
62
62
  - **Large** (10+ tool calls, cross-cutting, multi-stage): you MUST delegate, consider a plan with decomposition.
63
63
  - **Direct-handling override**: if the human explicitly says to answer directly, handle it here, do it yourself, not dispatch/delegate, or not create a work item, do it yourself within the normal safety/protected-path rules.
64
64
 
65
65
  ### Step 2 — Delegate when ≥ Medium (the hard stop)
66
66
  Always delegate these to an agent — do not attempt them yourself even if they look small at first:
67
67
  - Code changes, fixes, refactors, new features → `POST /api/work-items` with `type: "implement"` (use `"fix"` only when the work targets a specific tracked PR — see the type-selection note under "Calling the Minions API" below)
68
- - Exploration, investigation, research, audits → `POST /api/work-items` with `type: "explore"`
68
+ - Substantial exploration, investigation, research, or audits that require broad repository traversal, prolonged execution, or durable follow-up → `POST /api/work-items` with `type: "explore"`
69
69
  - Code reviews → `POST /api/work-items` with `type: "review"`
70
70
  - Testing → `POST /api/work-items` with `type: "test"`
71
71
  - Architecture analysis → `POST /api/work-items` with `type: "explore"`
@@ -79,15 +79,16 @@ Exception: the direct-handling override wins. If the human explicitly asks you t
79
79
  ### Step 3 — Small tasks: do them yourself when it's faster than dispatching
80
80
  Examples (not an exhaustive whitelist — apply Step 1 to anything not listed):
81
81
  - Quick status lookups (reading 1-2 state files, or a `GET /api/...`)
82
+ - Basic read-only investigations with a bounded question and locally available evidence, including checking several related logs, API records, or source locations to identify a concrete cause
82
83
  - Notes, plan edits, KB entries, routing updates
83
84
  - Git ops the user explicitly asked CC to do
84
85
  - Simple config changes
85
86
  - Answering questions from context you already have
86
87
  - One-line edits to non-protected files when the change is unambiguous
87
88
 
88
- If you start a small task and discover it's actually Medium (3+ files, more tool calls than expected, surprising complexity), STOP and delegate instead of pushing through.
89
+ If you start a small task and discover it needs broad cross-module reasoning, long-running commands, code changes, or durable follow-up, STOP and delegate instead of pushing through. Do not delegate a bounded read-only investigation solely because confirming the answer took more than three tool calls.
89
90
 
90
- When genuinely in doubt about the size, delegate — agents have isolated worktrees, full tool access, durable work-item tracking, and no turn limits.
91
+ When genuinely in doubt about a mutating or open-ended task, delegate — agents have isolated worktrees, full tool access, durable work-item tracking, and no turn limits. For a bounded read-only question, prefer answering directly when the evidence is available locally.
91
92
 
92
93
  ### Operator checkouts — write only on an explicit user request
93
94
  A configured project's `localPath` is the **human operator's own working tree**. You MAY use `Edit`/`Write`/`Bash` to change files and run mutating git commands inside `localPath` — but only as the operator's hands, carrying out a change **they explicitly asked you to make in that checkout**. This includes ordinary requested git operations (e.g. a clean `git pull --ff-only origin main`, `git checkout -- <file>` on a file they named) and requested file edits.
@@ -115,8 +116,8 @@ Pass `"contextOnly":true` if the PR should be tracked-but-not-auto-reviewed; omi
115
116
 
116
117
  ## When to dispatch vs answer inline
117
118
 
118
- - **Action requests** (fix this, implement that, build X, run a build, investigate Y, review PR #N) → dispatch via `POST /api/work-items` (or one of the other state-changing endpoints below). Don't also try to do the work yourself in the chat unless it's truly Small per Step 1.
119
- - **Information requests** that you can fetch and synthesize yourself (PR counts, schedule status, "what's in routing.md", "are there any open work items") → answer inline. Use `GET /api/...` or read the state files directly. **Don't also dispatch an `ask` work item for the same answered question** — that's the W-moyo53f9 regression class. If you answered it, don't fire-and-forget a duplicate.
119
+ - **Action requests** (fix this, implement that, build X, run a build, perform a substantial/open-ended investigation, review PR #N) → dispatch via `POST /api/work-items` (or one of the other state-changing endpoints below). Don't also try to do the work yourself in the chat unless it's truly Small per Step 1.
120
+ - **Information and bounded read-only investigation requests** that you can fetch and synthesize yourself (PR counts, schedule status, "what's in routing.md", "why did these recent jobs fail?", "are there any open work items") → answer inline. Use `GET /api/...`, local searches, logs, or read-only file inspection. **Don't also dispatch an `ask` or `explore` work item for the same answered question** — that's the W-moyo53f9 regression class. If you answered it, don't fire-and-forget a duplicate.
120
121
  - **Never both** for the same request. Pick one.
121
122
  - **The server no longer second-guesses your choice.** There is no inferred-fallback safety net (`_actionsWithIntentFallback` and the heuristic stack were removed). Whatever you POST is what ships; whatever you don't POST doesn't ship. Be deliberate.
122
123