@yemi33/minions 0.1.1732 → 0.1.1733

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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1733 (2026-05-05)
4
+
5
+ ### Features
6
+ - dryRun resolveAgent, bounded mainBranch cache, SSE heartbeat helper
7
+
3
8
  ## 0.1.1732 (2026-05-05)
4
9
 
5
10
  ### Features
package/dashboard.js CHANGED
@@ -472,6 +472,43 @@ let HTML = HTML_RAW;
472
472
  let HTML_GZ = zlib.gzipSync(HTML);
473
473
  let HTML_ETAG = '"' + require('crypto').createHash('md5').update(HTML).digest('hex') + '"';
474
474
 
475
+ const SSE_CLIENT_HEARTBEAT_MS = 30000;
476
+ const _sseClientCleanups = new WeakMap();
477
+ function _removeSseClient(clientSet, res) {
478
+ const cleanup = _sseClientCleanups.get(res);
479
+ if (cleanup) cleanup();
480
+ else clientSet.delete(res);
481
+ }
482
+
483
+ function _trackSseClient(clientSet, req, res, { heartbeatMs = SSE_CLIENT_HEARTBEAT_MS } = {}) {
484
+ let closed = false;
485
+ let heartbeatTimer = null;
486
+ const cleanup = () => {
487
+ if (closed) return;
488
+ closed = true;
489
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
490
+ heartbeatTimer = null;
491
+ clientSet.delete(res);
492
+ _sseClientCleanups.delete(res);
493
+ };
494
+ const heartbeat = () => {
495
+ if (res.destroyed || res.writableEnded) {
496
+ cleanup();
497
+ return;
498
+ }
499
+ try { res.write(': heartbeat\n\n'); } catch { cleanup(); }
500
+ };
501
+ heartbeatTimer = setInterval(heartbeat, heartbeatMs);
502
+ if (heartbeatTimer.unref) heartbeatTimer.unref();
503
+ _sseClientCleanups.set(res, cleanup);
504
+ clientSet.add(res);
505
+ req.on('close', cleanup);
506
+ req.on('aborted', cleanup);
507
+ res.on('close', cleanup);
508
+ res.on('error', cleanup);
509
+ return cleanup;
510
+ }
511
+
475
512
  // Hot-reload: watch dashboard/ directory for changes, rebuild, and push reload to browsers
476
513
  const _hotReloadClients = new Set();
477
514
 
@@ -486,11 +523,11 @@ function rebuildDashboardHtml() {
486
523
  console.log(' Dashboard hot-reloaded');
487
524
  // Push reload to all connected browsers via status-stream (saves a connection)
488
525
  for (const res of _statusStreamClients) {
489
- try { res.write('event: reload\ndata: reload\n\n'); } catch { _statusStreamClients.delete(res); }
526
+ try { res.write('event: reload\ndata: reload\n\n'); } catch { _removeSseClient(_statusStreamClients, res); }
490
527
  }
491
528
  // Legacy hot-reload clients
492
529
  for (const res of _hotReloadClients) {
493
- try { res.write('data: reload\n\n'); } catch { _hotReloadClients.delete(res); }
530
+ try { res.write('data: reload\n\n'); } catch { _removeSseClient(_hotReloadClients, res); }
494
531
  }
495
532
  } catch (e) { console.error(' Hot-reload error:', e.message); }
496
533
  }
@@ -742,7 +779,7 @@ function invalidateStatusCache(opts) {
742
779
  if (_statusStreamClients.size === 0) return;
743
780
  const data = getStatusJson();
744
781
  for (const res of _statusStreamClients) {
745
- try { res.write('data: ' + data + '\n\n'); } catch { _statusStreamClients.delete(res); }
782
+ try { res.write('data: ' + data + '\n\n'); } catch { _removeSseClient(_statusStreamClients, res); }
746
783
  }
747
784
  }, 500);
748
785
  }
@@ -874,7 +911,7 @@ setInterval(() => {
874
911
  if (data === _lastStatusPushRef) return; // O(1) reference comparison — new string ref means content changed
875
912
  _lastStatusPushRef = data;
876
913
  for (const res of _statusStreamClients) {
877
- try { res.write('data: ' + data + '\n\n'); } catch { _statusStreamClients.delete(res); }
914
+ try { res.write('data: ' + data + '\n\n'); } catch { _removeSseClient(_statusStreamClients, res); }
878
915
  }
879
916
  }, 10000);
880
917
 
@@ -894,6 +931,7 @@ const CC_LOCK_WAIT_MS = 200; // grace period for previous handler's finally to r
894
931
  const CC_STREAM_HEARTBEAT_MS = 15000; // keep streaming responses alive across proxies/restart races
