claude-code-session-manager 0.92.0 → 0.93.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 (75) hide show
  1. package/dist/assets/DataModel-DDGx3qrn.js +1 -0
  2. package/dist/assets/{History-BB6wpf02.js → History-CHBjYZyD.js} +2 -2
  3. package/dist/assets/{Hooks-zQZH56Sv.js → Hooks-DG4c-Rzz.js} +3 -3
  4. package/dist/assets/{HostBilko-CW0pHoPS.js → HostBilko-DHXcpV2c.js} +1 -1
  5. package/dist/assets/{Library-DrGdaz9N.js → Library-CN1h5rAE.js} +1 -1
  6. package/dist/assets/{MarkdownEditor-H1ytN4Tk.js → MarkdownEditor-DmBxrhdn.js} +1 -1
  7. package/dist/assets/{McpServers-CYSRCI-j.js → McpServers-Beb_HD6I.js} +2 -2
  8. package/dist/assets/{Memory-zMNpJyRU.js → Memory-Dk4uDCjg.js} +4 -4
  9. package/dist/assets/{Permissions-Be6cIkPg.js → Permissions-BovobeZw.js} +3 -3
  10. package/dist/assets/Plugins-DvOf_B62.js +2 -0
  11. package/dist/assets/ProvenanceBadge-Dutz_4OY.js +1 -0
  12. package/dist/assets/{SaveBar-xQZ2EBOn.js → SaveBar-CT5u6ZCJ.js} +1 -1
  13. package/dist/assets/Scheduler-B7GSnZj5.js +16 -0
  14. package/dist/assets/{ScopeSwitcher-BAfquN6Y.js → ScopeSwitcher-BUYDpz2v.js} +1 -1
  15. package/dist/assets/Settings-DIRmr3tL.js +3 -0
  16. package/dist/assets/{SkillReferenceGraph-BI1YtCbB.js → SkillReferenceGraph-CdGr6d3_.js} +1 -1
  17. package/dist/assets/Skills-BQOheQv4.js +3 -0
  18. package/dist/assets/SystemPrompt-5LsimKbX.js +1 -0
  19. package/dist/assets/TagLibrary-DreLsT3n.js +1 -0
  20. package/dist/assets/{TiptapBody-BptXAVR9.js → TiptapBody-C9kmlBnI.js} +1 -1
  21. package/dist/assets/{Toggle-BwiClCSI.js → Toggle-BKLCBu9A.js} +1 -1
  22. package/dist/assets/{index-D3P_jldk.css → index-CKH5Uxik.css} +1 -1
  23. package/dist/assets/index-Db_SK9rW.js +3079 -0
  24. package/dist/assets/settingsSchema-B5jBhSUP.js +1 -0
  25. package/dist/index.html +2 -2
  26. package/package.json +1 -1
  27. package/src/main/__tests__/agentEffortResolve.test.cjs +117 -0
  28. package/src/main/__tests__/agentLibrary.test.cjs +21 -0
  29. package/src/main/__tests__/agentModelResolve.test.cjs +16 -0
  30. package/src/main/__tests__/agentOverlayWrite.test.cjs +95 -0
  31. package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +44 -0
  32. package/src/main/__tests__/personaMerge.test.cjs +169 -0
  33. package/src/main/__tests__/prdCreatePlanId.test.cjs +132 -0
  34. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +13 -0
  35. package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +75 -10
  36. package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +47 -2
  37. package/src/main/agentLibrary.cjs +86 -40
  38. package/src/main/build-info.json +4 -4
  39. package/src/main/chatRunner.cjs +5 -0
  40. package/src/main/index.cjs +10 -0
  41. package/src/main/ipcSchemas.cjs +12 -0
  42. package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +10 -5
  43. package/src/main/lib/__tests__/modelCatalog.test.cjs +202 -0
  44. package/src/main/lib/agentEffortResolve.cjs +74 -0
  45. package/src/main/lib/agentModelResolve.cjs +52 -59
  46. package/src/main/lib/agentPersonaSchema.cjs +3 -0
  47. package/src/main/lib/effectiveModelInfo.cjs +38 -20
  48. package/src/main/lib/epicMint.cjs +5 -2
  49. package/src/main/lib/epicWorktreeMerge.cjs +7 -6
  50. package/src/main/lib/modelCatalog.cjs +243 -0
  51. package/src/main/lib/personaMerge.cjs +166 -0
  52. package/src/main/lib/prdCreate.cjs +23 -5
  53. package/src/main/lib/prdDisposition.cjs +46 -0
  54. package/src/main/lib/prdFrontmatter.cjs +6 -2
  55. package/src/main/lib/promptSessionSchema.cjs +3 -0
  56. package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
  57. package/src/main/pty.cjs +16 -2
  58. package/src/main/scheduler/prdParser.cjs +2 -0
  59. package/src/main/scheduler.cjs +21 -5
  60. package/src/main/templates/PRD_AUTHORING.md +4 -0
  61. package/src/preload/api.d.ts +53 -2
  62. package/src/preload/index.cjs +4 -0
  63. package/dist/assets/AgentLibrary-Bu-1eT_n.js +0 -3
  64. package/dist/assets/DataModel-Bk9rkJ8x.js +0 -1
  65. package/dist/assets/ListDetail-D4bxU_-2.js +0 -1
  66. package/dist/assets/Panel-RMz3Acpu.js +0 -1
  67. package/dist/assets/Plugins-B8sTWf2K.js +0 -2
  68. package/dist/assets/ProvenanceBadge-CO-DZN38.js +0 -1
  69. package/dist/assets/Scheduler-CF3zsJBw.js +0 -16
  70. package/dist/assets/Settings-uPjmqutA.js +0 -3
  71. package/dist/assets/Skills-BEy9FDYd.js +0 -3
  72. package/dist/assets/SystemPrompt-BYkkw-LL.js +0 -1
  73. package/dist/assets/TagLibrary-Kg3yOC5b.js +0 -1
  74. package/dist/assets/index-a2MVk8qh.js +0 -3074
  75. package/dist/assets/settingsSchema-DgxlStF5.js +0 -3
