claude-code-session-manager 0.62.1 → 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 (45) 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-BwN4pNC0.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-CVCCMw5o.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__/transcripts-batch-flush.test.cjs +249 -0
  35. package/src/main/config.cjs +31 -7
  36. package/src/main/health.cjs +32 -0
  37. package/src/main/heapSnapshot.cjs +122 -0
  38. package/src/main/historyAggregator.cjs +176 -36
  39. package/src/main/index.cjs +9 -0
  40. package/src/main/ipcSchemas.cjs +9 -0
  41. package/src/main/lib/runLogRetention.cjs +358 -0
  42. package/src/main/transcripts.cjs +29 -5
  43. package/src/preload/api.d.ts +16 -2
  44. package/src/preload/index.cjs +11 -2
  45. package/dist/assets/index-ZjTDk5b7.js +0 -3197
@@ -0,0 +1,358 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * runLogRetention.cjs — computes (and, only when explicitly opted in,
5
+ * applies) a retention policy over `scheduled-plans/runs/`.
6
+ *
7
+ * That directory is a machine-level Session-Manager runtime artifact store
8
+ * (execution logs of the scheduler's own `claude -p` runs), not anything
9
+ * governed by the per-project single-writer law in opsOwnership.cjs — see
10
+ * queueStore.cjs's header comment, which draws the same line for
11
+ * scheduler-machine.json.
12
+ *
13
+ * Directory shape: one subdirectory per tick (`RUNS_DIR/<iso-ts>/`, minted by
14
+ * scheduler.cjs's pickRunDir), which commonly holds MANY different PRD
15
+ * slugs' artifacts side by side — `<slug>.log`, `<slug>.meta.json`, and an
16
+ * optional `root-cause-<slug>.md` (rcaReport.cjs) per slug. Retention is
17
+ * therefore computed per (runId, slug) ENTRY, not per directory: a directory
18
+ * is only "fully removable" once every entry it holds is independently
19
+ * eligible AND every file physically present in it is claimed by one of
20
+ * those entries. Definition-of-done reports
21
+ * (`definition-of-done-<batchKey>.md`, definitionOfDone.cjs) are keyed by a
22
+ * hash of a job batch, not a single slug, so they are deliberately left
23
+ * unclaimed by any entry — their presence in a directory blocks that
24
+ * directory from being fully removed, but never blocks removal of a claimed
25
+ * slug's own files.
26
+ *
27
+ * SAFETY MODEL (the point of this module — see the PRD that added it):
28
+ * - Nothing is ever deleted unless the caller passes settings with
29
+ * `schedulerRunLogRetention.enabled === true` AND a non-empty policy.
30
+ * Everywhere else (computeReport, and applyRetention with no opt-in)
31
+ * this module only READS the filesystem.
32
+ * - An entry belonging to a job that is currently live — status `pending`
33
+ * (queued), `running`, `needs_review`, or `investigating` (the exact
34
+ * literals scheduler.cjs uses; see LIVE_STATUSES) — is never eligible,
35
+ * regardless of age or count policy.
36
+ * - The most recent entry for any given slug is never eligible, even past
37
+ * an age cap — every PRD keeps at least its latest evidence.
38
+ * - Age and count policies are COMBINABLE and combine conservatively
39
+ * (AND): when both are set, an entry must clear both floors to be
40
+ * eligible. A policy is a safety floor, not a removal trigger — setting
41
+ * one dimension should never make the other one weaker.
42
+ */
43
+
44
+ const fs = require('node:fs');
45
+ const os = require('node:os');
46
+ const path = require('node:path');
47
+
48
+ const DEFAULT_RUNS_DIR = path.join(
49
+ os.homedir(),
50
+ '.claude', 'session-manager', 'scheduled-plans', 'runs'
51
+ );
52
+
53
+ // Any status that is not yet a terminal outcome. Mirrors the status literals
54
+ // used throughout scheduler.cjs (see e.g. its DOD_SLUG_RE-adjacent status
55
+ // checks) — 'completed' and 'failed' are the only terminal ones.
56
+ const LIVE_STATUSES = new Set(['pending', 'running', 'needs_review', 'investigating']);
57
+
58
+ const META_SUFFIX = '.meta.json';
59
+ const DAY_MS = 24 * 60 * 60 * 1000;
60
+
61
+ function isLiveJob(job) {
62
+ return !!job && LIVE_STATUSES.has(job.status);
63
+ }
64
+
65
+ /**
66
+ * Build the `${slug}|${runId}` protection set from a scheduler job list
67
+ * (queueStore.readMergedSync().jobs, or any array with the same shape). Jobs
68
+ * with no runId yet (never started) have no run directory to protect.
69
+ *
70
+ * A `needs_review` job can lose its `runId` (an old queue-schema gap —
71
+ * scheduler.cjs's own `resolveRunId` backfill exists for the same reason)
72
+ * while a run directory for its slug still exists on disk. When `runsDir` is
73
+ * given, every run directory containing `<slug>.log` is protected for such a
74
+ * job — not just scheduler.cjs's single newest match, since this module has
75
+ * no way to know which one the job actually corresponds to and protecting
76
+ * too many is always safe here, never protecting too few.
77
+ */
78
+ function liveKeysFromJobs(jobs, opts) {
79
+ const runsDir = opts && opts.runsDir;
80
+ const keys = new Set();
81
+ for (const job of jobs || []) {
82
+ if (!isLiveJob(job) || !job.slug) continue;
83
+ if (job.runId) {
84
+ keys.add(`${job.slug}|${job.runId}`);
85
+ continue;
86
+ }
87
+ if (!runsDir) continue;
88
+ let dirs;
89
+ try {
90
+ dirs = fs.readdirSync(runsDir);
91
+ } catch {
92
+ continue;
93
+ }
94
+ for (const d of dirs) {
95
+ try {
96
+ if (fs.existsSync(path.join(runsDir, d, `${job.slug}.log`))) keys.add(`${job.slug}|${d}`);
97
+ } catch {
98
+ // skip
99
+ }
100
+ }
101
+ }
102
+ return keys;
103
+ }
104
+
105
+ /**
106
+ * Scan RUNS_DIR into one entry per (runId, slug) pair found via its
107
+ * `<slug>.meta.json` file. Read-only; never throws on a missing runsDir.
108
+ */
109
+ function scanRunEntries(runsDir) {
110
+ let dirEntries;
111
+ try {
112
+ dirEntries = fs.readdirSync(runsDir, { withFileTypes: true });
113
+ } catch (e) {
114
+ if (e && e.code === 'ENOENT') return [];
115
+ throw e;
116
+ }
117
+
118
+ const entries = [];
119
+ for (const de of dirEntries) {
120
+ if (!de.isDirectory()) continue;
121
+ const runId = de.name;
122
+ const dir = path.join(runsDir, runId);
123
+ let files;
124
+ try {
125
+ files = fs.readdirSync(dir);
126
+ } catch {
127
+ continue; // vanished between readdir calls
128
+ }
129
+
130
+ const metaFiles = files.filter((f) => f.endsWith(META_SUFFIX));
131
+ for (const metaFile of metaFiles) {
132
+ const slug = metaFile.slice(0, -META_SUFFIX.length);
133
+ const relatedNames = files.filter(
134
+ (f) => f === metaFile || f === `${slug}.log` || f === `root-cause-${slug}.md`
135
+ );
136
+
137
+ let sizeBytes = 0;
138
+ let mtimeMs = null;
139
+ const filePaths = [];
140
+ for (const name of relatedNames) {
141
+ const fp = path.join(dir, name);
142
+ filePaths.push(fp);
143
+ try {
144
+ const st = fs.statSync(fp);
145
+ sizeBytes += st.size;
146
+ if (mtimeMs === null || st.mtimeMs > mtimeMs) mtimeMs = st.mtimeMs;
147
+ } catch {
148
+ // file vanished mid-scan — skip its contribution
149
+ }
150
+ }
151
+
152
+ let recordedAtMs = null;
153
+ try {
154
+ const meta = JSON.parse(fs.readFileSync(path.join(dir, metaFile), 'utf8'));
155
+ const candidate = meta.finishedAt ?? meta.startedAt;
156
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) recordedAtMs = candidate;
157
+ } catch {
158
+ // corrupt/unreadable meta.json — fall back to file mtime below
159
+ }
160
+
161
+ entries.push({
162
+ runId,
163
+ slug,
164
+ dir,
165
+ files: filePaths,
166
+ sizeBytes,
167
+ timeMs: recordedAtMs ?? mtimeMs ?? 0,
168
+ });
169
+ }
170
+ }
171
+ return entries;
172
+ }
173
+
174
+ /**
175
+ * Evaluate eligibility for each entry against a policy. Returns entries
176
+ * augmented with { ageDays, rank, isMostRecent, isLive, eligible }.
177
+ *
178
+ * @param {Array} entries Output of scanRunEntries().
179
+ * @param {{maxAgeDays?: number, keepPerSlug?: number}} policy
180
+ * @param {{now?: number, liveKeys?: Set<string>}} opts
181
+ */
182
+ function computeEligibility(entries, policy, opts) {
183
+ const { maxAgeDays, keepPerSlug } = policy || {};
184
+ const now = (opts && opts.now) ?? Date.now();
185
+ const liveKeys = (opts && opts.liveKeys) ?? new Set();
186
+ const hasPolicy = maxAgeDays != null || keepPerSlug != null;
187
+
188
+ const bySlug = new Map();
189
+ for (const e of entries) {
190
+ if (!bySlug.has(e.slug)) bySlug.set(e.slug, []);
191
+ bySlug.get(e.slug).push(e);
192
+ }
193
+ const rankOf = new Map();
194
+ for (const list of bySlug.values()) {
195
+ list.sort((a, b) => b.timeMs - a.timeMs);
196
+ list.forEach((e, i) => rankOf.set(e, i));
197
+ }
198
+
199
+ return entries.map((e) => {
200
+ const rank = rankOf.get(e) ?? 0;
201
+ const isMostRecent = rank === 0;
202
+ const isLive = liveKeys.has(`${e.slug}|${e.runId}`);
203
+ const ageDays = (now - e.timeMs) / DAY_MS;
204
+ const ageOk = maxAgeDays == null || ageDays > maxAgeDays;
205
+ const countOk = keepPerSlug == null || rank >= keepPerSlug;
206
+ const eligible = hasPolicy && !isLive && !isMostRecent && ageOk && countOk;
207
+ return { ...e, ageDays, rank, isMostRecent, isLive, eligible };
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Read-only report: current usage against runsDir, plus what the given
213
+ * policy WOULD remove. Never writes or deletes anything.
214
+ */
215
+ function computeReport(runsDir, policy, opts) {
216
+ const now = (opts && opts.now) ?? Date.now();
217
+ const liveKeys = (opts && opts.liveKeys) ?? new Set();
218
+ const entries = scanRunEntries(runsDir);
219
+ const evaluated = computeEligibility(entries, policy || {}, { now, liveKeys });
220
+
221
+ const totalBytes = entries.reduce((sum, e) => sum + e.sizeBytes, 0);
222
+ const dirSet = new Set(entries.map((e) => e.dir));
223
+ const oldestRunAt = entries.length ? Math.min(...entries.map((e) => e.timeMs)) : null;
224
+
225
+ const eligible = evaluated.filter((e) => e.eligible);
226
+ const eligibleBytes = eligible.reduce((sum, e) => sum + e.sizeBytes, 0);
227
+
228
+ // A directory is fully removable only when every file physically present
229
+ // in it belongs to an eligible entry — an unclaimed file (a DoD report, or
230
+ // a slug entry that isn't eligible) keeps the directory itself alive even
231
+ // though the eligible entries' own files can still be unlinked.
232
+ const removableDirs = [];
233
+ for (const dir of dirSet) {
234
+ let actualFiles;
235
+ try {
236
+ actualFiles = fs.readdirSync(dir);
237
+ } catch {
238
+ continue;
239
+ }
240
+ if (actualFiles.length === 0) continue;
241
+ const claimedEligible = new Set();
242
+ for (const e of evaluated) {
243
+ if (e.dir !== dir || !e.eligible) continue;
244
+ for (const fp of e.files) claimedEligible.add(path.basename(fp));
245
+ }
246
+ if (actualFiles.every((f) => claimedEligible.has(f))) removableDirs.push(dir);
247
+ }
248
+
249
+ return {
250
+ generatedAt: now,
251
+ runsDir,
252
+ policy: policy || null,
253
+ usage: {
254
+ totalBytes,
255
+ dirCount: dirSet.size,
256
+ runCount: entries.length,
257
+ oldestRunAt,
258
+ },
259
+ eligible,
260
+ eligibleSummary: { count: eligible.length, bytes: eligibleBytes },
261
+ removableDirs,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * True only when settings carry an explicit, well-formed opt-in:
267
+ * `schedulerRunLogRetention.enabled === true` plus a non-empty policy.
268
+ */
269
+ function isRetentionEnabled(settings) {
270
+ const cfg = settings && settings.schedulerRunLogRetention;
271
+ if (!cfg || cfg.enabled !== true) return false;
272
+ const policy = cfg.policy;
273
+ return !!policy && (policy.maxAgeDays != null || policy.keepPerSlug != null);
274
+ }
275
+
276
+ /**
277
+ * Resolve the live-job protection set for applyRetention. Prefers whatever
278
+ * the caller supplied (opts.liveKeys, or opts.jobs to derive it from); if
279
+ * neither is given AND deletion is actually about to happen, falls back to
280
+ * reading the real scheduler queue itself via queueStore.cjs (plain Node, no
281
+ * Electron deps — safe to require here) rather than silently treating no
282
+ * jobs as live. This is deliberately defense-in-depth: a future caller that
283
+ * forgets to thread live-job info through must not thereby lose live-job
284
+ * protection.
285
+ */
286
+ function resolveLiveKeysForApply(runsDir, opts, enabled) {
287
+ if (opts && opts.liveKeys) return opts.liveKeys;
288
+ if (opts && opts.jobs) return liveKeysFromJobs(opts.jobs, { runsDir });
289
+ if (!enabled) return new Set(); // dry-run path never deletes; no live read needed
290
+ try {
291
+ const queueStore = require('./queueStore.cjs');
292
+ const state = queueStore.readMergedSync();
293
+ return liveKeysFromJobs(state.jobs || [], { runsDir });
294
+ } catch {
295
+ return new Set();
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Compute the report, and — ONLY when isRetentionEnabled(settings) — delete
301
+ * the eligible files and rmdir any directory left fully empty. With no
302
+ * opt-in (the default), this is exactly computeReport(): read-only,
303
+ * `deleted: false`, nothing removed.
304
+ */
305
+ function applyRetention(runsDir, settings, opts) {
306
+ const cfg = (settings && settings.schedulerRunLogRetention) || null;
307
+ const policy = (cfg && cfg.policy) || {};
308
+ const enabled = isRetentionEnabled(settings);
309
+ const liveKeys = resolveLiveKeysForApply(runsDir, opts, enabled);
310
+ const report = computeReport(runsDir, policy, { now: opts && opts.now, liveKeys });
311
+
312
+ if (!enabled) {
313
+ return {
314
+ deleted: false,
315
+ reason: 'dry-run: schedulerRunLogRetention.enabled is not true (or has no policy)',
316
+ report,
317
+ };
318
+ }
319
+
320
+ let removedFiles = 0;
321
+ let freedBytes = 0;
322
+ const errors = [];
323
+
324
+ for (const entry of report.eligible) {
325
+ for (const fp of entry.files) {
326
+ try {
327
+ const st = fs.statSync(fp);
328
+ fs.unlinkSync(fp);
329
+ removedFiles += 1;
330
+ freedBytes += st.size;
331
+ } catch (e) {
332
+ if (e && e.code !== 'ENOENT') errors.push({ path: fp, error: e.message });
333
+ }
334
+ }
335
+ }
336
+
337
+ for (const dir of report.removableDirs) {
338
+ try {
339
+ if (fs.readdirSync(dir).length === 0) fs.rmdirSync(dir);
340
+ } catch (e) {
341
+ if (e && e.code !== 'ENOENT') errors.push({ path: dir, error: e.message });
342
+ }
343
+ }
344
+
345
+ return { deleted: true, removedFiles, freedBytes, errors, report };
346
+ }
347
+
348
+ module.exports = {
349
+ DEFAULT_RUNS_DIR,
350
+ LIVE_STATUSES,
351
+ isLiveJob,
352
+ liveKeysFromJobs,
353
+ scanRunEntries,
354
+ computeEligibility,
355
+ computeReport,
356
+ isRetentionEnabled,
357
+ applyRetention,
358
+ };
@@ -126,8 +126,23 @@ async function readDelta(sub) {
126
126
  }
127
127
  }
128
128
 
129
+ // Cap on the number of events carried in a single `transcript:event:<tabId>`
130
+ // IPC message. One flush can classify a large delta (e.g. a rotated/replayed
131
+ // transcript, or the 8 MB MAX_DELTA_BYTES window) into far more than a
132
+ // screenful of events — sending them all as one unbounded array risks a
133
+ // single oversized IPC payload. Above this count, doFlush sends multiple
134
+ // ordered batches instead of one giant one; each still lands as one store
135
+ // commit on the renderer side.
136
+ const MAX_EVENTS_PER_BATCH = 200;
137
+
129
138
  async function doFlush(sub, { emit = true } = {}) {
130
139
  const lines = await readDelta(sub);
140
+ let batch = [];
141
+ const flushBatch = () => {
142
+ if (batch.length === 0) return;
143
+ if (emit) sendIfAlive(window, `transcript:event:${sub.tabId}`, batch);
144
+ batch = [];
145
+ };
131
146
  for (const line of lines) {
132
147
  // Index every line — including ones that fail to parse — so line numbers
133
148
  // and byte offsets stay correct for paged reads regardless of content.
@@ -142,13 +157,13 @@ async function doFlush(sub, { emit = true } = {}) {
142
157
  const ref = { filePath: sub.filePath, byteOffset: line.byteOffset, byteLength: line.byteLength };
143
158
  const events = classifyLine(obj, ref);
144
159
  for (const ev of events) {
145
- if (emit) sendIfAlive(window, `transcript:event:${sub.tabId}`, ev);
160
+ batch.push(ev);
146
161
  // Mirror to OTEL — no-op when disabled. We emit on the initial drain too
147
162
  // so backfilled transcripts show up in the trace store. One span per
148
- // emitted event, not per line. doFlush only ever processes a given
149
- // line once (readDelta never re-returns already-consumed bytes), so
150
- // this can't double-record — paged re-reads (readPage) go through a
151
- // separate code path below that never touches OTEL.
163
+ // emitted event, not per line or per batch. doFlush only ever processes
164
+ // a given line once (readDelta never re-returns already-consumed
165
+ // bytes), so this can't double-record — paged re-reads (readPage) go
166
+ // through a separate code path below that never touches OTEL.
152
167
  otel.recordTranscriptEvent({
153
168
  tabId: sub.tabId,
154
169
  tabCwd: sub.cwd,
@@ -156,8 +171,10 @@ async function doFlush(sub, { emit = true } = {}) {
156
171
  data: ev.data,
157
172
  ts: Date.now(),
158
173
  });
174
+ if (batch.length >= MAX_EVENTS_PER_BATCH) flushBatch();
159
175
  }
160
176
  }
177
+ flushBatch();
161
178
  }
162
179
 
163
180
  /**
@@ -525,4 +542,11 @@ module.exports = {
525
542
  // particular) — asserting a memory ceiling requires inspecting what's
526
543
  // actually held, not just what a read API returns.
527
544
  __getSubForTest: (tabId) => subs.get(tabId),
545
+ // Test-only: run a live (emit:true) flush directly against a subscription,
546
+ // without going through the chokidar watcher — lets batching/IPC-shape
547
+ // tests stay deterministic instead of racing a filesystem watch event.
548
+ __doFlushForTest: (sub, opts) => doFlush(sub, opts),
549
+ // Batch size cap for a single transcript:event IPC message — exported so
550
+ // tests can size fixtures against it without a magic-number duplicate.
551
+ MAX_EVENTS_PER_BATCH,
528
552
  };
@@ -43,6 +43,13 @@ export interface ReadTextResult {
43
43
  text: string;
44
44
  mtimeMs: number;
45
45
  error: string | null;
46
+ /** True when maxBytes was set and the file is larger than the read prefix. */
47
+ truncated: boolean;
48
+ }
49
+
50
+ export interface ReadTextOptions {
51
+ /** Read at most this many bytes from the start of the file instead of the whole file. */
52
+ maxBytes?: number;
46
53
  }
47
54
 
48
55
  /** One resolved node in a CLAUDE.md-like file's `@path` import chain. */
@@ -1245,7 +1252,10 @@ export interface SessionManagerAPI {
1245
1252
  * session with no transcript file yet (or one over the main-process size
1246
1253
  * cap) maps to null. */
1247
1254
  usageFor: (cwd: string, sessionIds: string[]) => Promise<Record<string, { inputTokens: number; outputTokens: number } | null>>;
1248
- onEvent: (tabId: string, handler: (ev: TranscriptEvent) => void) => () => void;
1255
+ /** Fires once per main-process flush with the ORDERED batch of events that
1256
+ * flush produced (never one call per event) — see transcripts.cjs's
1257
+ * doFlush / MAX_EVENTS_PER_BATCH. */
1258
+ onEvent: (tabId: string, handler: (events: TranscriptEvent[]) => void) => () => void;
1249
1259
  };
1250
1260
  sessions: {
1251
1261
  load: () => Promise<LoadedSessions>;
@@ -1281,7 +1291,7 @@ export interface SessionManagerAPI {
1281
1291
  };
1282
1292
  config: {
1283
1293
  readJson: (path: string) => Promise<ReadJsonResult>;
1284
- readText: (path: string) => Promise<ReadTextResult>;
1294
+ readText: (path: string, opts?: ReadTextOptions) => Promise<ReadTextResult>;
1285
1295
  /** `writer` declares the owning surface for the single-writer law — required
1286
1296
  * when `path` is inside a project's session-manager-operations/ root. */
1287
1297
  writeJson: (path: string, data: unknown, writer?: OpsWriter) => Promise<WriteResult>;
@@ -1326,6 +1336,10 @@ export interface SessionManagerAPI {
1326
1336
  status: () => Promise<OtelStatus>;
1327
1337
  configPath: () => Promise<string>;
1328
1338
  };
1339
+ diagnostics: {
1340
+ /** Rejects unless the main process has SM_HEAP_SNAPSHOT=1 set. */
1341
+ takeHeapSnapshot: () => Promise<{ filePath: string; bytes: number | null; ms: number }>;
1342
+ };
1329
1343
  files: {
1330
1344
  list: (path: string, showHidden?: boolean) => Promise<FilesListResult>;
1331
1345
  read: (path: string) => Promise<FilesReadResult>;
@@ -68,9 +68,13 @@ contextBridge.exposeInMainWorld('api', {
68
68
  ipcRenderer.invoke('transcript:path', { cwd, sessionUuid }),
69
69
  usageFor: (cwd, sessionIds) =>
70
70
  ipcRenderer.invoke('transcript:usageFor', { cwd, sessionIds }),
71
+ // Main sends one batch (array) per flush — see transcripts.cjs's doFlush —
72
+ // so the handler is array-shaped, not per-event, letting subscribers
73
+ // (live.ts, chat.ts) commit their store once per batch instead of once
74
+ // per event.
71
75
  onEvent: (tabId, handler) => {
72
76
  const channel = `transcript:event:${tabId}`;
73
- const listener = (_e, ev) => handler(ev);
77
+ const listener = (_e, events) => handler(events);
74
78
  ipcRenderer.on(channel, listener);
75
79
  return () => ipcRenderer.removeListener(channel, listener);
76
80
  },
@@ -112,7 +116,7 @@ contextBridge.exposeInMainWorld('api', {
112
116
  },
113
117
  config: {
114
118
  readJson: (path) => ipcRenderer.invoke('config:read-json', { path }),
115
- readText: (path) => ipcRenderer.invoke('config:read-text', { path }),
119
+ readText: (path, opts) => ipcRenderer.invoke('config:read-text', { path, maxBytes: opts?.maxBytes }),
116
120
  writeJson: (path, data, writer) => ipcRenderer.invoke('config:write-json', { path, data, writer }),
117
121
  writeText: (path, text, writer) => ipcRenderer.invoke('config:write-text', { path, text, writer }),
118
122
  listDir: (path, opts) => ipcRenderer.invoke('config:list-dir', { path, opts }),
@@ -173,6 +177,11 @@ contextBridge.exposeInMainWorld('api', {
173
177
  status: () => ipcRenderer.invoke('otel:status'),
174
178
  configPath: () => ipcRenderer.invoke('otel:config-path'),
175
179
  },
180
+ // Diagnostic only — no handler is registered unless the main process was
181
+ // launched with SM_HEAP_SNAPSHOT=1, so this rejects by default.
182
+ diagnostics: {
183
+ takeHeapSnapshot: () => ipcRenderer.invoke('diagnostics:heap-snapshot'),
184
+ },
176
185
  history: {
177
186
  aggregate: (req) => ipcRenderer.invoke('history:aggregate', req),
178
187
  scanProjects: () => ipcRenderer.invoke('history:scan-projects'),