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
@@ -0,0 +1,132 @@
1
+ /**
2
+ * prdCreatePlanId.test.cjs — createPrd() stamps a durable `planId` (wave identity):
3
+ * first-ever PRD mints, append inherits the terminal's, new-head mints, explicit
4
+ * multi-dep inherits the lowest-numbered dependency's (diverged ids logged once).
5
+ *
6
+ * Run: timeout 120 npx vitest run src/main/__tests__/prdCreatePlanId.test.cjs
7
+ */
8
+
9
+ 'use strict';
10
+
11
+ import { test, expect, beforeEach, afterEach, vi } from 'vitest';
12
+ const fsp = require('node:fs/promises');
13
+ const os = require('node:os');
14
+ const path = require('node:path');
15
+ const config = require('../config.cjs');
16
+ const prdParser = require('../scheduler/prdParser.cjs');
17
+ const { parsePrdFile } = require('../lib/prdFrontmatter.cjs');
18
+ const { createPrd } = require('../lib/prdCreate.cjs');
19
+ const { resolveInheritedPlanId, isValidPlanId } = require('../lib/prdDisposition.cjs');
20
+
21
+ async function mkTmpPrdsDir() {
22
+ const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'sm-planid-create-prd-'));
23
+ config.addAllowedRoot(root);
24
+ const prdsDir = path.join(root, '.claude', 'prds');
25
+ await fsp.mkdir(prdsDir, { recursive: true });
26
+ return prdsDir;
27
+ }
28
+
29
+ function makeFakeRemote(prdsDir, statusBySlug = {}) {
30
+ return {
31
+ async allocateParallelGroup() { return prdParser.allocateParallelGroup(prdsDir); },
32
+ async readPrd(slug) {
33
+ try { return { ok: true, text: await fsp.readFile(path.join(prdsDir, `${slug}.md`), 'utf8') }; }
34
+ catch (e) { return { ok: false, error: e?.message }; }
35
+ },
36
+ async writePrd(slug, body) {
37
+ await config.writeTextAtomic(path.join(prdsDir, `${slug}.md`), body, { writer: 'scheduler' });
38
+ return { ok: true };
39
+ },
40
+ async listPrds() {
41
+ const prds = [];
42
+ for (const f of await fsp.readdir(prdsDir)) {
43
+ if (!f.endsWith('.md')) continue;
44
+ const slug = f.slice(0, -3);
45
+ const { frontmatter } = parsePrdFile(await fsp.readFile(path.join(prdsDir, f), 'utf8'));
46
+ prds.push({
47
+ slug,
48
+ sourcePromptId: frontmatter.sourcePromptId ?? null,
49
+ dependsOn: frontmatter.dependsOn ?? null,
50
+ planId: frontmatter.planId ?? null,
51
+ status: statusBySlug[slug] ?? null,
52
+ });
53
+ }
54
+ return { prds, total: prds.length, limit: prds.length, offset: 0, hasMore: false };
55
+ },
56
+ };
57
+ }
58
+
59
+ async function seedPrd(prdsDir, slug, { planId, dependsOn } = {}) {
60
+ const lines = ['---', 'title: seed', 'sourcePromptId: epic-1'];
61
+ if (dependsOn) lines.push(`dependsOn: [${dependsOn.join(', ')}]`);
62
+ if (planId) lines.push(`planId: ${planId}`);
63
+ lines.push('---', '# Goal', '', 'seed', '');
64
+ await fsp.writeFile(path.join(prdsDir, `${slug}.md`), lines.join('\n'));
65
+ }
66
+
67
+ const body = (o = {}) => ({
68
+ title: 'Next wave', cwd: os.homedir(), estimateMinutes: 10, goal: 'g',
69
+ acceptanceCriteria: ['a'], implementationNotes: 'n', ...o,
70
+ });
71
+ const planIdOf = async (dir, result) =>
72
+ parsePrdFile(await fsp.readFile(path.join(dir, result.filename), 'utf8')).frontmatter.planId;
73
+
74
+ const originalJobSlug = process.env.SM_SCHEDULER_JOB_SLUG;
75
+ beforeEach(() => { delete process.env.SM_SCHEDULER_JOB_SLUG; });
76
+ afterEach(() => {
77
+ vi.restoreAllMocks();
78
+ if (originalJobSlug === undefined) delete process.env.SM_SCHEDULER_JOB_SLUG;
79
+ else process.env.SM_SCHEDULER_JOB_SLUG = originalJobSlug;
80
+ });
81
+
82
+ test('first-ever PRD in an Epic mints a valid planId', async () => {
83
+ const dir = await mkTmpPrdsDir();
84
+ const r = await createPrd(body({ sourcePromptId: 'epic-1' }), makeFakeRemote(dir));
85
+ expect(r.ok).toBe(true);
86
+ expect(isValidPlanId(await planIdOf(dir, r))).toBe(true);
87
+ });
88
+
89
+ test('a caller-supplied planId is ignored (API-owned)', async () => {
90
+ const dir = await mkTmpPrdsDir();
91
+ const r = await createPrd(body({ planId: 'evil-id' }), makeFakeRemote(dir));
92
+ expect(await planIdOf(dir, r)).not.toBe('evil-id');
93
+ });
94
+
95
+ test('append inherits the chain terminal\'s planId', async () => {
96
+ const dir = await mkTmpPrdsDir();
97
+ await seedPrd(dir, '1-tail', { planId: 'pl-abc' });
98
+ const r = await createPrd(body({ sourcePromptId: 'epic-1', disposition: 'append' }), makeFakeRemote(dir, { '1-tail': 'pending' }));
99
+ expect(r.ok).toBe(true);
100
+ expect(await planIdOf(dir, r)).toBe('pl-abc');
101
+ });
102
+
103
+ test('new-head mints a fresh planId, distinct from the existing plan', async () => {
104
+ const dir = await mkTmpPrdsDir();
105
+ await seedPrd(dir, '1-tail', { planId: 'pl-abc' });
106
+ const r = await createPrd(body({ sourcePromptId: 'epic-1', disposition: 'new-head' }), makeFakeRemote(dir, { '1-tail': 'pending' }));
107
+ const id = await planIdOf(dir, r);
108
+ expect(isValidPlanId(id)).toBe(true);
109
+ expect(id).not.toBe('pl-abc');
110
+ });
111
+
112
+ test('explicit multi-dep across two plans inherits the lowest-numbered dependency\'s and warns once', async () => {
113
+ const dir = await mkTmpPrdsDir();
114
+ await seedPrd(dir, '9-late', { planId: 'pl-late' });
115
+ await seedPrd(dir, '2-early', { planId: 'pl-early' });
116
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
117
+ const r = await createPrd(body({ dependsOn: ['9-late', '2-early'] }), makeFakeRemote(dir));
118
+ expect(r.ok).toBe(true);
119
+ expect(await planIdOf(dir, r)).toBe('pl-early');
120
+ expect(warn.mock.calls.filter((c) => String(c[0]).includes('spans plans')).length).toBe(1);
121
+ });
122
+
123
+ test('deps that predate the stamp fall back to a freshly minted planId', async () => {
124
+ const dir = await mkTmpPrdsDir();
125
+ await seedPrd(dir, '1-legacy');
126
+ const r = await createPrd(body({ dependsOn: ['1-legacy'] }), makeFakeRemote(dir));
127
+ expect(isValidPlanId(await planIdOf(dir, r))).toBe(true);
128
+ });
129
+
130
+ test('resolveInheritedPlanId ignores garbage planIds', () => {
131
+ expect(resolveInheritedPlanId(['1-a'], [{ slug: '1-a', planId: 'bad id,[x]' }]).planId).toBeNull();
132
+ });
@@ -144,3 +144,16 @@ test('a cwd outside every allowed root is rejected before anything is minted', a
144
144
  const index = readActiveIndex(unregistered);
145
145
  expect(Object.keys(index.sessions)).toHaveLength(0);
146
146
  });