895
932
  const CC_STREAM_REATTACH_GRACE_MS = 60000; // keep CC job alive briefly after disconnect so the UI can reattach
896
933
  const CC_STREAM_DONE_RETENTION_MS = 30000; // retain final payload briefly so reconnect can still receive it
934
+ const CC_LIVE_STREAM_MAX_AGE_MS = shared.ENGINE_DEFAULTS.ccLiveStreamMaxAgeMs;
897
935
  // Doc-chat is interactive — long-doc edits with multi-step Read+Write tool use can run
898
936
  // 4–5 min on `canEdit:true` paths. CC's default 2-min timeout was killing legitimate
899
937
  // edits mid-stream. Pinned to 6 min as the bounded but generous ceiling.
@@ -902,6 +940,9 @@ function _releaseCCTab(tabId) { ccInFlightTabs.delete(tabId); ccInFlightAborts.d
902
940
  function _getCcLiveStream(tabId) {
903
941
  return ccLiveStreams.get(tabId) || null;
904
942
  }
943
+ function _touchCcLiveStream(state) {
944
+ if (state) state.updatedAt = Date.now();
945
+ }
905
946
  function _clearCcLiveTimers(tabId) {
906
947
  const state = _getCcLiveStream(tabId);
907
948
  if (!state) return;
@@ -934,6 +975,8 @@ function _ensureCcLiveStream(tabId) {
934
975
  abortFn: null,
935
976
  abortTimer: null,
936
977
  cleanupTimer: null,
978
+ createdAt: Date.now(),
979
+ updatedAt: Date.now(),
937
980
  };
938
981
  ccLiveStreams.set(tabId, state);
939
982
  return state;
@@ -943,6 +986,7 @@ function _attachCcLiveStream(tabId, writer, endResponse) {
943
986
  _clearCcLiveTimers(tabId);
944
987
  state.writer = writer;
945
988
  state.endResponse = endResponse;
989
+ _touchCcLiveStream(state);
946
990
  return state;
947
991
  }
948
992
  function _detachCcLiveStream(tabId, writer) {
@@ -961,7 +1005,9 @@ function _scheduleCcLiveAbort(tabId) {
961
1005
  const live = _getCcLiveStream(tabId);
962
1006
  if (!live || live.donePayload || live.writer) return;
963
1007
  try { if (live.abortFn) live.abortFn(); } catch {}
1008
+ _scheduleCcLiveCleanup(tabId, CC_STREAM_REATTACH_GRACE_MS);
964
1009
  }, CC_STREAM_REATTACH_GRACE_MS);
1010
+ if (state.abortTimer.unref) state.abortTimer.unref();
965
1011
  }
966
1012
  function _scheduleCcLiveCleanup(tabId, delayMs = CC_STREAM_DONE_RETENTION_MS) {
967
1013
  const state = _getCcLiveStream(tabId);
@@ -972,7 +1018,25 @@ function _scheduleCcLiveCleanup(tabId, delayMs = CC_STREAM_DONE_RETENTION_MS) {
972
1018
  if (!live || live.writer) return;
973
1019
  _clearCcLiveStream(tabId);
974
1020
  }, delayMs);
1021
+ if (state.cleanupTimer.unref) state.cleanupTimer.unref();
1022
+ }
1023
+ function _sweepCcLiveStreams() {
1024
+ const now = Date.now();
1025
+ for (const [tabId, state] of ccLiveStreams.entries()) {
1026
+ const age = now - (state.updatedAt || state.createdAt || now);
1027
+ if (state.donePayload && !state.writer && age > CC_STREAM_DONE_RETENTION_MS) {
1028
+ _clearCcLiveStream(tabId);
1029
+ continue;
1030
+ }
1031
+ if (age <= CC_LIVE_STREAM_MAX_AGE_MS) continue;
1032
+ try { if (state.abortFn) state.abortFn(); } catch {}
1033
+ try { if (state.endResponse) state.endResponse(); } catch {}
1034
+ _releaseCCTab(tabId);
1035
+ _clearCcLiveStream(tabId);
1036
+ }
975
1037
  }
