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
@@ -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,202 @@
1
+ 'use strict';
2
+ import { describe, it, expect, vi } from 'vitest';
3
+ const { resolveModelCatalog, CATALOG_FLOOR, parseAliases, parseEffortLevels, scanModelIds } = require('../modelCatalog.cjs');
4
+
5
+ const MODEL_OUT = 'Usage: /model <name>. Available: sonnet, opus, haiku, fable, best, sonnet[1m], opus[1m], fable[1m], opusplan, default, or a full model ID.';
6
+ const EFFORT_OUT = 'Usage: /effort <low|medium|high|xhigh|max|auto>';
7
+ const NOW = Date.parse('2026-09-20T00:00:00Z');
8
+
9
+ function mk(over = {}) {
10
+ const files = over.files || {};
11
+ const fsImpl = {
12
+ readFileSync: (f) => { if (f in files) return files[f]; if (String(f).endsWith('claude-settings-schema.json')) return JSON.stringify({ properties: { effortLevel: { enum: ['low', 'medium', 'high', 'xhigh'] } } }); throw new Error('ENOENT'); },
13
+ realpathSync: (p) => p,
14
+ statSync: () => ({ isFile: () => true, size: 200 * 1024 * 1024 }),
15
+ openSync: () => 1, readSync: (fd, buf) => { buf.write('\x7fELF'); return 4; }, closeSync: () => {},
16
+ ...over.fs,
17
+ };
18
+ const runClaudeP = vi.fn(async (prompt) => ({ ok: true, out: prompt === '/model' ? MODEL_OUT : EFFORT_OUT }));
19
+ const writeJson = vi.fn(async () => {});
20
+ const deps = {
21
+ env: {}, now: () => NOW, homeDir: () => '/home/u', projectRootOf: (c) => c,
22
+ probeClaudeVersion: async () => '2.1.277', resolveClaudeBin: () => '/bin/claude',
23
+ scanModelIds: () => ['claude-opus-5', 'claude-sonnet-5'], fs: fsImpl, runClaudeP, writeJson, ...over.deps,
24
+ };
25
+ return { deps, runClaudeP, writeJson };
26
+ }
27
+ const CACHE = '/home/u/.claude/session-manager/model-catalog.json';
28
+ const cacheEntry = (o = {}) => JSON.stringify({ aliases: ['sonnet'], models: ['claude-old-1'], effortLevels: ['low'], settingsEffortLevels: ['low'], claudeVersion: '2.1.277', probedAt: new Date(NOW - 1000).toISOString(), degraded: false, ...o });
29
+
30
+ describe('parsers', () => {
31
+ it('parses aliases, dropping the "or a full model ID" clause and keeping [1m]', () => {
32
+ expect(parseAliases(`Current model: x\n${MODEL_OUT}`)).toEqual(['sonnet', 'opus', 'haiku', 'fable', 'best', 'sonnet[1m]', 'opus[1m]', 'fable[1m]', 'opusplan', 'default']);
33
+ });
34
+ it('parses effort levels', () => {
35
+ expect(parseEffortLevels(EFFORT_OUT)).toEqual(['low', 'medium', 'high', 'xhigh', 'max', 'auto']);
36
+ });
37
+ it('returns null on garbage', () => {
38
+ expect(parseAliases('nope')).toBeNull();
39
+ expect(parseEffortLevels('nope')).toBeNull();
40
+ });
41
+ });
42
+
43
+ describe('scanModelIds', () => {
44
+ const scanBuf = (content, opts) => {
45
+ const data = Buffer.from(content, 'latin1');
46
+ let pos = 0;
47
+ const fsImpl = {
48
+ openSync: () => 1, closeSync: () => {},
49
+ readSync: (fd, buf, off, len) => { const n = Math.min(len, data.length - pos); data.copy(buf, off, pos, pos + n); pos += n; return n; },
50
+ };
51
+ return scanModelIds('/x', { fsImpl, ...opts });
52
+ };
53
+ it('finds an id split across a chunk boundary', () => {
54
+ const content = 'x'.repeat(13) + 'claude-opus-4-8' + '\0' + 'y'.repeat(20) + 'claude-haiku-4-5-20251001\0';
55
+ // chunk 20 cuts the first id after "claude-opus" (offset 13..28)
56
+ expect(scanBuf(content, { chunkSize: 20, overlap: 64 })).toEqual(['claude-haiku-4-5-20251001', 'claude-opus-4-8']);
57
+ });
58
+ it('does not report a truncated id when the tail is cut mid-number', () => {
59
+ expect(scanBuf('aaaaclaude-opus-4-8zz', { chunkSize: 14, overlap: 64 })).toEqual(['claude-opus-4-8']);
60
+ });
61
+ });
62
+
63
+ describe('resolveModelCatalog', () => {
64
+ it('happy path: merges probes, binary, schema, allowlist and writes cache', async () => {
65
+ const t = mk({ files: { '/home/u/.claude/settings.json': JSON.stringify({ availableModels: ['opus', 'haiku'] }), '/p/.claude/settings.local.json': JSON.stringify({ availableModels: ['haiku', 'sonnet'] }) } });
66
+ const c = await resolveModelCatalog({ cwd: '/p', force: true, deps: t.deps });
67
+ expect(c.aliases).toContain('opus[1m]');
68
+ expect(c.effortLevels).toEqual(['low', 'medium', 'high', 'xhigh', 'max']); // `auto` is a reset verb, not selectable
69
+ expect(c.models).toEqual(['claude-opus-5', 'claude-sonnet-5']);
70
+ expect(c.settingsEffortLevels).toEqual(['low', 'medium', 'high', 'xhigh']);
71
+ expect(c.availableModels).toEqual(['opus', 'haiku', 'sonnet']);
72
+ expect(c.sources).toEqual({ aliases: 'probe', models: 'binary', effortLevels: 'probe' });
73
+ expect(c.degraded).toBe(false);
74
+ expect(c.claudeVersion).toBe('2.1.277');
75
+ expect(t.runClaudeP).toHaveBeenCalledWith('/model', { model: 'haiku', timeoutMs: 45000 });
76
+ expect(t.runClaudeP).toHaveBeenCalledWith('/effort', { model: 'haiku', timeoutMs: 45000 });
77
+ expect(t.writeJson).toHaveBeenCalledWith(CACHE, c);
78
+ });
79
+ it('availableModels is null when no scope defines it', async () => {
80
+ const c = await resolveModelCatalog({ cwd: '/p', force: true, deps: mk().deps });
81
+ expect(c.availableModels).toBeNull();
82
+ });
83
+ it('fresh matching cache is returned without spawning; force bypasses it', async () => {
84
+ const t = mk({ files: { [CACHE]: cacheEntry() } });
85
+ const c = await resolveModelCatalog({ deps: t.deps });
86
+ expect(c.aliases).toEqual(['sonnet']);
87
+ expect(c.sources.aliases).toBe('cache');
88
+ expect(t.runClaudeP).not.toHaveBeenCalled();
89
+ await resolveModelCatalog({ force: true, deps: t.deps });
90
+ expect(t.runClaudeP).toHaveBeenCalledTimes(2);
91
+ });
92
+ it('probe timeout falls back to stale cache and marks degraded', async () => {
93
+ const t = mk({ files: { [CACHE]: cacheEntry({ probedAt: new Date(NOW - 48 * 3600e3).toISOString() }) }, deps: { runClaudeP: vi.fn(async () => ({ ok: false, error: 'timeout' })) } });
94
+ const c = await resolveModelCatalog({ deps: t.deps });
95
+ expect(c.aliases).toEqual(['sonnet']);
96
+ expect(c.effortLevels).toEqual(['low']);
97
+ expect(c.sources.aliases).toBe('cache');
98
+ expect(c.sources.effortLevels).toBe('cache');
99
+ expect(c.sources.models).toBe('binary');
100
+ expect(c.degraded).toBe(true);
101
+ expect(t.writeJson).not.toHaveBeenCalled();
102
+ });
103
+ it('stale cache from a different CLI version is used before the floor; no cache → floor', async () => {
104
+ const fail = { runClaudeP: vi.fn(async () => ({ ok: false })), scanModelIds: () => { throw new Error('ENOENT'); } };
105
+ const stale = mk({ files: { [CACHE]: cacheEntry({ claudeVersion: '1.0.0' }) }, deps: fail });
106
+ const c1 = await resolveModelCatalog({ deps: stale.deps });
107
+ expect(c1.aliases).toEqual(['sonnet']);
108
+ expect(c1.models).toEqual(['claude-old-1']);
109
+ const none = mk({ deps: fail });
110
+ const c2 = await resolveModelCatalog({ deps: none.deps });
111
+ expect(c2.aliases).toEqual(CATALOG_FLOOR.aliases);
112
+ expect(c2.effortLevels).toEqual(CATALOG_FLOOR.effortLevels);
113
+ expect(c2.models).toEqual([]);
114
+ expect(c2.sources).toEqual({ aliases: 'floor', models: 'floor', effortLevels: 'floor' });
115
+ expect(c2.degraded).toBe(true);
116
+ });
117
+ it('corrupt cache degrades to floor without throwing', async () => {
118
+ const t = mk({ files: { [CACHE]: '{not json' }, deps: { runClaudeP: vi.fn(async () => ({ ok: false })) } });
119
+ const c = await resolveModelCatalog({ deps: t.deps });
120
+ expect(c.aliases).toEqual(CATALOG_FLOOR.aliases);
121
+ });
122
+ it('single-flight collapses concurrent calls into one probe pair', async () => {
123
+ const t = mk();
124
+ const [a, b] = await Promise.all([resolveModelCatalog({ force: true, deps: t.deps }), resolveModelCatalog({ force: true, deps: t.deps })]);
125
+ expect(a).toBe(b);
126
+ expect(t.runClaudeP).toHaveBeenCalledTimes(2);
127
+ });
128
+ it('SM_E2E=1 never spawns: floor, or a cache when present', async () => {
129
+ const t = mk({ deps: { env: { SM_E2E: '1' } } });
130
+ const c = await resolveModelCatalog({ force: true, deps: t.deps });
131
+ expect(c.aliases).toEqual(CATALOG_FLOOR.aliases);
132
+ expect(t.runClaudeP).not.toHaveBeenCalled();
133
+ const t2 = mk({ files: { [CACHE]: cacheEntry() }, deps: { env: { SM_E2E: '1' } } });
134
+ expect((await resolveModelCatalog({ deps: t2.deps })).aliases).toEqual(['sonnet']);
135
+ expect(t2.runClaudeP).not.toHaveBeenCalled();
136
+ });
137
+ });
138
+
139
+ describe('catalog cache file (real write boundary)', () => {
140
+ it('is written under ~/.claude, a path the real validateWrite accepts', async () => {
141
+ const os = require('node:os'); const pth = require('node:path');
142
+ const config = require('../../config.cjs');
143
+ const realHome = os.homedir();
144
+ const file = pth.join(realHome, '.claude', 'session-manager', 'model-catalog.json');
145
+ const t = mk({ deps: { homeDir: () => realHome, writeJson: undefined } });
146
+ const written = [];
147
+ t.deps.writeJson = async (f, d) => { written.push(f); config.validateWrite(f); };
148
+ await resolveModelCatalog({ force: true, deps: t.deps });
149
+ expect(written).toEqual([file]);
150
+ });
151
+ it('a userData path is rejected by validateWrite (why the cache must not live there)', () => {
152
+ const config = require('../../config.cjs');
153
+ expect(() => config.validateWrite('/somewhere/userData/model-catalog.json')).toThrow(/outside allowed write boundaries/);
154
+ });
155
+ });
156
+
157
+ describe('binary scan guard', () => {
158
+ it('never scans a small shell-script stub; degrades to the floor', async () => {
159
+ const scan = vi.fn(() => ['claude-x-1']);
160
+ const t = mk({ fs: { statSync: () => ({ isFile: () => true, size: 300 }) }, deps: { scanModelIds: scan } });
161
+ const c = await resolveModelCatalog({ force: true, deps: t.deps });
162
+ expect(scan).not.toHaveBeenCalled();
163
+ expect(c.sources.models).toBe('floor');
164
+ expect(c.degraded).toBe(true);
165
+ });
166
+ it('never scans a large file that is a shell script', async () => {
167
+ const scan = vi.fn(() => ['claude-x-1']);
168
+ const t = mk({ fs: { readSync: (fd, buf) => { buf.write('#!/bin/bash\n'); return 12; } }, deps: { scanModelIds: scan } });
169
+ await resolveModelCatalog({ force: true, deps: t.deps });
170
+ expect(scan).not.toHaveBeenCalled();
171
+ });
172
+ });
173
+
174
+ describe('null CLI version key', () => {
175
+ it('neither serves a cache as fresh nor writes one', async () => {
176
+ const t = mk({ files: { [CACHE]: cacheEntry({ claudeVersion: null }) }, deps: { probeClaudeVersion: async () => null } });
177
+ const c = await resolveModelCatalog({ deps: t.deps });
178
+ expect(t.runClaudeP).toHaveBeenCalled();
179
+ expect(c.claudeVersion).toBeNull();
180
+ expect(t.writeJson).not.toHaveBeenCalled();
181
+ });
182
+ it('a null-version cache entry is never a fresh hit for a real version', async () => {
183
+ const t = mk({ files: { [CACHE]: cacheEntry({ claudeVersion: null }) } });
184
+ await resolveModelCatalog({ deps: t.deps });
185
+ expect(t.runClaudeP).toHaveBeenCalled();
186
+ });
187
+ });
188
+
189
+ describe('effort auto is never a flag value', () => {
190
+ it('drops `auto` from live and cached effort levels', async () => {
191
+ const t = mk({ files: { [CACHE]: cacheEntry({ effortLevels: ['low', 'auto'], probedAt: new Date(NOW - 48 * 3600e3).toISOString() }) }, deps: { runClaudeP: vi.fn(async () => ({ ok: false })) } });
192
+ const c = await resolveModelCatalog({ deps: t.deps });
193
+ expect(c.effortLevels).toEqual(['low']);
194
+ });
195
+ it('effortArgs and resolveEpicEffort never emit --effort auto', () => {
196
+ const { effortArgs } = require('../agentEffortResolve.cjs');
197
+ expect(effortArgs('auto')).toEqual([]);
198
+ expect(effortArgs('AUTO')).toEqual([]);
199
+ expect(effortArgs('inherit')).toEqual([]);
200
+ expect(effortArgs('high')).toEqual(['--effort', 'high']);
201
+ });
202
+ });
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * agentEffortResolve.cjs — the effort twin of agentModelResolve.cjs: resolves
5
+ * the `--effort <level>` an Epic-/PRD-backed `claude` launch should carry,
6
+ * from the agentType persona's own `effort:` frontmatter field (written by
7
+ * agentLibrary.cjs's serializePersona; the key is literally `effort`).
8
+ *
9
+ * PRECEDENCE (explicit):
10
+ * 0. Epic-level `effort` (the New Session card's override, stored on the Epic
11
+ * record; source 'epic') beats everything below. Only reachable on the
12
+ * Chat/Terminal path (claudeSessionId) — a scheduled PRD has no Epic record
13
+ * of its own to carry it. `inherit`/`auto` there means "no override".
14
+ * 1. persona `effort:` — project overlay (`<cwd>/.claude/agents/<name>.md`)
15
+ * beats the global `~/.claude/agents/<name>.md` (same path resolver and
16
+ * same reader as the model path: agentModelResolve.cjs's
17
+ * readOverlayAwarePersona, i.e. personaMerge.cjs's field-level merge — a
18
+ * frontmatter-only overlay can pin just `effort`; source is
19
+ * 'persona-overlay' only when the overlay itself supplied the value).
20
+ * 2. `inherit`, absent, dangling agentType, unreadable persona → `effort:
21
+ * null`, meaning "pass NO --effort flag at all" so the CLI applies its
22
+ * own settings.json / default resolution.
23
+ *
24
+ * This module NEVER reads settings.json and NEVER invents a fallback level.
25
+ * A caller must append the flag only when `effort` is a non-null string —
26
+ * never `--effort inherit`, `--effort null`, or an empty value.
27
+ *
28
+ * The persona value is passed through UNCHANGED (only trimmed): the CLI
29
+ * accepts low/medium/high/xhigh/max and, for an unknown level, merely warns
30
+ * (`Unknown --effort value 'x' — ignoring it and using the default effort.`)
31
+ * and proceeds at the default — so a wrong value degrades gracefully instead
32
+ * of failing the run, and rewriting it here would hide the mistake.
33
+ *
34
+ * Plain sync Node module, never throws (same contract as resolveEpicModel).
35
+ */
36
+
37
+ const { findEpicByClaudeSessionId, epicOverrideValue, readOverlayAwarePersona } = require('./agentModelResolve.cjs');
38
+
39
+ // `auto` is a `/effort` RESET verb, not a `--effort` value; it must never reach argv.
40
+ const NON_FLAG_EFFORT = new Set(['inherit', 'auto']);
41
+
42
+ const NO_EFFORT = Object.freeze({ effort: null, source: null });
43
+
44
+ /**
45
+ * @param {{ cwd: string, claudeSessionId?: string, agentType?: string|null, deps?: object }} opts
46
+ * `agentType` wins when given (scheduler PRD path); otherwise it is joined
47
+ * from the Epic's `claudeSessionId` (Chat/Terminal path).
48
+ * @returns {{ effort: string|null, source: 'epic'|'persona'|'persona-overlay'|'inherit'|null }}
49
+ * source null = no agentType/persona resolved; 'inherit' = persona present
50
+ * but sets no level.
51
+ */
52
+ function resolveEpicEffort({ cwd, claudeSessionId, agentType, deps = {} } = {}) {
53
+ try {
54
+ const epic = claudeSessionId ? findEpicByClaudeSessionId(cwd, claudeSessionId, deps) : null;
55
+ const override = epicOverrideValue(epic, 'effort');
56
+ if (override && !NON_FLAG_EFFORT.has(override.toLowerCase())) return { effort: override, source: 'epic' };
57
+ const type = agentType || epic?.agentType || null;
58
+ if (!type) return { ...NO_EFFORT };
59
+ const persona = readOverlayAwarePersona(type, { ...deps, cwd });
60
+ if (!persona) return { ...NO_EFFORT };
61
+ const raw = typeof persona.fm.effort === 'string' ? persona.fm.effort.trim() : '';
62
+ if (!raw || NON_FLAG_EFFORT.has(raw.toLowerCase())) return { effort: null, source: 'inherit' };
63
+ return { effort: raw, source: persona.provenance.effort === 'overlay' ? 'persona-overlay' : 'persona' };
64
+ } catch {
65
+ return { ...NO_EFFORT };
66
+ }
67
+ }
68
+
69
+ /** argv fragment for a resolved effort: `['--effort', level]` or `[]`. One place decides the "no flag" rule. */
70
+ function effortArgs(effort) {
71
+ return typeof effort === 'string' && effort && !NON_FLAG_EFFORT.has(effort.toLowerCase()) ? ['--effort', effort] : [];
72
+ }
73
+
74
+ module.exports = { resolveEpicEffort, effortArgs };
@@ -17,17 +17,21 @@
17
17
  * views literally call the same function instead of two functions that are
