cc-viewer 1.9.0 → 1.9.2

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 (160) hide show
  1. package/README.md +3 -1
  2. package/cli.js +33 -4
  3. package/concepts/ar/UltraPlan.md +1 -1
  4. package/concepts/da/UltraPlan.md +1 -1
  5. package/concepts/de/UltraPlan.md +1 -1
  6. package/concepts/en/UltraPlan.md +1 -1
  7. package/concepts/es/UltraPlan.md +1 -1
  8. package/concepts/fr/UltraPlan.md +1 -1
  9. package/concepts/it/UltraPlan.md +1 -1
  10. package/concepts/ja/UltraPlan.md +1 -1
  11. package/concepts/ko/UltraPlan.md +1 -1
  12. package/concepts/no/UltraPlan.md +1 -1
  13. package/concepts/pl/UltraPlan.md +1 -1
  14. package/concepts/pt-BR/UltraPlan.md +1 -1
  15. package/concepts/ru/UltraPlan.md +1 -1
  16. package/concepts/th/UltraPlan.md +1 -1
  17. package/concepts/tr/UltraPlan.md +1 -1
  18. package/concepts/uk/UltraPlan.md +1 -1
  19. package/concepts/zh/UltraPlan.md +1 -1
  20. package/concepts/zh-TW/UltraPlan.md +1 -1
  21. package/dist/assets/App-DJ4yNvwz.css +1 -0
  22. package/dist/assets/App-DPRn6oGn.js +2 -0
  23. package/dist/assets/{MdxEditorPanel-3j8rS3IT.js → MdxEditorPanel-Ci4ldhd7.js} +1 -1
  24. package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
  25. package/dist/assets/Mobile-Dq-KcO1s.js +1 -0
  26. package/dist/assets/ProxyStatsModal-CUFpqt1s.js +1 -0
  27. package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
  28. package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
  29. package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
  30. package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
  31. package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
  32. package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
  33. package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
  34. package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
  35. package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
  36. package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
  37. package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
  38. package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
  39. package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
  40. package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
  41. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
  42. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
  43. package/dist/assets/clone-CFj55h49.js +1 -0
  44. package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
  45. package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
  46. package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
  47. package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
  48. package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
  49. package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
  50. package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
  51. package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
  52. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
  53. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
  54. package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
  55. package/dist/assets/index-BLzca-ta.css +1 -0
  56. package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
  57. package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
  58. package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
  59. package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
  60. package/dist/assets/index-Ci38xZiX.js +2 -0
  61. package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
  62. package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
  63. package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
  64. package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
  65. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
  66. package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
  67. package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
  68. package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
  69. package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
  70. package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
  71. package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
  72. package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
  73. package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
  74. package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
  75. package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
  76. package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
  77. package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
  78. package/dist/assets/seqResourceLoaders-B0p7e_lM.js +2 -0
  79. package/dist/assets/seqResourceLoaders-BsMFNfvR.css +41 -0
  80. package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
  81. package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
  82. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
  83. package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
  84. package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
  85. package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
  86. package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
  87. package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
  88. package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
  89. package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
  90. package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
  91. package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
  92. package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
  93. package/dist/index.html +5 -5
  94. package/package.json +1 -1
  95. package/server/interceptor.js +192 -24
  96. package/server/lib/ask/ask-store.js +7 -3
  97. package/server/lib/async-file-lock.js +29 -5
  98. package/server/lib/auth.js +71 -54
  99. package/server/lib/chat-queue.js +67 -4
  100. package/server/lib/config-backup.js +8 -5
  101. package/server/lib/credential-access.js +233 -0
  102. package/server/lib/credential-migrate.js +147 -0
  103. package/server/lib/credential-store.js +120 -0
  104. package/server/lib/credential-vault.js +112 -0
  105. package/server/lib/file-access-policy.js +16 -0
  106. package/server/lib/git-diff.js +49 -20
  107. package/server/lib/im/im-config.js +81 -31
  108. package/server/lib/im-deny.js +22 -8
  109. package/server/lib/json-store.js +264 -0
  110. package/server/lib/log-watcher.js +9 -1
  111. package/server/lib/prefs-store.js +8 -33
  112. package/server/lib/same-origin.js +22 -0
  113. package/server/lib/session-pin-store.js +7 -14
  114. package/server/lib/v2/convert.js +1 -1
  115. package/server/lib/v2/layout.js +53 -3
  116. package/server/lib/v2/live-feed.js +41 -4
  117. package/server/lib/v2/resume-list.js +150 -0
  118. package/server/lib/v2/session-select.js +63 -14
  119. package/server/lib/v2/v2-writer.js +172 -34
  120. package/server/lib/v2/view-router.js +83 -0
  121. package/server/lib/view-root.js +209 -0
  122. package/server/pty-manager.js +809 -163
  123. package/server/routes/ask-perm.js +21 -1
  124. package/server/routes/auth.js +21 -1
  125. package/server/routes/events.js +123 -7
  126. package/server/routes/expert.js +46 -14
  127. package/server/routes/files-content.js +72 -7
  128. package/server/routes/files-fs.js +28 -3
  129. package/server/routes/git.js +31 -5
  130. package/server/routes/im.js +15 -2
  131. package/server/routes/preferences.js +59 -46
  132. package/server/routes/project-meta.js +32 -3
  133. package/server/routes/proxy-stats.js +33 -5
  134. package/server/routes/resume.js +457 -0
  135. package/server/routes/search.js +23 -1
  136. package/server/routes/skills.js +18 -2
  137. package/server/routes/v2.js +6 -1
  138. package/server/routes/workspaces.js +18 -5
  139. package/server/server.js +199 -25
  140. package/server/system-prompt-templates/presets/GLM-5.2.md +0 -1
  141. package/server/system-prompt-templates/presets/GLM-5.3.md +0 -1
  142. package/server/system-prompt-templates/presets/Qwen-3.md +0 -1
  143. package/server/system-prompt-templates/presets/deepseek-v4-flash.md +0 -1
  144. package/server/system-prompt-templates/presets/deepseek-v4-pro.md +0 -1
  145. package/server/system-prompt-templates/presets/kimi-k2.7-code.md +0 -1
  146. package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
  147. package/server/system-prompt-templates/systemPromptModel.md +0 -1
  148. package/server/workspace-registry.js +150 -75
  149. package/ultraAgents/code-expert.json +1 -1
  150. package/dist/assets/App-M61pqQEx.css +0 -1
  151. package/dist/assets/App-_HfxMJvv.js +0 -2
  152. package/dist/assets/Mobile-DaRLB7Gh.js +0 -1
  153. package/dist/assets/ProxyStatsModal-dlPIDlx-.js +0 -1
  154. package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
  155. package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
  156. package/dist/assets/clone-B0_ghF3F.js +0 -1
  157. package/dist/assets/index-CjbBUDGP.js +0 -2
  158. package/dist/assets/index-Nvk8r3tO.css +0 -1
  159. package/dist/assets/seqResourceLoaders-B_FSQh3U.css +0 -41
  160. package/dist/assets/seqResourceLoaders-hqLhsiGO.js +0 -2