1038
+ const _ccLiveSweepTimer = setInterval(_sweepCcLiveStreams, Math.min(CC_LIVE_STREAM_MAX_AGE_MS, 5 * 60 * 1000));
1039
+ if (_ccLiveSweepTimer.unref) _ccLiveSweepTimer.unref();
976
1040
  function _ccTabIsInFlight(tabId) {
977
1041
  if (!ccInFlightTabs.has(tabId)) return false;
978
1042
  // Auto-release stale locks — if a request has been in-flight longer than CC_INFLIGHT_TIMEOUT_MS,
@@ -1797,7 +1861,7 @@ async function executeCCActions(actions) {
1797
1861
  // Pre-flight routing check: warn the user if no agent is currently available so the new
1798
1862
  // item won't sit pending invisibly. Routing failure is non-fatal — the WI was created.
1799
1863
  try {
1800
- const resolvedAgent = routing.resolveAgent(workType, CONFIG, { agentHints });
1864
+ const resolvedAgent = routing.resolveAgent(workType, CONFIG, { agentHints, dryRun: true });
1801
1865
  if (!resolvedAgent) {
1802
1866
  const lastResult = results[results.length - 1];
1803
1867
  lastResult.warning = `Created ${id} but no agent is currently available to dispatch (routing returned no match for workType=${workType}${agentHints.length ? ', hints=' + agentHints.join(',') : ''}). Item will sit pending until an agent becomes available.`;
@@ -1841,12 +1905,20 @@ async function executeCCActions(actions) {
1841
1905
  project_path: project.localPath || '',
1842
1906
  task: `Build & test ${pr.id}: ${pr.title || ''}`,
1843
1907
  }, `Build & test ${pr.id}: ${pr.title || ''}`,
1844
- { dispatchKey, source: 'cc-build-and-test', pr, branch: pr.branch, project: { name: project.name, localPath: project.localPath } });
1908
+ { dispatchKey, source: 'cc-build-and-test', pr, branch: pr.branch, project: { name: project.name, localPath: project.localPath } });
1845
1909
  if (!item) {
1910
+ if (agentId?.startsWith('temp-')) {
1911
+ routing.tempAgents.delete(agentId);
1912
+ routing._claimedAgents.delete(agentId);
1913
+ }
1846
1914
  results.push({ type: 'build-and-test', error: 'Failed to render build-and-test playbook' });
1847
1915
  break;
1848
1916
  }
1849
1917
  const id = dispatchMod.addToDispatch(item);
1918
+ if (agentId?.startsWith('temp-')) {
1919
+ routing.tempAgents.delete(agentId);
1920
+ routing._claimedAgents.delete(agentId);
1921
+ }
1850
1922
  results.push({ type: 'build-and-test', id, agent: agentId, pr: pr.id, ok: true });
1851
1923
  break;
1852
1924
  }
@@ -1975,8 +2047,36 @@ async function executeDocChatActions(actions) {
1975
2047
  const CC_SESSIONS_PATH = path.join(ENGINE_DIR, 'cc-sessions.json');
1976
2048
  const DOC_SESSIONS_PATH = path.join(ENGINE_DIR, 'doc-sessions.json');
1977
2049
  const DOC_SESSION_TTL_MS = shared.ENGINE_DEFAULTS.docSessionTtlMs;
2050
+ const DOC_SESSION_MAX_ENTRIES = shared.ENGINE_DEFAULTS.docSessionMaxEntries;
1978
2051
  const docSessions = new Map(); // key → { sessionId, lastActiveAt, turnCount }
1979
2052
 
2053
+ function _docSessionLastActiveMs(session) {
2054
+ const ms = Date.parse(session?.lastActiveAt || session?.createdAt || '');
2055
+ return Number.isFinite(ms) ? ms : 0;
2056
+ }
2057
+
2058
+ function pruneDocSessions() {
2059
+ let changed = false;
2060
+ for (const [key, s] of docSessions.entries()) {
2061
+ if (!s || (s.turnCount || 0) >= CC_SESSION_MAX_TURNS ||
2062
+ _sessionExpired(s.lastActiveAt || s.createdAt, DOC_SESSION_TTL_MS) ||
2063
+ s._promptHash !== _docChatPromptHash) {
2064
+ docSessions.delete(key);
2065
+ changed = true;
2066
+ }
2067
+ }
2068
+ const maxEntries = Number(DOC_SESSION_MAX_ENTRIES) > 0 ? Number(DOC_SESSION_MAX_ENTRIES) : 200;
2069
+ if (docSessions.size > maxEntries) {
2070
+ const oldest = Array.from(docSessions.entries())
2071
+ .sort((a, b) => _docSessionLastActiveMs(a[1]) - _docSessionLastActiveMs(b[1]));
2072
+ for (const [key] of oldest.slice(0, docSessions.size - maxEntries)) {
2073
+ docSessions.delete(key);
2074
+ changed = true;
2075
+ }
2076
+ }
2077
+ return changed;
2078
+ }
2079
+
1980
2080
  // Load persisted doc sessions on startup
1981
2081
  try {
1982
2082
  const saved = safeJson(DOC_SESSIONS_PATH);
@@ -1986,15 +2086,22 @@ try {
1986
2086
  if (_sessionExpired(s.lastActiveAt || s.createdAt, DOC_SESSION_TTL_MS)) continue;
1987
2087
  docSessions.set(key, s);
1988
2088
  }
2089
+ pruneDocSessions();
1989
2090
  }
1990
2091
  } catch { /* optional */ }
1991
2092
 
1992
2093
  function persistDocSessions() {
2094
+ pruneDocSessions();
1993
2095
  const obj = {};
1994
2096
  for (const [key, s] of docSessions) obj[key] = s;
1995
2097
  safeWrite(DOC_SESSIONS_PATH, obj);
1996
2098
  }
1997
2099
 
2100
+ const _docSessionPruneTimer = setInterval(() => {
2101
+ if (pruneDocSessions()) persistDocSessions();
2102
+ }, Math.min(DOC_SESSION_TTL_MS, 60 * 60 * 1000));
2103
+ if (_docSessionPruneTimer.unref) _docSessionPruneTimer.unref();
2104
+
1998
2105
  // Debounced variant — coalesces rapid writes (e.g. back-to-back doc-chat turns)
1999
2106
  let _persistDocSessionsTimer = null;
2000
2107
  function schedulePersistDocSessions() {
@@ -2062,6 +2169,7 @@ function updateSession(store, key, sessionId, existing) {
2062
2169
  _docHash: prev?._docHash || null,
2063
2170
  _promptHash: _docChatPromptHash,
2064
2171
  });
2172
+ pruneDocSessions();
2065
2173
  schedulePersistDocSessions();
2066
2174
  }
