cc-viewer 1.7.2 → 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,13 +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
31
  import { isDiscardableSession } from './session-select.js';
32
+ import { isForeignLiveOwned } from './session-owner.js';
33
+ import { isConvertRunning } from './convert-manager.js';
32
34
  import { computeCacheLoss } from './meta-rows.js';
33
35
  import { classifyRequest } from '../../../src/utils/requestType.js';
34
36
 
@@ -38,6 +40,12 @@ const DEFER_MS = 3000; // synthesizer park deadline (lagging lines)
38
40
  const ATTACH_RECENT_MS = 5 * 60 * 1000; // pre-existing dirs considered "live"
39
41
  const TICK_RETRY_MS = 250; // in-process tick raced the first queue drain
40
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;
41
49
 
42
50
  const READ_CHUNK_BYTES = 8 * 1024 * 1024;
43
51
  // Node's max string length (~512MiB) — a longer line can never be decoded.
@@ -149,7 +157,11 @@ export class V2LiveFeed {
149
157
  * @param {number} [opts.safetyPollMs] - 0 disables the timer (tests drive
150
158
  * _safetyTick manually)
151
159
  * @param {number} [opts.deferMs]
160
+ * @param {number} [opts.idleEvictMs] - 0 disables idle cursor eviction (tests)
152
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
153
165
  */
154
166
  constructor(opts = {}) {
155
167
  this._clients = opts.clients || [];
@@ -159,7 +171,14 @@ export class V2LiveFeed {
159
171
  this._watchImpl = opts.watchImpl || watch;
160
172
  this._safetyPollMs = typeof opts.safetyPollMs === 'number' ? opts.safetyPollMs : SAFETY_POLL_MS;
161
173
  this._deferMs = typeof opts.deferMs === 'number' ? opts.deferMs : DEFER_MS;
174
+ this._idleEvictMs = typeof opts.idleEvictMs === 'number' ? opts.idleEvictMs : IDLE_EVICT_MS;
162
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;
163
182
  // Wire v3 (V3.S2): when on, every emitted item ALSO broadcasts a metadata
164
183
  // row (v2_requests_delta). Explicit ctor param — this module has no access
165
184
  // to the server deps object.
@@ -207,7 +226,12 @@ export class V2LiveFeed {
207
226
  if (!this._active || !sessionDir) return;
208
227
  let cur = this._sessions.get(sessionDir);
209
228
  if (!cur) {
210
- 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) });
211
235
  if (!cur) {
212
236
  if (_retries < TICK_RETRY_MAX) {
213
237
  setTimeout(() => this.tick(sessionDir, _retries + 1), TICK_RETRY_MS);
@@ -315,6 +339,16 @@ export class V2LiveFeed {
315
339
  _attach(dir, { suppressExisting }) {
316
340
  if (this._sessions.has(dir)) return this._sessions.get(dir);
317
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;
318
352
  // Discardable sessions (quota-probe orphans — no main/teammate req, no
319
353
  // meta.leader) are never followed: single choke point, every attach path
320
354
  // (_initialScan / _maybeAttachNew / tick / _safetyTick / _rebuildCursor)
@@ -332,6 +366,32 @@ export class V2LiveFeed {
332
366
  // not swallow it: cross-process producers (IM worker, second ccv) have no
333
367
  // cold-load fallback for a connected client.
334
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
+ }
335
395
  const cur = {
336
396
  dir,
337
397
  synth: new SessionSynthesizer(dir, { deferMs: this._deferMs, now: this._now }),
@@ -348,24 +408,92 @@ export class V2LiveFeed {
348
408
  reading: false,
349
409
  dirty: false,
350
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(),
351
421
  };
352
422
  this._sessions.set(dir, cur);
353
423
  this._seenDirs.set(dir, this._journalMtime(dir));
354
- try {
355
- cur.watcher = this._watchImpl(dir, () => this._scheduleRead(cur));
356
- 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) {
357
433
  reportSwallowed('v2-live.session-watch', err);
358
- try { cur.watcher.close(); } catch { /* already closed */ }
359
- cur.watcher = null; // safety poll keeps the cursor alive
360
- });
361
- } catch (err) {
362
- 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);
363
442
  }
364
- this._readCursor(cur); // seed (suppressed history or brand-new content)
365
443
  cur.suppress = false;
366
444
  return cur;
367
445
  }
368
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
+
369
497
  _closeCursor(cur) {
370
498
  if (cur.debounce) { clearTimeout(cur.debounce); cur.debounce = null; }
371
499
  if (cur.watcher) { try { cur.watcher.close(); } catch { /* already closed */ } cur.watcher = null; }
@@ -405,7 +533,11 @@ export class V2LiveFeed {
405
533
  try { line = JSON.parse(raw); } catch (err) {
406
534
  reportSwallowed('v2-live.journal-parse', new Error(`${cur.dir}: ${err.message}`));
407
535
  }
408
- 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
+ }
409
541
  }
410
542
  this._emitDrained(cur);
411
543
  } while (cur.dirty);
@@ -415,44 +547,22 @@ export class V2LiveFeed {
415
547
  }
416
548
 
417
549
  _feedConvFiles(cur) {
418
- const convRoot = join(cur.dir, 'conversations');
419
- if (!existsSync(convRoot)) return;
420
- let keys = [];
421
- try {
422
- keys = readdirSync(convRoot, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
423
- } catch {
424
- return;
425
- }
426
- for (const key of keys) {
427
- let files = [];
428
- try {
429
- files = readdirSync(join(convRoot, key)).filter((f) => /^e\d+\.jsonl$/.test(f));
430
- } catch {
431
- 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);
432
555
  }
433
- // Numeric epoch order. NB: file order is NOT guaranteed to be globally
434
- // seq-ordered (a pre-fix writer restart appended newer seqs into an
435
- // older epoch file) — the synthesizer's ingestConvLine keeps its event
436
- // window seq-sorted on insert, so feed order only needs to be stable.
437
- files.sort((a, b) => Number(a.match(/\d+/)[0]) - Number(b.match(/\d+/)[0]));
438
- for (const f of files) {
439
- const p = join(convRoot, key, f);
440
- let fc = cur.convFiles.get(p);
441
- if (!fc) {
442
- fc = { key, path: p, offset: 0 };
443
- 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}`));
444
562
  }
445
- const lines = readNewLines(fc);
446
- if (lines === null) { this._rebuildCursor(cur); return; }
447
- for (const raw of lines) {
448
- let ev = null;
449
- try { ev = JSON.parse(raw); } catch (err) {
450
- reportSwallowed('v2-live.conv-parse', new Error(`${cur.dir}/${key}: ${err.message}`));
451
- }
452
- if (ev) {
453
- cur.synth.ingestConvLine(key, ev);
454
- if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
455
- }
563
+ if (ev) {
564
+ cur.synth.ingestConvLine(key, ev);
565
+ if (this._wireV3 && !cur.suppress) this._forwardNative(cur, 'conv', key, raw);
456
566
  }
457
567
  }
458
568
  }
@@ -622,7 +732,38 @@ export class V2LiveFeed {
622
732
  if (!this._active) return;
623
733
  this._armRootWatcher(); // re-arm after errors / late directory creation
624
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.
625
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
+ }
626
767
  this._readCursor(cur);
627
768
  }
628
769
  // Unattached dirs: attach brand-new ones (missed root events) and
@@ -0,0 +1,135 @@
1
+ // Per-session-dir live-owner claim (multi-window log isolation, 2026-07-17).
2
+ //
3
+ // Problem: every ccv window (one process = V2Writer + viewer server) writes
4
+ // into the SHARED `LOG_DIR/<project>/sessions/` root. The "newest session"
5
+ // selectors (cold-load fallback, `-c` folder adoption) and the live feed's
6
+ // whole-root follow have no notion of "which process owns which dir", so
7
+ // parallel windows on one project cross-read (and, via adoption, cross-WRITE)
8
+ // each other's sessions.
9
+ //
10
+ // Mechanism: the writing process claims its session dir with an `owner.lock`
11
+ // sidecar `{pid, startedAt}`. The file is only a POINTER — the claim's
12
+ // validity is "the owning process is alive" (`process.kill(pid, 0)`, a
13
+ // kernel-memory fact), so a crashed owner's claim expires the instant the
14
+ // process dies and a dir can never be locked forever. Same liveness model as
15
+ // im-lock.js / log-management.js. The sidecar never touches journal /
16
+ // conversations / blobs — it is not part of the wire format.
17
+ //
18
+ // Error-direction asymmetry (deliberate — each path errs toward its safe side):
19
+ // - acquire: an unreadable/half-written lock is treated as HELD (refuse) —
20
+ // worst case the caller mints a fresh dir, which is Claude Code's own
21
+ // `-c` fork semantics anyway; never two writers on one journal.
22
+ // - isForeignLiveOwned: an unreadable lock is treated as UNOWNED (readable) —
23
+ // a torn crash residue must never permanently gate a dir out of the live
24
+ // feed / cold-load fallback.
25
+
26
+ import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+ import { isPidAlive } from '../pid-alive.js';
29
+ import { reportSwallowed } from '../error-report.js';
30
+
31
+ export const OWNER_LOCK_NAME = 'owner.lock';
32
+
33
+ // Contention retries for the wx create-exclusive acquire (im-lock precedent).
34
+ const MAX_CLAIM_ATTEMPTS = 3;
35
+
36
+ export function ownerLockPath(dir) { return join(dir, OWNER_LOCK_NAME); }
37
+
38
+ /** Read the claim; missing / half-written / non-object → null (tolerated,
39
+ * never grounds for deletion here — acquire has its own recycle rules). */
40
+ export function readSessionClaim(dir) {
41
+ try {
42
+ const o = JSON.parse(readFileSync(ownerLockPath(dir), 'utf-8'));
43
+ return (o && typeof o === 'object') ? o : null;
44
+ } catch { return null; }
45
+ }
46
+
47
+ /**
48
+ * Atomically claim `dir` for `pid` (default: this process). Modeled on
49
+ * im-lock.js acquireImLock: `wx` create-exclusive IS the arbiter, so two
50
+ * processes racing to adopt the same dead-owned dir get exactly one winner.
51
+ * Two DELIBERATE divergences from that model (don't "fix" them back):
52
+ * own-pid holder → idempotent {ok:true} without rewriting (acquire runs twice
53
+ * per dir: adoption, then _session), where im-lock recycles its own residue;
54
+ * and content is written in the same call as creation ('wx' flag) — no
55
+ * empty-file window between sentinel and payload, so no temp+rename step.
56
+ * `startedAt` is diagnostic only — validity is pid-liveness alone (the
57
+ * accepted im-lock-style pid-reuse tradeoff; a socket-bind probe would close
58
+ * it and is backlog).
59
+ *
60
+ * @param {string} dir - absolute session dir
61
+ * @param {{pid?: number, pidAlive?: (pid:number)=>boolean}} [opts] test seams
62
+ * @returns {{ok:true}|{ok:false, holder?:object|null}} ok:false = cannot
63
+ * claim, for any of: a live foreign holder, an unreadable/half-written lock
64
+ * (conservatively treated as held), an unexpected write I/O failure
65
+ * (reported via reportSwallowed), or contention exhausting the retries.
66
+ * `holder` is set only when a live foreign pid was identified. Either way
67
+ * the caller must NOT write into this dir (adoption: mint a fresh one).
68
+ */
69
+ export function acquireSessionClaim(dir, opts = {}) {
70
+ const pid = opts.pid ?? process.pid;
71
+ const pidAlive = opts.pidAlive || isPidAlive;
72
+ const p = ownerLockPath(dir);
73
+ for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS; attempt++) {
74
+ try {
75
+ writeFileSync(p, JSON.stringify({ pid, startedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
76
+ return { ok: true };
77
+ } catch (e) {
78
+ if (!e || e.code !== 'EEXIST') {
79
+ // Unexpected I/O failure (EACCES, EROFS, fd exhaustion…): a silently
80
+ // missing claim means the isolation quietly stops working — report.
81
+ reportSwallowed('v2-owner.claim', e);
82
+ return { ok: false, holder: null };
83
+ }
84
+ const holder = readSessionClaim(dir);
85
+ if (holder && holder.pid === pid) return { ok: true }; // idempotent re-entry
86
+ if (holder && pidAlive(holder.pid)) return { ok: false, holder };
87
+ if (!holder) {
88
+ // Unreadable: either a concurrent recycler unlinked it between our
89
+ // EEXIST and the read (→ retry the atomic create) or it is genuinely
90
+ // half-written (→ conservatively held; the writer is mid-claim).
91
+ if (!existsSync(p)) continue;
92
+ return { ok: false, holder: null };
93
+ }
94
+ // Dead holder → recycle the stale lock and retry the atomic create.
95
+ try { unlinkSync(p); } catch { /* concurrent recycler won the unlink — retry */ }
96
+ }
97
+ }
98
+ // Contention exhausted the retries: report the current holder if live.
99
+ const holder = readSessionClaim(dir);
100
+ if (holder && holder.pid !== pid && pidAlive(holder.pid)) return { ok: false, holder };
101
+ return { ok: false, holder: holder || null };
102
+ }
103
+
104
+ /**
105
+ * Release the claim on `dir` — identity-checked: only unlinks when the lock
106
+ * is held by `pid` (default: this process), so a successor process's claim is
107
+ * never deleted by a late-exiting predecessor. Missing file = already
108
+ * released. Synchronous on purpose: it runs at the top of V2Writer.close()
109
+ * ahead of the (bounded, raceable) async queue drain.
110
+ * @returns {boolean} true when the dir is no longer claimed by `pid`
111
+ */
112
+ export function releaseSessionClaim(dir, opts = {}) {
113
+ const pid = opts.pid ?? process.pid;
114
+ const holder = readSessionClaim(dir);
115
+ if (holder && holder.pid !== pid) return false; // taken over — don't touch
116
+ try { unlinkSync(ownerLockPath(dir)); } catch { /* ENOENT = already released */ }
117
+ return true;
118
+ }
119
+
120
+ /**
121
+ * Is `dir` exclusively owned by ANOTHER live process? The single skip
122
+ * predicate used by latestMainSession (cold-load fallback + `-c` adoption
123
+ * candidates) and the live feed's attach/re-check gates. History readers
124
+ * never call this — a dead owner's claim makes the dir plain unowned data.
125
+ * @param {string} dir - absolute session dir
126
+ * @param {{pid?: number, pidAlive?: (pid:number)=>boolean}} [opts] test seams
127
+ * @returns {boolean}
128
+ */
129
+ export function isForeignLiveOwned(dir, opts = {}) {
130
+ const pid = opts.pid ?? process.pid;
131
+ const pidAlive = opts.pidAlive || isPidAlive;
132
+ const holder = readSessionClaim(dir); // unreadable → null → unowned (self-healing)
133
+ if (!holder || !Number.isInteger(holder.pid)) return false;
134
+ return holder.pid !== pid && pidAlive(holder.pid);
135
+ }
@@ -24,6 +24,7 @@ import { join } from 'node:path';
24
24
  import { StringDecoder } from 'node:string_decoder';
25
25
  import { listSessionIds } from './replay.js';
26
26
  import { isSupportedWireFormat } from './layout.js';
27
+ import { isForeignLiveOwned } from './session-owner.js';
27
28
 
28
29
  /**
29
30
  * Find the on-disk dir NAME of an existing session for `sessionId` (a UUID), or
@@ -247,17 +248,29 @@ export function isDiscardableSession(dir, meta) {
247
248
  * has-a-main-req gate would otherwise re-select exactly the dir the caller's
248
249
  * completed-turn gate just rejected (it IS the newest once its first main req
249
250
  * is written), nullifying the fallback.
251
+ *
252
+ * `skipForeignLive` (multi-window isolation, 2026-07-17) drops candidates
253
+ * whose `owner.lock` is held by ANOTHER live process — a parallel ccv window's
254
+ * in-flight session. Without it, the cold-load fallback serves the other
255
+ * window's conversation and `-c` adoption writes into its journal. A dead
256
+ * owner's claim expires with its pid (kernel liveness), so crashed windows'
257
+ * sessions stay selectable — never a permanent lock. Both consumers
258
+ * (getLiveLogSource fallback, _resolveAdoption) pass true; default false keeps
259
+ * the raw picker semantics for everything else.
250
260
  * @param {string} projectDir - absolute LOG_DIR/<project>
251
- * @param {{excludeDir?: string}} [opts]
261
+ * @param {{excludeDir?: string, skipForeignLive?: boolean}} [opts]
252
262
  * @returns {{dir:string, sessionId:string}|null}
253
263
  */
254
- export function latestMainSession(projectDir, { excludeDir = '' } = {}) {
264
+ export function latestMainSession(projectDir, { excludeDir = '', skipForeignLive = false } = {}) {
255
265
  if (!projectDir) return null;
256
266
  const candidates = []; // { dir, sessionId, startTs }
257
267
  for (const name of listSessionIds(projectDir)) {
258
268
  const dir = join(projectDir, 'sessions', name);
259
269
  if (excludeDir && dir === excludeDir) continue;
260
270
  if (!existsSync(join(dir, 'journal.jsonl'))) continue;
271
+ // After the cheaper journal-existence reject: skips one owner.lock read
272
+ // per torn/non-session dir on large-history cold loads.
273
+ if (skipForeignLive && isForeignLiveOwned(dir)) continue;
261
274
  let meta = null;
262
275
  try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { continue; }
263
276
  if (!meta) continue;