18
18
  * merely supposed to agree.
19
19
  *
20
+ * PRECEDENCE (explicit): Epic-level `model` (the New Session card's override,
21
+ * stored on the Epic record) > persona frontmatter `model` > `fallbackModel`.
22
+ * An Epic with no `model` key (every Epic minted before the override existed)
23
+ * resolves exactly as it always did.
24
+ *
20
25
  * Plain Node module (sync fs) — no Electron deps, mirrors epicMint.cjs's
21
26
  * readActiveIndex so this can be called from chatRunner.cjs's synchronous
22
27
  * executeRun() without restructuring it into an async flow.
23
28
  */
24
29
 
25
- const fs = require('node:fs');
26
30
  const path = require('node:path');
27
31
  const os = require('node:os');
28
32
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
29
33
  const { readActiveIndex, resolvePersonaPaths } = require('./epicMint.cjs');
30
- const { PERSONA_NAME_RE } = require('../agentLibrary.cjs');
34
+ const { resolveMergedPersona, PERSONA_NAME_RE } = require('./personaMerge.cjs');
31
35
  const configMgr = require('../config.cjs');
32
36
 
33
37
  /** The hardcoded floor every branch of resolveEpicModel falls back to — --model must never be omitted (CLAUDE.md "Automation model pinning"). */
