claude-code-session-manager 0.57.1 → 0.59.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/README.md +2 -2
  2. package/dist/assets/{TiptapBody-CJDxVF7X.js → TiptapBody-PUx4oZTh.js} +1 -1
  3. package/dist/assets/index-BPnfPdLW.js +3222 -0
  4. package/dist/assets/{index-WUc_GHMf.css → index-CPMP2XZ_.css} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -2
  7. package/plugins/session-manager-dev/skills/develop/SKILL.md +7 -6
  8. package/plugins/session-manager-dev/skills/develop/standards.md +1 -1
  9. package/plugins/session-manager-dev/skills/explain-to-me/SKILL.md +1 -1
  10. package/plugins/session-manager-dev/skills/find-opportunity/SKILL.md +1 -1
  11. package/plugins/session-manager-dev/skills/ops-sweep/SKILL.md +16 -25
  12. package/plugins/session-manager-dev/skills/project-status/SKILL.md +5 -5
  13. package/scripts/lib/watchdogHelpers.cjs +0 -434
  14. package/scripts/mint-epic.cjs +6 -6
  15. package/src/main/__tests__/activeIndexMerge.test.cjs +2 -2
  16. package/src/main/__tests__/agentModelResolve.test.cjs +119 -0
  17. package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
  18. package/src/main/__tests__/epicContextDigest.test.cjs +100 -0
  19. package/src/main/__tests__/epicMint.test.cjs +165 -207
  20. package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
  21. package/src/main/__tests__/prdMigration.test.cjs +160 -0
  22. package/src/main/__tests__/projectPages.test.cjs +151 -0
  23. package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
  24. package/src/main/__tests__/promptSessionSchema.test.cjs +101 -0
  25. package/src/main/__tests__/promptSessionsCreateEpicHandler.test.cjs +139 -0
  26. package/src/main/__tests__/rcaReport.test.cjs +188 -0
  27. package/src/main/__tests__/runVerify.test.cjs +109 -6
  28. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
  29. package/src/main/__tests__/scheduler-epic-digest.test.cjs +214 -0
  30. package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
  31. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
  32. package/src/main/__tests__/scheduler-writeprd-epic-rollback.test.cjs +5 -5
  33. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
  34. package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
  35. package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
  36. package/src/main/chatRunner.cjs +10 -1
  37. package/src/main/health.cjs +5 -1
  38. package/src/main/index.cjs +3 -5
  39. package/src/main/ipcSchemas.cjs +72 -4
  40. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
  41. package/src/main/lib/agentModelResolve.cjs +97 -0
  42. package/src/main/lib/auditLog.cjs +2 -2
  43. package/src/main/lib/classifyTranscriptLine.cjs +131 -48
  44. package/src/main/lib/epicContextDigest.cjs +78 -0
  45. package/src/main/lib/epicMint.cjs +86 -164
  46. package/src/main/lib/epicValidationHook.cjs +192 -0
  47. package/src/main/lib/prdMigration.cjs +76 -5
  48. package/src/main/lib/promptSessionSchema.cjs +95 -0
  49. package/src/main/lib/promptSessionsCreateEpic.cjs +70 -0
  50. package/src/main/lib/{rcaFeedbackHook.cjs → rcaReport.cjs} +47 -114
  51. package/src/main/lib/schedulerBatch.cjs +70 -111
  52. package/src/main/lib/schedulerConfig.cjs +0 -1
  53. package/src/main/otel.cjs +3 -1
  54. package/src/main/projectPages.cjs +60 -14
  55. package/src/main/promptSessionEvents.cjs +16 -1
  56. package/src/main/runVerify.cjs +17 -2
  57. package/src/main/scheduler.cjs +302 -68
  58. package/src/main/templates/project-pages-default-home.html +123 -0
  59. package/src/main/transcripts.cjs +191 -32
  60. package/src/main/webRemote.cjs +8 -7
  61. package/src/preload/api.d.ts +100 -57
  62. package/src/preload/index.cjs +9 -7
  63. package/dist/assets/index-DCa3QvAi.js +0 -3227
  64. package/plugins/session-manager-dev/skills/propose-epic/SKILL.md +0 -68
  65. package/scripts/propose-epic.cjs +0 -56
  66. package/src/main/__tests__/rcaFeedbackHook.test.cjs +0 -280
  67. package/src/main/repoAnalyzer.cjs +0 -346
  68. package/src/main/search.cjs +0 -332
