claude-code-session-manager 0.92.1 → 0.94.0

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.
Files changed (107) hide show
  1. package/dist/assets/DataModel-B0LDnnSL.js +1 -0
  2. package/dist/assets/{History-BOR_fJNr.js → History-C685Kytt.js} +2 -2
  3. package/dist/assets/{Hooks-BtD436iL.js → Hooks-CwLnp0Z_.js} +3 -3
  4. package/dist/assets/HostBilko-CwEHEKYk.js +1 -0
  5. package/dist/assets/{Library-DeKdY-tD.js → Library-BU9np05E.js} +25 -25
  6. package/dist/assets/{MarkdownEditor-CbzC-Oqf.js → MarkdownEditor-B74ogK2f.js} +1 -1
  7. package/dist/assets/McpServers-BLM3ftzP.js +2 -0
  8. package/dist/assets/{Memory-B68AJjg2.js → Memory-DAP1t8bA.js} +6 -6
  9. package/dist/assets/{Permissions-Cr0RRvNy.js → Permissions-3pGLJmxQ.js} +3 -3
  10. package/dist/assets/Plugins-Q1KoLEzn.js +2 -0
  11. package/dist/assets/ProvenanceBadge-BnjBQiV9.js +1 -0
  12. package/dist/assets/{SaveBar-87ZJfscX.js → SaveBar-Zq2NfcPf.js} +1 -1
  13. package/dist/assets/Scheduler-D488ebKm.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-CKzpqjJ_.js → ScopeSwitcher-Cnojv22D.js} +1 -1
  15. package/dist/assets/Settings-D5Lhj7ga.js +3 -0
  16. package/dist/assets/{SkillReferenceGraph-D_-wg4r9.js → SkillReferenceGraph-BRDiE6Hv.js} +2 -2
  17. package/dist/assets/Skills-BQDqp-EN.js +3 -0
  18. package/dist/assets/SystemPrompt-tFr19Od6.js +1 -0
  19. package/dist/assets/TagLibrary-BjyrnaRE.js +1 -0
  20. package/dist/assets/{TiptapBody-DYXIp8aG.js → TiptapBody-DGzSg3BO.js} +1 -1
  21. package/dist/assets/{Toggle-CDXR5l3m.js → Toggle-DzoROibf.js} +1 -1
  22. package/dist/assets/index-CCl4tz-u.js +3079 -0
  23. package/dist/assets/{index-D3P_jldk.css → index-CKH5Uxik.css} +1 -1
  24. package/dist/assets/settingsSchema-BYKVe1WM.js +1 -0
  25. package/dist/index.html +2 -2
  26. package/package.json +6 -3
  27. package/scripts/README.md +3 -0
  28. package/src/main/__tests__/agentEffortResolve.test.cjs +117 -0
  29. package/src/main/__tests__/agentLibrary.test.cjs +21 -0
  30. package/src/main/__tests__/agentModelResolve.test.cjs +16 -0
  31. package/src/main/__tests__/agentOverlayWrite.test.cjs +95 -0
  32. package/src/main/__tests__/chat-cancel-terminal.test.cjs +5 -2
  33. package/src/main/__tests__/chat-exit-close-race.test.cjs +8 -2
  34. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +5 -2
  35. package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +44 -0
  36. package/src/main/__tests__/intradayRefresh.test.cjs +39 -0
  37. package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +25 -0
  38. package/src/main/__tests__/opsErrorLog.test.cjs +22 -0
  39. package/src/main/__tests__/personaMerge.test.cjs +169 -0
  40. package/src/main/__tests__/prdCreatePlanId.test.cjs +132 -0
  41. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +13 -0
  42. package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +21 -0
  43. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +14 -0
  44. package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +26 -0
  45. package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +11 -1
  46. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +11 -1
  47. package/src/main/__tests__/runVerify.test.cjs +12 -1
  48. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +47 -2
  49. package/src/main/__tests__/transcriptsUsageFor.test.cjs +112 -1
  50. package/src/main/agentLibrary.cjs +86 -40
  51. package/src/main/build-info.json +4 -4
  52. package/src/main/chatRunner.cjs +50 -10
  53. package/src/main/git.cjs +9 -2
  54. package/src/main/historyAggregator.cjs +2 -19
  55. package/src/main/index.cjs +17 -8
  56. package/src/main/ipcSchemas.cjs +25 -3
  57. package/src/main/lib/__tests__/childWithLog.test.cjs +180 -0
  58. package/src/main/lib/__tests__/delegationReadiness.test.cjs +19 -0
  59. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +10 -5
  60. package/src/main/lib/__tests__/gitCacheBound.test.cjs +69 -0
  61. package/src/main/lib/__tests__/modelCatalog.test.cjs +202 -0
  62. package/src/main/lib/agentEffortResolve.cjs +74 -0
  63. package/src/main/lib/agentModelResolve.cjs +52 -59
  64. package/src/main/lib/agentPersonaSchema.cjs +5 -0
  65. package/src/main/lib/childWithLog.cjs +92 -51
  66. package/src/main/lib/delegationReadiness.cjs +3 -1
  67. package/src/main/lib/effectiveModelInfo.cjs +38 -20
  68. package/src/main/lib/epicMint.cjs +5 -2
  69. package/src/main/lib/epicSpawnPlan.cjs +27 -8
  70. package/src/main/lib/epicTranscriptPath.cjs +5 -1
  71. package/src/main/lib/epicWorktreeMerge.cjs +7 -6
  72. package/src/main/lib/headTailBuffer.cjs +43 -0
  73. package/src/main/lib/intradayRefresh.cjs +33 -0
  74. package/src/main/lib/lruCache.cjs +39 -0
  75. package/src/main/lib/modelCatalog.cjs +243 -0
  76. package/src/main/lib/openExternalApp.cjs +27 -9
  77. package/src/main/lib/opsErrorLog.cjs +22 -0
  78. package/src/main/lib/personaMerge.cjs +166 -0
  79. package/src/main/lib/prdCreate.cjs +23 -5
  80. package/src/main/lib/prdDisposition.cjs +46 -0
  81. package/src/main/lib/prdFrontmatter.cjs +6 -2
  82. package/src/main/lib/promptSessionSchema.cjs +5 -0
  83. package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
  84. package/src/main/lib/rendererRecovery.cjs +141 -0
  85. package/src/main/lib/scheduleJobSchema.cjs +3 -0
  86. package/src/main/runVerify.cjs +3 -1
  87. package/src/main/scheduler/prdParser.cjs +2 -0
  88. package/src/main/scheduler.cjs +381 -262
  89. package/src/main/templates/PRD_AUTHORING.md +4 -0
  90. package/src/main/transcripts.cjs +54 -11
  91. package/src/preload/api.d.ts +56 -3
  92. package/src/preload/index.cjs +4 -0
  93. package/dist/assets/AgentLibrary-Ci6u03bq.js +0 -3
  94. package/dist/assets/DataModel-BIqcjsv2.js +0 -1
  95. package/dist/assets/HostBilko-CMe5cH3H.js +0 -1
  96. package/dist/assets/ListDetail-CtVjrHKB.js +0 -1
  97. package/dist/assets/McpServers-DKN75Shn.js +0 -2
  98. package/dist/assets/Panel-C2YTW-qP.js +0 -1
  99. package/dist/assets/Plugins-DbZkJs5l.js +0 -2
  100. package/dist/assets/ProvenanceBadge-kJ__BOQv.js +0 -1
  101. package/dist/assets/Scheduler-DdEaxku8.js +0 -16
  102. package/dist/assets/Settings-CjywVsNB.js +0 -3
  103. package/dist/assets/Skills-Cd_yPO_l.js +0 -3
  104. package/dist/assets/SystemPrompt-2z6eGPSF.js +0 -1
  105. package/dist/assets/TagLibrary-DcaWQSp4.js +0 -1
  106. package/dist/assets/index-D5H_H5wC.js +0 -3074
  107. package/dist/assets/settingsSchema-JRDfjgHb.js +0 -3
