cc-viewer 1.8.20 → 1.8.21

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.
Files changed (114) hide show
  1. package/cli.js +33 -4
  2. package/dist/assets/App-BmZyzd9Q.js +2 -0
  3. package/dist/assets/App-DOZI5HME.css +1 -0
  4. package/dist/assets/{MdxEditorPanel-Dsnv2wIw.js → MdxEditorPanel-BHvoYOF-.js} +1 -1
  5. package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
  6. package/dist/assets/Mobile-C10tOIQo.js +1 -0
  7. package/dist/assets/ProxyStatsModal-Do4qrHT7.js +1 -0
  8. package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
  9. package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
  13. package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
  24. package/dist/assets/clone-CFj55h49.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
  35. package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
  36. package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
  37. package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
  38. package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
  39. package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
  40. package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
  41. package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
  42. package/dist/assets/index-DyVKoGOX.js +2 -0
  43. package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
  44. package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
  45. package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
  46. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
  47. package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
  48. package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
  49. package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
  50. package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
  51. package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
  52. package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
  53. package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
  54. package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
  55. package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
  56. package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
  57. package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
  58. package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
  59. package/dist/assets/seqResourceLoaders-B-wmkeam.js +2 -0
  60. package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
  61. package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
  62. package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
  63. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
  64. package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
  65. package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
  66. package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
  67. package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
  68. package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
  69. package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
  70. package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
  71. package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
  72. package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
  73. package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
  74. package/dist/index.html +5 -5
  75. package/package.json +1 -1
  76. package/server/interceptor.js +119 -13
  77. package/server/lib/chat-queue.js +67 -4
  78. package/server/lib/same-origin.js +22 -0
  79. package/server/lib/v2/convert.js +1 -1
  80. package/server/lib/v2/layout.js +28 -3
  81. package/server/lib/v2/live-feed.js +41 -4
  82. package/server/lib/v2/resume-list.js +150 -0
  83. package/server/lib/v2/session-select.js +63 -14
  84. package/server/lib/v2/v2-writer.js +172 -34
  85. package/server/lib/v2/view-router.js +83 -0
  86. package/server/lib/view-root.js +209 -0
  87. package/server/pty-manager.js +790 -159
  88. package/server/routes/ask-perm.js +21 -1
  89. package/server/routes/events.js +123 -7
  90. package/server/routes/expert.js +46 -14
  91. package/server/routes/files-content.js +72 -7
  92. package/server/routes/files-fs.js +28 -3
  93. package/server/routes/git.js +31 -5
  94. package/server/routes/preferences.js +1 -16
  95. package/server/routes/project-meta.js +32 -3
  96. package/server/routes/proxy-stats.js +33 -5
  97. package/server/routes/resume.js +302 -0
  98. package/server/routes/search.js +23 -1
  99. package/server/routes/skills.js +18 -2
  100. package/server/routes/v2.js +6 -1
  101. package/server/routes/workspaces.js +10 -3
  102. package/server/server.js +191 -25
  103. package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
  104. package/server/workspace-registry.js +15 -1
  105. package/dist/assets/App-CesjoFSv.js +0 -2
  106. package/dist/assets/App-M61pqQEx.css +0 -1
  107. package/dist/assets/Mobile-bvGZ392d.js +0 -1
  108. package/dist/assets/ProxyStatsModal-HFk51cI8.js +0 -1
  109. package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
  110. package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
  111. package/dist/assets/clone-B0_ghF3F.js +0 -1
  112. package/dist/assets/index-DanpP6qP.js +0 -2
  113. package/dist/assets/seqResourceLoaders-BumAjQDW.css +0 -41
  114. package/dist/assets/seqResourceLoaders-ByHQMNIi.js +0 -2
@@ -566,23 +566,49 @@ export function markSessionStart(payload) {
566
566
  console.warn('[ccv session-start] resume signal without transcript_path — ignored');
567
567
  return;
568
568
  }