147
+
148
+ test('model/effort overrides persist on the minted Epic; without them the record gains no such keys', async () => {
149
+ const cwd = await mkCwd();
150
+ const payload = schemas.promptSessionsCreateEpic.parse({ cwd, goalText: 'Override run', agentType: 'architect', model: 'claude-opus-5', effort: 'max' });
151
+ const withOverride = await createEpicViaIpc(cwd, payload);
152
+ expect(withOverride.session).toMatchObject({ model: 'claude-opus-5', effort: 'max' });
153
+ expect(readActiveIndex(cwd).sessions[withOverride.epicId]).toEqual(withOverride.session);
154
+ expect(PromptSessionSchema.safeParse(withOverride.session).success).toBe(true);
155
+
156
+ const plain = await createEpicViaIpc(cwd, schemas.promptSessionsCreateEpic.parse({ cwd, goalText: 'Plain run', agentType: 'architect' }));
157
+ expect('model' in plain.session).toBe(false);
158
+ expect('effort' in plain.session).toBe(false);
159
+ });
@@ -175,22 +175,87 @@ test('spawn() opens a merged-worktree Epic in the project cwd (dead worktree dir
175
175
  manager.kill(sessionId);
176
176
  });
177
177
 
178
- test('spawn() refuses a completed Epic without spawning and returns the plan message', () => {
179
- const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-closed-'));
180
- const sessionId = 'epic-session-closed';
181
- writeActiveIndexWithWorktree(mainCwd, { epicId: 'epic-c', claudeSessionId: sessionId, worktreeDir: mainCwd });
178
+ function setEpic(mainCwd, epicId, patch) {
182
179
  const idxPath = path.join(mainCwd, 'session-manager-operations', 'prompt-sessions', 'active-index.json');
183
180
  const idx = JSON.parse(fs.readFileSync(idxPath, 'utf8'));
184
- idx.sessions['epic-c'].status = 'completed';
181
+ const rec = idx.sessions[epicId];
182
+ if (patch.status) rec.status = patch.status;
183
+ if (patch.worktreeStatus) rec.worktree.status = patch.worktreeStatus;
185
184
  fs.writeFileSync(idxPath, JSON.stringify(idx));
185
+ }
186
186
 
187
- let spawned = false;
188
- nodePty.spawn = () => {
189
- spawned = true;
187
+ function captureSpawn() {
188
+ const out = { opts: null, count: 0 };
189
+ nodePty.spawn = (shell, args, opts) => {
190
+ out.opts = opts;
191
+ out.count += 1;
190
192
  return fakeProc();
191
193
  };
194
+ return out;
195
+ }
196
+
197
+ function fakeWindow(sent) {
198
+ return {
199
+ isDestroyed: () => false,
200
+ webContents: { isDestroyed: () => false, isCrashed: () => false, send: (channel, payload) => sent.push({ channel, payload }) },
201
+ };
202
+ }
203
+
204
+ test('spawn() opens a completed Epic (merged worktree) as a shell in the project cwd with an informational line', () => {
205
+ const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-closed-'));
206
+ const sessionId = 'epic-session-closed';
207
+ const deadDir = path.join(os.tmpdir(), 'sm-pty-closed-dead-worktree');
208
+ writeActiveIndexWithWorktree(mainCwd, { epicId: 'epic-c', claudeSessionId: sessionId, worktreeDir: deadDir });
209
+ setEpic(mainCwd, 'epic-c', { status: 'completed', worktreeStatus: 'merged' });
210
+
211
+ const cap = captureSpawn();
212
+ const sent = [];
213
+ manager.window = fakeWindow(sent);
214
+ try {
215
+ const result = manager.spawn({ tabId: sessionId, cwd: mainCwd, cols: 80, rows: 24 });
216
+ expect(cap.count).toBe(1);
217
+ expect(cap.opts.cwd).toBe(mainCwd);
218
+ expect(result.pid).toBe(4242);
219
+ expect(result.error).toBeUndefined();
220
+ const info = sent.find((m) => m.channel === `pty:data:${sessionId}`);
221
+ expect(info.payload).toMatch(/Epic is completed/);
222
+ expect(info.payload).toContain(mainCwd);
223
+ } finally {
224
+ manager.window = null;
225
+ manager.kill(sessionId);
226
+ }
227
+ });
228
+
229
+ test('spawn() opens a completed Epic whose worktree dir still exists in that worktree dir', () => {
230
+ const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-closed-live-'));
231
+ const worktreeCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-pty-closed-live-wt-'));
232
+ const sessionId = 'epic-session-closed-live';
233
+ writeActiveIndexWithWorktree(mainCwd, { epicId: 'epic-cl', claudeSessionId: sessionId, worktreeDir: worktreeCwd });
234
+ setEpic(mainCwd, 'epic-cl', { status: 'completed' });
235
+
236
+ const cap = captureSpawn();
237
+ manager.spawn({ tabId: sessionId, cwd: mainCwd, cols: 80, rows: 24 });
238
+ expect(cap.count).toBe(1);
239
+ expect(cap.opts.cwd).toBe(worktreeCwd);
240
+ manager.kill(sessionId);
241
+ fs.rmSync(worktreeCwd, { recursive: true, force: true });
242
+ });
243
+
244
+ test('spawn() still refuses session_unreachable without any node-pty spawn', () => {
245
+ const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-pty-unreachable-'));
246
+ const sessionId = 'epic-session-unreachable';
247
+ const deadDir = path.join(os.tmpdir(), 'sm-pty-unreachable-dead-worktree');
248
+ writeActiveIndexWithWorktree(mainCwd, { epicId: 'epic-u', claudeSessionId: sessionId, worktreeDir: deadDir });
249
+ setEpic(mainCwd, 'epic-u', { worktreeStatus: 'merged' });
250
+ // Transcript exists only under the dead worktree's encoded project dir.
251
+ const enc = deadDir.replace(/[^a-zA-Z0-9]/g, '-');
252
+ const tdir = path.join(tmpHome, '.claude', 'projects', enc);
253
+ fs.mkdirSync(tdir, { recursive: true });
254
+ fs.writeFileSync(path.join(tdir, `${sessionId}.jsonl`), '{"type":"user"}\n');
255
+
256
+ const cap = captureSpawn();
192
257
  const result = manager.spawn({ tabId: sessionId, cwd: mainCwd, cols: 80, rows: 24 });
193
- expect(spawned).toBe(false);
258
+ expect(cap.count).toBe(0);
194
259
  expect(result.pid).toBeNull();
195
- expect(result.error).toMatch(/closed/);
260
+ expect(result.error).toMatch(/cannot be resumed/);
196
261
  });
@@ -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
+ });
@@ -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.0",
3
- "gitSha": "d0e160e50a2560620e956e6d819cb405b0423110",
4
- "gitShortSha": "d0e160e",
2
+ "version": "0.93.0",
3
+ "gitSha": "78fa526eb94e044b2c72c736681e956bee653451",
4
+ "gitShortSha": "78fa526",
5
5
  "gitBranch": "HEAD",
6
- "builtAt": "2026-09-20T21:26:16.666Z",
6
+ "builtAt": "2026-09-21T03:09:24.532Z",
7
7
  "dirty": false
8
8
  }