@@ -0,0 +1,457 @@
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. When the
21
+ // killed project WAS the bound one and a survivor is still live, the server
22
+ // re-binds its workspace identity (_projectName + CCV_PROJECT_DIR) to that
23
+ // survivor (a same-name/same-cwd twin degrades this to a dir-only rebind),
24
+ // broadcasts workspace_started(rebound:true) so every client lands on the
25
+ // survivor, and the response carries `rebound: { project, cwd }`.
26
+ //
27
+ // POST /api/resume-session { sessionUuid, project?, instanceKey? }
28
+ // TRUE /resume (2026-10-06): inject `/resume <uuid>` into the target
29
+ // project's running claude PTY so the process switches INTO that session
30
+ // in place (Claude Code's native SessionStart hook then re-binds the v2
31
+ // writer). Destructive-ish (re-points a live conversation), so admin +
32
+ // same-origin gated like /close. 409 busy when the TUI isn't observably
33
+ // idle, 409 ambiguous when a project-only target has 2+ live instances,
34
+ // 404 when the target isn't a live claude PTY, 400 on a bad uuid.
35
+ //
36
+ // Boundary note (verify:boundaries): routes (L3) may import lib/ (L1) and
37
+ // interceptor (L2) per the existing house pattern (session-pin.js / logs.js).
38
+ // The aggregation itself lives in lib/v2/resume-list.js (pure, unit-tested);
39
+ // this handler is a thin HTTP wrapper.
40
+
41
+ import { LOG_DIR } from '../../findcc.js';
42
+ import { _projectName, _v2Writer, initForWorkspace } from '../interceptor.js';
43
+ // Static import (not the route-local dynamic import) so the close-rebind helper
44
+ // can call listLivePtys synchronously — a static import keeps the single shared
45
+ // module instance, and module state is shared either way.
46
+ import { listLivePtys } from '../pty-manager.js';
47
+ import { listResumeSessions } from '../lib/v2/resume-list.js';
48
+ import { projectKeyForCwd } from '../lib/system-prompt-snapshots.js';
49
+ import { readClaudeProjectModel } from '../lib/context-watcher.js';
50
+ import { bumpWorkspacesVersion } from '../lib/file-access-policy.js';
51
+ import { isAdminReq } from '../lib/is-admin.js';
52
+ import { isSameOriginBrowserRequest } from '../lib/same-origin.js';
53
+ import { sseWrite } from '../lib/wire-compress.js';
54
+ import { reportSwallowed } from '@ccv/core/error-report';
55
+
56
+ function sendJson(res, code, obj) {
57
+ res.writeHead(code, { 'Content-Type': 'application/json' });
58
+ res.end(JSON.stringify(obj));
59
+ }
60
+
61
+ async function getSessionsHandler(req, res, parsedUrl) {
62
+ // parsedUrl is a URL object (server.js handleRequest) — read via searchParams.
63
+ const limitRaw = Number(parsedUrl.searchParams.get('limit'));
64
+ const limit = Number.isFinite(limitRaw) && limitRaw > 0 ? Math.min(Math.floor(limitRaw), 50) : 10;
65
+ try {
66
+ // Multi-project (2026-10): the viewed tab's hover dropdown may ask for the
67
+ // VIEWED project's sessions via ?project= (name-sanitized by
68
+ // listResumeSessions itself); absent ⇒ the bound project (the header's
69
+ // current-project dropdown, the pre-multi-project behavior).
70
+ const projectParam = parsedUrl.searchParams.get('project');
71
+ const project = (typeof projectParam === 'string' && projectParam) ? projectParam : (_projectName || '');
72
+ const { items, total } = listResumeSessions(LOG_DIR, { project, limit });
73
+ // currentSessionUuid: the single live session the main process is running.
74
+ // The client marks exactly ONE row "current" (its sessionUuid === this).
75
+ // Null before the first sid-bearing request.
76
+ let currentSessionUuid = null;
77
+ try { currentSessionUuid = _v2Writer && typeof _v2Writer.currentSessionId === 'function' ? _v2Writer.currentSessionId() : null; } catch (err) { reportSwallowed('resume-route.current-sid', err); }
78
+ sendJson(res, 200, { items, total, currentSessionUuid });
79
+ } catch (err) {
80
+ reportSwallowed('resume-route.list', err);
81
+ sendJson(res, 500, { error: 'list failed' });
82
+ }
83
+ }
84
+
85
+ // GET /api/live-processes — enumerate the LIVE main claude PTYs this server owns
86
+ // (one per activated project, kept alive across project switches), for the Header's
87
+ // parallel-project chips. Each entry: { kind:'main', project, cwd, pid, active }.
88
+ // `project` is derived from cwd via projectKeyForCwd (same mapping the interceptor
89
+ // uses for write routing). Read-only; a probe failure degrades to an empty list.
90
+ async function getLiveProcessesHandler(req, res, parsedUrl) {
91
+ const processes = [];
92
+ let currentInstanceKey = null;
93
+ try {
94
+ const { listLivePtys } = await import('../pty-manager.js');
95
+ const live = listLivePtys();
96
+ // Bound project's own instanceKey (pure-client viewing, 2026-10-07): the identity of
97
+ // the BOUND project — the live record whose cwd equals the server's bound root. This
98
+ // is per-server and single-valued, deliberately NOT the shared `active` pointer (that
99
+ // is the terminal attachment, which lags and differs per client). The header uses it
100
+ // to single out THE bound tab among same-basename tabs without reading `active`.
101
+ const boundCwd = process.env.CCV_PROJECT_DIR || process.cwd();
102
+ for (const p of live) {
103
+ // Multi-project (2026-10, review P1): only CLAUDE main PTYs become project
104
+ // tabs. A scratch/$EDITOR shell (ptyKind 'shell') in a differently-named
105
+ // cwd would otherwise surface as a closable "project" tab and flip the
106
+ // Header into the multi-project tab-bar form on its own — violating the
107
+ // single-project parity contract.
108
+ if (p.ptyKind && p.ptyKind !== 'claude') continue;
109
+ if (!currentInstanceKey && p.instanceKey && (p.cwd || '') === boundCwd) {
110
+ currentInstanceKey = p.instanceKey;
111
+ }
112
+ processes.push({
113
+ kind: 'main',
114
+ project: projectKeyForCwd(p.cwd),
115
+ cwd: p.cwd,
116
+ pid: p.pid,
117
+ active: p.isActive === true,
118
+ // Multi-instance (2026-10-06): surface the per-process instanceKey so the web tab bar
119
+ // can tell two concurrent same-cwd instances apart and target attach/close precisely.
120
+ instanceKey: p.instanceKey || null,
121
+ });
122
+ }
123
+ } catch (err) { reportSwallowed('resume-route.live-main', err); }
124
+ sendJson(res, 200, { processes, currentProject: _projectName || '', currentInstanceKey });
125
+ }
126
+
127
+ // POST /api/live-processes/attach { project } — move the main view's PTY
128
+ // attachment to the given project's record. Powers the terminal panel after a
129
+ // chip view-switch / [+] launch: without it the shared /ws/terminal stream
130
+ // stays pinned to the last-spawned project and the viewed project's terminal
131
+ // shows the wrong screen (or never starts). Idempotent: re-attaching the
132
+ // already-active project is a no-op; unknown projects get 404 and nothing
133
+ // changes. Body capped via deps.MAX_POST_BODY like the other POST routes.
134
+ function postLiveProcessAttachHandler(req, res, parsedUrl, isLocal, deps) {
135
+ let body = '';
136
+ req.on('data', (chunk) => { body += chunk; if (deps && body.length > deps.MAX_POST_BODY) req.destroy(); });
137
+ req.on('end', async () => {
138
+ let project = '';
139
+ let instanceKey = '';
140
+ try {
141
+ const parsed = JSON.parse(body || '{}');
142
+ project = typeof parsed.project === 'string' ? parsed.project : '';
143
+ instanceKey = typeof parsed.instanceKey === 'string' ? parsed.instanceKey : '';
144
+ } catch {
145
+ sendJson(res, 400, { ok: false, reason: 'bad-json' });
146
+ return;
147
+ }
148
+ if (!project && !instanceKey) {
149
+ sendJson(res, 400, { ok: false, reason: 'missing-project' });
150
+ return;
151
+ }
152
+ try {
153
+ const { attachPtyFor } = await import('../pty-manager.js');
154
+ const r = attachPtyFor({ project: project || undefined, instanceKey: instanceKey || undefined });
155
+ if (!r.ok) {
156
+ // Multi-instance: an ambiguous project-name-only attach (2+ live same-basename
157
+ // instances) is a 409 with the live candidates, so the client re-issues with an
158
+ // instanceKey instead of silently pinning an arbitrary process.
159
+ if (r.reason === 'ambiguous') {
160
+ sendJson(res, 409, { ok: false, reason: 'ambiguous', candidates: r.candidates || [] });
161
+ return;
162
+ }
163
+ sendJson(res, 404, { ok: false, reason: r.reason || 'not-found' });
164
+ return;
165
+ }
166
+ sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, running: r.running, ptyKind: r.ptyKind, exitCode: r.exitCode, switched: r.switched });
167
+ } catch (err) {
168
+ reportSwallowed('resume-route.attach', err);
169
+ sendJson(res, 500, { ok: false, reason: 'attach-failed' });
170
+ }
171
+ });
172
+ }
173
+
174
+ // POST /api/live-processes/close { project } — kill that project's main PTY
175
+ // (the web multi-project tab bar's × button, 2026-10). Destructive, so unlike
176
+ // the read-only routes above this is admin-gated: loopback or an authenticated
177
+ // remote admin (isAdminReq; killing another user's process over LAN must not be
178
+ // possible with a mere view token). 404 when the project has no record. When
179
+ // the killed PTY was the terminal's active attachment, the server re-anchors
180
+ // the attachment to another live record internally (killedActive in the
181
+ // response). When the killed project WAS the bound one and a survivor is still
182
+ // live, the server ALSO re-binds its workspace identity (_projectName +
183
+ // CCV_PROJECT_DIR) to that survivor and broadcasts workspace_started(rebound:true)
184
+ // so every client lands on the survivor (the "当前项目" label would otherwise
185
+ // stay on the just-closed project); the response then carries `rebound`.
186
+ function postLiveProcessCloseHandler(req, res, parsedUrl, isLocal, deps) {
187
+ // Two gates, both required: admin identity (loopback or authenticated remote
188
+ // admin) AND same-origin browser context. The server intentionally answers
189
+ // CORS preflight permissively for legacy APIs, so without the second gate a
190
+ // cross-site page could kill a project's process through the user's browser
191
+ // (their token/cookie rides along). Same guard the executable-selection
192
+ // endpoints in routes/preferences.js stack on top of isAdminReq.
193
+ if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
194
+ sendJson(res, 403, { ok: false, reason: 'forbidden' });
195
+ return;
196
+ }
197
+ let body = '';
198
+ req.on('data', (chunk) => { body += chunk; if (deps && body.length > deps.MAX_POST_BODY) req.destroy(); });
199
+ req.on('end', async () => {
200
+ let project = '';
201
+ let instanceKey = '';
202
+ try {
203
+ const parsed = JSON.parse(body || '{}');
204
+ project = typeof parsed.project === 'string' ? parsed.project : '';
205
+ instanceKey = typeof parsed.instanceKey === 'string' ? parsed.instanceKey : '';
206
+ } catch {
207
+ sendJson(res, 400, { ok: false, reason: 'bad-json' });
208
+ return;
209
+ }
210
+ if (!project && !instanceKey) {
211
+ sendJson(res, 400, { ok: false, reason: 'missing-project' });
212
+ return;
213
+ }
214
+ try {
215
+ const { killPtyFor } = await import('../pty-manager.js');
216
+ const r = killPtyFor({ project: project || undefined, instanceKey: instanceKey || undefined });
217
+ if (!r.ok) {
218
+ // Multi-instance: an ambiguous project-name-only close (2+ live same-basename
219
+ // instances) is a 409 with the live candidates, so the client re-issues with an
220
+ // instanceKey instead of killing an arbitrary process.
221
+ if (r.reason === 'ambiguous') {
222
+ sendJson(res, 409, { ok: false, reason: 'ambiguous', candidates: r.candidates || [] });
223
+ return;
224
+ }
225
+ sendJson(res, 404, { ok: false, reason: r.reason || 'not-found' });
226
+ return;
227
+ }
228
+ // Bound-project rebound (2026-10): when the closed PTY WAS the server's bound
229
+ // project, the binding (projectName / CCV_PROJECT_DIR / log dir) would otherwise
230
+ // keep pointing at a dead project — the "当前项目" label stays on the just-closed
231
+ // name while the survivor is left as a mere chip. Rebind to a survivor and
232
+ // broadcast workspace_started so every client (including the closing tab) lands
233
+ // on the survivor. Judgment is by the killed record's ORIGINAL cwd vs
234
+ // CCV_PROJECT_DIR — never by basename, or a same-name twin close would false-hit.
235
+ let rebound = null;
236
+ try {
237
+ rebound = _maybeRebindAfterClose(r, deps);
238
+ } catch (err) { reportSwallowed('resume-route.close-rebind', err); }
239
+ sendJson(res, 200, { ok: true, project, instanceKey: r.key || instanceKey || null, killedActive: r.killedActive === true, rebound });
240
+ } catch (err) {
241
+ reportSwallowed('resume-route.close', err);
242
+ sendJson(res, 500, { ok: false, reason: 'close-failed' });
243
+ }
244
+ });
245
+ }
246
+
247
+ // Bound-project rebound helper (2026-10). After killPtyFor removed the BOUND
248
+ // project's PTY, pick a surviving live claude record and re-bind the server's
249
+ // workspace identity to it. A full rebind additionally broadcasts
250
+ // workspace_started (with `rebound:true` / `reboundFrom`) so the affected SSE
251
+ // clients re-scope to the survivor; a DIR-ONLY rebind (a same-name twin owns
252
+ // the name's session space) moves CCV_PROJECT_DIR without broadcasting.
253
+ // Returns { project, cwd } when a rebind happened, else null (closed project
254
+ // was not the bound one / no survivor / a same-cwd twin still lives / Electron
255
+ // multi-tab).
256
+ // May throw only BEFORE the rebind is applied (a pre-state-change failure →
257
+ // the caller wraps it into a 200 with rebound:null, which is accurate then);
258
+ // after the state change it never throws (the restart side-effects are each
259
+ // wrapped), so a returned rebound always matches reality.
260
+ //
261
+ // ORDERING INVARIANT: the broadcast (full rebind only) is emitted BEFORE the
262
+ // best-effort feed/timer restarts, and the whole kill→rebind→broadcast block
263
+ // is synchronous (no await) — under Node's single thread no interleaved
264
+ // request can observe a half-applied rebind, and a throwing side-effect can no
265
+ // longer strand the "binding moved but nobody was told" state (the broadcast
266
+ // has already gone out by then).
267
+ function _maybeRebindAfterClose(killResult, deps) {
268
+ // Electron multi-tab: the manager process does NOT own log init (the tab
269
+ // workers do); mirroring workspaces.js:56, skip rebind entirely there.
270
+ if (process.env.CCV_ELECTRON_MULTITAB === '1') return null;
271
+ const killedCwd = typeof killResult.cwd === 'string' ? killResult.cwd : '';
272
+ const boundCwd = process.env.CCV_PROJECT_DIR || process.cwd();
273
+ // Exact-cwd equality, no realpath — both sides stay in the same path space
274
+ // (a realpath would split /tmp vs /private/tmp and miss the hit).
275
+ if (!killedCwd || killedCwd !== boundCwd) return null;
276
+
277
+ // Pick the survivor: a still-live CLAUDE record (listLivePtys also returns
278
+ // 'shell' scratch PTYs, which /api/live-processes itself filters out of its
279
+ // own processes[]), preferring the record killPtyFor just re-anchored the
280
+ // terminal attachment to (reattachedTo), then the terminal's live attachment
281
+ // (isActive — preserves the launch-time invariant bound == attached), else
282
+ // the first live claude record in Map order.
283
+ const live = listLivePtys().filter((p) => p && p.cwd && (!p.ptyKind || p.ptyKind === 'claude'));
284
+ if (!live.length) return null;
285
+ // A same-cwd TWIN of the closed bound project still lives → the bound
286
+ // identity (same project name, same log dir) is already correct for it;
287
+ // rebinding away would only wipe that twin's session bindings for nothing.
288
+ if (live.some((p) => p.cwd === killedCwd)) return null;
289
+ const survivor = live.find((p) => p.key === killResult.reattachedTo)
290
+ || live.find((p) => p.isActive)
291
+ || live[0];
292
+ if (!survivor || !survivor.cwd) return null;
293
+
294
+ const killedName = projectKeyForCwd(killedCwd);
295
+ const survivorProject = projectKeyForCwd(survivor.cwd);
296
+ // initForWorkspace's scoped resetSessions keys on the SANITIZED project name
297
+ // (v2-writer.js). So any time the CLOSED project's name still has a live
298
+ // record (a same-name twin in another dir, or the survivor itself is a
299
+ // same-name twin), running initForWorkspace would wipe that LIVE project's
300
+ // session bindings. In every such case the project NAME (and thus the log
301
+ // dir) does not change — so do a DIR-ONLY rebind instead: point
302
+ // CCV_PROJECT_DIR at the survivor (file-tree/git/skills/allowlist roots follow
303
+ // the live project) while skipping initForWorkspace entirely.
304
+ const closedNameStillLive = live.some((p) => projectKeyForCwd(p.cwd) === killedName);
305
+ const survivorIsSameName = survivorProject === killedName;
306
+ const dirOnly = closedNameStillLive || survivorIsSameName;
307
+
308
+ let result;
309
+ if (dirOnly) {
310
+ // _projectName / _logDir stay (same sanitized name — already correct);
311
+ // only the bound ROOT moves to the surviving twin's directory. No broadcast
312
+ // either (see below): the name — and with it every client's SSE scope stamp
313
+ // — is unchanged, so there is nothing to re-scope.
314
+ result = { projectName: killedName };
315
+ } else {
316
+ result = initForWorkspace(survivor.cwd);
317
+ }
318
+ process.env.CCV_PROJECT_DIR = survivor.cwd;
319
+ // The file-access allowlist caches its roots (including the bound project
320
+ // dir) and is only invalidated on register/remove or LOG_DIR change — none of
321
+ // which fire on a close-rebind. Bump it so the survivor's dir is an allowed
322
+ // root even when it never went through this server's registerWorkspace.
323
+ try { bumpWorkspacesVersion(); } catch (err) { reportSwallowed('resume-route.close-rebind.roots', err); }
324
+
325
+ // Broadcast workspace_started to EVERY SSE client (same fan-out as launch,
326
+ // workspaces.js:101-107) BEFORE the throwable side-effects below, so a
327
+ // failure there can't leave "binding moved but clients untold" — but ONLY for
328
+ // a full rebind. A dir-only rebind keeps the project NAME — and with it every
329
+ // client's SSE scope stamp — moving only the bound ROOT to the survivor's dir,
330
+ // so there is nothing for any client to re-scope: a broadcast would needlessly
331
+ // hard-reset every same-name viewer mid-stream for a root-only change.
332
+ // Deliberately NO load_start/load_chunk/load_end replay: those handlers have
333
+ // no project guard on the client (AppBase.jsx:1990-2148) and would pour the
334
+ // survivor's transcript into a tab that is viewing a THIRD project. Each
335
+ // AFFECTED client re-scopes and cold-loads its own view on receipt (the
336
+ // `rebound:true` marker + its own local view state decide; see the
337
+ // workspace_started handler in AppBase.jsx).
338
+ if (!dirOnly) {
339
+ const payload = `event: workspace_started\ndata: ${JSON.stringify({
340
+ projectName: result.projectName,
341
+ path: survivor.cwd,
342
+ claudeProjectModel: readClaudeProjectModel(survivor.cwd),
343
+ rebound: true,
344
+ // The project name this client's SSE scope was stamped with before the
345
+ // rebind. Clients whose SSE (or an attach/parallel view) is still pinned
346
+ // to it are the affected domain and must reset + reconnect; a tab viewing
347
+ // an unrelated project must keep its view and its live feed.
348
+ reboundFrom: killedName,
349
+ })}\n\n`;
350
+ const clients = (deps && deps.clients) || [];
351
+ clients.forEach((client) => {
352
+ try { sseWrite(client, payload); } catch (err) { reportSwallowed('resume-route.close-rebind.write', err); }
353
+ });
354
+ }
355
+
356
+ // Best-effort feed/timer restarts for the new bound project. Without
357
+ // startLogWatch the survivor's writer activity hits "no feed → drop" and the
358
+ // real-time pane never updates until a manual refresh; statsWorker/
359
+ // streamingStatusTimer mirror workspaces.js:71-75. All deps.* are
360
+ // optional-chained so unit tests need only stub what they assert on. Each is
361
+ // individually wrapped AND runs AFTER the broadcast: if one throws it is
362
+ // reported and swallowed, never turning the (already-applied) rebind into a
363
+ // misleading rebound:null on the 200 response.
364
+ if (deps && typeof deps.startLogWatch === 'function') {
365
+ try { deps.startLogWatch(); } catch (err) { reportSwallowed('resume-route.close-rebind.logwatch', err); }
366
+ }
367
+ if (deps && !deps.statsWorker && typeof deps.startStatsWorker === 'function') {
368
+ try { deps.startStatsWorker(); } catch (err) { reportSwallowed('resume-route.close-rebind.stats', err); }
369
+ }
370
+ if (deps && typeof deps.startStreamingStatusTimer === 'function') {
371
+ try { deps.startStreamingStatusTimer(); } catch (err) { reportSwallowed('resume-route.close-rebind.stream', err); }
372
+ }
373
+ return { project: result.projectName, cwd: survivor.cwd };
374
+ }
375
+
376
+ 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;
377
+
378
+ // POST /api/resume-session { sessionUuid, project?, instanceKey? } — TRUE /resume:
379
+ // inject `/resume <uuid>` into the target project's live claude PTY so the running
380
+ // process switches INTO that session in place. Admin + same-origin gated (it re-points
381
+ // a live conversation). See the header comment for the status/reason mapping.
382
+ function postResumeSessionHandler(req, res, parsedUrl, isLocal, deps) {
383
+ if (!isAdminReq(req, isLocal) || !isSameOriginBrowserRequest(req, parsedUrl)) {
384
+ sendJson(res, 403, { ok: false, reason: 'forbidden' });
385
+ return;
386
+ }
387
+ let body = '';
388
+ req.on('data', (chunk) => { body += chunk; if (deps && body.length > deps.MAX_POST_BODY) req.destroy(); });
389
+ req.on('end', async () => {
390
+ let sessionUuid = '';
391
+ let project = '';
392
+ let instanceKey = '';
393
+ try {
394
+ const parsed = JSON.parse(body || '{}');
395
+ sessionUuid = typeof parsed.sessionUuid === 'string' ? parsed.sessionUuid.trim() : '';
396
+ project = typeof parsed.project === 'string' ? parsed.project : '';
397
+ instanceKey = typeof parsed.instanceKey === 'string' ? parsed.instanceKey : '';
398
+ } catch {
399
+ sendJson(res, 400, { ok: false, reason: 'bad-json' });
400
+ return;
401
+ }
402
+ if (!sessionUuid) {
403
+ sendJson(res, 400, { ok: false, reason: 'missing-session' });
404
+ return;
405
+ }
406
+ // Shape-validate BEFORE it ever reaches the TUI paste buffer (defense-in-depth;
407
+ // sanitizeInbound alone would still let `;`, quotes, `$()` through).
408
+ if (!RESUME_UUID_RE.test(sessionUuid)) {
409
+ sendJson(res, 400, { ok: false, reason: 'bad-uuid' });
410
+ return;
411
+ }
412
+ try {
413
+ const pm = await import('../pty-manager.js');
414
+ const targetProject = project || (_projectName || '');
415
+ // Multi-instance: a project-name-only resume that matches 2+ live same-basename
416
+ // instances is ambiguous — refuse with the candidates so the client re-issues
417
+ // with an instanceKey (mirror of attach/close).
418
+ if (!instanceKey && targetProject) {
419
+ const live = pm.liveInstancesForProject(targetProject);
420
+ if (live.length > 1) {
421
+ sendJson(res, 409, { ok: false, reason: 'ambiguous', candidates: live });
422
+ return;
423
+ }
424
+ }
425
+ // Strict target-kind gate (no active-PTY fallback): never inject into the wrong
426
+ // project's conversation when the anchor doesn't resolve, and never into a shell.
427
+ if (pm.getPtyKindFor({ project: targetProject || undefined, instanceKey: instanceKey || undefined }) !== 'claude') {
428
+ sendJson(res, 404, { ok: false, reason: 'not-found' });
429
+ return;
430
+ }
431
+ const chatQueue = await import('../lib/chat-queue.js');
432
+ const r = await chatQueue.injectResumeCommand(sessionUuid, { project: targetProject || undefined, instanceKey: instanceKey || undefined });
433
+ if (r.ok) {
434
+ // Session identity just changed — drop any still-queued composer messages so a
435
+ // message typed for the OLD conversation never drains into the resumed one.
436
+ try { chatQueue.clear(); } catch (err) { reportSwallowed('resume-route.clear-queue', err); }
437
+ sendJson(res, 200, { ok: true });
438
+ return;
439
+ }
440
+ if (r.reason === 'busy') { sendJson(res, 409, { ok: false, reason: 'busy' }); return; }
441
+ if (r.reason === 'bad-uuid') { sendJson(res, 400, { ok: false, reason: 'bad-uuid' }); return; }
442
+ if (r.reason === 'no-deps') { sendJson(res, 409, { ok: false, reason: 'unavailable' }); return; }
443
+ sendJson(res, 500, { ok: false, reason: r.reason || 'inject-failed' });
444
+ } catch (err) {
445
+ reportSwallowed('resume-route.resume', err);
446
+ sendJson(res, 500, { ok: false, reason: 'resume-failed' });
447
+ }
448
+ });
449
+ }
450
+
451
+ export const resumeRoutes = [
452
+ { method: 'GET', match: 'exact', path: '/api/resume-sessions', handler: getSessionsHandler },
453
+ { method: 'GET', match: 'exact', path: '/api/live-processes', handler: getLiveProcessesHandler },
454
+ { method: 'POST', match: 'exact', path: '/api/live-processes/attach', handler: postLiveProcessAttachHandler },
455
+ { method: 'POST', match: 'exact', path: '/api/live-processes/close', handler: postLiveProcessCloseHandler },
456
+ { method: 'POST', match: 'exact', path: '/api/resume-session', handler: postResumeSessionHandler },
457
+ ];
@@ -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' });
@@ -21,9 +21,15 @@ import { sseWrite } from '../lib/wire-compress.js';
21
21
 