569
- // Soft cross-project guard: a foreign project's claude that somehow
570
- // carries our CCVIEWER_PORT must not re-bind THIS project's writer. Only
571
- // enforced when both sides are known (workspace mode may leave
572
- // _projectName empty until bound).
573
- if (cwd && typeof cwd === 'string' && _projectName) {
569
+ // Cross-project guard (2026-10-06, relaxed for true-/resume): a foreign project's
570
+ // claude that somehow carries our CCVIEWER_PORT must not re-bind THIS project's
571
+ // writer — BUT a resume in a PARALLEL project this server also manages (a live ccv
572
+ // PTY whose cwd maps to that project) is legitimate and must re-bind keyed by THAT
573
+ // project. Resolve the target project; drop only when neither bound nor a known
574
+ // live ccv project. This is what lets /api/resume-session switch a parallel
575
+ // viewed project and still have its writer follow.
576
+ let resumeProject = null; // null = bound project (default)
577
+ if (cwd && typeof cwd === 'string') {
574
578
  const cwdProject = projectKeyForCwd(cwd);
575
- if (cwdProject !== _projectName) {
576
- console.warn(`[ccv session-start] resume signal from project "${cwdProject}" ignored (bound to "${_projectName}")`);
577
- return;
579
+ if (_projectName && cwdProject === _projectName) {
580
+ resumeProject = null; // bound project — default key
581
+ } else {
582
+ // Synchronously resolvable? No — isLiveClaudeProject lives in pty-manager
583
+ // (L2), imported lazily below. Compute the candidate key here and let the
584
+ // async gate decide whether to arm the switch.
585
+ resumeProject = cwdProject || null;
578
586
  }
579
587
  }
580
588
  const transcriptUuid = basename(transcriptPath, '.jsonl');
581
589
  if (source === 'resume') {
582
- _v2Writer.beginResumeSwitch({
583
- transcriptUuid,
584
- hookSid: (typeof sessionId === 'string' && sessionId) ? sessionId : null,
585
- });
590
+ const armSwitch = () => {
591
+ _v2Writer.beginResumeSwitch({
592
+ transcriptUuid,
593
+ hookSid: (typeof sessionId === 'string' && sessionId) ? sessionId : null,
594
+ // Keyed by the resumed conversation's project (bound = undefined default).
595
+ project: resumeProject || undefined,
596
+ });
597
+ };
598
+ if (!resumeProject || (_projectName && resumeProject === _projectName)) {
599
+ armSwitch();
600
+ } else {
601
+ // Parallel project: only arm when it's a live ccv-managed claude PTY.
602
+ import('./pty-manager.js')
603
+ .then((pm) => {
604
+ if (pm && typeof pm.isLiveClaudeProject === 'function' && pm.isLiveClaudeProject(resumeProject)) {
605
+ armSwitch();
606
+ } else {
607
+ console.warn(`[ccv session-start] resume signal from unmanaged project "${resumeProject}" ignored`);
608
+ }
609
+ })
610
+ .catch((err) => reportSwallowed('session-start.resume-project-gate', err));
611
+ }
586
612
  }
587
613
  // System-prompt snapshot, Bind B (system-prompt-snapshots.js): a process-level
588
614
  // -c/-r launch queued a resumeExpected pending at spawn (pty-manager). The
@@ -682,13 +708,20 @@ export function initForWorkspace(projectPath, { forceNew = false } = {}) { // es
682
708
  const dir = join(LOG_DIR, projectName);
683
709
  try { mkdirSync(dir, { recursive: true }); } catch {}
684
710
 
711
+ // Capture the project being switched AWAY from BEFORE rebinding: a scoped
712
+ // resetSessions(below) drops only THAT project's cached session bindings, so a
713
+ // concurrently-running BACKGROUND project (a cross-project /resume takeover's
714
+ // old project, still writing via entry._resumeProject) keeps its bindings and
715
+ // continues uninterrupted. Same-project re-init passes the same name (a no-op
716
+ // scoped clear of just that project, matching the old full clear for it).
717
+ const previousProject = _projectName;
685
718
  _projectName = projectName;
686
719
  setProjectName(projectName);
687
720
  _logDir = dir;
688
721
  // Fresh workspace context — no carried names apply; future requests must
689
722
  // create session dirs under the NEW project.
690
723
  _agentSpawnRegistry.clear();
691
- _v2Writer.resetSessions(); // also clears the per-process `-c` adoption latch
724
+ _v2Writer.resetSessions(previousProject || null); // also clears the per-process `-c` adoption latch
692
725
  // A `--fork-session` / `-r` launch marks fork/resume intent; neither must
693
726
  // leak into a LATER workspace's `-c` (in a long-lived server that would
694
727
  // permanently suppress adoption). Clear both marks per launch and re-sync —
@@ -863,6 +896,9 @@ export function setupInterceptor() {
863
896
  if (isProxyTrace && options?.headers) {
864
897
  delete options.headers['x-cc-viewer-trace'];
865
898
  }
899
+ // NOTE: `x-ccv-project-dir` is stripped LATER (right before _originalFetch,
900
+ // after requestEntry has extracted `_resumeProject` from it) — stripping it
901
+ // here would delete it before the conversion copy below could read it.
866
902
 
867
903
  const timestamp = new Date().toISOString();
868
904
  let body = null;
@@ -926,10 +962,47 @@ export function setupInterceptor() {
926
962
  safeHeaders['authorization'] = '****';
927
963
  }
928
964
  }
965
+ // ccv-internal self-report headers (x-ccv-project-dir / x-ccv-instance) are routing
966
+ // metadata, not request content — drop them from the logged/forwarded `safeHeaders`
967
+ // copy so they never land in the on-disk journal or reach the frontend wire (they are
968
+ // extracted onto `_resumeProject`/`_ccvInstance` separately). Case-insensitive.
969
+ for (const k of Object.keys(safeHeaders)) {
970
+ const lk = k.toLowerCase();
971
+ if (lk === 'x-ccv-project-dir' || lk === 'x-ccv-instance') delete safeHeaders[k];
972
+ }
929
973
 
930
974
  requestEntry = {
931
975
  timestamp,
932
976
  project: (() => { try { return basename(process.cwd()); } catch { return 'unknown'; } })(),
977
+ // /resume global sessions: a child claude spawned for resume carries
978
+ // `x-ccv-project-dir` = its own cwd, so its traffic is logged under THAT
979
+ // project rather than this ccv process's cwd. Set ONLY the dedicated
980
+ // `_resumeProject` override (the writer prefers it over the bound
981
+ // project); `entry.project` keeps its original process-cwd meaning for
982
+ // every other consumer. projectKeyForCwd takes only the basename and
983
+ // sanitizes it, so the header cannot path-traverse.
984
+ ...(() => {
985
+ const dir = findHeader(headers, 'x-ccv-project-dir');
986
+ const proj = (typeof dir === 'string' && dir) ? projectKeyForCwd(dir) : '';
987
+ if (!proj) return {};
988
+ // Same-name session isolation (2026-10-07): also surface the RAW full cwd
989
+ // (`dir`, the findHeader output — NOT the projectKeyForCwd basename above)
990
+ // as `_resumeProjectCwd`, so the writer can stamp `meta.cwd` on the session
991
+ // dir. The read-side fallback then tightens by full-path cwd, telling two
992
+ // cross-dir same-basename projects apart (basename routing cannot). Using the
993
+ // basename here would never equal the read side's full-path targetCwd.
994
+ return { _resumeProject: proj, _resumeProjectCwd: dir };
995
+ })(),
996
+ // Multi-instance (2026-10-06): the spawned claude also self-reports its PTY
997
+ // instanceKey via `x-ccv-instance` (spawnClaude injects it). Surface it as
998
+ // `_ccvInstance` so the writer can pin the resolved sessionId to THIS exact process
999
+ // — basename routing cannot tell two same-cwd instances apart. Validated to the
1000
+ // minted `ccv-<hex>` shape before use (it becomes a Map key downstream).
1001
+ ...(() => {
1002
+ const inst = findHeader(headers, 'x-ccv-instance');
1003
+ const key = (typeof inst === 'string' && /^ccv-[0-9a-f]+$/.test(inst)) ? inst : '';
1004
+ return key ? { _ccvInstance: key } : {};
1005
+ })(),
933
1006
  url: urlStr,
934
1007
  method: options?.method || 'GET',
935
1008
  headers: safeHeaders,
@@ -1231,6 +1304,32 @@ export function setupInterceptor() {
1231
1304
  catch (err) { reportSwallowed('interceptor.ingest-request', err); }
1232
1305
  }
1233
1306
 
1307
+ // Strip the ccv-internal project self-report header right before forwarding
1308
+ // upstream — AFTER requestEntry already read `_resumeProject` from it. Strip
1309
+ // case-insensitively (the claude client applies it via ANTHROPIC_CUSTOM_HEADERS,
1310
+ // so casing is not under ccv's control). `_fetchOpts` starts as the same object
1311
+ // as `options` and is only shallow-copied on rewrite, so scrub BOTH.
1312
+ // `x-ccv-instance` (the multi-instance self-report) is stripped the same way —
1313
+ // it must never reach Anthropic.
1314
+ const CCV_INTERNAL_HEADERS = ['x-ccv-project-dir', 'x-ccv-instance'];
1315
+ const scrubHeaderBag = (hdrs) => {
1316
+ if (!hdrs) return;
1317
+ if (hdrs instanceof Headers) {
1318
+ for (const name of CCV_INTERNAL_HEADERS) { if (hdrs.has(name)) hdrs.delete(name); }
1319
+ } else if (typeof hdrs === 'object') {
1320
+ for (const k of Object.keys(hdrs)) {
1321
+ if (CCV_INTERNAL_HEADERS.includes(k.toLowerCase())) delete hdrs[k];
1322
+ }
1323
+ }
1324
+ };
1325
+ for (const optsBag of [options, _fetchOpts]) {
1326
+ scrubHeaderBag(optsBag && optsBag.headers);
1327
+ }
1328
+ // Request-object form: `fetch(new Request(...))` carries headers on the Request itself
1329
+ // (options may be undefined), so scrub those too or the internal header reaches upstream.
1330
+ if (typeof Request !== 'undefined' && _fetchUrl instanceof Request) scrubHeaderBag(_fetchUrl.headers);
1331
+ if (typeof Request !== 'undefined' && url instanceof Request && url !== _fetchUrl) scrubHeaderBag(url.headers);
1332
+
1234
1333
  let response;
1235
1334
  try {
1236
1335
  response = await _originalFetch.call(this, _fetchUrl, _fetchOpts);
@@ -1291,6 +1390,10 @@ export function setupInterceptor() {
1291
1390
  url: requestEntry.url,
1292
1391
  response: { body: snap },
1293
1392
  body: { model: requestEntry.body?.model },
1393
+ // B3 (multi-session view-attach): tag the live overlay stream with the
1394
+ // session uuid so an attached client can show only ITS sid's typewriter
1395
+ // and drop the other session's. Additive — older clients ignore it.
1396
+ sessionId: parseUserId(requestEntry.body?.metadata?.user_id)?.sessionId ?? null,
1294
1397
  };
1295
1398
  sendStreamChunk(chunkEntry, ++liveChunkSeq, (ok) => {
1296
1399
  // 413 → 禁用当次流式,后续全由最终 v2 completion 交付
@@ -1314,6 +1417,9 @@ export function setupInterceptor() {
1314
1417
  url: requestEntry.url,
1315
1418
  response: { body: null },
1316
1419
  body: { model: requestEntry.body?.model },
1420
+ // B3: same sessionId tag on the skeleton (seq 0) so the overlay filter
1421
+ // keys on (timestamp, sessionId) from the very first frame.
1422
+ sessionId: parseUserId(requestEntry.body?.metadata?.user_id)?.sessionId ?? null,
1317
1423
  }, 0, (ok) => { if (!ok) liveStreamEnabled = false; });
1318
1424
  }
1319
1425
 
@@ -67,11 +67,12 @@ function _broadcast() {
67
67
  catch (err) { reportSwallowed('chat-queue.broadcast', err); }
68
68
  }
69
69
 
70
- function _makeItem(text) {
70
+ function _makeItem(text, anchor) {
71
71
  return {
72
72
  id: 'q_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8),
73
73
  text,
74
74
  ts: Date.now(),
75
+ anchor: anchor || null, // { project, sessionId } from the enqueueing client (2026-10-05)
75
76
  };
76
77
  }
77
78
 
@@ -95,6 +96,13 @@ function _clearEscTimer() {
95
96
  * NOTE 2: `isStreaming` (streamingState.active) flickers false between tool calls mid-turn;
96
97
  * injecting into that gap is harmless (the TUI natively queues the paste) but it means an
97
98
  * "immediate" inject can land inside the current turn's chain — accepted risk class.
99
+ * KNOWN LIMITATION (multi-project, 2026-10-05): the busy gate is global/bound-scoped —
100
+ * `isStreaming` reads the bound project's streamingState and `getPtyKind` follows the
101
+ * global active PTY, and `_items` is a single FIFO across projects. A message queued on
102
+ * project A can be head-of-line blocked by (or misjudge the busyness of) project B.
103
+ * Injection itself IS anchor-routed (item.anchor), so a queued message still lands on
104
+ * the project it was written in; only the timing/busy heuristic is not per-project.
105
+ * Per-project streaming state + queue isolation is a follow-up.
98
106
  */
99
107
  function _safeToInject() {
100
108
  if (!_deps) return false;
@@ -126,7 +134,12 @@ function _injectHead() {
126
134
  _injecting = true;
127
135
  const gen = _gen; // invalidate on clear()/stop(): a stale completion must not re-park
128
136
  try {
129
- _deps.writeToPtySequential(bracketPasteSubmit(item.text), (ok) => {
137
+ // Route the inject by the enqueueing client's anchor when one was captured,
138
+ // so a message queued while viewing project A never lands in project B's PTY.
139
+ const _write = _deps.writeToPtySequentialFor && item.anchor
140
+ ? (chunks, done, opts) => _deps.writeToPtySequentialFor(chunks, done, opts, item.anchor)
141
+ : _deps.writeToPtySequential;
142
+ _write(bracketPasteSubmit(item.text), (ok) => {
130
143
  _injecting = false;
131
144
  if (gen !== _gen) return;
132
145
  if (!ok) {
@@ -175,7 +188,7 @@ function _pollUntilSafe(fn, onTimeout) {
175
188
  * `\x1b[201~` would otherwise break the paste frame), broadcasts, and arms a rescue timer for
176
189
  * the stale-busy case (frontend's isStreaming lags the real turn end by up to its debounce).
177
190
  */
178
- export function enqueue(text) {
191
+ export function enqueue(text, anchor) {
179
192
  if (!_deps) return null;
180
193
  const clean = sanitizeInbound(text == null ? '' : String(text)).trim();
181
194
  if (!clean) return null;
@@ -186,7 +199,7 @@ export function enqueue(text) {
186
199
  catch (err) { reportSwallowed('chat-queue.reject-broadcast', err); }
187
200
  return null;
188
201
  }
189
- const item = _makeItem(clean);
202
+ const item = _makeItem(clean, anchor);
190
203
  _items.push(item);
191
204
  _clearRescueTimer();
192
205
  _broadcast();
@@ -335,6 +348,56 @@ export function stop() {
335
348
  _gen++;
336
349
  }
337
350
 
351
+ const RESUME_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
352
+
353
+ /**
354
+ * True /resume (2026-10-06): inject `/resume <uuid>` into a target project's running claude
355
+ * PTY so the process switches INTO that session in place (Claude Code's native SessionStart
356
+ * hook then re-binds the v2 writer via beginResumeSwitch). This is the in-process switch —
357
+ * NOT a respawn — and must only happen when the TUI is observably idle.
358
+ *
359
+ * Promise-returning so the route can map the outcome to HTTP. Resolves:
360
+ * { ok:true } — the paste was written
361
+ * { ok:false, reason:'busy' } — TUI streaming / approval open / another inject or poll in
362
+ * flight / timed out waiting for idle (never blind-injects)
363
+ * { ok:false, reason:'bad-uuid' } — sessionUuid is not a UUID shape
364
+ * { ok:false, reason:'no-deps' } — initChatQueue never ran (SDK mode / not wired)
365
+ * { ok:false, reason:'write-failed' } — the PTY write failed or threw
366
+ *
367
+ * Shares the single `_injecting`/`_pollTimer` slots with the drain/sendNow paths, so it
368
+ * refuses up-front when either is owned (mirrors sendNow's guard) instead of clobbering a
369
+ * pending drain timer and stranding a queued message. The completion handler resets
370
+ * `_injecting` unconditionally (both callback and catch) so a mid-inject clear()/stop()
371
+ * generation bump can never wedge the queue.
372
+ */
373
+ export function injectResumeCommand(sessionUuid, anchor) {
374
+ return new Promise((resolve) => {
375
+ if (!_deps) { resolve({ ok: false, reason: 'no-deps' }); return; }
376
+ const uuid = sanitizeInbound(String(sessionUuid == null ? '' : sessionUuid)).trim();
377
+ if (!RESUME_UUID_RE.test(uuid)) { resolve({ ok: false, reason: 'bad-uuid' }); return; }
378
+ // Refuse BEFORE touching _pollUntilSafe: a drain's poll owns the single _pollTimer slot,
379
+ // and an in-flight inject owns _injecting — barging in would strand a queued message.
380
+ if (_injecting || _pollTimer) { resolve({ ok: false, reason: 'busy' }); return; }
381
+ let settled = false;
382
+ const done = (r) => { if (!settled) { settled = true; resolve(r); } };
383
+ const inject = () => {
384
+ _injecting = true;
385
+ try {
386
+ _deps.writeToPtySequentialFor(bracketPasteSubmit(`/resume ${uuid}`), (ok) => {
387
+ _injecting = false;
388
+ done(ok ? { ok: true } : { ok: false, reason: 'write-failed' });
389
+ }, { settleMs: 250 }, anchor || {});
390
+ } catch (err) {
391
+ _injecting = false;
392
+ reportSwallowed('chat-queue.resume.inject', err);
393
+ done({ ok: false, reason: 'write-failed' });
394
+ }
395
+ };
396
+ if (_safeToInject()) { inject(); return; }
397
+ _pollUntilSafe(inject, () => done({ ok: false, reason: 'busy' }));
398
+ });
399
+ }
400
+
338
401
  export function __resetForTests() {
339
402
  stop();
340
403
  _deps = null;
@@ -0,0 +1,22 @@
1
+ // Same-origin guard for sensitive browser-facing endpoints.
2
+ //
3
+ // Loopback identifies the socket peer, not the browser page that initiated a
4
+ // request. Since the server intentionally has permissive CORS for legacy APIs,
5
+ // sensitive endpoints (executable selection, process kill, ...) need their own
6
+ // same-origin guard stacked on the admin check: a cross-site page riding the
7
+ // user's browser (token cookie / ?token= URL) must not reach them, while
8
+ // CLI/native clients (no Origin header) and same-origin pages pass through.
9
+ //
10
+ // Canonical implementation — every call site imports from here.
11
+ export function isSameOriginBrowserRequest(req, parsedUrl) {
12
+ const origin = req.headers?.origin;
13
+ if (!origin) return true; // CLI/native clients do not send Origin.
14
+ if (req.headers?.['sec-fetch-site'] === 'cross-site') return false;
15
+ try {
16
+ const originUrl = new URL(origin);
17
+ if (parsedUrl?.origin) return originUrl.origin.toLowerCase() === parsedUrl.origin.toLowerCase();
18
+ return originUrl.host.toLowerCase() === String(req.headers?.host || '').toLowerCase();
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
@@ -53,7 +53,7 @@ const STOP_CHECK_EVERY = 50; // entries between shouldStop() polls
53
53
  const PROXY_SHARD_NAME = /^proxy_\d{4}-\d{2}-\d{2}\.jsonl$/;
54
54
 
55
55
  /** Enumerate a project's v1 log files, ascending by filename timestamp.
56
- * Excludes `_temp.jsonl` (resume scratch) and `proxy_*.jsonl` (retry stats). */
56
+ * Excludes `_temp.jsonl` and `proxy_*.jsonl` (retry stats). */
57
57
  export function listV1Files(projectDir) {
58
58
  if (!existsSync(projectDir)) return [];
59
59
  return readdirSync(projectDir)
@@ -6,7 +6,7 @@
6
6
  // and the swallowed ENOENT would silently drop v2 lines (plan risk F2).
7
7
 
8
8
  import { mkdirSync, writeFileSync, renameSync, existsSync, openSync, writeSync, fsyncSync, closeSync, readdirSync, statSync } from 'node:fs';
9
- import { join } from 'node:path';
9
+ import { join, resolve } from 'node:path';
10
10
 
11
11
  // The wire-format version this build writes AND the only one it reads.
12
12
  // Stamped into meta.json and the journal sentinel at session creation; the
@@ -28,6 +28,29 @@ export function sanitizePathComponent(name) {
28
28
  return cleaned;
29
29
  }
30
30
 
31
+ /**
32
+ * Normalize a session's owning cwd for cross-dir same-basename comparison
33
+ * (2026-10-07). Lexical ONLY — `resolve` collapses `.`/`..` and duplicate
34
+ * separators without touching the filesystem, then a trailing-separator strip
35
+ * evens `/a/x` vs `/a/x/`. (On POSIX `resolve` already drops trailing slashes,
36
+ * so the strip is redundant there; it still guards Windows `\` and the root
37
+ * case.) Deliberately NOT realpath: the write side stamps the raw spawn cwd and
38
+ * the read side reads the live PTY record's raw cwd, and both are the SAME raw
39
+ * string — realpath would introduce a symlink-mismatch (/tmp → /private/tmp)
40
+ * where the two raw sides already agree byte-for-byte. Applied identically on
41
+ * the write (meta.cwd stamp), read-candidate, and read-target sides so the
42
+ * comparison stays symmetric across a respawn. Absolute cwds pass through
43
+ * deterministically; a relative cwd would resolve against process.cwd() (never
44
+ * occurs for a spawn cwd, which is always absolute).
45
+ * @param {string} cwd
46
+ * @returns {string} '' for empty/non-string input.
47
+ */
48
+ export function normalizeSessionCwd(cwd) {
49
+ if (!cwd || typeof cwd !== 'string') return '';
50
+ const out = resolve(cwd);
51
+ return out.length > 1 ? out.replace(/[\\/]+$/, '') : out;
52
+ }
53
+
31
54
  /** Session creation time (ISO) → a 14-digit LOCAL-time stamp `yyyymmddhhmmss`
32
55
  * for the session dir-name prefix (task C). Deliberately NO internal separator
33
56
  * (unlike log-management's `compactLocalTs`, which emits `YYYYMMDD_HHMMSS`) so
@@ -122,8 +145,10 @@ export function writeFileAtomicSync(finalPath, data) {
122
145
  * targeting this session. Returns the paths object.
123
146
  *
124
147
  * meta: { wireFormat:2, sessionId, project, pid, startTs, userIdRaw,
125
- * userIdEncoding, leader?, im? } — spec §3. Existing meta is never
126
- * rewritten here (later additive updates go through updateMetaSync).
148
+ * userIdEncoding, leader?, im?, cwd? } — spec §3. Existing meta is never
149
+ * rewritten here (later additive updates go through updateMetaSync). `cwd` is
150
+ * the session's owning full working directory (same-name isolation, 2026-10-07);
151
+ * absent for legacy/external producers.
127
152
  */
128
153
  export function ensureSessionDirSync(logDir, project, sessionId, meta = {}, sessionsDirName = SESSIONS_DIR_NAME, dirName = sessionId) {
129
154
  // Path uses dirName (`<ts>_<uuid>` for live, task C); IDENTITY (meta.sessionId
@@ -183,6 +183,14 @@ export class V2LiveFeed {
183
183
  // row (v2_requests_delta). Explicit ctor param — this module has no access
184
184
  // to the server deps object.
185
185
  this._wireV3 = !!opts.wireV3;
186
+ // Per-project view layer (2026-10): this feed's project name + an optional
187
+ // client filter. The server runs ONE feed per project (bound + any viewed);
188
+ // each broadcast must reach only the clients whose viewed project matches
189
+ // THIS feed's project. `clientFilter(clients, project)` returns the subset;
190
+ // the default (no filter) is the legacy broadcast-to-all behavior, so the
191
+ // single-project case is byte-identical.
192
+ this._project = typeof opts.project === 'string' ? opts.project : '';
193
+ this._clientFilter = typeof opts.clientFilter === 'function' ? opts.clientFilter : null;
186
194
  this._sessions = new Map(); // dir → cursor bundle
187
195
  this._seenDirs = new Map(); // dir → last observed journal mtimeMs (attached or not)
188
196
  this._discardGated = new Set(); // dirs refused by the discard gate — first real attach must NOT suppress history
@@ -193,6 +201,29 @@ export class V2LiveFeed {
193
201
  this._active = false;
194
202
  }
195
203
 
204
+ /** Clients this feed's broadcasts should reach right now: the full list, or
205
+ * the per-project filtered subset when a filter is installed. */
206
+ _clientsFor() {
207
+ return this._clientFilter ? this._clientFilter(this._clients, this._project) : this._clients;
208
+ }
209
+
210
+ /**
211
+ * Instance-scoped variant (multi-instance, 2026-10-06): narrow the per-project clients to
212
+ * those viewing the SAME instance as this session. Two concurrent same-cwd instances share
213
+ * one project feed, so this keeps instance A's entries out of instance B's viewers.
214
+ * Backward compatible: a client with NO `_ccvViewInstance` stamp (the default / legacy /
215
+ * single-instance view) sees every session regardless of `sessionInstance`; a session with
216
+ * no stamped instance (legacy/external) is likewise visible to all.
217
+ */
218
+ _clientsForInstance(sessionInstance) {
219
+ const base = this._clientsFor();
220
+ if (!sessionInstance) return base; // session carries no instance → visible to all viewers
221
+ let anyInstanceViewer = false;
222
+ for (const c of base) { if (c && c._ccvViewInstance) { anyInstanceViewer = true; break; } }
223
+ if (!anyInstanceViewer) return base; // nobody is instance-scoped → keep legacy broadcast
224
+ return base.filter((c) => !c || !c._ccvViewInstance || c._ccvViewInstance === sessionInstance);
225
+ }
226
+
196
227
  /** Begin (or switch to) live-following a project's sessions/ root. */
197
228
  start(projectDir) {
198
229
  this.stop();
@@ -374,9 +405,14 @@ export class V2LiveFeed {
374
405
  // 4GB main-thread OOM). Seek their cursors to EOF instead: nothing is
375
406
  // read, cloned, or broadcast; the cold-load channel owns their history.
376
407
  let convertOrigin = false;
408
+ let sessionInstance = null;
377
409
  try {
378
410
  const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
379
411
  convertOrigin = !!meta && meta.origin === 'convert';
412
+ // Multi-instance (2026-10-06): the owning PTY instanceKey stamped on meta.json (by the
413
+ // writer) lets this feed route the session's entries to exactly the clients viewing
414
+ // THAT instance of a same-cwd project. Absent for legacy/convert/external sessions.
415
+ sessionInstance = (meta && typeof meta.instance === 'string' && meta.instance) ? meta.instance : null;
380
416
  } catch (err) {
381
417
  // meta.json is written synchronously BEFORE the journal's first async
382
418
  // drain (ensureSessionDirSync), so journal-present + meta-ABSENT is
@@ -409,6 +445,7 @@ export class V2LiveFeed {
409
445
  dirty: false,
410
446
  suppress: !!suppressExisting,
411
447
  convertOrigin,
448
+ sessionInstance,
412
449
  // Journal req seqs with no done yet. The journal is only touched at
413
450
  // request start and completion, so a single >10min request would look
414
451
  // "idle" to the mtime-based eviction and get detached mid-flight — its
@@ -611,7 +648,7 @@ export class V2LiveFeed {
611
648
  try {
612
649
  processWatchedEntry(parsed, {
613
650
  reconstructor: cur.reconstructor,
614
- clients: this._clients,
651
+ clients: this._clientsForInstance(cur.sessionInstance),
615
652
  getClaudePid: this._getClaudePid,
616
653
  runParallelHook: this._runParallelHook,
617
654
  suppressEntryBroadcast: this._wireV3,
@@ -648,7 +685,7 @@ export class V2LiveFeed {
648
685
  ? `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"channel":${JSON.stringify(channel)},"line":${raw}}`
649
686
  : `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"line":${raw}}`;
650
687
  const eventName = kind === 'conv' ? 'v3_conv' : 'v3_resp';
651
- sendEventRawToClients(this._clients, eventName, payload);
688
+ sendEventRawToClients(this._clientsForInstance(cur.sessionInstance), eventName, payload);
652
689
  } catch (err) {
653
690
  reportSwallowed('v2-live.native-forward', err);
654
691
  }
@@ -721,12 +758,12 @@ export class V2LiveFeed {
721
758
  const corrected = withAgentNameSubType(classifyRequest(prev.entry, parsed), prev.entry);
722
759
  if (JSON.stringify(corrected) !== JSON.stringify(prev.row.typeTag)) {
723
760
  prev.row.typeTag = corrected;
724
- sendEventToClients(this._clients, 'v2_requests_delta', prev.row);
761
+ sendEventToClients(this._clientsForInstance(cur.sessionInstance), 'v2_requests_delta', prev.row);
725
762
  }
726
763
  } catch (err) { reportSwallowed('v2-live.row-classify', err); }
727
764
  }
728
765
  cur._v3Last = { row, entry: parsed };
729
- sendEventToClients(this._clients, 'v2_requests_delta', row);
766
+ sendEventToClients(this._clientsForInstance(cur.sessionInstance), 'v2_requests_delta', row);
730
767
  }
731
768
 
732
769
  // ── safety poll ────────────────────────────────────────────────────────────
@@ -0,0 +1,150 @@
1
+ // Per-project "recent sessions" list for the /resume hover list (2026-10).
2
+ //
3
+ // Serves the N most-recent sessions of ONE project (the currently-bound one),
4
+ // for the header's current-project hover dropdown. Kept as a pure lib function
5
+ // (no HTTP, no interceptor state) so it is unit-testable with a temp LOG_DIR and
6
+ // an injectable `now` seam — same test posture as listV2LogsPage / deleteLogFiles.
7
+ //
8
+ // Scope: ONLY ccv's own v2 store is listed (sessions ccv actually proxied).
9
+ //
10
+ // Cost shape: a CHEAP meta.json pass per session (startTs + leader), plus a
11
+ // bounded journal head-scan (sessionHasMainOrTeammateReq) that drops quota-probe
12
+ // orphan dirs BEFORE they can occupy a top slot. Candidates are sorted
13
+ // newest-first, sliced to the top `limit`, and only THEN run the EXPENSIVE
14
+ // summarize (journal fold + dir walk + prompts head) via summarizeSessionPage —
15
+ // which shares listV2Sessions' freshness-keyed row cache, so repeat hover-opens
16
+ // are ~1-3ms. Fields the cheap pass cannot supply (sessionUuid from
17
+ // meta.sessionId, running, pinnedId) are read fresh per call for the surviving
18
+ // rows only, never from the cached row.
19
+
20
+ import { existsSync, readFileSync, statSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { sanitizePathComponent } from './layout.js';
23
+ import { listSessionIds } from './replay.js';
24
+ import { summarizeSessionPage } from './session-list.js';
25
+ import { sessionHasMainOrTeammateReq } from './session-select.js';
26
+ import { readPin } from '../session-pin-store.js';
27
+ import { LIVE_SESSION_MTIME_MS } from '../log-file-utils.js';
28
+ import { reportSwallowed } from '@ccv/core/error-report';
29
+
30
+ /**
31
+ * Liveness heuristic for the "running" placeholder column. A session is
32
+ * treated as possibly-live when its journal was appended within
33
+ * LIVE_SESSION_MTIME_MS. Coarse by design (a background-but-idle >5min session
34
+ * reads as not-running) — this is a display hint only. `now` is injectable for tests.
35
+ * @param {string} sessionDir
36
+ * @param {number} now - ms epoch
37
+ * @returns {boolean}
38
+ */
39
+ function isRecentlyActive(sessionDir, now) {
40
+ try {
41
+ const st = statSync(join(sessionDir, 'journal.jsonl'));
42
+ return (now - st.mtimeMs) < LIVE_SESSION_MTIME_MS;
43
+ } catch { return false; }
44
+ }
45
+
46
+ /**
47
+ * List the `limit` most-recent v2 sessions of ONE project under `logDir`,
48
+ * newest-first. Row shape:
49
+ * { source:'ccv', project, file, sessionUuid, startTs, turns, preview, running, pinnedId }
50
+ * - `project` — the LOG_DIR subdirectory name (already sanitize-safe).
51
+ * - `file` — v2 addressing token `v2:<project>/<dirName>` (dirName is
52
+ * `<ts>_<uuid>` or legacy `<uuid>`), for the existing
53
+ * open-log / live-tail paths.
54
+ * - `sessionUuid` — the Claude Code session UUID from meta.sessionId (NOT the
55
+ * dir name), the key the client uses to scope a `?sid=` view.
56
+ * - `pinnedId` — the project's persisted pin value verbatim (readPin), a
57
+ * ts-based display id. The SERVER cannot map it to this row
58
+ * (the ts stable id is a frontend runtime _timestamp), so the
59
+ * row just carries it through; the CLIENT decides `selected`
60
+ * by comparing against its own session stable ids.
61
+ * - `running` — coarse liveness placeholder (see isRecentlyActive).
62
+ *
63
+ * @param {string} logDir - LOG_DIR root (e.g. ~/.claude/cc-viewer).
64
+ * @param {object} [opts]
65
+ * @param {string} opts.project - the single project to list (required).
66
+ * @param {number} [opts.limit=10]
67
+ * @param {number} [opts.now] - ms epoch, injectable for tests.
68
+ * @returns {{ items: Array, total: number }}
69
+ */
70
+ export function listResumeSessions(logDir, { project, limit = 10, now = Date.now() } = {}) {
71
+ const out = { items: [], total: 0 };
72
+ if (!project || project !== sanitizePathComponent(project)) return out;
73
+
74
+ const projectDir = join(logDir, project);
75
+ if (!logDir || !existsSync(projectDir)) return out;
76
+
77
+ // ── ccv's own v2 store (sessions ccv proxied). Cheap meta.json pass per
78
+ // session for startTs + leader ordering/filtering, no journal fold.
79
+ const candidates = [];
80
+ let sessionIds;
81
+ try { sessionIds = listSessionIds(projectDir); } catch (err) {
82
+ reportSwallowed('resume-list.readdir', err, { project });
83
+ return out;
84
+ }
85
+ for (const dirName of sessionIds) {
86
+ let meta = null;
87
+ try { meta = JSON.parse(readFileSync(join(projectDir, 'sessions', dirName, 'meta.json'), 'utf-8')); } catch { /* journal is self-describing */ }
88
+ if (meta && meta.leader) continue; // teammate — folded into its leader's row
89
+ // Cheap discardable-session pre-check (bounded journal head-scan, early
90
+ // exit on the first main/teammate req): drops quota-probe orphan dirs
91
+ // BEFORE they can occupy a top-`limit` slot — without paying the full
92
+ // summarize (journal fold + dir walk) for every candidate. Same source
93
+ // predicate as session-list's isDiscardableSession (leader-absent + no
94
+ // main/teammate req), so a probe is filtered here and the summarize-time
95
+ // `discard` re-check below becomes a no-op for survivors.
96
+ if (!sessionHasMainOrTeammateReq(join(projectDir, 'sessions', dirName))) continue;
97
+ candidates.push({
98
+ project,
99
+ projectDir,
100
+ dirName,
101
+ startTs: (meta && meta.startTs) || '',
102
+ sessionUuid: (meta && meta.sessionId) || null,
103
+ });
104
+ }
105
+
106
+ // Order newest-first, then take the top `limit` BEFORE the expensive
107
+ // summarize (journal fold + dir walk + prompts head) — so a large history
108
+ // pays the summarize for at most `limit` rows, not for every session. Rows
109
+ // dropped by the summarize-time filters (teammate fold / quota-probe orphan /
110
+ // unreadable) leave their slot empty rather than being back-filled; such rows
111
+ // are rare and `limit` is small, so the occasional short list is accepted in
112
+ // exchange for the bounded cost.
113
+ candidates.sort((a, b) =>
114
+ b.startTs.localeCompare(a.startTs)
115
+ || String(b.dirName || '').localeCompare(String(a.dirName || '')));
116
+ const top = candidates.slice(0, limit);
117
+
118
+ const ccvRows = [];
119
+ for (const c of top) {
120
+ let s = null;
121
+ try { s = summarizeSessionPage(c.projectDir, c.dirName); } catch (err) {
122
+ reportSwallowed('resume-list.summarize', err, { project: c.project, dirName: c.dirName });
123
+ continue;
124
+ }
125
+ if (!s) continue;
126
+ if (s.leader) continue;
127
+ if (s.size === 0) continue;
128
+ if (s.discard) continue; // quota-probe orphans: never listed
129
+ const sessionDir = join(c.projectDir, 'sessions', c.dirName);
130
+ ccvRows.push({
131
+ source: 'ccv',
132
+ project: c.project,
133
+ file: `v2:${c.project}/${s.sid}`,
134
+ sessionUuid: c.sessionUuid,
135
+ startTs: s.startTs,
136
+ turns: s.turns,
137
+ preview: s.preview || [],
138
+ running: isRecentlyActive(sessionDir, now),
139
+ // The pin lives at the PROJECT dir and holds a ts-based display id, not a
140
+ // uuid — pass it through verbatim; the client maps it to a row.
141
+ pinnedId: readPin(c.projectDir),
142
+ });
143
+ }
144
+
145
+ // Already newest-first from the candidate sort (the summarize filter only
146
+ // drops rows, never reorders). total = surviving rows shown (≤ limit).
147
+ out.total = ccvRows.length;
148
+ out.items = ccvRows;
149
+ return out;
150
+ }