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
|
@@ -259,3 +259,24 @@ test('spawn() still refuses session_unreachable without any node-pty spawn', ()
|
|
|
259
259
|
expect(result.pid).toBeNull();
|
|
260
260
|
expect(result.error).toMatch(/cannot be resumed/);
|
|
261
261
|
});
|
|
262
|
+
|
|
263
|
+
test('planEpicSpawn with a REAL on-disk index: project-root caller resumes, vanished-worktree caller is refused spawn_cwd_missing', () => {
|
|
264
|
+
const { planEpicSpawn, __resetForTests } = require('../lib/epicSpawnPlan.cjs');
|
|
265
|
+
__resetForTests();
|
|
266
|
+
const projectRoot = fs.mkdtempSync(path.join(tmpHome, 'sm-plan-root-'));
|
|
267
|
+
const deadWt = path.join(os.tmpdir(), 'sm-plan-dead-worktree-xyz');
|
|
268
|
+
const sessionId = 'epic-session-root-probe';
|
|
269
|
+
writeActiveIndexWithWorktree(projectRoot, { epicId: 'epic-rp', claudeSessionId: sessionId, worktreeDir: deadWt });
|
|
270
|
+
const tdir = path.join(tmpHome, '.claude', 'projects', projectRoot.replace(/[^a-zA-Z0-9]/g, '-'));
|
|
271
|
+
fs.mkdirSync(tdir, { recursive: true });
|
|
272
|
+
fs.writeFileSync(path.join(tdir, `${sessionId}.jsonl`), '{"type":"user"}\n');
|
|
273
|
+
const deps = { homeDir: tmpHome, restoreWorktree: () => null };
|
|
274
|
+
|
|
275
|
+
expect(planEpicSpawn({ cwd: projectRoot, claudeSessionId: sessionId, deps }))
|
|
276
|
+
.toMatchObject({ ok: true, execCwd: projectRoot, useResume: true });
|
|
277
|
+
__resetForTests();
|
|
278
|
+
const refused = planEpicSpawn({ cwd: deadWt, claudeSessionId: sessionId, deps });
|
|
279
|
+
expect(refused).toMatchObject({ ok: false, code: 'spawn_cwd_missing' });
|
|
280
|
+
expect(refused.message).toContain(deadWt);
|
|
281
|
+
expect(refused.message).toMatch(/project cwd/);
|
|
282
|
+
});
|
|
@@ -20,6 +20,8 @@ const {
|
|
|
20
20
|
nextBackoffMs,
|
|
21
21
|
shouldWarnFailureStreak,
|
|
22
22
|
shouldEscalateFailureStreak,
|
|
23
|
+
restoreFailureStreakWarnedAt,
|
|
24
|
+
persistedStreakMinutes,
|
|
23
25
|
BACKOFF_MAX_MS,
|
|
24
26
|
FAILURE_STREAK_WARN_THRESHOLD,
|
|
25
27
|
FAILURE_STREAK_ESCALATION_MS,
|
|
@@ -112,3 +114,15 @@ test('shouldEscalateFailureStreak: re-arms after a streak clears (lastEscalatedA
|
|
|
112
114
|
const laterStreakNow = firstStreakNow + 5 * 60_000; // well under 30 min later
|
|
113
115
|
expect(shouldEscalateFailureStreak(FAILURE_STREAK_WARN_THRESHOLD, null, laterStreakNow)).toBe(true);
|
|
114
116
|
});
|
|
117
|
+
|
|
118
|
+
test('restoreFailureStreakWarnedAt backfills now when warned=true has no numeric timestamp', () => {
|
|
119
|
+
expect(restoreFailureStreakWarnedAt(true, null, 5000)).toBe(5000);
|
|
120
|
+
expect(restoreFailureStreakWarnedAt(true, 1000, 5000)).toBe(1000);
|
|
121
|
+
expect(restoreFailureStreakWarnedAt(false, null, 5000)).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('persistedStreakMinutes is always a number, never null', () => {
|
|
125
|
+
expect(persistedStreakMinutes(null, 5000)).toBe(0);
|
|
126
|
+
expect(persistedStreakMinutes(0, 120_000)).toBe(2);
|
|
127
|
+
expect(persistedStreakMinutes(restoreFailureStreakWarnedAt(true, undefined, 5000), 5000)).toBe(0);
|
|
128
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { test, expect, vi } from 'vitest';
|
|
2
|
+
const fs = require('node:fs');
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const src = fs.readFileSync(path.join(__dirname, '..', 'runVerify.cjs'), 'utf8');
|
|
5
|
+
|
|
6
|
+
test('verdicts sidecar write goes through config.writeJsonSync, not bare writeFileSync', () => {
|
|
7
|
+
expect(src).not.toMatch(/writeFileSync\(verdictsPath/);
|
|
8
|
+
expect(src).toMatch(/config\.cjs'\)\.writeJsonSync\(verdictsPath/);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
test('conclude() invokes the atomic helper at runtime', async () => {
|
|
12
|
+
const config = require('../config.cjs');
|
|
13
|
+
const spy = vi.spyOn(config, 'writeJsonSync').mockReturnValue({ ok: true, mtimeMs: 0 });
|
|
14
|
+
const { verifyRun } = require('../runVerify.cjs');
|
|
15
|
+
const os = require('node:os');
|
|
16
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rv-atomic-'));
|
|
17
|
+
try {
|
|
18
|
+
try {
|
|
19
|
+
await verifyRun({ queueEntry: { slug: 's' }, runDir: dir, prdPath: path.join(dir, 'none.md'), exitCode: 0 });
|
|
20
|
+
} catch { /* signature drift is fine; assert only on the spy */ }
|
|
21
|
+
expect(spy.mock.calls.some(([p]) => p === path.join(dir, 's.verdicts.json'))).toBe(true);
|
|
22
|
+
} finally {
|
|
23
|
+
spy.mockRestore();
|
|
24
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
* Run standalone: timeout 120 npx vitest run src/main/__tests__/runVerify-landed-commit-outranks.test.cjs
|
|
33
33
|
*/
|
|
34
34
|
|
|
35
|
-
import { test } from 'vitest';
|
|
35
|
+
import { test, beforeAll, afterAll, vi } from 'vitest';
|
|
36
36
|
const assert = require('node:assert/strict');
|
|
37
37
|
const os = require('node:os');
|
|
38
38
|
const fs = require('node:fs');
|
|
@@ -40,6 +40,16 @@ const path = require('node:path');
|
|
|
40
40
|
const { execFileSync } = require('node:child_process');
|
|
41
41
|
const { verifyRun } = require('../runVerify.cjs');
|
|
42
42
|
|
|
43
|
+
// Sidecar goes through config.writeJsonSync, whose allow-list rejects os.tmpdir() fixtures.
|
|
44
|
+
let writeSpy;
|
|
45
|
+
beforeAll(() => {
|
|
46
|
+
writeSpy = vi.spyOn(require('../config.cjs'), 'writeJsonSync').mockImplementation((abs, data) => {
|
|
47
|
+
require('node:fs').writeFileSync(abs, JSON.stringify(data, null, 2) + '\n');
|
|
48
|
+
return { ok: true, mtimeMs: 0 };
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
afterAll(() => writeSpy.mockRestore());
|
|
52
|
+
|
|
43
53
|
const SLUG = '1218-fo-01-move-scripts-lib-into-src-main-lib';
|
|
44
54
|
const FIXTURE_PATH = path.join(__dirname, 'fixtures', `${SLUG}.log`);
|
|
45
55
|
const LANDED_COMMIT = 'd1edf15be50a783f08a55e6de328780f0feb3cb0';
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* Run standalone: timeout 120 npx vitest run src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { test } from 'vitest';
|
|
17
|
+
import { test, beforeAll, afterAll, vi } from 'vitest';
|
|
18
18
|
const assert = require('node:assert/strict');
|
|
19
19
|
const os = require('node:os');
|
|
20
20
|
const fs = require('node:fs');
|
|
@@ -22,6 +22,16 @@ const path = require('node:path');
|
|
|
22
22
|
const { execFileSync } = require('node:child_process');
|
|
23
23
|
const { verifyRun } = require('../runVerify.cjs');
|
|
24
24
|
|
|
25
|
+
// Sidecar goes through config.writeJsonSync, whose allow-list rejects os.tmpdir() fixtures.
|
|
26
|
+
let writeSpy;
|
|
27
|
+
beforeAll(() => {
|
|
28
|
+
writeSpy = vi.spyOn(require('../config.cjs'), 'writeJsonSync').mockImplementation((abs, data) => {
|
|
29
|
+
require('node:fs').writeFileSync(abs, JSON.stringify(data, null, 2) + '\n');
|
|
30
|
+
return { ok: true, mtimeMs: 0 };
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
afterAll(() => writeSpy.mockRestore());
|
|
34
|
+
|
|
25
35
|
// ─── helpers (mirrors runVerify.test.cjs) ─────────────────────────────────────
|
|
26
36
|
|
|
27
37
|
function makeTmpDir() {
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
* Expected: clean → null (caller may stamp 'completed')
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import { test } from 'vitest';
|
|
22
|
+
import { test, beforeAll, afterAll, vi } from 'vitest';
|
|
23
23
|
const assert = require('node:assert/strict');
|
|
24
24
|
const os = require('node:os');
|
|
25
25
|
const fs = require('node:fs');
|
|
@@ -27,6 +27,17 @@ const path = require('node:path');
|
|
|
27
27
|
const { execFileSync } = require('node:child_process');
|
|
28
28
|
const { verifyRun, parseLog } = require('../runVerify.cjs');
|
|
29
29
|
|
|
30
|
+
// The verdicts sidecar goes through config.writeJsonSync, whose write allow-list
|
|
31
|
+
// rejects os.tmpdir() fixtures. Route it to a plain write for these fixtures.
|
|
32
|
+
let writeSpy;
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
writeSpy = vi.spyOn(require('../config.cjs'), 'writeJsonSync').mockImplementation((abs, data) => {
|
|
35
|
+
fs.writeFileSync(abs, JSON.stringify(data, null, 2) + '\n');
|
|
36
|
+
return { ok: true, mtimeMs: 0 };
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
afterAll(() => writeSpy.mockRestore());
|
|
40
|
+
|
|
30
41
|
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
31
42
|
|
|
32
43
|
/** Create a temp directory that is cleaned up after the test. */
|
|
@@ -82,8 +82,10 @@ function writePersona(dir, name, frontmatterLines, bodyText) {
|
|
|
82
82
|
fs.writeFileSync(path.join(dir, `${name}.md`), lines.join('\n'));
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
async function runJobWithAgentType({ agentType }) {
|
|
86
|
-
|
|
85
|
+
async function runJobWithAgentType({ agentType, overlay }) {
|
|
86
|
+
// Under tmpHome so config.validatePath's home-dir boundary admits the project overlay.
|
|
87
|
+
const mainCwd = fs.mkdtempSync(path.join(tmpHome, 'sm-prd-persona-main-'));
|
|
88
|
+
if (overlay) writePersona(path.join(mainCwd, '.claude', 'agents'), overlay.name, overlay.frontmatter, overlay.body || '');
|
|
87
89
|
fs.mkdirSync(path.join(tmpHome, '.claude'), { recursive: true });
|
|
88
90
|
const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-persona-run-'));
|
|
89
91
|
const slug = `1115-test-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
@@ -131,3 +133,46 @@ test('executeJob: a job with no agentType at all runs unaffected (no persona, so
|
|
|
131
133
|
const modelIdx = argv.indexOf('--model');
|
|
132
134
|
expect(argv[modelIdx + 1]).toBe('sonnet');
|
|
133
135
|
});
|
|
136
|
+
|
|
137
|
+
// ---------- persona effort → --effort ----------
|
|
138
|
+
|
|
139
|
+
test('buildClaudeSpawnArgs: effort appends --effort <level>; null/inherit/absent add no token', () => {
|
|
140
|
+
const base = { prompt: 'x', model: 'sonnet', sessionId: 'sid', resume: false };
|
|
141
|
+
const args = buildClaudeSpawnArgs({ ...base, effort: 'high' });
|
|
142
|
+
expect(args[args.indexOf('--effort') + 1]).toBe('high');
|
|
143
|
+
expect(args.indexOf('--model')).toBeGreaterThanOrEqual(0);
|
|
144
|
+
for (const effort of [null, 'inherit', undefined]) {
|
|
145
|
+
expect(buildClaudeSpawnArgs({ ...base, effort })).not.toContain('--effort');
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test('executeJob: persona effort reaches the child as --effort; inherit yields no --effort token', async () => {
|
|
150
|
+
const globalAgentsDir = path.join(tmpHome, '.claude', 'agents');
|
|
151
|
+
writePersona(globalAgentsDir, 'effort-high', ['model: opus', 'effort: high'], 'body');
|
|
152
|
+
writePersona(globalAgentsDir, 'effort-inherit', ['model: opus', 'effort: inherit'], 'body');
|
|
153
|
+
|
|
154
|
+
const high = await runJobWithAgentType({ agentType: 'effort-high' });
|
|
155
|
+
expect(high.argv[high.argv.indexOf('--effort') + 1]).toBe('high');
|
|
156
|
+
expect(high.argv[high.argv.indexOf('--model') + 1]).toBe('opus');
|
|
157
|
+
|
|
158
|
+
const inherit = await runJobWithAgentType({ agentType: 'effort-inherit' });
|
|
159
|
+
expect(inherit.argv).not.toContain('--effort');
|
|
160
|
+
expect(inherit.argv).not.toContain('inherit');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ---------- project overlay MERGE: frontmatter-only overlay patches the runtime fields ----------
|
|
164
|
+
|
|
165
|
+
test('executeJob: a frontmatter-only project overlay changes --model/--effort while the global persona body still reaches --append-system-prompt', async () => {
|
|
166
|
+
const globalAgentsDir = path.join(tmpHome, '.claude', 'agents');
|
|
167
|
+
writePersona(globalAgentsDir, 'dev-lead', ['model: sonnet'], 'You are dev-lead. Global body.');
|
|
168
|
+
|
|
169
|
+
const { result, argv } = await runJobWithAgentType({
|
|
170
|
+
agentType: 'dev-lead',
|
|
171
|
+
overlay: { name: 'dev-lead', frontmatter: ['model: claude-opus-4-6', 'effort: high'], body: '' },
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
expect(result.exitCode).toBe(0);
|
|
175
|
+
expect(argv[argv.indexOf('--model') + 1]).toBe('claude-opus-4-6');
|
|
176
|
+
expect(argv[argv.indexOf('--effort') + 1]).toBe('high');
|
|
177
|
+
expect(argv[argv.indexOf('--append-system-prompt') + 1]).toBe('You are dev-lead. Global body.');
|
|
178
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
'use strict';
|
|
10
10
|
|
|
11
|
-
import { test, expect, beforeEach, afterEach } from 'vitest';
|
|
11
|
+
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
12
12
|
|
|
13
13
|
const fs = require('node:fs');
|
|
14
14
|
const os = require('node:os');
|
|
@@ -93,3 +93,114 @@ test('a growing transcript is re-summed after the file mtime/size change', async
|
|
|
93
93
|
const after = await usageFor(cwd, [sessionId]);
|
|
94
94
|
expect(after[sessionId]).toEqual({ inputTokens: 13, outputTokens: 6 });
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
// ── incremental tail parse + bounded cache ─────────────────────────────────
|
|
98
|
+
|
|
99
|
+
const fsp = require('node:fs/promises');
|
|
100
|
+
const transcripts = require('../transcripts.cjs');
|
|
101
|
+
|
|
102
|
+
function fromScratch(filePath) {
|
|
103
|
+
let inputTokens = 0;
|
|
104
|
+
let outputTokens = 0;
|
|
105
|
+
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
|
106
|
+
if (!line) continue;
|
|
107
|
+
let o;
|
|
108
|
+
try { o = JSON.parse(line); } catch { continue; }
|
|
109
|
+
const u = o.usage || {};
|
|
110
|
+
inputTokens += u.input_tokens ?? 0;
|
|
111
|
+
outputTokens += u.output_tokens ?? 0;
|
|
112
|
+
}
|
|
113
|
+
return { inputTokens, outputTokens };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
test('incremental result equals a from-scratch parse across 5 appends (partial line + repeated id)', async () => {
|
|
117
|
+
const sessionId = 'sess-incr';
|
|
118
|
+
const filePath = writeTranscript(cwd, sessionId, [
|
|
119
|
+
{ id: 'm1', usage: { input_tokens: 10, output_tokens: 1 } },
|
|
120
|
+
]);
|
|
121
|
+
const check = async () => {
|
|
122
|
+
const r = await usageFor(cwd, [sessionId]);
|
|
123
|
+
expect(r[sessionId]).toEqual(fromScratch(filePath));
|
|
124
|
+
};
|
|
125
|
+
await check();
|
|
126
|
+
const l2 = JSON.stringify({ id: 'm2', usage: { input_tokens: 20, output_tokens: 2 } });
|
|
127
|
+
// 1: append ending mid-line
|
|
128
|
+
fs.appendFileSync(filePath, l2.slice(0, 15));
|
|
129
|
+
await check();
|
|
130
|
+
// 2: complete that line
|
|
131
|
+
fs.appendFileSync(filePath, l2.slice(15) + '\n');
|
|
132
|
+
await check();
|
|
133
|
+
// 3: repeat an earlier message id (full parse sums it again)
|
|
134
|
+
fs.appendFileSync(filePath, JSON.stringify({ id: 'm1', usage: { input_tokens: 10, output_tokens: 1 } }) + '\n');
|
|
135
|
+
await check();
|
|
136
|
+
// 4: non-usage + malformed line
|
|
137
|
+
fs.appendFileSync(filePath, JSON.stringify({ type: 'user' }) + '\nnot json\n');
|
|
138
|
+
await check();
|
|
139
|
+
// 5: multibyte content then usage
|
|
140
|
+
fs.appendFileSync(filePath, JSON.stringify({ t: 'héllo 日本', usage: { input_tokens: 4, output_tokens: 4 } }) + '\n');
|
|
141
|
+
await check();
|
|
142
|
+
expect((await usageFor(cwd, [sessionId]))[sessionId]).toEqual({ inputTokens: 44, outputTokens: 8 });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('the call after a one-line append reads < 4 KB from disk', async () => {
|
|
146
|
+
const sessionId = 'sess-bytes';
|
|
147
|
+
const big = Array.from({ length: 2000 }, (_, i) => ({ i, pad: 'x'.repeat(100), usage: { input_tokens: 1, output_tokens: 1 } }));
|
|
148
|
+
const filePath = writeTranscript(cwd, sessionId, big);
|
|
149
|
+
await usageFor(cwd, [sessionId]);
|
|
150
|
+
expect(fs.statSync(filePath).size).toBeGreaterThan(200_000);
|
|
151
|
+
|
|
152
|
+
fs.appendFileSync(filePath, JSON.stringify({ usage: { input_tokens: 5, output_tokens: 5 } }) + '\n');
|
|
153
|
+
const fh = await fsp.open(filePath, 'r');
|
|
154
|
+
const proto = Object.getPrototypeOf(fh);
|
|
155
|
+
await fh.close();
|
|
156
|
+
const orig = proto.read;
|
|
157
|
+
const lengths = [];
|
|
158
|
+
const spy = vi.spyOn(proto, 'read').mockImplementation(function (buf, off, len, pos) {
|
|
159
|
+
lengths.push(len);
|
|
160
|
+
return orig.call(this, buf, off, len, pos);
|
|
161
|
+
});
|
|
162
|
+
try {
|
|
163
|
+
const r = await usageFor(cwd, [sessionId]);
|
|
164
|
+
expect(r[sessionId]).toEqual({ inputTokens: 2005, outputTokens: 2005 });
|
|
165
|
+
} finally {
|
|
166
|
+
spy.mockRestore();
|
|
167
|
+
}
|
|
168
|
+
expect(lengths.length).toBeGreaterThan(0);
|
|
169
|
+
expect(lengths.reduce((a, b) => a + b, 0)).toBeLessThan(4096);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('truncation / replacement triggers a full re-parse', async () => {
|
|
173
|
+
const sessionId = 'sess-trunc';
|
|
174
|
+
const filePath = writeTranscript(cwd, sessionId, [
|
|
175
|
+
{ usage: { input_tokens: 100, output_tokens: 100 } },
|
|
176
|
+
{ usage: { input_tokens: 100, output_tokens: 100 } },
|
|
177
|
+
]);
|
|
178
|
+
await usageFor(cwd, [sessionId]);
|
|
179
|
+
fs.writeFileSync(filePath, JSON.stringify({ usage: { input_tokens: 1, output_tokens: 2 } }) + '\n');
|
|
180
|
+
expect((await usageFor(cwd, [sessionId]))[sessionId]).toEqual({ inputTokens: 1, outputTokens: 2 });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('usageCache is bounded and evicts the least-recently-used entry', async () => {
|
|
184
|
+
const cache = transcripts.__usageCacheForTest;
|
|
185
|
+
const max = transcripts.__usageCacheMaxForTest;
|
|
186
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-usage-lru-'));
|
|
187
|
+
try {
|
|
188
|
+
const files = [];
|
|
189
|
+
for (let i = 0; i < max + 5; i++) {
|
|
190
|
+
const f = path.join(dir, `s${i}.jsonl`);
|
|
191
|
+
fs.writeFileSync(f, JSON.stringify({ usage: { input_tokens: 1, output_tokens: 1 } }) + '\n');
|
|
192
|
+
files.push(f);
|
|
193
|
+
}
|
|
194
|
+
const before = cache.size;
|
|
195
|
+
for (const f of files) {
|
|
196
|
+
// usageForOne is reached through usageFor's path; call via a cwd-independent route.
|
|
197
|
+
await transcripts.__usageForOneForTest(f);
|
|
198
|
+
}
|
|
199
|
+
expect(cache.size).toBeLessThanOrEqual(max);
|
|
200
|
+
expect(before).toBeLessThanOrEqual(max);
|
|
201
|
+
expect(cache.has(files[0])).toBe(false);
|
|
202
|
+
expect(cache.has(files[files.length - 1])).toBe(true);
|
|
203
|
+
} finally {
|
|
204
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
@@ -22,7 +22,7 @@ const fsSync = require('node:fs');
|
|
|
22
22
|
const path = require('node:path');
|
|
23
23
|
const os = require('node:os');
|
|
24
24
|
const { splitFrontmatter } = require('./lib/prdFrontmatter.cjs');
|
|
25
|
-
const {
|
|
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))
|
|
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
|
|
255
|
-
*
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
* `
|
|
259
|
-
*
|
|
260
|
-
*
|
|
261
|
-
*
|
|
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
|
-
//
|
|
269
|
-
//
|
|
270
|
-
|
|
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 = {
|
package/src/main/build-info.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
|
-
"version": "0.
|
|
3
|
-
"gitSha": "
|
|
4
|
-
"gitShortSha": "
|
|
2
|
+
"version": "0.94.0",
|
|
3
|
+
"gitSha": "b5777198add78466da6d34b83d818bb1352f999a",
|
|
4
|
+
"gitShortSha": "b577719",
|
|
5
5
|
"gitBranch": "HEAD",
|
|
6
|
-
"builtAt": "2026-09-
|
|
6
|
+
"builtAt": "2026-09-21T19:59:46.626Z",
|
|
7
7
|
"dirty": false
|
|
8
8
|
}
|