@@ -0,0 +1,123 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>Project Home</title>
7
+ <meta name="description" content="Project Home — a hosted document generated from this project's own repo, not yet generated for this project." />
8
+ <style>
9
+ /* Design tokens copied from src/renderer/lib/projectPages/library/kit.tsx's
10
+ PK palette/fonts — no font files are embedded here (unlike the
11
+ generated lenses), so the font-family stacks fall back to their system
12
+ alternatives (Georgia / system-ui / ui-monospace). This keeps the
13
+ document a zero-network, zero-JS static file while still reading as
14
+ the same visual system: same colors, same type scale, same shapes. */
15
+ :root {
16
+ --pk-paper: #f6efe1;
17
+ --pk-panel: #efe6d3;
18
+ --pk-card: #fbf6ec;
19
+ --pk-deep: #2a221a;
20
+ --pk-edge: #e0d3b8;
21
+ --pk-rule: #d9c9a8;
22
+ --pk-ink: #2a221a;
23
+ --pk-ink-soft: #5b4a36;
24
+ --pk-ink-mute: #8a7a60;
25
+ --pk-accent: #b85c34;
26
+ --pk-accent-deep: #9c4a26;
27
+ --pk-sage: #6f7d52;
28
+ --pk-butter: #e4b85a;
29
+ --pk-serif: 'Newsreader', Georgia, serif;
30
+ --pk-sans: 'Geist', system-ui, sans-serif;
31
+ --pk-mono: 'IBM Plex Mono', ui-monospace, monospace;
32
+ }
33
+ * { box-sizing: border-box; }
34
+ html, body { margin: 0; padding: 0; background: var(--pk-paper); color: var(--pk-ink); }
35
+ body { font-family: var(--pk-sans); }
36
+ .eyebrow {
37
+ font-family: var(--pk-mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase;
38
+ color: var(--pk-accent); font-weight: 500;
39
+ }
40
+ .pill {
41
+ display: inline-flex; align-items: center; gap: 6px; font-family: var(--pk-mono); font-size: 11px;
42
+ color: var(--pk-ink-soft); background: transparent; border: 1px solid var(--pk-rule); border-radius: 999px;
43
+ padding: 3px 9px; white-space: nowrap;
44
+ }
45
+ h1 {
46
+ margin: 0; font-family: var(--pk-serif); font-weight: 500; font-size: clamp(30px, 5vw, 44px);
47
+ line-height: 1.1; letter-spacing: -0.015em; color: var(--pk-ink);
48
+ }
49
+ h2 {
50
+ margin: 0; font-family: var(--pk-serif); font-weight: 500; font-size: 24px; line-height: 1.15;
51
+ letter-spacing: -0.01em; color: var(--pk-ink);
52
+ }
53
+ p { margin: 0; font-family: var(--pk-sans); font-size: 16px; line-height: 1.62; color: var(--pk-ink-soft); }
54
+ section { padding: clamp(28px, 6vw, 72px); }
55
+ .hero { border-bottom: 1px solid var(--pk-rule); background: var(--pk-panel); }
56
+ .hero-inner { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 16px; }
57
+ .cta {
58
+ display: inline-flex; width: fit-content; align-items: center; gap: 8px; font-family: var(--pk-sans);
59
+ font-size: 14px; font-weight: 600; padding: 11px 20px; border-radius: 9px; border: 1px solid transparent;
60
+ background: var(--pk-accent); color: #fff;
61
+ }
62
+ .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 20px; max-width: 960px; margin: 0 auto; }
63
+ .card {
64
+ background: var(--pk-card); border: 1px solid var(--pk-edge); border-radius: 12px; padding: 20px;
65
+ display: flex; flex-direction: column; gap: 8px;
66
+ }
67
+ .card .k { font-family: var(--pk-mono); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--pk-ink-mute); }
68
+ .card .t { font-family: var(--pk-serif); font-size: 18px; color: var(--pk-ink); }
69
+ .card .d { font-size: 13.5px; line-height: 1.55; color: var(--pk-ink-soft); }
70
+ .footnote { max-width: 760px; margin: 0 auto; display: flex; flex-direction: column; gap: 6px; }
71
+ .footnote .label { font-family: var(--pk-mono); font-size: 11px; color: var(--pk-ink-mute); }
72
+ </style>
73
+ </head>
74
+ <body>
75
+ <section class="hero">
76
+ <div class="hero-inner">
77
+ <span class="pill">Shipped default — not yet generated for this project</span>
78
+ <div class="eyebrow">Project Home</div>
79
+ <h1>This is Project Home's shipped default page.</h1>
80
+ <p>
81
+ Project Home is a hosted document — a self-contained page describing this specific
82
+ project: what it is, its main areas, and what it's for. Session-manager ships this
83
+ default so the page is never empty, but it does not describe your project yet.
84
+ Nothing shown on this page is specific to any real project.
85
+ </p>
86
+ <a class="cta" href="#" onclick="return false;">Generate My Project Home</a>
87
+ <p style="font-size: 13px; color: var(--pk-ink-mute);">
88
+ Press "Generate My Project Home" in the app (this link is inert inside this static
89
+ preview) to replace this page with one generated from your project's own repo.
90
+ </p>
91
+ </div>
92
+ </section>
93
+ <section>
94
+ <div class="grid">
95
+ <div class="card">
96
+ <div class="k">What it reads</div>
97
+ <div class="t">Your repo, live</div>
98
+ <div class="d">Generation reads this project's own files, git history, and CLAUDE.md conventions — never invented content.</div>
99
+ </div>
100
+ <div class="card">
101
+ <div class="k">What it produces</div>
102
+ <div class="t">A static document</div>
103
+ <div class="d">A self-contained HTML page — inline styling, no network calls, no live app state — that replaces this default at a fixed path.</div>
104
+ </div>
105
+ <div class="card">
106
+ <div class="k">How it stays fresh</div>
107
+ <div class="t">On demand</div>
108
+ <div class="d">Regenerate any time from Project Home; this default only ever shows before the first generation.</div>
109
+ </div>
110
+ </div>
111
+ </section>
112
+ <section>
113
+ <div class="footnote">
114
+ <div class="label">About this page</div>
115
+ <p style="font-size: 13.5px;">
116
+ Shipped with Session Manager as a build-time asset — the same design system as a
117
+ generated Project Home page, but honest, generic copy instead of project-specific
118
+ content. See "About these templates" for how a generated page is built.
119
+ </p>
120
+ </div>
121
+ </section>
122
+ </body>
123
+ </html>
@@ -50,20 +50,27 @@ const MAX_DELTA_BYTES = 8 * 1024 * 1024;
50
50
  /**
51
51
  * Read new bytes from sub.filePath into sub.offset/pending/inode in place.
52
52
  * Resets offset+pending when the file inode changes (rename+replace rotation).
53
- * Returns parsed line strings ready for JSON.parse.
53
+ * Returns `{ text, byteOffset, byteLength }` per complete line, so callers can
54
+ * build a classifyLine `ref` pointing at the exact bytes on disk without
55
+ * re-deriving offsets downstream. '\n' is always a single UTF-8 byte (0x0A
56
+ * never appears inside a multi-byte continuation sequence), so byte lengths
57
+ * computed from the already-decoded string segments are exact.
54
58
  */