@@ -53,8 +53,14 @@ const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
53
53
  const sessionSlots = require('./lib/sessionSlots.cjs');
54
54
  const opsErrorLog = require('./lib/opsErrorLog.cjs');
55
55
  const agentModelResolve = require('./lib/agentModelResolve.cjs');
56
+ const { resolveEpicEffort, effortArgs } = require('./lib/agentEffortResolve.cjs');
56
57
  const { planEpicSpawn } = require('./lib/epicSpawnPlan.cjs');
57
58
  const logs = require('./logs.cjs');
59
+ const { createHeadTailBuffer } = require('./lib/headTailBuffer.cjs');
60
+
61
+ // Caps on per-run stream buffers (a run can last hours).
62
+ const STDERR_TAIL_MAX = 64 * 1024;
63
+ const LINE_BUFFER_MAX = 8 * 1024 * 1024;
58
64
 
59
65
  // The only two CLI errors that mean "wrong session flag" (verified against the
60
66
  // real CLI). "in use" → the transcript exists, so resume; "no conversation" →
@@ -316,6 +322,13 @@ const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume, silent, onSilen
316
322
  // Closes the microtask-wide window in which per-tab exclusivity would
317
323
  // otherwise not hold. See pump().
318
324
  const dispatching = new Set();
325
+ // sessionIds with a live CLI child: added when pump() dispatches a run, dropped
326
+ // in the same .finally() that frees the lane — i.e. only after executeRun has
327
+ // settled from `close`/`error`/spawn failure. A cancelled child still exiting
328
+ // therefore keeps holding it. Two `claude -p` against one sessionId die with
329
+ // "Session ID … is already in use", so pump() refuses to start a second run
330
+ // for a held sessionId (it waits in `waiting` like any other blocked run).
331
+ const liveSessions = new Set();
319
332
  let activeCount = 0;
320
333
 
321
334
  // Indirection so tests can stub the spawn without launching claude.
