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.
- package/dist/assets/DataModel-B0LDnnSL.js +1 -0
- package/dist/assets/{History-BOR_fJNr.js → History-C685Kytt.js} +2 -2
- package/dist/assets/{Hooks-BtD436iL.js → Hooks-CwLnp0Z_.js} +3 -3
- package/dist/assets/HostBilko-CwEHEKYk.js +1 -0
- package/dist/assets/{Library-DeKdY-tD.js → Library-BU9np05E.js} +25 -25
- package/dist/assets/{MarkdownEditor-CbzC-Oqf.js → MarkdownEditor-B74ogK2f.js} +1 -1
- package/dist/assets/McpServers-BLM3ftzP.js +2 -0
- package/dist/assets/{Memory-B68AJjg2.js → Memory-DAP1t8bA.js} +6 -6
- package/dist/assets/{Permissions-Cr0RRvNy.js → Permissions-3pGLJmxQ.js} +3 -3
- package/dist/assets/Plugins-Q1KoLEzn.js +2 -0
- package/dist/assets/ProvenanceBadge-BnjBQiV9.js +1 -0
- package/dist/assets/{SaveBar-87ZJfscX.js → SaveBar-Zq2NfcPf.js} +1 -1
- package/dist/assets/Scheduler-D488ebKm.js +16 -0
- package/dist/assets/{ScopeSwitcher-CKzpqjJ_.js → ScopeSwitcher-Cnojv22D.js} +1 -1
- package/dist/assets/Settings-D5Lhj7ga.js +3 -0
- package/dist/assets/{SkillReferenceGraph-D_-wg4r9.js → SkillReferenceGraph-BRDiE6Hv.js} +2 -2
- package/dist/assets/Skills-BQDqp-EN.js +3 -0
- package/dist/assets/SystemPrompt-tFr19Od6.js +1 -0
- package/dist/assets/TagLibrary-BjyrnaRE.js +1 -0
- package/dist/assets/{TiptapBody-DYXIp8aG.js → TiptapBody-DGzSg3BO.js} +1 -1
- package/dist/assets/{Toggle-CDXR5l3m.js → Toggle-DzoROibf.js} +1 -1
- package/dist/assets/index-CCl4tz-u.js +3079 -0
- package/dist/assets/{index-D3P_jldk.css → index-CKH5Uxik.css} +1 -1
- package/dist/assets/settingsSchema-BYKVe1WM.js +1 -0
- package/dist/index.html +2 -2
- package/package.json +6 -3
- package/scripts/README.md +3 -0
- package/src/main/__tests__/agentEffortResolve.test.cjs +117 -0
- package/src/main/__tests__/agentLibrary.test.cjs +21 -0
- package/src/main/__tests__/agentModelResolve.test.cjs +16 -0
- package/src/main/__tests__/agentOverlayWrite.test.cjs +95 -0
- package/src/main/__tests__/chat-cancel-terminal.test.cjs +5 -2
- package/src/main/__tests__/chat-exit-close-race.test.cjs +8 -2
- package/src/main/__tests__/chat-mcp-consent-notice.test.cjs +5 -2
- package/src/main/__tests__/chatRunner-session-flag-retry.test.cjs +44 -0
- package/src/main/__tests__/intradayRefresh.test.cjs +39 -0
- package/src/main/__tests__/openExternalApp-spawn-error.test.cjs +25 -0
- package/src/main/__tests__/opsErrorLog.test.cjs +22 -0
- package/src/main/__tests__/personaMerge.test.cjs +169 -0
- package/src/main/__tests__/prdCreatePlanId.test.cjs +132 -0
- package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +13 -0
- package/src/main/__tests__/pty-epic-worktree-spawn-cwd.test.cjs +21 -0
- package/src/main/__tests__/rateLimitPollerStreak.test.cjs +14 -0
- package/src/main/__tests__/runVerify-atomic-verdicts.test.cjs +26 -0
- package/src/main/__tests__/runVerify-landed-commit-outranks.test.cjs +11 -1
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +11 -1
- package/src/main/__tests__/runVerify.test.cjs +12 -1
- package/src/main/__tests__/scheduler-prd-persona-spawn.test.cjs +47 -2
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +112 -1
- package/src/main/agentLibrary.cjs +86 -40
- package/src/main/build-info.json +4 -4
- package/src/main/chatRunner.cjs +50 -10
- package/src/main/git.cjs +9 -2
- package/src/main/historyAggregator.cjs +2 -19
- package/src/main/index.cjs +17 -8
- package/src/main/ipcSchemas.cjs +25 -3
- package/src/main/lib/__tests__/childWithLog.test.cjs +180 -0
- package/src/main/lib/__tests__/delegationReadiness.test.cjs +19 -0
- package/src/main/lib/__tests__/effectiveModelInfo.test.cjs +10 -5
- package/src/main/lib/__tests__/gitCacheBound.test.cjs +69 -0
- package/src/main/lib/__tests__/modelCatalog.test.cjs +202 -0
- package/src/main/lib/agentEffortResolve.cjs +74 -0
- package/src/main/lib/agentModelResolve.cjs +52 -59
- package/src/main/lib/agentPersonaSchema.cjs +5 -0
- package/src/main/lib/childWithLog.cjs +92 -51
- package/src/main/lib/delegationReadiness.cjs +3 -1
- package/src/main/lib/effectiveModelInfo.cjs +38 -20
- package/src/main/lib/epicMint.cjs +5 -2
- package/src/main/lib/epicSpawnPlan.cjs +27 -8
- package/src/main/lib/epicTranscriptPath.cjs +5 -1
- package/src/main/lib/epicWorktreeMerge.cjs +7 -6
- package/src/main/lib/headTailBuffer.cjs +43 -0
- package/src/main/lib/intradayRefresh.cjs +33 -0
- package/src/main/lib/lruCache.cjs +39 -0
- package/src/main/lib/modelCatalog.cjs +243 -0
- package/src/main/lib/openExternalApp.cjs +27 -9
- package/src/main/lib/opsErrorLog.cjs +22 -0
- package/src/main/lib/personaMerge.cjs +166 -0
- package/src/main/lib/prdCreate.cjs +23 -5
- package/src/main/lib/prdDisposition.cjs +46 -0
- package/src/main/lib/prdFrontmatter.cjs +6 -2
- package/src/main/lib/promptSessionSchema.cjs +5 -0
- package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
- package/src/main/lib/rendererRecovery.cjs +141 -0
- package/src/main/lib/scheduleJobSchema.cjs +3 -0
- package/src/main/runVerify.cjs +3 -1
- package/src/main/scheduler/prdParser.cjs +2 -0
- package/src/main/scheduler.cjs +381 -262
- package/src/main/templates/PRD_AUTHORING.md +4 -0
- package/src/main/transcripts.cjs +54 -11
- package/src/preload/api.d.ts +56 -3
- package/src/preload/index.cjs +4 -0
- package/dist/assets/AgentLibrary-Ci6u03bq.js +0 -3
- package/dist/assets/DataModel-BIqcjsv2.js +0 -1
- package/dist/assets/HostBilko-CMe5cH3H.js +0 -1
- package/dist/assets/ListDetail-CtVjrHKB.js +0 -1
- package/dist/assets/McpServers-DKN75Shn.js +0 -2
- package/dist/assets/Panel-C2YTW-qP.js +0 -1
- package/dist/assets/Plugins-DbZkJs5l.js +0 -2
- package/dist/assets/ProvenanceBadge-kJ__BOQv.js +0 -1
- package/dist/assets/Scheduler-DdEaxku8.js +0 -16
- package/dist/assets/Settings-CjywVsNB.js +0 -3
- package/dist/assets/Skills-Cd_yPO_l.js +0 -3
- package/dist/assets/SystemPrompt-2z6eGPSF.js +0 -1
- package/dist/assets/TagLibrary-DcaWQSp4.js +0 -1
- package/dist/assets/index-D5H_H5wC.js +0 -3074
- 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('
|
|
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
|
|
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
|
|
80
|
+
if (session && session.claudeSessionId === claudeSessionId) return session;
|
|
77
81
|
}
|
|
78
82
|
return null;
|
|
79
83
|
}
|
|
80
84
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
114
|
-
*
|
|
115
|
-
* (
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
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
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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
|
-
|
|
130
|
-
|
|
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
|
|
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
|
-
|
|
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);
|