@@ -18,7 +18,7 @@ const fs = require('node:fs');
18
18
  const path = require('node:path');
19
19
  const os = require('node:os');
20
20
  const { encodeCwd } = require('../encodeCwd.cjs');
21
- const { resolveEffectiveModelInfo, isConcreteModelId, computeEffortReachable } = require('../effectiveModelInfo.cjs');
21
+ const { resolveEffectiveModelInfo, isConcreteModelId, computeEffortEnvReachable } = require('../effectiveModelInfo.cjs');
22
22
  // config.cjs's `allowedRoots` is a module-level singleton seeded from
23
23
  // os.homedir() at FIRST require — since this test's HOME changes every test
24
24
  // (fresh mkdtemp), a persona lookup (getPersonaBody -> validatePath) would
@@ -223,17 +223,22 @@ test('isConcreteModelId treats known CLI aliases as non-concrete and everything
223
223
  expect(isConcreteModelId('opus')).toBe(false);
224
224
  expect(isConcreteModelId('sonnet')).toBe(false);
225
225
  expect(isConcreteModelId('haiku')).toBe(false);
226
+ expect(isConcreteModelId('opus[1m]')).toBe(false);
227
+ expect(isConcreteModelId('best')).toBe(false);
228
+ expect(isConcreteModelId('opusplan')).toBe(false);
229
+ expect(isConcreteModelId('default')).toBe(false);
230
+ expect(isConcreteModelId('claude-opus-4-7[1m]')).toBe(true);
226
231
  expect(isConcreteModelId('fable')).toBe(false);
227
232
  expect(isConcreteModelId(null)).toBe(false);
228
233
  expect(isConcreteModelId('claude-opus-5')).toBe(true);
229
234
  });
230
235
 
