claude-code-session-manager 0.93.0 → 0.95.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 (78) hide show
  1. package/dist/assets/{DataModel-DDGx3qrn.js → DataModel-DZK9QZpV.js} +1 -1
  2. package/dist/assets/{History-CHBjYZyD.js → History-BDyJUGtR.js} +2 -2
  3. package/dist/assets/{Hooks-DG4c-Rzz.js → Hooks-CmK6l0uf.js} +3 -3
  4. package/dist/assets/HostBilko-D8TIx-Bx.js +1 -0
  5. package/dist/assets/{Library-CN1h5rAE.js → Library-DTxpOjx8.js} +25 -25
  6. package/dist/assets/{MarkdownEditor-DmBxrhdn.js → MarkdownEditor-BPJyrevq.js} +1 -1
  7. package/dist/assets/McpServers-B_rvIYDs.js +2 -0
  8. package/dist/assets/{Memory-Dk4uDCjg.js → Memory-B-9GlHqe.js} +6 -6
  9. package/dist/assets/{Permissions-BovobeZw.js → Permissions-DhMMp27n.js} +3 -3
  10. package/dist/assets/{Plugins-DvOf_B62.js → Plugins-DKfAlmMY.js} +2 -2
  11. package/dist/assets/{ProvenanceBadge-Dutz_4OY.js → ProvenanceBadge-ptlItdCK.js} +1 -1
  12. package/dist/assets/{SaveBar-CT5u6ZCJ.js → SaveBar-CSbNSl2W.js} +1 -1
  13. package/dist/assets/Scheduler--rn1QBYc.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-BUYDpz2v.js → ScopeSwitcher-BJof-GWH.js} +1 -1
  15. package/dist/assets/{Settings-DIRmr3tL.js → Settings-Dsx6XZP5.js} +3 -3
  16. package/dist/assets/{SkillReferenceGraph-CdGr6d3_.js → SkillReferenceGraph-CGl4B89-.js} +2 -2
  17. package/dist/assets/{Skills-BQOheQv4.js → Skills-D522Zfol.js} +2 -2
  18. package/dist/assets/{SystemPrompt-5LsimKbX.js → SystemPrompt-DKj-q39J.js} +1 -1
  19. package/dist/assets/{TagLibrary-DreLsT3n.js → TagLibrary-BH7CG7-6.js} +1 -1
  20. package/dist/assets/{TiptapBody-C9kmlBnI.js → TiptapBody-BbcJL9az.js} +1 -1
  21. package/dist/assets/{Toggle-BKLCBu9A.js → Toggle-DAslvnnv.js} +1 -1
  22. package/dist/assets/{index-Db_SK9rW.js → index-Yf8e7VCB.js} +821 -821
  23. package/dist/assets/{settingsSchema-B5jBhSUP.js → settingsSchema-pzp0TgvN.js} +1 -1
  24. package/dist/index.html +1 -1
  25. package/package.json +8 -3
  26. package/scripts/README.md +6 -0
  27. package/src/main/__tests__/chat-cancel-terminal.test.cjs +5 -2
  28. package/src/main/__tests__/chat-exit-close-race.test.cjs +8 -2
  29. package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +5 -2
  30. package/src/main/__tests__/health-tick-liveness.test.cjs +12 -0
  31. package/src/main/__tests__/intradayRefresh.test.cjs +39 -0
  32. package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +25 -0
  33. package/src/main/__tests__/opsErrorLog.test.cjs +22 -0
  34. package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +21 -0
  35. package/src/main/__tests__/rateLimitPollerStreak.test.cjs +14 -0
  36. package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +26 -0
  37. package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +11 -1
  38. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +11 -1
  39. package/src/main/__tests__/runVerify.test.cjs +12 -1
  40. package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +15 -0
  41. package/src/main/__tests__/scheduler-utilization-hold.test.cjs +89 -0
  42. package/src/main/__tests__/transcriptsUsageFor.test.cjs +112 -1
  43. package/src/main/build-info.json +4 -4
  44. package/src/main/chatRunner.cjs +45 -10
  45. package/src/main/crashDiagnostics.cjs +9 -0
  46. package/src/main/git.cjs +9 -2
  47. package/src/main/health.cjs +25 -1
  48. package/src/main/historyAggregator.cjs +2 -19
  49. package/src/main/index.cjs +7 -8
  50. package/src/main/ipcSchemas.cjs +13 -3
  51. package/src/main/lib/__tests__/childWithLog.test.cjs +180 -0
  52. package/src/main/lib/__tests__/delegationReadiness.test.cjs +19 -0
  53. package/src/main/lib/__tests__/gitCacheBound.test.cjs +69 -0
  54. package/src/main/lib/__tests__/loopDelay.test.cjs +68 -0
  55. package/src/main/lib/__tests__/usageCircuit.test.cjs +86 -17
  56. package/src/main/lib/agentPersonaSchema.cjs +2 -0
  57. package/src/main/lib/childWithLog.cjs +92 -51
  58. package/src/main/lib/delegationReadiness.cjs +3 -1
  59. package/src/main/lib/epicSpawnPlan.cjs +27 -8
  60. package/src/main/lib/epicTranscriptPath.cjs +5 -1
  61. package/src/main/lib/headTailBuffer.cjs +43 -0
  62. package/src/main/lib/intradayRefresh.cjs +33 -0
  63. package/src/main/lib/loopDelay.cjs +44 -0
  64. package/src/main/lib/lruCache.cjs +39 -0
  65. package/src/main/lib/openExternalApp.cjs +27 -9
  66. package/src/main/lib/opsErrorLog.cjs +22 -0
  67. package/src/main/lib/promptSessionSchema.cjs +2 -0
  68. package/src/main/lib/rendererRecovery.cjs +141 -0
  69. package/src/main/lib/scheduleJobSchema.cjs +3 -0
  70. package/src/main/lib/usageCircuit.cjs +40 -12
  71. package/src/main/runVerify.cjs +3 -1
  72. package/src/main/scheduler.cjs +409 -260
  73. package/src/main/transcripts.cjs +54 -11
  74. package/src/main/usage.cjs +9 -1
  75. package/src/preload/api.d.ts +20 -3
  76. package/dist/assets/HostBilko-DHXcpV2c.js +0 -1
  77. package/dist/assets/McpServers-Beb_HD6I.js +0 -2
  78. package/dist/assets/Scheduler-B7GSnZj5.js +0 -16
