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.
- package/dist/assets/{DataModel-DDGx3qrn.js → DataModel-DZK9QZpV.js} +1 -1
- package/dist/assets/{History-CHBjYZyD.js → History-BDyJUGtR.js} +2 -2
- package/dist/assets/{Hooks-DG4c-Rzz.js → Hooks-CmK6l0uf.js} +3 -3
- package/dist/assets/HostBilko-D8TIx-Bx.js +1 -0
- package/dist/assets/{Library-CN1h5rAE.js → Library-DTxpOjx8.js} +25 -25
- package/dist/assets/{MarkdownEditor-DmBxrhdn.js → MarkdownEditor-BPJyrevq.js} +1 -1
- package/dist/assets/McpServers-B_rvIYDs.js +2 -0
- package/dist/assets/{Memory-Dk4uDCjg.js → Memory-B-9GlHqe.js} +6 -6
- package/dist/assets/{Permissions-BovobeZw.js → Permissions-DhMMp27n.js} +3 -3
- package/dist/assets/{Plugins-DvOf_B62.js → Plugins-DKfAlmMY.js} +2 -2
- package/dist/assets/{ProvenanceBadge-Dutz_4OY.js → ProvenanceBadge-ptlItdCK.js} +1 -1
- package/dist/assets/{SaveBar-CT5u6ZCJ.js → SaveBar-CSbNSl2W.js} +1 -1
- package/dist/assets/Scheduler--rn1QBYc.js +16 -0
- package/dist/assets/{ScopeSwitcher-BUYDpz2v.js → ScopeSwitcher-BJof-GWH.js} +1 -1
- package/dist/assets/{Settings-DIRmr3tL.js → Settings-Dsx6XZP5.js} +3 -3
- package/dist/assets/{SkillReferenceGraph-CdGr6d3_.js → SkillReferenceGraph-CGl4B89-.js} +2 -2
- package/dist/assets/{Skills-BQOheQv4.js → Skills-D522Zfol.js} +2 -2
- package/dist/assets/{SystemPrompt-5LsimKbX.js → SystemPrompt-DKj-q39J.js} +1 -1
- package/dist/assets/{TagLibrary-DreLsT3n.js → TagLibrary-BH7CG7-6.js} +1 -1
- package/dist/assets/{TiptapBody-C9kmlBnI.js → TiptapBody-BbcJL9az.js} +1 -1
- package/dist/assets/{Toggle-BKLCBu9A.js → Toggle-DAslvnnv.js} +1 -1
- package/dist/assets/{index-Db_SK9rW.js → index-Yf8e7VCB.js} +821 -821
- package/dist/assets/{settingsSchema-B5jBhSUP.js → settingsSchema-pzp0TgvN.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +8 -3
- package/scripts/README.md +6 -0
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +5 -2
- package/src/main/__tests__/chat-exit-close-race.test.cjs +8 -2
- package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +5 -2
- package/src/main/__tests__/health-tick-liveness.test.cjs +12 -0
- package/src/main/__tests__/intradayRefresh.test.cjs +39 -0
- package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +25 -0
- package/src/main/__tests__/opsErrorLog.test.cjs +22 -0
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +21 -0
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +14 -0
- package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +26 -0
- package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +11 -1
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +11 -1
- package/src/main/__tests__/runVerify.test.cjs +12 -1
- package/src/main/__tests__/scheduler-rate-limit-pause.test.cjs +15 -0
- package/src/main/__tests__/scheduler-utilization-hold.test.cjs +89 -0
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +112 -1
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +45 -10
- package/src/main/crashDiagnostics.cjs +9 -0
- package/src/main/git.cjs +9 -2
- package/src/main/health.cjs +25 -1
- package/src/main/historyAggregator.cjs +2 -19
- package/src/main/index.cjs +7 -8
- package/src/main/ipcSchemas.cjs +13 -3
- package/src/main/lib/__tests__/childWithLog.test.cjs +180 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +19 -0
- package/src/main/lib/__tests__/gitCacheBound.test.cjs +69 -0
- package/src/main/lib/__tests__/loopDelay.test.cjs +68 -0
- package/src/main/lib/__tests__/usageCircuit.test.cjs +86 -17
- package/src/main/lib/agentPersonaSchema.cjs +2 -0
- package/src/main/lib/childWithLog.cjs +92 -51
- package/src/main/lib/delegationReadiness.cjs +3 -1
- package/src/main/lib/epicSpawnPlan.cjs +27 -8
- package/src/main/lib/epicTranscriptPath.cjs +5 -1
- package/src/main/lib/headTailBuffer.cjs +43 -0
- package/src/main/lib/intradayRefresh.cjs +33 -0
- package/src/main/lib/loopDelay.cjs +44 -0
- package/src/main/lib/lruCache.cjs +39 -0
- package/src/main/lib/openExternalApp.cjs +27 -9
- package/src/main/lib/opsErrorLog.cjs +22 -0
- package/src/main/lib/promptSessionSchema.cjs +2 -0
- package/src/main/lib/rendererRecovery.cjs +141 -0
- package/src/main/lib/scheduleJobSchema.cjs +3 -0
- package/src/main/lib/usageCircuit.cjs +40 -12
- package/src/main/runVerify.cjs +3 -1
- package/src/main/scheduler.cjs +409 -260
- package/src/main/transcripts.cjs +54 -11
- package/src/main/usage.cjs +9 -1
- package/src/preload/api.d.ts +20 -3
- package/dist/assets/HostBilko-DHXcpV2c.js +0 -1
- package/dist/assets/McpServers-Beb_HD6I.js +0 -2
- 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
|
+
});
|
package/src/main/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitShortSha": "
|
|
2
|
+
"version": "0.95.0",
|
|
3
|
+
"gitSha": "42f7779080772b1b8432b6287aff06c52b9f0f90",
|
|
4
|
+
"gitShortSha": "42f77790",
|
|
5
5
|
"gitBranch": "HEAD",
|
|
6
|
-
"builtAt": "2026-09-
|
|
6
|
+
"builtAt": "2026-09-21T22:01:57.972Z",
|
|
7
7
|
"dirty": false
|
|
8
8
|
}
|
package/src/main/chatRunner.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
862
|
+
&& !/rate.?limit/i.test(stderrText())) {
|
|
828
863
|
const swapTo = resumeFlag
|
|
829
|
-
? (NO_CONVERSATION_RE.test(
|
|
830
|
-
: (SESSION_IN_USE_RE.test(
|
|
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(
|
|
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
|
|
879
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
};
|
package/src/main/health.cjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
53
|
-
|
|
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.
|
package/src/main/index.cjs
CHANGED
|
@@ -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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
package/src/main/ipcSchemas.cjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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] =
|
|
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);
|