@yemi33/minions 0.1.1046 → 0.1.1048

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,11 +1,13 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1046 (2026-04-16)
3
+ ## 0.1.1048 (2026-04-17)
4
4
 
5
5
  ### Fixes
6
+ - stamp live-output.log stub before spawn (#1198)
6
7
  - harden settings save and migrate pr poll config
7
8
 
8
9
  ### Other
10
+ - Harden prompt context handling
9
11
  - Harden loop watch conversion
10
12
  - Add watches sidebar activity badge
11
13
  - test(cli): add unit tests for handleCommand, start, stop, kill, spawn (#1191)
package/dashboard.js CHANGED
@@ -533,9 +533,9 @@ setInterval(() => {
533
533
 
534
534
  // ── Command Center: session state + helpers ─────────────────────────────────
535
535
 
536
- // Sessions never expire by time user controls lifecycle via tabs (new tab = new session, /clear = reset).
537
- // Only invalidated by: system prompt change, explicit clear, or tab close.
538
- const CC_SESSION_MAX_TURNS = Infinity;
536
+ // Bound resumed session growth so stale conversations do not accumulate unbounded context.
537
+ const CC_SESSION_MAX_TURNS = shared.ENGINE_DEFAULTS.ccMaxTurns;
538
+ const CC_SESSION_TTL_MS = shared.ENGINE_DEFAULTS.ccSessionTtlMs;
539
539
  let ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
540
540
  const ccInFlightTabs = new Map(); // tabId → timestamp — per-tab in-flight tracking for parallel CC requests
541
541
  const ccInFlightAborts = new Map(); // tabId → abortFn — lets a new request kill the stale LLM
@@ -568,15 +568,14 @@ function ccSessionValid() {
568
568
  ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
569
569
  return false;
570
570
  }
571
+ if (_sessionExpired(ccSession.lastActiveAt || ccSession.createdAt, CC_SESSION_TTL_MS)) {
572
+ console.log('[CC] Session expired by TTL — starting fresh');
573
+ ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
574
+ return false;
575
+ }
571
576
  return ccSession.turnCount < CC_SESSION_MAX_TURNS;
572
577
  }
573
578
 
574
- // Load persisted CC session on startup
575
- try {
576
- const saved = safeJson(path.join(ENGINE_DIR, 'cc-session.json'));
577
- if (saved && saved.sessionId) ccSession = saved;
578
- } catch { /* optional */ }
579
-
580
579
  // Static system prompt — baked into session on creation, never changes
581
580
  // Load CC system prompt from file — editable without touching engine code
582
581
  const CC_STATIC_SYSTEM_PROMPT = (() => {
@@ -592,6 +591,34 @@ const CC_STATIC_SYSTEM_PROMPT = (() => {
592
591
  // Hash the system prompt so we can detect changes and invalidate stale sessions
593
592
  const _ccPromptHash = require('crypto').createHash('md5').update(CC_STATIC_SYSTEM_PROMPT).digest('hex').slice(0, 8);
594
593
 
594
+ function _sessionExpired(lastActiveAt, ttlMs) {
595
+ if (!lastActiveAt || !ttlMs) return false;
596
+ const at = new Date(lastActiveAt).getTime();
597
+ if (!Number.isFinite(at)) return true;
598
+ return Date.now() - at > ttlMs;
599
+ }
600
+
601
+ function _filterCcTabSessions(sessions) {
602
+ return (Array.isArray(sessions) ? sessions : []).filter(s =>
603
+ s && s.id && s.sessionId &&
604
+ (s.turnCount || 0) < CC_SESSION_MAX_TURNS &&
605
+ !_sessionExpired(s.lastActiveAt || s.createdAt, CC_SESSION_TTL_MS) &&
606
+ (!s._promptHash || s._promptHash === _ccPromptHash)
607
+ );
608
+ }
609
+
610
+ function _readCcTabSessions({ prune = true } = {}) {
611
+ const sessions = _filterCcTabSessions(shared.safeJsonArr(CC_SESSIONS_PATH));
612
+ if (prune) safeWrite(CC_SESSIONS_PATH, sessions);
613
+ return sessions;
614
+ }
615
+
616
+ // Load persisted CC session on startup
617
+ try {
618
+ const saved = safeJson(path.join(ENGINE_DIR, 'cc-session.json'));
619
+ if (saved && saved.sessionId && !_sessionExpired(saved.lastActiveAt || saved.createdAt, CC_SESSION_TTL_MS)) ccSession = saved;
620
+ } catch { /* optional */ }
621
+
595
622
  let _preambleCache = null;
596
623
  let _preambleCacheTs = 0;
597
624
  const PREAMBLE_TTL = 30000; // 30s — longer TTL since preamble is lightweight orientation, not real-time data
@@ -998,17 +1025,16 @@ async function executeCCActions(actions) {
998
1025
  // Session store for doc modals — keyed by filePath or title, persisted to disk
999
1026
  const CC_SESSIONS_PATH = path.join(ENGINE_DIR, 'cc-sessions.json');
1000
1027
  const DOC_SESSIONS_PATH = path.join(ENGINE_DIR, 'doc-sessions.json');
1001
- const DOC_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 604800000 — 7 days
1028
+ const DOC_SESSION_TTL_MS = shared.ENGINE_DEFAULTS.docSessionTtlMs;
1002
1029
  const docSessions = new Map(); // key → { sessionId, lastActiveAt, turnCount }
1003
1030
 
1004
1031
  // Load persisted doc sessions on startup
1005
1032
  try {
1006
1033
  const saved = safeJson(DOC_SESSIONS_PATH);
1007
1034
  if (saved && typeof saved === 'object') {
1008
- const now = Date.now();
1009
1035
  for (const [key, s] of Object.entries(saved)) {
1010
1036
  if (s.turnCount >= CC_SESSION_MAX_TURNS) continue;
1011
- if (s.lastActiveAt && now - new Date(s.lastActiveAt).getTime() > DOC_SESSION_TTL_MS) continue;
1037
+ if (_sessionExpired(s.lastActiveAt || s.createdAt, DOC_SESSION_TTL_MS)) continue;
1012
1038
  docSessions.set(key, s);
1013
1039
  }
1014
1040
  }
@@ -1052,7 +1078,7 @@ function resolveSession(store, key) {
1052
1078
  persistDocSessions();
1053
1079
  return null;
1054
1080
  }
1055
- if (s.lastActiveAt && Date.now() - new Date(s.lastActiveAt).getTime() > DOC_SESSION_TTL_MS) {
1081
+ if (_sessionExpired(s.lastActiveAt || s.createdAt, DOC_SESSION_TTL_MS)) {
1056
1082
  docSessions.delete(key);
1057
1083
  persistDocSessions();
1058
1084
  return null;
@@ -3819,14 +3845,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3819
3845
  }
3820
3846
 
3821
3847
  async function handleCCSessionsList(req, res) {
3822
- const sessions = shared.safeJsonArr(CC_SESSIONS_PATH);
3848
+ const sessions = _readCcTabSessions();
3823
3849
  return jsonReply(res, 200, { sessions });
3824
3850
  }
3825
3851
 
3826
3852
  async function handleCCSessionDelete(req, res, match) {
3827
3853
  const id = match?.[1];
3828
3854
  if (!id) return jsonReply(res, 400, { error: 'id required' });
3829
- const sessions = shared.safeJsonArr(CC_SESSIONS_PATH);
3855
+ const sessions = _readCcTabSessions();
3830
3856
  const filtered = sessions.filter(s => s.id !== id);
3831
3857
  safeWrite(CC_SESSIONS_PATH, filtered);
3832
3858
  return jsonReply(res, 200, { ok: true });
@@ -3885,7 +3911,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3885
3911
  }
3886
3912
 
3887
3913
  const parsed = parseCCActions(result.text);
3888
- const toolUses = _extractToolUsesFromRaw(result.raw);
3914
+ const toolUses = Array.isArray(result.toolUses) ? result.toolUses : _extractToolUsesFromRaw(result.raw);
3889
3915
  // Safety net: detect /loop invocation and convert to create-watch
3890
3916
  const _loopWatch = _detectLoopInvocation(parsed.text, parsed.actions, toolUses);
3891
3917
  if (_loopWatch) {
@@ -3981,9 +4007,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
3981
4007
  let sessionReset = false;
3982
4008
  // If system prompt changed since this session was created, force a fresh session
3983
4009
  if (tabSessionId) {
3984
- const sessions = shared.safeJsonArr(CC_SESSIONS_PATH);
4010
+ const sessions = _readCcTabSessions();
3985
4011
  const tabEntry = sessions.find(s => s.id === (body.tabId || 'default'));
3986
- if (tabEntry && tabEntry._promptHash && tabEntry._promptHash !== _ccPromptHash) {
4012
+ if (!tabEntry) {
4013
+ tabSessionId = null;
4014
+ sessionReset = true;
4015
+ } else if (tabEntry._promptHash && tabEntry._promptHash !== _ccPromptHash) {
3987
4016
  tabSessionId = null;
3988
4017
  sessionReset = true;
3989
4018
  }
@@ -4065,7 +4094,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4065
4094
  const _persistTabId = body.tabId;
4066
4095
  if (_persistTabId && responseSessionId) {
4067
4096
  try {
4068
- const sessions = shared.safeJsonArr(CC_SESSIONS_PATH);
4097
+ const sessions = _readCcTabSessions();
4069
4098
  const existing = sessions.find(s => s.id === _persistTabId);
4070
4099
  const preview = (body.message || '').slice(0, 80);
4071
4100
  if (existing) {
package/engine/cleanup.js CHANGED
@@ -369,6 +369,22 @@ function runCleanup(config, verbose = false) {
369
369
  } catch (e) { log('warn', 'prune output archives: ' + e.message); }
370
370
  }
371
371
 
372
+ // 6b. Prune notes/archive — keep the most recent bounded set
373
+ cleaned.notesArchive = 0;
374
+ try {
375
+ const archiveDir = path.join(MINIONS_DIR, 'notes', 'archive');
376
+ if (fs.existsSync(archiveDir)) {
377
+ const archiveFiles = fs.readdirSync(archiveDir)
378
+ .map(name => ({ name, mtime: fs.statSync(path.join(archiveDir, name)).mtimeMs }))
379
+ .sort((a, b) => b.mtime - a.mtime);
380
+ if (archiveFiles.length > ENGINE_DEFAULTS.notesArchiveMaxFiles) {
381
+ for (const old of archiveFiles.slice(ENGINE_DEFAULTS.notesArchiveMaxFiles)) {
382
+ try { fs.unlinkSync(path.join(archiveDir, old.name)); cleaned.notesArchive++; } catch { /* cleanup */ }
383
+ }
384
+ }
385
+ }
386
+ } catch (e) { log('warn', 'prune notes archive: ' + e.message); }
387
+
372
388
  // 7. Prune orphaned dispatch entries — items whose source work item no longer exists
373
389
  cleaned.orphanedDispatches = 0;
374
390
  try {
@@ -708,8 +724,8 @@ function runCleanup(config, verbose = false) {
708
724
  // 14. Scrub stale temp agent keys from metrics.json
709
725
  try { scrubStaleMetrics(); } catch { /* best-effort cleanup */ }
710
726
 
711
- if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles + cleaned.pendingContextsTrimmed > 0) {
712
- log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pendingContextsTrimmed} pendingCtx trimmed, ${cleaned.pidFiles} PID files`);
727
+ if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles + cleaned.pendingContextsTrimmed + cleaned.notesArchive > 0) {
728
+ log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pendingContextsTrimmed} pendingCtx trimmed, ${cleaned.notesArchive} archived notes, ${cleaned.pidFiles} PID files`);
713
729
  }
714
730
 
715
731
  return cleaned;
package/engine/llm.js CHANGED
@@ -5,7 +5,7 @@
5
5
 
6
6
  const path = require('path');
7
7
  const shared = require('./shared');
8
- const { safeWrite, safeUnlink, uid, ts, runFile, cleanChildEnv, parseStreamJsonOutput, mutateJsonFileLocked } = shared;
8
+ const { safeWrite, safeUnlink, uid, ts, runFile, cleanChildEnv, parseStreamJsonOutput, mutateJsonFileLocked, appendTextTail, ENGINE_DEFAULTS } = shared;
9
9
 
10
10
  const MINIONS_DIR = shared.MINIONS_DIR;
11
11
  const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
@@ -127,6 +127,93 @@ function _spawnProcess(promptText, sysPromptText, { direct, label, model, maxTur
127
127
  return { proc, cleanupFiles };
128
128
  }
129
129
 
130
+ function _createStreamAccumulator({
131
+ maxRawBytes,
132
+ maxStderrBytes,
133
+ maxLineBufferBytes,
134
+ maxTextLength = 0,
135
+ onChunk = null,
136
+ onToolUse = null,
137
+ }) {
138
+ let stdout = '';
139
+ let stderr = '';
140
+ let lineBuf = '';
141
+ let text = '';
142
+ let usage = null;
143
+ let sessionId = null;
144
+ let lastTextSent = '';
145
+ const toolUses = [];
146
+
147
+ function captureResult(obj) {
148
+ if (!obj || typeof obj !== 'object') return;
149
+ if (obj.session_id) sessionId = obj.session_id;
150
+ if (obj.type === 'result') {
151
+ if (typeof obj.result === 'string') {
152
+ text = maxTextLength ? obj.result.slice(0, maxTextLength) : obj.result;
153
+ }
154
+ if (obj.total_cost_usd || obj.usage) {
155
+ usage = {
156
+ costUsd: obj.total_cost_usd || 0,
157
+ inputTokens: obj.usage?.input_tokens || 0,
158
+ outputTokens: obj.usage?.output_tokens || 0,
159
+ cacheRead: obj.usage?.cache_read_input_tokens || obj.usage?.cacheReadInputTokens || 0,
160
+ cacheCreation: obj.usage?.cache_creation_input_tokens || obj.usage?.cacheCreationInputTokens || 0,
161
+ durationMs: obj.duration_ms || 0,
162
+ numTurns: obj.num_turns || 0,
163
+ };
164
+ }
165
+ }
166
+ if (obj.type === 'assistant' && Array.isArray(obj.message?.content)) {
167
+ for (const block of obj.message.content) {
168
+ if (block?.type === 'text' && block.text) {
169
+ text = maxTextLength ? block.text.slice(0, maxTextLength) : block.text;
170
+ if (onChunk && block.text !== lastTextSent) {
171
+ lastTextSent = block.text;
172
+ onChunk(block.text);
173
+ }
174
+ } else if (block?.type === 'tool_use' && block.name) {
175
+ const toolUse = { name: block.name, input: block.input || {} };
176
+ toolUses.push(toolUse);
177
+ if (onToolUse) onToolUse(toolUse.name, toolUse.input);
178
+ }
179
+ }
180
+ }
181
+ }
182
+
183
+ function ingestStdout(chunk) {
184
+ const str = chunk == null ? '' : chunk.toString();
185
+ stdout = appendTextTail(stdout, str, maxRawBytes, '...(truncated stdout)\n');
186
+ lineBuf = appendTextTail(lineBuf, str, maxLineBufferBytes, '');
187
+ const lines = lineBuf.split('\n');
188
+ lineBuf = lines.pop() || '';
189
+ for (const line of lines) {
190
+ const trimmed = line.trim();
191
+ if (!trimmed || !trimmed.startsWith('{')) continue;
192
+ try { captureResult(JSON.parse(trimmed)); } catch { /* incomplete JSON or non-JSON line */ }
193
+ }
194
+ }
195
+
196
+ function ingestStderr(chunk) {
197
+ stderr = appendTextTail(stderr, chunk == null ? '' : chunk.toString(), maxStderrBytes, '...(truncated stderr)\n');
198
+ }
199
+
200
+ function finalize() {
201
+ const trimmed = lineBuf.trim();
202
+ if (trimmed.startsWith('{')) {
203
+ try { captureResult(JSON.parse(trimmed)); } catch { /* incomplete trailing JSON */ }
204
+ }
205
+ if (!text || !usage || !sessionId) {
206
+ const parsedTail = parseStreamJsonOutput(stdout, maxTextLength ? { maxTextLength } : {});
207
+ if (!text && parsedTail.text) text = parsedTail.text;
208
+ if (!usage && parsedTail.usage) usage = parsedTail.usage;
209
+ if (!sessionId && parsedTail.sessionId) sessionId = parsedTail.sessionId;
210
+ }
211
+ return { text, usage, sessionId, raw: stdout, stderr, toolUses };
212
+ }
213
+
214
+ return { ingestStdout, ingestStderr, finalize };
215
+ }
216
+
130
217
  // ── Core LLM Call ───────────────────────────────────────────────────────────
131
218
 
132
219
  function callLLM(promptText, sysPromptText, { timeout = 120000, label = 'llm', model = 'sonnet', maxTurns = 1, allowedTools = '', sessionId = null, effort = null, direct = false } = {}) {
@@ -134,29 +221,32 @@ function callLLM(promptText, sysPromptText, { timeout = 120000, label = 'llm', m
134
221
  const promise = new Promise((resolve) => {
135
222
  const _startMs = Date.now();
136
223
  const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, { direct, label, model, maxTurns, allowedTools, effort, sessionId });
224
+ const acc = _createStreamAccumulator({
225
+ maxRawBytes: ENGINE_DEFAULTS.maxLlmRawBytes,
226
+ maxStderrBytes: ENGINE_DEFAULTS.maxLlmStderrBytes,
227
+ maxLineBufferBytes: ENGINE_DEFAULTS.maxLlmLineBufferBytes,
228
+ });
137
229
 
138
230
  _abort = () => { shared.killImmediate(proc); };
139
231
 
140
- let stdout = '';
141
- let stderr = '';
142
- proc.stdout.on('data', d => { stdout += d.toString(); });
143
- proc.stderr.on('data', d => { stderr += d.toString(); });
232
+ proc.stdout.on('data', d => { acc.ingestStdout(d); });
233
+ proc.stderr.on('data', d => { acc.ingestStderr(d); });
144
234
 
145
235
  const timer = setTimeout(() => { shared.killImmediate(proc); }, timeout);
146
236
 
147
237
  proc.on('close', (code) => {
148
238
  clearTimeout(timer);
149
239
  for (const f of cleanupFiles) safeUnlink(f);
150
- const parsed = parseStreamJsonOutput(stdout);
240
+ const parsed = acc.finalize();
151
241
  const durationMs = Date.now() - _startMs;
152
242
  const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
153
- resolve({ text: parsed.text || '', usage, sessionId: parsed.sessionId || null, code, stderr, raw: stdout });
243
+ resolve({ text: parsed.text || '', usage, sessionId: parsed.sessionId || null, code, stderr: parsed.stderr, raw: parsed.raw, toolUses: parsed.toolUses });
154
244
  });
155
245
 
156
246
  proc.on('error', (err) => {
157
247
  clearTimeout(timer);
158
248
  for (const f of cleanupFiles) safeUnlink(f);
159
- resolve({ text: '', usage: null, sessionId: null, code: 1, stderr: err.message, raw: '' });
249
+ resolve({ text: '', usage: null, sessionId: null, code: 1, stderr: err.message, raw: '', toolUses: [] });
160
250
  });
161
251
  });
162
252
  promise.abort = () => { if (_abort) _abort(); };
@@ -190,56 +280,34 @@ function callLLMStreaming(promptText, sysPromptText, { timeout = 120000, label =
190
280
  const promise = new Promise((resolve) => {
191
281
  const _startMs = Date.now();
192
282
  const { proc, cleanupFiles } = _spawnProcess(promptText, sysPromptText, { direct, label, model, maxTurns, allowedTools, effort, sessionId });
283
+ const acc = _createStreamAccumulator({
284
+ maxRawBytes: ENGINE_DEFAULTS.maxLlmRawBytes,
285
+ maxStderrBytes: ENGINE_DEFAULTS.maxLlmStderrBytes,
286
+ maxLineBufferBytes: ENGINE_DEFAULTS.maxLlmLineBufferBytes,
287
+ onChunk,
288
+ onToolUse,
289
+ });
193
290
 
194
291
  _abort = () => { shared.killImmediate(proc); };
195
292
 
196
- let stdout = '';
197
- let stderr = '';
198
- let lineBuf = '';
199
-
200
- let lastTextSent = '';
201
- proc.stdout.on('data', d => {
202
- const chunk = d.toString();
203
- stdout += chunk;
204
- lineBuf += chunk;
205
- // Parse complete lines for streaming text
206
- const lines = lineBuf.split('\n');
207
- lineBuf = lines.pop(); // keep incomplete line in buffer
208
- for (const line of lines) {
209
- const trimmed = line.trim();
210
- if (!trimmed || !trimmed.startsWith('{')) continue;
211
- try {
212
- const obj = JSON.parse(trimmed);
213
- if (obj.type === 'assistant' && obj.message?.content) {
214
- for (const block of obj.message.content) {
215
- if (block.type === 'text' && block.text && block.text !== lastTextSent) {
216
- lastTextSent = block.text;
217
- onChunk(block.text);
218
- } else if (block.type === 'tool_use' && block.name && onToolUse) {
219
- onToolUse(block.name, block.input);
220
- }
221
- }
222
- }
223
- } catch { /* incomplete JSON or non-JSON line */ }
224
- }
225
- });
226
- proc.stderr.on('data', d => { stderr += d.toString(); });
293
+ proc.stdout.on('data', d => { acc.ingestStdout(d); });
294
+ proc.stderr.on('data', d => { acc.ingestStderr(d); });
227
295
 
228
296
  const timer = setTimeout(() => { shared.killImmediate(proc); }, timeout);
229
297
 
230
298
  proc.on('close', (code) => {
231
299
  clearTimeout(timer);
232
300
  for (const f of cleanupFiles) safeUnlink(f);
233
- const parsed = parseStreamJsonOutput(stdout);
301
+ const parsed = acc.finalize();
234
302
  const durationMs = Date.now() - _startMs;
235
303
  const usage = parsed.usage ? { ...parsed.usage, durationMs } : { durationMs };
236
- resolve({ text: parsed.text || '', usage, sessionId: parsed.sessionId || null, code, stderr, raw: stdout });
304
+ resolve({ text: parsed.text || '', usage, sessionId: parsed.sessionId || null, code, stderr: parsed.stderr, raw: parsed.raw, toolUses: parsed.toolUses });
237
305
  });
238
306
 
239
307
  proc.on('error', (err) => {
240
308
  clearTimeout(timer);
241
309
  for (const f of cleanupFiles) safeUnlink(f);
242
- resolve({ text: '', usage: null, sessionId: null, code: 1, stderr: err.message, raw: '' });
310
+ resolve({ text: '', usage: null, sessionId: null, code: 1, stderr: err.message, raw: '', toolUses: [] });
243
311
  });
244
312
  });
245
313
  promise.abort = () => { if (_abort) _abort(); };
@@ -252,4 +320,3 @@ module.exports = {
252
320
  trackEngineUsage,
253
321
  isResumeSessionStillValid,
254
322
  };
255
-
package/engine/meeting.js CHANGED
@@ -18,6 +18,20 @@ const EMPTY_OUTPUT_PATTERNS = ['(no output)', '(no findings)', '(no response)'];
18
18
 
19
19
  const MEETINGS_DIR = path.join(__dirname, '..', 'meetings');
20
20
 
21
+ function truncateMeetingContext(text, maxBytes, label) {
22
+ return shared.truncateTextBytes(text, maxBytes, `\n\n_...${label} truncated — review the meeting transcript if needed._`);
23
+ }
24
+
25
+ function formatMeetingContributions(entries, agents, emptyText, label, maxBytes) {
26
+ const pairs = Object.entries(typeof entries === 'object' && entries ? entries : {});
27
+ if (pairs.length === 0) return emptyText;
28
+ const perEntryBytes = Math.max(1024, Math.floor(maxBytes / Math.max(1, pairs.length)));
29
+ const combined = pairs.map(([agent, value]) =>
30
+ `### ${agents[agent]?.name || agent}\n\n${truncateMeetingContext(value?.content || emptyText, perEntryBytes, `${label} entry`)}`
31
+ ).join('\n\n---\n\n');
32
+ return truncateMeetingContext(combined, maxBytes, label);
33
+ }
34
+
21
35
  function getMeetings() {
22
36
  if (!fs.existsSync(MEETINGS_DIR)) return [];
23
37
  return fs.readdirSync(MEETINGS_DIR)
@@ -100,13 +114,25 @@ function discoverMeetingWork(config) {
100
114
  const key = `${concludePrefix}${concluder}`;
101
115
  if (activeKeys.has(key)) continue;
102
116
 
103
- const humanNotes = (Array.isArray(meeting.humanNotes) ? meeting.humanNotes : []).map(n => '- ' + n).join('\n');
104
- const allFindings = Object.entries(typeof meeting.findings === 'object' && meeting.findings ? meeting.findings : {}).map(([agent, f]) =>
105
- `### ${agents[agent]?.name || agent}\n\n${f.content || '(no findings)'}`
106
- ).join('\n\n---\n\n');
107
- const allDebate = Object.entries(typeof meeting.debate === 'object' && meeting.debate ? meeting.debate : {}).map(([agent, d]) =>
108
- `### ${agents[agent]?.name || agent}\n\n${d.content || '(no response)'}`
109
- ).join('\n\n---\n\n');
117
+ const humanNotes = truncateMeetingContext(
118
+ (Array.isArray(meeting.humanNotes) ? meeting.humanNotes : []).map(n => '- ' + n).join('\n'),
119
+ ENGINE_DEFAULTS.maxMeetingHumanNotesBytes,
120
+ 'human meeting notes'
121
+ );
122
+ const allFindings = formatMeetingContributions(
123
+ meeting.findings,
124
+ agents,
125
+ '(no findings)',
126
+ 'meeting findings',
127
+ ENGINE_DEFAULTS.maxMeetingPromptBytes
128
+ );
129
+ const allDebate = formatMeetingContributions(
130
+ meeting.debate,
131
+ agents,
132
+ '(no response)',
133
+ 'meeting debate',
134
+ ENGINE_DEFAULTS.maxMeetingPromptBytes
135
+ );
110
136
 
111
137
  const vars = {
112
138
  agent_name: agents[concluder]?.name || concluder,
@@ -149,7 +175,11 @@ function discoverMeetingWork(config) {
149
175
  const key = `meeting-${meeting.id}-r${round}-${agentId}`;
150
176
  if (activeKeys.has(key)) continue;
151
177
 
152
- const humanNotes = (meeting.humanNotes || []).map(n => '- ' + n).join('\n');
178
+ const humanNotes = truncateMeetingContext(
179
+ (meeting.humanNotes || []).map(n => '- ' + n).join('\n'),
180
+ ENGINE_DEFAULTS.maxMeetingHumanNotesBytes,
181
+ 'human meeting notes'
182
+ );
153
183
  const vars = {
154
184
  agent_name: agents[agentId]?.name || agentId,
155
185
  agent_role: agents[agentId]?.role || 'Agent',
@@ -160,9 +190,13 @@ function discoverMeetingWork(config) {
160
190
  };
161
191
 
162
192
  if (roundName === 'debating') {
163
- vars.all_findings = Object.entries(meeting.findings || {}).map(([agent, f]) =>
164
- `### ${agents[agent]?.name || agent}\n\n${f.content || '(no findings)'}`
165
- ).join('\n\n---\n\n');
193
+ vars.all_findings = formatMeetingContributions(
194
+ meeting.findings,
195
+ agents,
196
+ '(no findings)',
197
+ 'meeting findings',
198
+ ENGINE_DEFAULTS.maxMeetingPromptBytes
199
+ );
166
200
  }
167
201
 
168
202
  const playbookName = roundName === 'investigating' ? 'meeting-investigate' : 'meeting-debate';
@@ -15,6 +15,10 @@ const { parseCronExpr, shouldRunNow } = require('./scheduler');
15
15
  const PIPELINES_DIR = path.join(__dirname, '..', 'pipelines');
16
16
  const PIPELINE_RUNS_PATH = path.join(__dirname, 'pipeline-runs.json');
17
17
 
18
+ function truncatePipelineContext(text, maxBytes, label) {
19
+ return shared.truncateTextBytes(text, maxBytes, `\n\n_...${label} truncated — inspect the upstream artifacts if needed._`);
20
+ }
21
+
18
22
  // ── Pipeline CRUD ────────────────────────────────────────────────────────────
19
23
 
20
24
  function getPipelines() {
@@ -433,7 +437,11 @@ async function executePlanStage(stage, stageState, run, config) {
433
437
  try {
434
438
  const mtg = safeJson(path.join(__dirname, '..', 'meetings', mid + '.json'));
435
439
  if (mtg) {
436
- const transcript = (mtg.transcript || []).map(formatTranscriptEntry).join('\n\n---\n\n');
440
+ const transcript = truncatePipelineContext(
441
+ (mtg.transcript || []).map(formatTranscriptEntry).join('\n\n---\n\n'),
442
+ ENGINE_DEFAULTS.maxPipelineMeetingContextBytes,
443
+ `meeting transcript for ${mtg.title || mid}`
444
+ );
437
445
  meetingContext += '# Meeting: ' + (mtg.title || mid) + '\n\n**Agenda:** ' + (mtg.agenda || '') + '\n\n' + transcript + '\n\n';
438
446
  }
439
447
  } catch (e) { log('warn', `Pipeline plan: failed to read meeting ${mid}: ${e.message}`); }
@@ -443,10 +451,15 @@ async function executePlanStage(stage, stageState, run, config) {
443
451
  for (const depId of stage.dependsOn) {
444
452
  const depStage = run.stages[depId];
445
453
  if (depStage?.output && !depStage.artifacts?.meetings?.length) {
446
- meetingContext += '## From: ' + depId + '\n\n' + depStage.output + '\n\n';
454
+ meetingContext += '## From: ' + depId + '\n\n' + truncatePipelineContext(
455
+ depStage.output,
456
+ ENGINE_DEFAULTS.maxPipelineMeetingContextBytes,
457
+ `pipeline stage output from ${depId}`
458
+ ) + '\n\n';
447
459
  }
448
460
  }
449
461
  }
462
+ meetingContext = truncatePipelineContext(meetingContext, ENGINE_DEFAULTS.maxPipelineMeetingContextBytes, 'pipeline meeting context');
450
463
 
451
464
  // Use LLM to generate a structured plan (same approach as dashboard "Create Plan from Meeting" button)
452
465
  let content = '';
@@ -9,7 +9,7 @@ const path = require('path');
9
9
  const shared = require('./shared');
10
10
  const queries = require('./queries');
11
11
 
12
- const { safeJson, safeRead, getProjects, log, ts, dateStamp, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT } = shared;
12
+ const { safeJson, safeRead, getProjects, log, ts, dateStamp, truncateTextBytes, ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT } = shared;
13
13
  const { getConfig, getDispatch, getNotes, getAgentCharter, getPrs, AGENTS_DIR } = queries;
14
14
 
15
15
  const MINIONS_DIR = path.resolve(__dirname, '..');
@@ -119,6 +119,10 @@ function resolveTaskContext(item, config) {
119
119
  }));
120
120
  const resolved = { additionalContext: '', referencedFiles: [] };
121
121
 
122
+ function truncateReferencedContext(content, maxBytes, label) {
123
+ return truncateTextBytes(content, maxBytes, `\n\n_...${label} truncated — read the full file if needed._`);
124
+ }
125
+
122
126
 
123
127
  // Match agent references: "ripley's plan", "dallas's pr", "lambert's output", etc.
124
128
  for (const agent of agentNames) {
@@ -144,7 +148,7 @@ function resolveTaskContext(item, config) {
144
148
  const planPath = path.join(MINIONS_DIR, 'plans', planFile);
145
149
  try {
146
150
  const content = safeRead(planPath);
147
- resolved.additionalContext += `\n\n## Referenced Plan: ${planFile} (created by ${agent.name})\n\n${content}`;
151
+ resolved.additionalContext += `\n\n## Referenced Plan: ${planFile} (created by ${agent.name})\n\n${truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedPlanBytes, 'referenced plan')}`;
148
152
  resolved.referencedFiles.push(planPath);
149
153
  log('info', `Context resolution: found plan "${planFile}" by ${agent.name} for work item ${item.id}`);
150
154
  } catch (e) { log('warn', 'resolve plan context: ' + e.message); }
@@ -155,7 +159,7 @@ function resolveTaskContext(item, config) {
155
159
  const planPath = path.join(MINIONS_DIR, 'plans', match);
156
160
  try {
157
161
  const content = safeRead(planPath);
158
- resolved.additionalContext += `\n\n## Referenced Plan: ${match}\n\n${content}`;
162
+ resolved.additionalContext += `\n\n## Referenced Plan: ${match}\n\n${truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedPlanBytes, 'referenced plan')}`;
159
163
  resolved.referencedFiles.push(planPath);
160
164
  log('info', `Context resolution: found plan "${match}" (name match) for work item ${item.id}`);
161
165
  } catch (e) { log('warn', 'resolve plan fallback context: ' + e.message); }
@@ -178,7 +182,7 @@ function resolveTaskContext(item, config) {
178
182
  .sort().reverse();
179
183
  if (files.length > 0) {
180
184
  const content = safeRead(path.join(inboxDir, files[0]));
181
- resolved.additionalContext += `\n\n## Referenced Notes by ${agent.name}: ${files[0]}\n\n${content.slice(0, 5000)}`;
185
+ resolved.additionalContext += `\n\n## Referenced Notes by ${agent.name}: ${files[0]}\n\n${truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedNotesBytes, 'referenced notes')}`;
182
186
  resolved.referencedFiles.push(path.join(inboxDir, files[0]));
183
187
  log('info', `Context resolution: found notes "${files[0]}" by ${agent.name} for work item ${item.id}`);
184
188
  }
@@ -197,13 +201,20 @@ function resolveTaskContext(item, config) {
197
201
  if (plans.length > 0) {
198
202
  const planPath = path.join(MINIONS_DIR, 'plans', plans[0]);
199
203
  const content = safeRead(planPath);
200
- resolved.additionalContext += `\n\n## Referenced Plan (latest): ${plans[0]}\n\n${content}`;
204
+ resolved.additionalContext += `\n\n## Referenced Plan (latest): ${plans[0]}\n\n${truncateReferencedContext(content, ENGINE_DEFAULTS.maxReferencedPlanBytes, 'referenced plan')}`;
201
205
  resolved.referencedFiles.push(planPath);
202
206
  log('info', `Context resolution: using latest plan "${plans[0]}" for work item ${item.id}`);
203
207
  }
204
208
  } catch (e) { log('warn', 'resolve latest plan context: ' + e.message); }
205
209
  }
206
210
 
211
+ if (resolved.additionalContext) {
212
+ resolved.additionalContext = truncateTextBytes(
213
+ resolved.additionalContext,
214
+ ENGINE_DEFAULTS.maxResolvedTaskContextBytes,
215
+ '\n\n_...additional referenced context truncated — read the referenced files if needed._'
216
+ );
217
+ }
207
218
  return resolved;
208
219
  }
209
220
 
@@ -316,14 +327,16 @@ function renderPlaybook(type, vars) {
316
327
  content += '\n\n---\n\n## Pinned Context (CRITICAL — READ FIRST)\n\n' + pinnedContent;
317
328
  }
318
329
 
319
- // Inject team notes (single injection point — not in buildAgentContext) — capped at 8KB
330
+ // Inject team notes (single injection point — not in buildAgentContext) — capped via ENGINE_DEFAULTS
320
331
  let notes = getNotes();
321
332
  if (notes) {
322
- if (notes.length > 8192) {
333
+ if (Buffer.byteLength(notes, 'utf8') > ENGINE_DEFAULTS.maxNotesPromptBytes) {
323
334
  const sections = notes.split(/(?=^### )/m);
324
- const recent = sections.slice(-10).join('');
325
- notes = recent.length > 8192 ? recent.slice(0, 8192) + '\n\n_...notes truncated_' : recent;
326
- notes += '\n\n_' + Math.max(0, sections.length - 10) + ' older entries in `notes.md` — Read if needed._';
335
+ const recent = sections.slice(-10).join('') || notes;
336
+ const olderCount = Math.max(0, sections.length - 10);
337
+ const footer = olderCount > 0 ? `\n\n_${olderCount} older entries in \`notes.md\` — Read if needed._` : '';
338
+ const budget = Math.max(0, ENGINE_DEFAULTS.maxNotesPromptBytes - Buffer.byteLength(footer, 'utf8'));
339
+ notes = truncateTextBytes(recent, budget, '\n\n_...notes truncated_') + footer;
327
340
  }
328
341
  content += '\n\n---\n\n## Team Notes (MUST READ)\n\n' + notes;
329
342
  }
package/engine/shared.js CHANGED
@@ -391,6 +391,44 @@ function uniquePath(filePath) {
391
391
  return `${base}-${Date.now()}${ext}`;
392
392
  }
393
393
 
394
+ function truncateTextBytes(text, maxBytes, suffix = '') {
395
+ const value = text == null ? '' : String(text);
396
+ if (!maxBytes || maxBytes <= 0) return '';
397
+ if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value;
398
+ const suffixText = suffix == null ? '' : String(suffix);
399
+ const suffixBytes = Buffer.byteLength(suffixText, 'utf8');
400
+ const targetBytes = Math.max(0, maxBytes - suffixBytes);
401
+ let low = 0;
402
+ let high = value.length;
403
+ while (low < high) {
404
+ const mid = Math.ceil((low + high) / 2);
405
+ if (Buffer.byteLength(value.slice(0, mid), 'utf8') <= targetBytes) low = mid;
406
+ else high = mid - 1;
407
+ }
408
+ return value.slice(0, low) + suffixText;
409
+ }
410
+
411
+ function tailTextBytes(text, maxBytes, prefix = '') {
412
+ const value = text == null ? '' : String(text);
413
+ if (!maxBytes || maxBytes <= 0) return '';
414
+ if (Buffer.byteLength(value, 'utf8') <= maxBytes) return value;
415
+ const prefixText = prefix == null ? '' : String(prefix);
416
+ const prefixBytes = Buffer.byteLength(prefixText, 'utf8');
417
+ const targetBytes = Math.max(0, maxBytes - prefixBytes);
418
+ let low = 0;
419
+ let high = value.length;
420
+ while (low < high) {
421
+ const mid = Math.floor((low + high) / 2);
422
+ if (Buffer.byteLength(value.slice(mid), 'utf8') <= targetBytes) high = mid;
423
+ else low = mid + 1;
424
+ }
425
+ return prefixText + value.slice(low);
426
+ }
427
+
428
+ function appendTextTail(existing, addition, maxBytes, prefix = '') {
429
+ return tailTextBytes((existing || '') + (addition || ''), maxBytes, prefix);
430
+ }
431
+
394
432
  // ── Inbox Helpers ───────────────────────────────────────────────────────────
395
433
 
396
434
  /**
@@ -687,6 +725,19 @@ const ENGINE_DEFAULTS = {
687
725
  maxDispatchPromptBytes: 1024 * 1024, // 1 MB — dispatch items with prompts larger than this sidecar to engine/contexts/ to prevent dispatch.json OOM (#1167)
688
726
  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)
689
727
  ccMaxTurns: 50, // max tool-use turns for CC/doc-chat before CLI stops
728
+ ccSessionTtlMs: 2 * 60 * 60 * 1000, // 2h — expire stale resumed CC sessions to cap context growth
729
+ docSessionTtlMs: 7 * 24 * 60 * 60 * 1000, // 7d — longer-lived doc sessions, still bounded
730
+ maxLlmRawBytes: 256 * 1024, // keep only a bounded stdout tail from direct Claude calls
731
+ maxLlmStderrBytes: 64 * 1024, // keep only a bounded stderr tail from direct Claude calls
732
+ maxLlmLineBufferBytes: 128 * 1024, // cap the incremental JSON line buffer to avoid malformed-stream OOMs
733
+ maxReferencedPlanBytes: 12 * 1024, // cap full plan injection when implicit task context references prior plans
734
+ maxReferencedNotesBytes: 5 * 1024, // cap referenced inbox note excerpts injected via task context resolution
735
+ maxResolvedTaskContextBytes: 20 * 1024, // bound the total implicit context injected from referenced plans/notes
736
+ maxNotesPromptBytes: 8 * 1024, // cap Team Notes injected into every playbook prompt
737
+ maxMeetingPromptBytes: 16 * 1024, // cap meeting findings/debate context injected into prompts
738
+ maxMeetingHumanNotesBytes: 2 * 1024, // cap human note bullet lists injected into meeting prompts
739
+ maxPipelineMeetingContextBytes: 16 * 1024, // cap aggregated meeting/dependency context for pipeline plan generation
740
+ notesArchiveMaxFiles: 2000, // keep notes/archive bounded during periodic cleanup
690
741
  // Teams integration — config.teams shape: { enabled, appId, appPassword, certPath, privateKeyPath, tenantId, notifyEvents, ccMirror, inboxPollInterval }
691
742
  // Auth modes: (1) appId + appPassword (client secret), or (2) appId + certPath + privateKeyPath + tenantId (certificate)
692
743
  teams: {
@@ -1527,6 +1578,9 @@ module.exports = {
1527
1578
  mutatePullRequests,
1528
1579
  uid,
1529
1580
  uniquePath,
1581
+ truncateTextBytes,
1582
+ tailTextBytes,
1583
+ appendTextTail,
1530
1584
  writeToInbox, parseNoteId,
1531
1585
  exec,
1532
1586
  execAsync,
package/engine/timeout.js CHANGED
@@ -308,16 +308,27 @@ function checkTimeouts(config) {
308
308
  const procInfo = activeProcesses.get(item.id);
309
309
  if (procInfo?._steeringAt && Date.now() - procInfo._steeringAt < 60000) continue;
310
310
 
311
+ // Capture live-output.log file state for orphan/hung diagnostics (#W-mo248lkjwgsu).
312
+ // Three distinguishable failure modes:
313
+ // logExists=false → spawn call itself threw, no log ever written
314
+ // logExists=true, size~stub → process died at startup (stub-only content)
315
+ // logExists=true, size>stub → genuine hang (process alive, wrote output, then stopped)
316
+ let _logState = 'logExists=false logSize=0';
317
+ try {
318
+ const lst = fs.statSync(liveLogPath);
319
+ _logState = `logExists=true logSize=${lst.size}`;
320
+ } catch { /* ENOENT — keep default */ }
321
+
311
322
  if (!hasProcess && silentMs > effectiveTimeout && (Date.now() > engineRestartGraceUntil || engineRestartGraceExempt?.has(item.id))) {
312
323
  // No tracked process AND no recent output past effective timeout AND (grace period expired OR confirmed-dead at restart) → orphaned
313
- log('warn', `Orphan detected: ${item.agent} (${item.id}) — no process tracked, silent for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
324
+ log('warn', `Orphan detected: ${item.agent} (${item.id}) — no process tracked, silent for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''} [${_logState}]`);
314
325
  dispatch().updateAgentStatus(item.id, AGENT_STATUS.TIMED_OUT, `Orphaned — no process, silent for ${silentSec}s`);
315
326
  // Clear session so retry starts fresh
316
327
  try { shared.safeUnlink(path.join(AGENTS_DIR, item.agent, 'session.json')); } catch {}
317
328
  deadItems.push({ item, reason: `Orphaned — no process, silent for ${silentSec}s` });
318
329
  } else if (hasProcess && silentMs > effectiveTimeout) {
319
330
  // Has process but no output past effective timeout → hung
320
- log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
331
+ log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''} [${_logState}]`);
321
332
  dispatch().updateAgentStatus(item.id, AGENT_STATUS.TIMED_OUT, `Hung — no output for ${silentSec}s`);
322
333
  const procInfo = activeProcesses.get(item.id);
323
334
  if (procInfo) {
package/engine.js CHANGED
@@ -871,25 +871,16 @@ async function spawnAgent(dispatchItem, config) {
871
871
  const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
872
872
  const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args];
873
873
 
874
- const proc = runFile(process.execPath, spawnArgs, {
875
- cwd,
876
- stdio: ['pipe', 'pipe', 'pipe'],
877
- env: childEnv,
878
- });
879
-
880
- const MAX_OUTPUT = 1024 * 1024; // 1MB
881
- let stdout = '';
882
- let stderr = '';
883
- let lastOutputAt = Date.now();
884
- let heartbeatTimer = null;
885
- let _trustCheckDone = false;
886
- const _spawnTime = Date.now();
887
-
888
- // Live output file — written as data arrives so dashboard can tail it
874
+ // Live output file — stamped BEFORE child process is spawned (#W-mo248lkjwgsu).
875
+ // Writing the stub pre-spawn lets the orphan detector distinguish three failure modes
876
+ // that otherwise look identical:
877
+ // 1. No log file → spawn call itself threw before reaching this line
878
+ // 2. Log has stub only → process started but died before its first write
879
+ // 3. Log has stub + ... → process alive but hung (the only case that warrants orphan kill+retry)
889
880
  const liveOutputPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
890
881
 
891
882
  // Rotate previous live output to preserve session history (fixes #543: orphan recovery overwrites)
892
- // Only rotate if the existing file has meaningful content (beyond just the header)
883
+ // Only rotate if the existing file has meaningful content (beyond just the header stub)
893
884
  const LIVE_OUTPUT_SPARSE_THRESHOLD = 500; // bytes — header + init JSON is typically < 500
894
885
  try {
895
886
  if (fs.existsSync(liveOutputPath)) {
@@ -901,7 +892,37 @@ async function spawnAgent(dispatchItem, config) {
901
892
  }
902
893
  } catch { /* rotation is best-effort — overwrite still happens below */ }
903
894
 
904
- safeWrite(liveOutputPath, `# Live output for ${agentId} ${id}\n# Started: ${startedAt}\n# Task: ${dispatchItem.task}\n\n`);
895
+ // Stamp the log synchronously before spawn. If the synchronous write throws,
896
+ // we still attempt to spawn (log-file stamp failure must not block the agent),
897
+ // but we note it so the diagnostic trail isn't silent either.
898
+ try {
899
+ safeWrite(liveOutputPath, `# Live output for ${agentId} — ${id}\n# Started: ${startedAt}\n# Task: ${dispatchItem.task}\n[${new Date().toISOString()}] spawn: agent=${agentId} item=${id}\n\n`);
900
+ } catch (stubErr) {
901
+ log('warn', `Failed to stamp live-output stub for ${agentId} (${id}): ${stubErr.message}`);
902
+ }
903
+
904
+ let proc;
905
+ try {
906
+ proc = runFile(process.execPath, spawnArgs, {
907
+ cwd,
908
+ stdio: ['pipe', 'pipe', 'pipe'],
909
+ env: childEnv,
910
+ });
911
+ } catch (spawnErr) {
912
+ // Synchronous spawn failure — record it to the (already-stamped) log so the
913
+ // orphan detector's "logSize > stub-only" check can tell this apart from a
914
+ // hung process. Then rethrow so the dispatch loop handles it normally.
915
+ try { fs.appendFileSync(liveOutputPath, `[${new Date().toISOString()}] spawn-failed: ${spawnErr.message}\n[process-exit] spawn-failed\n`); } catch { /* cleanup-only best effort */ }
916
+ throw spawnErr;
917
+ }
918
+
919
+ const MAX_OUTPUT = 1024 * 1024; // 1MB
920
+ let stdout = '';
921
+ let stderr = '';
922
+ let lastOutputAt = Date.now();
923
+ let heartbeatTimer = null;
924
+ let _trustCheckDone = false;
925
+ const _spawnTime = Date.now();
905
926
 
906
927
  // Keep live log active even when the agent produces no stdout/stderr for long stretches.
907
928
  // This makes "silent but running" states visible in the dashboard tail view.
@@ -2491,9 +2512,10 @@ function normalizeAc(ac) {
2491
2512
  function buildWorkItemDispatchVars(item, vars, config, options = {}) {
2492
2513
  const { worktreePath, includeNotes = true, workType } = options;
2493
2514
 
2494
- // Notes content (uses queries.getNotes instead of inline fs.readFileSync)
2515
+ // Team notes are injected once in renderPlaybook(). Keep notes_content as a
2516
+ // lightweight pointer so prompts do not duplicate the full notes.md blob.
2495
2517
  if (includeNotes) {
2496
- vars.notes_content = getNotes() || '';
2518
+ vars.notes_content = 'See the **Team Notes** section above for the latest shared team context.';
2497
2519
  }
2498
2520
 
2499
2521
  // References
@@ -2540,7 +2562,7 @@ function buildWorkItemDispatchVars(item, vars, config, options = {}) {
2540
2562
  if (workType === WORK_TYPE.ASK) {
2541
2563
  vars.question = item.title + (item.description ? '\n\n' + item.description : '');
2542
2564
  vars.task_id = item.id;
2543
- vars.notes_content = getNotes() || '';
2565
+ vars.notes_content = 'See the **Team Notes** section above for the latest shared team context.';
2544
2566
  }
2545
2567
 
2546
2568
  // Resolve implicit context references (e.g., "ripley's plan", "the latest plan")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1046",
3
+ "version": "0.1.1048",
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"