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.
Files changed (68) hide show
  1. package/dist/assets/AgentLibrary-BeJJa_zv.js +1 -0
  2. package/dist/assets/History-mWbqemhZ.js +2 -0
  3. package/dist/assets/Hooks-D_8sciZu.js +3 -0
  4. package/dist/assets/HostBilko-6aUJ7Smz.js +1 -0
  5. package/dist/assets/Library-CG1LDXLw.js +46 -0
  6. package/dist/assets/ListDetail-CIIJOWwI.js +1 -0
  7. package/dist/assets/MarkdownEditor-D0-y8V-f.js +1 -0
  8. package/dist/assets/McpServers-Kc-djiVi.js +2 -0
  9. package/dist/assets/Memory-B60DGRTW.js +8 -0
  10. package/dist/assets/Panel-CbTPsYHq.js +1 -0
  11. package/dist/assets/Permissions-C8KrSfrd.js +3 -0
  12. package/dist/assets/Plugins-BT8EumHb.js +2 -0
  13. package/dist/assets/ProvenanceBadge-cEqPpFsT.js +1 -0
  14. package/dist/assets/Scheduler-DbKPd0or.js +14 -0
  15. package/dist/assets/ScopeSwitcher-BNesCJ_t.js +1 -0
  16. package/dist/assets/Settings-Bjt7AQ6G.js +3 -0
  17. package/dist/assets/SkillReferenceGraph-DzuaPDM-.js +46 -0
  18. package/dist/assets/Skills-CVL_4X3T.js +3 -0
  19. package/dist/assets/SystemPrompt-Ct__nFF1.js +1 -0
  20. package/dist/assets/TagLibrary-DmrTfw4D.js +1 -0
  21. package/dist/assets/{TiptapBody-Bn-LMce4.js → TiptapBody-DaS0M_Ni.js} +1 -1
  22. package/dist/assets/Toggle-C0a-6xV7.js +1 -0
  23. package/dist/assets/index-CO_7DroC.js +3066 -0
  24. package/dist/assets/{index-DOXvph-Z.css → index-LlWpj2VJ.css} +1 -1
  25. package/dist/assets/listSkills-QORduPIk.js +1 -0
  26. package/dist/assets/settingsSchema-B7dMJaix.js +3 -0
  27. package/dist/assets/skillFrontmatter-Dif5JIg7.js +10 -0
  28. package/dist/index.html +2 -2
  29. package/package.json +1 -3
  30. package/src/main/__tests__/config-readText-bounded.test.cjs +84 -0
  31. package/src/main/__tests__/heapSnapshot.test.cjs +121 -0
  32. package/src/main/__tests__/historyAggregatorIntraday.test.cjs +313 -0
  33. package/src/main/__tests__/runLogRetention.test.cjs +343 -0
  34. package/src/main/__tests__/runVerify-transcript-commit-evidence.test.cjs +215 -0
  35. package/src/main/__tests__/scheduler-investigation-prompt.test.cjs +42 -1
  36. package/src/main/__tests__/scheduler-prd-missing-skip.test.cjs +54 -1
  37. package/src/main/__tests__/transcripts-batch-flush.test.cjs +249 -0
  38. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +6 -0
  39. package/src/main/__tests__/transcripts-paged-reads.test.cjs +6 -0
  40. package/src/main/__tests__/transcriptsUsageFor.test.cjs +6 -0
  41. package/src/main/config.cjs +32 -29
  42. package/src/main/files.cjs +20 -0
  43. package/src/main/health.cjs +32 -0
  44. package/src/main/heapSnapshot.cjs +122 -0
  45. package/src/main/historyAggregator.cjs +176 -36
  46. package/src/main/index.cjs +10 -63
  47. package/src/main/ipcSchemas.cjs +20 -209
  48. package/src/main/lib/opsOwnership.cjs +0 -2
  49. package/src/main/lib/runLogRetention.cjs +358 -0
  50. package/src/main/lib/summarize.cjs +0 -1
  51. package/src/main/runVerify.cjs +55 -6
  52. package/src/main/scheduler.cjs +46 -7
  53. package/src/main/transcripts.cjs +29 -5
  54. package/src/preload/api.d.ts +18 -199
  55. package/src/preload/index.cjs +12 -102
  56. package/dist/assets/index-BfiMRkln.js +0 -3225
  57. package/src/main/__tests__/browserAgentServer.test.cjs +0 -235
  58. package/src/main/__tests__/browserView-destroyed-handler.test.cjs +0 -85
  59. package/src/main/__tests__/browserView-oauth-popup.test.cjs +0 -126
  60. package/src/main/__tests__/web-remote-e2e-pinning.test.cjs +0 -181
  61. package/src/main/browserAgentServer.cjs +0 -224
  62. package/src/main/browserCapture.cjs +0 -728
  63. package/src/main/browserView.cjs +0 -916
  64. package/src/main/lib/__tests__/browserAgentActions.test.cjs +0 -76
  65. package/src/main/lib/browserAgentActions.cjs +0 -114
  66. package/src/main/lib/e2eStateMachine.cjs +0 -39
  67. package/src/main/webRemote.cjs +0 -1367
  68. 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) {
@@ -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, OR its
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
- const raw = await fsp.readFile(abs, 'utf8');
227
- const stat = await fsp.stat(abs);
228
- return { exists: true, text: raw, mtimeMs: stat.mtimeMs, error: null };
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.configPath, ({ path: p }) => readText(p)));
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 }) => {
@@ -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
  };
@@ -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
+ };