@@ -56,7 +60,7 @@ function logDanglingPersonaOnce(cwd, agentType) {
56
60
  cwd,
57
61
  scope: 'agentModelResolve',
58
62
  level: 'warn',
59
- message: `agentType '${agentType}' has no resolvable persona file — model resolution fell back to '${FALLBACK_MODEL}'`,
63
+ message: `agentType '${agentType}' has no resolvable persona file — model resolution fell back to '${FALLBACK_MODEL}' (and no persona effort applies)`,
60
64
  meta: { agentType },
61
65
  });
62
66
  } catch { /* logging must never break model resolution */ }
@@ -68,74 +72,55 @@ function logDanglingPersonaOnce(cwd, agentType) {
68
72
  * resolveSourcePromptIdFromClaudeSession uses (Epic:claude-session is 1:1).
69
73
  * Returns null if no matching Epic is found or it has no agentType set.
70
74
  */
71
- function findAgentTypeByClaudeSessionId(cwd, claudeSessionId, deps = {}) {
75
+ function findEpicByClaudeSessionId(cwd, claudeSessionId, deps = {}) {
72
76
  if (!cwd || !claudeSessionId) return null;
73
77
  const load = deps.readActiveIndex || readActiveIndex;
74
78
  const { sessions } = load(cwd);
75
79
  for (const session of Object.values(sessions)) {
76
- if (session && session.claudeSessionId === claudeSessionId) return session.agentType || null;
80
+ if (session && session.claudeSessionId === claudeSessionId) return session;
77
81
  }
78
82
  return null;
79
83
  }
80
84
 
81
- /**
82
- * Shared miss-tolerant reader `readOverlayAwarePersonaModel` below is a thin
83
- * wrapper over: tries each candidate path in order (`validatePath` then sync
84
- * `readFileSync`), and returns the first readable file's `model` frontmatter
85
- * field — even if that field is absent, which is why an empty-`model`
86
- * overlay file does NOT fall through to a later candidate (matches
87
- * `getPersonaBody`'s own semantics). Logs the dangling-persona warning at
88
- * most once per `(deps.cwd, agentType)` only when EVERY candidate misses.
89
- * Never throws.
90
- */
91
- function readModelFromCandidatePaths(candidates, agentType, deps) {
92
- const validatePath = deps.validatePath || configMgr.validatePath;
93
- for (const candidate of candidates) {
94
- let real;
95
- try {
96
- real = validatePath(candidate);
97
- } catch {
98
- continue;
99
- }
100
- try {
101
- const text = fs.readFileSync(real, 'utf8');
102
- const { fm } = splitFrontmatter(text);
103
- return fm.model || null;
104
- } catch {
105
- continue;
106
- }
107
- }
108
- logDanglingPersonaOnce(deps.cwd, agentType);
109
- return null;
85
+ function findAgentTypeByClaudeSessionId(cwd, claudeSessionId, deps = {}) {
86
+ return findEpicByClaudeSessionId(cwd, claudeSessionId, deps)?.agentType || null;
87
+ }
88
+
89
+ /** A non-empty trimmed string from an Epic's optional `model`/`effort` override field, else null. */
90
+ function epicOverrideValue(epic, field) {
91
+ const raw = epic && typeof epic[field] === 'string' ? epic[field].trim() : '';
92
+ return raw || null;
110
93
  }
111
94
 
112
95
  /**
113
- * Reads an Epic's agentType persona's `model` frontmatter field with the SAME
114
- * project-overlay-then-global precedence `agentLibrary.cjs`'s `getPersonaBody`
115
- * (and, through it, `resolvePrdPersonaForSpawn` below) already applies for a
116
- * scheduled PRD — `epicMint.cjs`'s `resolvePersonaPaths` is the shared path
117
- * resolver both readers go through, so there is exactly one place that decides
118
- * which of the two files wins.
96
+ * Reads an Epic's agentType persona through personaMerge.cjs's
97
+ * `resolveMergedPersona` — the ONE overlay-over-global merge every reader
98
+ * shares (overlay frontmatter keys win, absent keys and an empty overlay body
99
+ * fall back to the global; see that module's header for the `inherit` and
100
+ * malformed-overlay rules). Returns `{ fm, body, path, fromOverlay,
101
+ * provenance, ... }`, or null on a total miss / invalid name — never throws,
102
+ * and logs the dangling-persona warning at most once per (cwd, agentType).
119
103
  *
120
- * Sync (not `getPersonaBody`'s async `fsp.readFile`) because `resolveEpicModel`
121
- * must stay synchronous: `chatRunner.cjs`'s `executeRun()` registers its
122
- * cancel handle into `inFlight` synchronously, and its caller (`pump()`) reads
123
- * that entry back immediately after invoking the executor — an `await`
124
- * inserted ahead of that registration would run it a tick late and silently
125
- * break `cancel()`. Returns null on a total miss (neither location resolves)
126
- * — never throws.
104
+ * Sync (not `getPersonaBody`'s async form) because `resolveEpicModel` must
105
+ * stay synchronous: `chatRunner.cjs`'s `executeRun()` registers its cancel
106
+ * handle into `inFlight` synchronously, and its caller (`pump()`) reads that
107
+ * entry back immediately after invoking the executor — an `await` inserted
108
+ * ahead of that registration would run it a tick late and silently break
109
+ * `cancel()`. The PERSONA_NAME_RE traversal guard lives in the merge module.
127
110
  */
111
+ function readOverlayAwarePersona(agentType, deps = {}) {
112
+ const persona = resolveMergedPersona(deps.cwd, agentType, deps);
113
+ if (!persona) {
114
+ if (agentType && PERSONA_NAME_RE.test(agentType)) logDanglingPersonaOnce(deps.cwd, agentType);
115
+ return null;
116
+ }
117
+ return persona;
118
+ }
119
+
120
+ /** The persona's `model` frontmatter field (overlay-then-global), or null. Never throws. */
128
121
  function readOverlayAwarePersonaModel(agentType, deps = {}) {
129
- // Same guard agentLibrary.cjs's getPersonaBody applies before it joins
130
- // `name` into a path: without it, a caller-supplied agentType like
131
- // "../../other-project/CLAUDE" survives resolvePersonaPaths' plain
132
- // path.join and validatePath's allowed-roots check (which only confirms
133
- // the resolved path stays under the home dir / an opened project), letting
134
- // any readable .md under those roots be read on this --model spawn path.
135
- if (!agentType || !PERSONA_NAME_RE.test(agentType)) return null;
136
- const resolvePaths = deps.resolvePersonaPaths || resolvePersonaPaths;
137
- const { projectPath, globalPath } = resolvePaths(deps.cwd, agentType, deps);
138
- return readModelFromCandidatePaths([projectPath, globalPath], agentType, deps);
122
+ const persona = readOverlayAwarePersona(agentType, deps);
123
+ return (persona && persona.fm.model) || null;
139
124
  }
140
125
 
141
126
  /**
@@ -179,7 +164,10 @@ function isProjectOverlayPersonaPath(cwd, agentType, personaPath, deps = {}) {
179
164
  */
180
165
  function resolveEpicModel({ cwd, claudeSessionId, fallbackModel = FALLBACK_MODEL, deps = {} } = {}) {
181
166
  try {
182
- const agentType = findAgentTypeByClaudeSessionId(cwd, claudeSessionId, deps);
167
+ const epic = findEpicByClaudeSessionId(cwd, claudeSessionId, deps);
168
+ const override = epicOverrideValue(epic, 'model');
169
+ if (override && override !== 'inherit') return override;
170
+ const agentType = epic?.agentType || null;
183
171
  if (!agentType) return fallbackModel;
184
172
  const model = readOverlayAwarePersonaModel(agentType, { ...deps, cwd });
185
173
  if (!model || model === 'inherit') return fallbackModel;
@@ -237,7 +225,9 @@ async function resolvePrdPersonaForSpawn({ cwd, agentType, fallbackModel = FALLB
237
225
  reportOnce(cwd, agentType, deps);
238
226
  return miss;
239
227
  }
240
- const { fm, body } = splitFrontmatter(persona.text);
228
+ // getPersonaBody returns the MERGED persona (fm + body); a bare `{ path, text }`
229
+ // (older injected reader) is parsed here.
230
+ const { fm, body } = persona.fm ? persona : splitFrontmatter(persona.text);
241
231
  const model = fm.model && fm.model !== 'inherit' ? fm.model : fallbackModel;
242
232
  const systemPrompt = buildPersonaBodyText(body, persona.path);
243
233
  return { model, systemPrompt, personaPath: persona.path };
@@ -250,7 +240,10 @@ module.exports = {
250
240
  FALLBACK_MODEL,
251
241
  resolveEpicModel,
252
242
  findAgentTypeByClaudeSessionId,
243
+ findEpicByClaudeSessionId,
244
+ epicOverrideValue,
253
245
  readOverlayAwarePersonaModel,
246
+ readOverlayAwarePersona,
254
247
  isProjectOverlayPersonaPath,
255
248
  resolvePrdPersonaForSpawn,
256
249
  };
@@ -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
@@ -50,12 +51,15 @@ const AgentPersonaSaveSchema = z.object({
50
51
  description: BoundedString,
51
52
  tools: z.array(BoundedString),
52
53
  model: BoundedString,
54
+ effort: BoundedString.optional(),
53
55
  color: BoundedString,
54
56
  tags: z.array(WorkTypeSchema),
55
57
  projects: z.array(ProjectEntrySchema).optional(),
56
58
  action: z.string().max(20000).optional(),
57
59
  actionLabel: BoundedString.optional(),
58
60
  title: BoundedString.optional(),
61
+ // Set = write this project's frontmatter-only override (model/effort only) instead of the global file.
62
+ projectName: BoundedString.optional(),
59
63
  // Unbounded — a persona's body is arbitrary system-prompt prose.
60
64
  body: z.string(),
61
65
  });
@@ -64,6 +68,7 @@ const AgentPersonaSaveSchema = z.object({
64
68
  * Throws a clear, descriptive error (not a raw ZodError dump) when `persona`
65
69
  * doesn't match AgentPersonaSaveSchema. Mirrors assertValidPromptSession /
66
70
  * assertValidScheduleJob's shape and message format exactly.
71
+ * @param {unknown} persona
67
72
  */
68
73
  function assertValidAgentPersonaSave(persona) {
69
74
  const result = AgentPersonaSaveSchema.safeParse(persona);