@@ -53,6 +53,7 @@ const { classifyPromptTicket } = require('./lib/classifyPromptTicket.cjs');
53
53
  const sessionSlots = require('./lib/sessionSlots.cjs');
54
54
  const opsErrorLog = require('./lib/opsErrorLog.cjs');
55
55
  const agentModelResolve = require('./lib/agentModelResolve.cjs');
56
+ const { resolveEpicEffort, effortArgs } = require('./lib/agentEffortResolve.cjs');
56
57
  const { planEpicSpawn } = require('./lib/epicSpawnPlan.cjs');
57
58
  const logs = require('./logs.cjs');
58
59
 
@@ -546,6 +547,9 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
546
547
  // Epic, no agentType, or the persona has no model/'inherit' — --model
547
548
  // must never be left unpinned (CLAUDE.md model-pinning rule).
548
549
  const model = agentModelResolve.resolveEpicModel({ cwd, claudeSessionId: sessionId });
550
+ // Persona `effort:` (same overlay-aware persona, agentEffortResolve.cjs);
551
+ // effortArgs() yields NO tokens for inherit/absent/dangling.
552
+ const effortFlags = effortArgs(resolveEpicEffort({ cwd, claudeSessionId: sessionId }).effort);
549
553
 
550
554
  // Main is authoritative about --resume vs --session-id AND the spawn cwd, and it decides
