claude-code-session-manager 0.58.0 → 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 (38) hide show
  1. package/README.md +2 -2
  2. package/dist/assets/{TiptapBody-BEBLdJl_.js → TiptapBody-PUx4oZTh.js} +1 -1
  3. package/dist/assets/{index-DEQzGYa6.js → index-BPnfPdLW.js} +1076 -1081
  4. package/dist/assets/{index-DwUffaDq.css → index-CPMP2XZ_.css} +1 -1
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/src/main/__tests__/classifyTranscriptLine.test.cjs +128 -17
  8. package/src/main/__tests__/epicValidationHook.test.cjs +291 -0
  9. package/src/main/__tests__/prdMigration.test.cjs +160 -0
  10. package/src/main/__tests__/projectPages.test.cjs +151 -0
  11. package/src/main/__tests__/promptSessionEvents.test.cjs +74 -0
  12. package/src/main/__tests__/scheduler-effective-concurrency.test.cjs +32 -12
  13. package/src/main/__tests__/scheduler-heal-refusal.test.cjs +61 -0
  14. package/src/main/__tests__/scheduler-notify-originating-tab.test.cjs +74 -0
  15. package/src/main/__tests__/transcripts-doFlush-array.test.cjs +118 -0
  16. package/src/main/__tests__/transcripts-paged-reads.test.cjs +233 -0
  17. package/src/main/__tests__/uniquePrdNumbers.test.cjs +7 -2
  18. package/src/main/health.cjs +5 -1
  19. package/src/main/index.cjs +1 -1
  20. package/src/main/ipcSchemas.cjs +26 -3
  21. package/src/main/lib/__tests__/schedulerBatchDepends.test.cjs +130 -0
  22. package/src/main/lib/classifyTranscriptLine.cjs +131 -48
  23. package/src/main/lib/epicMint.cjs +6 -1
  24. package/src/main/lib/epicValidationHook.cjs +192 -0
  25. package/src/main/lib/prdMigration.cjs +76 -5
  26. package/src/main/lib/promptSessionSchema.cjs +16 -0
  27. package/src/main/lib/promptSessionsCreateEpic.cjs +5 -3
  28. package/src/main/lib/schedulerBatch.cjs +70 -111
  29. package/src/main/lib/schedulerConfig.cjs +0 -1
  30. package/src/main/otel.cjs +3 -1
  31. package/src/main/projectPages.cjs +60 -14
  32. package/src/main/promptSessionEvents.cjs +16 -1
  33. package/src/main/scheduler.cjs +226 -47
  34. package/src/main/templates/project-pages-default-home.html +123 -0
  35. package/src/main/transcripts.cjs +191 -32
  36. package/src/main/webRemote.cjs +8 -7
  37. package/src/preload/api.d.ts +75 -9
  38. package/src/preload/index.cjs +3 -0
@@ -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
 
@@ -171,10 +171,20 @@ export type TranscriptEventKind =
171
171
  | 'message'
172
172
  | string;
173
173
 
174
+ export interface TranscriptEventRef {
175
+ filePath: string;
176
+ byteOffset: number;
177
+ byteLength: number;
178
+ }
179
+
174
180
  export interface TranscriptEvent {
175
181
  kind: TranscriptEventKind;
176
182
  data: unknown;
177
183
  raw: unknown;
184
+ /** Short, bounded human-scannable preview of `data` — never the source of truth. */
185
+ previewText: string;
186
+ /** Byte range of this event's source line on disk; null when classified with no file context. */
187
+ ref: TranscriptEventRef | null;
178
188
  }
179
189
 
