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
@@ -259,3 +259,24 @@ test('spawn() still refuses session_unreachable without any node-pty spawn', ()
259
259
  expect(result.pid).toBeNull();
260
260
  expect(result.error).toMatch(/cannot be resumed/);
261
261
  });
262
+
263
+ test('planEpicSpawn with a REAL on-disk index: project-root caller resumes, vanished-worktree caller is refused spawn_cwd_missing', () => {
264
+ const { planEpicSpawn, __resetForTests } = require('../lib/epicSpawnPlan.cjs');
265
+ __resetForTests();
266
+ const projectRoot = fs.mkdtempSync(path.join(tmpHome, 'sm-plan-root-'));
267
+ const deadWt = path.join(os.tmpdir(), 'sm-plan-dead-worktree-xyz');
268
+ const sessionId = 'epic-session-root-probe';
269
+ writeActiveIndexWithWorktree(projectRoot, { epicId: 'epic-rp', claudeSessionId: sessionId, worktreeDir: deadWt });
270
+ const tdir = path.join(tmpHome, '.claude', 'projects', projectRoot.replace(/[^a-zA-Z0-9]/g, '-'));
271
+ fs.mkdirSync(tdir, { recursive: true });
272
+ fs.writeFileSync(path.join(tdir, `${sessionId}.jsonl`), '{"type":"user"}\n');
273
+ const deps = { homeDir: tmpHome, restoreWorktree: () => null };
274
+
275
+ expect(planEpicSpawn({ cwd: projectRoot, claudeSessionId: sessionId, deps }))
276
+ .toMatchObject({ ok: true, execCwd: projectRoot, useResume: true });
277
+ __resetForTests();
278
+ const refused = planEpicSpawn({ cwd: deadWt, claudeSessionId: sessionId, deps });
279
+ expect(refused).toMatchObject({ ok: false, code: 'spawn_cwd_missing' });
280
+ expect(refused.message).toContain(deadWt);
281
+ expect(refused.message).toMatch(/project cwd/);
282
+ });
@@ -20,6 +20,8 @@ const {
20
20
  nextBackoffMs,
21
21
  shouldWarnFailureStreak,
22
22
  shouldEscalateFailureStreak,
23
+ restoreFailureStreakWarnedAt,
24
+ persistedStreakMinutes,
23
25
  BACKOFF_MAX_MS,
24
26
  FAILURE_STREAK_WARN_THRESHOLD,
25
27
  FAILURE_STREAK_ESCALATION_MS,
@@ -112,3 +114,15 @@ test('shouldEscalateFailureStreak: re-arms after a streak clears (lastEscalatedA
112
114
  const laterStreakNow = firstStreakNow + 5 * 60_000; // well under 30 min later
113
115
  expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, null, laterStreakNow)).toBe(true);
114
116
  });
