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
@@ -4,8 +4,32 @@ import { join } from 'node:path';
4
4
  import { PACKAGE_JSON } from '../_paths.js';
5
5
  import { LOG_DIR } from '../../findcc.js';
6
6
  import { _projectName } from '../interceptor.js';
7
+ import { sanitizePathComponent } from '../lib/v2/layout.js';
8
+ import { resolveViewRoot } from '../lib/view-root.js';
9
+ import { loadWorkspaces } from '../workspace-registry.js';
10
+ import { listLivePtys } from '../pty-manager.js';
7
11
  import { detectHomebrewInstall } from '../lib/updater.js';
8
12
 
13
+ // Multi-project (2026-10, review P2-6): stats reads for a VIEWED project must
14
+ // not become a presence oracle over LOG_DIR — `?project=` is accepted ONLY
15
+ // when it resolves to a registered workspace / live PTY (or the bound
16
+ // project), never an arbitrary sanitized name.
17
+ function _resolveStatsName(projectParam, res) {
18
+ if (!projectParam) return _projectName || '';
19
+ const r = resolveViewRoot({
20
+ projectParam,
21
+ boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
22
+ loadWorkspaces,
23
+ listLivePtys,
24
+ });
25
+ if (!r.ok) {
26
+ res.writeHead(r.status, { 'Content-Type': 'application/json' });
27
+ res.end(JSON.stringify({ error: r.error }));
28
+ return null;
29
+ }
30
+ return sanitizePathComponent(projectParam);
31
+ }
32
+
9
33
  // 判定当前 cc-viewer 的安装渠道,供前端精准匹配升级命令。
10
34
  // - electron:桌面版(in-process server),走 GitHub Releases 重新下载安装包。
11
35
  // - brew:Homebrew Cellar 布局命中 → `brew upgrade cc-viewer`(npm install -g 会跟 Cellar 打架)。
@@ -44,14 +68,19 @@ function versionInfo(req, res) {
44
68
  }
45
69
  }
46
70
 