2067
2175
  }
@@ -5255,6 +5363,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5255
5363
  sessionId, effort, direct: true,
5256
5364
  engineConfig,
5257
5365
  onChunk: (text) => {
5366
+ _touchCcLiveStream(liveState);
5258
5367
  const display = stripCCActionsForStream(text);
5259
5368
  liveState.text = display;
5260
5369
  // Once text is flowing, the SSE-replay branch (live.thinkingSent &&
@@ -5263,11 +5372,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
5263
5372
  if (liveState.writer) liveState.writer({ type: 'chunk', text: display });
5264
5373
  },
5265
5374
  onToolUse: (name, input) => {
5375
+ _touchCcLiveStream(liveState);
5266
5376
  toolUses.push({ name, input: input || {} });
5267
5377
  liveState.tools.push({ name, input: input || {} });
5268
5378
  if (liveState.writer) liveState.writer({ type: 'tool', name, input: _lightToolInput(input) });
5269
5379
  },
5270
5380
  onThinking: () => {
5381
+ _touchCcLiveStream(liveState);
5271
5382
  liveState.thinkingSent = true;
5272
5383
  if (liveState.writer) liveState.writer({ type: 'thinking', text: 'Thinking...' });
5273
5384
  },
@@ -6318,15 +6429,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
6318
6429
  { method: 'GET', path: '/api/status-stream', desc: 'SSE stream of real-time status updates', handler: (req, res) => {
6319
6430
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
6320
6431
  res.write('data: ' + getStatusJson() + '\n\n');
6321
- _statusStreamClients.add(res);
6322
- req.on('close', () => _statusStreamClients.delete(res));
6432
+ _trackSseClient(_statusStreamClients, req, res);
6323
6433
  }},
6324
6434
  { method: 'GET', path: '/api/health', desc: 'Lightweight health check for monitoring', handler: handleHealth },
6325
6435
  { method: 'GET', path: '/api/hot-reload', desc: 'SSE stream for dashboard hot-reload notifications', handler: (req, res) => {
6326
6436
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
6327
6437
  res.write('data: connected\n\n');
6328
- _hotReloadClients.add(res);
6329
- req.on('close', () => _hotReloadClients.delete(res));
6438
+ _trackSseClient(_hotReloadClients, req, res);
6330
6439
  }},
6331
6440
 
6332
6441
  // Work items
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-05T09:53:42.976Z"
4
+ "cachedAt": "2026-05-05T20:52:36.454Z"
5
5
  }
package/engine/routing.js CHANGED
@@ -190,7 +190,7 @@ function normalizeAgentHints(agentHints, authorAgent = null, agents = null) {
190
190
  }
191
191
 
