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
@@ -2,6 +2,8 @@
2
2
  import { loadAskStore, consumeIfFinal as askStoreConsumeIfFinal, markCancelled as askStoreMarkCancelled } from '../lib/ask/ask-store.js';
3
3
  import { runWaterfallHook, runParallelHook } from '../lib/plugin-loader.js';
4
4
  import { sendEventToClients } from '../lib/log-watcher.js';
5
+ import { filterClientsByViewProject } from '../lib/v2/view-router.js';
6
+ import { _projectName } from '../interceptor.js';
5
7
 
6
8
  function pendingAsks(req, res, parsedUrl, isLocal, deps) {
7
9
  try {
@@ -512,13 +514,31 @@ function streamChunk(req, res, parsedUrl, isLocal, deps) {
512
514
  }
513
515
  // 用 named event 'stream-progress' 避免混入 data: 流与 dedup 冲突
514
516
  // 精简 payload:前端只需要 timestamp/url/content 渲染 Live overlay
517
+ // Multi-project (2026-10): stamp the SOURCE project so the broadcast can
518
+ // be routed only to clients VIEWING that project — an unfiltered
519
+ // broadcast let project A's typewriter render inside project B's view
520
+ // (the SSE cross-project bleed). `entry._resumeProject` is the emitting
521
+ // claude's own project (self-reported via x-ccv-project-dir); absent ⇒
522
+ // the bound project (legacy no-header producer).
523
+ const sourceProject = (typeof entry._resumeProject === 'string' && entry._resumeProject)
524
+ ? entry._resumeProject
525
+ : (_projectName || '');
515
526
  const _streamChunkPayload = {
516
527
  timestamp: entry.timestamp,
517
528
  url: entry.url,
518
529
  content: entry.response?.body?.content || [],
519
530
  model: entry.body?.model,
531
+ // B3 (multi-session view-attach): relay the session uuid so an attached
532
+ // client can drop the other session's typewriter. Additive — older clients
533
+ // ignore it.
534
+ sessionId: typeof entry.sessionId === 'string' ? entry.sessionId : null,
535
+ project: sourceProject,
520
536
  };
521
- sendEventToClients(deps.clients, 'stream-progress', _streamChunkPayload);
537
+ sendEventToClients(
538
+ filterClientsByViewProject(deps.clients, sourceProject, _projectName || ''),
539
+ 'stream-progress',
540
+ _streamChunkPayload,
541
+ );
522
542
  runParallelHook('onStreamChunk', _streamChunkPayload);
523
543
  } catch {}
524
544
  try { res.writeHead(204); res.end(); } catch {}
@@ -9,6 +9,9 @@ import { reportSwallowed } from '@ccv/core/error-report';
9
9
  import { sseHead, sseWrite, needsDrain, wireEnd, awaitWireDrain, isWireV3Enabled } from '../lib/wire-compress.js';
10
10
  import { readV2ColdBundle } from '../lib/v2/meta-rows.js';
11
11
  import { readV2SingleEntry } from '../lib/v2/adapter.js';
12
+ import { resolveSessionDirName, latestMainSessionDir } from '../lib/v2/session-select.js';
13
+ import { sanitizePathComponent, normalizeSessionCwd } from '../lib/v2/layout.js';
14
+ import { listLivePtys } from '../pty-manager.js';
12
15
  import { enrichRawIfNeeded } from '../lib/enrich-plan-input.js';
13
16
  import { validateLogPath } from '../lib/log-management.js';
14
17
  import { isMainAgentEntry, extractCachedContent } from '../lib/kv-cache-analyzer.js';
@@ -188,12 +191,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
188
191
  const updFrame = sseUpdateBadgeFrame(deps.pendingMajorUpdate);
189
192
  if (updFrame) sseWrite(res, updFrame);
190
193
 
191
- // 1.7.0 迁移引导(P2):当前项目仍有未转换的 v1 日志 → 连接即推 migrate_prompt。
194
+ // 1.7.0 迁移引导(P2):「正在被查看的项目」仍有未转换的 v1 日志 → 连接即推 migrate_prompt。
192
195
  // 是否弹窗由客户端决定(「不再提醒」偏好在客户端;continued=true 时无视 dismissed
193
196
  // 再提醒一次——`-c` 续接的对话前半段在旧格式里,不迁移就看不到)。工作区切换后的
194
- // 提示由 workspaces launch 路由对存量连接广播(本帧只覆盖新连接)。
197
+ // 提示由 workspaces launch 路由对存量连接广播(本帧只覆盖新连接)。viewedProject
198
+ // (declared below from ?project=) scopes the check to the project the client is
199
+ // actually viewing, not always the bound one.
195
200
  try {
196
- const mig = migrationStatus(LOG_DIR, _projectName || '');
201
+ const migProject = parsedUrl.searchParams.get('project') || _projectName || '';
202
+ const mig = migrationStatus(LOG_DIR, migProject);
197
203
  if (mig.pending) {
198
204
  sseWrite(res, `event: migrate_prompt\ndata: ${JSON.stringify({ ...mig, continued: isContinuedLaunch() })}\n\n`);
199
205
  }
@@ -203,8 +209,15 @@ async function events(req, res, parsedUrl, isLocal, deps) {
203
209
  const sinceParam = parsedUrl.searchParams.get('since');
204
210
  const ccParam = parseInt(parsedUrl.searchParams.get('cc'), 10) || 0;
205
211
  const projectParam = parsedUrl.searchParams.get('project');
206
- const projectMatch = !projectParam || projectParam === (_projectName || '');
207
- const useIncremental = !!(sinceParam && ccParam > 0 && projectMatch && !isNaN(new Date(sinceParam).getTime()));
212
+ // Multi-project (2026-10): a foreign `?project=` (a parallel-project VIEW)
213
+ // may ALSO resume incrementally — the view-state cache keeps the departing
214
+ // project's lastTs and the client returns with `?project=<p>&since=<lastTs>`.
215
+ // The previous `projectMatch` gate forced a full cold reload for any
216
+ // non-bound project (the "slow switch" half of the bug). `?project` already
217
+ // self-declares the view scope, and the cold-source resolution below serves
218
+ // that project's current session read-only without re-binding the server —
219
+ // so the bound-match condition is dropped (cc>0 + a valid date still guard).
220
+ const useIncremental = !!(sinceParam && ccParam > 0 && !isNaN(new Date(sinceParam).getTime()));
208
221
 
209
222
  // 分页参数:
210
223
  // - mobile 首次加载传 ?limit=200
@@ -223,6 +236,102 @@ async function events(req, res, parsedUrl, isLocal, deps) {
223
236
  }
224
237
  const useLimit = effectiveLimit > 0;
225
238
 
239
+ // Cold-load source resolution. Two view scopes, both read-only and NEITHER
240
+ // re-binds the server (the bound project `_projectName` is untouched):
241
+ // - `?sid=<uuid>`: THAT session's full transcript (incl. pre-attach history)
242
+ // instead of the single "current" session dir. `?project=<p>` scopes the
243
+ // sid lookup to project <p>'s dir; without it the lookup scans the bound
244
+ // project and a foreign sid resolves to null.
245
+ // - `?project=<p>` (no sid): project <p>'s CURRENT session — the newest
246
+ // readable, non-teammate session with a main turn — so a parallel-project
247
+ // chip can be VIEWED without respawning or killing its process.
248
+ const sidParamRaw = parsedUrl.searchParams.get('sid');
249
+ const sidParam = (typeof sidParamRaw === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(sidParamRaw)) ? sidParamRaw : null;
250
+ // Multi-instance (2026-10-06): `?instance=<instanceKey>` narrows the view to ONE concurrent
251
+ // process of a same-cwd project (which otherwise share a basename session dir pool). It
252
+ // scopes the no-sid cold-load pick (each instance resolves to ITS OWN latest session) and is
253
+ // stamped on the SSE response so the per-project live feed can route to exactly the clients
254
+ // viewing that instance. Validated to the minted `ccv-<hex>` shape.
255
+ const instanceParamRaw = parsedUrl.searchParams.get('instance');
256
+ const instanceParam = (typeof instanceParamRaw === 'string' && /^ccv-[0-9a-f]+$/.test(instanceParamRaw)) ? instanceParamRaw : null;
257
+ // `projectParam` (declared above for the incremental guard) doubles as the view
258
+ // scope. The project this client is VIEWING: the explicit ?project= override,
259
+ // else the bound project. Stamped onto the SSE response so per-project feeds can
260
+ // route live entries to exactly the clients viewing that project.
261
+ const viewedProject = projectParam || (_projectName || '');
262
+ const resolveColdSource = () => {
263
+ if (sidParam) {
264
+ const project = projectParam || _projectName;
265
+ if (!project) return null;
266
+ try {
267
+ const projectDir = join(LOG_DIR, sanitizePathComponent(project));
268
+ const dirName = resolveSessionDirName(projectDir, sidParam);
269
+ return dirName ? join(projectDir, 'sessions', dirName) : null;
270
+ } catch (err) {
271
+ reportSwallowed('events.sid-resolve', err, { sid: sidParam });
272
+ return null;
273
+ }
274
+ }
275
+ // No sid: a foreign ?project= resolves to that project's current session
276
+ // (skipForeignLive honors multi-window isolation, same as getLiveLogSource's
277
+ // fallback). When it matches the bound project (or is absent) the standard
278
+ // live source already serves exactly this — return null to take that path.
279
+ // Multi-instance (2026-10-06): an explicit `?instance=` ALSO drives a cold-source pick even
280
+ // when the project IS the bound one — a second same-cwd instance must cold-load ITS OWN
281
+ // latest session, not the bound project's `_currentSid` (which the most-recent instance
282
+ // overwrites). Without this branch the instance filter was unreachable for the bound
283
+ // project and the first paint showed the OTHER instance's conversation.
284
+ if (projectParam && (projectParam !== (_projectName || '') || instanceParam)) {
285
+ try {
286
+ // Same-name isolation (2026-10-07): resolve the TARGET instance's owning cwd so the
287
+ // cold-load fallback can be scoped to it (two cross-dir same-basename projects share
288
+ // this basename pool). The live PTY record's cwd is the same spawn cwd the writer
289
+ // stamped into meta.cwd (its `s.cwd`) — so compare after the same lexical normalize.
290
+ // Instance dead / no live record / lookup failure → '' → no cwd filter
291
+ // (conservative, identical to today's whole-pool fallback; never mis-excludes history).
292
+ let targetCwd = '';
293
+ if (instanceParam) {
294
+ try {
295
+ const rec = listLivePtys().find((p) => p && p.instanceKey === instanceParam);
296
+ targetCwd = rec && rec.cwd ? normalizeSessionCwd(rec.cwd) : '';
297
+ } catch (err) { targetCwd = ''; reportSwallowed('events.target-cwd', err); }
298
+ }
299
+ return latestMainSessionDir(join(LOG_DIR, sanitizePathComponent(projectParam)), { skipForeignLive: true, instanceKey: instanceParam || '', targetCwd }) || null;
300
+ } catch (err) {
301
+ reportSwallowed('events.project-resolve', err, { project: projectParam });
302
+ return null;
303
+ }
304
+ }
305
+ return null;
306
+ };
307
+ const viewColdDir = resolveColdSource();
308
+ // Silent-fallback guard (design: never silently fall back). A `?sid=<uuid>`
309
+ // that resolves to null (the session dir was /clear-pruned or quota-cleaned)
310
+ // must NOT fall through to the live current session while the client believes
311
+ // it attached — tell the client explicitly so it can toast + auto-detach.
312
+ const sidNotFound = !!(sidParam && !viewColdDir);
313
+ if (sidNotFound) {
314
+ try { sseWrite(res, `event: sid-not-found\ndata: ${JSON.stringify({ sid: sidParam, ts: Date.now() })}\n\n`); } catch { }
315
+ }
316
+ // Multi-instance (2026-10-06): an explicit `?instance=` that resolves to NO session dir must
317
+ // likewise NOT silently fall through to the bound project's current session (the instance
318
+ // filter excludes legacy/other-instance dirs, so an empty result would otherwise show the
319
+ // WRONG project's/instance's conversation). Tell the client explicitly (same posture as
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) {
323
+ 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
+ }
325
+ // When a view-scoped dir was requested AND resolved, it replaces the live-source
326
+ // cold load; otherwise fall back to the standard single-current-session source.
327
+ const coldSource = viewColdDir || null;
328
+ // A client viewing a non-bound project needs that project's live feed running
329
+ // (its PTY is alive but its feed is lazy). Start it idempotently; the feed's
330
+ // per-project client filter keeps its entries scoped to this project's viewers.
331
+ if (viewedProject && viewedProject !== (_projectName || '') && typeof deps.ensureProjectFeed === 'function') {
332
+ try { deps.ensureProjectFeed(viewedProject); } catch (err) { reportSwallowed('events.ensure-feed', err, { project: viewedProject }); }
333
+ }
334
+
226
335
  // KV-Cache / context_window 追踪(扫描全量条目,不受 since 过滤影响)
227
336
  let latestKvCache = null;
228
337
  let latestContextWindow = null;
@@ -243,7 +352,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
243
352
  // Wire v3 (V3.S5): flagged + v2 source ⇒ the legacy full-entry cold stream
244
353
  // is REPLACED by rows + native lines (the byte win); the client assembler
245
354
  // rebuilds entries locally. v1 legacy files keep the entry pipeline.
246
- const _v3Src = deps.wireV3 ? getLiveLogSource() : null;
355
+ const _v3Src = deps.wireV3 ? (coldSource || getLiveLogSource()) : null;
247
356
  const v3Cold = !!(_v3Src && existsSync(join(_v3Src, 'journal.jsonl')));
248
357
 
249
358
  // S6b: the cold-load source is the current v2 session dir when the v2
@@ -252,7 +361,7 @@ async function events(req, res, parsedUrl, isLocal, deps) {
252
361
  // in-flight placeholder, which the client batch gate still blocks — the
253
362
  // previous conversation is the better cold load there (interceptor.js
254
363
  // getLiveLogSource header comment).
255
- const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(getLiveLogSource({ serveInFlight: !!deps.wireV3 }), async (raw) => {
364
+ const coldLoadResult = v3Cold ? null : await streamRawEntriesAsync(coldSource || getLiveLogSource({ serveInFlight: !!deps.wireV3 }), async (raw) => {
256
365
  // 直接发送原始 JSON 字符串,不做 parse/reconstruct/stringify
257
366
  // ExitPlanMode V2 空 input 的条目按需补全 plan / planFilePath,其它原样透传
258
367
  if (res.destroyed || !res.writable) return;
@@ -412,6 +521,13 @@ async function events(req, res, parsedUrl, isLocal, deps) {
412
521
 
413
522
  // 历史数据 + KV-Cache + context_window 全部发送完毕后,才将客户端加入广播列表。
414
523
  // 这样 watcher 的 sendToClients 不会在 load 阶段向该客户端推送 live entry。
524
+ // Stamp the viewed project so per-project feeds route live entries to exactly
525
+ // the clients viewing that project (unset ⇒ the bound project, the default).
526
+ if (viewedProject) res._ccvViewProject = viewedProject;
527
+ // Multi-instance: stamp the viewed instance too, so a same-cwd project's live feed can
528
+ // route each instance's entries to exactly the clients viewing THAT instance (unset ⇒
529
+ // instance-agnostic, the pre-multi-instance default).
530
+ if (instanceParam) res._ccvViewInstance = instanceParam;
415
531
  deps.clients.push(res);
416
532
 
417
533
  // 任务清单快照补推:内存态不在冷加载数据里,页面刷新/重连后横条要等下一次
@@ -38,6 +38,46 @@ async function resolveDir(deps) {
38
38
  return deps.isWorkspaceMode ? null : process.cwd();
39
39
  }
40
40
 
41
+ // Multi-project (2026-10): expert GET routes resolve the viewed project ONLY
42
+ // when ?project= is present (resolveViewRoot). Absent it they fall back to the
43
+ // legacy resolveDir chain (live cwd > CCV_PROJECT_DIR > null-in-workspace-mode)
44
+ // — the workspaceMode "no active workspace ⇒ null" contract is preserved
45
+ // verbatim. POST routes keep the bound resolveDir (writes stay bound).
46
+ async function _viewRootOrReply(req, res, parsedUrl, deps) {
47
+ const projectParam = parsedUrl?.searchParams?.get('project');
48
+ const instanceParam = parsedUrl?.searchParams?.get('instance');
49
+ if (!projectParam && !instanceParam) return resolveDir(deps);
50
+ const { resolveViewRoot } = await import('../lib/view-root.js');
51
+ const { loadWorkspaces } = await import('../workspace-registry.js');
52
+ const { listLivePtys } = await import('../pty-manager.js');
53
+ const r = resolveViewRoot({
54
+ projectParam,
55
+ instanceParam,
56
+ boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
57
+ loadWorkspaces,
58
+ listLivePtys,
59
+ });
60
+ if (!r.ok) {
61
+ sendJson(res, r.status, { error: r.error });
62
+ return null;
63
+ }
64
+ return r.root;
65
+ }
66
+
67
+ async function getSystemText(req, res, parsedUrl, isLocal, deps) {
68
+ try {
69
+ const dir = await _viewRootOrReply(req, res, parsedUrl, deps);
70
+ // Legacy no-active-workspace surface: dir=null → readWorkspaceSystemText
71
+ // returns the empty append surface (active=false) — not an error.
72
+ const { mode, text } = readWorkspaceSystemText(dir);
73
+ sendJson(res, 200, { dir: dir || null, active: !!dir, mode, text });
74
+ } catch (e) {
75
+ // 原始 fs 错误只落服务端日志,对外返回通用错误码(不外泄绝对路径/系统细节)。
76
+ console.error('[CC Viewer] expert system-text GET failed:', e.message);
77
+ sendJson(res, 500, { error: 'read_failed' });
78
+ }
79
+ }
80
+
41
81
  function sendJson(res, code, obj) {
42
82
  if (res.headersSent) return;
43
83
  try {
@@ -102,18 +142,6 @@ function computeSystemPromptStatus(dir) {
102
142
  return out;
103
143
  }
104
144
 
105
- async function getSystemText(req, res, parsedUrl, isLocal, deps) {
106
- try {
107
- const dir = await resolveDir(deps);
108
- const { mode, text } = readWorkspaceSystemText(dir);
109
- sendJson(res, 200, { dir: dir || null, active: !!dir, mode, text });
110
- } catch (e) {
111
- // 原始 fs 错误只落服务端日志,对外返回通用错误码(不外泄绝对路径/系统细节)。
112
- console.error('[CC Viewer] expert system-text GET failed:', e.message);
113
- sendJson(res, 500, { error: 'read_failed' });
114
- }
115
- }
116
-
117
145
  function postSystemText(req, res, parsedUrl, isLocal, deps) {
118
146
  let body = '';
119
147
  let truncated = false;
@@ -158,7 +186,9 @@ function collectModelEntries(dir) {
158
186
 
159
187
  async function getModelPrompts(req, res, parsedUrl, isLocal, deps) {
160
188
  try {
161
- const dir = await resolveDir(deps);
189
+ const dir = await _viewRootOrReply(req, res, parsedUrl, deps);
190
+ // dir=null(无活动工作区)不是错误:照常渲染 workspaceActive:false + 全局条目
191
+ //(与改动前 resolveDir 返回 null 的路径逐字节一致)。
162
192
  const globalDir = join(LOG_DIR, MODEL_PROMPT_DIR);
163
193
  const status = computeSystemPromptStatus(dir);
164
194
  // 内置 preset 条目(默认生效层):text 为 renderPresetTemplate 输出(边界已剥离),
@@ -195,7 +225,9 @@ async function getModelPrompts(req, res, parsedUrl, isLocal, deps) {
195
225
  // 轻量状态查询(不内联提示词文本):头部工具栏据此决定是否自动露出「系统提示词修改」入口。
196
226
  async function getSystemPromptStatus(req, res, parsedUrl, isLocal, deps) {
197
227
  try {
198
- const dir = await resolveDir(deps);
228
+ const dir = await _viewRootOrReply(req, res, parsedUrl, deps);
229
+ // dir=null(无活动工作区)不是错误:computeSystemPromptStatus 容忍 null
230
+ //(旧 resolveDir 返回 null 的路径照常渲染全局命中)。
199
231
  sendJson(res, 200, computeSystemPromptStatus(dir));
200
232
  } catch (e) {
201
233
  console.error('[CC Viewer] expert system-prompt-status GET failed:', e.message);
@@ -3,11 +3,27 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathS
3
3
  import { join, basename, dirname, resolve, sep } from 'node:path';
4
4
  import { isReadAllowed, reasonToStatus } from '../lib/file-access-policy.js';
5
5
  import { ERROR_STATUS_MAP } from '../lib/file-api.js';
6
+ import { viewRootOrReply } from '../lib/view-root.js';
7
+ import { loadWorkspaces } from '../workspace-registry.js';
8
+ import { listLivePtys } from '../pty-manager.js';
6
9
  import { reportSwallowed } from '@ccv/core/error-report';
7
10
  import { discoverClaudeMdCandidates, readCandidateById } from '../lib/claude-md-discovery.js';
8
11
  import { getClaudeConfigDir } from '../../findcc.js';
9
12
  import { _projectName } from '../interceptor.js';
10
13
 
14
+ // Multi-project (2026-10): project-scoped READ routes resolve the viewed
15
+ // project via the shared viewRootOrReply (?project=). WRITE routes keep the
16
+ // bound root. SYNCHRONOUS: these handlers were sync before multi-project, and
17
+ // their tests (and the dispatcher's sync-call convention) assert on res
18
+ // immediately after the call — the override must not async-ify them.
19
+ function _viewRootOrReply(req, res, parsedUrl) {
20
+ return viewRootOrReply(req, res, parsedUrl, {
21
+ boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
22
+ loadWorkspaces,
23
+ listLivePtys,
24
+ });
25
+ }
26
+
11
27
  // file-raw 扩展名 → MIME 映射。图片走 <img> 预览;html 走 iframe 预览(带下方 CSP sandbox);
12
28
  // 其余为 HTML 预览的同目录子资源(c8/nyc/lcov-genhtml 等静态报告):CSP sandbox 使文档
13
29
  // 处于 opaque origin,对一切子资源都是跨源——跨源样式表受浏览器严格 MIME 校验,
@@ -84,7 +100,12 @@ function planFile(req, res, parsedUrl) {
84
100
 
85
101
  function fileContentGet(req, res, parsedUrl) {
86
102
  const reqPath = parsedUrl.searchParams.get('path');
87
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
103
+ const cwd = _viewRootOrReply(req, res, parsedUrl);
104
+ if (!cwd) return;
105
+ fileContentGetWithRoot(req, res, parsedUrl, reqPath, cwd);
106
+ }
107
+
108
+ function fileContentGetWithRoot(req, res, parsedUrl, reqPath, cwd) {
88
109
  try {
89
110
  if (!reqPath) {
90
111
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -143,10 +164,30 @@ function projectMemory(req, res, parsedUrl) {
143
164
  res.writeHead(status, { 'Content-Type': 'application/json' });
144
165
  res.end(JSON.stringify(body));
145
166
  };
167
+ projectMemoryEntry(req, res, parsedUrl, respondJson);
168
+ }
169
+
170
+ // The entry wraps BOTH the view-root resolution and the read in the endpoint's
171
+ // single error surface (the searchParams-throw defense test asserts a 500 from
172
+ // THIS catch, regardless of which stage throws).
173
+ function projectMemoryEntry(req, res, parsedUrl, respondJson) {
146
174
  try {
147
- const cwdRaw = process.env.CCV_PROJECT_DIR || process.cwd();
148
- const cwd = cwdRaw.replace(/[/\\]+$/, '');
149
- const encoded = cwd.replace(/[^a-zA-Z0-9-]/g, '-');
175
+ const cwd = _viewRootOrReply(req, res, parsedUrl);
176
+ if (!cwd) return;
177
+ projectMemoryWithRoot(req, res, parsedUrl, respondJson, cwd);
178
+ } catch (err) {
179
+ // 与 /api/file-content 一致:已知 errno(ENOENT/EACCES 等)走 ERROR_STATUS_MAP 映射;
180
+ // 500 时不回显 err.message —— 可能含 ~/.claude/projects/<encoded>/memory/ 路径片段。
181
+ const status = ERROR_STATUS_MAP[err.code] || 500;
182
+ const message = status === 500 ? 'Internal error' : err.message;
183
+ respondJson(status, { error: message });
184
+ }
185
+ }
186
+
187
+ function projectMemoryWithRoot(req, res, parsedUrl, respondJson, cwd) {
188
+ try {
189
+ const cwdClean = cwd.replace(/[/\\]+$/, '');
190
+ const encoded = cwdClean.replace(/[^a-zA-Z0-9-]/g, '-');
150
191
  const dir = join(getClaudeConfigDir(), 'projects', encoded, 'memory');
151
192
  const fileParam = parsedUrl.searchParams.get('file');
152
193
  const MAX_BYTES = 512 * 1024;
@@ -205,8 +246,22 @@ function claudeMd(req, res, parsedUrl) {
205
246
  res.writeHead(status, { 'Content-Type': 'application/json' });
206
247
  res.end(JSON.stringify(body));
207
248
  };
249
+ // The entry wraps BOTH the view-root resolution and the read in the
250
+ // endpoint's single error surface (the searchParams-throw defense test
251
+ // asserts a 500 from THIS catch, regardless of which stage throws).
252
+ try {
253
+ const cwd = _viewRootOrReply(req, res, parsedUrl);
254
+ if (!cwd) return;
255
+ claudeMdWithRoot(req, res, parsedUrl, respondJson, cwd);
256
+ } catch (err) {
257
+ const status = ERROR_STATUS_MAP[err.code] || 500;
258
+ const message = status === 500 ? 'Internal error' : err.message;
259
+ respondJson(status, { error: message });
260
+ }
261
+ }
262
+
263
+ function claudeMdWithRoot(req, res, parsedUrl, respondJson, cwd) {
208
264
  try {
209
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
210
265
  const claudeConfigDir = getClaudeConfigDir();
211
266
  // 注入 isReadAllowed 做发现阶段预过滤: 不在 allowlist 内的祖先候选不进列表 ——
212
267
  // 否则 UI 会渲染出点击必 403 的"看得见点不开"chip。
@@ -262,7 +317,12 @@ function fileRaw(req, res, parsedUrl) {
262
317
  } else {
263
318
  reqPath = parsedUrl.searchParams.get('path');
264
319
  }
265
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
320
+ const cwd = _viewRootOrReply(req, res, parsedUrl);
321
+ if (!cwd) return;
322
+ fileRawWithRoot(req, res, parsedUrl, reqPath, cwd, method);
323
+ }
324
+
325
+ function fileRawWithRoot(req, res, parsedUrl, reqPath, cwd, method) {
266
326
  try {
267
327
  if (!reqPath) {
268
328
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -336,7 +396,12 @@ function fileRaw(req, res, parsedUrl) {
336
396
  // No size cap — a download is precisely the path for files too large to preview.
337
397
  function fileDownload(req, res, parsedUrl) {
338
398
  const reqPath = parsedUrl.searchParams.get('path');
339
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
399
+ const cwd = _viewRootOrReply(req, res, parsedUrl);
400
+ if (!cwd) return;
401
+ fileDownloadWithRoot(req, res, parsedUrl, reqPath, cwd);
402
+ }
403
+
404
+ function fileDownloadWithRoot(req, res, parsedUrl, reqPath, cwd) {
340
405
  try {
341
406
  if (!reqPath) {
342
407
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -8,9 +8,27 @@ import { homedir, tmpdir } from 'node:os';
8
8
  import { execFile, spawn } from 'node:child_process';
9
9
  import { bumpWorkspacesVersion, isReadAllowed } from '../lib/file-access-policy.js';
10
10
  import { validateImportDir } from '../lib/file-api.js';
11
+ import { viewRootOrReply } from '../lib/view-root.js';
12
+ import { loadWorkspaces } from '../workspace-registry.js';
13
+ import { listLivePtys } from '../pty-manager.js';
11
14
  import { PROFILE_PATH, _projectName, _logDir } from '../interceptor.js';
12
15
  import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
13
16
 
17
+ // Multi-project (2026-10): project-scoped READ routes resolve the viewed
18
+ // project via the shared viewRootOrReply (?project=, or POST body {project}
19
+ // for the body-carrying read probes). WRITE routes keep the bound root.
20
+ // SYNCHRONOUS: the affected handlers were sync before multi-project and their
21
+ // tests assert on res immediately after the call.
22
+ function _viewRootOrReply(req, res, parsedUrl, bodyProject, bodyInstance) {
23
+ return viewRootOrReply(req, res, parsedUrl, {
24
+ boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
25
+ loadWorkspaces,
26
+ listLivePtys,
27
+ bodyProject,
28
+ bodyInstance,
29
+ });
30
+ }
31
+
14
32
  function upload(req, res, parsedUrl, isLocal, deps) {
15
33
  const contentType = req.headers['content-type'] || '';
16
34
  const boundaryMatch = contentType.match(/boundary=(.+)/);
@@ -245,7 +263,8 @@ async function files(req, res, parsedUrl, isLocal, deps) {
245
263
  res.end(JSON.stringify({ error: 'Invalid path' }));
246
264
  return;
247
265
  }
248
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
266
+ const cwd = _viewRootOrReply(req, res, parsedUrl);
267
+ if (!cwd) return;
249
268
  const targetDir = join(cwd, reqPath);
250
269
  try {
251
270
  const entries = readdirSync(targetDir, { withFileTypes: true });
@@ -636,7 +655,7 @@ function openFile(req, res, parsedUrl, isLocal, deps) {
636
655
  function resolvePath(req, res, parsedUrl, isLocal, deps) {
637
656
  let body = '';
638
657
  req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
639
- req.on('end', () => {
658
+ req.on('end', async () => {
640
659
  let parsed;
641
660
  try { parsed = JSON.parse(body); } catch {
642
661
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -650,6 +669,11 @@ function resolvePath(req, res, parsedUrl, isLocal, deps) {
650
669
  res.end(JSON.stringify({ error: 'Invalid path' }));
651
670
  return;
652
671
  }
672
+ // resolve-path is an internal UI helper (resolve a relative path against
673
+ // the project root for reveal/open actions). It stays BOUND-only (review
674
+ // P2-5): resolving a foreign project's root and echoing its absolute path
675
+ // back would make this endpoint a weak path-leak oracle for projects the
676
+ // caller is not viewing.
653
677
  const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
654
678
  const fullPath = relPath ? join(cwd, relPath) : cwd;
655
679
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -709,7 +733,8 @@ function filesExists(req, res, parsedUrl, isLocal, deps) {
709
733
  return;
710
734
  }
711
735
  }
712
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
736
+ const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
737
+ if (!cwd) return;
713
738
  const results = paths.map(p => ({ path: p, exists: probeFileExists(p, cwd) }));
714
739
  res.writeHead(200, { 'Content-Type': 'application/json' });
715
740
  res.end(JSON.stringify({ results }));
@@ -2,12 +2,32 @@
2
2
  import { existsSync, readdirSync, realpathSync } from 'node:fs';
3
3
  import { join, basename, resolve, sep } from 'node:path';
4
4
  import { getGitDiffs, countUntrackedLines, getUnpushedCommits, isValidCommitHash } from '../lib/git-diff.js';
5
+ import { viewRootOrReply } from '../lib/view-root.js';
6
+ import { loadWorkspaces } from '../workspace-registry.js';
7
+ import { listLivePtys } from '../pty-manager.js';
5
8
 
6
9
  const GIT_RESTORE_LOCK_CLEANUP_MS = 30000;
7
10
 
8
- function gitRepos(req, res) {
11
+ // Multi-project (2026-10): git READ routes resolve the viewed project via the
12
+ // shared viewRootOrReply (?project=) and pass it as resolveRepoCwd's baseDir.
13
+ // git-restore (destructive) keeps the bound root. SYNCHRONOUS: gitRepos was
14
+ // sync before multi-project and its tests assert on res immediately.
15
+ function _viewRootOrReply(req, res, parsedUrl) {
16
+ return viewRootOrReply(req, res, parsedUrl, {
17
+ boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
18
+ loadWorkspaces,
19
+ listLivePtys,
20
+ });
21
+ }
22
+
23
+ function gitRepos(req, res, parsedUrl) {
24
+ const projectDir = _viewRootOrReply(req, res, parsedUrl || new URL(req.url, 'http://localhost'));
25
+ if (!projectDir) return;
26
+ gitReposWithRoot(req, res, projectDir);
27
+ }
28
+
29
+ function gitReposWithRoot(req, res, projectDir) {
9
30
  try {
10
- const projectDir = process.env.CCV_PROJECT_DIR || process.cwd();
11
31
  const repos = [];
12
32
  if (existsSync(join(projectDir, '.git'))) {
13
33
  repos.push({ name: basename(projectDir), path: '.', isRoot: true });
@@ -102,7 +122,9 @@ function gitRestore(req, res, parsedUrl, isLocal, deps) {
102
122
  async function gitStatus(req, res, parsedUrl, isLocal, deps) {
103
123
  try {
104
124
  const repoParam = parsedUrl.searchParams.get('repo');
105
- const cwd = deps.resolveRepoCwd(repoParam);
125
+ const viewRoot = _viewRootOrReply(req, res, parsedUrl);
126
+ if (!viewRoot) return;
127
+ const cwd = deps.resolveRepoCwd(repoParam, viewRoot);
106
128
  if (!cwd) {
107
129
  res.writeHead(400, { 'Content-Type': 'application/json' });
108
130
  res.end(JSON.stringify({ error: 'Invalid repo parameter', changes: [] }));
@@ -173,7 +195,9 @@ async function gitStatus(req, res, parsedUrl, isLocal, deps) {
173
195
  async function gitDiff(req, res, parsedUrl, isLocal, deps) {
174
196
  try {
175
197
  const repoParam = parsedUrl.searchParams.get('repo');
176
- const cwd = deps.resolveRepoCwd(repoParam);
198
+ const viewRoot = _viewRootOrReply(req, res, parsedUrl);
199
+ if (!viewRoot) return;
200
+ const cwd = deps.resolveRepoCwd(repoParam, viewRoot);
177
201
  if (!cwd) {
178
202
  res.writeHead(400, { 'Content-Type': 'application/json' });
179
203
  res.end(JSON.stringify({ error: 'Invalid repo parameter', diffs: [] }));
@@ -204,7 +228,9 @@ async function gitDiff(req, res, parsedUrl, isLocal, deps) {
204
228
  async function gitLogUnpushed(req, res, parsedUrl, isLocal, deps) {
205
229
  try {
206
230
  const repoParam = parsedUrl.searchParams.get('repo');
207
- const cwd = deps.resolveRepoCwd(repoParam);
231
+ const viewRoot = _viewRootOrReply(req, res, parsedUrl);
232
+ if (!viewRoot) return;
233
+ const cwd = deps.resolveRepoCwd(repoParam, viewRoot);
208
234
  if (!cwd) {
209
235
  res.writeHead(400, { 'Content-Type': 'application/json' });
210
236
  res.end(JSON.stringify({ error: 'Invalid repo parameter', commits: [], hasUpstream: false }));
@@ -18,6 +18,7 @@ import { mutatePrefs, applyPrefsPatch, readPrefsRaw } from '../lib/prefs-store.j
18
18
  import { mutateJsonSync } from '../lib/json-store.js';
19
19
  import { persistProfilesApiKeys } from '../lib/credential-access.js';
20
20
  import { isAdminReq } from '../lib/is-admin.js';
21
+ import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
21
22
  import {
22
23
  getCurrentProjectKey, getCurrentProjectName, hasFork, listForks, resolveScoped,
23
24
  } from '../lib/project-prefs.js';
@@ -29,22 +30,6 @@ function stripImConfigs(obj) {
29
30
  if (obj) for (const id of listPlatforms()) delete obj[id];
30
31
  }
31
32
 
32
- // Loopback identifies the socket peer, not the browser page that initiated a
33
- // request. Since the server intentionally has permissive CORS for legacy APIs,
34
- // sensitive executable-selection endpoints need their own same-origin guard.
35
- function isSameOriginBrowserRequest(req, parsedUrl) {
36
- const origin = req.headers?.origin;
37
- if (!origin) return true; // CLI/native clients do not send Origin.
38
- if (req.headers?.['sec-fetch-site'] === 'cross-site') return false;
39
- try {
40
- const originUrl = new URL(origin);
41
- if (parsedUrl?.origin) return originUrl.origin.toLowerCase() === parsedUrl.origin.toLowerCase();
42
- return originUrl.host.toLowerCase() === String(req.headers?.host || '').toLowerCase();
43
- } catch {
44
- return false;
45
- }
46
- }
47
-
48
33
  // /theme 选择器特征:选项文案高特异、不太可能出现在普通生成输出里(ESC 兜底的门控签名)
49
34
  const THEME_PICKER_RE = /Auto \(match terminal\)|colorblind-friendly/;
50
35