47
- function projectStats(req, res) {
71
+ function projectStats(req, res, parsedUrl) {
48
72
  try {
49
- if (!_projectName) {
73
+ // Multi-project (2026-10): project-stats resolves the VIEWED project
74
+ // (?project=, registered/live only — never an arbitrary name).
75
+ const projectParam = parsedUrl && parsedUrl.searchParams ? parsedUrl.searchParams.get('project') : null;
76
+ const name = _resolveStatsName(projectParam, res);
77
+ if (name === null) return; // error already replied
78
+ if (!name) {
50
79
  res.writeHead(404, { 'Content-Type': 'application/json' });
51
80
  res.end(JSON.stringify({ error: 'No project name' }));
52
81
  return;
53
82
  }
54
- const statsFile = join(LOG_DIR, _projectName, `${_projectName}.json`);
83
+ const statsFile = join(LOG_DIR, name, `${name}.json`);
55
84
  if (!existsSync(statsFile)) {
56
85
  res.writeHead(404, { 'Content-Type': 'application/json' });
57
86
  res.end(JSON.stringify({ error: 'Stats file not found' }));
@@ -10,17 +10,45 @@ import { readFileSync, existsSync } from 'node:fs';
10
10
  import { join } from 'node:path';
11
11
  import { LOG_DIR } from '../../findcc.js';
12
12
  import { _projectName } from '../interceptor.js';
13
+ import { sanitizePathComponent } from '../lib/v2/layout.js';
14
+ import { resolveViewRoot } from '../lib/view-root.js';
15
+ import { loadWorkspaces } from '../workspace-registry.js';
16
+ import { listLivePtys } from '../pty-manager.js';
13
17
 
14
- // GET /api/proxy-stats — returns the current project's proxy retry stats (proxyStats field).
15
- // Response body: { proxyStats: {...} }; proxyStats is null when there is no data.
16
- function getProxyStats(req, res) {
18
+ // Multi-project (2026-10, review P2-6): stats reads for a VIEWED project must
19
+ // not become a presence oracle over LOG_DIR — `?project=` is accepted ONLY
20
+ // when it resolves to a registered workspace / live PTY (or the bound
21
+ // project), never an arbitrary sanitized name.
22
+ function _resolveStatsName(projectParam, res) {
23
+ if (!projectParam) return _projectName || '';
24
+ const r = resolveViewRoot({
25
+ projectParam,
26
+ boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
27
+ loadWorkspaces,
28
+ listLivePtys,
29
+ });
30
+ if (!r.ok) {
31
+ res.writeHead(r.status, { 'Content-Type': 'application/json' });
32
+ res.end(JSON.stringify({ error: r.error }));
33
+ return null;
34
+ }
35
+ return sanitizePathComponent(projectParam);
36
+ }
37
+
38
+ // GET /api/proxy-stats — returns the VIEWED project's proxy retry stats
39
+ // (?project=, registered/live only; the log store is name-keyed, not fs-dir).
40
+ // Response body: { proxyStats: {...} }; null when there is no data.
41
+ function getProxyStats(req, res, parsedUrl) {
17
42
  try {
18
- if (!_projectName) {
43
+ const projectParam = parsedUrl && parsedUrl.searchParams ? parsedUrl.searchParams.get('project') : null;
44
+ const name = _resolveStatsName(projectParam, res);
45
+ if (name === null) return; // error already replied
46
+ if (!name) {
19
47
  res.writeHead(404, { 'Content-Type': 'application/json' });
20
48
  res.end(JSON.stringify({ error: 'No project name', proxyStats: null }));
21
49
  return;
22
50
  }
23
- const statsFile = join(LOG_DIR, _projectName, `${_projectName}.json`);
51
+ const statsFile = join(LOG_DIR, name, `${name}.json`);
24
52
  if (!existsSync(statsFile)) {
25
53
  res.writeHead(200, { 'Content-Type': 'application/json' });
26
54
  res.end(JSON.stringify({ proxyStats: null }));
@@ -0,0 +1,302 @@
1
+ // /resume feature routes (2026-10).
2
+ //
3
+ // GET /api/resume-sessions?limit=5
4
+ // Recent-session list for the CURRENT project only (ccv's own v2 store).
5
+ // Powers the header's current-project hover dropdown. Read-only; no
6
+ // loopback gate (view state).
7
+ //
8
+ // GET /api/live-processes
9
+ // Live main claude PTYs this server owns (one per activated project), for
10
+ // the Header's parallel-project chips. Read-only.
11
+ //
12
+ // POST /api/live-processes/attach { project }
13
+ // Attach the main view's PTY stream to that project's record (multi-PTY
14
+ // view switch, 2026-10). Idempotent; never spawns/kills. 404 when the
15
+ // project has no live record (caller degrades silently).
16
+ //
17
+ // POST /api/live-processes/close { project }
18
+ // Kill that project's main PTY (the web header tab's × button). Admin-only
19
+ // (loopback or authenticated remote admin); 403 otherwise, 404 when the
20
+ // project has no record. The record is kept for a later re-launch.
21
+ //
22
+ // POST /api/resume-session { sessionUuid, project?, instanceKey? }
23
+ // TRUE /resume (2026-10-06): inject `/resume <uuid>` into the target
24
+ // project's running claude PTY so the process switches INTO that session
25
+ // in place (Claude Code's native SessionStart hook then re-binds the v2
26
+ // writer). Destructive-ish (re-points a live conversation), so admin +
27
+ // same-origin gated like /close. 409 busy when the TUI isn't observably
28
+ // idle, 409 ambiguous when a project-only target has 2+ live instances,
29
+ // 404 when the target isn't a live claude PTY, 400 on a bad uuid.
30
+ //
31
+ // Boundary note (verify:boundaries): routes (L3) may import lib/ (L1) and
32
+ // interceptor (L2) per the existing house pattern (session-pin.js / logs.js).
33
+ // The aggregation itself lives in lib/v2/resume-list.js (pure, unit-tested);
34
+ // this handler is a thin HTTP wrapper.
35
+
36
+ import { LOG_DIR } from '../../findcc.js';
37
+ import { _projectName, _v2Writer } from '../interceptor.js';
38
+ import { listResumeSessions } from '../lib/v2/resume-list.js';
39
+ import { projectKeyForCwd } from '../lib/system-prompt-snapshots.js';
40
+ import { isAdminReq } from '../lib/is-admin.js';
41
+ import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
42
+ import { reportSwallowed } from '@ccv/core/error-report';
43
+
44
+ function sendJson(res, code, obj) {
45
+ res.writeHead(code, { 'Content-Type': 'application/json' });
46
+ res.end(JSON.stringify(obj));
47
+ }
48
+
49
+ async function getSessionsHandler(req, res, parsedUrl) {
50
+ // parsedUrl is a URL object (server.js handleRequest) — read via searchParams.
51
+ const limitRaw = Number(parsedUrl.searchParams.get('limit'));
52
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 50) : 10;
53
+ try {
54
+ // Multi-project (2026-10): the viewed tab's hover dropdown may ask for the
55
+ // VIEWED project's sessions via ?project= (name-sanitized by
56
+ // listResumeSessions itself); absent ⇒ the bound project (the header's
57
+ // current-project dropdown, the pre-multi-project behavior).
58
+ const projectParam = parsedUrl.searchParams.get('project');
59
+ const project = (typeof projectParam === 'string' && projectParam) ? projectParam : (_projectName || '');
60
+ const { items, total } = listResumeSessions(LOG_DIR, { project, limit });
61
+ // currentSessionUuid: the single live session the main process is running.
62
+ // The client marks exactly ONE row "current" (its sessionUuid === this).
63
+ // Null before the first sid-bearing request.
64
+ let currentSessionUuid = null;
65
+ try { currentSessionUuid = _v2Writer && typeof _v2Writer.currentSessionId === 'function' ? _v2Writer.currentSessionId() : null; } catch (err) { reportSwallowed('resume-route.current-sid', err); }
66
+ sendJson(res, 200, { items, total, currentSessionUuid });
67
+ } catch (err) {
68
+ reportSwallowed('resume-route.list', err);
69
+ sendJson(res, 500, { error: 'list failed' });
70
+ }
71
+ }
72
+
73
+ // GET /api/live-processes — enumerate the LIVE main claude PTYs this server owns
74
+ // (one per activated project, kept alive across project switches), for the Header's
75
+ // parallel-project chips. Each entry: { kind:'main', project, cwd, pid, active }.
76
+ // `project` is derived from cwd via projectKeyForCwd (same mapping the interceptor
77
+ // uses for write routing). Read-only; a probe failure degrades to an empty list.
78
+ async function getLiveProcessesHandler(req, res, parsedUrl) {
79
+ const processes = [];
80
+ let currentInstanceKey = null;
81
+ try {
82
+ const { listLivePtys } = await import('../pty-manager.js');
83
+ const live = listLivePtys();
84
+ // Bound project's own instanceKey (pure-client viewing, 2026-10-07): the identity of
85
+ // the BOUND project — the live record whose cwd equals the server's bound root. This
86
+ // is per-server and single-valued, deliberately NOT the shared `active` pointer (that
87
+ // is the terminal attachment, which lags and differs per client). The header uses it
88
+ // to single out THE bound tab among same-basename tabs without reading `active`.
89
+ const boundCwd = process.env.CCV_PROJECT_DIR || process.cwd();
90
+ for (const p of live) {
91
+ // Multi-project (2026-10, review P1): only CLAUDE main PTYs become project
92
+ // tabs. A scratch/$EDITOR shell (ptyKind 'shell') in a differently-named
93
+ // cwd would otherwise surface as a closable "project" tab and flip the
94
+ // Header into the multi-project tab-bar form on its own — violating the
95
+ // single-project parity contract.
96
+ if (p.ptyKind && p.ptyKind !== 'claude') continue;
97
+ if (!currentInstanceKey && p.instanceKey && (p.cwd || '') === boundCwd) {
98
+ currentInstanceKey = p.instanceKey;
99
+ }
100
+ processes.push({
101
+ kind: 'main',
102
+ project: projectKeyForCwd(p.cwd),
103
+ cwd: p.cwd,
104
+ pid: p.pid,
105
+ active: p.isActive === true,
106
+ // Multi-instance (2026-10-06): surface the per-process instanceKey so the web tab bar
107
+ // can tell two concurrent same-cwd instances apart and target attach/close precisely.
108
+ instanceKey: p.instanceKey || null,
109
+ });
110
+ }
111
+ } catch (err) { reportSwallowed('resume-route.live-main', err); }
112
+ sendJson(res, 200, { processes, currentProject: _projectName || '', currentInstanceKey });
113
+ }
114
+
115
+ // POST /api/live-processes/attach { project } — move the main view's PTY
116
+ // attachment to the given project's record. Powers the terminal panel after a
117
+ // chip view-switch / [+] launch: without it the shared /ws/terminal stream
118
+ // stays pinned to the last-spawned project and the viewed project's terminal
119
+ // shows the wrong screen (or never starts). Idempotent: re-attaching the
120
+ // already-active project is a no-op; unknown projects get 404 and nothing
121
+ // changes. Body capped via deps.MAX_POST_BODY like the other POST routes.
122
+ function postLiveProcessAttachHandler(req, res, parsedUrl, isLocal, deps) {
123
+ let body = '';
124
+ req.on('data', (chunk) => { body += chunk; if (deps && body.length > deps.MAX_POST_BODY) req.destroy(); });
125
+ req.on('end', async () => {
126
+ let project = '';
127
+ let instanceKey = '';
128
+ try {
129
+ const parsed = JSON.parse(body || '{}');
130
+ project = typeof parsed.project === 'string' ? parsed.project : '';
131
+ instanceKey = typeof parsed.instanceKey === 'string' ? parsed.instanceKey : '';
132
+ } catch {
133
+ sendJson(res, 400, { ok: false, reason: 'bad-json' });
134
+ return;
135
+ }
136
+ if (!project && !instanceKey) {
137
+ sendJson(res, 400, { ok: false, reason: 'missing-project' });
138
+ return;
139
+ }
140
+ try {
141
+ const { attachPtyFor } = await import('../pty-manager.js');
142
+ const r = attachPtyFor({ project: project || undefined, instanceKey: instanceKey || undefined });
143
+ if (!r.ok) {
144
+ // Multi-instance: an ambiguous project-name-only attach (2+ live same-basename
145
+ // instances) is a 409 with the live candidates, so the client re-issues with an
146
+ // instanceKey instead of silently pinning an arbitrary process.
147
+ if (r.reason === 'ambiguous') {
148
+ sendJson(res, 409, { ok: false, reason: 'ambiguous', candidates: r.candidates || [] });
149
+ return;
150
+ }
151
+ sendJson(res, 404, { ok: false, reason: r.reason || 'not-found' });
152
+ return;
153
+ }
154
+ sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, running: r.running, ptyKind: r.ptyKind, exitCode: r.exitCode, switched: r.switched });
155
+ } catch (err) {
156
+ reportSwallowed('resume-route.attach', err);
157
+ sendJson(res, 500, { ok: false, reason: 'attach-failed' });
158
+ }
159
+ });
160
+ }
161
+
162
+ // POST /api/live-processes/close { project } — kill that project's main PTY
163
+ // (the web multi-project tab bar's × button, 2026-10). Destructive, so unlike
164
+ // the read-only routes above this is admin-gated: loopback or an authenticated
165
+ // remote admin (isAdminReq; killing another user's process over LAN must not be
166
+ // possible with a mere view token). 404 when the project has no record. When
167
+ // the killed PTY was the terminal's active attachment, the server re-anchors
168
+ // the attachment to another live record internally (killedActive in the
169
+ // response); the client decides its own view repair from the remaining tab
170
+ // list (it does not need the re-anchor target).
171
+ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
172
+ // Two gates, both required: admin identity (loopback or authenticated remote
173
+ // admin) AND same-origin browser context. The server intentionally answers
174
+ // CORS preflight permissively for legacy APIs, so without the second gate a
175
+ // cross-site page could kill a project's process through the user's browser
176
+ // (their token/cookie rides along). Same guard the executable-selection
177
+ // endpoints in routes/preferences.js stack on top of isAdminReq.
178
+ if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
179
+ sendJson(res, 403, { ok: false, reason: 'forbidden' });
180
+ return;
181
+ }
182
+ let body = '';
183
+ req.on('data', (chunk) => { body += chunk; if (deps && body.length > deps.MAX_POST_BODY) req.destroy(); });
184
+ req.on('end', async () => {
185
+ let project = '';
186
+ let instanceKey = '';
187
+ try {
188
+ const parsed = JSON.parse(body || '{}');
189
+ project = typeof parsed.project === 'string' ? parsed.project : '';
190
+ instanceKey = typeof parsed.instanceKey === 'string' ? parsed.instanceKey : '';
191
+ } catch {
192
+ sendJson(res, 400, { ok: false, reason: 'bad-json' });
193
+ return;
194
+ }
195
+ if (!project && !instanceKey) {
196
+ sendJson(res, 400, { ok: false, reason: 'missing-project' });
197
+ return;
198
+ }
199
+ try {
200
+ const { killPtyFor } = await import('../pty-manager.js');
201
+ const r = killPtyFor({ project: project || undefined, instanceKey: instanceKey || undefined });
202
+ if (!r.ok) {
203
+ // Multi-instance: an ambiguous project-name-only close (2+ live same-basename
204
+ // instances) is a 409 with the live candidates, so the client re-issues with an
205
+ // instanceKey instead of killing an arbitrary process.
206
+ if (r.reason === 'ambiguous') {
207
+ sendJson(res, 409, { ok: false, reason: 'ambiguous', candidates: r.candidates || [] });
208
+ return;
209
+ }
210
+ sendJson(res, 404, { ok: false, reason: r.reason || 'not-found' });
211
+ return;
212
+ }
213
+ sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, killedActive: r.killedActive === true });
214
+ } catch (err) {
215
+ reportSwallowed('resume-route.close', err);
216
+ sendJson(res, 500, { ok: false, reason: 'close-failed' });
217
+ }
218
+ });
219
+ }
220
+
221
+ 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;
222
+
223
+ // POST /api/resume-session { sessionUuid, project?, instanceKey? } — TRUE /resume:
224
+ // inject `/resume <uuid>` into the target project's live claude PTY so the running
225
+ // process switches INTO that session in place. Admin + same-origin gated (it re-points
226
+ // a live conversation). See the header comment for the status/reason mapping.
227
+ function postResumeSessionHandler(req, res, parsedUrl, isLocal, deps) {
228
+ if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
229
+ sendJson(res, 403, { ok: false, reason: 'forbidden' });
230
+ return;
231
+ }
232
+ let body = '';
233
+ req.on('data', (chunk) => { body += chunk; if (deps && body.length > deps.MAX_POST_BODY) req.destroy(); });
234
+ req.on('end', async () => {
235
+ let sessionUuid = '';
236
+ let project = '';
237
+ let instanceKey = '';
238
+ try {
239
+ const parsed = JSON.parse(body || '{}');
240
+ sessionUuid = typeof parsed.sessionUuid === 'string' ? parsed.sessionUuid.trim() : '';
241
+ project = typeof parsed.project === 'string' ? parsed.project : '';
242
+ instanceKey = typeof parsed.instanceKey === 'string' ? parsed.instanceKey : '';
243
+ } catch {
244
+ sendJson(res, 400, { ok: false, reason: 'bad-json' });
245
+ return;
246
+ }
247
+ if (!sessionUuid) {
248
+ sendJson(res, 400, { ok: false, reason: 'missing-session' });
249
+ return;
250
+ }
251
+ // Shape-validate BEFORE it ever reaches the TUI paste buffer (defense-in-depth;
252
+ // sanitizeInbound alone would still let `;`, quotes, `$()` through).
253
+ if (!RESUME_UUID_RE.test(sessionUuid)) {
254
+ sendJson(res, 400, { ok: false, reason: 'bad-uuid' });
255
+ return;
256
+ }
257
+ try {
258
+ const pm = await import('../pty-manager.js');
259
+ const targetProject = project || (_projectName || '');
260
+ // Multi-instance: a project-name-only resume that matches 2+ live same-basename
261
+ // instances is ambiguous — refuse with the candidates so the client re-issues
262
+ // with an instanceKey (mirror of attach/close).
263
+ if (!instanceKey && targetProject) {
264
+ const live = pm.liveInstancesForProject(targetProject);
265
+ if (live.length > 1) {
266
+ sendJson(res, 409, { ok: false, reason: 'ambiguous', candidates: live });
267
+ return;
268
+ }
269
+ }
270
+ // Strict target-kind gate (no active-PTY fallback): never inject into the wrong
271
+ // project's conversation when the anchor doesn't resolve, and never into a shell.
272
+ if (pm.getPtyKindFor({ project: targetProject || undefined, instanceKey: instanceKey || undefined }) !== 'claude') {
273
+ sendJson(res, 404, { ok: false, reason: 'not-found' });
274
+ return;
275
+ }
276
+ const chatQueue = await import('../lib/chat-queue.js');
277
+ const r = await chatQueue.injectResumeCommand(sessionUuid, { project: targetProject || undefined, instanceKey: instanceKey || undefined });
278
+ if (r.ok) {
279
+ // Session identity just changed — drop any still-queued composer messages so a
280
+ // message typed for the OLD conversation never drains into the resumed one.
281
+ try { chatQueue.clear(); } catch (err) { reportSwallowed('resume-route.clear-queue', err); }
282
+ sendJson(res, 200, { ok: true });
283
+ return;
284
+ }
285
+ if (r.reason === 'busy') { sendJson(res, 409, { ok: false, reason: 'busy' }); return; }
286
+ if (r.reason === 'bad-uuid') { sendJson(res, 400, { ok: false, reason: 'bad-uuid' }); return; }
287
+ if (r.reason === 'no-deps') { sendJson(res, 409, { ok: false, reason: 'unavailable' }); return; }
288
+ sendJson(res, 500, { ok: false, reason: r.reason || 'inject-failed' });
289
+ } catch (err) {
290
+ reportSwallowed('resume-route.resume', err);
291
+ sendJson(res, 500, { ok: false, reason: 'resume-failed' });
292
+ }
293
+ });
294
+ }
295
+
296
+ export const resumeRoutes = [
297
+ { method: 'GET', match: 'exact', path: '/api/resume-sessions', handler: getSessionsHandler },
298
+ { method: 'GET', match: 'exact', path: '/api/live-processes', handler: getLiveProcessesHandler },
299
+ { method: 'POST', match: 'exact', path: '/api/live-processes/attach', handler: postLiveProcessAttachHandler },
300
+ { method: 'POST', match: 'exact', path: '/api/live-processes/close', handler: postLiveProcessCloseHandler },
301
+ { method: 'POST', match: 'exact', path: '/api/resume-session', handler: postResumeSessionHandler },
302
+ ];
@@ -2,6 +2,22 @@
2
2
  // Scopes to the project root and delegates to lib/code-search.js and lib/code-replace.js.
3
3
  import { searchCode } from '../lib/code-search.js';
4
4
  import { searchReplace } from '../lib/code-replace.js';
5
+ import { resolveViewRoot } from '../lib/view-root.js';
6
+ import { loadWorkspaces } from '../workspace-registry.js';
7
+
8
+ // Multi-project (2026-10): code SEARCH (read-only) resolves the viewed project
9
+ // via resolveViewRoot (body {project}). search-replace (destructive) stays bound.
10
+ async function _viewRootOrReply(req, res, projectParam, instanceParam) {
11
+ let listLivePtys = () => [];
12
+ try { ({ listLivePtys } = await import('../pty-manager.js')); } catch { /* no pty map */ }
13
+ return resolveViewRoot({
14
+ projectParam,
15
+ instanceParam,
16
+ boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
17
+ loadWorkspaces,
18
+ listLivePtys,
19
+ });
20
+ }
5
21
 
6
22
  const VALID_ENGINES = new Set(['auto', 'ripgrep', 'node']);
7
23
  const VALID_SCOPES = new Set(['all', 'file', 'match']);
@@ -50,7 +66,13 @@ function searchHandler(req, res, parsedUrl, isLocal, deps) {
50
66
  }
51
67
 
52
68
  const engine = VALID_ENGINES.has(parsed.engine) ? parsed.engine : 'auto';
53
- const root = process.env.CCV_PROJECT_DIR || process.cwd();
69
+ const viewR = await _viewRootOrReply(req, res, typeof parsed.project === 'string' ? parsed.project : '', typeof parsed.instance === 'string' ? parsed.instance : '');
70
+ if (!viewR.ok) {
71
+ res.writeHead(viewR.status, { 'Content-Type': 'application/json' });
72
+ res.end(JSON.stringify({ error: viewR.error }));
73
+ return;
74
+ }
75
+ const root = viewR.root;
54
76
 
55
77
  try {
56
78
  const result = await searchCode({
@@ -4,10 +4,26 @@ import { join, resolve, sep, dirname } from 'node:path';
4
4
  import { getClaudeConfigDir } from '../../findcc.js';
5
5
  import { listSkills, moveSkill, deleteSkill, validateSkillName, parseSkillFrontmatter } from '../lib/skills-api.js';
6
6
  import { isAdminReq } from '../lib/is-admin.js';
7
+ import { viewRootOrReply } from '../lib/view-root.js';
8
+ import { loadWorkspaces } from '../workspace-registry.js';
9
+ import { listLivePtys } from '../pty-manager.js';
7
10
 
8
- async function skillsList(req, res) {
11
+ // Multi-project (2026-10): the skills LIST resolves the viewed project via
12
+ // the shared viewRootOrReply (?project=). toggle/delete (mutations) keep the
13
+ // bound root.
14
+ function _viewRootOrReply(req, res, parsedUrl) {
15
+ return viewRootOrReply(req, res, parsedUrl, {
16
+ boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
17
+ loadWorkspaces,
18
+ listLivePtys,
19
+ });
20
+ }
21
+
22
+ async function skillsList(req, res, parsedUrl) {
9
23
  try {
10
- const skills = listSkills({ projectDir: process.env.CCV_PROJECT_DIR || process.cwd() });
24
+ const viewRoot = _viewRootOrReply(req, res, parsedUrl);
25
+ if (!viewRoot) return;
26
+ const skills = listSkills({ projectDir: viewRoot });
11
27
  res.writeHead(200, { 'Content-Type': 'application/json' });
12
28
  res.end(JSON.stringify({ ok: true, skills }));
13
29
  } catch (err) {
@@ -44,7 +44,12 @@ async function v2Entry(req, res, parsedUrl) {
44
44
  sseWrite(res, `{"entry":${result.entry},"prevMain":${result.prevMain ?? 'null'}}`);
45
45
  wireEnd(res);
46
46
  } catch (err) {
47
- console.error('[v2-entry]', file, err && err.stack || err);
47
+ // A NOT_FOUND here is routine, not an error: the detail-fetch target (a v3
48
+ // row's session dir) was quota-pruned or /clear-cleaned after the list was
49
+ // rendered. The 404 is the correct, sufficient response — logging it to
50
+ // stderr as a stack would be pure noise. Only genuine faults (validation
51
+ // bugs, mid-stream I/O errors) are worth the console.
52
+ if (err.code !== 'NOT_FOUND') console.error('[v2-entry]', file, err && err.stack || err);
48
53
  if (!res.headersSent) {
49
54
  const status = err.code === 'NOT_FOUND' ? 404 : err.code === 'ACCESS_DENIED' ? 403 : 500;
50
55
  res.writeHead(status, { 'Content-Type': 'application/json' });
@@ -82,8 +82,12 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
82
82
  // 启动 PTY
83
83
  const proxyPort = process.env.CCV_PROXY_PORT;
84
84
  if (proxyPort) {
85
- const { spawnClaude } = await import('../pty-manager.js');
86
- await spawnClaude(parseInt(proxyPort), wsPath, mergedArgs, deps.workspaceClaudePath, deps.workspaceIsNpmVersion, deps.actualPort, deps.protocol, deps.INTERNAL_TOKEN);
85
+ // Multi-instance (2026-10-06): spawnClaude now mints a fresh instanceKey per call and
86
+ // no longer kills an existing same-cwd record, so re-launching an ALREADY-LIVE cwd
87
+ // here would silently start a duplicate claude. ensurePtyForCwd restores the intended
88
+ // "re-open = reuse" semantics: attach to the live record instead of re-spawning.
89
+ const { ensurePtyForCwd } = await import('../pty-manager.js');
90
+ await ensurePtyForCwd({ cwd: wsPath, proxyPort: parseInt(proxyPort), extraArgs: mergedArgs, claudePath: deps.workspaceClaudePath, isNpmVersion: deps.workspaceIsNpmVersion, serverPort: deps.actualPort, serverProtocol: deps.protocol, internalToken: deps.INTERNAL_TOKEN });
87
91
  }
88
92
 
89
93
  deps.setWorkspaceLaunched(true);
@@ -171,7 +175,10 @@ function workspacesDelete(req, res, parsedUrl) {
171
175
 
172
176
  function workspacesStop(req, res, parsedUrl, isLocal, deps) {
173
177
  Promise.all([
174
- import('../pty-manager.js').then(({ killPty }) => killPty()),
178
+ // killAllMain (not killPty): a cross-project /resume takeover can leave a background
179
+ // project's PTY alive; stop must reap every main PTY, not just the active one, or the
180
+ // old project's claude leaks past the workspace session.
181
+ import('../pty-manager.js').then(({ killAllMain }) => killAllMain()),
175
182
  import('../scratch-pty-manager.js').then(({ killAllScratch }) => killAllScratch()).catch(() => {}),
176
183
  ]).then(() => {
177
184
  // 接续原有清理流程