22
22
  function workspacesList(req, res, parsedUrl, isLocal, deps) {
23
23
  import('../workspace-registry.js').then(async ({ getWorkspaces }) => {
24
- const workspaces = await getWorkspaces();
24
+ // ?limit=N(2026-10-08):parsedUrl 是 WHATWG URL,只有 .searchParams 没有 .query。
25
+ // 先排序后富化,只算前 N 个的 dirSize/isDiscardable。默认 0(全部),上限 50
26
+ // 与 resume.js 的对齐。total 是富化前总数,预留给将来「按需加载」的前端。
27
+ const rawLimit = parsedUrl?.searchParams?.get('limit');
28
+ const parsedLimit = rawLimit != null ? Number(rawLimit) : 0;
29
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(Math.floor(parsedLimit), 50) : 0;
30
+ const { workspaces, total } = await getWorkspaces({ limit });
25
31
  res.writeHead(200, { 'Content-Type': 'application/json' });
26
- res.end(JSON.stringify({ workspaces, workspaceMode: deps.isWorkspaceMode && !deps.workspaceLaunched }));
32
+ res.end(JSON.stringify({ workspaces, total, workspaceMode: deps.isWorkspaceMode && !deps.workspaceLaunched }));
27
33
  }).catch(err => {
28
34
  res.writeHead(500, { 'Content-Type': 'application/json' });
29
35
  res.end(JSON.stringify({ error: err.message }));
@@ -82,8 +88,12 @@ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
82
88
  // 启动 PTY
83
89
  const proxyPort = process.env.CCV_PROXY_PORT;
84
90
  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);
91
+ // Multi-instance (2026-10-06): spawnClaude now mints a fresh instanceKey per call and
92
+ // no longer kills an existing same-cwd record, so re-launching an ALREADY-LIVE cwd
93
+ // here would silently start a duplicate claude. ensurePtyForCwd restores the intended
94
+ // "re-open = reuse" semantics: attach to the live record instead of re-spawning.
95
+ const { ensurePtyForCwd } = await import('../pty-manager.js');
96
+ 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
97
  }
88
98
 
89
99
  deps.setWorkspaceLaunched(true);
@@ -171,7 +181,10 @@ function workspacesDelete(req, res, parsedUrl) {
171
181
 
172
182
  function workspacesStop(req, res, parsedUrl, isLocal, deps) {
173
183
  Promise.all([
174
- import('../pty-manager.js').then(({ killPty }) => killPty()),
184
+ // killAllMain (not killPty): a cross-project /resume takeover can leave a background
185
+ // project's PTY alive; stop must reap every main PTY, not just the active one, or the
186
+ // old project's claude leaks past the workspace session.
187
+ import('../pty-manager.js').then(({ killAllMain }) => killAllMain()),
175
188
  import('../scratch-pty-manager.js').then(({ killAllScratch }) => killAllScratch()).catch(() => {}),
176
189
  ]).then(() => {
177
190
  // 接续原有清理流程