@yemi33/minions 0.1.1033 → 0.1.1035

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/CHANGELOG.md CHANGED
@@ -1,5 +1,84 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1035 (2026-04-16)
4
+
5
+ ### Features
6
+ - Doc-chat performance — debounced persistence, session TTL, smart disk reads (#1164)
7
+ - split getStatus() into fast/slow state tiers with separate TTLs (#1170)
8
+ - Cache _countWorktrees() with 30s TTL (#1166)
9
+ - Pre-cache gzipped status buffer alongside JSON cache (#1171)
10
+ - parallelize ADO and GitHub PR polling with Promise.allSettled (#1172)
11
+ - route implement items to dedicated implement playbook (#1115)
12
+
13
+ ### Fixes
14
+ - sidecar oversized dispatch prompts to prevent dashboard OOM (closes #1167) (#1183)
15
+ - use locked write for failed-with-PR reconciliation in cleanup.js (#1169)
16
+ - prevent test from corrupting live meeting state
17
+ - scheduler enabled field falsy check treats undefined as disabled (#1160)
18
+ - update branch-dedup tests to use canonical PR IDs after merge with master (#1146)
19
+ - auto-review not firing for manually-linked PRs with autoObserve=true
20
+ - mark PR abandoned on 404 instead of silently retrying each tick
21
+ - replace undefined PROJECTS with config.projects in checkWatches (#1108)
22
+ - write permission for publish workflow
23
+ - run tests inline and post check runs for publish PRs
24
+ - add maxBuffer to all GitHub CLI spawns + paginate PR list (#1130)
25
+ - add required CI checks for PRs + update publish for auto-merge
26
+ - revert to PR merge now that stale status check is removed
27
+ - guard live review check against undefined vote/state values (#1132)
28
+ - push version bump directly to master instead of via PR
29
+ - add push-triggered CI for chore/publish branches
30
+ - publish workflow chore PRs failing to merge
31
+ - harden KB ordering
32
+ - harden audited state transitions
33
+
34
+ ### Other
35
+ - [E2E] Dashboard & engine perf: gzip cache, tiered status, lock backoff, polling parallelization (#1174)
36
+ - Fix doc chat session isolation
37
+ - test(pipeline): add unit tests for CRUD, stage execution, run lifecycle (#1162)
38
+ - chore: test publish after removing stale status check
39
+ - chore: trigger publish test
40
+ - chore: test publish workflow fix
41
+ - chore: trigger publish workflow test
42
+
43
+ ## 0.1.1034 (2026-04-16)
44
+
45
+ ### Features
46
+ - Doc-chat performance — debounced persistence, session TTL, smart disk reads (#1164)
47
+ - split getStatus() into fast/slow state tiers with separate TTLs (#1170)
48
+ - Cache _countWorktrees() with 30s TTL (#1166)
49
+ - Pre-cache gzipped status buffer alongside JSON cache (#1171)
50
+ - parallelize ADO and GitHub PR polling with Promise.allSettled (#1172)
51
+ - route implement items to dedicated implement playbook (#1115)
52
+
53
+ ### Fixes
54
+ - use locked write for failed-with-PR reconciliation in cleanup.js (#1169)
55
+ - prevent test from corrupting live meeting state
56
+ - scheduler enabled field falsy check treats undefined as disabled (#1160)
57
+ - update branch-dedup tests to use canonical PR IDs after merge with master (#1146)
58
+ - auto-review not firing for manually-linked PRs with autoObserve=true
59
+ - mark PR abandoned on 404 instead of silently retrying each tick
60
+ - replace undefined PROJECTS with config.projects in checkWatches (#1108)
61
+ - write permission for publish workflow
62
+ - run tests inline and post check runs for publish PRs
63
+ - add maxBuffer to all GitHub CLI spawns + paginate PR list (#1130)
64
+ - add required CI checks for PRs + update publish for auto-merge
65
+ - revert to PR merge now that stale status check is removed
66
+ - guard live review check against undefined vote/state values (#1132)
67
+ - push version bump directly to master instead of via PR
68
+ - add push-triggered CI for chore/publish branches
69
+ - publish workflow chore PRs failing to merge
70
+ - harden KB ordering
71
+ - harden audited state transitions
72
+
73
+ ### Other
74
+ - [E2E] Dashboard & engine perf: gzip cache, tiered status, lock backoff, polling parallelization (#1174)
75
+ - Fix doc chat session isolation
76
+ - test(pipeline): add unit tests for CRUD, stage execution, run lifecycle (#1162)
77
+ - chore: test publish after removing stale status check
78
+ - chore: trigger publish test
79
+ - chore: test publish workflow fix
80
+ - chore: trigger publish workflow test
81
+
3
82
  ## 0.1.1033 (2026-04-16)
4
83
 
5
84
  ### Features
@@ -428,6 +428,7 @@ async function _processQaMessage(message, selection, opts) {
428
428
  selection: selection,
429
429
  filePath: capturedFilePath || null,
430
430
  model: window._lastStatus?.autoMode?.ccModel || undefined,
431
+ contentHash: capturedDocContext.content ? capturedDocContext.content.length + ':' + capturedDocContext.content.charCodeAt(0) + ':' + capturedDocContext.content.charCodeAt(capturedDocContext.content.length - 1) : undefined,
431
432
  }),
432
433
  });
433
434
  const data = await res.json();
package/dashboard.js CHANGED
@@ -32,6 +32,24 @@ const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
32
32
  getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
33
33
  MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, DISPATCH_PATH, PRD_DIR } = queries;
34
34
 
35
+ // Startup size guard (#1167): fail fast with a clear error when dispatch.json /
36
+ // cooldowns.json have ballooned past ENGINE_DEFAULTS.maxStateFileBytes. Without
37
+ // this, V8 silently OOMs on JSON.parse(~1 GB) and the operator has no hint as to
38
+ // which file is bloated. The thrown error names the file and directs to
39
+ // engine/contexts/ where sidecars live.
40
+ (() => {
41
+ const stateFiles = [
42
+ DISPATCH_PATH,
43
+ path.join(ENGINE_DIR, 'cooldowns.json'),
44
+ ];
45
+ for (const fp of stateFiles) {
46
+ try { shared.assertStateFileSize(fp); } catch (e) {
47
+ console.error('\n[dashboard] STARTUP ABORTED — ' + e.message + '\n');
48
+ process.exit(78); // 78 = configuration error; consistent with spawn-agent.js
49
+ }
50
+ }
51
+ })();
52
+
35
53
  const PORT = parseInt(process.env.PORT || process.argv[2]) || 7331;
36
54
  let CONFIG = queries.getConfig();
37
55
  let PROJECTS = _getProjects(CONFIG);
@@ -852,16 +870,18 @@ async function executeCCActions(actions) {
852
870
  // Session store for doc modals — keyed by filePath or title, persisted to disk
853
871
  const CC_SESSIONS_PATH = path.join(ENGINE_DIR, 'cc-sessions.json');
854
872
  const DOC_SESSIONS_PATH = path.join(ENGINE_DIR, 'doc-sessions.json');
873
+ const DOC_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 604800000 — 7 days
855
874
  const docSessions = new Map(); // key → { sessionId, lastActiveAt, turnCount }
856
875
 
857
876
  // Load persisted doc sessions on startup
858
877
  try {
859
878
  const saved = safeJson(DOC_SESSIONS_PATH);
860
879
  if (saved && typeof saved === 'object') {
880
+ const now = Date.now();
861
881
  for (const [key, s] of Object.entries(saved)) {
862
- if (s.turnCount < CC_SESSION_MAX_TURNS) {
863
- docSessions.set(key, s);
864
- }
882
+ if (s.turnCount >= CC_SESSION_MAX_TURNS) continue;
883
+ if (s.lastActiveAt && now - new Date(s.lastActiveAt).getTime() > DOC_SESSION_TTL_MS) continue;
884
+ docSessions.set(key, s);
865
885
  }
866
886
  }
867
887
  } catch { /* optional */ }
@@ -872,6 +892,25 @@ function persistDocSessions() {
872
892
  safeWrite(DOC_SESSIONS_PATH, obj);
873
893
  }
874
894
 
895
+ // Debounced variant — coalesces rapid writes (e.g. back-to-back doc-chat turns)
896
+ let _persistDocSessionsTimer = null;
897
+ function schedulePersistDocSessions() {
898
+ if (_persistDocSessionsTimer) clearTimeout(_persistDocSessionsTimer);
899
+ _persistDocSessionsTimer = setTimeout(() => {
900
+ _persistDocSessionsTimer = null;
901
+ persistDocSessions();
902
+ }, 5000); // 5s debounce — rapid turns produce one write per burst
903
+ }
904
+
905
+ /** Flush any pending debounced write immediately (call on shutdown). */
906
+ function flushPendingDocSessions() {
907
+ if (_persistDocSessionsTimer) {
908
+ clearTimeout(_persistDocSessionsTimer);
909
+ _persistDocSessionsTimer = null;
910
+ persistDocSessions();
911
+ }
912
+ }
913
+
875
914
  // Resolve session from any store (CC global or doc-specific)
876
915
  function resolveSession(store, key) {
877
916
  if (store === 'cc') {
@@ -885,6 +924,11 @@ function resolveSession(store, key) {
885
924
  persistDocSessions();
886
925
  return null;
887
926
  }
927
+ if (s.lastActiveAt && Date.now() - new Date(s.lastActiveAt).getTime() > DOC_SESSION_TTL_MS) {
928
+ docSessions.delete(key);
929
+ persistDocSessions();
930
+ return null;
931
+ }
888
932
  return s;
889
933
  }
890
934
 
@@ -909,7 +953,7 @@ function updateSession(store, key, sessionId, existing) {
909
953
  turnCount: (existing && prev ? prev.turnCount : 0) + 1,
910
954
  _docHash: prev?._docHash || null,
911
955
  });
912
- persistDocSessions();
956
+ schedulePersistDocSessions();
913
957
  }
914
958
  }
915
959
 
@@ -972,7 +1016,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
972
1016
  safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
973
1017
  } else if (sessionKey) {
974
1018
  docSessions.delete(sessionKey);
975
- persistDocSessions();
1019
+ schedulePersistDocSessions();
976
1020
  }
977
1021
  }
978
1022
 
@@ -1007,6 +1051,12 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
1007
1051
  return result;
1008
1052
  }
1009
1053
 
1054
+ // Lightweight content fingerprint — same algorithm used browser-side (no crypto needed)
1055
+ function contentFingerprint(str) {
1056
+ if (!str) return '';
1057
+ return str.length + ':' + str.charCodeAt(0) + ':' + str.charCodeAt(str.length - 1);
1058
+ }
1059
+
1010
1060
  // Doc-specific wrapper — adds document context, parses ---DOCUMENT---
1011
1061
  async function ccDocCall({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, onAbortReady }) {
1012
1062
  const sessionKey = filePath || title;
@@ -1047,7 +1097,7 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
1047
1097
  // One-shot call — discard the session ccCall just stored so it cannot
1048
1098
  // bleed into future interactions under the same key.
1049
1099
  docSessions.delete(sessionKey);
1050
- persistDocSessions();
1100
+ schedulePersistDocSessions();
1051
1101
  } else if (result.code === 0 && result.sessionId) {
1052
1102
  // Store doc hash for next call's unchanged check
1053
1103
  const session = resolveSession('doc', sessionKey);
@@ -3221,7 +3271,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3221
3271
  try { shared.sanitizePath(body.filePath, MINIONS_DIR); } catch { return jsonReply(res, 400, { error: 'path must be under minions directory' }); }
3222
3272
  fullPath = path.resolve(MINIONS_DIR, body.filePath);
3223
3273
  const diskContent = safeRead(fullPath);
3224
- if (diskContent !== null) currentContent = diskContent;
3274
+ if (diskContent !== null) {
3275
+ // If client sent a contentHash and it matches disk, skip replacement — client copy is fresh
3276
+ if (body.contentHash && contentFingerprint(diskContent) === body.contentHash) {
3277
+ // body.document is already current — no override needed
3278
+ } else {
3279
+ currentContent = diskContent;
3280
+ }
3281
+ }
3225
3282
  }
3226
3283
 
3227
3284
  const { answer, content, actions } = await ccDocCall({
@@ -4741,7 +4798,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4741
4798
  { method: 'GET', path: /^\/api\/knowledge\/([^/]+)\/([^?]+)/, desc: 'Read a specific knowledge base entry', handler: handleKnowledgeRead },
4742
4799
 
4743
4800
  // Doc chat
4744
- { method: 'POST', path: '/api/doc-chat', desc: 'Minions-aware doc Q&A + editing via CC session', params: 'message, document, title?, filePath?, selection?', handler: handleDocChat },
4801
+ { method: 'POST', path: '/api/doc-chat', desc: 'Minions-aware doc Q&A + editing via CC session', params: 'message, document, title?, filePath?, selection?, contentHash?', handler: handleDocChat },
4745
4802
 
4746
4803
  // Inbox
4747
4804
  { method: 'POST', path: '/api/inbox/persist', desc: 'Promote an inbox item to team notes', params: 'name', handler: handleInboxPersist },
@@ -5102,3 +5159,8 @@ server.on('error', e => {
5102
5159
  }
5103
5160
  process.exit(1);
5104
5161
  });
5162
+
5163
+ // ── Graceful shutdown: flush debounced writes ──────────────────────────────
5164
+ server.on('close', () => flushPendingDocSessions());
5165
+ process.on('SIGTERM', () => { flushPendingDocSessions(); process.exit(0); });
5166
+ process.on('SIGINT', () => { flushPendingDocSessions(); process.exit(0); });
package/engine/cli.js CHANGED
@@ -51,6 +51,18 @@ function handleCommand(cmd, args) {
51
51
 
52
52
  const commands = {
53
53
  start() {
54
+ // Startup state-file size guard (#1167): dispatch.json / cooldowns.json
55
+ // bloated past 100 MB silently OOMed V8 on JSON.parse at startup. Fail fast
56
+ // with an actionable message instead.
57
+ try {
58
+ for (const fp of [DISPATCH_PATH, path.join(ENGINE_DIR, 'cooldowns.json')]) {
59
+ shared.assertStateFileSize(fp);
60
+ }
61
+ } catch (stateErr) {
62
+ console.error('\n[engine] STARTUP ABORTED — ' + stateErr.message + '\n');
63
+ process.exit(78); // 78 = configuration error
64
+ }
65
+
54
66
  // Run preflight checks (warn but don't block — engine may still be useful)
55
67
  try {
56
68
  const { runPreflight, printPreflight } = require('./preflight');
@@ -10,6 +10,31 @@ const queries = require('./queries');
10
10
  const { safeJson, safeWrite, log, ENGINE_DEFAULTS } = shared;
11
11
  const { ENGINE_DIR } = queries;
12
12
 
13
+ /**
14
+ * Truncate any string fields on a pendingContexts entry so a single huge PR
15
+ * comment / build log cannot bloat cooldowns.json to hundreds of MB (#1167).
16
+ * Returns a new entry object (does not mutate the caller's copy).
17
+ */
18
+ function _truncateContextEntry(entry, maxBytes) {
19
+ if (entry == null) return entry;
20
+ const limit = Number(maxBytes) > 0 ? Number(maxBytes) : ENGINE_DEFAULTS.maxPendingContextEntryBytes;
21
+ if (typeof entry === 'string') {
22
+ return Buffer.byteLength(entry, 'utf8') > limit
23
+ ? entry.slice(0, limit) + `\n\n... [truncated: context exceeded ${Math.round(limit / 1024)} KB]`
24
+ : entry;
25
+ }
26
+ if (typeof entry !== 'object') return entry;
27
+ const out = Array.isArray(entry) ? [] : {};
28
+ for (const [k, v] of Object.entries(entry)) {
29
+ if (typeof v === 'string' && Buffer.byteLength(v, 'utf8') > limit) {
30
+ out[k] = v.slice(0, limit) + `\n\n... [truncated: ${k} exceeded ${Math.round(limit / 1024)} KB]`;
31
+ } else {
32
+ out[k] = v;
33
+ }
34
+ }
35
+ return out;
36
+ }
37
+
13
38
  const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
14
39
  const dispatchCooldowns = new Map(); // key → { timestamp, failures }
15
40
 
@@ -39,9 +64,15 @@ function saveCooldowns() {
39
64
  }
40
65
  // Trim pendingContexts arrays before writing to prevent bloat
41
66
  const cap = ENGINE_DEFAULTS.maxPendingContexts;
67
+ const entryLimit = ENGINE_DEFAULTS.maxPendingContextEntryBytes;
42
68
  for (const [, v] of dispatchCooldowns) {
43
- if (Array.isArray(v.pendingContexts) && v.pendingContexts.length > cap) {
44
- v.pendingContexts = v.pendingContexts.slice(-cap);
69
+ if (Array.isArray(v.pendingContexts)) {
70
+ if (v.pendingContexts.length > cap) {
71
+ v.pendingContexts = v.pendingContexts.slice(-cap);
72
+ }
73
+ // Also truncate oversized individual entries — #1167 showed
74
+ // 20 entries × 25 MB each still produced a 500 MB cooldowns.json.
75
+ v.pendingContexts = v.pendingContexts.map(e => _truncateContextEntry(e, entryLimit));
45
76
  }
46
77
  }
47
78
  const obj = Object.fromEntries(dispatchCooldowns);
@@ -69,7 +100,12 @@ function setCooldown(key) {
69
100
  function setCooldownWithContext(key, context) {
70
101
  const existing = dispatchCooldowns.get(key);
71
102
  let pendingContexts = existing?.pendingContexts || [];
72
- if (context) pendingContexts.push(context);
103
+ if (context) {
104
+ // Truncate oversized string fields per-entry BEFORE storing so a single
105
+ // huge payload (PR diff, build log, full repo dump) cannot bloat
106
+ // cooldowns.json regardless of array cap (#1167).
107
+ pendingContexts.push(_truncateContextEntry(context, ENGINE_DEFAULTS.maxPendingContextEntryBytes));
108
+ }
73
109
  // Cap to last N entries to prevent unbounded growth (cooldowns.json bloat)
74
110
  const cap = ENGINE_DEFAULTS.maxPendingContexts;
75
111
  if (pendingContexts.length > cap) pendingContexts = pendingContexts.slice(-cap);
@@ -11,6 +11,7 @@ const { setCooldownFailure } = require('./cooldown');
11
11
 
12
12
  const { safeJson, safeWrite, safeReadDir, mutateJsonFileLocked, mutateWorkItems,
13
13
  mutatePullRequests, getProjects, projectWorkItemsPath, projectPrPath, log, ts, dateStamp,
14
+ sidecarDispatchPrompt, deleteDispatchPromptSidecar,
14
15
  WI_STATUS, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS } = shared;
15
16
  const { getConfig, getDispatch, DISPATCH_PATH, INBOX_DIR } = queries;
16
17
 
@@ -24,13 +25,34 @@ function recovery() { if (!_recovery) _recovery = require('./recovery'); return
24
25
 
25
26
  // ─── Dispatch Mutation ───────────────────────────────────────────────────────
26
27
 
28
+ /**
29
+ * Sweep pending + active dispatch entries and move any oversized prompts to
30
+ * sidecar files. Keeps dispatch.json from bloating to hundreds of MB when
31
+ * fix-type prompts inline PR diffs / build logs / coalesced feedback (#1167).
32
+ * Safe to call on every mutation: small prompts are untouched.
33
+ */
34
+ function _sidecarOversizedPrompts(dispatch) {
35
+ const threshold = ENGINE_DEFAULTS.maxDispatchPromptBytes;
36
+ const lists = [dispatch.pending, dispatch.active];
37
+ for (const list of lists) {
38
+ if (!Array.isArray(list)) continue;
39
+ for (const item of list) {
40
+ if (item && typeof item.prompt === 'string') sidecarDispatchPrompt(item, threshold);
41
+ }
42
+ }
43
+ }
44
+
27
45
  function mutateDispatch(mutator) {
28
46
  const defaultDispatch = { pending: [], active: [], completed: [] };
29
47
  const result = mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
30
48
  dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
31
49
  dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
32
50
  dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
33
- return mutator(dispatch) ?? dispatch;
51
+ const next = mutator(dispatch) ?? dispatch;
52
+ // Prompt-size guard: runs on every write so a single bad item cannot bloat
53
+ // dispatch.json. Sidecars live in engine/contexts/<id>.md.
54
+ _sidecarOversizedPrompts(next);
55
+ return next;
34
56
  }, { defaultValue: defaultDispatch });
35
57
  // Invalidate the read cache so next getDispatch() sees fresh data
36
58
  try { require('./queries').invalidateDispatchCache(); } catch {}
@@ -116,7 +138,12 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
116
138
  if (reason) item.reason = reason;
117
139
  if (resultSummary) item.resultSummary = resultSummary;
118
140
  if (failureClass && result === DISPATCH_RESULT.ERROR) item.failureClass = failureClass;
141
+ // Drop prompt (and sidecar file, if any) — completed entries don't need
142
+ // replayable content and it would accumulate forever (#1167).
143
+ try { deleteDispatchPromptSidecar(item); } catch { /* best-effort */ }
119
144
  delete item.prompt;
145
+ delete item._promptFile;
146
+ delete item._promptBytes;
120
147
  if (dispatch.completed.length >= 100) {
121
148
  dispatch.completed = dispatch.completed.slice(-100);
122
149
  }
package/engine/shared.js CHANGED
@@ -162,6 +162,118 @@ function safeUnlink(p) {
162
162
  try { fs.unlinkSync(p); } catch { /* cleanup */ }
163
163
  }
164
164
 
165
+ // ── Dispatch Prompt Sidecar (#1167) ─────────────────────────────────────────
166
+ // Large prompts (PR diffs, build error logs, coalesced human feedback) inlined
167
+ // into dispatch.json caused hundreds-of-MB bloat per entry and eventual V8 OOM
168
+ // at startup. Sidecar files keep dispatch.json small while preserving full
169
+ // content for the agent at spawn time.
170
+
171
+ // Resolve lazily so MINIONS_TEST_DIR overrides work in tests.
172
+ function _promptContextsDir() {
173
+ return path.join(MINIONS_DIR, 'engine', 'contexts');
174
+ }
175
+ // Keep the constant for callers that expect a stable export; callers that need
176
+ // the current value (tests) should call _promptContextsDir().
177
+ const PROMPT_CONTEXTS_DIR = _promptContextsDir();
178
+
179
+ /** Absolute path to the sidecar prompt file for a given dispatch id. */
180
+ function dispatchPromptSidecarPath(dispatchId) {
181
+ if (!dispatchId) return null;
182
+ const safeId = String(dispatchId).replace(/[^a-zA-Z0-9._-]/g, '-');
183
+ return path.join(_promptContextsDir(), `${safeId}.md`);
184
+ }
185
+
186
+ /**
187
+ * If the dispatch item's prompt exceeds thresholdBytes, write the full prompt
188
+ * to engine/contexts/<id>.md and replace `item.prompt` with a short stub
189
+ * + `_promptFile` reference. Mutates item in place and returns true when
190
+ * sidecaring happened, false otherwise.
191
+ */
192
+ function sidecarDispatchPrompt(item, thresholdBytes) {
193
+ if (!item || typeof item.prompt !== 'string') return false;
194
+ const threshold = Number(thresholdBytes) > 0
195
+ ? Number(thresholdBytes)
196
+ : ENGINE_DEFAULTS.maxDispatchPromptBytes;
197
+ const byteLen = Buffer.byteLength(item.prompt, 'utf8');
198
+ if (byteLen <= threshold) return false;
199
+ if (!item.id) return false; // can't sidecar without a stable id
200
+ try {
201
+ const ctxDir = _promptContextsDir();
202
+ if (!fs.existsSync(ctxDir)) fs.mkdirSync(ctxDir, { recursive: true });
203
+ const sidecar = dispatchPromptSidecarPath(item.id);
204
+ safeWrite(sidecar, item.prompt);
205
+ const relPath = path.relative(MINIONS_DIR, sidecar).replace(/\\/g, '/');
206
+ item._promptFile = relPath;
207
+ item._promptBytes = byteLen;
208
+ item.prompt = `[Prompt sidecarred to ${relPath} — ${Math.round(byteLen / 1024)} KB. The engine reads the sidecar when spawning this agent.]`;
209
+ try { log('warn', `Sidecarred oversized dispatch prompt: ${item.id} (${Math.round(byteLen / 1024)} KB → ${relPath})`); } catch { /* logger may not be ready */ }
210
+ return true;
211
+ } catch (e) {
212
+ try { log('warn', `sidecarDispatchPrompt failed for ${item.id}: ${e.message}`); } catch { /* cleanup */ }
213
+ return false;
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Read the effective prompt for a dispatch item. Prefers the sidecar file when
219
+ * `_promptFile` is set so spawnAgent always sees the full prompt even though
220
+ * dispatch.json only stores a small stub.
221
+ */
222
+ function resolveDispatchPrompt(item) {
223
+ if (!item) return '';
224
+ if (item._promptFile) {
225
+ const candidates = [
226
+ path.isAbsolute(item._promptFile) ? item._promptFile : path.resolve(MINIONS_DIR, item._promptFile),
227
+ dispatchPromptSidecarPath(item.id),
228
+ ].filter(Boolean);
229
+ for (const c of candidates) {
230
+ try {
231
+ const content = fs.readFileSync(c, 'utf8');
232
+ if (content) return content;
233
+ } catch { /* try next candidate */ }
234
+ }
235
+ }
236
+ return item.prompt || '';
237
+ }
238
+
239
+ /** Remove the sidecar prompt file for a completed/cancelled dispatch. */
240
+ function deleteDispatchPromptSidecar(item) {
241
+ if (!item) return;
242
+ const paths = new Set();
243
+ if (item._promptFile) {
244
+ paths.add(path.isAbsolute(item._promptFile) ? item._promptFile : path.resolve(MINIONS_DIR, item._promptFile));
245
+ }
246
+ const idPath = dispatchPromptSidecarPath(item.id);
247
+ if (idPath) paths.add(idPath);
248
+ for (const p of paths) safeUnlink(p);
249
+ }
250
+
251
+ /**
252
+ * Startup guard: throw a clear error when a state file has grown past
253
+ * maxStateFileBytes. Without this the dashboard silently OOMs on JSON.parse
254
+ * (seen on a 491 MB dispatch.json + 509 MB cooldowns.json — #1167).
255
+ * The thrown error points at the bloated file so operators can act instead
256
+ * of chasing V8 heap traces.
257
+ */
258
+ function assertStateFileSize(filePath, maxBytes) {
259
+ const limit = Number(maxBytes) > 0 ? Number(maxBytes) : ENGINE_DEFAULTS.maxStateFileBytes;
260
+ try {
261
+ const stat = fs.statSync(filePath);
262
+ if (stat.size > limit) {
263
+ throw new Error(
264
+ `State file too large: ${filePath} is ${Math.round(stat.size / (1024 * 1024))} MB ` +
265
+ `(limit ${Math.round(limit / (1024 * 1024))} MB). ` +
266
+ `This usually means dispatch prompts or cooldown contexts were inlined and not sidecarred. ` +
267
+ `Inspect/trim the file manually, then restart. See engine/contexts/ for sidecar files.`
268
+ );
269
+ }
270
+ } catch (e) {
271
+ if (e.code === 'ENOENT') return; // file absent is fine
272
+ if (e.message && e.message.startsWith('State file too large:')) throw e;
273
+ // Other stat errors (permission etc.) — do not block startup
274
+ }
275
+ }
276
+
165
277
  function sleepMs(ms) {
166
278
  try {
167
279
  const ab = new SharedArrayBuffer(4);
@@ -571,6 +683,9 @@ const ENGINE_DEFAULTS = {
571
683
  ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
572
684
  heartbeatTimeouts: {}, // populated after WORK_TYPE is defined (below)
573
685
  maxPendingContexts: 20, // cap pendingContexts arrays in cooldowns.json to prevent unbounded growth
686
+ maxPendingContextEntryBytes: 256 * 1024, // 256 KB — cap each pendingContexts entry to prevent huge PR comments from bloating cooldowns.json
687
+ maxDispatchPromptBytes: 1024 * 1024, // 1 MB — dispatch items with prompts larger than this sidecar to engine/contexts/ to prevent dispatch.json OOM (#1167)
688
+ maxStateFileBytes: 100 * 1024 * 1024, // 100 MB — fail startup with a clear error when dispatch.json / cooldowns.json exceed this, rather than silently OOMing on JSON.parse (#1167)
574
689
  ccMaxTurns: 50, // max tool-use turns for CC/doc-chat before CLI stops
575
690
  // Teams integration — config.teams shape: { enabled, appId, appPassword, certPath, privateKeyPath, tenantId, notifyEvents, ccMirror, inboxPollInterval }
576
691
  // Auth modes: (1) appId + appPassword (client secret), or (2) appId + certPath + privateKeyPath + tenantId (certificate)
@@ -1399,6 +1514,12 @@ module.exports = {
1399
1514
  safeJson, safeJsonObj, safeJsonArr,
1400
1515
  safeWrite,
1401
1516
  safeUnlink,
1517
+ PROMPT_CONTEXTS_DIR,
1518
+ dispatchPromptSidecarPath,
1519
+ sidecarDispatchPrompt,
1520
+ resolveDispatchPrompt,
1521
+ deleteDispatchPromptSidecar,
1522
+ assertStateFileSize,
1402
1523
  withFileLock,
1403
1524
  mutateJsonFileLocked,
1404
1525
  mutateWorkItems,
package/engine.js CHANGED
@@ -355,7 +355,11 @@ async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts
355
355
  }
356
356
 
357
357
  async function spawnAgent(dispatchItem, config) {
358
- const { id, agent: agentId, prompt: taskPrompt, type, meta } = dispatchItem;
358
+ const { id, agent: agentId, type, meta } = dispatchItem;
359
+ // Resolve prompt — prefers sidecar file when dispatchItem._promptFile is set
360
+ // (large prompts are written to engine/contexts/<id>.md to keep dispatch.json
361
+ // small — see shared.sidecarDispatchPrompt / #1167).
362
+ const taskPrompt = shared.resolveDispatchPrompt(dispatchItem);
359
363
  const claudeConfig = config.claude || {};
360
364
  const engineConfig = config.engine || {};
361
365
  const startedAt = ts();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1033",
3
+ "version": "0.1.1035",
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"