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,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
  ];
package/server/server.js CHANGED
@@ -76,7 +76,7 @@ function execWithStdin(cmd, args, input, options) {
76
76
  child.stdin.end();
77
77
  });
78
78
  }
79
- import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, setLivePort, getImLiveText, resetImLiveText } from './interceptor.js';
79
+ import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, setLivePort, getImLiveText, resetImLiveText, markSessionStart } from './interceptor.js';
80
80
  import { V2LiveFeed } from './lib/v2/live-feed.js';
81
81
  import { sanitizePathComponent } from './lib/v2/layout.js';
82
82
  import { maybeResumeConvert } from './lib/v2/convert-manager.js';
@@ -554,6 +554,9 @@ const deps = {
554
554
  startLogWatch,
555
555
  stopLogWatch,
556
556
  scheduleTurnEndBroadcast: _scheduleTurnEndBroadcast,
557
+ // SessionStart hook notify → conversation-switch signal (in-terminal
558
+ // /resume). The source gate + V2Writer re-bind live in the interceptor.
559
+ onSessionStartNotify: markSessionStart,
557
560
  ensureImWatch: _ensureImWatch,
558
561
  maskProfiles: _maskProfiles,
559
562
  maskApiKey: _maskApiKey,