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
@@ -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
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * gitCacheBound.test.cjs — git status caches are LRU-bounded and drop expired entries.
3
+ *
4
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/gitCacheBound.test.cjs
5
+ */
6
+ 'use strict';
7
+
8
+ import { test, expect, beforeEach, afterEach, vi } from 'vitest';
9
+ const fs = require('node:fs');
10
+ const os = require('node:os');
11
+ const path = require('node:path');
12
+ const { addAllowedRoot } = require('../../config.cjs');
13
+ const git = require('../../git.cjs');
14
+
15
+ const { statusCache, fileStatusCache, CACHE_MAX_CWDS } = git._caches;
16
+ let root;
17
+ beforeEach(() => {
18
+ git.clearCache();
19
+ root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'git-cache-bound-')));
20
+ addAllowedRoot(root);
21
+ });
22
+ afterEach(() => {
23
+ vi.restoreAllMocks();
24
+ fs.rmSync(root, { recursive: true, force: true });
25
+ });
26
+
27
+ function mkdirs(n) {
28
+ return Array.from({ length: n }, (_, i) => {
29
+ const d = path.join(root, `d${i}`);
30
+ fs.mkdirSync(d);
31
+ return fs.realpathSync(d);
32
+ });
33
+ }
34
+
35
+ test('50 distinct cwds never exceed the cap', async () => {
36
+ for (const d of mkdirs(50)) {
37
+ await git.getStatus(d);
38
+ await git.getFileStatus(d);
39
+ }
40
+ expect(statusCache.size).toBeLessThanOrEqual(CACHE_MAX_CWDS);
41
+ expect(fileStatusCache.size).toBeLessThanOrEqual(CACHE_MAX_CWDS);
42
+ expect(statusCache.size).toBe(CACHE_MAX_CWDS);
43
+ });
44
+
45
+ test('an expired entry is deleted when encountered', async () => {
46
+ const [d] = mkdirs(1);
47
+ const t0 = 1_000_000;
48
+ const now = vi.spyOn(Date, 'now').mockReturnValue(t0);
49
+ await git.getStatus(d);
50
+ await git.getFileStatus(d);
51
+ expect(statusCache.get(d).expiresAt).toBe(t0 + 5_000);
52
+
53
+ // Live entry: served from cache, untouched.
54
+ now.mockReturnValue(t0 + 4_999);
55
+ await git.getStatus(d);
56
+ expect(statusCache.get(d).expiresAt).toBe(t0 + 5_000);
57
+
58
+ // Expired: the stale entry is deleted and replaced by a fresh one.
59
+ now.mockReturnValue(t0 + 60_000);
60
+ const del = vi.spyOn(statusCache, 'delete');
61
+ await git.getStatus(d);
62
+ expect(del).toHaveBeenCalledWith(d);
63
+ expect(statusCache.get(d).expiresAt).toBe(t0 + 65_000);
64
+
65
+ const delFile = vi.spyOn(fileStatusCache, 'delete');
66
+ await git.getFileStatus(d);
67
+ expect(delFile).toHaveBeenCalledWith(d);
68
+ expect(fileStatusCache.get(d).expiresAt).toBe(t0 + 65_000);
69
+ });
@@ -0,0 +1,68 @@
1
+ /**
2
+ * loopDelay.test.cjs — event-loop delay probe used by crashDiagnostics's heartbeat.
3
+ *
4
+ * Run: timeout 120 npx vitest run src/main/lib/__tests__/loopDelay.test.cjs
5
+ */
6
+ 'use strict';
7
+
8
+ import { test, expect } from 'vitest';
9
+
10
+ const { createLoopDelayMonitor, stallVerdict, LOOP_STALL_WARN_MS } = require('../loopDelay.cjs');
11
+
12
+ // Histogram stub: values in ns, like perf_hooks.
13
+ function stubHistogram(state) {
14
+ return () => ({
15
+ enabled: false,
16
+ enable() { this.enabled = true; },
17
+ disable() { this.enabled = false; },
18
+ percentile(p) { return p === 50 ? state.p50 : state.p99; },
19
+ get max() { return state.max; },
20
+ reset() { state.resets += 1; state.p50 = 0; state.p99 = 0; state.max = 0; },
21
+ });
22
+ }
23
+
24
+ test('normal beat: converts ns to ms (1 decimal) and does not flag a stall', () => {
25
+ const state = { p50: 21_040_000, p99: 34_960_000, max: 40_000_000, resets: 0 };
26
+ const m = createLoopDelayMonitor(stubHistogram(state));
27
+ m.enable();
28
+ const snap = m.snapshot();
29
+ expect(snap).toEqual({ loopDelayP50Ms: 21, loopDelayP99Ms: 35, loopDelayMaxMs: 40 });
30
+ expect(stallVerdict(snap)).toBeNull();
31
+ });
32
+
33
+ test('stall beat: max above threshold yields warn "main loop stall"', () => {
34
+ const state = { p50: 20_000_000, p99: 300_000_000, max: 412_300_000, resets: 0 };
35
+ const snap = createLoopDelayMonitor(stubHistogram(state)).snapshot();
36
+ expect(snap.loopDelayMaxMs).toBe(412.3);
37
+ expect(stallVerdict(snap)).toEqual({ level: 'warn', message: 'main loop stall' });
38
+ expect(stallVerdict({ loopDelayMaxMs: LOOP_STALL_WARN_MS })).toBeNull(); // strictly greater
39
+ });
40
+
41
+ test('reset between beats: a stall does not bleed into the next interval', () => {
42
+ const state = { p50: 20_000_000, p99: 300_000_000, max: 500_000_000, resets: 0 };
43
+ const m = createLoopDelayMonitor(stubHistogram(state));
44
+ expect(stallVerdict(m.snapshot())).not.toBeNull();
45
+ expect(state.resets).toBe(1);
46
+ const second = m.snapshot();
47
+ expect(second.loopDelayMaxMs).toBe(0);
48
+ expect(stallVerdict(second)).toBeNull();
49
+ expect(state.resets).toBe(2);
50
+ });
51
+
52
+ test('passes a 20 ms resolution to the histogram factory and disable() stops it', () => {
53
+ let opts; let h;
54
+ const m = createLoopDelayMonitor((o) => { opts = o; h = stubHistogram({ resets: 0 })(); return h; });
55
+ m.enable();
56
+ expect(opts).toEqual({ resolution: 20 });
57
+ expect(h.enabled).toBe(true);
58
+ m.disable();
59
+ expect(h.enabled).toBe(false);
60
+ });
61
+
62
+ test('real perf_hooks histogram produces finite numbers', () => {
63
+ const m = createLoopDelayMonitor();
64
+ m.enable();
65
+ const snap = m.snapshot();
66
+ m.disable();
67
+ for (const v of Object.values(snap)) expect(Number.isFinite(v)).toBe(true);
68
+ });
@@ -101,18 +101,74 @@ test('isResetFresh: past -> false, future -> true, null -> false', () => {
101
101
  assert.strictEqual(isResetFresh('not-a-date', now), false);
102
102
  });