180
190
  export interface SubscribeResult {
@@ -396,7 +406,6 @@ export interface ScheduleConfig {
396
406
  /** Minutes to wait after the 5h reset before firing pending jobs.
397
407
  * Used by 'on-reset'. Ignored by 'when-available' and 'manual'. */
398
408
  offsetMinutes: number;
399
- concurrencyCap: number;
400
409
  defaultCwd: string;
401
410
  /** Auto-fire policy. Default 'when-available'. */
402
411
  firePolicy: ScheduleFirePolicy;
@@ -547,14 +556,45 @@ export interface SchedulePollHealth {
547
556
  lastFailureKind: string | null;
548
557
  }
549
558
 
559
+ /** One `pending` job held by an unsatisfied dependency, and which dep. */
560
+ export interface ScheduleJobHold {
561
+ slug: string;
562
+ dep: string;
563
+ depStatus: string;
564
+ }
565
+
566
+ /** Outcome of the scheduler's last tick — why the batch was (or wasn't) fired.
567
+ * Every field is computed by tickQueue; nothing here is derived in the UI. */
568
+ export interface ScheduleLastTick {
569
+ fired: boolean;
570
+ reason: 'held' | 'slots-exhausted' | 'memory-deferred' | 'already-running' | 'drained' | 'paused' | string;
571
+ /** Human-readable one-liner for the binding constraint, when there is one. */
572
+ detail?: string;
573
+ deferredCount?: number;
574
+ runningCount?: number;
575
+ count?: number;
576
+ availableMb?: number;
577
+ threshold?: number;
578
+ holders?: { owner: string; at: string }[];
579
+ holds?: ScheduleJobHold[];
580
+ at: string;
581
+ }
582
+
550
583
  export interface ScheduleEffectiveConcurrency {
584
+ /** Total slots in the machine-wide sessionSlots pool — the ONLY concurrency
585
+ * limit. The scheduler no longer carries a private concurrencyCap. */
551
586
  cap: number;
552
- /** 'env' when SM_SCHEDULER_MAX_CONCURRENCY pins the cap; 'config' when concurrencyCap governs. */
553
- source: 'env' | 'config';
587
+ /** Slots free right now (pool total minus held, incl. chat runs). */
588
+ free: number;
589
+ /** 'env' when SM_SESSION_SLOTS pins the pool; 'pool' when the persisted
590
+ * Home-tab value governs. */
591
+ source: 'env' | 'pool';
554
592
  }
555
593
 
556
594
  export interface ScheduleStateSnapshot {
557
595
  config: ScheduleConfig & { supervisor?: SupervisorConfig };
596
+ /** Why the last tick fired (or didn't). Null before the first tick. */
597
+ lastTick?: ScheduleLastTick | null;
558
598
  jobs: ScheduleJob[];
559
599
  scheduledFor: string | null;
560
600
  lastRunAt: string | null;
@@ -948,17 +988,28 @@ export type ProjectBriefUpdateResult =
948
988
  | { ok: true; brief: ProjectBrief }
949
989
  | { ok: false; error: string };
950
990
 
951
- // ────────────────────────────────────────────── Project Pages (PRD 929-932)
991
+ // ────────────────────────────────────────────── Project Pages (PRD 929-932, 969, project-home-hosted-html-spec)
952
992
  export interface ProjectPagesOutput {
953
993
  home: string;
954
- marketing: string;
955
- feature: string;
956
- architecture: string;
957
- generatedAt: string;
994
+ /** Absent when `isDefault` is true — the shipped default only covers the home lens. */
995
+ marketing?: string;
996
+ /** Absent when `isDefault` is true — the shipped default only covers the home lens. */
997
+ feature?: string;
998
+ /** Absent when `isDefault` is true — the shipped default only covers the home lens. */
999
+ architecture?: string;
1000
+ /** Absent for output generated before the 'brief' lens existed (PRD 969), or
1001
+ * when `isDefault` is true — a project must regenerate to pick it up. */
1002
+ brief?: string;
1003
+ /** null for the shipped default (never generated). */
1004
+ generatedAt: string | null;
1005
+ /** True when `home` is the build-time shipped default, not this project's own
1006
+ * generated output — the renderer must label provenance rather than infer it. */
1007
+ isDefault: boolean;
958
1008
  }
959
1009
 
960
1010
  export interface ProjectPagesGetResult {
961
- /** null when no manifest/output files exist yet — the empty-state signal. */
1011
+ /** Never null in practice — a project with no generated output still gets the
1012
+ * shipped default (isDefault: true). The type stays nullable defensively. */
962
1013
  output: ProjectPagesOutput | null;
963
1014
  }
964
1015
 
@@ -1357,6 +1408,11 @@ export interface SessionManagerAPI {
1357
1408
  /** Permanently destroy the sub (genuine tab close). */
1358
1409
  closeTab: (tabId: string) => Promise<{ ok: boolean }>;
1359
1410
  buffer: (tabId: string) => Promise<TranscriptEvent[]>;
1411
+ /** Paged read over the subscribed transcript's line-offset index — [startLine, endLine] inclusive, 0-based.
1412
+ * Reads only the requested byte range from disk; never materializes the whole file. */
1413
+ page: (tabId: string, startLine: number, endLine: number) => Promise<{ events: (TranscriptEvent & { lineNumber: number })[]; totalLines: number }>;
1414
+ /** Full untruncated single-line read via a classifier byte reference (expand-to-full path). */
1415
+ readRef: (ref: TranscriptEventRef) => Promise<{ ok: boolean; text?: string; error?: string }>;
1360
1416
  pathFor: (cwd: string, sessionUuid: string) => Promise<string>;
1361
1417
  /** Batched token-usage totals, one map entry per requested sessionId. A
1362
1418
  * session with no transcript file yet (or one over the main-process size
@@ -1752,6 +1808,16 @@ export interface PromptSessionsCreateEpicPayload {
1752
1808
  runId?: string;
1753
1809
  sourceTabId?: string;
1754
1810
  };
1811
+ /** Full first-prompt body + its labeled sections (epicIntake.ts's
1812
+ * composeEpicIntake) — carried alongside goalText so the Epic's first
1813
+ * turn can render a structured AIM briefing card. */
1814
+ openingPrompt?: string;
1815
+ sections?: Array<{
1816
+ kind: 'actor' | 'injection' | 'input' | 'mission' | 'goal' | 'reference';
1817
+ label: string;
1818
+ text: string;
1819
+ source?: string;
1820
+ }>;
1755
1821
  }
1756
1822
 
1757
1823
  export interface PromptSessionsCreateEpicResult {
@@ -121,6 +121,9 @@ contextBridge.exposeInMainWorld('api', {
121
121
  unsubscribe: (tabId) => ipcRenderer.invoke('transcript:unsubscribe', { tabId }),
122
122
  closeTab: (tabId) => ipcRenderer.invoke('transcript:close', { tabId }),
123
123
  buffer: (tabId) => ipcRenderer.invoke('transcript:buffer', { tabId }),
124
+ page: (tabId, startLine, endLine) =>
125
+ ipcRenderer.invoke('transcript:page', { tabId, startLine, endLine }),
126
+ readRef: (ref) => ipcRenderer.invoke('transcript:readRef', ref),
124
127
  pathFor: (cwd, sessionUuid) =>
125
128
  ipcRenderer.invoke('transcript:path', { cwd, sessionUuid }),
126
129
  usageFor: (cwd, sessionIds) =>