117
+
118
+ test('restoreFailureStreakWarnedAt backfills now when warned=true has no numeric timestamp', () => {
119
+ expect(restoreFailureStreakWarnedAt(true, null, 5000)).toBe(5000);
120
+ expect(restoreFailureStreakWarnedAt(true, 1000, 5000)).toBe(1000);
121
+ expect(restoreFailureStreakWarnedAt(false, null, 5000)).toBeNull();
122
+ });
123
+
124
+ test('persistedStreakMinutes is always a number, never null', () => {
125
+ expect(persistedStreakMinutes(null, 5000)).toBe(0);
126
+ expect(persistedStreakMinutes(0, 120_000)).toBe(2);
127
+ expect(persistedStreakMinutes(restoreFailureStreakWarnedAt(true, undefined, 5000), 5000)).toBe(0);
128
+ });
@@ -0,0 +1,26 @@
1
+ import { test, expect, vi } from 'vitest';
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const src = fs.readFileSync(path.join(__dirname, '..', 'runVerify.cjs'), 'utf8');
5
+
6
+ test('verdicts sidecar write goes through config.writeJsonSync, not bare writeFileSync', () => {
7
+ expect(src).not.toMatch(/writeFileSync\(verdictsPath/);
8
+ expect(src).toMatch(/config\.cjs'\)\.writeJsonSync\(verdictsPath/);
9
+ });
10
+
11
+ test('conclude() invokes the atomic helper at runtime', async () => {
12
+ const config = require('../config.cjs');
13
+ const spy = vi.spyOn(config, 'writeJsonSync').mockReturnValue({ ok: true, mtimeMs: 0 });
14
+ const { verifyRun } = require('../runVerify.cjs');
15
+ const os = require('node:os');
16
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rv-atomic-'));
17
+ try {
18
+ try {
19
+ await verifyRun({ queueEntry: { slug: 's' }, runDir: dir, prdPath: path.join(dir, 'none.md'), exitCode: 0 });
20
+ } catch { /* signature drift is fine; assert only on the spy */ }
21
+ expect(spy.mock.calls.some(([p]) => p === path.join(dir, 's.verdicts.json'))).toBe(true);
22
+ } finally {
23
+ spy.mockRestore();
24
+ fs.rmSync(dir, { recursive: true, force: true });
25
+ }
26
+ });
@@ -32,7 +32,7 @@
32
32
  * Run standalone: timeout 120 npx vitest run src/main/__tests__/runVerify-landed-commit-outranks.test.cjs
33
33
  */
34
34
 
35
- import { test } from 'vitest';
35
+ import { test, beforeAll, afterAll, vi } from 'vitest';
36
36
  const assert = require('node:assert/strict');
37
37
  const os = require('node:os');
38
38
  const fs = require('node:fs');
@@ -40,6 +40,16 @@ const path = require('node:path');
40
40
  const { execFileSync } = require('node:child_process');
41
41
  const { verifyRun } = require('../runVerify.cjs');
42
42
 
43
+ // Sidecar goes through config.writeJsonSync, whose allow-list rejects os.tmpdir() fixtures.
44
+ let writeSpy;
45
+ beforeAll(() => {
46
+ writeSpy = vi.spyOn(require('../config.cjs'), 'writeJsonSync').mockImplementation((abs, data) => {
47
+ require('node:fs').writeFileSync(abs, JSON.stringify(data, null, 2) + '\n');
48
+ return { ok: true, mtimeMs: 0 };
49
+ });
50
+ });
51
+ afterAll(() => writeSpy.mockRestore());
52
+
43
53
  const SLUG = '1218-fo-01-move-scripts-lib-into-src-main-lib';
44
54
  const FIXTURE_PATH = path.join(__dirname, 'fixtures', `${SLUG}.log`);
45
55
  const LANDED_COMMIT = 'd1edf15be50a783f08a55e6de328780f0feb3cb0';
@@ -14,7 +14,7 @@
14
14
  * Run standalone: timeout 120 npx vitest run src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs
15
15
  */
16
16
 
17
- import { test } from 'vitest';
17
+ import { test, beforeAll, afterAll, vi } from 'vitest';
18
18
  const assert = require('node:assert/strict');
19
19
  const os = require('node:os');
20
20
  const fs = require('node:fs');
@@ -22,6 +22,16 @@ const path = require('node:path');
22
22
  const { execFileSync } = require('node:child_process');
23
23
  const { verifyRun } = require('../runVerify.cjs');
24
24
 
25
+ // Sidecar goes through config.writeJsonSync, whose allow-list rejects os.tmpdir() fixtures.
26
+ let writeSpy;
27
+ beforeAll(() => {
28
+ writeSpy = vi.spyOn(require('../config.cjs'), 'writeJsonSync').mockImplementation((abs, data) => {
29
+ require('node:fs').writeFileSync(abs, JSON.stringify(data, null, 2) + '\n');
30
+ return { ok: true, mtimeMs: 0 };
31
+ });
32
+ });
33
+ afterAll(() => writeSpy.mockRestore());
34
+
25
35
  // ─── helpers (mirrors runVerify.test.cjs) ─────────────────────────────────────
26
36
 
27
37
  function makeTmpDir() {
@@ -19,7 +19,7 @@
19
19
  * Expected: clean → null (caller may stamp 'completed')
20
20
  */
21
21
 
22
- import { test } from 'vitest';
22
+ import { test, beforeAll, afterAll, vi } from 'vitest';
23
23
  const assert = require('node:assert/strict');
24
24
  const os = require('node:os');
25
25
  const fs = require('node:fs');
@@ -27,6 +27,17 @@ const path = require('node:path');
27
27
  const { execFileSync } = require('node:child_process');
28
28
  const { verifyRun, parseLog } = require('../runVerify.cjs');
29
29
 
30
+ // The verdicts sidecar goes through config.writeJsonSync, whose write allow-list
31
+ // rejects os.tmpdir() fixtures. Route it to a plain write for these fixtures.
32
+ let writeSpy;
33
+ beforeAll(() => {
34
+ writeSpy = vi.spyOn(require('../config.cjs'), 'writeJsonSync').mockImplementation((abs, data) => {
35
+ fs.writeFileSync(abs, JSON.stringify(data, null, 2) + '\n');
36
+ return { ok: true, mtimeMs: 0 };
37
+ });
38
+ });
39
+ afterAll(() => writeSpy.mockRestore());
40
+
30
41
  // ─── helpers ──────────────────────────────────────────────────────────────────
31
42
 
32
43
  /** Create a temp directory that is cleaned up after the test. */
@@ -82,8 +82,10 @@ function writePersona(dir, name, frontmatterLines, bodyText) {
82
82
  fs.writeFileSync(path.join(dir, `${name}.md`), lines.join('\n'));
83
83
  }
84
84
 
85
- async function runJobWithAgentType({ agentType }) {
86
- const mainCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-persona-main-'));
85
+ async function runJobWithAgentType({ agentType, overlay }) {
86
+ // Under tmpHome so config.validatePath's home-dir boundary admits the project overlay.
87
+ const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-prd-persona-main-'));
88
+ if (overlay) writePersona(path.join(mainCwd, '.claude', 'agents'), overlay.name, overlay.frontmatter, overlay.body || '');
87
89
  fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
88
90
  const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-persona-run-'));
89
91
  const slug = `1115-test-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
@@ -131,3 +133,46 @@ test('executeJob: a job with no agentType at all runs unaffected (no persona, so
131
133
  const modelIdx = argv.indexOf('--model');
132
134
  expect(argv[modelIdx + 1]).toBe('sonnet');
133
135
  });
136
+
137
+ // ---------- persona effort → --effort ----------
138
+
139
+ test('buildClaudeSpawnArgs: effort appends --effort <level>; null/inherit/absent add no token', () => {
140
+ const base = { prompt: 'x', model: 'sonnet', sessionId: 'sid', resume: false };
141
+ const args = buildClaudeSpawnArgs({ ...base, effort: 'high' });
142
+ expect(args[args.indexOf('--effort') + 1]).toBe('high');
143
+ expect(args.indexOf('--model')).toBeGreaterThanOrEqual(0);
144
+ for (const effort of [null, 'inherit', undefined]) {
145
+ expect(buildClaudeSpawnArgs({ ...base, effort })).not.toContain('--effort');
146
+ }
147
+ });
148
+
149
+ test('executeJob: persona effort reaches the child as --effort; inherit yields no --effort token', async () => {
150
+ const globalAgentsDir = path.join(tmpHome, '.claude', 'agents');
151
+ writePersona(globalAgentsDir, 'effort-high', ['model: opus', 'effort: high'], 'body');
152
+ writePersona(globalAgentsDir, 'effort-inherit', ['model: opus', 'effort: inherit'], 'body');
153
+
154
+ const high = await runJobWithAgentType({ agentType: 'effort-high' });
155
+ expect(high.argv[high.argv.indexOf('--effort') + 1]).toBe('high');
156
+ expect(high.argv[high.argv.indexOf('--model') + 1]).toBe('opus');
157
+
158
+ const inherit = await runJobWithAgentType({ agentType: 'effort-inherit' });
159
+ expect(inherit.argv).not.toContain('--effort');
160
+ expect(inherit.argv).not.toContain('inherit');
161
+ });
162
+
163
+ // ---------- project overlay MERGE: frontmatter-only overlay patches the runtime fields ----------
164
+
165
+ test('executeJob: a frontmatter-only project overlay changes --model/--effort while the global persona body still reaches --append-system-prompt', async () => {
166
+ const globalAgentsDir = path.join(tmpHome, '.claude', 'agents');
167
+ writePersona(globalAgentsDir, 'dev-lead', ['model: sonnet'], 'You are dev-lead. Global body.');
168
+
169
+ const { result, argv } = await runJobWithAgentType({
170
+ agentType: 'dev-lead',
171
+ overlay: { name: 'dev-lead', frontmatter: ['model: claude-opus-4-6', 'effort: high'], body: '' },
172
+ });
173
+
174
+ expect(result.exitCode).toBe(0);
175
+ expect(argv[argv.indexOf('--model') + 1]).toBe('claude-opus-4-6');
176
+ expect(argv[argv.indexOf('--effort') + 1]).toBe('high');
177
+ expect(argv[argv.indexOf('--append-system-prompt') + 1]).toBe('You are dev-lead. Global body.');
178
+ });
@@ -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
+ });
@@ -22,7 +22,7 @@ const fsSync = require('node:fs');
22
22
  const path = require('node:path');
23
23
  const os = require('node:os');
24
24
  const { splitFrontmatter } = require('./lib/prdFrontmatter.cjs');
25
- const { resolvePersonaPaths } = require('./lib/epicMint.cjs');
25
+ const { resolveMergedPersona, PERSONA_NAME_RE } = require('./lib/personaMerge.cjs');
26
26
  const configMgr = require('./config.cjs');
27
27
  const sessionsStore = require('./sessionsStore.cjs');
28
28
 
@@ -32,9 +32,6 @@ function parseTools(raw) {
32
32
  return raw.split(',').map((t) => t.trim()).filter(Boolean);
33
33
  }
34
34
 
35
- /** Filename-safe persona name: lowercase, hyphenated, matches the `.md` files on disk. */
36
- const PERSONA_NAME_RE = /^[a-z][a-z0-9-]*$/;
37
-
38
35
  /** Sentinel `projects:` entry meaning "this agent's Action button shows in every project". */
39
36
  const ALL_PROJECTS = '*';
40
37
 
@@ -70,11 +67,12 @@ function decodeLine(s) {
70
67
  * overrides the button's caption (defaults to the persona name). Claude Code
71
68
  * ignores all three; only this app reads them.
72
69
  */
73
- function serializePersona({ name, description, tools, model, color, tags, projects, action, actionLabel, title, body }) {
70
+ function serializePersona({ name, description, tools, model, effort, color, tags, projects, action, actionLabel, title, body }) {
74
71
  const lines = ['---', `name: ${name}`];
75
72
  if (description) lines.push(`description: ${description}`);
76
73
  if (tools && tools.length) lines.push(`tools: ${tools.join(', ')}`);
77
74
  if (model && model !== 'inherit') lines.push(`model: ${model}`);
75
+ if (effort && effort !== 'inherit') lines.push(`effort: ${effort}`);
78
76
  if (color) lines.push(`color: ${color}`);
79
77
  if (tags && tags.length) lines.push(`tags: ${tags.join(', ')}`);
80
78
  if (projects && projects.length) lines.push(`projects: ${projects.join(', ')}`);
@@ -85,10 +83,51 @@ function serializePersona({ name, description, tools, model, color, tags, projec
85
83
  return lines.join('\n') + (body || '').trim() + '\n';
86
84
  }
87
85
 
86
+ /**
87
+ * Project OVERRIDE write: a frontmatter-only overlay at
88
+ * `<cwd>/.claude/agents/<name>.md` carrying ONLY the overridden `model` /
89
+ * `effort` keys and no body (lib/personaMerge.cjs inherits everything else
90
+ * from the global persona). Keys already in an existing frontmatter-only
91
+ * overlay are preserved; an existing overlay that has a BODY is refused so a
92
+ * full-body overlay is never silently converted. Both keys unset removes the
93
+ * file (same as `removeOverride`) rather than leaving an empty overlay.
94
+ */
95
+ async function saveOverlay({ name, projectName, model, effort, loadSessions, validatePath, writeTextAtomic }) {
96
+ const projects = await openProjects({ loadSessions });
97
+ const project = projects.find((p) => p.name === projectName);
98
+ if (!project) throw new Error(`project not open: ${projectName}`);
99
+ const target = validatePath(path.join(project.cwd, '.claude', 'agents', `${name}.md`));
100
+ let fm = {};
101
+ try {
102
+ const existing = splitFrontmatter(await fsp.readFile(target, 'utf8'));
103
+ if (existing.body.trim() !== '') {
104
+ throw new Error(`${name} has a full-body override in ${projectName} — edit ${target} directly; it is not converted to a frontmatter-only override`);
105
+ }
106
+ fm = existing.fm;
107
+ } catch (e) {
108
+ if (e.code !== 'ENOENT') throw e;
109
+ }
110
+ const next = { ...fm };
111
+ for (const [k, v] of [['model', model], ['effort', effort]]) {
112
+ if (v && v !== 'inherit') next[k] = v;
113
+ else delete next[k];
114
+ }
115
+ if (Object.keys(next).length === 0) {
116
+ await removeOverride({ name, projectName, loadSessions, validatePath });
117
+ return { ok: true, path: target, removed: true };
118
+ }
119
+ const lines = ['---', ...Object.entries(next).map(([k, v]) => `${k}: ${v}`), '---', ''];
120
+ await writeTextAtomic(target, lines.join('\n'));
121
+ return { ok: true, path: target };
122
+ }
123
+
88
124
  /**
89
125
  * Writes a global persona `.md` file — `~/.claude/agents/<name>.md`. Pass
90
126
  * `originalName` when renaming an existing persona so the old file is
91
- * removed once the new one is written (no-op if names match).
127
+ * removed once the new one is written (no-op if names match). Pass
128
+ * `projectName` (an open project) to instead write that project's
129
+ * frontmatter-only OVERRIDE of the persona — see saveOverlay; only `model` and
130
+ * `effort` are read in that mode.
92
131
  */
93
132
  async function savePersona({
94
133
  name,
@@ -96,6 +135,7 @@ async function savePersona({
96
135
  description,
97
136
  tools,
98
137
  model,
138
+ effort,
99
139
  color,
100
140
  tags,
101
141
  projects,
@@ -103,15 +143,20 @@ async function savePersona({
103
143
  actionLabel,
104
144
  title,
105
145
  body,
146
+ projectName,
106
147
  globalDir = path.join(os.homedir(), '.claude', 'agents'),
148
+ loadSessions = sessionsStore.load,
107
149
  validatePath = configMgr.validatePath,
108
150
  writeTextAtomic = configMgr.writeTextAtomic,
109
151
  } = {}) {
110
152
  if (!PERSONA_NAME_RE.test(name || '')) {
111
153
  throw new Error('agent name must be lowercase, hyphenated (e.g. "my-agent")');
112
154
  }
155
+ if (projectName) {
156
+ return saveOverlay({ name, projectName, model, effort, loadSessions, validatePath, writeTextAtomic });
157
+ }
113
158
  const target = validatePath(path.join(globalDir, `${name}.md`));
114
- const text = serializePersona({ name, description, tools, model, color, tags, projects, action, actionLabel, title, body });
159
+ const text = serializePersona({ name, description, tools, model, effort, color, tags, projects, action, actionLabel, title, body });
115
160
  await writeTextAtomic(target, text);
116
161
  if (originalName && originalName !== name) {
117
162
  const oldReal = validatePath(path.join(globalDir, `${originalName}.md`));
@@ -220,6 +265,9 @@ async function listPersonas({
220
265
  const { fm, body } = splitFrontmatter(text);
221
266
 
222
267
  const overridingProjects = [];
268
+ // Per-project merged view: which frontmatter fields (and whether the body)
269
+ // the overlay actually overrides — from the shared merge, not a boolean.
270
+ const overrideDetails = [];
223
271
  for (const p of projects) {
224
272
  let overlayReal;
225
273
  try {
@@ -227,7 +275,18 @@ async function listPersonas({
227
275
  } catch {
228
276
  continue; // project cwd outside allowed roots — skip rather than throw
229
277
  }
230
- if (fsSync.existsSync(overlayReal)) overridingProjects.push(p.name);
278
+ if (!fsSync.existsSync(overlayReal)) continue;
279
+ overridingProjects.push(p.name);
280
+ const merged = resolveMergedPersona(p.cwd, path.basename(file, '.md'), { validatePath, globalDir });
281
+ const fields = merged ? Object.keys(merged.provenance).filter((k) => merged.provenance[k] === 'overlay') : [];
282
+ overrideDetails.push({
283
+ project: p.name,
284
+ fields,
285
+ // The project's value for each overridden key (the UI renders global → project).
286
+ values: merged ? Object.fromEntries(fields.map((k) => [k, merged.fm[k]])) : {},
287
+ bodyOverridden: merged ? merged.bodySource === 'overlay' : false,
288
+ issue: merged ? merged.overlayIssue : null,
289
+ });
231
290
  }
232
291
 
233
292
  personas.push({
@@ -235,6 +294,7 @@ async function listPersonas({
235
294
  description: fm.description || null,
236
295
  tools: parseTools(fm.tools),
237
296
  model: fm.model || null,
297
+ effort: fm.effort || null,
238
298
  color: fm.color || null,
239
299
  tags: parseTools(fm.tags),
240
300
  projects: parseTools(fm.projects),
@@ -244,6 +304,7 @@ async function listPersonas({
244
304
  path: real,
245
305
  body: body.trim(),
246
306
  overridingProjects,
307
+ overrideDetails,
247
308
  });
248
309
  }
249
310
 
@@ -251,44 +312,29 @@ async function listPersonas({
251
312
  }
252
313
 
253
314
  /**
254
- * Reads a persona `.md` file's raw text for `epicIntake.ts`'s `agentBody`
255
- * input, honoring the same project-overlay-then-global precedence as
256
- * `epicMint.cjs`'s `resolvePersonaPaths` (a project's `<cwd>/.claude/agents/
257
- * <name>.md` wins over `~/.claude/agents/<name>.md`) — the same precedence
258
- * `listPersonas` deliberately does NOT apply, since that lists only the
259
- * global directory for the Agent Library nav page. Returns `null` when
260
- * neither location has the file; frontmatter stripping and the 6000-char cap
261
- * are epicIntake.ts's job, not this reader's.
315
+ * Reads a persona for `epicIntake.ts`'s `agentBody` input and the PRD spawn
316
+ * path, through the shared overlay-over-global MERGE (lib/personaMerge.cjs —
317
+ * see its header for the rules). Returns `{ path, text, fm, body, fromOverlay,
318
+ * bodySource, provenance, overlayIssue }` where `fm`/`body` are the MERGED
319
+ * view, `provenance[key]` says which frontmatter keys came from the overlay,
320
+ * and `text` is the body-source file's raw text (byte-identical to the
321
+ * pre-merge result for a full-body overlay) or a re-serialized merge for a
322
+ * frontmatter-only overlay. `listPersonas` deliberately does NOT apply this —
323
+ * it lists the global directory for the Agent Library nav page. Returns `null`
324
+ * when neither location has the file; frontmatter stripping and the 6000-char
325
+ * cap are epicIntake.ts's / agentModelResolve.cjs's job, not this reader's.
326
+ * (Sync fs inside an async signature: the merge module is shared with the
327
+ * synchronous model resolver.)
262
328
  */
263
329
  async function getPersonaBody({
264
330
  cwd,
265
331
  name,
266
332
  validatePath = configMgr.validatePath,
333
+ ...deps
267
334
  } = {}) {
268
- // Same gate savePersona/deletePersona apply before touching a path built
269
- // from `name` (agentLibrary.cjs's PERSONA_NAME_RE check) — without it a
270
- // `name` like "../../other-project/CLAUDE" survives resolvePersonaPaths'
271
- // plain path.join and validatePath's allowed-roots check (which only
272
- // confirms the resolved path stays under the home dir / an opened
273
- // project), letting any readable .md under those roots be read and
274
- // forwarded into the Epic's opening prompt.
275
- if (!PERSONA_NAME_RE.test(name || '')) return null;
276
- const { projectPath, globalPath } = resolvePersonaPaths(cwd, name);
277
- for (const candidate of [projectPath, globalPath]) {
278
- let real;
279
- try {
280
- real = validatePath(candidate);
281
- } catch {
282
- continue;
283
- }
284
- try {
285
- const text = await fsp.readFile(real, 'utf8');
286
- return { path: real, text };
287
- } catch {
288
- continue;
289
- }
290
- }
291
- return null;
335
+ // PERSONA_NAME_RE is enforced inside resolveMergedPersona (the traversal
336
+ // guard for "../../other-project/CLAUDE"-style names).
337
+ return resolveMergedPersona(cwd, name, { ...deps, validatePath });
292
338
  }
293
339
 
294
340
  module.exports = {
@@ -1,8 +1,8 @@
1
1
  {
2
- "version": "0.92.1",
3
- "gitSha": "cc3a80b2b9933e408e8effca99e550e3409b4d42",
4
- "gitShortSha": "cc3a80b",
2
+ "version": "0.94.0",
3
+ "gitSha": "b5777198add78466da6d34b83d818bb1352f999a",
4
+ "gitShortSha": "b577719",
5
5
  "gitBranch": "HEAD",
6
- "builtAt": "2026-09-20T22:38:10.510Z",
6
+ "builtAt": "2026-09-21T19:59:46.626Z",
7
7
  "dirty": false
8
8
  }