@@ -8,7 +8,7 @@
8
8
 
9
9
  'use strict';
10
10
 
11
- import { test, expect, beforeEach, afterEach } from 'vitest';
11
+ import { test, expect, beforeEach, afterEach, vi } from 'vitest';
12
12
 
13
13
  const fs = require('node:fs');
14
14
  const os = require('node:os');
@@ -93,3 +93,114 @@ test('a growing transcript is re-summed after the file mtime/size change', async
93
93
  const after = await usageFor(cwd, [sessionId]);
94
94
  expect(after[sessionId]).toEqual({ inputTokens: 13, outputTokens: 6 });
95
95
  });
96
+
97
+ // ── incremental tail parse + bounded cache ─────────────────────────────────
98
+
99
+ const fsp = require('node:fs/promises');
100
+ const transcripts = require('../transcripts.cjs');
101
+
102
+ function fromScratch(filePath) {
103
+ let inputTokens = 0;
104
+ let outputTokens = 0;
105
+ for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
106
+ if (!line) continue;
107
+ let o;
108
+ try { o = JSON.parse(line); } catch { continue; }
109
+ const u = o.usage || {};
110
+ inputTokens += u.input_tokens ?? 0;
111
+ outputTokens += u.output_tokens ?? 0;
112
+ }
113
+ return { inputTokens, outputTokens };
114
+ }
115
+
116
+ test('incremental result equals a from-scratch parse across 5 appends (partial line + repeated id)', async () => {
117
+ const sessionId = 'sess-incr';
118
+ const filePath = writeTranscript(cwd, sessionId, [
119
+ { id: 'm1', usage: { input_tokens: 10, output_tokens: 1 } },
120
+ ]);
121
+ const check = async () => {
122
+ const r = await usageFor(cwd, [sessionId]);
123
+ expect(r[sessionId]).toEqual(fromScratch(filePath));
124
+ };
125
+ await check();
126
+ const l2 = JSON.stringify({ id: 'm2', usage: { input_tokens: 20, output_tokens: 2 } });
127
+ // 1: append ending mid-line
128
+ fs.appendFileSync(filePath, l2.slice(0, 15));
129
+ await check();
130
+ // 2: complete that line
131
+ fs.appendFileSync(filePath, l2.slice(15) + '\n');
132
+ await check();
133
+ // 3: repeat an earlier message id (full parse sums it again)
134
+ fs.appendFileSync(filePath, JSON.stringify({ id: 'm1', usage: { input_tokens: 10, output_tokens: 1 } }) + '\n');
135
+ await check();
136
+ // 4: non-usage + malformed line
137
+ fs.appendFileSync(filePath, JSON.stringify({ type: 'user' }) + '\nnot json\n');
138
+ await check();
139
+ // 5: multibyte content then usage
140
+ fs.appendFileSync(filePath, JSON.stringify({ t: 'héllo 日本', usage: { input_tokens: 4, output_tokens: 4 } }) + '\n');
141
+ await check();
142
+ expect((await usageFor(cwd, [sessionId]))[sessionId]).toEqual({ inputTokens: 44, outputTokens: 8 });
143
+ });
144
+
145
+ test('the call after a one-line append reads < 4 KB from disk', async () => {
146
+ const sessionId = 'sess-bytes';
147
+ const big = Array.from({ length: 2000 }, (_, i) => ({ i, pad: 'x'.repeat(100), usage: { input_tokens: 1, output_tokens: 1 } }));
148
+ const filePath = writeTranscript(cwd, sessionId, big);
149
+ await usageFor(cwd, [sessionId]);
150
+ expect(fs.statSync(filePath).size).toBeGreaterThan(200_000);
151
+
152
+ fs.appendFileSync(filePath, JSON.stringify({ usage: { input_tokens: 5, output_tokens: 5 } }) + '\n');
153
+ const fh = await fsp.open(filePath, 'r');
154
+ const proto = Object.getPrototypeOf(fh);
155
+ await fh.close();
156
+ const orig = proto.read;
157
+ const lengths = [];
158
+ const spy = vi.spyOn(proto, 'read').mockImplementation(function (buf, off, len, pos) {
159
+ lengths.push(len);
160
+ return orig.call(this, buf, off, len, pos);
161
+ });
162
+ try {
163
+ const r = await usageFor(cwd, [sessionId]);
164
+ expect(r[sessionId]).toEqual({ inputTokens: 2005, outputTokens: 2005 });
165
+ } finally {
166
+ spy.mockRestore();
167
+ }
168
+ expect(lengths.length).toBeGreaterThan(0);
169
+ expect(lengths.reduce((a, b) => a + b, 0)).toBeLessThan(4096);
170
+ });
171
+
172
+ test('truncation / replacement triggers a full re-parse', async () => {
173
+ const sessionId = 'sess-trunc';
174
+ const filePath = writeTranscript(cwd, sessionId, [
175
+ { usage: { input_tokens: 100, output_tokens: 100 } },
176
+ { usage: { input_tokens: 100, output_tokens: 100 } },
177
+ ]);
178
+ await usageFor(cwd, [sessionId]);
179
+ fs.writeFileSync(filePath, JSON.stringify({ usage: { input_tokens: 1, output_tokens: 2 } }) + '\n');
180
+ expect((await usageFor(cwd, [sessionId]))[sessionId]).toEqual({ inputTokens: 1, outputTokens: 2 });
181
+ });
182
+
183
+ test('usageCache is bounded and evicts the least-recently-used entry', async () => {
184
+ const cache = transcripts.__usageCacheForTest;
185
+ const max = transcripts.__usageCacheMaxForTest;
186
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-usage-lru-'));
187
+ try {
188
+ const files = [];
189
+ for (let i = 0; i < max + 5; i++) {
190
+ const f = path.join(dir, `s${i}.jsonl`);
191
+ fs.writeFileSync(f, JSON.stringify({ usage: { input_tokens: 1, output_tokens: 1 } }) + '\n');
192
+ files.push(f);
193
+ }
194
+ const before = cache.size;
195
+ for (const f of files) {
196
+ // usageForOne is reached through usageFor's path; call via a cwd-independent route.
197
+ await transcripts.__usageForOneForTest(f);
198
+ }
199
+ expect(cache.size).toBeLessThanOrEqual(max);
200
+ expect(before).toBeLessThanOrEqual(max);
201
+ expect(cache.has(files[0])).toBe(false);
202
+ expect(cache.has(files[files.length - 1])).toBe(true);
203
+ } finally {
204
+ fs.rmSync(dir, { recursive: true, force: true });
205
+ }
206
+ });
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.93.0",
3
- "gitSha": "78fa526eb94e044b2c72c736681e956bee653451",
4
- "gitShortSha": "78fa526",
2
+ "version": "0.95.0",
3
+ "gitSha": "42f7779080772b1b8432b6287aff06c52b9f0f90",
4
+ "gitShortSha": "42f77790",
5
5
  "gitBranch": "HEAD",
