claude-code-session-manager 0.62.0 → 0.63.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/AgentLibrary-BeJJa_zv.js +1 -0
- package/dist/assets/History-mWbqemhZ.js +2 -0
- package/dist/assets/Hooks-D_8sciZu.js +3 -0
- package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
- package/dist/assets/Library-CG1LDXLw.js +46 -0
- package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
- package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
- package/dist/assets/McpServers-Kc-djiVi.js +2 -0
- package/dist/assets/Memory-B60DGRTW.js +8 -0
- package/dist/assets/Panel-CbTPsYHq.js +1 -0
- package/dist/assets/Permissions-C8KrSfrd.js +3 -0
- package/dist/assets/Plugins-BT8EumHb.js +2 -0
- package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
- package/dist/assets/Scheduler-DbKPd0or.js +14 -0
- package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
- package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
- package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
- package/dist/assets/Skills-CVL_4X3T.js +3 -0
- package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
- package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
- package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-DaS0M_Ni.js} +1 -1
- package/dist/assets/Toggle-C0a-6xV7.js +1 -0
- package/dist/assets/index-CO_7DroC.js +3066 -0
- package/dist/assets/{index-DOXvph-Z.css → index-LlWpj2VJ.css} +1 -1
- package/dist/assets/listSkills-QORduPIk.js +1 -0
- package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
- package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
- package/dist/index.html +2 -2
- package/package.json +1 -3
- package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
- package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
- package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
- package/src/main/__tests__/runLogRetention.test.cjs +343 -0
- package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
- package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
- package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
- package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
- package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
- package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
- package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
- package/src/main/config.cjs +32 -29
- package/src/main/files.cjs +20 -0
- package/src/main/health.cjs +32 -0
- package/src/main/heapSnapshot.cjs +122 -0
- package/src/main/historyAggregator.cjs +176 -36
- package/src/main/index.cjs +10 -63
- package/src/main/ipcSchemas.cjs +20 -209
- package/src/main/lib/opsOwnership.cjs +0 -2
- package/src/main/lib/runLogRetention.cjs +358 -0
- package/src/main/lib/summarize.cjs +0 -1
- package/src/main/runVerify.cjs +55 -6
- package/src/main/scheduler.cjs +46 -7
- package/src/main/transcripts.cjs +29 -5
- package/src/preload/api.d.ts +18 -199
- package/src/preload/index.cjs +12 -102
- package/dist/assets/index-BfiMRkln.js +0 -3225
- package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
- package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
- package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
- package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
- package/src/main/browserAgentServer.cjs +0 -224
- package/src/main/browserCapture.cjs +0 -728
- package/src/main/browserView.cjs +0 -916
- package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
- package/src/main/lib/browserAgentActions.cjs +0 -114
- package/src/main/lib/e2eStateMachine.cjs +0 -39
- package/src/main/webRemote.cjs +0 -1367
- package/src/preload/browserViewPreload.cjs +0 -118
|
@@ -23,6 +23,7 @@ const path = require('node:path');
|
|
|
23
23
|
let tmpHome;
|
|
24
24
|
let originalHome;
|
|
25
25
|
let executeJob;
|
|
26
|
+
let spawnJob;
|
|
26
27
|
let PRDS_DIR;
|
|
27
28
|
|
|
28
29
|
beforeAll(() => {
|
|
@@ -30,7 +31,7 @@ beforeAll(() => {
|
|
|
30
31
|
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-skip-'));
|
|
31
32
|
process.env.HOME = tmpHome;
|
|
32
33
|
|
|
33
|
-
({ executeJob, PRDS_DIR } = require('../scheduler.cjs'));
|
|
34
|
+
({ executeJob, spawnJob, PRDS_DIR } = require('../scheduler.cjs'));
|
|
34
35
|
|
|
35
36
|
if (!PRDS_DIR.startsWith(tmpHome)) {
|
|
36
37
|
throw new Error(`refusing to run: PRDS_DIR (${PRDS_DIR}) is not under the temp HOME (${tmpHome})`);
|
|
@@ -87,3 +88,55 @@ test('a job whose PRD source exists but is unreadable for a non-ENOENT reason st
|
|
|
87
88
|
fs.rmSync(runDir, { recursive: true, force: true });
|
|
88
89
|
}
|
|
89
90
|
});
|
|
91
|
+
|
|
92
|
+
function registerActiveProject(cwd) {
|
|
93
|
+
const projectsDir = path.join(tmpHome, '.claude', 'projects');
|
|
94
|
+
const slugDir = path.join(projectsDir, `fake-project-slug-${path.basename(cwd)}`);
|
|
95
|
+
fs.mkdirSync(slugDir, { recursive: true });
|
|
96
|
+
fs.writeFileSync(path.join(slugDir, 'transcript.jsonl'), JSON.stringify({ cwd }) + '\n');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function writeProjectQueue(cwd, jobs) {
|
|
100
|
+
const stateDir = path.join(cwd, 'session-manager-operations', 'scheduler', 'state');
|
|
101
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
102
|
+
fs.writeFileSync(path.join(stateDir, 'queue.json'), JSON.stringify({ jobs }, null, 2));
|
|
103
|
+
return path.join(stateDir, 'queue.json');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// This is the actual regression case: PRD 812's fix only covered executeJob's
|
|
107
|
+
// own return value. The bug lived one layer up, in spawnJob's post-run
|
|
108
|
+
// handler, which only short-circuited on skipped === 'prd-archived' and let
|
|
109
|
+
// 'prd-missing' fall through into the verify path meant for real runs —
|
|
110
|
+
// downgrading a stale-queue-entry retire into a needs_review with a bogus RCA
|
|
111
|
+
// feedback item. Drive spawnJob itself (not just executeJob) so this covers
|
|
112
|
+
// the actual defect, not just the classification it depends on.
|
|
113
|
+
test("spawnJob's post-run handler retires a prd-missing skip as a plain completion, without running verify", async () => {
|
|
114
|
+
const projectCwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-prd-missing-spawnjob-project-'));
|
|
115
|
+
registerActiveProject(projectCwd);
|
|
116
|
+
const runDir = fs.mkdtempSync(path.join(tmpHome, '.claude', 'sm-prd-missing-spawnjob-run-'));
|
|
117
|
+
try {
|
|
118
|
+
const slug = `812-test-spawnjob-missing-${process.pid}-${Math.floor(Math.random() * 1e6)}`;
|
|
119
|
+
const queuePath = writeProjectQueue(projectCwd, [
|
|
120
|
+
{ slug, status: 'pending', cwd: projectCwd },
|
|
121
|
+
]);
|
|
122
|
+
|
|
123
|
+
const job = { slug, cwd: projectCwd };
|
|
124
|
+
await spawnJob(job, 'run-spawnjob-missing', runDir, projectCwd);
|
|
125
|
+
|
|
126
|
+
const state = JSON.parse(fs.readFileSync(queuePath, 'utf8'));
|
|
127
|
+
const row = state.jobs.find((j) => j.slug === slug);
|
|
128
|
+
expect(row).toBeDefined();
|
|
129
|
+
expect(row.status).toBe('completed');
|
|
130
|
+
expect(row.exitCode).toBe(0);
|
|
131
|
+
expect(row.error).toBeNull();
|
|
132
|
+
// No verify pass ran and no RCA feedback fired: a verified run always
|
|
133
|
+
// stamps a verify-derived status ('needs_review') or a landedCommit/verify
|
|
134
|
+
// field onto the row. Neither is present here — the row was completed by
|
|
135
|
+
// the early-return skip branch, not the verification block further down.
|
|
136
|
+
expect(row.status).not.toBe('needs_review');
|
|
137
|
+
expect(row.landedCommit).toBeUndefined();
|
|
138
|
+
} finally {
|
|
139
|
+
fs.rmSync(projectCwd, { recursive: true, force: true });
|
|
140
|
+
fs.rmSync(runDir, { recursive: true, force: true });
|
|
141
|
+
}
|
|
142
|
+
});
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* transcripts-batch-flush.test.cjs — unit tests for the batched IPC flush
|
|
3
|
+
* (PRD transcript-batch-flush): transcripts.cjs's doFlush now sends the
|
|
4
|
+
* events produced by ONE flush as a SINGLE `transcript:event:<tabId>` IPC
|
|
5
|
+
* message (an array), instead of one message per event, while still
|
|
6
|
+
* recording one OTEL span per event and preserving exact event order.
|
|
7
|
+
*
|
|
8
|
+
* Run: timeout 120 npx vitest run src/main/__tests__/transcripts-batch-flush.test.cjs
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
import { test, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
14
|
+
|
|
15
|
+
const fs = require('node:fs');
|
|
16
|
+
const os = require('node:os');
|
|
17
|
+
const path = require('node:path');
|
|
18
|
+
|
|
19
|
+
const {
|
|
20
|
+
subscribe,
|
|
21
|
+
closeTab,
|
|
22
|
+
transcriptPath,
|
|
23
|
+
attachWindow,
|
|
24
|
+
__getSubForTest,
|
|
25
|
+
__doFlushForTest,
|
|
26
|
+
MAX_EVENTS_PER_BATCH,
|
|
27
|
+
} = require('../transcripts.cjs');
|
|
28
|
+
const otel = require('../otel.cjs');
|
|
29
|
+
|
|
30
|
+
let cwd;
|
|
31
|
+
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'sm-batch-flush-test-'));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
fs.rmSync(cwd, { recursive: true, force: true });
|
|
38
|
+
fs.rmSync(path.dirname(transcriptPath(cwd, 'x')), { recursive: true, force: true });
|
|
39
|
+
attachWindow(null);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function writeTranscript(cwdArg, sessionId, lines) {
|
|
43
|
+
const filePath = transcriptPath(cwdArg, sessionId);
|
|
44
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
45
|
+
fs.writeFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
46
|
+
return filePath;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function appendTranscript(filePath, lines) {
|
|
50
|
+
fs.appendFileSync(filePath, lines.map((l) => JSON.stringify(l)).join('\n') + '\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A fake BrowserWindow that records every webContents.send call. */
|
|
54
|
+
function makeFakeWindow() {
|
|
55
|
+
const sent = [];
|
|
56
|
+
return {
|
|
57
|
+
sent,
|
|
58
|
+
isDestroyed: () => false,
|
|
59
|
+
webContents: {
|
|
60
|
+
isDestroyed: () => false,
|
|
61
|
+
isCrashed: () => false,
|
|
62
|
+
send: (channel, payload) => sent.push({ channel, payload }),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function assistantLine(text, toolId) {
|
|
68
|
+
return {
|
|
69
|
+
type: 'assistant',
|
|
70
|
+
message: {
|
|
71
|
+
content: [
|
|
72
|
+
{ type: 'text', text },
|
|
73
|
+
{ type: 'tool_use', name: 'Bash', id: toolId, input: { command: 'ls' } },
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
test('one flush with several multi-event lines sends exactly ONE IPC message carrying the whole ordered batch', async () => {
|
|
80
|
+
const sessionId = 'sess-one-batch';
|
|
81
|
+
const tabId = 'tab-one-batch';
|
|
82
|
+
const filePath = writeTranscript(cwd, sessionId, []);
|
|
83
|
+
|
|
84
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
85
|
+
expect(res.ok).toBe(true);
|
|
86
|
+
|
|
87
|
+
const win = makeFakeWindow();
|
|
88
|
+
attachWindow(win);
|
|
89
|
+
|
|
90
|
+
// 5 lines * 2 events each = 10 events, well under MAX_EVENTS_PER_BATCH.
|
|
91
|
+
appendTranscript(
|
|
92
|
+
filePath,
|
|
93
|
+
Array.from({ length: 5 }, (_, i) => assistantLine(`line ${i}`, `tu-${i}`)),
|
|
94
|
+
);
|
|
95
|
+
const sub = __getSubForTest(tabId);
|
|
96
|
+
await sub.watcher?.close();
|
|
97
|
+
await __doFlushForTest(sub);
|
|
98
|
+
|
|
99
|
+
const messages = win.sent.filter((m) => m.channel === `transcript:event:${tabId}`);
|
|
100
|
+
expect(messages).toHaveLength(1);
|
|
101
|
+
expect(Array.isArray(messages[0].payload)).toBe(true);
|
|
102
|
+
expect(messages[0].payload).toHaveLength(10);
|
|
103
|
+
// Event order preserved: text/tool_use pairs in line order.
|
|
104
|
+
expect(messages[0].payload.map((e) => e.kind)).toEqual([
|
|
105
|
+
'assistant', 'tool_use', 'assistant', 'tool_use', 'assistant', 'tool_use', 'assistant', 'tool_use', 'assistant', 'tool_use',
|
|
106
|
+
]);
|
|
107
|
+
expect(messages[0].payload.map((e) => e.data?.id ?? e.data).filter(Boolean)).toEqual(
|
|
108
|
+
expect.arrayContaining(['tu-0', 'tu-1', 'tu-2', 'tu-3', 'tu-4']),
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
closeTab(tabId);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('order is preserved exactly across a multi-event flush (matches per-event order semantics)', async () => {
|
|
115
|
+
const sessionId = 'sess-order';
|
|
116
|
+
const tabId = 'tab-order';
|
|
117
|
+
const filePath = writeTranscript(cwd, sessionId, []);
|
|
118
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
119
|
+
expect(res.ok).toBe(true);
|
|
120
|
+
|
|
121
|
+
const win = makeFakeWindow();
|
|
122
|
+
attachWindow(win);
|
|
123
|
+
|
|
124
|
+
appendTranscript(filePath, [
|
|
125
|
+
{ type: 'user', message: { content: 'first' } },
|
|
126
|
+
assistantLine('second', 'tu-order-1'),
|
|
127
|
+
{ type: 'user', message: { content: 'third' } },
|
|
128
|
+
]);
|
|
129
|
+
const sub = __getSubForTest(tabId);
|
|
130
|
+
await sub.watcher?.close();
|
|
131
|
+
await __doFlushForTest(sub);
|
|
132
|
+
|
|
133
|
+
const messages = win.sent.filter((m) => m.channel === `transcript:event:${tabId}`);
|
|
134
|
+
expect(messages).toHaveLength(1);
|
|
135
|
+
const kinds = messages[0].payload.map((e) => e.kind);
|
|
136
|
+
const texts = messages[0].payload.map((e) =>
|
|
137
|
+
typeof e.data === 'string' ? e.data : e.data?.id ?? e.data?.message?.content,
|
|
138
|
+
);
|
|
139
|
+
expect(kinds).toEqual(['user', 'assistant', 'tool_use', 'user']);
|
|
140
|
+
expect(texts).toEqual(['first', 'second', 'tu-order-1', 'third']);
|
|
141
|
+
|
|
142
|
+
closeTab(tabId);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('OTEL still records ONE span per event, not one per batch', async () => {
|
|
146
|
+
const sessionId = 'sess-otel-batch';
|
|
147
|
+
const tabId = 'tab-otel-batch';
|
|
148
|
+
const filePath = writeTranscript(cwd, sessionId, []);
|
|
149
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
150
|
+
expect(res.ok).toBe(true);
|
|
151
|
+
|
|
152
|
+
const win = makeFakeWindow();
|
|
153
|
+
attachWindow(win);
|
|
154
|
+
|
|
155
|
+
const spy = vi.spyOn(otel, 'recordTranscriptEvent');
|
|
156
|
+
const before = spy.mock.calls.length;
|
|
157
|
+
|
|
158
|
+
appendTranscript(
|
|
159
|
+
filePath,
|
|
160
|
+
Array.from({ length: 6 }, (_, i) => assistantLine(`otel line ${i}`, `otel-tu-${i}`)),
|
|
161
|
+
);
|
|
162
|
+
const sub = __getSubForTest(tabId);
|
|
163
|
+
await sub.watcher?.close();
|
|
164
|
+
await __doFlushForTest(sub);
|
|
165
|
+
|
|
166
|
+
// 6 lines * 2 events = 12 spans, sent as one IPC batch.
|
|
167
|
+
expect(spy.mock.calls.length - before).toBe(12);
|
|
168
|
+
const messages = win.sent.filter((m) => m.channel === `transcript:event:${tabId}`);
|
|
169
|
+
expect(messages).toHaveLength(1);
|
|
170
|
+
expect(messages[0].payload).toHaveLength(12);
|
|
171
|
+
|
|
172
|
+
spy.mockRestore();
|
|
173
|
+
closeTab(tabId);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('backpressure: a flush producing more than MAX_EVENTS_PER_BATCH events is sent as multiple ordered, capped batches', async () => {
|
|
177
|
+
const sessionId = 'sess-backpressure';
|
|
178
|
+
const tabId = 'tab-backpressure';
|
|
179
|
+
const filePath = writeTranscript(cwd, sessionId, []);
|
|
180
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
181
|
+
expect(res.ok).toBe(true);
|
|
182
|
+
|
|
183
|
+
const win = makeFakeWindow();
|
|
184
|
+
attachWindow(win);
|
|
185
|
+
|
|
186
|
+
// Each line yields 2 events; pick a line count that produces more than
|
|
187
|
+
// 2x the cap so at least 3 batches are required.
|
|
188
|
+
const lineCount = Math.ceil((MAX_EVENTS_PER_BATCH * 2.5) / 2);
|
|
189
|
+
const totalEvents = lineCount * 2;
|
|
190
|
+
appendTranscript(
|
|
191
|
+
filePath,
|
|
192
|
+
Array.from({ length: lineCount }, (_, i) => assistantLine(`bp line ${i}`, `bp-tu-${i}`)),
|
|
193
|
+
);
|
|
194
|
+
const sub = __getSubForTest(tabId);
|
|
195
|
+
await sub.watcher?.close();
|
|
196
|
+
await __doFlushForTest(sub);
|
|
197
|
+
|
|
198
|
+
const messages = win.sent.filter((m) => m.channel === `transcript:event:${tabId}`);
|
|
199
|
+
expect(messages.length).toBeGreaterThan(1);
|
|
200
|
+
for (const m of messages) {
|
|
201
|
+
expect(m.payload.length).toBeLessThanOrEqual(MAX_EVENTS_PER_BATCH);
|
|
202
|
+
}
|
|
203
|
+
// Concatenating every batch in send order reproduces the full ordered
|
|
204
|
+
// event list — no event dropped, none duplicated, none reordered.
|
|
205
|
+
const flattened = messages.flatMap((m) => m.payload);
|
|
206
|
+
expect(flattened).toHaveLength(totalEvents);
|
|
207
|
+
const ids = flattened.map((e) => (e.kind === 'tool_use' ? e.data.id : null)).filter(Boolean);
|
|
208
|
+
expect(ids).toEqual(Array.from({ length: lineCount }, (_, i) => `bp-tu-${i}`));
|
|
209
|
+
|
|
210
|
+
closeTab(tabId);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test('benchmark: IPC message count drops from N-per-event to a small number of batches for a 20+ event flush', async () => {
|
|
214
|
+
const sessionId = 'sess-benchmark';
|
|
215
|
+
const tabId = 'tab-benchmark';
|
|
216
|
+
const filePath = writeTranscript(cwd, sessionId, []);
|
|
217
|
+
const res = await subscribe({ tabId, cwd, sessionUuid: sessionId });
|
|
218
|
+
expect(res.ok).toBe(true);
|
|
219
|
+
|
|
220
|
+
const win = makeFakeWindow();
|
|
221
|
+
attachWindow(win);
|
|
222
|
+
|
|
223
|
+
// 12 lines * 2 events = 24 events — at least 20, comfortably under one cap.
|
|
224
|
+
const lineCount = 12;
|
|
225
|
+
appendTranscript(
|
|
226
|
+
filePath,
|
|
227
|
+
Array.from({ length: lineCount }, (_, i) => assistantLine(`bench line ${i}`, `bench-tu-${i}`)),
|
|
228
|
+
);
|
|
229
|
+
const sub = __getSubForTest(tabId);
|
|
230
|
+
await sub.watcher?.close();
|
|
231
|
+
await __doFlushForTest(sub);
|
|
232
|
+
|
|
233
|
+
const messages = win.sent.filter((m) => m.channel === `transcript:event:${tabId}`);
|
|
234
|
+
const totalEvents = messages.reduce((n, m) => n + m.payload.length, 0);
|
|
235
|
+
expect(totalEvents).toBeGreaterThanOrEqual(20);
|
|
236
|
+
|
|
237
|
+
// BEFORE this PRD: doFlush sent one IPC message PER EVENT, so a 24-event
|
|
238
|
+
// flush meant 24 messages and (in the renderer) 24 store commits. AFTER:
|
|
239
|
+
// one message per flush (bounded by MAX_EVENTS_PER_BATCH), so the same
|
|
240
|
+
// flush is 1 message — a >20x reduction in IPC round-trips, and the
|
|
241
|
+
// renderer's live.ts/chat.ts now fold that one message into exactly one
|
|
242
|
+
// store commit each (see live.test.ts / chatTranscriptFeed.test.ts).
|
|
243
|
+
const beforeMessageCount = totalEvents; // one send per event, historically
|
|
244
|
+
const afterMessageCount = messages.length;
|
|
245
|
+
expect(afterMessageCount).toBeLessThan(beforeMessageCount);
|
|
246
|
+
expect(afterMessageCount).toBe(1);
|
|
247
|
+
|
|
248
|
+
closeTab(tabId);
|
|
249
|
+
});
|
|
@@ -29,6 +29,12 @@ beforeEach(() => {
|
|
|
29
29
|
|
|
30
30
|
afterEach(() => {
|
|
31
31
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
32
|
+
// writeTranscript mkdir -p's this run's folder under the REAL
|
|
33
|
+
// ~/.claude/projects (that's where transcriptPath resolves to), so leaving
|
|
34
|
+
// it behind accumulates one phantom project directory per test run — 1760
|
|
35
|
+
// of them had piled up before this cleanup existed, and Home counted every
|
|
36
|
+
// one as a project. Remove it with the tmp cwd it mirrors.
|
|
37
|
+
fs.rmSync(path.dirname(transcriptPath(cwd, 'x')), { recursive: true, force: true });
|
|
32
38
|
});
|
|
33
39
|
|
|
34
40
|
function writeTranscript(cwdArg, sessionId, lines) {
|
|
@@ -36,6 +36,12 @@ beforeEach(() => {
|
|
|
36
36
|
|
|
37
37
|
afterEach(() => {
|
|
38
38
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
39
|
+
// writeTranscript mkdir -p's this run's folder under the REAL
|
|
40
|
+
// ~/.claude/projects (that's where transcriptPath resolves to), so leaving
|
|
41
|
+
// it behind accumulates one phantom project directory per test run. Thousands
|
|
42
|
+
// had piled up before this cleanup existed, and Home counted every one as a
|
|
43
|
+
// project. Remove it with the tmp cwd it mirrors.
|
|
44
|
+
fs.rmSync(path.dirname(transcriptPath(cwd, 'x')), { recursive: true, force: true });
|
|
39
45
|
});
|
|
40
46
|
|
|
41
47
|
function writeTranscript(cwdArg, sessionId, lines) {
|
|
@@ -24,6 +24,12 @@ beforeEach(() => {
|
|
|
24
24
|
|
|
25
25
|
afterEach(() => {
|
|
26
26
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
27
|
+
// writeTranscript mkdir -p's this run's folder under the REAL
|
|
28
|
+
// ~/.claude/projects (that's where transcriptPath resolves to), so leaving
|
|
29
|
+
// it behind accumulates one phantom project directory per test run. Thousands
|
|
30
|
+
// had piled up before this cleanup existed, and Home counted every one as a
|
|
31
|
+
// project. Remove it with the tmp cwd it mirrors.
|
|
32
|
+
fs.rmSync(path.dirname(transcriptPath(cwd, 'x')), { recursive: true, force: true });
|
|
27
33
|
});
|
|
28
34
|
|
|
29
35
|
function writeTranscript(cwdArg, sessionId, lines) {
|
package/src/main/config.cjs
CHANGED
|
@@ -129,10 +129,7 @@ function validateWrite(realAbs) {
|
|
|
129
129
|
(p) => realAbs === p || realAbs.startsWith(p + path.sep)
|
|
130
130
|
);
|
|
131
131
|
if (inWritePrefix) return;
|
|
132
|
-
// Also allowed inside a registered project root's .claude/ subtree
|
|
133
|
-
// tests/fixtures/browser-capture/ subtree (PRD 407 "PRD fixture" capture
|
|
134
|
-
// destination — narrowly scoped to that one path segment, not a general
|
|
135
|
-
// project-root write grant).
|
|
132
|
+
// Also allowed inside a registered project root's .claude/ subtree.
|
|
136
133
|
for (const root of allowedRoots) {
|
|
137
134
|
if (root === os.homedir()) continue;
|
|
138
135
|
let realRoot;
|
|
@@ -142,24 +139,6 @@ function validateWrite(realAbs) {
|
|
|
142
139
|
if (realAbs === claudeSub || realAbs.startsWith(claudeSub + path.sep)) {
|
|
143
140
|
return;
|
|
144
141
|
}
|
|
145
|
-
const fixturesSub = path.join(realRoot, 'tests', 'fixtures', 'browser-capture');
|
|
146
|
-
if (realAbs === fixturesSub || realAbs.startsWith(fixturesSub + path.sep)) {
|
|
147
|
-
return;
|
|
148
|
-
}
|
|
149
|
-
// PRD 410 Recorder → Playwright-spec export: narrowly scoped to
|
|
150
|
-
// tests/e2e/ (matches the repo's existing e2e spec location), not a
|
|
151
|
-
// general project-root write grant.
|
|
152
|
-
const e2eSub = path.join(realRoot, 'tests', 'e2e');
|
|
153
|
-
if (realAbs === e2eSub || realAbs.startsWith(e2eSub + path.sep)) {
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
// Browser tab scratch saves (DOM captures, screenshots, recorded
|
|
157
|
-
// flows): narrowly scoped to session-manager-operations/browser/,
|
|
158
|
-
// this repo's existing per-project artifact-store convention.
|
|
159
|
-
const browserSub = path.join(realRoot, 'session-manager-operations', 'browser');
|
|
160
|
-
if (realAbs === browserSub || realAbs.startsWith(browserSub + path.sep)) {
|
|
161
|
-
return;
|
|
162
|
-
}
|
|
163
142
|
// PromptSession persistence (active-index.json + per-session archives,
|
|
164
143
|
// promptSessions.ts) and the scheduler's own read-modify-write of that
|
|
165
144
|
// same active index (promptSessionEvents.cjs, PRD 814) — narrowly
|
|
@@ -220,17 +199,41 @@ async function readJson(abs) {
|
|
|
220
199
|
}
|
|
221
200
|
}
|
|
222
201
|
|
|
223
|
-
async function readText(abs) {
|
|
202
|
+
async function readText(abs, opts = {}) {
|
|
203
|
+
const { maxBytes } = opts;
|
|
224
204
|
try {
|
|
225
205
|
abs = validatePath(expandHome(abs));
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
206
|
+
if (maxBytes == null) {
|
|
207
|
+
const raw = await fsp.readFile(abs, 'utf8');
|
|
208
|
+
const stat = await fsp.stat(abs);
|
|
209
|
+
return { exists: true, text: raw, mtimeMs: stat.mtimeMs, error: null, truncated: false };
|
|
210
|
+
}
|
|
211
|
+
const handle = await fsp.open(abs, 'r');
|
|
212
|
+
try {
|
|
213
|
+
const stat = await handle.stat();
|
|
214
|
+
const readLen = Math.min(maxBytes, stat.size);
|
|
215
|
+
const buf = Buffer.alloc(readLen);
|
|
216
|
+
if (readLen > 0) {
|
|
217
|
+
await handle.read(buf, 0, readLen, 0);
|
|
218
|
+
}
|
|
219
|
+
const truncated = stat.size > readLen;
|
|
220
|
+
let text = buf.toString('utf8');
|
|
221
|
+
if (truncated) {
|
|
222
|
+
// A byte-bounded read can split a multi-byte UTF-8 char or a JSONL
|
|
223
|
+
// line mid-way; drop the partial trailing line so callers never
|
|
224
|
+
// JSON.parse a truncated fragment.
|
|
225
|
+
const lastNewline = text.lastIndexOf('\n');
|
|
226
|
+
text = lastNewline === -1 ? '' : text.slice(0, lastNewline + 1);
|
|
227
|
+
}
|
|
228
|
+
return { exists: true, text, mtimeMs: stat.mtimeMs, error: null, truncated };
|
|
229
|
+
} finally {
|
|
230
|
+
await handle.close();
|
|
231
|
+
}
|
|
229
232
|
} catch (e) {
|
|
230
233
|
if (e.code === 'ENOENT') {
|
|
231
|
-
return { exists: false, text: '', mtimeMs: 0, error: null };
|
|
234
|
+
return { exists: false, text: '', mtimeMs: 0, error: null, truncated: false };
|
|
232
235
|
}
|
|
233
|
-
return { exists: false, text: '', mtimeMs: 0, error: e.message };
|
|
236
|
+
return { exists: false, text: '', mtimeMs: 0, error: e.message, truncated: false };
|
|
234
237
|
}
|
|
235
238
|
}
|
|
236
239
|
|
|
@@ -459,7 +462,7 @@ function closeAllWatchers() {
|
|
|
459
462
|
function registerConfigHandlers() {
|
|
460
463
|
const { schemas: s, validated: v } = require('./ipcSchemas.cjs');
|
|
461
464
|
ipcMain.handle('config:read-json', v(s.configPath, ({ path: p }) => readJson(p)));
|
|
462
|
-
ipcMain.handle('config:read-text', v(s.
|
|
465
|
+
ipcMain.handle('config:read-text', v(s.configReadText, ({ path: p, maxBytes }) => readText(p, { maxBytes })));
|
|
463
466
|
// `writer` is the renderer's declared owner id for the single-writer law
|
|
464
467
|
// (lib/opsOwnership.cjs). Ignored outside the ops root; required inside it.
|
|
465
468
|
ipcMain.handle('config:write-json', v(s.configWriteJson, ({ path: p, data, writer }) => {
|
package/src/main/files.cjs
CHANGED
|
@@ -318,6 +318,21 @@ async function deleteEntry(filePath) {
|
|
|
318
318
|
}
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Binary-safe atomic write (formerly browser:save-binary). Routes through
|
|
323
|
+
* config.cjs's writeBinaryAtomic so the single-writer law still applies to
|
|
324
|
+
* ops-root destinations; `writer` is the renderer's declared owner id.
|
|
325
|
+
*/
|
|
326
|
+
async function saveBinary(filePath, base64, writer) {
|
|
327
|
+
const { writeBinaryAtomic } = require('./config.cjs');
|
|
328
|
+
try {
|
|
329
|
+
await writeBinaryAtomic(filePath, Buffer.from(base64, 'base64'), { writer });
|
|
330
|
+
return { ok: true };
|
|
331
|
+
} catch (e) {
|
|
332
|
+
return { ok: false, error: e && e.message ? e.message : String(e) };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
321
336
|
async function openExternal(filePath) {
|
|
322
337
|
let resolved;
|
|
323
338
|
try { resolved = validateHomePath(filePath); }
|
|
@@ -385,6 +400,10 @@ function registerFilesHandlers() {
|
|
|
385
400
|
const { path: p } = filesPath.parse(payload);
|
|
386
401
|
return deleteEntry(p);
|
|
387
402
|
});
|
|
403
|
+
ipcMain.handle('files:save-binary', (_e, payload) => {
|
|
404
|
+
const { path: p, base64, writer } = schemas.filesSaveBinary.parse(payload);
|
|
405
|
+
return saveBinary(p, base64, writer);
|
|
406
|
+
});
|
|
388
407
|
// files:open-external / files:show-in-finder consolidated into shell:open
|
|
389
408
|
// (index.cjs, as: 'openPath' / 'revealPath'). The openExternal/showInFinder
|
|
390
409
|
// functions are exported for that handler to call.
|
|
@@ -406,4 +425,5 @@ module.exports = {
|
|
|
406
425
|
deleteEntry,
|
|
407
426
|
duplicateEntry,
|
|
408
427
|
duplicateNameFor,
|
|
428
|
+
saveBinary,
|
|
409
429
|
};
|
package/src/main/health.cjs
CHANGED
|
@@ -14,6 +14,7 @@ const { checkPersonaImports } = require('./lib/personaImportHealth.cjs');
|
|
|
14
14
|
const { resolvePrdsDirs } = require('./lib/prdLocations.cjs');
|
|
15
15
|
const { migratePrds } = require('./lib/prdMigration.cjs');
|
|
16
16
|
const queueStore = require('./lib/queueStore.cjs');
|
|
17
|
+
const { DEFAULT_RUNS_DIR, computeReport, isRetentionEnabled, liveKeysFromJobs } = require('./lib/runLogRetention.cjs');
|
|
17
18
|
|
|
18
19
|
const MAX_LOG_AGE_MS = 5 * 60_000; // 5 min — warn if no logs this old
|
|
19
20
|
const PROJECT_ROOT = path.resolve(__dirname, '../..');
|
|
@@ -370,6 +371,37 @@ async function check() {
|
|
|
370
371
|
}
|
|
371
372
|
}
|
|
372
373
|
|
|
374
|
+
// 6.6. Run-log retention report (informational only — never blocks health).
|
|
375
|
+
// Read-only against the REAL scheduled-plans/runs/ dir: reports current
|
|
376
|
+
// usage + what the configured policy (if any) would remove. Nothing is
|
|
377
|
+
// ever deleted here; see runLogRetention.cjs's header for the safety
|
|
378
|
+
// model. schedulerRunLogRetention lives in scheduler-machine.json's
|
|
379
|
+
// `config`, same home as every other scheduler machine setting.
|
|
380
|
+
try {
|
|
381
|
+
const retentionCfg = queueState?.config?.schedulerRunLogRetention;
|
|
382
|
+
const liveKeys = liveKeysFromJobs(queueState?.jobs || [], { runsDir: DEFAULT_RUNS_DIR });
|
|
383
|
+
const report = computeReport(
|
|
384
|
+
DEFAULT_RUNS_DIR,
|
|
385
|
+
retentionCfg?.policy || {},
|
|
386
|
+
{ liveKeys }
|
|
387
|
+
);
|
|
388
|
+
status.components.run_log_retention = {
|
|
389
|
+
ok: true,
|
|
390
|
+
path: DEFAULT_RUNS_DIR,
|
|
391
|
+
totalBytes: report.usage.totalBytes,
|
|
392
|
+
dirCount: report.usage.dirCount,
|
|
393
|
+
runCount: report.usage.runCount,
|
|
394
|
+
oldestRunAt: report.usage.oldestRunAt,
|
|
395
|
+
policyConfigured: !!retentionCfg?.policy,
|
|
396
|
+
retentionEnabled: isRetentionEnabled(queueState?.config || {}),
|
|
397
|
+
wouldRemoveCount: report.eligibleSummary.count,
|
|
398
|
+
wouldRemoveBytes: report.eligibleSummary.bytes,
|
|
399
|
+
wouldRemoveDirs: report.removableDirs.length,
|
|
400
|
+
};
|
|
401
|
+
} catch (e) {
|
|
402
|
+
status.components.run_log_retention = { ok: true, error: e.message };
|
|
403
|
+
}
|
|
404
|
+
|
|
373
405
|
// 7. Summary scoring: ok if all critical components pass.
|
|
374
406
|
// Critical: nodejs, config dir, typescript, build artifact, test infrastructure.
|
|
375
407
|
// Non-fatal: scheduler/transcripts dirs may not exist on fresh install.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* heapSnapshot.cjs — on-demand V8 heap snapshot of the RENDERER process,
|
|
3
|
+
* written to disk without restarting the app and without opening a
|
|
4
|
+
* `--remote-debugging-port`.
|
|
5
|
+
*
|
|
6
|
+
* Gated behind SM_HEAP_SNAPSHOT=1 (read fresh on every call, never cached at
|
|
7
|
+
* require time, so a test can flip it per-case). With the flag unset:
|
|
8
|
+
* - registerIpc() registers no IPC channel.
|
|
9
|
+
* - buildMenuItem() returns null (nothing spliced into the Dev menu).
|
|
10
|
+
* - No timer, listener, or allocation is added anywhere.
|
|
11
|
+
*
|
|
12
|
+
* Uses webContents.takeHeapSnapshot(filePath) (Electron's own DevTools-
|
|
13
|
+
* protocol capture) rather than a debugging port — this stays off by
|
|
14
|
+
* default and never listens on a socket. capture() races the write against
|
|
15
|
+
* a hard timeout so a stuck capture can't hang the caller (or a scheduler
|
|
16
|
+
* job waiting on the same event loop) forever; it does not and cannot
|
|
17
|
+
* cancel Electron's in-flight write, it only stops *waiting* on it.
|
|
18
|
+
*
|
|
19
|
+
* Kept free of `require('electron')` at module scope so it can be unit
|
|
20
|
+
* tested with fake ipcMain/webContents objects, no Electron runtime needed.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
'use strict';
|
|
24
|
+
|
|
25
|
+
const path = require('node:path');
|
|
26
|
+
const os = require('node:os');
|
|
27
|
+
const fsp = require('node:fs/promises');
|
|
28
|
+
|
|
29
|
+
const CHANNEL = 'diagnostics:heap-snapshot';
|
|
30
|
+
const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000; // 3 min — snapshots can be multi-hundred-MB
|
|
31
|
+
|
|
32
|
+
function isEnabled() {
|
|
33
|
+
return process.env.SM_HEAP_SNAPSHOT === '1';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function snapshotDir() {
|
|
37
|
+
return path.join(os.homedir(), '.claude', 'session-manager');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function timestampedFilename(now = new Date()) {
|
|
41
|
+
return `heap-${now.toISOString().replace(/[:.]/g, '-')}.heapsnapshot`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {{ webContents: { takeHeapSnapshot(filePath: string): Promise<void> } }} win
|
|
46
|
+
* @param {{ dir?: string, now?: Date, timeoutMs?: number }} [opts]
|
|
47
|
+
* @returns {Promise<{ filePath: string, bytes: number|null, ms: number }>}
|
|
48
|
+
*/
|
|
49
|
+
async function captureSnapshot(win, opts = {}) {
|
|
50
|
+
if (!isEnabled()) {
|
|
51
|
+
throw new Error('Heap snapshot capture is disabled — set SM_HEAP_SNAPSHOT=1 and restart to enable.');
|
|
52
|
+
}
|
|
53
|
+
if (!win || win.isDestroyed?.() || !win.webContents) {
|
|
54
|
+
throw new Error('No renderer window available to snapshot.');
|
|
55
|
+
}
|
|
56
|
+
const dir = opts.dir || snapshotDir();
|
|
57
|
+
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
58
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
59
|
+
const filePath = path.join(dir, timestampedFilename(opts.now));
|
|
60
|
+
|
|
61
|
+
const started = Date.now();
|
|
62
|
+
let timer;
|
|
63
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
64
|
+
timer = setTimeout(() => {
|
|
65
|
+
reject(new Error(`Heap snapshot timed out after ${timeoutMs}ms (still writing ${filePath})`));
|
|
66
|
+
}, timeoutMs);
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
await Promise.race([win.webContents.takeHeapSnapshot(filePath), timeout]);
|
|
70
|
+
} finally {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const stat = await fsp.stat(filePath).catch(() => null);
|
|
75
|
+
const result = { filePath, bytes: stat ? stat.size : null, ms: Date.now() - started };
|
|
76
|
+
console.log(`[heapSnapshot] wrote ${filePath}${stat ? ` (${stat.size} bytes, ${result.ms}ms)` : ''}`);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Registers the IPC handler ONLY when SM_HEAP_SNAPSHOT=1. No-op otherwise —
|
|
82
|
+
* this is the whole default-off contract for the renderer-facing trigger.
|
|
83
|
+
* @param {{ ipcMain: { handle(channel: string, listener: Function): void } }} deps
|
|
84
|
+
* @param {() => any} getWindow
|
|
85
|
+
*/
|
|
86
|
+
function registerIpc({ ipcMain, getWindow }) {
|
|
87
|
+
if (!isEnabled()) return false;
|
|
88
|
+
ipcMain.handle(CHANNEL, () => captureSnapshot(getWindow()));
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Menu item template for the Dev menu, or null when disabled. index.cjs
|
|
94
|
+
* splices this into its existing template — never builds a menu unless the
|
|
95
|
+
* flag is set.
|
|
96
|
+
* @param {() => any} getWindow
|
|
97
|
+
*/
|
|
98
|
+
function buildMenuItem(getWindow) {
|
|
99
|
+
if (!isEnabled()) return null;
|
|
100
|
+
return {
|
|
101
|
+
label: 'Take Heap Snapshot (renderer)',
|
|
102
|
+
click: async () => {
|
|
103
|
+
try {
|
|
104
|
+
const result = await captureSnapshot(getWindow());
|
|
105
|
+
console.log(`[heapSnapshot] menu capture complete: ${result.filePath}`);
|
|
106
|
+
} catch (err) {
|
|
107
|
+
console.error('[heapSnapshot] menu capture failed:', err && err.message);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
CHANNEL,
|
|
115
|
+
DEFAULT_TIMEOUT_MS,
|
|
116
|
+
isEnabled,
|
|
117
|
+
snapshotDir,
|
|
118
|
+
timestampedFilename,
|
|
119
|
+
captureSnapshot,
|
|
120
|
+
registerIpc,
|
|
121
|
+
buildMenuItem,
|
|
122
|
+
};
|