192
192
  function resolveAgent(workType, config, opts = {}) {
193
- const { authorAgent = null, agentHints = null } = opts || {};
193
+ const { authorAgent = null, agentHints = null, dryRun = false } = opts || {};
194
194
  const route = routeForWorkType(workType);
195
195
  const agents = config.agents || {};
196
196
 
@@ -199,7 +199,7 @@ function resolveAgent(workType, config, opts = {}) {
199
199
  let fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
200
200
 
201
201
  const isAvailable = (id) => {
202
- if (!agents[id] || !isAgentIdle(id) || _claimedAgents.has(id)) return false;
202
+ if (!agents[id] || !isAgentIdle(id) || (!dryRun && _claimedAgents.has(id))) return false;
203
203
  // Budget check — no budget means infinite (no limit)
204
204
  const budget = agents[id].monthlyBudgetUsd;
205
205
  if (budget && budget > 0) {
@@ -214,23 +214,23 @@ function resolveAgent(workType, config, opts = {}) {
214
214
  const idle = Object.keys(agents)
215
215
  .filter(id => !excludeSet.has(id) && isAvailable(id))
216
216
  .sort((a, b) => getAgentErrorRate(a) - getAgentErrorRate(b));
217
- if (idle[0]) { _claimedAgents.add(idle[0]); return idle[0]; }
217
+ if (idle[0]) { if (!dryRun) _claimedAgents.add(idle[0]); return idle[0]; }
218
218
  return null;
219
219
  };
220
220
 
221
221
  const hintedAgents = normalizeAgentHints(agentHints, authorAgent, agents);
222
222
  if (hintedAgents.length > 0) {
223
223
  for (const id of hintedAgents) {
224
- if (isAvailable(id)) { _claimedAgents.add(id); return id; }
224
+ if (isAvailable(id)) { if (!dryRun) _claimedAgents.add(id); return id; }
225
225
  }
226
226
  }
227
227
 
228
228
  // Resolve _any_ token — pick any available agent (#480)
229
229
  if (preferred === ANY_AGENT) { const pick = pickAnyIdle(); if (pick) return pick; }
230
- else if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
230
+ else if (preferred && isAvailable(preferred)) { if (!dryRun) _claimedAgents.add(preferred); return preferred; }
231
231
 
232
232
  if (fallback === ANY_AGENT) { const pick = pickAnyIdle([preferred]); if (pick) return pick; }
233
- else if (fallback && isAvailable(fallback)) { _claimedAgents.add(fallback); return fallback; }
233
+ else if (fallback && isAvailable(fallback)) { if (!dryRun) _claimedAgents.add(fallback); return fallback; }
234
234
 
235
235
  // Fall back to any idle agent, preferring lower error rates
236
236
  const anyIdle = pickAnyIdle([preferred, fallback]);
@@ -247,6 +247,7 @@ function resolveAgent(workType, config, opts = {}) {
247
247
  log('info', `Temp agent refused for ${workType} — per-tick budget exhausted (maxConcurrent reached)`);
248
248
  return null;
249
249
  }
250
+ if (dryRun) return 'temp-preview';
250
251
  _tempBudget--;
251
252
  const tempId = `temp-${shared.uid()}`;
252
253
  _claimedAgents.add(tempId);
package/engine/shared.js CHANGED
@@ -684,10 +684,34 @@ function execAsync(cmd, opts = {}) {
684
684
  * Cached per rootDir to avoid repeated git calls within a tick.
685
685
  */
686
686
  const _mainBranchCache = new Map();
687
+ function _pruneTimedMap(map, { maxEntries, ttlMs, getTs, now = Date.now() }) {
688
+ const max = Number(maxEntries) > 0 ? Number(maxEntries) : Infinity;
689
+ const ttl = Number(ttlMs) > 0 ? Number(ttlMs) : Infinity;
690
+ for (const [key, value] of map) {
691
+ const entryTs = Number(getTs(value)) || 0;
692
+ if (entryTs <= 0 || now - entryTs > ttl) map.delete(key);
693
+ }
694
+ if (map.size <= max) return;
695
+ const oldest = Array.from(map.entries())
696
+ .sort((a, b) => (Number(getTs(a[1])) || 0) - (Number(getTs(b[1])) || 0));
697
+ for (const [key] of oldest.slice(0, Math.max(0, map.size - max))) map.delete(key);
698
+ }
699
+
700
+ function _setMainBranchCache(cacheKey, branch) {
701
+ _pruneTimedMap(_mainBranchCache, {
702
+ maxEntries: ENGINE_DEFAULTS.mainBranchCacheMaxEntries,
703
+ ttlMs: ENGINE_DEFAULTS.mainBranchCacheTtlMs,
704
+ getTs: entry => entry?.ts,
705
+ });
706
+ _mainBranchCache.set(cacheKey, { branch, ts: Date.now() });
707
+ }
708
+
687
709
  function resolveMainBranch(rootDir, configuredBranch) {
688
710
  const cacheKey = rootDir + ':' + (configuredBranch || '');
711
+ // _setMainBranchCache prunes on every write, which bounds the map without
712
+ // an extra O(n) scan on hits. The TTL check below handles per-entry expiry.
689
713
  const cached = _mainBranchCache.get(cacheKey);
690
- if (cached && (Date.now() - cached.ts) < 300000) return cached.branch; // 5min TTL
714
+ if (cached && (Date.now() - cached.ts) < ENGINE_DEFAULTS.mainBranchCacheTtlMs) return cached.branch;
691
715
 
692
716
  const gitOpts = { cwd: rootDir, encoding: 'utf8', stdio: 'pipe', timeout: 5000, windowsHide: true };
693
717
 
@@ -695,12 +719,12 @@ function resolveMainBranch(rootDir, configuredBranch) {
695
719
  if (configuredBranch) {
696
720
  try {
697
721
  _execSync(`git rev-parse --verify "${configuredBranch}"`, gitOpts);
698
- _mainBranchCache.set(cacheKey, { branch: configuredBranch, ts: Date.now() });
722
+ _setMainBranchCache(cacheKey, configuredBranch);
699
723
  return configuredBranch;
700
724
  } catch { /* configured branch doesn't exist locally */ }
701
725
  try {
702
726
  _execSync(`git rev-parse --verify "origin/${configuredBranch}"`, gitOpts);
703
- _mainBranchCache.set(cacheKey, { branch: configuredBranch, ts: Date.now() });
727
+ _setMainBranchCache(cacheKey, configuredBranch);
704
728
  return configuredBranch;
705
729
  } catch { /* not on remote either */ }
706
730
  }
@@ -710,14 +734,14 @@ function resolveMainBranch(rootDir, configuredBranch) {
710
734
  const ref = _execSync('git symbolic-ref refs/remotes/origin/HEAD', gitOpts).trim();
711
735
  const branch = ref.replace('refs/remotes/origin/', '');
712
736
  if (branch) {
713
- _mainBranchCache.set(cacheKey, { branch, ts: Date.now() });
737
+ _setMainBranchCache(cacheKey, branch);
714
738
  return branch;
715
739
  }
716
740
  } catch { /* no remote HEAD set */ }
717
741
 
718
742
  // 3. Fallback
719
743
  const fallback = configuredBranch || 'main';
720
- _mainBranchCache.set(cacheKey, { branch: fallback, ts: Date.now() });
744
+ _setMainBranchCache(cacheKey, fallback);
721
745
  return fallback;
722
746
  }
723
747
 
@@ -885,9 +909,15 @@ const ENGINE_DEFAULTS = {
885
909
  maxPendingContextEntryBytes: 256 * 1024, // 256 KB — cap each pendingContexts entry to prevent huge PR comments from bloating cooldowns.json
886
910
  maxDispatchPromptBytes: 1024 * 1024, // 1 MB — dispatch items with prompts larger than this sidecar to engine/contexts/ to prevent dispatch.json OOM (#1167)
887
911
  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)
912
+ mainBranchCacheTtlMs: 300000, // 5min — cache git default-branch detection, then prune expired entries
913
+ mainBranchCacheMaxEntries: 100, // bound repo/branch detection cache in long-lived dashboard/engine processes
914
+ removeWorktreeFailureTtlMs: 24 * 60 * 60 * 1000, // stale failed paths are forgotten after a day
915
+ removeWorktreeFailureMaxEntries: 1000, // bound failed-worktree retry suppression cache
888
916
  ccMaxTurns: 50, // max tool-use turns for CC/doc-chat before CLI stops
889
917
  ccSessionTtlMs: 7 * 24 * 60 * 60 * 1000, // 7d — keep chats resumable after breaks, still bounded by turn cap
890
918
  docSessionTtlMs: 7 * 24 * 60 * 60 * 1000, // 7d — longer-lived doc sessions, still bounded
919
+ docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity
920
+ ccLiveStreamMaxAgeMs: 30 * 60 * 1000, // hard cap reconnect buffers if abort/cleanup stalls
891
921
  maxLlmRawBytes: 256 * 1024, // keep only a bounded stdout tail from direct Claude calls
892
922
  maxLlmStderrBytes: 64 * 1024, // keep only a bounded stderr tail from direct Claude calls
893
923
  maxLlmLineBufferBytes: 128 * 1024, // cap the incremental JSON line buffer to avoid malformed-stream OOMs
@@ -2485,6 +2515,13 @@ function mutatePullRequests(filePath, mutator) {
2485
2515
  * 2. cmd /c rd /s /q as final fallback handles any remaining reserved names
2486
2516
  */
2487
2517
  const _removeWorktreeFailures = new Map(); // path → { count, lastAttempt }
2518
+ function _pruneRemoveWorktreeFailures() {
2519
+ _pruneTimedMap(_removeWorktreeFailures, {
2520
+ maxEntries: ENGINE_DEFAULTS.removeWorktreeFailureMaxEntries,
2521
+ ttlMs: ENGINE_DEFAULTS.removeWorktreeFailureTtlMs,
2522
+ getTs: entry => entry?.lastAttempt,
2523
+ });
2524
+ }
2488
2525
 
2489
2526
  // Windows reserved device names that cannot be deleted via normal paths
2490
2527
  const _WIN_RESERVED_NAMES = new Set([
@@ -2583,6 +2620,7 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot) {
2583
2620
  log('warn', `removeWorktree: refusing to remove ${wtPath} — not under ${worktreeRoot}`);
2584
2621
  return false;
2585
2622
  }
2623
+ _pruneRemoveWorktreeFailures();
2586
2624
  // Skip paths that failed 3+ times — retry after 1 hour cooldown
2587
2625
  const prior = _removeWorktreeFailures.get(resolved);
2588
2626
  if (prior && prior.count >= 3 && Date.now() - prior.lastAttempt < 3600000) return false;
@@ -2619,6 +2657,7 @@ function removeWorktree(wtPath, gitRoot, worktreeRoot) {
2619
2657
  fail.count++;
2620
2658
  fail.lastAttempt = Date.now();
2621
2659
  _removeWorktreeFailures.set(resolved, fail);
2660
+ _pruneRemoveWorktreeFailures();
2622
2661
  if (fail.count <= 3) log('warn', `removeWorktree: failed for ${wtPath} (attempt ${fail.count}/3): ${rmErr.message}`);
2623
2662
  return false;
2624
2663
  }
package/engine.js CHANGED
@@ -154,6 +154,17 @@ const realActivityMap = new Map(); // dispatchId → timestamp of last agent std
154
154
  let engineRestartGraceUntil = 0; // timestamp — suppress orphan detection until this time
155
155
  const engineRestartGraceExempt = new Set(); // dispatch IDs with confirmed-dead PIDs at restart — bypass grace period
156
156
 
157
+ function cleanupTempAgent(agentId) {
158
+ if (!tempAgents.has(agentId)) return;
159
+ tempAgents.delete(agentId);
160
+ try {
161
+ const agentDir = path.join(AGENTS_DIR, agentId);
162
+ // Keep output archive but remove temp agent directory (live-output.log etc.)
163
+ fs.rmSync(agentDir, { recursive: true, force: true });
164
+ log('info', `Temp agent ${agentId} cleaned up`);
165
+ } catch { /* cleanup */ }
166
+ }
167
+
157
168
  // Per-tick cache of refs that failed to fetch — avoids repeating 30s ETIMEDOUT for same missing ref
158
169
  // Cleared at the start of each tick cycle (see tickInner)
159
170
  const _failedRefCache = new Set();
@@ -692,6 +703,7 @@ async function spawnAgent(dispatchItem, config) {
692
703
  log('error', `Failed to create worktree for ${branchName}: ${err.message}${err.stderr ? '\n' + err.stderr.toString().slice(0, 500) : ''}`);
693
704
  _cleanupPromptFiles();
694
705
  completeDispatch(id, DISPATCH_RESULT.ERROR, 'Worktree creation failed: ' + (err.message || '').slice(0, 200));
706
+ cleanupTempAgent(agentId);
695
707
  return null;
696
708
  }
697
709
  }
@@ -945,6 +957,7 @@ async function spawnAgent(dispatchItem, config) {
945
957
  });
946
958
  } catch (e) { log('warn', `Failed to auto-queue conflict-fix: ${e.message}`); }
947
959
  }
960
+ cleanupTempAgent(agentId);
948
961
  return;
949
962
  }
950
963
  } catch (e) {
@@ -1091,6 +1104,7 @@ async function spawnAgent(dispatchItem, config) {
1091
1104
  // orphan detector's "logSize > stub-only" check can tell this apart from a
1092
1105
  // hung process. Then rethrow so the dispatch loop handles it normally.
1093
1106
  try { fs.appendFileSync(liveOutputPath, `[${new Date().toISOString()}] spawn-failed: ${spawnErr.message}\n[process-exit] spawn-failed\n`); } catch { /* cleanup-only best effort */ }
1107
+ cleanupTempAgent(agentId);
1094
1108
  throw spawnErr;
1095
1109
  }
1096
1110
 
@@ -1222,6 +1236,7 @@ async function spawnAgent(dispatchItem, config) {
1222
1236
  try { fs.appendFileSync(liveOutputPath, `\n[steering-failed] No session to resume. Message was: ${steerMsg}\n`); } catch {}
1223
1237
  activeProcesses.delete(id);
1224
1238
  completeDispatch(id, DISPATCH_RESULT.SUCCESS, 'Steering skipped (no session)', '', { processWorkItemFailure: false });
1239
+ cleanupTempAgent(agentId);
1225
1240
  return;
1226
1241
  }
1227
1242
 
@@ -1252,6 +1267,7 @@ async function spawnAgent(dispatchItem, config) {
1252
1267
  try { fs.appendFileSync(liveOutputPath, `\n[steering-failed] Could not write prompt. Message was: ${steerMsg}\n`); } catch {}
1253
1268
  activeProcesses.delete(id);
1254
1269
  completeDispatch(id, DISPATCH_RESULT.SUCCESS, 'Steering prompt write failed', '', { processWorkItemFailure: false });
1270
+ cleanupTempAgent(agentId);
1255
1271
  return;
1256
1272
  }
1257
1273
 
@@ -1274,6 +1290,7 @@ async function spawnAgent(dispatchItem, config) {
1274
1290
  try { fs.unlinkSync(steerPromptPath); } catch {}
1275
1291
  activeProcesses.delete(id);
1276
1292
  completeDispatch(id, DISPATCH_RESULT.SUCCESS, 'Steering not supported by runtime', '', { processWorkItemFailure: false });
1293
+ cleanupTempAgent(agentId);
1277
1294
  return;
1278
1295
  }
1279
1296
 
@@ -1302,6 +1319,7 @@ async function spawnAgent(dispatchItem, config) {
1302
1319
  try { fs.unlinkSync(steerPromptPath); } catch {}
1303
1320
  activeProcesses.delete(id);
1304
1321
  completeDispatch(id, DISPATCH_RESULT.SUCCESS, 'Steering spawn failed', '', { processWorkItemFailure: false });
1322
+ cleanupTempAgent(agentId);
1305
1323
  return;
1306
1324
  }
1307
1325
 
@@ -1420,6 +1438,7 @@ async function spawnAgent(dispatchItem, config) {
1420
1438
  try { fs.unlinkSync(sysPromptPath); } catch { /* cleanup */ }
1421
1439
  try { fs.unlinkSync(promptPath); } catch { /* cleanup */ }
1422
1440
  try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch { /* cleanup */ }
1441
+ cleanupTempAgent(agentId);
1423
1442
  return;
1424
1443
  }
1425
1444
 
@@ -1443,6 +1462,7 @@ async function spawnAgent(dispatchItem, config) {
1443
1462
  try { fs.unlinkSync(sysPromptPath); } catch { /* cleanup */ }
1444
1463
  try { fs.unlinkSync(promptPath); } catch { /* cleanup */ }
1445
1464
  try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch { /* cleanup */ }
1465
+ cleanupTempAgent(agentId);
1446
1466
  return;
1447
1467
  }
1448
1468
 
@@ -1539,16 +1559,7 @@ async function spawnAgent(dispatchItem, config) {
1539
1559
  } catch (err) { log('warn', `Artifact tracking: ${err.message}`); }
1540
1560
  }
1541
1561
 
1542
- // Clean up temp agent directory
1543
- if (tempAgents.has(agentId)) {
1544
- tempAgents.delete(agentId);
1545
- try {
1546
- const agentDir = path.join(AGENTS_DIR, agentId);
1547
- // Keep output archive but remove temp agent directory (live-output.log etc.)
1548
- fs.rmSync(agentDir, { recursive: true, force: true });
1549
- log('info', `Temp agent ${agentId} cleaned up`);
1550
- } catch { /* cleanup */ }
1551
- }
1562
+ cleanupTempAgent(agentId);
1552
1563
  }
1553
1564
 
1554
1565
  proc.on('close', onAgentClose);
@@ -1558,6 +1569,7 @@ async function spawnAgent(dispatchItem, config) {
1558
1569
  activeProcesses.delete(id);
1559
1570
  realActivityMap.delete(id);
1560
1571
  completeDispatch(id, DISPATCH_RESULT.ERROR, `Spawn error: ${err.message}`);
1572
+ cleanupTempAgent(agentId);
1561
1573
  });
1562
1574
 
1563
1575
  // Safety: if process exits immediately (within 3s), log it
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1732",
3
+ "version": "0.1.1733",
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"