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.
@@ -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
@@ -75,9 +76,13 @@ const SCAN_CHUNK = 64 * 1024;
75
76
  * @param {string} dir - absolute session dir
76
77
  * @param {number} budget - max bytes to read before giving up
77
78
  * @param {(line:string) => boolean} onLine
79
+ * @param {boolean} [ioErrorResult=false] - returned when the journal EXISTS
80
+ * but reading it throws (transient lock / fd exhaustion). The cold-load
81
+ * scanners keep the false default ("not activated"); the discard predicate
82
+ * passes true so an I/O hiccup can never hide a REAL session.
78
83
  * @returns {boolean}
79
84
  */
80
- function scanJournal(dir, budget, onLine) {
85
+ function scanJournal(dir, budget, onLine, ioErrorResult = false) {
81
86
  const journal = join(dir, 'journal.jsonl');
82
87
  if (!existsSync(journal)) return false;
83
88
  let fd;
@@ -105,7 +110,7 @@ function scanJournal(dir, budget, onLine) {
105
110
  const last = (carry + decoder.end()).trim();
106
111
  return !!(last && onLine(last));
107
112
  } catch {
108
- return false;
113
+ return ioErrorResult;
109
114
  } finally {
110
115
  if (fd !== undefined) { try { closeSync(fd); } catch {} }
111
116
  }
@@ -167,6 +172,69 @@ export function sessionHasCompletedMainTurn(dir) {
167
172
  });
168
173
  }
169
174
 