6
- "builtAt": "2026-09-21T03:09:24.532Z",
6
+ "builtAt": "2026-09-21T22:01:57.972Z",
7
7
  "dirty": false
8
8
  }
@@ -56,6 +56,11 @@ const agentModelResolve = require('./lib/agentModelResolve.cjs');
56
56
  const { resolveEpicEffort, effortArgs } = require('./lib/agentEffortResolve.cjs');
57
57
  const { planEpicSpawn } = require('./lib/epicSpawnPlan.cjs');
58
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;
59
64
 
60
65
  // The only two CLI errors that mean "wrong session flag" (verified against the
61
66
  // real CLI). "in use" → the transcript exists, so resume; "no conversation" →
@@ -317,6 +322,13 @@ const waiting = []; // [{ tabId, sessionId, prompt, cwd, resume, silent, onSilen
317
322
  // Closes the microtask-wide window in which per-tab exclusivity would
318
323
  // otherwise not hold. See pump().
319
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();
320
332
  let activeCount = 0;
321
333
 
322
334
  // Indirection so tests can stub the spawn without launching claude.
@@ -332,6 +344,7 @@ function __setExecutor(fn) { executor = fn || executeRun; }
332
344
  function __resetQueueForTests() {
333
345
  inFlight.clear();
334
346
  dispatching.clear();
347
+ liveSessions.clear();
335
348
  waiting.length = 0;
336
349
  activeCount = 0;
337
350
  sessionSlots.__resetForTests();
@@ -380,6 +393,7 @@ function run(opts) {
380
393
  // visible in `inFlight` — the same window pump() guards against.
381
394
  const tabBusy = inFlight.has(opts.tabId)
382
395
  || dispatching.has(opts.tabId)
396
+ || liveSessions.has(opts.sessionId)
383
397
  || waiting.some((w) => w.tabId === opts.tabId);
384
398
  if (tabBusy && opts.silent) {
385
399
  // Silent probes stay best-effort and invisible: a probe that collides is
@@ -402,7 +416,8 @@ function pump() {
402
416
  // it in `inFlight` (which happens a microtask later) — without it, one
403
417
  // synchronous sweep of this loop could start two runs for the same tab and
404
418
  // race two --resume processes against a single sessionId.
405
- 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));
406
421
  // Everything queued is blocked behind its own tab's live run; the settle()
407
422
  // of that run re-pumps.
408
423
  if (idx === -1) break;
@@ -415,6 +430,7 @@ function pump() {
415
430
  const [job] = waiting.splice(idx, 1);
416
431
  activeCount += 1;
417
432
  dispatching.add(job.tabId);
433
+ liveSessions.add(job.sessionId);
418
434
  Promise.resolve()
419
435
  .then(() => {
420
436
  const donePromise = executor(job);
@@ -425,6 +441,7 @@ function pump() {
425
441
  .catch(() => { /* executeRun never rejects; defensive */ })
426
442
  .finally(() => {
427
443
  dispatching.delete(job.tabId);
444
+ liveSessions.delete(job.sessionId);
428
445
  sessionSlots.release(slotToken);
429
446
  activeCount -= 1;
430
447
  pump();
@@ -478,6 +495,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
478
495
  // funnels through here so the exit path can emit a fallback only when the
479
496
  // run settled without one.
480
497
  let terminalSent = false;
498
+ let lineOverflowLogged = false;
481
499
  const emitTerminal = (channel, payload) => {
482
500
  if (terminalSent) return;
483
501
  terminalSent = true;
@@ -700,7 +718,9 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
700
718
  // Per-attempt state — a retry starts from empty buffers.
701
719
  let lineBuffer = '';
702
720
  let finalAssistantText = '';
703
- let stderrBuffer = '';
721
+ const stderrBuffer = createHeadTailBuffer(STDERR_TAIL_MAX);
722
+
723
+ const stderrText = () => stderrBuffer.matchText();
704
724
 
705
725
  const processLine = (line) => {
706
726
  if (!line) return;
@@ -792,10 +812,25 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
792
812
  processLine(lineBuffer.slice(0, nl).trim());
793
813
  lineBuffer = lineBuffer.slice(nl + 1);
794
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
+ }
795
830
  });
796
831
 
797
832
  thisChild.stderr.on('data', (chunk) => {
798
- stderrBuffer += chunk.toString('utf8');
833
+ stderrBuffer.append(chunk.toString('utf8'));
799
834
  });
800
835
 
801
836
  thisChild.on('error', (err) => {
@@ -824,10 +859,10 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
824
859
  // terminal latch stay at executeRun level, so this is still one turn.
825
860
  if (!terminalSent && !killed && code !== 0 && attemptNo === 1
826
861
  && finalAssistantText === '' && recentToolUses.length === 0
827
- && !/rate.?limit/i.test(stderrBuffer)) {
862
+ && !/rate.?limit/i.test(stderrText())) {
828
863
  const swapTo = resumeFlag
829
- ? (NO_CONVERSATION_RE.test(stderrBuffer) ? false : null)
830
- : (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);
831
866
  if (swapTo !== null) {
832
867
  if (!silent) {
833
868
  broadcast('chat:run:notice', {
@@ -856,7 +891,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
856
891
  sessionId,
857
892
  message: 'run cancelled',
858
893
  });
859
- } 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())) {
860
895
  // A session-flag rejection that survived the one-shot swap (or was
861
896
  // not eligible for it). The raw stderr dump names neither the flag,
862
897
  // the cwd nor the transcript — a real report cost a full diagnostic
@@ -875,9 +910,8 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
875
910
  + 'the session-flag resolution logic lives in main, and a stale build resolves it with older rules.',
876
911
  });
877
912
  } else {
878
- const errDetail = stderrBuffer.trim()
879
- ? `: ${stderrBuffer.trim().slice(0, 300)}`
880
- : '';
913
+ const preview = stderrBuffer.preview();
914
+ const errDetail = preview ? `: ${preview}` : '';
881
915
  emitTerminal('chat:run:error', {
882
916
  tabId,
883
917
  sessionId,
@@ -996,6 +1030,7 @@ module.exports = {
996
1030
  run,
997
1031
  cancel,
998
1032
  __resetQueueForTests,
1033
+ __liveSessionCount: () => liveSessions.size,
999
1034
  attachWindow,
1000
1035
  registerChatHandlers,
1001
1036
  parseStopSignal,
@@ -27,11 +27,13 @@ const { app, powerSaveBlocker, powerMonitor, crashReporter } = require('electron
27
27
  const fs = require('node:fs');
28
28
  const path = require('node:path');
29
29
  const { reportCrash } = require('./lib/crashTelemetry.cjs');
30
+ const { createLoopDelayMonitor, stallVerdict } = require('./lib/loopDelay.cjs');
30
31
 
31
32
  let logs = null;
32
33
  let getPowerBlockerId = () => -1;
33
34
  let onSuspendWhileAlive = null;
34
35
  let heartbeatTimer = null;
36
+ let loopMonitor = null;
35
37
  let sentinelPath = null;
36
38
  let startedAtIso = null;
37
39
  let lastSample = null;
@@ -118,6 +120,7 @@ function heartbeat() {
118
120
  persistSentinel(false);
119
121
  const top = [...lastSample.procs].sort((a, b) => b.mb - a.mb).slice(0, 4);
120
122
  const level = lastSample.totalMb >= MEM_WARN_MB ? 'warn' : 'debug';
123
+ const loop = loopMonitor ? loopMonitor.snapshot() : null;
121
124
  logs?.writeLine({
122
125
  scope: 'crash-diag',
123
126
  level,
@@ -127,8 +130,11 @@ function heartbeat() {
127
130
  mainRssMb: lastSample.main.rssMb,
128
131
  powerBlockerHeld: lastSample.powerBlockerHeld,
129
132
  top: top.map((p) => `${p.type}:${p.mb}MB`),
133
+ ...loop,
130
134
  },
131
135
  });
136
+ const stall = loop && stallVerdict(loop);
137
+ if (stall) logs?.writeLine({ scope: 'crash-diag', ...stall, meta: { ...loop } });
132
138
  }
133
139
 
134
140
  // Read the previous run's sentinel. If it was still `open`, the app died
@@ -242,6 +248,8 @@ function init(opts) {
242
248
  persistSentinel(false); // mark this run as open
243
249
  registerCrashHooks();
244
250
 
251
+ loopMonitor = createLoopDelayMonitor();
252
+ loopMonitor.enable();
245
253
  heartbeat(); // immediate baseline sample
246
254
  heartbeatTimer = setInterval(heartbeat, HEARTBEAT_MS);
247
255
  if (heartbeatTimer.unref) heartbeatTimer.unref(); // don't keep the loop alive
@@ -250,6 +258,7 @@ function init(opts) {
250
258
  // Call from will-quit so a graceful exit is distinguishable from an OOM-kill.
251
259
  function markCleanShutdown() {
252
260
  if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
261
+ if (loopMonitor) { loopMonitor.disable(); loopMonitor = null; }
253
262
  persistSentinel(true);
254
263
  }
255
264
 
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
  };
@@ -39,6 +39,8 @@ const PROJECT_ROOT = path.resolve(__dirname, '../..');
39
39
  // outage goes unnoticed for hours (the 2026-07-14 incident sat stalled for
40
40
  // 16.5h before anything asserted on it).
41
41
  const TICK_STALL_MULTIPLIER = 3;
42
+ // A utilization hold whose reset is further out than this is not a routine 5h pause.
43
+ const LONG_HOLD_MS = 5 * 60 * 60_000;
42
44
  const TICK_STALL_THRESHOLD_MS = TICK_STALL_MULTIPLIER * POLL_INTERVAL_MS;
43
45
  // A heartbeat line older than this is treated as "the app isn't running /
44
46
  // we can't observe live utilization" rather than "utilization is low" —
@@ -139,7 +141,19 @@ function evaluateTickLiveness(queueState, heartbeat, now, runningCount) {
139
141
  };
140
142
  }
141
143
  if (typeof heartbeat.utilization === 'number' && heartbeat.utilization >= config.utilizationThreshold) {
142
- return { stalled: false, reason: 'utilization-at-threshold', utilization: heartbeat.utilization };
144
+ // A hold on the binding window is benign only if it self-heals soon
145
+ // (reset within 5h). A weekly hold, or an unknown reset horizon, can
146
+ // last days — surface it rather than reading GREEN.
147
+ const resetMs = heartbeat.nextReset ? Date.parse(heartbeat.nextReset) : NaN;
148
+ const longHold = !Number.isFinite(resetMs) || resetMs - now > LONG_HOLD_MS;
149
+ return {
150
+ stalled: false,
151
+ reason: 'utilization-at-threshold',
152
+ utilization: heartbeat.utilization,
153
+ window: heartbeat.utilizationWindow ?? null,
154
+ nextReset: heartbeat.nextReset ?? null,
155
+ ...(longHold ? { longHold: true } : {}),
156
+ };
143
157
  }
144
158
  }
145
159
 
@@ -860,6 +874,16 @@ async function check(opts = {}) {
860
874
  })(),
861
875
  loadGateThreshold: loadGateThreshold(),
862
876
  };
877
+ if (liveness.longHold) {
878
+ status.components.scheduler_queue.longHold = {
879
+ window: liveness.window, utilization: liveness.utilization, nextReset: liveness.nextReset,
880
+ };
881
+ status.issues.push(
882
+ `Queue held on the ${liveness.window ?? 'binding'} usage window at ${liveness.utilization}% (≥ threshold); `
883
+ + (liveness.nextReset ? `resets ${liveness.nextReset}` : 'reset time unknown')
884
+ + ' — hold may last days, not a normal short pause'
885
+ );
886
+ }
863
887
  if (liveness.stalled) {
864
888
  const ageMin = Math.round(liveness.tickAgeMs / 60_000);
865
889
  status.components.scheduler_queue.stalledJob = liveness.oldestPendingSlug;
@@ -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.
@@ -82,6 +82,7 @@ const { registerHistoryAggregatorHandlers, finalizeClosedDays, refreshIntradayTo
82
82
  const runLogRetention = require('./lib/runLogRetention.cjs');
83
83
  const { registerHistoryDashboardHandlers } = require('./historyDashboard.cjs');
84
84
  const { tryAcquireLock, releaseLock, DEFAULT_LOCK_PATH } = require('./lib/watchdogHelpers.cjs');
85
+ const { createIntradayRefresh } = require('./lib/intradayRefresh.cjs');
85
86
  const schedulerConfig = require('./lib/schedulerConfig.cjs');
86
87
  const memoryTool = require('./memoryTool.cjs');
87
88
  const { registerMemoryAggregateIpc } = require('./memoryAggregate.cjs');
@@ -204,14 +205,9 @@ const REBOOT_LOG = path.join(os.homedir(), '.claude', 'session-manager-reboot.lo
204
205
  // finalize pass never interleave writes to the rollup file. A contended lock
205
206
  // just means this tick is skipped — the next timer tick (or the next boot)
206
207
  // retries, so skipping is always safe.
207
- function runIntradayRefresh() {
208
- if (!tryAcquireLock(DEFAULT_LOCK_PATH)) return;
209
- refreshIntradayToday()
210
- .catch((e) => {
211
- logs.writeLine({ scope: 'history-rollup', level: 'error', message: 'refreshIntradayToday failed', meta: { error: e?.message } });
212
- })
213
- .finally(() => releaseLock(DEFAULT_LOCK_PATH));
214
- }
208
+ const runIntradayRefresh = createIntradayRefresh({
209
+ tryAcquireLock, releaseLock, lockPath: DEFAULT_LOCK_PATH, refresh: refreshIntradayToday, logs,
210
+ });
215
211
 
216
212
  function logReboot(line) {
217
213
  try {
@@ -444,6 +440,9 @@ function createWindow() {
444
440
  });
445
441
  });
446
442
 
443
+ // Bounded auto-recovery for a crashed / hung / failed-to-load renderer.
444
+ require('./lib/rendererRecovery.cjs').attachRendererRecovery(mainWindow.webContents, { logs });
445
+
447
446
  // Native right-click menu — Copy / Paste / Select All everywhere. Roles
448
447
  // hook into Electron's built-in clipboard/selection plumbing, which xterm.js
449
448
  // (and Monaco, and Tiptap) all participate in via the standard DOM
@@ -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
  };
@@ -1046,6 +1049,13 @@ const telemetrySetConfig = z.object({
1046
1049
  * call sites already rely on throw semantics for malformed input, so we keep
1047
1050
  * that behavior for backwards compatibility.
1048
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
+ */
1049
1059
  function validated(schema, handler) {
1050
1060
  return (_event, payload) => {
1051
1061
  const parsed = schema.parse(payload);