cc-viewer 1.7.1 → 1.7.3

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.
@@ -22,12 +22,15 @@
22
22
  // through log-watcher's processWatchedEntry pipeline — the same enrichment,
23
23
  // reconstruction and side-events the v1 tail produced.
24
24
 
25
- import { watch, existsSync, readdirSync, statSync, openSync, readSync, closeSync } from 'node:fs';
25
+ import { watch, existsSync, readdirSync, readFileSync, statSync, openSync, readSync, closeSync } from 'node:fs';
26
26
  import { join } from 'node:path';
27
27
  import { reportSwallowed } from '../error-report.js';
28
28
  import { createIncrementalReconstructor } from '../delta-reconstructor.js';
29
29
  import { processWatchedEntry, sendEventToClients, sendEventRawToClients } from '../log-watcher.js';
30
30
  import { SessionSynthesizer } from './adapter.js';
31
+ import { isDiscardableSession } from './session-select.js';
32
+ import { isForeignLiveOwned } from './session-owner.js';
33
+ import { isConvertRunning } from './convert-manager.js';
31
34
  import { computeCacheLoss } from './meta-rows.js';
32
35
  import { classifyRequest } from '../../../src/utils/requestType.js';
33
36
 
@@ -37,12 +40,33 @@ const DEFER_MS = 3000; // synthesizer park deadline (lagging lines)
37
40
  const ATTACH_RECENT_MS = 5 * 60 * 1000; // pre-existing dirs considered "live"
38
41
  const TICK_RETRY_MS = 250; // in-process tick raced the first queue drain
39
42
  const TICK_RETRY_MAX = 8;
43
+ // Idle cursor eviction: a followed dir whose journal hasn't moved for this
44
+ // long is detached, freeing its synthesizer/reconstructor state (a migration
45
+ // can promote hundreds of dirs at once — holding all of them pins ~the whole
46
+ // project in heap). Re-attach self-heals via the safety poll's mtime-bump
47
+ // scan or the leader's tick() (both suppress history on a seen dir).
48
+ const IDLE_EVICT_MS = 10 * 60 * 1000;
40
49
 
