cc-viewer 1.9.2 → 1.9.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.
@@ -318,13 +318,42 @@ async function events(req, res, parsedUrl, isLocal, deps) {
318
318
  // filter excludes legacy/other-instance dirs, so an empty result would otherwise show the
319
319
  // WRONG project's/instance's conversation). Tell the client explicitly (same posture as
320
320
  // sid-not-found) so it can surface an empty state instead of cross-project bleed.
321
- const instanceNotFound = !!(instanceParam && !sidParam && !viewColdDir);
322
- if (instanceNotFound) {
321
+ //
322
+ // Liveness-aware (2026-10-10): a NEWLY created workspace's claude is alive but has not
323
+ // sent its first request yet, so it legitimately has no session dir. Flagging that as an
324
+ // error made the client detach back to the bound project — and the cold load below then
325
+ // streamed the BOUND project's current session (cross-project bleed). A LIVE instance
326
+ // therefore gets an EMPTY view instead: the cold stream is pinned to the empty source
327
+ // (load_start{total:0} → load_end, no chunks), the client stays on this instance, and the
328
+ // per-project live feed streams its first turn in place the moment the PTY writes its
329
+ // first session dir. Only a DEAD instance keeps the explicit sid-not-found posture (the
330
+ // client toasts + auto-detaches to the bound view — unchanged from before this fix).
331
+ const instanceNoSession = !!(instanceParam && !sidParam && !viewColdDir);
332
+ let instanceAlive = false;
333
+ if (instanceNoSession) {
334
+ try {
335
+ instanceAlive = listLivePtys().some((p) => p && p.instanceKey === instanceParam);
336
+ } catch (err) {
337
+ // Lookup failure degrades to the explicit dead-instance posture (never
338
+ // silently blank the client on a lookup error).
339
+ instanceAlive = false;
340
+ reportSwallowed('events.instance-live', err, { instance: instanceParam });
341
+ }
342
+ }
343
+ if (instanceNoSession && !instanceAlive) {
323
344
  try { sseWrite(res, `event: sid-not-found\ndata: ${JSON.stringify({ sid: null, instance: instanceParam, reason: 'instance-no-session', ts: Date.now() })}\n\n`); } catch { }
324
345
  }
325
346
  // When a view-scoped dir was requested AND resolved, it replaces the live-source
326
347
  // cold load; otherwise fall back to the standard single-current-session source.
327
348
  const coldSource = viewColdDir || null;
349
+ // An instance with no session dir has NO legitimate cold source — pin EVERY cold
350
+ // read to the literal empty source, whether the instance is alive (empty live view:
351
+ // the per-project feed streams its first turn in place once written) or dead (the
352
+ // client detaches on the sid-not-found above; the cold frames would be dropped by
353
+ // its stale guard anyway). Both fallback sites below (`coldSource || getLiveLogSource()`,
354
+ // on the legacy wire AND the v3 wire) would otherwise resolve the BOUND project's
355
+ // current session — the exact cross-project bleed this guard exists to stop.
356
+ const emptyLiveView = instanceNoSession;
328
357
  // A client viewing a non-bound project needs that project's live feed running
329
358
  // (its PTY is alive but its feed is lazy). Start it idempotently; the feed's
330
359
  // per-project client filter keeps its entries scoped to this project's viewers.
@@ -352,7 +381,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
352
381
  // Wire v3 (V3.S5): flagged + v2 source ⇒ the legacy full-entry cold stream
353
382
  // is REPLACED by rows + native lines (the byte win); the client assembler
354
383
  // rebuilds entries locally. v1 legacy files keep the entry pipeline.
355
- const _v3Src = deps.wireV3 ? (coldSource || getLiveLogSource()) : null;
384
+ const _v3Src = (deps.wireV3 && !emptyLiveView) ? (coldSource || getLiveLogSource()) : null;
356
385
  const v3Cold = !!(_v3Src && existsSync(join(_v3Src, 'journal.jsonl')));
357
386
 
358
387
  // S6b: the cold-load source is the current v2 session dir when the v2
@@ -361,7 +390,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
361
390
  // in-flight placeholder, which the client batch gate still blocks — the
362
391
  // previous conversation is the better cold load there (interceptor.js
363
392
  // getLiveLogSource header comment).
364
- const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(coldSource || getLiveLogSource({ serveInFlight: !!deps.wireV3 }), async (raw) => {
393
+ const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(emptyLiveView ? '' : (coldSource || getLiveLogSource({ serveInFlight: !!deps.wireV3 })), async (raw) => {
365
394
  // 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
366
395
  // ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
367
396
  if (res.destroyed || !res.writable) return;
@@ -406,6 +435,12 @@ async function events(req, res, parsedUrl, isLocal, deps) {
406
435
  // Pass 1 完成、Pass 2 开始前:发送 load_start
407
436
  // 增量模式下不显示 loading 遮罩,非增量模式显示进度
408
437
  const loadStartData = { total: totalCount, incremental: !!useIncremental };
438
+ // An instance with no session dir (new workspace / pruned while alive):
439
+ // flag the empty view so the client invalidates any view-cache snapshot it
440
+ // restored for this scope — otherwise an incremental `?since&cc=` resume
441
+ // would merge an empty delta onto the CACHED (now-deleted) conversation and
442
+ // keep rendering it. load_end fires right after with zero chunks.
443
+ if (emptyLiveView) loadStartData.empty = true;
409
444
  // 分页模式下附加 hasMore/oldestTs(增量模式由客户端从缓存自行判断)
410
445
  if (useLimit) {
411
446
  loadStartData.hasMore = !!hasMore;
@@ -438,6 +473,9 @@ async function events(req, res, parsedUrl, isLocal, deps) {
438
473
  for (const p of native.convPayloads) v3Bytes += p.length;
439
474
  for (const p of native.respPayloads) v3Bytes += p.length;
440
475
  const loadStartData = { total: meta.totalCount, incremental: !!useIncremental, v3Bytes };
476
+ // Same empty-view flag as the legacy onReady path above (unreachable on the
477
+ // v3 wire today — an empty source is never a v2 dir — but kept symmetric).
478
+ if (emptyLiveView) loadStartData.empty = true;
441
479
  if (useLimit) {
442
480
  loadStartData.hasMore = !!meta.hasMore;
443
481
  loadStartData.oldestTs = meta.oldestTimestamp || '';
@@ -497,8 +535,11 @@ async function events(req, res, parsedUrl, isLocal, deps) {
497
535
  sseWrite(res, `event: context_window\ndata: ${JSON.stringify(latestContextWindow)}\n\n`);
498
536
  pushedContextWindow = true;
499
537
  }
500
- // Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json
501
- if (!pushedContextWindow) {
538
+ // Fallback: no MainAgent in log (e.g. fresh session after -c), read context-window.json.
539
+ // Skipped for an empty live view: CONTEXT_WINDOW_FILE is a single GLOBAL file (not
540
+ // project-scoped), so pushing it here would paint another project's usage onto a
541
+ // brand-new workspace. The first real main turn pushes a genuine measurement instead.
542
+ if (!pushedContextWindow && !emptyLiveView) {
502
543
  try {
503
544
  const cwRaw = readFileSync(CONTEXT_WINDOW_FILE, 'utf-8');
504
545
  const cwFile = JSON.parse(cwRaw);
@@ -410,6 +410,18 @@ function postResumeSessionHandler(req, res, parsedUrl, isLocal, deps) {
410
410
  return;
411
411
  }
412
412
  try {
413
+ // SDK mode: there is no claude PTY to inject `/resume` into — the sdk-manager
414
+ // switches the session identity directly (next user message resumes it).
415
+ // Read the env at request time (not module top) so tests can flip it per case.
416
+ if (process.env.CCV_SDK_MODE === '1') {
417
+ const sdk = await import('../lib/sdk-manager.js');
418
+ const r = sdk.switchToSession(sessionUuid);
419
+ if (r.ok) { sendJson(res, 200, { ok: true }); return; }
420
+ if (r.reason === 'busy') { sendJson(res, 409, { ok: false, reason: 'busy' }); return; }
421
+ if (r.reason === 'unavailable') { sendJson(res, 409, { ok: false, reason: 'unavailable' }); return; }
422
+ sendJson(res, 400, { ok: false, reason: r.reason || 'bad-uuid' });
423
+ return;
424
+ }
413
425
  const pm = await import('../pty-manager.js');
414
426
  const targetProject = project || (_projectName || '');
415
427
  // Multi-instance: a project-name-only resume that matches 2+ live same-basename