551
555
  // them TOGETHER (epicSpawnPlan.cjs): a transcript under an encoding the spawn cwd cannot see
@@ -579,6 +583,7 @@ function executeRun({ tabId, sessionId, prompt, cwd, resume, silent, onSilentRes
579
583
  const a = [
580
584
  '-p', fullPrompt,
581
585
  '--model', model,
586
+ ...effortFlags,
582
587
  '--dangerously-skip-permissions',
583
588
  '--output-format', 'stream-json',
584
589
  '--verbose',
@@ -43,7 +43,9 @@ const epicWorktreeMerge = require('./lib/epicWorktreeMerge.cjs');
43
43
  const epicWorktreeProjectConfig = require('./lib/epicWorktreeProjectConfig.cjs');
44
44
  const agentLibrary = require('./agentLibrary.cjs');
45
45
  const agentModelResolve = require('./lib/agentModelResolve.cjs');
46
+ const agentEffortResolve = require('./lib/agentEffortResolve.cjs');
46
47
  const { resolveEffectiveModelInfo } = require('./lib/effectiveModelInfo.cjs');
48
+ const { resolveModelCatalog } = require('./lib/modelCatalog.cjs');
47
49
  const { checkDelegationReadiness, ensureGuardsInstalled, installPrdWriteGuard, installDestructiveGitGuard, installInlineImplementationGuard, installSelfScheduleGuard } = require('./lib/delegationReadiness.cjs');
48
50
  const { resolveProjectContext } = require('./lib/projectRootResolve.cjs');
49
51
  const upgradeDrain = require('./lib/upgradeDrain.cjs');
@@ -565,6 +567,10 @@ ipcMain.handle('agents:get-persona-body', validated(schemas.agentsGetPersonaBody
565
567
  // is merely supposed to. Never throws (resolveEpicModel's own contract).
566
568
  ipcMain.handle('agents:resolve-epic-model', validated(schemas.agentsResolveEpicModel, (payload) =>
567
569
  agentModelResolve.resolveEpicModel(payload)));
570
+ // Effort twin of the above (agentEffortResolve.cjs): Terminal appends
571
+ // `--effort <level>` only when `effort` is non-null. Never throws.
572
+ ipcMain.handle('agents:resolve-epic-effort', validated(schemas.agentsResolveEpicEffort, (payload) =>
573
+ agentEffortResolve.resolveEpicEffort(payload)));
568
574
 
569
575
  // "What will this Epic actually run as?" (PRD: effective-model-resolver) —
570
576
  // persona alias + evidence-based concrete model id + provenance, for a given
@@ -572,6 +578,10 @@ ipcMain.handle('agents:resolve-epic-model', validated(schemas.agentsResolveEpicM
572
578
  ipcMain.handle('agents:resolve-model-info', validated(schemas.agentsResolveModelInfo, (payload) =>
573
579
  resolveEffectiveModelInfo(payload)));
574
580
 
581
+ // Live model/effort catalog (modelCatalog.cjs) — never throws; `force` bypasses its cache.
582
+ ipcMain.handle('models:catalog', validated(schemas.modelsCatalog, (payload) =>
583
+ resolveModelCatalog(payload)));
584
+
575
585
  // "Can this project actually delegate?" probe (PRD: delegation-readiness).
576
586
  // Structured data over the preconditions for scheduler_create_prd being in an
577
587
  // agent's tool list at all — surfacing it in the UI is a sibling PRD.
@@ -198,6 +198,9 @@ const promptSessionsCreateEpic = z.object({
198
198
  goalText: z.string().min(1).max(20000),
199
199
  tag: EpicTagSchema.optional(),
200
200
  agentType: z.string().min(1).max(256).optional(),
201
+ // Per-Epic runtime overrides (New Session card) — absent = use the persona's own value.
202
+ model: z.string().min(1).max(256).optional(),
203
+ effort: z.string().min(1).max(64).optional(),
201
204
  source: EpicSourceSchema.optional(),
202
205
  // The full first-prompt body + its labeled sections (epicIntake.ts's
203
206
  // composeEpicIntake) — both optional, since not every mint caller (e.g. a
@@ -661,6 +664,12 @@ const agentsResolveModelInfo = z.object({
661
664
  agentType: z.string().min(1).max(200),
662
665
  }).strict();
663
666
 
667
+ // ──────────────────────────────────────────── Live model/effort catalog (modelCatalog.cjs)
668
+ const modelsCatalog = z.object({
669
+ cwd: z.string().optional(),
670
+ force: z.boolean().optional(),
671
+ }).strict();
672
+
664
673
  // ──────────────────────────────────────────── Epic launch model (PRD: unify-epic-model-resolution)
665
674
  // Terminal's (EpicTerminalPane.tsx) call into agentModelResolve.cjs's
666
675
  // resolveEpicModel — the same launch-model authority chatRunner.cjs's Chat
@@ -669,6 +678,7 @@ const agentsResolveEpicModel = z.object({
669
678
  cwd: z.string().min(1).max(4096),
670
679
  claudeSessionId: z.string().min(1).max(200),
671
680
  }).strict();
681
+ const agentsResolveEpicEffort = agentsResolveEpicModel;
672
682
 
673
683
  // ──────────────────────────────────────────── Project Pages (PRD 932)
674
684
  // Same validation split as projectBriefCwd above: real path validation is
@@ -1146,7 +1156,9 @@ module.exports = {
1146
1156
  agentsSavePersona,
1147
1157
  agentsGetPersonaBody,
1148
1158
  agentsResolveModelInfo,
1159
+ modelsCatalog,
1149
1160
  agentsResolveEpicModel,
1161
+ agentsResolveEpicEffort,
1150
1162
  delegationReadinessCwd,
1151
1163
  telemetrySetConfig,
1152
1164
  },