175
+ /**
176
+ * Does this session dir have at least one req of kind 'main' OR 'teammate'?
177
+ * The positive half of the discardable-session predicate. Budgeted with the
178
+ * WIDE budget (not MAIN_REQ_SCAN_BUDGET): journal req lines run ~1.1-1.3KB
179
+ * (headers + params), and time-driven heartbeat/countTokens lines can pile up
180
+ * before a real session's first main — a 256KB budget could give up early and
181
+ * misjudge a REAL session as discardable. Sessions that have a main still
182
+ * early-exit at its line; only genuinely main-less journals pay the budget.
183
+ * @param {string} dir - absolute session dir
184
+ * @returns {boolean}
185
+ */
186
+ export function sessionHasMainOrTeammateReq(dir) {
187
+ // ioErrorResult=true: a transient read error must KEEP the session (treat
188
+ // as main-bearing) — hiding a real session is the worse failure direction;
189
+ // a missing journal still returns false (discard is correct there).
190
+ return scanJournal(dir, COMPLETED_TURN_SCAN_BUDGET, (line) => {
191
+ if (!line.includes('"ph":"req"')) return false;
192
+ if (!line.includes('"kind":"main"') && !line.includes('"kind":"teammate"')) return false;
193
+ try {
194
+ const o = JSON.parse(line);
195
+ return !!(o && o.ph === 'req' && (o.kind === 'main' || o.kind === 'teammate'));
196
+ } catch { return false; } // torn line — keep scanning
197
+ }, true);
198
+ }
199
+
200
+ /**
201
+ * Discardable-session predicate (2026-07-16): a session dir that must be
202
+ * DISCARDED by every read surface — never listed, counted, followed, or used
203
+ * as a candidate in any logic. These are orphan dirs minted by Claude Code's
204
+ * quota probes (max_tokens:1, one `'quota'` user message, a throwaway
205
+ * session_id per probe — fired at launches and agent-team spawns), plus any
206
+ * torn/empty dir with no renderable identity.
207
+ *
208
+ * discard ⟺ meta.leader ABSENT (not a teammate session)
209
+ * AND no journal req line of kind 'main' or 'teammate'
210
+ *
211
+ * The 'teammate' kind clause is the safety net for a torn meta.json. Every
212
+ * real session carries a main req at/near the journal head (verified across
213
+ * the full real dataset: 18/18 main-less dirs were single quota probes), so a
214
+ * kept session early-exits its scan cheaply. Read-side only and self-healing:
215
+ * the moment a dir gains its first main req, the predicate flips and every
216
+ * surface picks it up on its next scan/poll.
217
+ *
218
+ * KEEP IN SYNC: adapter.js listV2Sessions pre-computes the same verdict
219
+ * inline (hasMainOrTeammate inside its existing full journal fold — unbounded,
220
+ * vs this predicate's 8MB budget; intentional asymmetry) and then CONFIRMS a
221
+ * discard through this predicate so the error→keep direction is shared.
222
+ * Change the rule here and there together.
223
+ *
224
+ * @param {string} dir - absolute session dir
225
+ * @param {object|null} [meta] - pre-parsed meta.json (avoids a re-read);
226
+ * omitted → read here; unreadable → treated as leaderless (journal decides)
227
+ * @returns {boolean} true = discard everywhere
228
+ */
229
+ export function isDiscardableSession(dir, meta) {
230
+ let m = meta;
231
+ if (m === undefined) {
232
+ try { m = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { m = null; }
233
+ }
234
+ if (m && m.leader) return false;
235
+ return !sessionHasMainOrTeammateReq(dir);
236
+ }
237
+
170
238
  /**
171
239
  * The newest readable, non-teammate session that HAS a main turn, as
172
240
  * `{ dir, sessionId }`, or null. "Readable" mirrors listV2Sessions' gates (spec
@@ -180,17 +248,29 @@ export function sessionHasCompletedMainTurn(dir) {
180
248
  * has-a-main-req gate would otherwise re-select exactly the dir the caller's
181
249
  * completed-turn gate just rejected (it IS the newest once its first main req
182
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.
183
260
  * @param {string} projectDir - absolute LOG_DIR/<project>
184
- * @param {{excludeDir?: string}} [opts]
261
+ * @param {{excludeDir?: string, skipForeignLive?: boolean}} [opts]
185
262
  * @returns {{dir:string, sessionId:string}|null}
186
263
  */
187
- export function latestMainSession(projectDir, { excludeDir = '' } = {}) {
264
+ export function latestMainSession(projectDir, { excludeDir = '', skipForeignLive = false } = {}) {
188
265
  if (!projectDir) return null;
189
266
  const candidates = []; // { dir, sessionId, startTs }
190
267
  for (const name of listSessionIds(projectDir)) {
191
268
  const dir = join(projectDir, 'sessions', name);
192
269
  if (excludeDir && dir === excludeDir) continue;
193
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;
194
274
  let meta = null;
195
275
  try { meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8')); } catch { continue; }
196
276
  if (!meta) continue;
@@ -22,6 +22,7 @@ import { AsyncWriteQueue } from '../async-write-queue.js';
22
22
  import { reportSwallowed } from '../error-report.js';
23
23
  import { ensureSessionDirSync, compactLocalTs14, sanitizePathComponent } from './layout.js';
24
24
  import { resolveSessionDirName, latestMainSession } from './session-select.js';
25
+ import { acquireSessionClaim, releaseSessionClaim, isForeignLiveOwned } from './session-owner.js';
25
26
  import { BlobStore } from './blob-store.js';
26
27
  import { Journal } from './journal.js';
27
28
  import { ConversationStore } from './conversation-store.js';
@@ -76,6 +77,10 @@ export class V2Writer {
76
77
  // latency. Purely a nudge — the feed's data source stays the files.
77
78
  this._onActivity = typeof opts.onActivity === 'function' ? opts.onActivity : null;
78
79
  this._sessions = new Map(); // sessionId → {paths, blobs, journal, convs, resolver}
80
+ // Multi-window isolation (2026-07-17): session dirs this process has
81
+ // claimed via owner.lock. Released synchronously in resetSessions()/close()
82
+ // — a crash skips release harmlessly (claim validity is pid liveness).
83
+ this._claimedDirs = new Set();
79
84
  this._currentSid = null; // last successfully resolved session (fallback routing §8.3)
80
85
  this._pendingNoSid = []; // requests seen before any sid (cold-start heartbeats).
81
86
  // Contract with the caller: originalMessages held here must be a reference
@@ -92,6 +97,12 @@ export class V2Writer {
92
97
  this._forkSession = false;
93
98
  this._resumeSession = false;
94
99
  this._adopted = false;
100
+ // In-terminal /resume switch (SessionStart hook, source:'resume'): the
101
+ // next MAIN request must be re-routed to the resumed conversation's dir.
102
+ // {transcriptUuid, hookSid} | null; last-wins, consumed one-shot, no TTL
103
+ // (after a /resume the next main request belongs to the resumed
104
+ // conversation no matter how much later it is typed).
105
+ this._pendingResumeSwitch = null;
95
106
  }
96
107
 
97
108
  /** P2: true once any session's FIRST main wire already carried assistant
@@ -141,11 +152,126 @@ export class V2Writer {
141
152
  if (!m || m.length <= 1 || !m.some((x) => x && x.role === 'assistant')) return null;
142
153
  const projectDir = join(this._logDir, sanitizePathComponent(project));
143
154
  if (resolveSessionDirName(projectDir, sid, this._sessionsDirName)) return null; // same-UUID restart
144
- const prev = latestMainSession(projectDir);
155
+ // Multi-window isolation: never adopt a dir another LIVE window is writing
156
+ // (that is the two-writers-one-journal corruption), and CLAIM the picked
157
+ // dir atomically BEFORE committing — two simultaneous `-c` launches racing
158
+ // onto the same dead-owned dir get exactly one adopter via the owner.lock
159
+ // 'wx' arbiter; the loser falls through to a fresh dir, which is Claude
160
+ // Code's own continue/fork semantics (fresh wire sid per continue).
161
+ // skipForeignLive is deliberately UNCONDITIONAL while the claim below is
162
+ // _claimsEnabled()-gated: a non-claiming writer must still never adopt a
163
+ // live window's dir — it just doesn't stamp ownership of its own.
164
+ const prev = latestMainSession(projectDir, { skipForeignLive: true });
145
165
  if (!prev || !prev.sessionId) return null;
166
+ if (this._claimsEnabled()) {
167
+ if (!acquireSessionClaim(prev.dir).ok) return null;
168
+ // Record immediately (not only in _session): a throw between here and
169
+ // _session must not leak an untracked lock file until process exit.
170
+ this._claimedDirs.add(prev.dir);
171
+ }
146
172
  return { dirName: basename(prev.dir), identityUUID: prev.sessionId };
147
173
  }
148
174
 
175
+ /** In-terminal /resume signal (interceptor.markSessionStart → here): arm a
176
+ * one-shot routing switch. `transcriptUuid` is the resumed conversation's
177
+ * stable identity (transcript basename); `hookSid` is the fresh session
178
+ * uuid the hook minted — used as the NEW dir identity when the resumed
179
+ * conversation was never recorded by ccv (it must not be the old wire sid:
180
+ * `<ts>_<oldSid>` would be re-resolved back to the OLD dir by
181
+ * resolveSessionDirName). Last-wins across repeated /resume picks. */
182
+ beginResumeSwitch({ transcriptUuid, hookSid } = {}) {
183
+ try {
184
+ if (!transcriptUuid || typeof transcriptUuid !== 'string') return;
185
+ this._pendingResumeSwitch = {
186
+ transcriptUuid,
187
+ hookSid: (typeof hookSid === 'string' && hookSid) ? hookSid : null,
188
+ };
189
+ } catch (err) {
190
+ reportSwallowed('v2-write.resume-switch', err);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * Consume the pending /resume switch for the arriving MAIN request: pick
196
+ * the target dir, take its claim, drop the old same-sid binding, and return
197
+ * an adoptTarget for `_session()` (the same channel `-c` adoption uses).
198
+ * Target precedence: the recorded dir of the resumed conversation
199
+ * (resolveSessionDirName by transcript uuid — the log re-joins its original
200
+ * folder, identity preserved) unless another LIVE window holds its claim
201
+ * (acquire fails → fork semantics, mirror of `_resolveAdoption`); else a
202
+ * fresh `<ts>_<hookSid>` dir. Works for BOTH wire behaviors: same-sid
203
+ * (re-bind: delete the old map entry so `_session` re-creates it against
204
+ * the new dir) and fresh-sid (plain targeted adoption).
205
+ * @param {string|null} prevSid - the writer's active sid BEFORE this
206
+ * request: on a fresh-sid resume the departing conversation is keyed by
207
+ * it, not by the incoming `sid`
208
+ * @returns {{dirName:string, identityUUID:string}}
209
+ */
210
+ _resolveResumeSwitch(sid, project, prevSid = null) {
211
+ const sw = this._pendingResumeSwitch;
212
+ this._pendingResumeSwitch = null; // one-shot
213
+ const projectDir = join(this._logDir, sanitizePathComponent(project));
214
+ let dirName = resolveSessionDirName(projectDir, sw.transcriptUuid, this._sessionsDirName);
215
+ let identity = sw.transcriptUuid;
216
+ if (dirName) {
217
+ const targetDir = join(projectDir, this._sessionsDirName, dirName);
218
+ // Liveness guard is UNCONDITIONAL (mirror _resolveAdoption's
219
+ // unconditional skipForeignLive): even a non-claiming writer must never
220
+ // re-bind into a dir another live window is writing. The acquire below
221
+ // additionally arbitrates for claiming writers.
222
+ if (isForeignLiveOwned(targetDir)
223
+ || (this._claimsEnabled() && !acquireSessionClaim(targetDir).ok)) {
224
+ dirName = null; // live foreign owner — never share its journal
225
+ }
226
+ }
227
+ if (dirName) {
228
+ // Adopt the recorded dir under ITS identity (meta.sessionId,
229
+ // first-write-wins) so `_seqEpoch` continues that conversation.
230
+ try {
231
+ const m = JSON.parse(readFileSync(join(projectDir, this._sessionsDirName, dirName, 'meta.json'), 'utf-8'));
232
+ if (m && m.sessionId) identity = m.sessionId;
233
+ } catch { /* torn meta — transcriptUuid is the correct fallback identity */ }
234
+ } else {
235
+ identity = sw.hookSid || `resume-${process.pid}-${Date.now()}`;
236
+ dirName = `${compactLocalTs14(new Date().toISOString())}_${identity}`;
237
+ }
238
+ // Drop the departing conversation's binding and release its claim — this
239
+ // process has switched away and will not write it again, so other windows
240
+ // may now resume/adopt it. Same-sid wire (the observed behavior): the old
241
+ // binding is keyed by the INCOMING sid; fresh-sid wire: it is keyed by
242
+ // prevSid (review P2 — without this branch the old dir's claim + map
243
+ // entry leaked until process exit).
244
+ const oldKey = this._sessions.has(sid) ? sid
245
+ : (prevSid && prevSid !== sid && this._sessions.has(prevSid)) ? prevSid : null;
246
+ if (oldKey !== null) {
247
+ const old = this._sessions.get(oldKey);
248
+ this._sessions.delete(oldKey);
249
+ releaseSessionClaim(old.paths.dir);
250
+ this._claimedDirs.delete(old.paths.dir);
251
+ }
252
+ return { dirName, identityUUID: identity };
253
+ }
254
+
255
+ /** Multi-window isolation: does this writer claim its session dirs? Only a
256
+ * MAIN interactive leader does — teammate writers (leader set) and IM
257
+ * workers (CCV_IM_PLATFORM) stay unclaimed so any project viewer keeps
258
+ * following them (cross-process producers by design), and the offline
259
+ * converter (staging sessionsDirName) must never stamp live-ownership onto
260
+ * migrated history. */
261
+ _claimsEnabled() {
262
+ return this._enabled && !this._leader
263
+ && !process.env.CCV_IM_PLATFORM
264
+ && this._sessionsDirName === 'sessions';
265
+ }
266
+
267
+ /** Release every dir this process claimed (identity-checked unlinks).
268
+ * Synchronous on purpose — close() runs it ahead of the bounded async queue
269
+ * drain so a hung drain can't strand live-looking locks on a clean exit. */
270
+ _releaseAllClaims() {
271
+ for (const dir of this._claimedDirs) releaseSessionClaim(dir);
272
+ this._claimedDirs.clear();
273
+ }
274
+
149
275
  _session(sessionId, userIdRaw, encoding, project, startTsIso, adoptTarget = null) {
150
276
  let s = this._sessions.get(sessionId);
151
277
  if (s) return s; // hot path: map hit is O(1), the scan below only runs on miss
@@ -184,6 +310,14 @@ export class V2Writer {
184
310
  }
185
311
  }
186
312
  const paths = ensureSessionDirSync(this._logDir, project, identityId, meta, this._sessionsDirName, dirName);
313
+ // Multi-window isolation: stamp this process's live claim on the dir. A
314
+ // fresh wire-UUID dir has no contention (always succeeds); the adoption
315
+ // path already holds the claim (idempotent re-entry). Failure (a live
316
+ // foreign holder appeared) degrades to today's unclaimed behavior — the
317
+ // write itself must never be blocked by the claim.
318
+ if (this._claimsEnabled() && acquireSessionClaim(paths.dir).ok) {
319
+ this._claimedDirs.add(paths.dir);
320
+ }
187
321
  s = {
188
322
  paths,
189
323
  blobs: new BlobStore(paths),
@@ -231,6 +365,13 @@ export class V2Writer {
231
365
  if (!this._diskOk()) return null;
232
366
 
233
367
  const parsed = parseUserId(entry.body && entry.body.metadata && entry.body.metadata.user_id);
368
+ // Captured BEFORE the reassignment below: when a /resume switch arrives
369
+ // on a FRESH wire sid, the departing conversation's binding is keyed by
370
+ // this previous sid, not the incoming one (review P2 — the fresh-sid
371
+ // variant would otherwise leak the old dir's claim + map entry until
372
+ // process exit, blocking other windows from resuming that ended
373
+ // conversation).
374
+ const prevSid = this._currentSid;
234
375
  let sid;
235
376
  if (parsed) {
236
377
  sid = parsed.sessionId;
@@ -258,8 +399,26 @@ export class V2Writer {
258
399
  // before the folder is created; null on every non-adoption path.
259
400
  let adoptTarget = null;
260
401
  if (parsed) {
261
- adoptTarget = this._resolveAdoption(sid, project, originalMessages, entry);
262
- if (adoptTarget) this._adopted = true;
402
+ // In-terminal /resume switch outranks launch adoption: the pending
403
+ // signal is consumed by the first REAL main request (countTokens
404
+ // probes and heartbeats wear main-agent body shapes but are not the
405
+ // user's resumed turn — consuming on them would re-bind too early
406
+ // with the same result, but keep the gate strict for clarity).
407
+ if (this._pendingResumeSwitch && entry.mainAgent
408
+ && !entry.isCountTokens && !entry.isHeartbeat) {
409
+ // Locally guarded (review P2): the switch resolution does dir-scan
410
+ // fs work (resolveSessionDirName → readdirSync) whose residual
411
+ // throw surface (EACCES/ENOTDIR) would otherwise escape to the
412
+ // outer catch and drop THIS request entirely — the resumed
413
+ // conversation's first main turn. A failure must degrade to normal
414
+ // routing (recorded in the current dir), never to a lost entry.
415
+ try { adoptTarget = this._resolveResumeSwitch(sid, project, prevSid); }
416
+ catch (err) { reportSwallowed('v2-write.resume-switch-apply', err); }
417
+ }
418
+ if (!adoptTarget) {
419
+ adoptTarget = this._resolveAdoption(sid, project, originalMessages, entry);
420
+ if (adoptTarget) this._adopted = true;
421
+ }
263
422
  }
264
423
 
265
424
  // entry.timestamp = this session's first-request time → meta.startTs +
@@ -484,9 +643,12 @@ export class V2Writer {
484
643
  return s ? s.paths.dir : null;
485
644
  }
486
645
 
487
- /** Lifecycle hook (resume / workspace reset): drop in-memory conversation
488
- * continuity so the next request snapshots fresh. Journal seq keeps counting
489
- * (same process, same session dirs — seq must stay monotonic per session). */
646
+ /** Lifecycle hook: drop in-memory conversation continuity so the next
647
+ * request snapshots fresh. Journal seq keeps counting (same process, same
648
+ * session dirs — seq must stay monotonic per session). NOTE: the
649
+ * in-terminal /resume switch does NOT ride this — it needs a DIR re-bind,
650
+ * not just fresh continuity (see beginResumeSwitch); this stays for
651
+ * in-place resets that keep the dir. */
490
652
  resetConversations() {
491
653
  try {
492
654
  for (const s of this._sessions.values()) {
@@ -505,6 +667,10 @@ export class V2Writer {
505
667
  * under the new project — different directory, so no seq collision. */
506
668
  resetSessions() {
507
669
  try {
670
+ // Release live claims FIRST (before the session map is gone): a
671
+ // switched-away workspace's dirs are dormant and legitimately adoptable
672
+ // by other windows from this moment on.
673
+ this._releaseAllClaims();
508
674
  this._sessions.clear();
509
675
  this._pendingNoSid.length = 0;
510
676
  this._lateHandles.clear();
@@ -512,6 +678,9 @@ export class V2Writer {
512
678
  // A second workspace's `-c` must be able to adopt afresh — the previous
513
679
  // workspace's adoption doesn't carry over.
514
680
  this._adopted = false;
681
+ // A /resume signal armed in the previous workspace context is
682
+ // meaningless after the switch (its transcript belongs to that project).
683
+ this._pendingResumeSwitch = null;
515
684
  } catch (err) {
516
685
  reportSwallowed('v2-write.reset-sessions', err);
517
686
  }
@@ -523,6 +692,12 @@ export class V2Writer {
523
692
  }
524
693
 
525
694
  async close() {
695
+ // Synchronous claim release BEFORE the async drain: close() runs under
696
+ // interceptor.js's 2s-bounded shutdown race, and a hung drain must not
697
+ // strand owner.lock files on a clean exit. (A crash skips this entirely —
698
+ // harmless, the claim's validity dies with the pid.)
699
+ try { this._releaseAllClaims(); }
700
+ catch (err) { reportSwallowed('v2-write.release-claims', err); }
526
701
  await this._queue.close();
527
702
  }
528
703
  }
@@ -11,6 +11,8 @@ import { buildSystemPromptFileArgs } from './lib/system-prompt-files.js';
11
11
  import { renderSystemPromptFileArgs } from './lib/system-prompt-render.js';
12
12
  import { MODEL_PROMPT_DIR } from './lib/model-system-prompts.js';
13
13
  import { resolveSpawnModel } from './lib/spawn-model-resolver.js';
14
+ import { mergeSettingsIntoArgs } from './lib/settings-merge.js';
15
+ import { t, tFor } from './i18n.js';
14
16
 
15
17
  const __filename = fileURLToPath(import.meta.url);
16
18
  const __dirname = dirname(__filename);
@@ -302,14 +304,29 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
302
304
  ],
303
305
  };
304
306
  }
305
- const settingsJson = JSON.stringify(settingsObj);
306
-
307
307
  // 注入 --thinking-display summarized;以下任一情况跳过注入:
308
308
  // - 路径在拒绝集里(上次因此 crash 过)
309
309
  // - 环境变量 CCV_SKIP_THINKING_DISPLAY=1(用户全局 opt-out,与 cli.js 保持一致)
310
310
  const shouldInjectThinkingDisplay = !_thinkingDisplayRejectedPaths.has(claudePath)
311
311
  && process.env.CCV_SKIP_THINKING_DISPLAY !== '1';
312
- const finalExtraArgs = shouldInjectThinkingDisplay ? withDefaultThinkingDisplay(extraArgs) : extraArgs;
312
+
313
+ // Fold any user-supplied --settings into the injected settings so the final argv
314
+ // carries a SINGLE --settings flag. claude is last-wins for duplicate --settings
315
+ // (empirically verified), so a user flag sitting after ours would silently clobber
316
+ // the injected ANTHROPIC_BASE_URL proxy override and the CCV_IM_DENY deny hardening.
317
+ // Merged: injected keys win, deny is unioned, other user config rides along.
318
+ // Runs on the RAW user args BEFORE our own --thinking-display / --system-prompt-file
319
+ // tokens are appended: otherwise a trailing valueless user --settings would consume
320
+ // an injected token as its value, silently dropping the injection. Relative settings
321
+ // paths resolve against the cwd claude itself runs with (spawnDir, computed below).
322
+ const spawnDir = cwd || process.cwd();
323
+ const settingsMerge = mergeSettingsIntoArgs(extraArgs, settingsObj, { cwd: spawnDir });
324
+ if (settingsMerge.warningDetail) {
325
+ console.warn(`[CC Viewer] ${tFor('cli.settingsMergeFailed', 'en', settingsMerge.warningDetail)}`);
326
+ }
327
+ const settingsJson = settingsMerge.settingsJson;
328
+ const userArgs = settingsMerge.args;
329
+ const finalExtraArgs = shouldInjectThinkingDisplay ? withDefaultThinkingDisplay(userArgs) : userArgs;
313
330
 
314
331
  // 启动目录存在 CC_SYSTEM.md / CC_APPEND_SYSTEM.md(非空)时,自动追加
315
332
  // --system-prompt-file / --append-system-prompt-file(两者独立、用户已传同义 flag 时跳过对应项)。
@@ -318,7 +335,7 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
318
335
  // 注:currentWorkspacePath 在下方才赋值,这里用 cwd 参数判定启动目录。
319
336
  // LOG_DIR 内的 spawn(IM worker 工作目录 = <LOG_DIR>/IM_<id>/)跳过模型匹配:
320
337
  // IM 人格依赖默认 sentinel CC_APPEND_SYSTEM.md 注入,全局模型条目不得静默取代它。
321
- const spawnDir = cwd || process.cwd();
338
+ // (spawnDir 已在上方 settings 合并处赋值。)
322
339
  // insideLogDir 留在 try 外:下方 onExit 的启动兜底门控也要用它。
323
340
  const insideLogDir = spawnDir === LOG_DIR || spawnDir.startsWith(LOG_DIR + sep);
324
341
  // 整个 system prompt 构建 + 渲染管道包在 try-catch 里(PR#128):任何意外抛错(模型解析、
@@ -495,6 +512,13 @@ async function _spawnClaudeImpl(proxyPort, cwd, extraArgs = [], claudePath = nul
495
512
  const modelSuffix = sysPrompt.model ? ` (model match: ${sysPrompt.model})` : '';
496
513
  emitSpawnNotice(`[CC Viewer] loaded ${sysPrompt.loaded.join(', ')} as system prompt${modelSuffix}`);
497
514
  }
515
+ // Settings-merge failures surface via emitSpawnNotice too: console.warn only reaches
516
+ // the server stdout, invisible in the embedded terminal. Localized here (the console.warn
517
+ // above stays English for greppable server logs). Must be emitted after spawn — the
518
+ // outputBuffer reset right before pty.spawn would swallow an earlier write.
519
+ if (settingsMerge.warningDetail && !_suppressNextSpawnNotice) {
520
+ emitSpawnNotice(`[CC Viewer] ${t('cli.settingsMergeFailed', settingsMerge.warningDetail)}`);
521
+ }
498
522
  _suppressNextSpawnNotice = false;
499
523
 
500
524
  return ptyProcess;
@@ -52,6 +52,49 @@ function turnEndNotify(req, res, parsedUrl, isLocal, deps) {
52
52
  });
53
53
  }
54
54
 
55
+ // SessionStart hook notify (session-start-bridge.js): the conversation-switch
56
+ // signal for an in-terminal /resume. Same security shape as turnEndNotify
57
+ // (loopback-only + internal token + 16KB cap); the actual gating on
58
+ // payload.source and the V2Writer re-bind live behind deps.onSessionStartNotify
59
+ // (server.js → interceptor.markSessionStart).
60
+ function sessionStartNotify(req, res, parsedUrl, isLocal, deps) {
61
+ if (!isLocal) {
62
+ res.writeHead(403, { 'Content-Type': 'application/json' });
63
+ res.end(JSON.stringify({ error: 'Loopback only' }));
64
+ return;
65
+ }
66
+ if (req.headers['x-ccviewer-internal'] !== deps.INTERNAL_TOKEN) {
67
+ res.writeHead(403, { 'Content-Type': 'application/json' });
68
+ res.end(JSON.stringify({ error: 'Invalid bridge token' }));
69
+ return;
70
+ }
71
+ let body = '';
72
+ let truncated = false;
73
+ req.on('data', chunk => {
74
+ body += chunk;
75
+ if (body.length > 16384) { truncated = true; req.destroy(); }
76
+ });
77
+ req.on('end', () => {
78
+ if (truncated) {
79
+ console.warn('[session-start-notify] body exceeded 16KB cap — request destroyed');
80
+ return; // socket already closed by destroy()
81
+ }
82
+ let payload = {};
83
+ let badJson = false;
84
+ try { payload = body ? JSON.parse(body) : {}; }
85
+ catch { badJson = true; console.warn('[session-start-notify] malformed JSON body'); }
86
+ if (badJson) {
87
+ res.writeHead(400, { 'Content-Type': 'application/json' });
88
+ res.end(JSON.stringify({ error: 'malformed JSON body' }));
89
+ return;
90
+ }
91
+ try { deps.onSessionStartNotify(payload); }
92
+ catch (err) { reportSwallowed('session-start-notify.dispatch', err); }
93
+ res.writeHead(200, { 'Content-Type': 'application/json' });
94
+ res.end(JSON.stringify({ ok: true }));
95
+ });
96
+ }
97
+
55
98
  // SSE endpoint
56
99
  // 构造「有新版」徽标的 SSE 帧(pending = {version, source})。pending 为空返回 null。
57
100
  // 抽成纯函数便于单测帧格式;events() 在新连接上调用它补推,使版本徽标跨刷新持续显示。
@@ -358,6 +401,7 @@ async function requests(req, res) {
358
401
 
359
402
  export const eventsRoutes = [
360
403
  { method: 'POST', match: 'exact', path: '/api/turn-end-notify', handler: turnEndNotify },
404
+ { method: 'POST', match: 'exact', path: '/api/session-start-notify', handler: sessionStartNotify },
361
405
  { method: 'GET', match: 'exact', path: '/events', handler: events },
362
406
  { method: 'GET', match: 'exact', path: '/api/requests', handler: requests },
363
407
  ];