231
- test('effortReachable is always false for an app-launched session — cleanChildEnv strips CLAUDE_EFFORT and CLAUDE_CODE_* before a child ever sees them', () => {
232
- expect(computeEffortReachable({})).toBe(false);
236
+ test('effortEnvReachable is always false for an app-launched session — cleanChildEnv strips CLAUDE_EFFORT and CLAUDE_CODE_* before a child ever sees them', () => {
237
+ expect(computeEffortEnvReachable({})).toBe(false);
233
238
  });
234
239
 
235
- test('resolveEffectiveModelInfo surfaces effortReachable alongside the model fields', async () => {
240
+ test('resolveEffectiveModelInfo surfaces effortEnvReachable alongside the model fields', async () => {
236
241
  writeGlobalPersona('any', ['model: sonnet']);
237
242
  const info = await resolveEffectiveModelInfo({ cwd, agentType: 'any' });
238
- expect(info.effortReachable).toBe(false);
243
+ expect(info.effortEnvReachable).toBe(false);
239
244
  });
@@ -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
  };
@@ -50,12 +50,15 @@ const AgentPersonaSaveSchema = z.object({
50
50
  description: BoundedString,
51
51
  tools: z.array(BoundedString),
52
52
  model: BoundedString,
53
+ effort: BoundedString.optional(),
53
54
  color: BoundedString,
54
55
  tags: z.array(WorkTypeSchema),
55
56
  projects: z.array(ProjectEntrySchema).optional(),
56
57
  action: z.string().max(20000).optional(),
57
58
  actionLabel: BoundedString.optional(),
58
59
  title: BoundedString.optional(),
60
+ // Set = write this project's frontmatter-only override (model/effort only) instead of the global file.
61
+ projectName: BoundedString.optional(),
59
62
  // Unbounded — a persona's body is arbitrary system-prompt prose.
60
63
  body: z.string(),
61
64
  });
@@ -13,11 +13,11 @@
13
13
  * "we don't know; the CLI resolves this alias at launch." The two must
14
14
  * never collapse into one optimistic string.
15
15
  *
16
- * Effort-level resolution is deliberately NOT here — it already exists,
17
- * unused, in the renderer's `useEffectiveSettings.ts` (a scope-chain reader
18
- * with a file watcher). This module supplies only the half that genuinely
19
- * needs main-process filesystem access: persona lookup (overlay-aware) and
20
- * concrete-model evidence (scheduler run logs, session transcripts).
16
+ * PERSONA effort IS here now (agentEffortResolve.cjs): it needs the same
17
+ * main-process, overlay-aware persona read as the persona model, and a launch
18
+ * now actually passes it as `--effort`. The settings.json effortLevel
19
+ * scope-chain read stays in the renderer (`useEffectiveSettings.ts`) as the
20
+ * FALLBACK when the persona sets no level — this module never reads settings.
21
21
  *
22
22
  * Never throws — every lookup failure degrades a field to null, same
23
23
  * contract as agentModelResolve.cjs's resolveEpicModel.
@@ -29,12 +29,17 @@ const os = require('node:os');
29
29
  const { splitFrontmatter } = require('./prdFrontmatter.cjs');
30
30
  const { resolveEpicTranscriptPath } = require('./epicTranscriptPath.cjs');
31
31
 
32
- // Mirrors rawSessionModel.ts's RAW_MODELS — duplicated rather than imported
32
+ // Deliberately stays a FIXED alias set (reviewed against the catalog-driven raw picker): it answers
33
+ // "alias vs concrete id", not "which options to offer", so live catalog ids must not enter it.
34
+ // Mirrors rawSessionModel.ts's RAW_MODELS fallback — duplicated rather than imported
33
35
  // because that file is a renderer ES module and this is a main-process CJS
34
36
  // module (CLAUDE.md: "No CommonJS in renderer, no ES modules in main").
35
37
  // Anything NOT in this set is treated as an already-concrete pinned model id
36
38
  // (e.g. 'claude-opus-5'), never as an alias needing evidence.
37
- const KNOWN_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku', 'fable']);
39
+ // Also the non-family CLI aliases the Agent Library picker can now write
40
+ // (best / opusplan / default); a trailing `[1m]`-style suffix is stripped
41
+ // before the lookup (see isConcreteModelId), so 'opus[1m]' is an alias too.
42
+ const KNOWN_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku', 'fable', 'best', 'opusplan', 'default']);
38
43
 
39
44
  // Run-log evidence (scheduler.cjs's executeJob, ~line 4473) is written
40
45
  // essentially at dispatch time, before any of the run's own stdout streams
@@ -54,10 +59,14 @@ function runsDir(homeDir) {
54
59
  }
55
60
 
56
61
  function isConcreteModelId(modelAlias) {
57
- return Boolean(modelAlias) && !KNOWN_MODEL_ALIASES.has(modelAlias);
62
+ if (!modelAlias) return false;
63
+ // 'opus[1m]' is an alias with a context suffix; 'claude-opus-4-7[1m]' keeps its id.
64
+ return !KNOWN_MODEL_ALIASES.has(modelAlias.replace(/\[[^\]]*\]$/, ''));
58
65
  }
59
66
 
60
67
  /**
68
+ * ENV-VAR question only (`effortEnvReachable`): persona effort now travels on argv as `--effort`,
69
+ * which cleanChildEnv cannot strip — so this says nothing about whether effort reaches the CLI.
61
70
  * True when this app's spawn path (cleanEnv.cjs's cleanChildEnv) would strip
62
71
  * an effort env var before a child ever saw it — i.e. whether the settings
63
72
  * schema's env-var effort configuration path is actually reachable for an
@@ -65,7 +74,7 @@ function isConcreteModelId(modelAlias) {
65
74
  * hardcoding a boolean, so this self-corrects if that deletion list ever
66
75
  * changes instead of silently drifting stale.
67
76
  */
68
- function computeEffortReachable(deps) {
77
+ function computeEffortEnvReachable(deps) {
69
78
  try {
70
79
  const { cleanChildEnv } = deps.cleanEnv || require('./cleanEnv.cjs');
71
80
  const probe = cleanChildEnv({ CLAUDE_EFFORT: 'high', CLAUDE_CODE_EFFORT_LEVEL: 'high' });
@@ -106,15 +115,18 @@ async function resolvePersonaModelAlias(cwd, agentType, deps) {
106
115
  } catch {
107
116
  persona = null;
108
117
  }
109
- if (!persona) return { modelAlias: null, modelSource: 'fallback' };
118
+ if (!persona) return { modelAlias: null, modelSource: 'fallback', provenance: {} };
110
119
 
111
- const { fm } = splitFrontmatter(persona.text);
112
- const fromOverlay = isProjectOverlayPersonaPath(cwd, agentType, persona.path, deps);
120
+ // getPersonaBody returns the MERGED persona with per-field provenance; a bare
121
+ // `{ path, text }` (older injected reader) falls back to whole-file provenance.
122
+ const fm = persona.fm || splitFrontmatter(persona.text).fm;
123
+ const provenance = persona.provenance
124
+ || Object.fromEntries(Object.keys(fm).map((k) => [k, isProjectOverlayPersonaPath(cwd, agentType, persona.path, deps) ? 'overlay' : 'global']));
113
125
 
114
126
  if (fm.model && fm.model !== 'inherit') {
115
- return { modelAlias: fm.model, modelSource: fromOverlay ? 'persona-overlay' : 'persona' };
127
+ return { modelAlias: fm.model, modelSource: provenance.model === 'overlay' ? 'persona-overlay' : 'persona', provenance };
116
128
  }
117
- return { modelAlias: null, modelSource: 'inherit' };
129
+ return { modelAlias: null, modelSource: 'inherit', provenance };
118
130
  }
119
131
 
120
132
  /** Best-effort read of `<cwd>/session-manager-operations/scheduler/state/queue.json`'s hot jobs[]. */
@@ -268,21 +280,25 @@ function findLatestTranscriptModel(cwd, agentType, deps) {
268
280
 
269
281
  /**
270
282
  * resolveEffectiveModelInfo({ cwd, agentType, deps? }) →
271
- * { agentType, modelAlias, modelSource, resolvedModelId, resolvedFrom, effortReachable }
283
+ * { agentType, modelAlias, modelSource, resolvedModelId, resolvedFrom, effortEnvReachable,
284
+ * personaEffort, personaEffortSource }
272
285
  *
273
286
  * Never throws. `cwd` is normalized through the same worktree/ops-internal
274
287
  * -aware resolver every ops-root reader goes through, so a job worktree cwd
275
288
  * resolves to its main project tree before anything is looked up.
276
289
  */
277
290
  async function resolveEffectiveModelInfo({ cwd, agentType, deps = {} } = {}) {
278
- const effortReachable = computeEffortReachable(deps);
291
+ const effortEnvReachable = computeEffortEnvReachable(deps);
279
292
  const miss = {
280
293
  agentType: agentType || null,
281
294
  modelAlias: null,
282
295
  modelSource: 'fallback',
296
+ personaProvenance: {},
283
297
  resolvedModelId: null,
284
298
  resolvedFrom: null,
285
- effortReachable,
299
+ effortEnvReachable,
300
+ personaEffort: null,
301
+ personaEffortSource: null,
286
302
  };
287
303
  if (!cwd || !agentType) return miss;
288
304
 
@@ -291,8 +307,10 @@ async function resolveEffectiveModelInfo({ cwd, agentType, deps = {} } = {}) {
291
307
  const normalizedCwd = projectRootOf(path.resolve(cwd));
292
308
  if (!normalizedCwd) return miss;
293
309
 
294
- const { modelAlias, modelSource } = await resolvePersonaModelAlias(normalizedCwd, agentType, deps);
295
- const result = { agentType, modelAlias, modelSource, resolvedModelId: null, resolvedFrom: null, effortReachable };
310
+ const { modelAlias, modelSource, provenance: personaProvenance } = await resolvePersonaModelAlias(normalizedCwd, agentType, deps);
311
+ const { effort: personaEffort, source: personaEffortSource } = (deps.agentEffortResolve || require('./agentEffortResolve.cjs'))
312
+ .resolveEpicEffort({ cwd: normalizedCwd, agentType, deps });
313
+ const result = { agentType, modelAlias, modelSource, personaProvenance, resolvedModelId: null, resolvedFrom: null, effortEnvReachable, personaEffort, personaEffortSource };
296
314
 
297
315
  if (isConcreteModelId(modelAlias)) {
298
316
  // Already a concrete pinned id — echo it back, fabricate no evidence.
@@ -327,6 +345,6 @@ async function resolveEffectiveModelInfo({ cwd, agentType, deps = {} } = {}) {
327
345
  module.exports = {
328
346
  resolveEffectiveModelInfo,
329
347
  isConcreteModelId,
330
- computeEffortReachable,
348
+ computeEffortEnvReachable,
331
349
  KNOWN_MODEL_ALIASES,
332
350
  };