41
- /** Read newly appended complete lines from a file cursor {path, offset,
42
- * pending}. Returns an array of raw line strings (possibly empty). A file
50
+ const READ_CHUNK_BYTES = 8 * 1024 * 1024;
51
+ // Node's max string length (~512MiB) a longer line can never be decoded.
52
+ const MAX_LINE_BYTES = 0x1fffffe8;
53
+
54
+ /** Read newly appended complete lines from a file cursor {path, offset}
55
+ * (partial-line carry lives on lazily-initialized _pendBufs/_pendBytes/
56
+ * _skipLine cursor fields). Returns an array of raw line strings (possibly
57
+ * empty). A file
43
58
  * that shrank (should never happen — v2 files are append-only) returns null
44
- * so the caller can rebuild the cursor. */
45
- function readNewLines(cursor) {
59
+ * so the caller can rebuild the cursor.
60
+ *
61
+ * Chunked + byte-level newline split (issue #129 twin): the first attach to
62
+ * a session seeds from offset 0, so one whole-file read + decode would throw
63
+ * ERR_STRING_TOO_LONG on an oversized journal and crash-loop the boot path
64
+ * exactly like the cold-scan crash the streaming reader fixed. The partial
65
+ * tail line is carried on the cursor as Buffer fragments (never decoded
66
+ * until its newline lands); a single line past the string cap is skipped
67
+ * with a report instead of thrown. Exported for tests (chunkBytes seam).
68
+ */
69
+ export function readNewLines(cursor, chunkBytes = READ_CHUNK_BYTES, maxLineBytes = MAX_LINE_BYTES) {
46
70
  let size;
47
71
  try {
48
72
  size = statSync(cursor.path).size;
@@ -51,23 +75,74 @@ function readNewLines(cursor) {
51
75
  }
52
76
  if (size < cursor.offset) return null;
53
77
  if (size === cursor.offset) return [];
54
- const toRead = size - cursor.offset;
55
- const buf = Buffer.alloc(toRead);
78
+ if (cursor._pendBufs === undefined) {
79
+ cursor._pendBufs = [];
80
+ cursor._pendBytes = 0;
81
+ cursor._skipLine = false;
82
+ }
83
+ const out = [];
56
84
  let fd;
57
85
  try {
58
86
  fd = openSync(cursor.path, 'r');
59
- readSync(fd, buf, 0, toRead, cursor.offset);
87
+ const chunk = Buffer.alloc(Math.min(chunkBytes, size - cursor.offset));
88
+ while (cursor.offset < size) {
89
+ const toRead = Math.min(chunk.length, size - cursor.offset);
90
+ const n = readSync(fd, chunk, 0, toRead, cursor.offset);
91
+ if (n === 0) break;
92
+ cursor.offset += n;
93
+ const view = chunk.subarray(0, n);
94
+ let from = 0;
95
+ while (from < n) {
96
+ const nl = view.indexOf(10, from);
97
+ if (nl === -1) {
98
+ if (!cursor._skipLine) {
99
+ const restLen = n - from;
100
+ if (cursor._pendBytes + restLen > maxLineBytes) {
101
+ cursor._skipLine = true;
102
+ cursor._pendBufs = [];
103
+ cursor._pendBytes = 0;
104
+ reportSwallowed('v2-live.read-line-too-long', new Error(`${cursor.path}: line exceeds ${maxLineBytes} bytes — skipped`));
105
+ } else {
106
+ // chunk is reused next readSync — the carried slice must own its bytes
107
+ cursor._pendBufs.push(Buffer.from(view.subarray(from)));
108
+ cursor._pendBytes += restLen;
109
+ }
110
+ }
111
+ break;
112
+ }
113
+ if (cursor._skipLine) {
114
+ cursor._skipLine = false; // the oversized line ends at this newline
115
+ } else {
116
+ const seg = view.subarray(from, nl);
117
+ let text = null;
118
+ try {
119
+ if (cursor._pendBufs.length > 0) {
120
+ cursor._pendBufs.push(seg);
121
+ text = Buffer.concat(cursor._pendBufs).toString('utf-8');
122
+ } else {
123
+ text = seg.toString('utf-8');
124
+ }
125
+ } catch (err) {
126
+ reportSwallowed('v2-live.read-line-too-long', err);
127
+ }
128
+ cursor._pendBufs = [];
129
+ cursor._pendBytes = 0;
130
+ if (text !== null) {
131
+ const t = text.trim();
132
+ if (t) out.push(t);
133
+ }
134
+ }
135
+ from = nl + 1;
136
+ }
137
+ }
60
138
  } catch (err) {
139
+ // Deliver what was already parsed; offset only advanced past read bytes,
140
+ // so the next poll resumes from the failure point.
61
141
  reportSwallowed('v2-live.read', err);
62
- return [];
63
142
  } finally {
64
143
  if (fd !== undefined) { try { closeSync(fd); } catch { /* already closed */ } }
65
144
  }
66
- cursor.offset = size;
67
- const chunk = cursor.pending + buf.toString('utf-8');
68
- const parts = chunk.split('\n');
69
- cursor.pending = parts.pop() || '';
70
- return parts.map((l) => l.trim()).filter(Boolean);
145
+ return out;
71
146
  }
72
147
 
73
148
  export class V2LiveFeed {
@@ -82,7 +157,11 @@ export class V2LiveFeed {
82
157
  * @param {number} [opts.safetyPollMs] - 0 disables the timer (tests drive
83
158
  * _safetyTick manually)
84
159
  * @param {number} [opts.deferMs]
160
+ * @param {number} [opts.idleEvictMs] - 0 disables idle cursor eviction (tests)
85
161
  * @param {Function} [opts.now]
162
+ * @param {Function} [opts.isConvertRunningFn] - injection seam (tests)
163
+ * @param {Function} [opts.isForeignLiveOwnedFn] - injection seam (tests):
164
+ * (dir) => boolean, defaults to session-owner's isForeignLiveOwned
86
165
  */
87
166
  constructor(opts = {}) {
88
167
  this._clients = opts.clients || [];
@@ -92,13 +171,21 @@ export class V2LiveFeed {
92
171
  this._watchImpl = opts.watchImpl || watch;
93
172
  this._safetyPollMs = typeof opts.safetyPollMs === 'number' ? opts.safetyPollMs : SAFETY_POLL_MS;
94
173
  this._deferMs = typeof opts.deferMs === 'number' ? opts.deferMs : DEFER_MS;
174
+ this._idleEvictMs = typeof opts.idleEvictMs === 'number' ? opts.idleEvictMs : IDLE_EVICT_MS;
95
175
  this._now = opts.now || Date.now;
176
+ this._isConvertRunning = opts.isConvertRunningFn || isConvertRunning;
177
+ // Claim owners are compared against THIS process's pid: the feed must
178
+ // co-reside with its V2Writer in one process (worker_threads would still
179
+ // share the pid; moving the feed into a forked CHILD process would make
180
+ // every own dir look foreign and break single-window live-follow).
181
+ this._isForeignLiveOwned = opts.isForeignLiveOwnedFn || isForeignLiveOwned;
96
182
  // Wire v3 (V3.S2): when on, every emitted item ALSO broadcasts a metadata
97
183
  // row (v2_requests_delta). Explicit ctor param — this module has no access
98
184
  // to the server deps object.
99
185
  this._wireV3 = !!opts.wireV3;
100
186
  this._sessions = new Map(); // dir → cursor bundle
101
187
  this._seenDirs = new Map(); // dir → last observed journal mtimeMs (attached or not)
188
+ this._discardGated = new Set(); // dirs refused by the discard gate — first real attach must NOT suppress history
102
189
  this._sessionsRoot = null;
103
190
  this._rootWatcher = null;
104
191
  this._safetyTimer = null;
@@ -126,6 +213,7 @@ export class V2LiveFeed {
126
213
  for (const cur of this._sessions.values()) this._closeCursor(cur);
127
214
  this._sessions.clear();
128
215
  this._seenDirs.clear();
216
+ this._discardGated.clear();
129
217
  this._sessionsRoot = null;
130
218
  }
131
219
 
@@ -138,7 +226,12 @@ export class V2LiveFeed {
138
226
  if (!this._active || !sessionDir) return;
139
227
  let cur = this._sessions.get(sessionDir);
140
228
  if (!cur) {
141
- cur = this._attach(sessionDir, { suppressExisting: false });
229
+ // A dir we have ALREADY seen re-attaching through tick() is an idle
230
+ // eviction (or a watcher loss) resuming — its backlog is history and
231
+ // must be seeded suppressed, or the re-attach replays the whole session
232
+ // through the broadcast path (the exact OOM this module was fixed for).
233
+ // Only a genuinely never-seen dir emits from byte 0.
234
+ cur = this._attach(sessionDir, { suppressExisting: this._seenDirs.has(sessionDir) });
142
235
  if (!cur) {
143
236
  if (_retries < TICK_RETRY_MAX) {
144
237
  setTimeout(() => this.tick(sessionDir, _retries + 1), TICK_RETRY_MS);
@@ -246,6 +339,59 @@ export class V2LiveFeed {
246
339
  _attach(dir, { suppressExisting }) {
247
340
  if (this._sessions.has(dir)) return this._sessions.get(dir);
248
341
  if (!existsSync(join(dir, 'journal.jsonl'))) return null;
342
+ // Multi-window isolation: a dir exclusively claimed by ANOTHER live ccv
343
+ // window (owner.lock, pid-liveness validity) never enters this feed — its
344
+ // traffic belongs to that window. Checked BEFORE the discard gate so a
345
+ // foreign dir never lands in _discardGated (whose delete side-effect would
346
+ // flip suppressExisting to false on a later attach and flood clients with
347
+ // the whole foreign history). Re-evaluated on every attach attempt — no
348
+ // persistent gate set — so a dead owner's claim stops mattering the
349
+ // moment its pid dies. Own dirs (tick path) and unclaimed teammate/IM
350
+ // dirs pass through untouched.
351
+ if (this._isForeignLiveOwned(dir)) return null;
352
+ // Discardable sessions (quota-probe orphans — no main/teammate req, no
353
+ // meta.leader) are never followed: single choke point, every attach path
354
+ // (_initialScan / _maybeAttachNew / tick / _safetyTick / _rebuildCursor)
355
+ // funnels here and handles null. Self-healing when a dir later gains its
356
+ // first main: the leader's own dir re-attaches via tick's retry chain
357
+ // (sub-second); cross-process dirs via the 5s safety poll's mtime-bump
358
+ // scan (_seenDirs bookkeeping stays in the callers, so the bump fires).
359
+ if (isDiscardableSession(dir)) {
360
+ this._discardGated.add(dir);
361
+ return null;
362
+ }
363
+ // A dir previously refused by the discard gate is attaching for the FIRST
364
+ // time — its first renderable turn was never broadcast, so the safety
365
+ // poll's suppressExisting:true (meant for "old stale dir resumed") must
366
+ // not swallow it: cross-process producers (IM worker, second ccv) have no
367
+ // cold-load fallback for a connected client.
368
+ if (this._discardGated.delete(dir)) suppressExisting = false;
369
+ // Migration output is dead history: converted sessions (meta origin:
370
+ // 'convert') have no producer and never append again, yet a promote
371
+ // renames dozens of them into sessions/ at once with fresh birthtimes —
372
+ // full-history seeding synthesized + JSON-round-tripped every entry of
373
+ // every promoted session inside the safety-poll/debounce timers (the
374
+ // 4GB main-thread OOM). Seek their cursors to EOF instead: nothing is
375
+ // read, cloned, or broadcast; the cold-load channel owns their history.
376
+ let convertOrigin = false;
377
+ try {
378
+ const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
379
+ convertOrigin = !!meta && meta.origin === 'convert';
380
+ } catch (err) {
381
+ // meta.json is written synchronously BEFORE the journal's first async
382
+ // drain (ensureSessionDirSync), so journal-present + meta-ABSENT is
383
+ // abnormal. Never fall through to a broadcast replay: mid-conversion
384
+ // (same-process check — the converter worker runs inside the server
385
+ // process) the dir is almost certainly a promote in flight — treat as
386
+ // convert output; a truly missing meta otherwise seeds suppressed (cold
387
+ // load covers the history). Any OTHER errno is a transient lock on an
388
+ // EXISTING meta (Windows AV/EBUSY) — keep the caller's suppress verdict:
389
+ // flipping a fresh live session to suppressed would silently drop its
390
+ // first turn (review finding).
391
+ reportSwallowed('v2-live.meta-read', err);
392
+ if (this._isConvertRunning()) convertOrigin = true;
393
+ else if (err && err.code === 'ENOENT') suppressExisting = true;
394
+ }
249
395
  const cur = {
250
396
  dir,
251
397
  synth: new SessionSynthesizer(dir, { deferMs: this._deferMs, now: this._now }),
@@ -254,32 +400,100 @@ export class V2LiveFeed {
254
400
  // rebase each other's accumulated baseline — v1's tail followed exactly
255
401
  // one file, so a single shared reconstructor was a new interleave surface.
256
402
  reconstructor: createIncrementalReconstructor(),
257
- journal: { path: join(dir, 'journal.jsonl'), offset: 0, pending: '' },
258
- responses: { path: join(dir, 'responses.jsonl'), offset: 0, pending: '' },
259
- convFiles: new Map(), // path → {key, offset, pending}
403
+ journal: { path: join(dir, 'journal.jsonl'), offset: 0 },
404
+ responses: { path: join(dir, 'responses.jsonl'), offset: 0 },
405
+ convFiles: new Map(), // path → {key, offset} (+ lazy _pendBufs carry)
260
406
  watcher: null,
261
407
  debounce: null,
262
408
  reading: false,
263
409
  dirty: false,
264
410
  suppress: !!suppressExisting,
411
+ convertOrigin,
412
+ // Journal req seqs with no done yet. The journal is only touched at
413
+ // request start and completion, so a single >10min request would look
414
+ // "idle" to the mtime-based eviction and get detached mid-flight — its
415
+ // completion would then re-attach suppressed and never resolve the
416
+ // already-broadcast placeholder (review finding). Eviction skips
417
+ // cursors with an open request. A crash orphan (req, no done ever)
418
+ // pins its one session un-evicted — acceptable: eviction is a memory
419
+ // optimization, a stuck live card is a correctness bug.
420
+ openReqs: new Set(),
265
421
  };
266
422
  this._sessions.set(dir, cur);
267
423
  this._seenDirs.set(dir, this._journalMtime(dir));
268
- try {
269
- cur.watcher = this._watchImpl(dir, () => this._scheduleRead(cur));
270
- cur.watcher.on('error', (err) => {
424
+ if (!convertOrigin) {
425
+ try {
426
+ cur.watcher = this._watchImpl(dir, () => this._scheduleRead(cur));
427
+ cur.watcher.on('error', (err) => {
428
+ reportSwallowed('v2-live.session-watch', err);
429
+ try { cur.watcher.close(); } catch { /* already closed */ }
430
+ cur.watcher = null; // safety poll keeps the cursor alive
431
+ });
432
+ } catch (err) {
271
433
  reportSwallowed('v2-live.session-watch', err);
272
- try { cur.watcher.close(); } catch { /* already closed */ }
273
- cur.watcher = null; // safety poll keeps the cursor alive
274
- });
275
- } catch (err) {
276
- reportSwallowed('v2-live.session-watch', err);
434
+ }
435
+ this._readCursor(cur); // seed (suppressed history or brand-new content)
436
+ } else {
437
+ // No per-dir watcher either: a promote can bring hundreds of dead dirs
438
+ // at once and each fs.watch costs an fd. The safety poll's unconditional
439
+ // _readCursor still picks up any (theoretical) append past the seeked
440
+ // offsets until idle eviction reclaims the cursor.
441
+ this._seekCursorsToEof(cur);
277
442
  }
278
- this._readCursor(cur); // seed (suppressed history or brand-new content)
279
443
  cur.suppress = false;
280
444
  return cur;
281
445
  }
282
446
 
447
+ /** Position every cursor of a session at the current end of its file —
448
+ * attach without reading: nothing existing is synthesized or emitted, only
449
+ * bytes appended AFTER this point would flow. Enumerates ALL conv epoch
450
+ * files up front so pre-existing epochs can't be replayed from 0 later. */
451
+ _seekCursorsToEof(cur) {
452
+ const sizeOf = (p) => {
453
+ try { return statSync(p).size; } catch (err) {
454
+ if (err && err.code === 'ENOENT') return 0; // missing file: offset 0 IS its EOF
455
+ // Any other stat failure must never seed offset 0 — that re-arms the
456
+ // full replay this seek exists to prevent. Park the cursor past any
457
+ // real size; a later successful read sees size < offset and rebuilds.
458
+ reportSwallowed('v2-live.seek-stat', err);
459
+ return Number.MAX_SAFE_INTEGER;
460
+ }
461
+ };
462
+ cur.journal.offset = sizeOf(cur.journal.path);
463
+ cur.responses.offset = sizeOf(cur.responses.path);
464
+ for (const { key, path } of this._enumerateConvFiles(cur.dir)) {
465
+ cur.convFiles.set(path, { key, path, offset: sizeOf(path) });
466
+ }
467
+ }
468
+
469
+ /** Enumerate a session's conversation epoch files as [{key, path}], epochs
470
+ * numerically ordered within each conv key — the single home of the
471
+ * two-level conversations/<key>/e<N>.jsonl walk (shared by the EOF-seek
472
+ * attach and the incremental reader; review dedup). NB: file order is NOT
473
+ * guaranteed to be globally seq-ordered (a pre-fix writer restart appended
474
+ * newer seqs into an older epoch file) — the synthesizer's ingestConvLine
475
+ * keeps its event window seq-sorted on insert, so consumers only need a
476
+ * stable feed order. */
477
+ _enumerateConvFiles(dir) {
478
+ const convRoot = join(dir, 'conversations');
479
+ const out = [];
480
+ let keys = [];
481
+ try {
482
+ keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
483
+ } catch {
484
+ return out; // no conversations dir yet
485
+ }
486
+ for (const key of keys) {
487
+ let files = [];
488
+ try {
489
+ files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f));
490
+ } catch { continue; }
491
+ files.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
492
+ for (const f of files) out.push({ key, path: join(convRoot, key, f) });
493
+ }
494
+ return out;
495
+ }
496
+
283
497
  _closeCursor(cur) {
284
498
  if (cur.debounce) { clearTimeout(cur.debounce); cur.debounce = null; }
285
499
  if (cur.watcher) { try { cur.watcher.close(); } catch { /* already closed */ } cur.watcher = null; }
@@ -319,7 +533,11 @@ export class V2LiveFeed {
319
533
  try { line = JSON.parse(raw); } catch (err) {
320
534
  reportSwallowed('v2-live.journal-parse', new Error(`${cur.dir}: ${err.message}`));
321
535
  }
322
- if (line) cur.synth.ingestJournalLine(line);
536
+ if (line) {
537
+ if (line.ph === 'req') cur.openReqs.add(line.seq);
538
+ else if (line.ph === 'done') cur.openReqs.delete(line.seq);
539
+ cur.synth.ingestJournalLine(line);
540
+ }
323
541
  }
324
542
  this._emitDrained(cur);
325
543
  } while (cur.dirty);
@@ -329,44 +547,22 @@ export class V2LiveFeed {
329
547
  }
330
548
 
331
549
  _feedConvFiles(cur) {
332
- const convRoot = join(cur.dir, 'conversations');
333
- if (!existsSync(convRoot)) return;
334
- let keys = [];
335
- try {
336
- keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
337
- } catch {
338
- return;
339
- }
340
- for (const key of keys) {
341
- let files = [];
342
- try {
343
- files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f));
344
- } catch {
345
- continue;
550
+ for (const { key, path } of this._enumerateConvFiles(cur.dir)) {
551
+ let fc = cur.convFiles.get(path);
552
+ if (!fc) {
553
+ fc = { key, path, offset: 0 };
554
+ cur.convFiles.set(path, fc);
346
555
  }
347
- // Numeric epoch order. NB: file order is NOT guaranteed to be globally
348
- // seq-ordered (a pre-fix writer restart appended newer seqs into an
349
- // older epoch file) — the synthesizer's ingestConvLine keeps its event
350
- // window seq-sorted on insert, so feed order only needs to be stable.
351
- files.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
352
- for (const f of files) {
353
- const p = join(convRoot, key, f);
354
- let fc = cur.convFiles.get(p);
355
- if (!fc) {
356
- fc = { key, path: p, offset: 0, pending: '' };
357
- cur.convFiles.set(p, fc);
556
+ const lines = readNewLines(fc);
557
+ if (lines === null) { this._rebuildCursor(cur); return; }
558
+ for (const raw of lines) {
559
+ let ev = null;
560
+ try { ev = JSON.parse(raw); } catch (err) {
561
+ reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
358
562
  }
359
- const lines = readNewLines(fc);
360
- if (lines === null) { this._rebuildCursor(cur); return; }
361
- for (const raw of lines) {
362
- let ev = null;
363
- try { ev = JSON.parse(raw); } catch (err) {
364
- reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
365
- }
366
- if (ev) {
367
- cur.synth.ingestConvLine(key, ev);
368
- if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
369
- }
563
+ if (ev) {
564
+ cur.synth.ingestConvLine(key, ev);
565
+ if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
370
566
  }
371
567
  }
372
568
  }
@@ -471,8 +667,17 @@ export class V2LiveFeed {
471
667
  timestamp: parsed.timestamp || '',
472
668
  url: parsed.url || '',
473
669
  method: parsed.method || 'POST',
474
- kind: item.isMain ? 'main' : (parsed.teammate ? 'teammate' : 'sub'),
475
- mainAgent: parsed.mainAgent === true,
670
+ // conv/evt/kind/mainAgent mirror the cold fold (meta-rows.js foldDir)
671
+ // journal truth, NOT re-derivation. conv is load-bearing: the client
672
+ // assembler's buildEntry is `if (row.conv)`-gated, so a conv-less live
673
+ // row rebuilt every entry with EMPTY messages (chat vanished at stream
674
+ // end). mainAgent must be kind-derived like cold: parsed.mainAgent
675
+ // re-derives from the body and mis-tags countTokens (main system/tools
676
+ // aboard) as true, which would merge its turn into the chat live-only.
677
+ conv: item.conv,
678
+ evt: item.evt,
679
+ kind: item.kind || (item.isMain ? 'main' : (parsed.teammate ? 'teammate' : 'sub')),
680
+ mainAgent: item.isMain === true,
476
681
  teammate: parsed.teammate || undefined,
477
682
  model: parsed.body?.model,
478
683
  proxyUrl: parsed.proxyUrl || undefined,
@@ -527,7 +732,38 @@ export class V2LiveFeed {
527
732
  if (!this._active) return;
528
733
  this._armRootWatcher(); // re-arm after errors / late directory creation
529
734
  // Attached sessions: unconditional slow re-read (fs.watch is lossy).
735
+ // Idle eviction first: a cursor whose journal has not moved for
736
+ // _idleEvictMs is detached, releasing its synthesizer/reconstructor/
737
+ // _v3PrevMain retained state (post-migration this is ~the whole project).
738
+ // _seenDirs keeps the mtime, so the existing mtime-bump scan below (or the
739
+ // leader's tick(), now suppress-on-seen) re-attaches on real new activity.
530
740
  for (const cur of [...this._sessions.values()]) {
741
+ // Multi-window isolation: an ATTACHED dir can turn foreign-owned after
742
+ // the fact — an unowned (dead-owner) dir followed since startup gets
743
+ // adopted by another window's `ccv -c`. The attach-time gate can't see
744
+ // that, so re-check here and detach before the unconditional re-read
745
+ // would stream the adopter's traffic into this window (bounded to one
746
+ // safety-poll period). _seenDirs keeps the current mtime so a later
747
+ // ownership release (owner exits) plus new activity re-attaches through
748
+ // the normal mtime-bump path below.
749
+ if (this._isForeignLiveOwned(cur.dir)) {
750
+ this._closeCursor(cur);
751
+ this._sessions.delete(cur.dir);
752
+ this._seenDirs.set(cur.dir, this._journalMtime(cur.dir));
753
+ continue;
754
+ }
755
+ // openReqs guard: never evict mid-flight — a long request keeps the
756
+ // journal mtime frozen between its req and done lines, and evicting
757
+ // then would strand its already-broadcast placeholder (see openReqs).
758
+ if (this._idleEvictMs > 0 && cur.openReqs.size === 0) {
759
+ const mtime = this._journalMtime(cur.dir);
760
+ if (mtime > 0 && this._now() - mtime > this._idleEvictMs) {
761
+ this._closeCursor(cur);
762
+ this._sessions.delete(cur.dir);
763
+ this._seenDirs.set(cur.dir, mtime);
764
+ continue;
765
+ }
766
+ }
531
767
  this._readCursor(cur);
532
768
  }
533
769
  // Unattached dirs: attach brand-new ones (missed root events) and
@@ -18,6 +18,7 @@
18
18
  import { readFileSync, readdirSync } from 'node:fs';
19
19
  import { join, basename } from 'node:path';
20
20
  import { readJsonlTolerant } from './replay.js';
21
+ import { iterateJsonlLines } from './jsonl-read.js';
21
22
  import { iterateV2Items, findTeammateSessionDirs } from './adapter.js';
22
23
  import { SingleFlight } from './singleflight.js';
23
24
  import { reportSwallowed } from '../error-report.js';
@@ -246,13 +247,11 @@ export async function readV2NativeCold(sessionDir, rows) {
246
247
  try { files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f)).sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0])); } catch { continue; }
247
248
  const raws = [];
248
249
  for (const f of files) {
249
- let text = '';
250
- try { text = readFileSync(join(convRoot, key, f), 'utf-8'); } catch { continue; }
251
- for (const line of text.split('\n')) {
252
- const trimmed = line.trim();
253
- if (trimmed) raws.push(trimmed);
254
- }
255
- await yieldLoop(); // one yield per conv file read
250
+ // streamed line-by-line (issue #129): a conv epoch past Node's string
251
+ // cap must degrade to skipped lines, not lose the whole file silently
252
+ try { for (const trimmed of iterateJsonlLines(join(convRoot, key, f))) raws.push(trimmed); }
253
+ catch (err) { reportSwallowed('v2-native.conv-read-failed', err); }
254
+ await yieldLoop(); // one yield per conv file read (error path included)
256
255
  }
257
256
  // last snapshot at-or-before the window start for this session
258
257
  let start = 0;
@@ -271,17 +270,15 @@ export async function readV2NativeCold(sessionDir, rows) {
271
270
  // boundaries: the browser paints and the byte meter ticks between them.
272
271
  pushChunked(convPayloads, windowRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"channel":${JSON.stringify(key)},"lines":[${linesJson}]}`);
273
272
  }
274
- // responses: exactly the window member seqs
275
- let respText = '';
276
- try { respText = readFileSync(join(dir, 'responses.jsonl'), 'utf-8'); } catch { /* none yet */ }
277
- await yieldLoop();
273
+ // responses: exactly the window member seqs — streamed (issue #129)
278
274
  const respRaws = [];
279
- for (const line of respText.split('\n')) {
280
- const trimmed = line.trim();
281
- if (!trimmed) continue;
282
- const m = /"seq":\s*(\d+)/.exec(trimmed);
283
- if (m && memberSeqs && memberSeqs.has(Number(m[1]))) respRaws.push(trimmed);
284
- }
275
+ try {
276
+ for (const trimmed of iterateJsonlLines(join(dir, 'responses.jsonl'))) {
277
+ const m = /"seq":\s*(\d+)/.exec(trimmed);
278
+ if (m && memberSeqs && memberSeqs.has(Number(m[1]))) respRaws.push(trimmed);
279
+ }
280
+ } catch (err) { reportSwallowed('v2-native.responses-read-failed', err); }
281
+ await yieldLoop();
285
282
  pushChunked(respPayloads, respRaws, (linesJson) => `{"sessionId":${JSON.stringify(sessionId)},"lines":[${linesJson}]}`);
286
283
  }
287
284
  return { convPayloads, respPayloads };
@@ -315,7 +312,7 @@ function pushChunked(out, raws, wrap) {
315
312
  async function attachBodyFields(sessionDir, rows) {
316
313
  const rowByKey = new Map(rows.map((r) => [itemKey(r.sessionId, r.seq), r]));
317
314
  const materialize = (sessionId, seq) => rowByKey.has(itemKey(sessionId, seq));
318
- let pending = null; // { row, entry } awaiting its nextReq for classify
315
+ let pending = null; // { row, entry, isMain } awaiting its nextReq for classify
319
316
  let prevMainFull = null; // previous mainAgent full-body entry (cacheLoss)
320
317
  let n = 0;
321
318
  const finish = (slot, nextEntry) => {
@@ -329,8 +326,12 @@ async function attachBodyFields(sessionDir, rows) {
329
326
  reportSwallowed('v2-meta.row-classify', err);
330
327
  row.typeTag = null; // journal-derived kind still renders
331
328
  }
332
- // authoritative synthesis-level markers (parity with the legacy list)
333
- row.mainAgent = entry.mainAgent === true;
329
+ // Authoritative synthesis-level markers. mainAgent stays KIND-derived
330
+ // (item.isMain = journal kind==='main' && !leader) like foldDir and the
331
+ // live rows — entry.mainAgent re-derives from the blob-backfilled body
332
+ // and mis-tags a main-shaped countTokens probe as true, which would merge
333
+ // its turn into the chat after a cold reload (2026-07-16 review P1).
334
+ row.mainAgent = slot.isMain === true;
334
335
  if (entry.teammate) row.teammate = entry.teammate;
335
336
  if (entry.body && entry.body.model) row.model = entry.body.model;
336
337
  if (row.mainAgent) {
@@ -353,7 +354,7 @@ async function attachBodyFields(sessionDir, rows) {
353
354
  entry = { ...entry, body: { ...entry.body, messages: item.stateRef } };
354
355
  }
355
356
  if (pending) finish(pending, entry);
356
- pending = { row, entry };
357
+ pending = { row, entry, isMain: item.isMain };
357
358
  if (++n % 20 === 0) await new Promise((resolve) => setImmediate(resolve));
358
359
  }
359
360
  if (pending) finish(pending, null);
@@ -14,14 +14,14 @@ import { join } from 'node:path';
14
14
  import { createHash } from 'node:crypto';
15
15
  import { messageFingerprint, normalizeMsgForEquality } from '../session-boundary.js';
16
16
  import { isSupportedWireFormat } from './layout.js';
17
+ import { iterateJsonlLines } from './jsonl-read.js';
17
18
 
18
- /** Parse a JSONL file tolerating a truncated tail line (spec §14). */
19
+ /** Parse a JSONL file tolerating a truncated tail line (spec §14). Streams
20
+ * line-by-line (issue #129): a file past Node's ~512MiB string cap must not
21
+ * throw ERR_STRING_TOO_LONG out of the whole read path. */
19
22
  export function readJsonlTolerant(path) {
20
- if (!existsSync(path)) return [];
21
23
  const out = [];
22
- for (const line of readFileSync(path, 'utf-8').split('\n')) {
23
- const t = line.trim();
24
- if (!t) continue;
24
+ for (const t of iterateJsonlLines(path)) {
25
25
  try { out.push(JSON.parse(t)); } catch { /* truncated tail — drop */ }
26
26
  }
27
27
  return out;