55
59
  async function readDelta(sub) {
56
60
  const stat = await fsp.stat(sub.filePath).catch(() => null);
57
61
  if (!stat) return [];
58
- // Inode changed → file was replaced underfoot; restart from the top.
62
+ // Inode changed → file was replaced underfoot; restart from the top. The
63
+ // line index is keyed to byte offsets in the OLD file, so it's invalid too.
59
64
  if (sub.inode !== undefined && stat.ino !== sub.inode) {
60
65
  sub.offset = 0;
61
66
  sub.pending = '';
67
+ sub.lineIndex.length = 0;
62
68
  }
63
69
  if (stat.size < sub.offset) {
64
- // File was truncated/rotated — start over.
70
+ // File was truncated/rotated — start over, and the index goes with it.
65
71
  sub.offset = 0;
66
72
  sub.pending = '';
73
+ sub.lineIndex.length = 0;
67
74
  }
68
75
  if (stat.size === sub.offset) {
69
76
  sub.inode = stat.ino;
@@ -72,6 +79,9 @@ async function readDelta(sub) {
72
79
  let readFrom = sub.offset;
73
80
  let length = stat.size - readFrom;
74
81
  let skipped = false;
82
+ // Byte offset of the first char of `sub.pending` — it was already counted
83
+ // into sub.offset by the previous readDelta call, so back it out here.
84
+ let textStartOffset = readFrom - Buffer.byteLength(sub.pending, 'utf8');
75
85
  if (length > MAX_DELTA_BYTES) {
76
86
  // Huge unread span — typically a very large transcript (hundreds of MB) on
77
87
  // first attach. Materializing the whole thing into a Buffer + decoded string
@@ -85,6 +95,7 @@ async function readDelta(sub) {
85
95
  length = stat.size - readFrom;
86
96
  sub.pending = '';
87
97
  skipped = true;
98
+ textStartOffset = readFrom;
88
99
  }
89
100
  const fd = await fsp.open(sub.filePath, 'r');
90
101
  try {
@@ -93,10 +104,23 @@ async function readDelta(sub) {
93
104
  const text = sub.pending + buf.toString('utf8');
94
105
  const parts = text.split('\n');
95
106
  sub.pending = parts.pop() ?? '';
96
- if (skipped) parts.shift(); // discard the partial line at the seek boundary
107
+ let cursor = textStartOffset;
108
+ if (skipped) {
109
+ // Discard the partial line at the seek boundary, but still advance the
110
+ // cursor past its bytes (+1 for the newline) so kept lines' offsets stay
111
+ // accurate.
112
+ const discarded = parts.shift() ?? '';
113
+ cursor += Buffer.byteLength(discarded, 'utf8') + 1;
114
+ }
115
+ const lines = [];
116
+ for (const part of parts) {
117
+ const byteLength = Buffer.byteLength(part, 'utf8');
118
+ if (part) lines.push({ text: part, byteOffset: cursor, byteLength });
119
+ cursor += byteLength + 1; // +1 for the newline separator
120
+ }
97
121
  sub.offset = stat.size;
98
122
  sub.inode = stat.ino;
99
- return parts.filter(Boolean);
123
+ return lines;
100
124
  } finally {
101
125
  await fd.close();
102
126
  }
@@ -105,27 +129,130 @@ async function readDelta(sub) {
105
129
  async function doFlush(sub, { emit = true } = {}) {
106
130
  const lines = await readDelta(sub);
107
131
  for (const line of lines) {
132
+ // Index every line — including ones that fail to parse — so line numbers
133
+ // and byte offsets stay correct for paged reads regardless of content.
134
+ // This index (not the parsed events) is what's kept in memory long-term.
135
+ sub.lineIndex.push({ byteOffset: line.byteOffset, byteLength: line.byteLength });
108
136
  let obj;
109
137
  try {
110
- obj = JSON.parse(line);
138
+ obj = JSON.parse(line.text);
111
139
  } catch {
112
140
  continue;
113
141
  }
114
- const ev = classifyLine(obj);
115
- if (!ev) continue;
116
- // Ring buffer (cap at 500 entries to bound memory).
117
- sub.buffer.push(ev);
118
- if (sub.buffer.length > 500) sub.buffer.shift();
119
- if (emit) sendIfAlive(window, `transcript:event:${sub.tabId}`, ev);
120
- // Mirror to OTEL — no-op when disabled. We emit on the initial drain too
121
- // so backfilled transcripts show up in the trace store.
122
- otel.recordTranscriptEvent({
123
- tabId: sub.tabId,
124
- tabCwd: sub.cwd,
125
- kind: ev.kind,
126
- data: ev.data,
127
- ts: Date.now(),
128
- });
142
+ const ref = { filePath: sub.filePath, byteOffset: line.byteOffset, byteLength: line.byteLength };
143
+ const events = classifyLine(obj, ref);
144
+ for (const ev of events) {
145
+ if (emit) sendIfAlive(window, `transcript:event:${sub.tabId}`, ev);
146
+ // Mirror to OTEL — no-op when disabled. We emit on the initial drain too
147
+ // 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.
152
+ otel.recordTranscriptEvent({
153
+ tabId: sub.tabId,
154
+ tabCwd: sub.cwd,
155
+ kind: ev.kind,
156
+ data: ev.data,
157
+ ts: Date.now(),
158
+ });
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Read events for JSONL lines [startLine, endLine] (inclusive, 0-based) from
165
+ * disk using the subscription's line-offset index — never the whole file.
166
+ * A single positional fd.read covers the byte span for the whole requested
167
+ * window; lines are then re-split and parsed from that buffer only. This is
168
+ * the only path a caller should use to fetch history beyond what has already
169
+ * streamed via transcript:event — it never touches OTEL (which fires exactly
170
+ * once per line, in doFlush, as new bytes are indexed) and never re-emits.
171
+ */
172
+ async function readPage(sub, startLine, endLine) {
173
+ const total = sub.lineIndex.length;
174
+ const from = Math.max(0, Math.min(startLine, total - 1));
175
+ const to = Math.max(0, Math.min(endLine, total - 1));
176
+ if (total === 0 || from > to) return { events: [], totalLines: total };
177
+ const first = sub.lineIndex[from];
178
+ const last = sub.lineIndex[to];
179
+ const spanOffset = first.byteOffset;
180
+ const spanLength = last.byteOffset + last.byteLength - spanOffset;
181
+ const events = [];
182
+ if (spanLength <= 0) return { events, totalLines: total };
183
+ const fd = await fsp.open(sub.filePath, 'r');
184
+ try {
185
+ const buf = Buffer.alloc(spanLength);
186
+ await fd.read(buf, 0, spanLength, spanOffset);
187
+ for (let lineNo = from; lineNo <= to; lineNo++) {
188
+ const entry = sub.lineIndex[lineNo];
189
+ const relOffset = entry.byteOffset - spanOffset;
190
+ const text = buf.toString('utf8', relOffset, relOffset + entry.byteLength);
191
+ let obj;
192
+ try {
193
+ obj = JSON.parse(text);
194
+ } catch {
195
+ continue; // malformed line: index slot preserved, nothing to render
196
+ }
197
+ const ref = { filePath: sub.filePath, byteOffset: entry.byteOffset, byteLength: entry.byteLength };
198
+ for (const ev of classifyLine(obj, ref)) {
199
+ events.push({ ...ev, lineNumber: lineNo });
200
+ }
201
+ }
202
+ } finally {
203
+ await fd.close();
204
+ }
205
+ return { events, totalLines: total };
206
+ }
207
+
208
+ async function pageEvents(tabId, startLine, endLine) {
209
+ const sub = subs.get(tabId);
210
+ if (!sub) return { events: [], totalLines: 0 };
211
+ return readPage(sub, startLine, endLine);
212
+ }
213
+
214
+ // Defensive ceiling on a single-line ref read (transcript:readRef). The
215
+ // largest real JSONL lines observed are multi-MB tool_results; 64 MB is far
216
+ // beyond plausible for ONE line while still bounding a hostile/corrupt ref.
217
+ const MAX_REF_BYTES = 64 * 1024 * 1024;
218
+
219
+ /**
220
+ * Read the exact bytes of ONE JSONL line via the classifier's byte reference
221
+ * ({ filePath, byteOffset, byteLength } — threaded per-event since PRD
222
+ * transcript-classifier-multi-emit). This is the renderer's expand-to-full-
223
+ * payload path: the ring-buffered event carries only a bounded preview, the
224
+ * full untruncated line lives on disk. Positional fd.read only — never the
225
+ * whole file (same OOM discipline as readDelta/readPage).
226
+ *
227
+ * filePath is renderer-supplied, so it is validated with config.cjs's
228
+ * realpath boundary check AND pinned to the transcripts root
229
+ * (~/.claude/projects/) — a ref can never read arbitrary home-dir files.
230
+ */
231
+ async function readRef({ filePath, byteOffset, byteLength }) {
232
+ const { validatePath } = require('./config.cjs');
233
+ let real;
234
+ try {
235
+ real = validatePath(filePath);
236
+ } catch (e) {
237
+ return { ok: false, error: e?.message || 'invalid path' };
238
+ }
239
+ const transcriptsRoot = path.join(os.homedir(), '.claude', 'projects') + path.sep;
240
+ if (!real.startsWith(transcriptsRoot)) {
241
+ return { ok: false, error: 'ref outside transcripts root' };
242
+ }
243
+ if (byteLength > MAX_REF_BYTES) {
244
+ return { ok: false, error: 'ref byteLength exceeds cap' };
245
+ }
246
+ let fd;
247
+ try {
248
+ fd = await fsp.open(real, 'r');
249
+ const buf = Buffer.alloc(byteLength);
250
+ const { bytesRead } = await fd.read(buf, 0, byteLength, byteOffset);
251
+ return { ok: true, text: buf.toString('utf8', 0, bytesRead) };
252
+ } catch (e) {
253
+ return { ok: false, error: e?.message || 'read failed' };
254
+ } finally {
255
+ await fd?.close().catch(() => {});
129
256
  }
130
257
  }
131
258
 
@@ -227,15 +354,20 @@ async function subscribe({ tabId, cwd, sessionUuid }) {
227
354
  filePath,
228
355
  offset: 0,
229
356
  pending: '',
230
- buffer: [],
357
+ // Line-offset index (byteOffset + byteLength per JSONL line) — this is
358
+ // what's kept in memory across the life of the subscription, not the
359
+ // parsed events themselves. See readPage() for how events are re-derived
360
+ // from it on demand.
361
+ lineIndex: [],
231
362
  watcher: null,
232
363
  flushing: null,
233
364
  dirty: false,
234
365
  };
235
366
  // If the file already exists, read current content as replay. Do not emit
236
- // during this initial drain — the renderer drains sub.buffer via
237
- // `transcript:buffer` after `transcript:subscribe` resolves. Emitting here
238
- // would race the renderer's onEvent listener registration and drop events.
367
+ // during this initial drain — the renderer drains history via
368
+ // `transcript:buffer` (or `transcript:page`) after `transcript:subscribe`
369
+ // resolves. Emitting here would race the renderer's onEvent listener
370
+ // registration and drop events.
239
371
  if (fs.existsSync(filePath)) {
240
372
  await doFlush(sub, { emit: false });
241
373
  }
@@ -257,9 +389,21 @@ function unsubscribe(tabId) {
257
389
  release(tabId);
258
390
  }
259
391
 
260
- function getBuffer(tabId) {
392
+ /**
393
+ * Full-history drain used by the `transcript:buffer` IPC (initial replay on
394
+ * subscribe / tab-switch resume). Pages the entire indexed range from disk —
395
+ * bounded by the index's byte offsets, never a whole-file read — rather than
396
+ * the old fixed 500-entry ring buffer, so scrolling to the top of a long
397
+ * session reaches the genuine first event instead of a truncated window.
398
+ * Large sessions should prefer transcript:page for a specific window; this
399
+ * stays for existing consumers (live.ts, chat.ts, terminalDigest.ts) that
400
+ * expect one full drain.
401
+ */
402
+ async function getBuffer(tabId) {
261
403
  const sub = subs.get(tabId);
262
- return sub ? sub.buffer.slice() : [];
404
+ if (!sub || sub.lineIndex.length === 0) return [];
405
+ const { events } = await readPage(sub, 0, sub.lineIndex.length - 1);
406
+ return events;
263
407
  }
264
408
 
265
409
  // Cap on the transcript file we'll fully re-read for a token-usage summary —
@@ -305,11 +449,13 @@ async function usageForOne(filePath) {
305
449
  } catch {
306
450
  continue;
307
451
  }
308
- const ev = classifyLine(obj);
309
- if (!ev || ev.kind !== 'usage') continue;
310
- const u = ev.data || {};
311
- inputTokens += u.input_tokens ?? u.inputTokens ?? 0;
312
- outputTokens += u.output_tokens ?? u.outputTokens ?? 0;
452
+ const events = classifyLine(obj);
453
+ for (const ev of events) {
454
+ if (ev.kind !== 'usage') continue;
455
+ const u = ev.data || {};
456
+ inputTokens += u.input_tokens ?? u.inputTokens ?? 0;
457
+ outputTokens += u.output_tokens ?? u.outputTokens ?? 0;
458
+ }
313
459
  }
314
460
  const usage = { inputTokens, outputTokens };
315
461
  usageCache.set(filePath, { mtimeMs: stat.mtimeMs, size: stat.size, usage });
@@ -354,6 +500,8 @@ function registerTranscriptHandlers() {
354
500
  ipcMain.handle('transcript:buffer', v(s.transcriptTabId, ({ tabId }) => getBuffer(tabId)));
355
501
  ipcMain.handle('transcript:path', v(s.transcriptPath, ({ cwd, sessionUuid }) => transcriptPath(cwd, sessionUuid)));
356
502
  ipcMain.handle('transcript:usageFor', v(s.transcriptUsageFor, ({ cwd, sessionIds }) => usageFor(cwd, sessionIds)));
503
+ ipcMain.handle('transcript:page', v(s.transcriptPage, ({ tabId, startLine, endLine }) => pageEvents(tabId, startLine, endLine)));
504
+ ipcMain.handle('transcript:readRef', v(s.transcriptReadRef, (ref) => readRef(ref)));
357
505
  }
358
506
 
359
507
  module.exports = {
@@ -366,4 +514,15 @@ module.exports = {
366
514
  transcriptPath,
367
515
  classifyLine,
368
516
  usageFor,
517
+ pageEvents,
518
+ readRef,
519
+ // Exported for unit tests exercising the array-of-events flush path
520
+ // (doFlush) and the line-offset index it maintains — not part of the IPC
521
+ // surface.
522
+ subscribe,
523
+ getBuffer,
524
+ // Test-only accessor onto the raw Subscription object (its lineIndex in
525
+ // particular) — asserting a memory ceiling requires inspecting what's
526
+ // actually held, not just what a read API returns.
527
+ __getSubForTest: (tabId) => subs.get(tabId),
369
528
  };
@@ -649,13 +649,14 @@ async function pollSessionWatcher(w) {
649
649
  for (const line of res.lines) {
650
650
  let obj;
651
651
  try { obj = JSON.parse(line); } catch { continue; }
652
- const ev = require('./transcripts.cjs').classifyLine(obj);
653
- if (!ev) continue;
654
- const s = deriveState(ev, obj);
655
- if (s) nextState = s;
656
- if (ev.kind === 'assistant') {
657
- const text = extractAssistantText(obj);
658
- if (text) { newAssistantText = text; newMsgId = obj.uuid || obj.message?.id || `${w.tabId}:${res.size}`; }
652
+ const events = require('./transcripts.cjs').classifyLine(obj);
653
+ for (const ev of events) {
654
+ const s = deriveState(ev, obj);
655
+ if (s) nextState = s;
656
+ if (ev.kind === 'assistant') {
657
+ const text = extractAssistantText(obj);
658
+ if (text) { newAssistantText = text; newMsgId = obj.uuid || obj.message?.id || `${w.tabId}:${res.size}`; }
659
+ }
659
660
  }
660
661
  }
661
662