@yemi33/minions 0.1.1738 → 0.1.1740

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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1740 (2026-05-06)
4
+
5
+ ### Features
6
+ - doc-chat resume retry + 1h timeout + surfaced stderr (#2101)
7
+ - make Command Center chat sessions non-expiring (#2100)
8
+
9
+ ### Fixes
10
+ - invalidate stale session IDs on runtime switch (#2102)
11
+ - drop 5s setTimeout that nuked sysprompt .tmp before Claude could read it (#2105)
12
+
3
13
  ## 0.1.1738 (2026-05-06)
4
14
 
5
15
  ### Features
@@ -392,6 +392,12 @@ function ccCloseTab(id) {
392
392
  closingTab._queue = [];
393
393
  _ccSending = (_ccTabs.some(function(t) { return t._sending; }));
394
394
  }
395
+ // Tabs are non-expiring on the server — explicit close is the only path that
396
+ // removes the persisted session. Fire-and-forget DELETE so closing a tab
397
+ // also evicts the server-side cc-sessions.json entry.
398
+ try {
399
+ fetch('/api/cc-sessions/' + encodeURIComponent(id), { method: 'DELETE' }).catch(function() {});
400
+ } catch {}
395
401
  _ccTabs.splice(idx, 1);
396
402
  if (_ccActiveTabId === id) {
397
403
  // Switch to adjacent tab or create new
package/dashboard.js CHANGED
@@ -932,9 +932,14 @@ setInterval(() => {
932
932
 
933
933
  // ── Command Center: session state + helpers ─────────────────────────────────
934
934
 
935
- // Bound resumed session growth so stale conversations do not accumulate unbounded context.
935
+ // CC chat sessions do NOT auto-expire. A tab is removed only via explicit user
936
+ // deletion (DELETE /api/cc-sessions/:id, wired from ccCloseTab). Doc-chat
937
+ // sessions keep their own TTL (DOC_SESSION_TTL_MS) — that's a separate store.
938
+ //
939
+ // CC_SESSION_MAX_TURNS is reused by the doc-chat session pruner to cap
940
+ // per-session turn growth there; CC chat sessions are not capped because
941
+ // users are expected to keep long-running tabs alive indefinitely.
936
942
  const CC_SESSION_MAX_TURNS = shared.ENGINE_DEFAULTS.ccMaxTurns;
937
- const CC_SESSION_TTL_MS = shared.ENGINE_DEFAULTS.ccSessionTtlMs;
938
943
  let ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
939
944
  const ccInFlightTabs = new Map(); // tabId → timestamp — per-tab in-flight tracking for parallel CC requests
940
945
  const ccInFlightAborts = new Map(); // tabId → abortFn — lets a new request kill the stale LLM
@@ -947,9 +952,10 @@ const CC_STREAM_REATTACH_GRACE_MS = 60000; // keep CC job alive briefly after di
947
952
  const CC_STREAM_DONE_RETENTION_MS = 30000; // retain final payload briefly so reconnect can still receive it
948
953
  const CC_LIVE_STREAM_MAX_AGE_MS = shared.ENGINE_DEFAULTS.ccLiveStreamMaxAgeMs;
949
954
  // Doc-chat is interactive — long-doc edits with multi-step Read+Write tool use can run
950
- // 4–5 min on `canEdit:true` paths. CC's default 2-min timeout was killing legitimate
951
- // edits mid-stream. Pinned to 6 min as the bounded but generous ceiling.
952
- const DOC_CHAT_TIMEOUT_MS = 360000;
955
+ // well past 5 min on `canEdit:true` paths. Bumped to 1 hour (matching CC) so legitimate
956
+ // edits aren't killed mid-stream and the backend timeout never beats the user's reading
957
+ // time. The doc-chat handlers still abort on client disconnect.
958
+ const DOC_CHAT_TIMEOUT_MS = 60 * 60 * 1000;
953
959
  function _releaseCCTab(tabId) { ccInFlightTabs.delete(tabId); ccInFlightAborts.delete(tabId); }
954
960
  function _getCcLiveStream(tabId) {
955
961
  return ccLiveStreams.get(tabId) || null;
@@ -1070,18 +1076,17 @@ function _ccTabIsInFlight(tabId) {
1070
1076
 
1071
1077
  function ccSessionValid() {
1072
1078
  if (!ccSession.sessionId) return false;
1073
- // Invalidate session if system prompt changed (e.g. after code update + restart)
1079
+ // Invalidate session if system prompt changed (e.g. after code update + restart).
1080
+ // This is correctness-driven, not expiration-driven: a session created against
1081
+ // a stale system prompt would carry the old persona/rules into resume turns.
1074
1082
  if (ccSession._promptHash && ccSession._promptHash !== _ccPromptHash) {
1075
1083
  console.log('[CC] System prompt changed — invalidating stale session');
1076
1084
  ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
1077
1085
  return false;
1078
1086
  }
1079
- if (_sessionExpired(ccSession.lastActiveAt || ccSession.createdAt, CC_SESSION_TTL_MS)) {
1080
- console.log('[CC] Session expired by TTL starting fresh');
1081
- ccSession = { sessionId: null, createdAt: null, lastActiveAt: null, turnCount: 0 };
1082
- return false;
1083
- }
1084
- return ccSession.turnCount < CC_SESSION_MAX_TURNS;
1087
+ // No TTL or turn-count cap — CC chat sessions live until the user explicitly
1088
+ // closes the tab (which calls DELETE /api/cc-sessions/:id).
1089
+ return true;
1085
1090
  }
1086
1091
 
1087
1092
  // Static system prompt — baked into session on creation, never changes
@@ -1120,11 +1125,13 @@ function _sessionExpired(lastActiveAt, ttlMs) {
1120
1125
  return Date.now() - at > ttlMs;
1121
1126
  }
1122
1127
 
1128
+ // CC tab sessions never auto-expire — only invalid records (missing id /
1129
+ // sessionId) and prompt-hash mismatches are filtered out. Tabs are removed
1130
+ // from disk only when the user explicitly closes them via DELETE
1131
+ // /api/cc-sessions/:id.
1123
1132
  function _filterCcTabSessions(sessions) {
1124
1133
  return (Array.isArray(sessions) ? sessions : []).filter(s =>
1125
1134
  s && s.id && s.sessionId &&
1126
- (s.turnCount || 0) < CC_SESSION_MAX_TURNS &&
1127
- !_sessionExpired(s.lastActiveAt || s.createdAt, CC_SESSION_TTL_MS) &&
1128
1135
  (!s._promptHash || s._promptHash === _ccPromptHash)
1129
1136
  );
1130
1137
  }
@@ -1157,10 +1164,12 @@ function _buildTranscriptCarryover(transcript, { previousRuntime } = {}) {
1157
1164
  return `${header}\n\n${truncationNote}${lines.join('\n\n')}\n\n--- Current message follows ---`;
1158
1165
  }
1159
1166
 
1160
- // Load persisted CC session on startup
1167
+ // Load persisted CC session on startup. CC chat sessions are non-expiring;
1168
+ // only restore-time validity checks here are sessionId presence (anything
1169
+ // else would auto-expire the user's chat without their consent).
1161
1170
  try {
1162
1171
  const saved = safeJson(path.join(ENGINE_DIR, 'cc-session.json'));
1163
- if (saved && saved.sessionId && !_sessionExpired(saved.lastActiveAt || saved.createdAt, CC_SESSION_TTL_MS)) ccSession = saved;
1172
+ if (saved && saved.sessionId) ccSession = saved;
1164
1173
  } catch { /* optional */ }
1165
1174
 
1166
1175
  let _preambleCache = null;
@@ -2458,6 +2467,56 @@ function _formatDocChatContext({ document, title, filePath, selection, canEdit,
2458
2467
  return context;
2459
2468
  }
2460
2469
 
2470
+ // Build the doc-chat extraContext for a single ccCall pass — refreshed on retry
2471
+ // so a fresh-session retry includes the full document instead of relying on the
2472
+ // dead session's prior turn for context.
2473
+ function _buildDocChatPass({ docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession }) {
2474
+ const existing = freshSession ? null : resolveSession('doc', sessionKey);
2475
+ const docHash = require('crypto').createHash('md5').update(docSlice).digest('hex').slice(0, 8);
2476
+ const docUnchanged = existing?.sessionId && existing._docHash === docHash;
2477
+ const extraContext = _formatDocChatContext({
2478
+ document: docSlice, title, filePath, selection, canEdit, isJson, docUnchanged,
2479
+ });
2480
+ return { extraContext, docHash, hadSession: !!existing?.sessionId };
2481
+ }
2482
+
2483
+ // One-shot recovery from a stuck resumed session: if the initial call rode an
2484
+ // existing session and came back empty / non-zero, invalidate the persisted
2485
+ // session and re-run via the supplied closure. ccCall already retries fresh
2486
+ // when the runtime can't extract a sessionId, but on adapter-still-alive
2487
+ // failures it preserves the session — and from doc-chat's perspective that's
2488
+ // the failure mode the user actually sees ("Failed to process request").
2489
+ async function _retryDocChatAfterResumeFailure({ result, initialPass, freshSession, sessionKey, runOnce }) {
2490
+ if (!initialPass.hadSession || freshSession || result.missingRuntime) return result;
2491
+ if (result.code === 0 && result.text) return result;
2492
+ console.log(`[doc-chat] Resumed session call failed (code=${result.code}, empty=${!result.text}) — invalidating session and retrying fresh for ${sessionKey || 'untitled'}`);
2493
+ if (sessionKey) {
2494
+ docSessions.delete(sessionKey);
2495
+ schedulePersistDocSessions();
2496
+ }
2497
+ return runOnce();
2498
+ }
2499
+
2500
+ // Build the {error} envelope returned to the dashboard when doc-chat ultimately
2501
+ // fails. Keeps the polite user-facing message but exposes the runtime's real
2502
+ // stderr / exit code / errorClass so the UI can render the cause and so future
2503
+ // failures are debuggable from logs.
2504
+ function _docChatFailureResponse(label, filePath, result) {
2505
+ const stderrTail = (result.stderr || '').slice(-2048);
2506
+ console.error(`[${label}] Failed: code=${result.code}, empty=${!result.text}, filePath=${filePath}, errorClass=${result.errorClass || 'null'}, stderr=${stderrTail.slice(0, 200)}`);
2507
+ return {
2508
+ answer: 'Failed to process request. Try again.',
2509
+ content: null,
2510
+ actions: [],
2511
+ error: {
2512
+ code: result.code ?? null,
2513
+ stderr: stderrTail,
2514
+ errorClass: result.errorClass || null,
2515
+ runtime: result.runtime || null,
2516
+ },
2517
+ };
2518
+ }
2519
+
2461
2520
  // Doc-specific wrapper — adds document context, parses ---DOCUMENT---
2462
2521
  async function ccDocCall({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, onAbortReady }) {
2463
2522
  const sessionKey = filePath || title;
@@ -2471,34 +2530,30 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
2471
2530
  // Skip persistDocSessions() here — the post-call cleanup below handles persistence.
2472
2531
  }
2473
2532
 
2474
- // Skip re-sending full document on session resume if content unchanged
2475
- const docHash = require('crypto').createHash('md5').update(docSlice).digest('hex').slice(0, 8);
2476
- const existing = freshSession ? null : resolveSession('doc', sessionKey);
2477
- const docUnchanged = existing?.sessionId && existing._docHash === docHash;
2478
-
2479
- const docContext = _formatDocChatContext({
2480
- document: docSlice,
2481
- title,
2482
- filePath,
2483
- selection,
2484
- canEdit,
2485
- isJson,
2486
- docUnchanged,
2487
- });
2488
2533
  const allowActions = _messageRequestsOrchestration(message);
2489
2534
 
2490
- const result = await ccCall(message, {
2491
- store: 'doc', sessionKey,
2492
- extraContext: docContext, label: 'doc-chat',
2493
- timeout: DOC_CHAT_TIMEOUT_MS,
2494
- allowedTools: canEdit ? 'Read,Write,Edit,Glob,Grep' : 'Read,Glob,Grep',
2495
- maxTurns: canEdit ? 25 : 10,
2496
- timeout: DOC_CHAT_TIMEOUT_MS,
2497
- skipStatePreamble: true,
2498
- systemPrompt: DOC_CHAT_SYSTEM_PROMPT,
2499
- ...(model ? { model } : {}),
2500
- onAbortReady,
2535
+ const runOnce = async () => {
2536
+ const { extraContext } = _buildDocChatPass({
2537
+ docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession,
2538
+ });
2539
+ return ccCall(message, {
2540
+ store: 'doc', sessionKey,
2541
+ extraContext, label: 'doc-chat',
2542
+ timeout: DOC_CHAT_TIMEOUT_MS,
2543
+ allowedTools: canEdit ? 'Read,Write,Edit,Glob,Grep' : 'Read,Glob,Grep',
2544
+ maxTurns: canEdit ? 25 : 10,
2545
+ skipStatePreamble: true,
2546
+ systemPrompt: DOC_CHAT_SYSTEM_PROMPT,
2547
+ ...(model ? { model } : {}),
2548
+ onAbortReady,
2549
+ });
2550
+ };
2551
+
2552
+ const initialPass = _buildDocChatPass({
2553
+ docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession,
2501
2554
  });
2555
+ let result = await runOnce();
2556
+ result = await _retryDocChatAfterResumeFailure({ result, initialPass, freshSession, sessionKey, runOnce });
2502
2557
 
2503
2558
  if (freshSession && sessionKey) {
2504
2559
  // One-shot call — discard the session ccCall just stored so it cannot
@@ -2508,7 +2563,7 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
2508
2563
  } else if (result.code === 0 && result.sessionId) {
2509
2564
  // Store doc hash for next call's unchanged check
2510
2565
  const session = resolveSession('doc', sessionKey);
2511
- if (session) session._docHash = docHash;
2566
+ if (session) session._docHash = initialPass.docHash;
2512
2567
  }
2513
2568
 
2514
2569
  if (result.missingRuntime) {
@@ -2516,8 +2571,7 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
2516
2571
  }
2517
2572
 
2518
2573
  if (result.code !== 0 || !result.text) {
2519
- console.error(`[doc-chat] Failed: code=${result.code}, empty=${!result.text}, filePath=${filePath}, stderr=${(result.stderr || '').slice(0, 200)}`);
2520
- return { answer: 'Failed to process request. Try again.', content: null, actions: [] };
2574
+ return _docChatFailureResponse('doc-chat', filePath, result);
2521
2575
  }
2522
2576
 
2523
2577
  return _parseDocChatResultText(result.text, { allowActions });
@@ -2531,42 +2585,39 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
2531
2585
  docSessions.delete(sessionKey);
2532
2586
  }
2533
2587
 
2534
- const docHash = require('crypto').createHash('md5').update(docSlice).digest('hex').slice(0, 8);
2535
- const existing = freshSession ? null : resolveSession('doc', sessionKey);
2536
- const docUnchanged = existing?.sessionId && existing._docHash === docHash;
2537
-
2538
- const docContext = _formatDocChatContext({
2539
- document: docSlice,
2540
- title,
2541
- filePath,
2542
- selection,
2543
- canEdit,
2544
- isJson,
2545
- docUnchanged,
2546
- });
2547
2588
  const allowActions = _messageRequestsOrchestration(message);
2548
2589
 
2549
- const result = await ccCallStreaming(message, {
2550
- store: 'doc', sessionKey,
2551
- extraContext: docContext, label: 'doc-chat',
2552
- timeout: DOC_CHAT_TIMEOUT_MS,
2553
- allowedTools: canEdit ? 'Read,Write,Edit,Glob,Grep' : 'Read,Glob,Grep',
2554
- maxTurns: canEdit ? 25 : 10,
2555
- timeout: DOC_CHAT_TIMEOUT_MS,
2556
- skipStatePreamble: true,
2557
- systemPrompt: DOC_CHAT_SYSTEM_PROMPT,
2558
- ...(model ? { model } : {}),
2559
- onAbortReady,
2560
- onChunk: (text) => { if (onChunk) onChunk(_docChatDisplayText(text, { allowActions })); },
2561
- onToolUse,
2590
+ const runOnce = async () => {
2591
+ const { extraContext } = _buildDocChatPass({
2592
+ docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession,
2593
+ });
2594
+ return ccCallStreaming(message, {
2595
+ store: 'doc', sessionKey,
2596
+ extraContext, label: 'doc-chat',
2597
+ timeout: DOC_CHAT_TIMEOUT_MS,
2598
+ allowedTools: canEdit ? 'Read,Write,Edit,Glob,Grep' : 'Read,Glob,Grep',
2599
+ maxTurns: canEdit ? 25 : 10,
2600
+ skipStatePreamble: true,
2601
+ systemPrompt: DOC_CHAT_SYSTEM_PROMPT,
2602
+ ...(model ? { model } : {}),
2603
+ onAbortReady,
2604
+ onChunk: (text) => { if (onChunk) onChunk(_docChatDisplayText(text, { allowActions })); },
2605
+ onToolUse,
2606
+ });
2607
+ };
2608
+
2609
+ const initialPass = _buildDocChatPass({
2610
+ docSlice, title, filePath, selection, canEdit, isJson, sessionKey, freshSession,
2562
2611
  });
2612
+ let result = await runOnce();
2613
+ result = await _retryDocChatAfterResumeFailure({ result, initialPass, freshSession, sessionKey, runOnce });
2563
2614
 
2564
2615
  if (freshSession && sessionKey) {
2565
2616
  docSessions.delete(sessionKey);
2566
2617
  schedulePersistDocSessions();
2567
2618
  } else if (result.code === 0 && result.sessionId) {
2568
2619
  const session = resolveSession('doc', sessionKey);
2569
- if (session) session._docHash = docHash;
2620
+ if (session) session._docHash = initialPass.docHash;
2570
2621
  }
2571
2622
 
2572
2623
  if (result.missingRuntime) {
@@ -2574,8 +2625,7 @@ async function ccDocCallStreaming({ message, document, title, filePath, selectio
2574
2625
  }
2575
2626
 
2576
2627
  if (result.code !== 0 || !result.text) {
2577
- console.error(`[doc-chat-stream] Failed: code=${result.code}, empty=${!result.text}, filePath=${filePath}, stderr=${(result.stderr || '').slice(0, 200)}`);
2578
- return { answer: 'Failed to process request. Try again.', content: null, actions: [] };
2628
+ return _docChatFailureResponse('doc-chat-stream', filePath, result);
2579
2629
  }
2580
2630
 
2581
2631
  return _parseDocChatResultText(result.text, { allowActions });
@@ -4672,7 +4722,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4672
4722
  }
4673
4723
  }
4674
4724
 
4675
- const { answer, content, actions, actionParseError } = await ccDocCall({
4725
+ const { answer, content, actions, actionParseError, error: ccError } = await ccDocCall({
4676
4726
  message: body.message, document: currentContent, title: body.title,
4677
4727
  filePath: body.filePath, selection: body.selection, canEdit, isJson,
4678
4728
  model: body.model || undefined,
@@ -4681,11 +4731,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4681
4731
  });
4682
4732
  const actionResults = await executeDocChatActions(actions);
4683
4733
  const baseReply = (extra = {}) => ({
4684
- ok: true,
4734
+ ok: !ccError,
4685
4735
  answer,
4686
4736
  actions,
4687
4737
  ...(actionResults ? { actionResults } : {}),
4688
4738
  ...(actionParseError ? { actionParseError } : {}),
4739
+ ...(ccError ? { error: ccError } : {}),
4689
4740
  ...extra,
4690
4741
  });
4691
4742
 
@@ -4787,7 +4838,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4787
4838
 
4788
4839
  try {
4789
4840
 
4790
- const { answer, content, actions, actionParseError } = await ccDocCallStreaming({
4841
+ const { answer, content, actions, actionParseError, error: ccError } = await ccDocCallStreaming({
4791
4842
  message: body.message, document: currentContent, title: body.title,
4792
4843
  filePath: body.filePath, selection: body.selection, canEdit, isJson,
4793
4844
  model: body.model || undefined,
@@ -4803,6 +4854,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
4803
4854
  actions,
4804
4855
  ...(actionResults ? { actionResults } : {}),
4805
4856
  ...(actionParseError ? { actionParseError } : {}),
4857
+ ...(ccError ? { error: ccError } : {}),
4806
4858
  ...extra,
4807
4859
  });
4808
4860
 
@@ -13,11 +13,12 @@ CC maintains a true multi-turn session using Claude CLI's `--resume` flag. Unlik
13
13
  **Session lifecycle:**
14
14
  - **Created** on first message (or after the system prompt changes, or when you click **New Session**)
15
15
  - **Resumed** on subsequent messages via `--resume <sessionId>`
16
- - **Invalidated** when the CC system prompt changes — detected by hashing `CC_STATIC_SYSTEM_PROMPT` into `_ccPromptHash` and comparing on each call. Sessions are also bounded by `ENGINE_DEFAULTS.ccMaxTurns` (default 50 turns) and `ENGINE_DEFAULTS.ccSessionTtlMs` (default 2h resumed sessions older than this get rotated).
17
- - **Persisted** to `engine/cc-session.json` — survives dashboard restarts
16
+ - **Invalidated** only when the CC system prompt changes — detected by hashing `CC_STATIC_SYSTEM_PROMPT` into `_ccPromptHash` and comparing on each call. CC chat sessions do **not** auto-expire by TTL or turn count; `ENGINE_DEFAULTS.ccMaxTurns` (default 50) is a per-call tool-use cap inside the Claude CLI, not a session lifetime cap.
17
+ - **Persisted** to `engine/cc-session.json` (legacy global session) and `engine/cc-sessions.json` (per-tab sessions) both survive dashboard restarts and engine cleanup ticks
18
18
  - **Frontend messages** saved to `localStorage` — survive page refresh
19
+ - **Removed** only when the user explicitly closes a tab via the **×** button on the tab strip — that fires `DELETE /api/cc-sessions/:id` to evict the persisted session
19
20
 
20
- Click **New Session** in the drawer header to start fresh.
21
+ Click **New Session** in the drawer header to start fresh; click the **×** on a tab to remove it permanently.
21
22
 
22
23
  ### Fresh State Each Turn
23
24
 
package/engine/cleanup.js CHANGED
@@ -681,20 +681,11 @@ async function runCleanup(config, verbose = false) {
681
681
  }
682
682
  } catch (e) { log('warn', 'orphan PRD status reset: ' + e.message); }
683
683
 
684
- // 10. Prune CC tab sessions cap at 50 entries, remove oldest beyond cap
684
+ // 10. CC tab sessions are non-expiring by design they persist until the
685
+ // user explicitly closes the tab (which fires DELETE /api/cc-sessions/:id).
686
+ // Cleanup intentionally does NOT prune cc-sessions.json; doing so would
687
+ // silently invalidate live chat tabs the user expects to keep.
685
688
  cleaned.ccSessions = 0;
686
- try {
687
- const ccSessionsPath = path.join(ENGINE_DIR, 'cc-sessions.json');
688
- const sessions = shared.safeJsonArr(ccSessionsPath);
689
- const CC_SESSIONS_CAP = 50;
690
- if (sessions.length > CC_SESSIONS_CAP) {
691
- // Sort by lastActiveAt descending, keep newest
692
- sessions.sort((a, b) => new Date(b.lastActiveAt || 0) - new Date(a.lastActiveAt || 0));
693
- const pruned = sessions.slice(0, CC_SESSIONS_CAP);
694
- cleaned.ccSessions = sessions.length - pruned.length;
695
- safeWrite(ccSessionsPath, pruned);
696
- }
697
- } catch (e) { log('warn', 'prune cc-sessions: ' + e.message); }
698
689
 
699
690
  // 10b. Prune doc-chat sessions — cap at 100 entries, remove oldest beyond cap
700
691
  cleaned.docSessions = 0;
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "runtime": "copilot",
3
3
  "models": null,
4
- "cachedAt": "2026-05-06T00:16:35.626Z"
4
+ "cachedAt": "2026-05-06T00:41:30.276Z"
5
5
  }
package/engine/llm.js CHANGED
@@ -787,6 +787,9 @@ function callLLMStreaming(promptText, sysPromptText, opts = {}) {
787
787
 
788
788
  proc.on('close', finish);
789
789
  proc.on('exit', (code) => {
790
+ // 'close' waits for stdio to close. If the runtime spawned a detached
791
+ // grandchild that inherited stdout/stderr, the OS pipe stays open and
792
+ // 'close' may never fire. Fall back to 'exit' after a drain window.
790
793
  if (settled) return;
791
794
  exitSettleTimer = setTimeout(() => finish(code), LLM_EXIT_SETTLE_GRACE_MS);
792
795
  });
@@ -287,12 +287,33 @@ function getSkillWriteTargets({ homeDir = os.homedir(), project = null } = {}) {
287
287
  };
288
288
  }
289
289
 
290
+ // Stamped into every session.json this adapter writes so the pre-spawn resume
291
+ // path can detect "session was produced by a different runtime" — Claude
292
+ // rejects Copilot session IDs (and vice versa) with "No conversation found",
293
+ // which would otherwise burn a retry slot before the post-failure cleanup at
294
+ // engine.js:1195 fires. See W-mot9fwya000d09cb.
295
+ const RUNTIME_NAME = 'claude';
296
+
290
297
  function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
291
298
  if (!agentId || agentId.startsWith('temp-') || !agentsDir) return null;
299
+ const sessionPath = path.join(agentsDir, agentId, 'session.json');
292
300
  try {
293
- const sessionPath = path.join(agentsDir, agentId, 'session.json');
294
301
  const sessionFile = _safeJson(sessionPath);
295
302
  if (!sessionFile?.sessionId || !sessionFile.savedAt) return null;
303
+
304
+ // Runtime-mismatch invalidation. Distinct from stale-by-age: the session is
305
+ // structurally unusable on this runtime, so drop it AND clear session.json
306
+ // so the next dispatch starts fresh instead of failing with --resume.
307
+ // Legacy sessions (no `runtime` field) are treated as compatible — opt-in
308
+ // check, no false invalidations on first deploy.
309
+ if (sessionFile.runtime && sessionFile.runtime !== RUNTIME_NAME) {
310
+ if (logger && typeof logger.info === 'function') {
311
+ logger.info(`Skipping resume for ${agentId}: runtime mismatch (session: ${sessionFile.runtime}, current: ${RUNTIME_NAME}) — clearing session.json`);
312
+ }
313
+ try { fs.unlinkSync(sessionPath); } catch {}
314
+ return null;
315
+ }
316
+
296
317
  const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
297
318
  const sameBranch = branchName && sessionFile.branch && sessionFile.branch === branchName;
298
319
  if (sessionAge < maxAgeMs && sameBranch) {
@@ -315,6 +336,7 @@ function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now =
315
336
  dispatchId,
316
337
  savedAt: typeof now === 'function' ? now() : new Date().toISOString(),
317
338
  branch: branch || null,
339
+ runtime: RUNTIME_NAME,
318
340
  });
319
341
  return true;
320
342
  } catch (err) {
@@ -306,12 +306,33 @@ function getSkillWriteTargets({ homeDir = os.homedir(), project = null } = {}) {
306
306
  };
307
307
  }
308
308
 
309
+ // Stamped into every session.json this adapter writes so the pre-spawn resume
310
+ // path can detect "session was produced by a different runtime" — Copilot
311
+ // rejects Claude session IDs (and vice versa) with "No conversation found",
312
+ // which would otherwise burn a retry slot before the post-failure cleanup at
313
+ // engine.js:1195 fires. See W-mot9fwya000d09cb.
314
+ const RUNTIME_NAME = 'copilot';
315
+
309
316
  function getResumeSessionId({ agentId, branchName, agentsDir, maxAgeMs = 2 * 60 * 60 * 1000, logger = console } = {}) {
310
317
  if (!agentId || agentId.startsWith('temp-') || !agentsDir) return null;
318
+ const sessionPath = path.join(agentsDir, agentId, 'session.json');
311
319
  try {
312
- const sessionPath = path.join(agentsDir, agentId, 'session.json');
313
320
  const sessionFile = _safeJson(sessionPath);
314
321
  if (!sessionFile?.sessionId || !sessionFile.savedAt) return null;
322
+
323
+ // Runtime-mismatch invalidation. Distinct from stale-by-age: the session is
324
+ // structurally unusable on this runtime, so drop it AND clear session.json
325
+ // so the next dispatch starts fresh instead of failing with --resume.
326
+ // Legacy sessions (no `runtime` field) are treated as compatible — opt-in
327
+ // check, no false invalidations on first deploy.
328
+ if (sessionFile.runtime && sessionFile.runtime !== RUNTIME_NAME) {
329
+ if (logger && typeof logger.info === 'function') {
330
+ logger.info(`Skipping resume for ${agentId}: runtime mismatch (session: ${sessionFile.runtime}, current: ${RUNTIME_NAME}) — clearing session.json`);
331
+ }
332
+ try { fs.unlinkSync(sessionPath); } catch {}
333
+ return null;
334
+ }
335
+
315
336
  const sessionAge = Date.now() - new Date(sessionFile.savedAt).getTime();
316
337
  const sameBranch = branchName && sessionFile.branch && sessionFile.branch === branchName;
317
338
  if (sessionAge < maxAgeMs && sameBranch) {
@@ -334,6 +355,7 @@ function saveSession({ agentId, dispatchId, branch, sessionId, agentsDir, now =
334
355
  dispatchId,
335
356
  savedAt: typeof now === 'function' ? now() : new Date().toISOString(),
336
357
  branch: branch || null,
358
+ runtime: RUNTIME_NAME,
337
359
  });
338
360
  return true;
339
361
  } catch (err) {
package/engine/shared.js CHANGED
@@ -914,7 +914,6 @@ const ENGINE_DEFAULTS = {
914
914
  removeWorktreeFailureTtlMs: 24 * 60 * 60 * 1000, // stale failed paths are forgotten after a day
915
915
  removeWorktreeFailureMaxEntries: 1000, // bound failed-worktree retry suppression cache
916
916
  ccMaxTurns: 50, // max tool-use turns for CC/doc-chat before CLI stops
917
- ccSessionTtlMs: 7 * 24 * 60 * 60 * 1000, // 7d — keep chats resumable after breaks, still bounded by turn cap
918
917
  docSessionTtlMs: 7 * 24 * 60 * 60 * 1000, // 7d — longer-lived doc sessions, still bounded
919
918
  docSessionMaxEntries: 200, // cap doc-chat session map/disk store by least-recent activity
920
919
  ccLiveStreamMaxAgeMs: 30 * 60 * 1000, // hard cap reconnect buffers if abort/cleanup stalls
@@ -355,11 +355,14 @@ function main() {
355
355
 
356
356
  // Clean up sys tmp (only created for non-resume sessions on adapters that
357
357
  // use --system-prompt-file).
358
- if (wantsSystemPromptFile) {
359
- setTimeout(() => { try { fs.unlinkSync(sysTmpPath); } catch { /* cleanup */ } }, 5000);
360
- }
361
-
362
- // Register exit handler to clean up orphaned temp files
358
+ //
359
+ // We deliberately DO NOT use a fixed-delay setTimeout here. Claude CLI reads
360
+ // `--system-prompt-file` lazily during init; on cold starts (MCP boot,
361
+ // `--add-dir` traversal, Windows process startup) the read can land well
362
+ // after any short timer fires, leaving Claude to bail with
363
+ // "System prompt file not found". The exit/SIGTERM handler below — plus the
364
+ // matching unlinks in engine/dispatch.js and engine/meeting.js on dispatch
365
+ // completion — are sufficient to keep tmp files from leaking.
363
366
  function _cleanupSpawnTempFiles() {
364
367
  if (wantsSystemPromptFile) {
365
368
  try { fs.unlinkSync(sysTmpPath); } catch { /* may already be cleaned */ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.1738",
3
+ "version": "0.1.1740",
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"