103
103
 
104
- test('bindingWindow: limits[] with weekly_all is_active wins over five_hour', () => {
104
+ const REAL_LIMITS = [
105
+ { kind: 'session', group: 'session', percent: 6, severity: 'normal', resets_at: '2026-09-22T00:40:00.280514+00:00', scope: null, is_active: false },
106
+ { kind: 'weekly_all', group: 'weekly', percent: 64, severity: 'normal', resets_at: '2026-09-24T17:00:00.280532+00:00', scope: null, is_active: true },
107
+ { kind: 'weekly_scoped', group: 'weekly', percent: 18, severity: 'normal', resets_at: '2026-09-24T17:00:00.280763+00:00', scope: { model: { id: null, display_name: 'Fable' }, surface: null }, is_active: false },
108
+ ];
109
+
110
+ test('bindingWindow: verbatim real payload selects weekly_all 64', () => {
105
111
  const payload = {
106
- five_hour: { utilization: 5, resets_at: '2026-09-13T15:00:00Z' },
112
+ five_hour: { utilization: 6, resets_at: '2026-09-22T00:40:00.280514+00:00' },
113
+ seven_day: { utilization: 64, resets_at: '2026-09-24T17:00:00.280532+00:00' },
114
+ limits: REAL_LIMITS,
115
+ };
116
+ assert.deepStrictEqual(bindingWindow(payload), {
117
+ name: 'weekly_all',
118
+ utilization: 64,
119
+ resets_at: '2026-09-24T17:00:00.280532+00:00',
120
+ });
121
+ });
122
+
123
+ test('bindingWindow: max percent wins over is_active (session 95 beats weekly_all 64)', () => {
124
+ const win = bindingWindow({
107
125
  limits: [
108
- { type: 'five_hour', is_active: false, utilization: 5, resets_at: '2026-09-13T15:00:00Z' },
109
- { type: 'weekly_all', is_active: true, utilization: 81, resets_at: '2026-09-19T00:00:00Z' },
126
+ { kind: 'session', percent: 95, resets_at: '2026-09-22T00:40:00Z', scope: null, is_active: false },
127
+ { kind: 'weekly_all', percent: 64, resets_at: '2026-09-24T17:00:00Z', scope: null, is_active: true },
110
128
  ],
111
- };
112
- const win = bindingWindow(payload);
129
+ });
130
+ assert.strictEqual(win.name, 'session');
131
+ assert.strictEqual(win.utilization, 95);
132
+ });
133
+
134
+ test('bindingWindow: is_active breaks a percent tie', () => {
135
+ const win = bindingWindow({
136
+ limits: [
137
+ { kind: 'session', percent: 50, resets_at: null, scope: null, is_active: false },
138
+ { kind: 'weekly_all', percent: 50, resets_at: null, scope: null, is_active: true },
139
+ ],
140
+ });
113
141
  assert.strictEqual(win.name, 'weekly_all');
114
- assert.strictEqual(win.utilization, 81);
115
- assert.strictEqual(win.resets_at, '2026-09-19T00:00:00Z');
142
+ });
143
+
144
+ test('bindingWindow: a high weekly_scoped entry is never selected', () => {
145
+ const win = bindingWindow({
146
+ limits: [
147
+ { kind: 'session', percent: 6, resets_at: null, scope: null, is_active: false },
148
+ { kind: 'weekly_all', percent: 10, resets_at: null, scope: null, is_active: true },
149
+ { kind: 'weekly_scoped', percent: 99, resets_at: null, scope: { model: { display_name: 'Fable' } }, is_active: false },
150
+ ],
151
+ });
152
+ assert.strictEqual(win.name, 'weekly_all');
153
+ assert.strictEqual(win.utilization, 10);
154
+ });
155
+
156
+ test('bindingWindow: percent over 100 is returned raw, not clamped', () => {
157
+ const win = bindingWindow({ limits: [{ kind: 'session', percent: 112, resets_at: null, scope: null }] });
158
+ assert.strictEqual(win.utilization, 112);
159
+ });
160
+
161
+ test('bindingWindow: null resets_at backfills from the matching flat window', () => {
162
+ const win = bindingWindow({
163
+ five_hour: { utilization: 40, resets_at: '2026-09-22T00:40:00Z' },
164
+ seven_day: { utilization: 5, resets_at: '2026-09-24T17:00:00Z' },
165
+ limits: [
166
+ { kind: 'session', percent: 40, resets_at: null, scope: null },
167
+ { kind: 'weekly_all', percent: 5, resets_at: null, scope: null },
168
+ ],
169
+ });
170
+ assert.strictEqual(win.name, 'session');
171
+ assert.strictEqual(win.resets_at, '2026-09-22T00:40:00Z');
116
172
  });
117
173
 
118
174
  test('bindingWindow: limits[] absent falls back to five_hour', () => {
@@ -122,12 +178,12 @@ test('bindingWindow: limits[] absent falls back to five_hour', () => {
122
178
  assert.strictEqual(win.utilization, 42);
123
179
  });
124
180
 
125
- test('bindingWindow: limits[] present but nothing is_active also falls back to five_hour', () => {
181
+ test('bindingWindow: limits[] with only scoped / non-finite entries falls back to five_hour', () => {
126
182
  const payload = {
127
183
  five_hour: { utilization: 12, resets_at: null },
128
184
  limits: [
129
- { type: 'five_hour', is_active: false, utilization: 12, resets_at: null },
130
- { type: 'weekly_all', is_active: false, utilization: 81, resets_at: null },
185
+ { kind: 'weekly_scoped', percent: 81, resets_at: null, scope: { model: { display_name: 'Fable' } } },
186
+ { kind: 'session', resets_at: null, scope: null },
131
187
  ],
132
188
  };
133
189
  const win = bindingWindow(payload);
@@ -135,19 +191,32 @@ test('bindingWindow: limits[] present but nothing is_active also falls back to f
135
191
  assert.strictEqual(win.utilization, 12);
136
192
  });
137
193
 
138
- test('degradedBudget: never yields 0 utilization, even when the last good binding window read 0', () => {
194
+ test('degradedBudget: a last-good binding window that genuinely read 0% carries forward as 0, not 100', () => {
139
195
  const payload = { five_hour: { utilization: 0, resets_at: null } };
140
- const { utilization } = degradedBudget(payload, {});
141
- assert.notStrictEqual(utilization, 0);
142
- assert.strictEqual(utilization, 100); // no real signal -> conservative default
196
+ assert.strictEqual(degradedBudget(payload, {}).utilization, 0);
197
+ });
198
+
199
+ test('degradedBudget: no payload ever received (null/absent) still yields 100, never a blind 0', () => {
200
+ assert.strictEqual(degradedBudget(null, {}).utilization, 100);
201
+ assert.strictEqual(degradedBudget(undefined, {}).utilization, 100);
202
+ });
203
+
204
+ test('degradedBudget: a degraded cycle after a real-shape poll carries forward the real percent (64), not 100', () => {
205
+ const payload = {
206
+ limits: [
207
+ { kind: 'session', percent: 6, resets_at: null, scope: null, is_active: false },
208
+ { kind: 'weekly_all', percent: 64, resets_at: '2026-09-24T17:00:00Z', scope: null, is_active: true },
209
+ ],
210
+ };
211
+ assert.strictEqual(degradedBudget(payload, {}).utilization, 64);
143
212
  });
144
213
 
145
214
  test('degradedBudget: carries forward the real last-good BINDING (weekly_all) utilization, e.g. 81%, never 0', () => {
146
215
  const payload = {
147
216
  five_hour: { utilization: 0, resets_at: null },
148
217
  limits: [
149
- { type: 'five_hour', is_active: false, utilization: 0, resets_at: null },
150
- { type: 'weekly_all', is_active: true, utilization: 81, resets_at: '2026-09-19T00:00:00Z' },
218
+ { kind: 'session', is_active: false, percent: 0, resets_at: null, scope: null },
219
+ { kind: 'weekly_all', is_active: true, percent: 81, resets_at: '2026-09-19T00:00:00Z', scope: null },
151
220
  ],
152
221
  };
153
222
  const { utilization } = degradedBudget(payload, {});
@@ -1,3 +1,4 @@
1
+ // @ts-check
1
2
  /**
2
3
  * Canonical runtime schema for an Agent persona WRITE payload, mirroring the
3
4
  * TS `AgentPersonaSaveInput` interface in src/preload/api.d.ts
@@ -67,6 +68,7 @@ const AgentPersonaSaveSchema = z.object({
67
68
  * Throws a clear, descriptive error (not a raw ZodError dump) when `persona`
68
69
  * doesn't match AgentPersonaSaveSchema. Mirrors assertValidPromptSession /
69
70
  * assertValidScheduleJob's shape and message format exactly.
71
+ * @param {unknown} persona
70
72
  */
71
73
  function assertValidAgentPersonaSave(persona) {
72
74
  const result = AgentPersonaSaveSchema.safeParse(persona);