@@ -331,6 +344,7 @@ function __setExecutor(fn) { executor = fn || executeRun; }
331
344
  function __resetQueueForTests() {
332
345
  inFlight.clear();
333
346
  dispatching.clear();
347
+ liveSessions.clear();
334
348
  waiting.length = 0;
335
349
  activeCount = 0;
336
350
  sessionSlots.__resetForTests();
@@ -379,6 +393,7 @@ function run(opts) {
379
393
  // visible in `inFlight` — the same window pump() guards against.
380
394
  const tabBusy = inFlight.has(opts.tabId)
381
395
  || dispatching.has(opts.tabId)
396
+ || liveSessions.has(opts.sessionId)
382
397
  || waiting.some((w) => w.tabId === opts.tabId);
383
398
  if (tabBusy && opts.silent) {
384
399
  // Silent probes stay best-effort and invisible: a probe that collides is
@@ -401,7 +416,8 @@ function pump() {
401
416
  // it in `inFlight` (which happens a microtask later) — without it, one
402
417
  // synchronous sweep of this loop could start two runs for the same tab and
403
418
  // race two --resume processes against a single sessionId.
404
- const idx = waiting.findIndex((w) => !inFlight.has(w.tabId) && !dispatching.has(w.tabId));
419
+ const idx = waiting.findIndex((w) => !inFlight.has(w.tabId) && !dispatching.has(w.tabId)
420
+ && !liveSessions.has(w.sessionId));
405
421
  // Everything queued is blocked behind its own tab's live run; the settle()
406
422
  // of that run re-pumps.
407
423
  if (idx === -1) break;
@@ -414,6 +430,7 @@ function pump() {
414
430
  const [job] = waiting.splice(idx, 1);
415
431
  activeCount += 1;
416
432
  dispatching.add(job.tabId);
433
+ liveSessions.add(job.sessionId);
417
434
  Promise.resolve()
418
435
  .then(() => {
419
436
  const donePromise = executor(job);
@@ -424,6 +441,7 @@ function pump() {
424
441
  .catch(() => { /* executeRun never rejects; defensive */ })
425
442
  .finally(() => {
426
443
  dispatching.delete(job.tabId);
444
+ liveSessions.delete(job.sessionId);
427
445
  sessionSlots.release(slotToken);
428
446
  activeCount -= 1;
429
447
  pump();
@@ -477,6 +495,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
477
495
  // funnels through here so the exit path can emit a fallback only when the
478
496
  // run settled without one.
479
497
  let terminalSent = false;
498
+ let lineOverflowLogged = false;
480
499
  const emitTerminal = (channel, payload) => {
481
500
  if (terminalSent) return;
482
501
  terminalSent = true;
@@ -546,6 +565,9 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
546
565
  // Epic, no agentType, or the persona has no model/'inherit' — --model
547
566
  // must never be left unpinned (CLAUDE.md model-pinning rule).
548
567
  const model = agentModelResolve.resolveEpicModel({ cwd, claudeSessionId: sessionId });
568
+ // Persona `effort:` (same overlay-aware persona, agentEffortResolve.cjs);
569
+ // effortArgs() yields NO tokens for inherit/absent/dangling.
570
+ const effortFlags = effortArgs(resolveEpicEffort({ cwd, claudeSessionId: sessionId }).effort);
549
571
 
550
572
  // Main is authoritative about --resume vs --session-id AND the spawn cwd, and it decides
551
573
  // them TOGETHER (epicSpawnPlan.cjs): a transcript under an encoding the spawn cwd cannot see
@@ -579,6 +601,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
579
601
  const a = [
580
602
  '-p', fullPrompt,
581
603
  '--model', model,
604
+ ...effortFlags,
582
605
  '--dangerously-skip-permissions',
583
606
  '--output-format', 'stream-json',
584
607
  '--verbose',
@@ -695,7 +718,9 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
695
718
  // Per-attempt state — a retry starts from empty buffers.
696
719
  let lineBuffer = '';
697
720
  let finalAssistantText = '';
698
- let stderrBuffer = '';
721
+ const stderrBuffer = createHeadTailBuffer(STDERR_TAIL_MAX);
722
+
723
+ const stderrText = () => stderrBuffer.matchText();
699
724
 
700
725
  const processLine = (line) => {
701
726
  if (!line) return;
@@ -787,10 +812,25 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
787
812
  processLine(lineBuffer.slice(0, nl).trim());
788
813
  lineBuffer = lineBuffer.slice(nl + 1);
789
814
  }
815
+ if (lineBuffer.length > LINE_BUFFER_MAX) {
816
+ // Un-terminated line past the cap: drop it rather than grow forever.
817
+ lineBuffer = '';
818
+ if (!lineOverflowLogged) {
819
+ lineOverflowLogged = true;
820
+ opsErrorLog.appendError({
821
+ cwd,
822
+ scope: 'chatRunner',
823
+ tabId,
824
+ tags: silent ? ['silent-probe'] : [],
825
+ message: `stdout line exceeded ${LINE_BUFFER_MAX} bytes without a newline; buffer dropped`,
826
+ meta: { sessionId, silent },
827
+ });
828
+ }
829
+ }
790
830
  });
791
831
 
792
832
  thisChild.stderr.on('data', (chunk) => {
793
- stderrBuffer += chunk.toString('utf8');
833
+ stderrBuffer.append(chunk.toString('utf8'));
794
834
  });
795
835
 
796
836
  thisChild.on('error', (err) => {
@@ -819,10 +859,10 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
819
859
  // terminal latch stay at executeRun level, so this is still one turn.
820
860
  if (!terminalSent && !killed && code !== 0 && attemptNo === 1
821
861
  && finalAssistantText === '' && recentToolUses.length === 0
822
- && !/rate.?limit/i.test(stderrBuffer)) {
862
+ && !/rate.?limit/i.test(stderrText())) {
823
863
  const swapTo = resumeFlag
824
- ? (NO_CONVERSATION_RE.test(stderrBuffer) ? false : null)
825
- : (SESSION_IN_USE_RE.test(stderrBuffer) ? true : null);
864
+ ? (NO_CONVERSATION_RE.test(stderrText()) ? false : null)
865
+ : (SESSION_IN_USE_RE.test(stderrText()) ? true : null);
826
866
  if (swapTo !== null) {
827
867
  if (!silent) {
828
868
  broadcast('chat:run:notice', {
@@ -851,7 +891,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
851
891
  sessionId,
852
892
  message: 'run cancelled',
853
893
  });
854
- } else if (SESSION_IN_USE_RE.test(stderrBuffer) || NO_CONVERSATION_RE.test(stderrBuffer)) {
894
+ } else if (SESSION_IN_USE_RE.test(stderrText()) || NO_CONVERSATION_RE.test(stderrText())) {
855
895
  // A session-flag rejection that survived the one-shot swap (or was
856
896
  // not eligible for it). The raw stderr dump names neither the flag,
857
897
  // the cwd nor the transcript — a real report cost a full diagnostic
@@ -870,9 +910,8 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
870
910
  + 'the session-flag resolution logic lives in main, and a stale build resolves it with older rules.',
871
911
  });
872
912
  } else {
873
- const errDetail = stderrBuffer.trim()
874
- ? `: ${stderrBuffer.trim().slice(0, 300)}`
875
- : '';
913
+ const preview = stderrBuffer.preview();
914
+ const errDetail = preview ? `: ${preview}` : '';
876
915
  emitTerminal('chat:run:error', {
877
916
  tabId,
878
917
  sessionId,
@@ -991,6 +1030,7 @@ module.exports = {
991
1030
  run,
992
1031
  cancel,
993
1032
  __resetQueueForTests,
1033
+ __liveSessionCount: () => liveSessions.size,
994
1034
  attachWindow,
995
1035
  registerChatHandlers,
996
1036
  parseStopSignal,
package/src/main/git.cjs CHANGED
@@ -23,6 +23,7 @@ const { spawn } = require('node:child_process');
23
23
  const path = require('node:path');
24
24
  const { schemas } = require('./ipcSchemas.cjs');
25
25
  const { validatePath } = require('./config.cjs');
26
+ const { LRUCache } = require('./lib/lruCache.cjs');
26
27
 
27
28
  const GIT_TIMEOUT_MS = 5_000;
28
29
  const CACHE_TTL_MS = 5_000;
@@ -30,8 +31,11 @@ const MAX_BUFFER = 10 * 1024 * 1024; // 10 MiB — same cap Unleashed uses
30
31
 
31
32
  // Per-cwd in-memory caches. Keyed by the realpath-validated cwd so symlinks
32
33
  // to the same repo dedupe. Each entry: { value, expiresAt }.
33
- const statusCache = new Map();
34
- const fileStatusCache = new Map();
34
+ // Bounded LRU: worktree cwds are unique per Epic/job, so an unbounded Map grew
35
+ // for the app's lifetime and pinned every files[] array.
36
+ const CACHE_MAX_CWDS = 16;
37
+ const statusCache = new LRUCache(CACHE_MAX_CWDS);
38
+ const fileStatusCache = new LRUCache(CACHE_MAX_CWDS);
35
39
 
36
40
  /**
37
41
  * Run `git <args>` in cwd with a hard 5s SIGKILL ceiling. Resolves
@@ -241,6 +245,7 @@ async function getStatus(cwd) {
241
245
  const now = Date.now();
242
246
  const cached = statusCache.get(realCwd);
243
247
  if (cached && cached.expiresAt > now) return cached.value;
248
+ if (cached) statusCache.delete(realCwd);
244
249
 
245
250
  // Single porcelain call gets us branch + ahead/behind + files in one shot.
246
251
  const result = await runGit(
@@ -281,6 +286,7 @@ async function getFileStatus(cwd) {
281
286
  const now = Date.now();
282
287
  const cached = fileStatusCache.get(realCwd);
283
288
  if (cached && cached.expiresAt > now) return cached.value;
289
+ if (cached) fileStatusCache.delete(realCwd);
284
290
 
285
291
  const result = await runGit(
286
292
  ['status', '--porcelain=v1', '-u', '-z'],
@@ -330,4 +336,5 @@ module.exports = {
330
336
  parsePorcelain,
331
337
  mapStatus,
332
338
  clearCache,
339
+ _caches: { statusCache, fileStatusCache, CACHE_MAX_CWDS },
333
340
  };
@@ -49,25 +49,8 @@ function resolvePricingKey(modelId) {
49
49
  }
50
50
 
51
51
  // ── LRU cache ─────────────────────────────────────────────────────────────────
52
- // Backed by an insertion-order Map: delete+re-insert on access = O(1) LRU.
53
- class LRUCache {
54
- constructor(max) {
55
- this._max = max;
56
- this._m = new Map();
57
- }
58
- get(k) {
59
- if (!this._m.has(k)) return undefined;
60
- const v = this._m.get(k);
61
- this._m.delete(k);
62
- this._m.set(k, v);
63
- return v;
64
- }
65
- set(k, v) {
66
- this._m.delete(k);
67
- this._m.set(k, v);
68
- if (this._m.size > this._max) this._m.delete(this._m.keys().next().value);
69
- }
70
- }
52
+ // Shared implementation: lib/lruCache.cjs (re-exported below for existing callers).
53
+ const { LRUCache } = require('./lib/lruCache.cjs');
71
54
 
72
55
  /**
73
56
  * Cache for parseJSONL results.
@@ -43,7 +43,9 @@ const epicWorktreeMerge = require('./lib/epicWorktreeMerge.cjs');
43
43
  const epicWorktreeProjectConfig = require('./lib/epicWorktreeProjectConfig.cjs');
44
44
  const agentLibrary = require('./agentLibrary.cjs');
45
45
  const agentModelResolve = require('./lib/agentModelResolve.cjs');
46
+ const agentEffortResolve = require('./lib/agentEffortResolve.cjs');
46
47
  const { resolveEffectiveModelInfo } = require('./lib/effectiveModelInfo.cjs');
48
+ const { resolveModelCatalog } = require('./lib/modelCatalog.cjs');
47
49
  const { checkDelegationReadiness, ensureGuardsInstalled, installPrdWriteGuard, installDestructiveGitGuard, installInlineImplementationGuard, installSelfScheduleGuard } = require('./lib/delegationReadiness.cjs');
48
50
  const { resolveProjectContext } = require('./lib/projectRootResolve.cjs');
49
51
  const upgradeDrain = require('./lib/upgradeDrain.cjs');
@@ -80,6 +82,7 @@ const { registerHistoryAggregatorHandlers, finalizeClosedDays, refreshIntradayTo
80
82
  const runLogRetention = require('./lib/runLogRetention.cjs');
81
83
  const { registerHistoryDashboardHandlers } = require('./historyDashboard.cjs');
82
84
  const { tryAcquireLock, releaseLock, DEFAULT_LOCK_PATH } = require('./lib/watchdogHelpers.cjs');
85
+ const { createIntradayRefresh } = require('./lib/intradayRefresh.cjs');
83
86
  const schedulerConfig = require('./lib/schedulerConfig.cjs');
84
87
  const memoryTool = require('./memoryTool.cjs');
85
88
  const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
@@ -202,14 +205,9 @@ const REBOOT_LOG = path.join(os.homedir(), '.claude', 'session-manager-reboot.lo
202
205
  // finalize pass never interleave writes to the rollup file. A contended lock
203
206
  // just means this tick is skipped — the next timer tick (or the next boot)
204
207
  // retries, so skipping is always safe.
205
- function runIntradayRefresh() {
206
- if (!tryAcquireLock(DEFAULT_LOCK_PATH)) return;
207
- refreshIntradayToday()
208
- .catch((e) => {
209
- logs.writeLine({ scope: 'history-rollup', level: 'error', message: 'refreshIntradayToday failed', meta: { error: e?.message } });
210
- })
211
- .finally(() => releaseLock(DEFAULT_LOCK_PATH));
212
- }
208
+ const runIntradayRefresh = createIntradayRefresh({
209
+ tryAcquireLock, releaseLock, lockPath: DEFAULT_LOCK_PATH, refresh: refreshIntradayToday, logs,
210
+ });
213
211
 
214
212
  function logReboot(line) {
215
213
  try {
@@ -442,6 +440,9 @@ function createWindow() {
442
440
  });
443
441
  });
444
442
 
443
+ // Bounded auto-recovery for a crashed / hung / failed-to-load renderer.
444
+ require('./lib/rendererRecovery.cjs').attachRendererRecovery(mainWindow.webContents, { logs });
445
+
445
446
  // Native right-click menu — Copy / Paste / Select All everywhere. Roles
446
447
  // hook into Electron's built-in clipboard/selection plumbing, which xterm.js
447
448
  // (and Monaco, and Tiptap) all participate in via the standard DOM
@@ -565,6 +566,10 @@ ipcMain.handle('agents:get-persona-body', validated(schemas.agentsGetPersonaBody
565
566
  // is merely supposed to. Never throws (resolveEpicModel's own contract).
566
567
  ipcMain.handle('agents:resolve-epic-model', validated(schemas.agentsResolveEpicModel, (payload) =>
567
568
  agentModelResolve.resolveEpicModel(payload)));
569
+ // Effort twin of the above (agentEffortResolve.cjs): Terminal appends
570
+ // `--effort <level>` only when `effort` is non-null. Never throws.
571
+ ipcMain.handle('agents:resolve-epic-effort', validated(schemas.agentsResolveEpicEffort, (payload) =>
572
+ agentEffortResolve.resolveEpicEffort(payload)));
568
573
 
569
574
  // "What will this Epic actually run as?" (PRD: effective-model-resolver) —
570
575
  // persona alias + evidence-based concrete model id + provenance, for a given
@@ -572,6 +577,10 @@ ipcMain.handle('agents:resolve-epic-model', validated(schemas.agentsResolveEpicM
572
577
  ipcMain.handle('agents:resolve-model-info', validated(schemas.agentsResolveModelInfo, (payload) =>
573
578
  resolveEffectiveModelInfo(payload)));
574
579
 
580
+ // Live model/effort catalog (modelCatalog.cjs) — never throws; `force` bypasses its cache.
581
+ ipcMain.handle('models:catalog', validated(schemas.modelsCatalog, (payload) =>
582
+ resolveModelCatalog(payload)));
583
+
575
584
  // "Can this project actually delegate?" probe (PRD: delegation-readiness).
576
585
  // Structured data over the preconditions for scheduler_create_prd being in an
577
586
  // agent's tool list at all — surfacing it in the UI is a sibling PRD.
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * Zod schemas for all IPC handler payloads. Applied at the boundary between
3
4
  * renderer and main — rejects malformed data before it reaches any business
@@ -152,20 +153,22 @@ const PROMPT_SESSION_ID_RE = /^(?!__proto__$|constructor$|prototype$)[A-Za-z0-9_
152
153
  // renderer only logs the failure). Dropping just the offending row matches how
153
154
  // the rest of this pipeline already fails: per-id (activeIndexMerge.cjs's
154
155
  // tombstone/resurrection handling), never per-batch.
156
+ /** @param {unknown} raw */
155
157
  const dropInvalidSessions = (raw) => {
156
158
  if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return raw;
159
+ const rec = /** @type {Record<string, unknown>} */ (raw);
157
160
  const kept = Object.create(null);
158
- for (const id of Object.keys(raw)) {
161
+ for (const id of Object.keys(rec)) {
159
162
  if (!PROMPT_SESSION_ID_RE.test(id)) {
160
163
  console.warn(`[ipc] merge-active-index: dropping session with invalid id ${JSON.stringify(id)}`);
161
164
  continue;
162
165
  }
163
- const parsed = PromptSessionSchema.safeParse(raw[id]);
166
+ const parsed = PromptSessionSchema.safeParse(rec[id]);
164
167
  if (!parsed.success) {
165
168
  console.warn(`[ipc] merge-active-index: dropping malformed session ${id} — ${parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('; ')}`);
166
169
  continue;
167
170
  }
168
- kept[id] = raw[id];
171
+ kept[id] = rec[id];
169
172
  }
170
173
  return kept;
171
174
  };
@@ -198,6 +201,9 @@ const promptSessionsCreateEpic = z.object({
198
201
  goalText: z.string().min(1).max(20000),
199
202
  tag: EpicTagSchema.optional(),
200
203
  agentType: z.string().min(1).max(256).optional(),
204
+ // Per-Epic runtime overrides (New Session card) — absent = use the persona's own value.
205
+ model: z.string().min(1).max(256).optional(),
206
+ effort: z.string().min(1).max(64).optional(),
201
207
  source: EpicSourceSchema.optional(),
202
208
  // The full first-prompt body + its labeled sections (epicIntake.ts's
203
209
  // composeEpicIntake) — both optional, since not every mint caller (e.g. a
@@ -661,6 +667,12 @@ const agentsResolveModelInfo = z.object({
661
667
  agentType: z.string().min(1).max(200),
662
668
  }).strict();
663
669
 
670
+ // ──────────────────────────────────────────── Live model/effort catalog (modelCatalog.cjs)
671
+ const modelsCatalog = z.object({
672
+ cwd: z.string().optional(),
673
+ force: z.boolean().optional(),
674
+ }).strict();
675
+
664
676
  // ──────────────────────────────────────────── Epic launch model (PRD: unify-epic-model-resolution)
665
677
  // Terminal's (EpicTerminalPane.tsx) call into agentModelResolve.cjs's
666
678
  // resolveEpicModel — the same launch-model authority chatRunner.cjs's Chat
@@ -669,6 +681,7 @@ const agentsResolveEpicModel = z.object({
669
681
  cwd: z.string().min(1).max(4096),
670
682
  claudeSessionId: z.string().min(1).max(200),
671
683
  }).strict();
684
+ const agentsResolveEpicEffort = agentsResolveEpicModel;
672
685
 
673
686
  // ──────────────────────────────────────────── Project Pages (PRD 932)
674
687
  // Same validation split as projectBriefCwd above: real path validation is
@@ -1036,6 +1049,13 @@ const telemetrySetConfig = z.object({
1036
1049
  * call sites already rely on throw semantics for malformed input, so we keep
1037
1050
  * that behavior for backwards compatibility.
1038
1051
  */
1052
+ /**
1053
+ * @template T
1054
+ * @template R
1055
+ * @param {{ parse: (payload: unknown) => T }} schema
1056
+ * @param {(parsed: T) => R} handler
1057
+ * @returns {(_event: unknown, payload: unknown) => R}
1058
+ */
1039
1059
  function validated(schema, handler) {
1040
1060
  return (_event, payload) => {
1041
1061
  const parsed = schema.parse(payload);
@@ -1146,7 +1166,9 @@ module.exports = {
1146
1166
  agentsSavePersona,
1147
1167
  agentsGetPersonaBody,
1148
1168
  agentsResolveModelInfo,
1169
+ modelsCatalog,
1149
1170
  agentsResolveEpicModel,
1171
+ agentsResolveEpicEffort,
1150
1172
  delegationReadinessCwd,
1151
1173
  telemetrySetConfig,
1152
1174
  },
@@ -139,3 +139,183 @@ describe('childWithLog onExit leakedDescendants reporting (PRD 1110)', () => {
139
139
  15000,
140
140
  );
141
141
  });
142
+
143
+ // PRD 1353 — async enumeration must never block/alter the kill cascade, and a
144
+ // throwing watchdog must not break the wall-clock kill path.
145
+ describe('childWithLog async enumeration + guarded watchdog (PRD 1353)', () => {
146
+ const { _deps } = require('../childWithLog.cjs');
147
+ const realExecFile = _deps.execFile;
148
+ afterEach(() => { _deps.execFile = realExecFile; });
149
+
150
+ it.skipIf(process.platform !== 'linux')(
151
+ 'enumeration failure omits the report, logs, and still kills the group',
152
+ async () => {
153
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-child-enumfail-'));
154
+ const logPath = path.join(dir, 'job.log');
155
+ const pidFile = path.join(dir, 'grandchild.pid');
156
+ const { fd, safeLog, closeFd } = openLog(logPath);
157
+ _deps.execFile = (_c, _a, _o, cb) => { setImmediate(() => cb(new Error('ps boom'))); };
158
+
159
+ const exitInfo = await new Promise((resolve) => {
160
+ withChildAndLog({
161
+ fd, logPath, safeLog, closeFd,
162
+ spawn: {
163
+ command: 'sh',
164
+ args: ['-c', `sleep 30 & echo $! > ${pidFile}; exec sleep 0.2`],
165
+ options: { detached: true },
166
+ },
167
+ onExit: (info) => { info.safeLog('onExit-ran\n'); resolve(info); },
168
+ });
169
+ });
170
+ expect(exitInfo.leakedDescendants).toEqual([]);
171
+ for (let i = 0; i < 20 && !fs.existsSync(pidFile); i++) await new Promise((r) => setTimeout(r, 50));
172
+ const gc = Number(fs.readFileSync(pidFile, 'utf8').trim());
173
+ await new Promise((r) => setTimeout(r, 500)); // SIGTERM lands immediately, no grace wait
174
+ expect(() => process.kill(gc, 0)).toThrow();
175
+ expect(fs.readFileSync(logPath, 'utf8')).toContain('enumeration failed');
176
+ },
177
+ 15000,
178
+ );
179
+
180
+ function runWatchdog(wd) {
181
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-child-wd-'));
182
+ const logPath = path.join(dir, 'job.log');
183
+ const { fd, safeLog, closeFd } = openLog(logPath);
184
+ let handle;
185
+ const done = new Promise((resolve) => {
186
+ handle = withChildAndLog({
187
+ fd, logPath, safeLog, closeFd,
188
+ spawn: { command: 'sleep', args: ['5'], options: {} },
189
+ watchdogs: [{ label: 'w', intervalMs: 20, ...wd }],
190
+ onExit: resolve,
191
+ });
192
+ });
193
+ return { logPath, handle, done };
194
+ }
195
+
196
+ it('a throwing shouldFire does not stop a later tick firing the action', async () => {
197
+ let calls = 0;
198
+ let fired = 0;
199
+ const { handle, logPath, done } = runWatchdog({
200
+ shouldFire: () => { calls++; if (calls < 3) throw new Error('sf boom'); return true; },
201
+ action: (ctx) => { fired++; ctx.killedByWatchdog = 'w'; ctx.killTree('SIGTERM'); },
202
+ });
203
+ const info = await done;
204
+ expect(fired).toBe(1);
205
+ expect(info.killedByWatchdog).toBe('w');
206
+ expect(fs.readFileSync(logPath, 'utf8')).toContain('watchdog "w" threw: sf boom');
207
+ handle.cancel();
208
+ }, 10000);
209
+
210
+ it('a throwing action clears its interval and is logged', async () => {
211
+ let should = 0;
212
+ let acts = 0;
213
+ const { handle, logPath, done } = runWatchdog({
214
+ shouldFire: () => { should++; return true; },
215
+ action: () => { acts++; throw new Error('act boom'); },
216
+ });
217
+ await new Promise((r) => setTimeout(r, 200));
218
+ expect(acts).toBe(1);
219
+ expect(should).toBe(1);
220
+ expect(fs.readFileSync(logPath, 'utf8')).toContain('watchdog "w" threw: act boom');
221
+ handle.cancel();
222
+ await done;
223
+ }, 10000);
224
+ });
225
+
226
+ // PRD 1370 — enumerate BEFORE SIGTERM; no-enumeration paths stay synchronous.
227
+ describe('childWithLog sweep ordering + sync paths (PRD 1370)', () => {
228
+ const { _deps } = require('../childWithLog.cjs');
229
+ const realExecFile = _deps.execFile;
230
+ const realKill = process.kill;
231
+ afterEach(() => { _deps.execFile = realExecFile; process.kill = realKill; });
232
+
233
+ function setup(name) {
234
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), name));
235
+ const logPath = path.join(dir, 'job.log');
236
+ return { logPath, ...openLog(logPath) };
237
+ }
238
+
239
+ it.skipIf(process.platform !== 'linux').each([
240
+ ['successful', (cb) => cb(null, 'PID PGRP %CPU ELAPSED COMMAND\n')],
241
+ ['failing', (cb) => cb(new Error('ps boom'))],
242
+ ])('SIGTERM waits for the %s enumeration', async (label, respond) => {
243
+ const { logPath, fd, safeLog, closeFd } = setup('sm-child-order-');
244
+ let psCb;
245
+ _deps.execFile = (_c, _a, _o, cb) => { psCb = cb; };
246
+ const groupKills = [];
247
+ process.kill = (pid, sig) => {
248
+ if (pid < -1 && sig === 'SIGTERM') { groupKills.push(pid); return true; }
249
+ return realKill.call(process, pid, sig);
250
+ };
251
+ let info;
252
+ const done = new Promise((resolve) => {
253
+ withChildAndLog({
254
+ fd, logPath, safeLog, closeFd,
255
+ spawn: { command: 'sh', args: ['-c', 'exit 0'], options: { detached: true } },
256
+ onExit: (i) => { info = i; resolve(); },
257
+ });
258
+ });
259
+ for (let i = 0; i < 100 && !psCb; i++) await new Promise((r) => setTimeout(r, 20));
260
+ expect(psCb).toBeTruthy();
261
+ await new Promise((r) => setTimeout(r, 100));
262
+ expect(groupKills).toEqual([]);
263
+ respond(psCb);
264
+ await done; // onExit runs only after the kill was issued
265
+ expect(groupKills.length).toBe(1);
266
+ expect(info.leakedDescendants).toEqual([]);
267
+ if (label === 'failing') expect(fs.readFileSync(logPath, 'utf8')).toContain('enumeration failed');
268
+ }, 15000);
269
+
270
+ it('runs onExit synchronously when the spawn throws', () => {
271
+ const { logPath, fd, safeLog, closeFd } = setup('sm-child-spawnfail-');
272
+ let ran = false;
273
+ const r = withChildAndLog({
274
+ fd, logPath, safeLog, closeFd,
275
+ spawn: { command: 'sh', args: [], options: { cwd: '/nonexistent\0bad', detached: true } },
276
+ onExit: (i) => { ran = i.spawnFailed === true; },
277
+ });
278
+ expect(r.child).toBeNull();
279
+ expect(ran).toBe(true);
280
+ });
281
+
282
+ it.skipIf(process.platform !== 'linux')('a throwing deferred onExit is logged and still closes the fd', async () => {
283
+ const { logPath, fd, safeLog, closeFd } = setup('sm-child-throw-');
284
+ _deps.execFile = (_c, _a, _o, cb) => { setImmediate(() => cb(null, 'h\n')); };
285
+ const unhandled = [];
286
+ const h = (e) => unhandled.push(e);
287
+ process.on('unhandledRejection', h);
288
+ let closed = false;
289
+ const wrapClose = () => { closed = true; closeFd(); };
290
+ await new Promise((resolve) => {
291
+ withChildAndLog({
292
+ fd, logPath, safeLog, closeFd: wrapClose,
293
+ spawn: { command: 'sh', args: ['-c', 'exit 0'], options: { detached: true } },
294
+ onExit: () => { setImmediate(resolve); throw new Error('exit boom'); },
295
+ });
296
+ });
297
+ await new Promise((r) => setTimeout(r, 50));
298
+ process.off('unhandledRejection', h);
299
+ expect(unhandled).toEqual([]);
300
+ expect(closed).toBe(true);
301
+ expect(fs.readFileSync(logPath, 'utf8')).toContain('onExit threw: exit boom');
302
+ }, 15000);
303
+
304
+ it('a throwing synchronous onExit is logged, closes the fd, and is re-thrown', async () => {
305
+ const { logPath, fd, safeLog, closeFd } = setup('sm-child-throw-sync-');
306
+ let closed = false;
307
+ const wrapClose = () => { closed = true; closeFd(); };
308
+ const thrown = await new Promise((resolve) => {
309
+ const onUncaught = (e) => { process.off('uncaughtException', onUncaught); resolve(e); };
310
+ process.on('uncaughtException', onUncaught);
311
+ withChildAndLog({
312
+ fd, logPath, safeLog, closeFd: wrapClose,
313
+ spawn: { command: 'sh', args: ['-c', 'exit 0'] },
314
+ onExit: () => { throw new Error('exit boom'); },
315
+ });
316
+ });
317
+ expect(thrown.message).toBe('exit boom');
318
+ expect(closed).toBe(true);
319
+ expect(fs.readFileSync(logPath, 'utf8')).toContain('onExit threw: exit boom');
320
+ }, 15000);
321
+ });
@@ -1154,3 +1154,22 @@ test('ensureGuardsInstalled: records every auto-install attempt in the ops error
1154
1154
  expect(delegationLines.every((l) => l.level === 'info')).toBe(true);
1155
1155
  expect(delegationLines.every((l) => l.meta?.action === 'installed')).toBe(true);
1156
1156
  }, 15_000);
1157
+
1158
+ test('ensureGuardsInstalled: an already-installed re-run writes no new ops error-log rows', async () => {
1159
+ const cwd = await mkTmp('sm-ensure-guards-noop-');
1160
+ const homeDir = await mkTmp('sm-ensure-guards-noop-home-');
1161
+
1162
+ await ensureGuardsInstalled(cwd, { homeDir });
1163
+ const logFile = opsErrorLogTodayFile(cwd);
1164
+ const before = fs.readFileSync(logFile, 'utf8');
1165
+
1166
+ clearGuardsInstalledCache();
1167
+ const again = await ensureGuardsInstalled(cwd, { homeDir });
1168
+ expect(Object.values(again.guards).every((g) => g.action === 'already-installed')).toBe(true);
1169
+ expect(fs.readFileSync(logFile, 'utf8')).toBe(before);
1170
+ }, 15_000);
1171
+
1172
+ test('delegationReadiness.cjs source contains no raw NUL byte', () => {
1173
+ const src = fs.readFileSync(path.join(__dirname, '..', 'delegationReadiness.cjs'));
1174
+ expect(src.includes(0)).toBe(false);
1175
+ });
@@ -18,7 +18,7 @@ const fs = require('node:fs');
18
18
  const path = require('node:path');
19
19
  const os = require('node:os');
20
20
  const { encodeCwd } = require('../encodeCwd.cjs');
21
- const { resolveEffectiveModelInfo, isConcreteModelId, computeEffortReachable } = require('../effectiveModelInfo.cjs');
21
+ const { resolveEffectiveModelInfo, isConcreteModelId, computeEffortEnvReachable } = require('../effectiveModelInfo.cjs');
22
22
  // config.cjs's `allowedRoots` is a module-level singleton seeded from
23
23
  // os.homedir() at FIRST require — since this test's HOME changes every test
24
24
  // (fresh mkdtemp), a persona lookup (getPersonaBody -> validatePath) would
@@ -223,17 +223,22 @@ test('isConcreteModelId treats known CLI aliases as non-concrete and everything
223
223
  expect(isConcreteModelId('opus')).toBe(false);
224
224
  expect(isConcreteModelId('sonnet')).toBe(false);
225
225
  expect(isConcreteModelId('haiku')).toBe(false);
226
+ expect(isConcreteModelId('opus[1m]')).toBe(false);
227
+ expect(isConcreteModelId('best')).toBe(false);
228
+ expect(isConcreteModelId('opusplan')).toBe(false);
229
+ expect(isConcreteModelId('default')).toBe(false);
230
+ expect(isConcreteModelId('claude-opus-4-7[1m]')).toBe(true);
226
231
  expect(isConcreteModelId('fable')).toBe(false);
227
232
  expect(isConcreteModelId(null)).toBe(false);
228
233
  expect(isConcreteModelId('claude-opus-5')).toBe(true);
229
234
  });
230
235
 
231
- test('effortReachable is always false for an app-launched session — cleanChildEnv strips CLAUDE_EFFORT and CLAUDE_CODE_* before a child ever sees them', () => {
232
- expect(computeEffortReachable({})).toBe(false);
236
+ test('effortEnvReachable is always false for an app-launched session — cleanChildEnv strips CLAUDE_EFFORT and CLAUDE_CODE_* before a child ever sees them', () => {
237
+ expect(computeEffortEnvReachable({})).toBe(false);
233
238
  });
234
239
 
235
- test('resolveEffectiveModelInfo surfaces effortReachable alongside the model fields', async () => {
240
+ test('resolveEffectiveModelInfo surfaces effortEnvReachable alongside the model fields', async () => {
236
241
  writeGlobalPersona('any', ['model: sonnet']);
237
242
  const info = await resolveEffectiveModelInfo({ cwd, agentType: 'any' });
238
- expect(info.effortReachable).toBe(false);
243
+ expect(info.effortEnvReachable).toBe(false);
239
244
  });