cc-viewer 1.6.274 → 1.6.276

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 (106) hide show
  1. package/README.md +11 -0
  2. package/cli.js +29 -0
  3. package/dist/assets/App-VfOsEOzR.css +1 -0
  4. package/dist/assets/App-g0FMGNbK.js +1 -0
  5. package/dist/assets/{MdxEditorPanel-DvqnmX-m.css → MdxEditorPanel-1vSx6ymU.css} +1 -1
  6. package/dist/assets/{MdxEditorPanel-BAnfnKiF.js → MdxEditorPanel-lkBOg8Jh.js} +1 -1
  7. package/dist/assets/Mobile-CfP2RFHS.js +1 -0
  8. package/dist/assets/{_baseUniq-CPJrFyUF.js → _baseUniq-IhLiJsz9.js} +1 -1
  9. package/dist/assets/{arc-BLBrFElt.js → arc-CI5WB-FW.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-CbnBsMiQ.js → architectureDiagram-Q4EWVU46-YOnqcO8r.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-0mYr6-Fl.js → blockDiagram-DXYQGD6D-B6oK5mZJ.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-CS7vcr0z.js → c4Diagram-AHTNJAMY-JsynU_tx.js} +1 -1
  13. package/dist/assets/{channel-CF3zZzSR.js → channel-DLsvHA9V.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-1FZYtnJ7.js → chunk-4BX2VUAB-DGhBdIp1.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-COs1qui5.js → chunk-4TB4RGXK-crHwtw-n.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-p77Qw3wN.js → chunk-55IACEB6-CHTfiCuV.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-5qaIrQKg.js → chunk-EDXVE4YY-buLXKlmZ.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-DmCR8mDZ.js → chunk-FMBD7UC4-DSfXNwCE.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-D6xDfgW3.js → chunk-OYMX7WX6-16K1lWZl.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-B6cmUU0N.js → chunk-QZHKN3VN-DgDuGTt2.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-l-OyOqnn.js → chunk-YZCP3GAM-DHqswL_6.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-BCPnChlV.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-BCPnChlV.js +1 -0
  24. package/dist/assets/clone-CJyRHxRw.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-CEzaS8XS.js → cose-bilkent-S5V4N54A-D1j0CqN7.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-B3U2njWW.js → dagre-KV5264BT-XKbpcECY.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-BRSDbyBr.js → diagram-5BDNPKRD-CcbZUyrk.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-BZfOi9B7.js → diagram-G4DWMVQ6-UxGWOz8H.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-CWpb3Cg0.js → diagram-MMDJMWI5-COG8DLtM.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-CTJyBSVj.js → diagram-TYMM5635-_dGuaThe.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-CHtTRd5S.js → erDiagram-SMLLAGMA-CNu-hiRP.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-Bda8X-WJ.js → flowDiagram-DWJPFMVM-DQHinAg1.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-CPfnGu5V.js → ganttDiagram-T4ZO3ILL-BAySDtwF.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-B5QxesQg.js → gitGraphDiagram-UUTBAWPF-BBO_XdYa.js} +1 -1
  35. package/dist/assets/{graph-ChltdhTU.js → graph-D9285_n5.js} +1 -1
  36. package/dist/assets/index-0hGZ2hk-.js +2 -0
  37. package/dist/assets/{index-BK4sui_O.js → index-6IDcGlHG.js} +1 -1
  38. package/dist/assets/{index-C2fhupP6.js → index-CXhN926Q.js} +1 -1
  39. package/dist/assets/{index-CfEkC3bc.js → index-CaKHIO3v.js} +1 -1
  40. package/dist/assets/{index-yClPXlMf.js → index-D18bphjK.js} +1 -1
  41. package/dist/assets/{index-B7lK5fJz.js → index-DTi4w1dY.js} +1 -1
  42. package/dist/assets/{index-C5PA4OJg.js → index-gSNq4ykV.js} +1 -1
  43. package/dist/assets/{index-DyGa-jNv.js → index-pEgUz6fU.js} +1 -1
  44. package/dist/assets/{infoDiagram-42DDH7IO-CD4TS4O2.js → infoDiagram-42DDH7IO-DODFGD-6.js} +1 -1
  45. package/dist/assets/{ishikawaDiagram-UXIWVN3A-jhiYabQ7.js → ishikawaDiagram-UXIWVN3A-kn6kHfYH.js} +1 -1
  46. package/dist/assets/{journeyDiagram-VCZTEJTY-BDzIXxxt.js → journeyDiagram-VCZTEJTY-BRaTTmzg.js} +1 -1
  47. package/dist/assets/{jszip.min-CuGGBMI4.js → jszip.min-CnLSX52a.js} +1 -1
  48. package/dist/assets/{kanban-definition-6JOO6SKY-D34MYATQ.js → kanban-definition-6JOO6SKY-CzLQb2u1.js} +1 -1
  49. package/dist/assets/{layout-D6sLAapX.js → layout-B9TjxURJ.js} +1 -1
  50. package/dist/assets/{linear-CFV4P-wn.js → linear-Dpck4eFk.js} +1 -1
  51. package/dist/assets/{mermaid.core-YmJi7T-s.js → mermaid.core-Dd_GyvDR.js} +2 -2
  52. package/dist/assets/{min-akJQqRMn.js → min-CUkc58GS.js} +1 -1
  53. package/dist/assets/{mindmap-definition-QFDTVHPH-w5zaJyrN.js → mindmap-definition-QFDTVHPH-BteQHO2X.js} +1 -1
  54. package/dist/assets/{pieDiagram-DEJITSTG-B6EM4Ow6.js → pieDiagram-DEJITSTG-CaOYrVcc.js} +1 -1
  55. package/dist/assets/{quadrantDiagram-34T5L4WZ-2TmBxFy-.js → quadrantDiagram-34T5L4WZ-DTcmYcmQ.js} +1 -1
  56. package/dist/assets/{requirementDiagram-MS252O5E-EOjvbxUy.js → requirementDiagram-MS252O5E-CY8WoU0d.js} +1 -1
  57. package/dist/assets/{sankeyDiagram-XADWPNL6-BmTQD5eT.js → sankeyDiagram-XADWPNL6-9NPSQPLx.js} +1 -1
  58. package/dist/assets/seqResourceLoaders-BgEHTs14.js +2 -0
  59. package/dist/assets/seqResourceLoaders-DPmXT2Hh.css +41 -0
  60. package/dist/assets/{sequenceDiagram-FGHM5R23-CLjtqA1D.js → sequenceDiagram-FGHM5R23-e7V5HhHU.js} +1 -1
  61. package/dist/assets/{stateDiagram-FHFEXIEX-Cbq90iZ8.js → stateDiagram-FHFEXIEX-B6R9nxzj.js} +1 -1
  62. package/dist/assets/stateDiagram-v2-QKLJ7IA2-sUx4g5Jk.js +1 -0
  63. package/dist/assets/{timeline-definition-GMOUNBTQ-f3kEDay0.js → timeline-definition-GMOUNBTQ-CqpCMRBB.js} +1 -1
  64. package/dist/assets/{vendor-antd-5xE7sz6B.js → vendor-antd-BqzW9CXo.js} +2 -2
  65. package/dist/assets/{vendor-codemirror-ib-jPbXC.js → vendor-codemirror-B5WQ33Y0.js} +1 -1
  66. package/dist/assets/vendor-markdown-DOJHsAxX.js +3 -0
  67. package/dist/assets/{vendor-mdxeditor-BdKMdw6O.js → vendor-mdxeditor-DwzTmNBd.js} +2 -2
  68. package/dist/assets/{vendor-qrcode-vKlE-WYu.js → vendor-qrcode-C9ZMcovz.js} +1 -1
  69. package/dist/assets/{vendor-virtuoso-DOIfjLfU.js → vendor-virtuoso-CQj5-1oy.js} +1 -1
  70. package/dist/assets/{vennDiagram-DHZGUBPP-0GDSFVnH.js → vennDiagram-DHZGUBPP-CxzTP1Mu.js} +1 -1
  71. package/dist/assets/{wardley-RL74JXVD-B2rj5j7G.js → wardley-RL74JXVD-_Dcq-0iY.js} +1 -1
  72. package/dist/assets/{wardleyDiagram-NUSXRM2D-COVTciJP.js → wardleyDiagram-NUSXRM2D-CltoIcVL.js} +1 -1
  73. package/dist/assets/{xychartDiagram-5P7HB3ND-DXoLnGxX.js → xychartDiagram-5P7HB3ND-DXKa-Cxe.js} +1 -1
  74. package/dist/index.html +4 -4
  75. package/package.json +1 -1
  76. package/server/i18n.js +186 -2
  77. package/server/lib/auth.js +348 -0
  78. package/server/lib/voice-pack-events.js +2 -2
  79. package/server/routes/_dispatch.js +43 -0
  80. package/server/routes/ask-perm.js +452 -0
  81. package/server/routes/auth.js +166 -0
  82. package/server/routes/events.js +335 -0
  83. package/server/routes/files-content.js +421 -0
  84. package/server/routes/files-fs.js +955 -0
  85. package/server/routes/git.js +228 -0
  86. package/server/routes/logs.js +201 -0
  87. package/server/routes/misc.js +22 -0
  88. package/server/routes/plugins.js +96 -0
  89. package/server/routes/preferences.js +216 -0
  90. package/server/routes/project-meta.js +118 -0
  91. package/server/routes/skills.js +261 -0
  92. package/server/routes/team.js +169 -0
  93. package/server/routes/voice-pack.js +235 -0
  94. package/server/routes/workspaces.js +171 -0
  95. package/server/server.js +340 -3953
  96. package/dist/assets/App-BediOgt6.js +0 -1
  97. package/dist/assets/App-TGGslOeT.css +0 -1
  98. package/dist/assets/Mobile-C_nqfEXb.js +0 -1
  99. package/dist/assets/classDiagram-6PBFFD2Q-BiCYgTHO.js +0 -1
  100. package/dist/assets/classDiagram-v2-HSJHXN6E-BiCYgTHO.js +0 -1
  101. package/dist/assets/clone-ChTCnPsO.js +0 -1
  102. package/dist/assets/index-CuE3VwXB.js +0 -2
  103. package/dist/assets/seqResourceLoaders-CgNKpehN.js +0 -2
  104. package/dist/assets/seqResourceLoaders-Dd_x1M9-.css +0 -41
  105. package/dist/assets/stateDiagram-v2-QKLJ7IA2-BKDg-3t_.js +0 -1
  106. package/dist/assets/vendor-markdown-BFrYfpb0.js +0 -1
@@ -0,0 +1,169 @@
1
+ // Concept docs + CCV process management + team-status routes (moved verbatim from server.js).
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { platform } from 'node:os';
5
+ import { CONCEPTS_DIR } from '../_paths.js';
6
+ import { buildTeamStatusResponse } from '../lib/team-runtime.js';
7
+
8
+ // GET /api/concept?lang=zh&doc=Tool-Bash
9
+ function concept(req, res, parsedUrl, isLocal, deps) {
10
+ const lang = parsedUrl.searchParams.get('lang') || 'zh';
11
+ const doc = parsedUrl.searchParams.get('doc') || '';
12
+ // 安全校验:只允许字母、数字、连字符
13
+ if (!/^[a-zA-Z0-9-]+$/.test(doc) || !/^[a-z]{2}(-[a-zA-Z]{2,})?$/.test(lang)) {
14
+ res.writeHead(400, { 'Content-Type': 'application/json' });
15
+ res.end(JSON.stringify({ error: 'Invalid parameters' }));
16
+ return;
17
+ }
18
+ let mdPath = join(CONCEPTS_DIR, lang, `${doc}.md`);
19
+ if (!existsSync(mdPath) && lang !== 'zh') {
20
+ mdPath = join(CONCEPTS_DIR, 'zh', `${doc}.md`);
21
+ }
22
+ if (existsSync(mdPath)) {
23
+ const content = readFileSync(mdPath, 'utf-8');
24
+ res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
25
+ res.end(content);
26
+ } else {
27
+ res.writeHead(404, { 'Content-Type': 'application/json' });
28
+ res.end(JSON.stringify({ error: 'Not found' }));
29
+ }
30
+ }
31
+
32
+ // CCV 进程列表
33
+ async function ccvProcesses(req, res, parsedUrl, isLocal, deps) {
34
+ if (platform() === 'win32') {
35
+ res.writeHead(200, { 'Content-Type': 'application/json' });
36
+ res.end(JSON.stringify({ processes: [] }));
37
+ return;
38
+ }
39
+ try {
40
+ const { stdout } = await deps.execAsync('lsof -iTCP:7008-7099 -sTCP:LISTEN -P -n', { timeout: 5000 }).catch(() => ({ stdout: '' }));
41
+ const lines = stdout.trim().split('\n').filter(Boolean);
42
+ // Parse lsof output: skip header, filter node processes, dedupe by PID:port
43
+ const seen = new Map(); // pid -> port
44
+ for (const line of lines.slice(1)) {
45
+ const parts = line.trim().split(/\s+/);
46
+ const cmd = parts[0];
47
+ if (cmd !== 'node') continue;
48
+ const pid = parseInt(parts[1], 10);
49
+ if (!pid) continue;
50
+ // lsof 输出: COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME (STATE)
51
+ // 端口在 NAME 列(倒数第二列),如 *:7008,最后一列是 (LISTEN)
52
+ const nameField = parts[parts.length - 2] || '';
53
+ const portMatch = nameField.match(/:(\d+)$/);
54
+ if (!portMatch) continue;
55
+ const port = portMatch[1];
56
+ if (!seen.has(pid)) seen.set(pid, port);
57
+ }
58
+ // 获取所有候选进程的 PPID,过滤掉 PPID 也在 CCV 进程集合中的子进程(即 ccv -c/-d 启动的 claude 子进程)
59
+ const ccvPids = new Set(seen.keys());
60
+ const filteredPids = [];
61
+ for (const [pid] of seen) {
62
+ try {
63
+ const { stdout: ppidOut } = await deps.execAsync(`ps -o ppid= -p ${pid}`, { timeout: 2000 }).catch(() => ({ stdout: '' }));
64
+ const ppid = parseInt(ppidOut.trim(), 10);
65
+ if (ppid && ccvPids.has(ppid)) continue; // 是某个 CCV 进程的子进程,跳过
66
+ } catch {}
67
+ filteredPids.push(pid);
68
+ }
69
+ const processes = [];
70
+ for (const pid of filteredPids) {
71
+ const port = seen.get(pid);
72
+ let startTime = '';
73
+ let command = '';
74
+ try {
75
+ const { stdout: psOut } = await deps.execAsync(`ps -p ${pid} -o lstart=,command=`, { timeout: 3000 }).catch(() => ({ stdout: '' }));
76
+ const psLine = psOut.trim();
77
+ // lstart format: "Day Mon DD HH:MM:SS YYYY rest..."
78
+ const lsMatch = psLine.match(/^\w+\s+(\w+)\s+(\d+)\s+([\d:]+)\s+(\d{4})\s+(.*)/);
79
+ if (lsMatch) {
80
+ const months = { Jan:1,Feb:2,Mar:3,Apr:4,May:5,Jun:6,Jul:7,Aug:8,Sep:9,Oct:10,Nov:11,Dec:12 };
81
+ const mon = String(months[lsMatch[1]] || 1).padStart(2, '0');
82
+ const day = String(lsMatch[2]).padStart(2, '0');
83
+ const time = lsMatch[3];
84
+ const year = lsMatch[4];
85
+ startTime = `${year}年${mon}月${day}日 ${time}`;
86
+ const rawCmd = lsMatch[5];
87
+ // Extract path after lib/ (e.g. node_modules/cc-viewer/cli.js -d → cc-viewer/cli.js -d)
88
+ const libMatch = rawCmd.match(/lib\/(.+)/);
89
+ command = libMatch ? libMatch[1] : rawCmd;
90
+ }
91
+ } catch {}
92
+ const isCurrent = pid === process.pid;
93
+ processes.push({ port, pid, command, startTime, isCurrent });
94
+ }
95
+ res.writeHead(200, { 'Content-Type': 'application/json' });
96
+ res.end(JSON.stringify({ processes }));
97
+ } catch (err) {
98
+ res.writeHead(500, { 'Content-Type': 'application/json' });
99
+ res.end(JSON.stringify({ error: err.message }));
100
+ }
101
+ }
102
+
103
+ // CCV 进程关闭
104
+ function ccvProcessesKill(req, res, parsedUrl, isLocal, deps) {
105
+ let body = '';
106
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
107
+ req.on('end', async () => {
108
+ try {
109
+ const { pid } = JSON.parse(body);
110
+ if (!Number.isInteger(pid) || pid <= 0) {
111
+ res.writeHead(400, { 'Content-Type': 'application/json' });
112
+ res.end(JSON.stringify({ error: 'Invalid PID' }));
113
+ return;
114
+ }
115
+ if (pid === process.pid) {
116
+ res.writeHead(403, { 'Content-Type': 'application/json' });
117
+ res.end(JSON.stringify({ error: 'Cannot kill current process' }));
118
+ return;
119
+ }
120
+ // 安全检查:确认是监听 CCV 端口范围 (7008-7099) 的 node 进程
121
+ const { stdout: lsofOut } = await deps.execAsync(`lsof -iTCP:7008-7099 -sTCP:LISTEN -P -n -p ${pid}`, { timeout: 5000 }).catch(() => ({ stdout: '' }));
122
+ const lsofLines = lsofOut.trim().split('\n').filter(Boolean).slice(1);
123
+ const isNodeOnCcvPort = lsofLines.some(line => line.trim().split(/\s+/)[0] === 'node');
124
+ if (!isNodeOnCcvPort) {
125
+ res.writeHead(403, { 'Content-Type': 'application/json' });
126
+ res.end(JSON.stringify({ error: 'Not a CCV process' }));
127
+ return;
128
+ }
129
+ process.kill(pid, 'SIGTERM');
130
+ res.writeHead(200, { 'Content-Type': 'application/json' });
131
+ res.end(JSON.stringify({ ok: true }));
132
+ } catch (err) {
133
+ res.writeHead(500, { 'Content-Type': 'application/json' });
134
+ res.end(JSON.stringify({ error: err.message }));
135
+ }
136
+ });
137
+ }
138
+
139
+ // Team 运行时状态检测(fs-only:目录存在性 + inbox mtime)
140
+ function teamStatus(req, res, parsedUrl, isLocal, deps) {
141
+ let body = '';
142
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
143
+ req.on('end', async () => {
144
+ let parsed;
145
+ try {
146
+ parsed = JSON.parse(body || '{}');
147
+ } catch {
148
+ // 固定文案避免把 JSON.parse 的原始 err.message 回显给客户端
149
+ res.writeHead(400, { 'Content-Type': 'application/json' });
150
+ res.end(JSON.stringify({ error: 'invalid_json' }));
151
+ return;
152
+ }
153
+ try {
154
+ const result = await buildTeamStatusResponse(parsed);
155
+ res.writeHead(200, { 'Content-Type': 'application/json' });
156
+ res.end(JSON.stringify(result));
157
+ } catch (err) {
158
+ res.writeHead(500, { 'Content-Type': 'application/json' });
159
+ res.end(JSON.stringify({ error: err.message }));
160
+ }
161
+ });
162
+ }
163
+
164
+ export const teamRoutes = [
165
+ { method: 'GET', match: 'exact', path: '/api/concept', handler: concept },
166
+ { method: 'GET', match: 'exact', path: '/api/ccv-processes', handler: ccvProcesses },
167
+ { method: 'POST', match: 'exact', path: '/api/ccv-processes/kill', handler: ccvProcessesKill },
168
+ { method: 'POST', match: 'exact', path: '/api/team-status', handler: teamStatus },
169
+ ];
@@ -0,0 +1,235 @@
1
+ // Voice-pack routes (moved verbatim from server.js handleRequest).
2
+ // Manages user-uploaded audio + serves the bundled "皇上系列" default pack.
3
+ // Uploads are loopback-only — LAN clients can play but not write.
4
+ import { lstatSync, statSync, createReadStream } from 'node:fs';
5
+ import { LOG_DIR } from '../../findcc.js';
6
+ import {
7
+ saveAudio as vpSaveAudio,
8
+ listUserAudio as vpListUserAudio,
9
+ deleteUserAudio as vpDeleteUserAudio,
10
+ getUserAudioPath as vpGetUserAudioPath,
11
+ getBundledPackPath as vpGetBundledPackPath,
12
+ listDefaultPack as vpListDefaultPack,
13
+ listBundledPacks as vpListBundledPacks,
14
+ isDefaultPackPlaceholder as vpIsDefaultPackPlaceholder,
15
+ mimeForFormat as vpMime,
16
+ isValidId as vpIsValidId,
17
+ BUNDLED_PACK_IDS as VP_BUNDLED_PACK_IDS,
18
+ EVENT_KEYS as VP_EVENT_KEYS,
19
+ MAX_AUDIO_BYTES as VP_MAX_BYTES,
20
+ } from '../lib/voice-pack-manager.js';
21
+
22
+ function voicePackList(req, res) {
23
+ try {
24
+ const userAudio = vpListUserAudio(LOG_DIR);
25
+ const bundledPacks = vpListBundledPacks();
26
+ // SUNSET-MARKER: ccv-voice-pack-defaultPack-flat-shape
27
+ // Legacy defaultPack / defaultPackPlaceholder fields kept alongside the
28
+ // new bundledPacks[] for one release so any out-of-tree consumer (mobile
29
+ // app shell, third-party fork) doesn't break on the shape change.
30
+ // Drop after 1.6.273+. New code should iterate bundledPacks.
31
+ const defaultPack = vpListDefaultPack();
32
+ res.writeHead(200, { 'Content-Type': 'application/json' });
33
+ res.end(JSON.stringify({
34
+ userAudio,
35
+ bundledPacks,
36
+ defaultPack,
37
+ defaultPackPlaceholder: vpIsDefaultPackPlaceholder(),
38
+ eventKeys: VP_EVENT_KEYS,
39
+ maxBytes: VP_MAX_BYTES,
40
+ }));
41
+ } catch (err) {
42
+ res.writeHead(500, { 'Content-Type': 'application/json' });
43
+ res.end(JSON.stringify({ error: 'list failed', detail: err?.message }));
44
+ }
45
+ }
46
+
47
+ function voicePackUpload(req, res, parsedUrl, isLocal) {
48
+ // Loopback-only — refuse LAN clients even if they hold a valid token.
49
+ // The token already gates LAN access but voice-pack writes touch the local FS
50
+ // and end up reachable from every client; keep the write side strictly local.
51
+ if (!isLocal) {
52
+ res.writeHead(403, { 'Content-Type': 'application/json' });
53
+ res.end(JSON.stringify({ error: 'Upload allowed from loopback only' }));
54
+ return;
55
+ }
56
+ const contentType = req.headers['content-type'] || '';
57
+ const boundaryMatch = contentType.match(/boundary=(.+)/);
58
+ if (!boundaryMatch) {
59
+ res.writeHead(400, { 'Content-Type': 'application/json' });
60
+ res.end(JSON.stringify({ error: 'Missing boundary' }));
61
+ return;
62
+ }
63
+ const contentLength = parseInt(req.headers['content-length'] || '0', 10);
64
+ if (contentLength > VP_MAX_BYTES + 4096) {
65
+ res.writeHead(413, { 'Content-Type': 'application/json' });
66
+ res.end(JSON.stringify({ error: `File too large (max ${VP_MAX_BYTES} bytes)` }));
67
+ return;
68
+ }
69
+ const boundary = boundaryMatch[1];
70
+ const chunks = [];
71
+ let totalSize = 0;
72
+ let aborted = false;
73
+ req.on('data', chunk => {
74
+ totalSize += chunk.length;
75
+ if (totalSize > VP_MAX_BYTES + 4096) {
76
+ aborted = true;
77
+ res.writeHead(413, { 'Content-Type': 'application/json' });
78
+ res.end(JSON.stringify({ error: `File too large (max ${VP_MAX_BYTES} bytes)` }));
79
+ req.destroy();
80
+ return;
81
+ }
82
+ chunks.push(chunk);
83
+ });
84
+ req.on('end', () => {
85
+ if (aborted) return;
86
+ try {
87
+ const buf = Buffer.concat(chunks);
88
+ const headerEnd = buf.indexOf('\r\n\r\n');
89
+ if (headerEnd === -1) throw new Error('Malformed multipart');
90
+ const headerStr = buf.slice(0, headerEnd).toString();
91
+ const nameMatch = headerStr.match(/filename="([^"]+)"/);
92
+ const originalName = nameMatch ? nameMatch[1].replace(/[\x00-\x1f/\\]/g, '_') : 'upload';
93
+ const bodyStart = headerEnd + 4;
94
+ const closingBoundary = Buffer.from('\r\n--' + boundary);
95
+ const bodyEnd = buf.indexOf(closingBoundary, bodyStart);
96
+ const fileData = bodyEnd !== -1 ? buf.slice(bodyStart, bodyEnd) : buf.slice(bodyStart);
97
+ const result = vpSaveAudio(LOG_DIR, originalName, fileData, { isLoopback: true });
98
+ res.writeHead(200, { 'Content-Type': 'application/json' });
99
+ res.end(JSON.stringify({ ok: true, ...result }));
100
+ } catch (err) {
101
+ const status = err?.code === 'TOO_LARGE' ? 413 : err?.code === 'BAD_FORMAT' ? 415 : 400;
102
+ res.writeHead(status, { 'Content-Type': 'application/json' });
103
+ res.end(JSON.stringify({ error: err?.message || 'Upload failed' }));
104
+ }
105
+ });
106
+ }
107
+
108
+ function voicePackDelete(req, res, parsedUrl, isLocal) {
109
+ if (!isLocal) {
110
+ res.writeHead(403, { 'Content-Type': 'application/json' });
111
+ res.end(JSON.stringify({ error: 'Delete allowed from loopback only' }));
112
+ return;
113
+ }
114
+ const url = parsedUrl.pathname;
115
+ const id = url.slice('/api/voice-pack/delete/'.length);
116
+ if (!vpIsValidId(id)) {
117
+ res.writeHead(400, { 'Content-Type': 'application/json' });
118
+ res.end(JSON.stringify({ error: 'Invalid id' }));
119
+ return;
120
+ }
121
+ const ok = vpDeleteUserAudio(LOG_DIR, id);
122
+ res.writeHead(ok ? 200 : 404, { 'Content-Type': 'application/json' });
123
+ res.end(JSON.stringify({ ok }));
124
+ }
125
+
126
+ // Serve audio — supports HTTP Range so iOS Safari / mobile players can seek mp3
127
+ // (Safari refuses to start playback when the server returns 200 without Accept-Ranges).
128
+ // Path forms:
129
+ // /api/voice-pack/audio/<packId>/<eventKey> — bundled pack (default, sanguo, …)
130
+ // /api/voice-pack/audio/<uuid> — user-uploaded file
131
+ function voicePackAudio(req, res, parsedUrl) {
132
+ const url = parsedUrl.pathname;
133
+ const tail = url.slice('/api/voice-pack/audio/'.length);
134
+ let resolved = null;
135
+ let isBundled = false;
136
+ // Iterate the explicit BUNDLED_PACK_IDS list so an unknown prefix can never
137
+ // accidentally hit the bundled branch — falls through to the uuid lookup,
138
+ // which is whitelisted by isValidId.
139
+ for (const packId of VP_BUNDLED_PACK_IDS) {
140
+ const prefix = `${packId}/`;
141
+ if (tail.startsWith(prefix)) {
142
+ const eventKey = tail.slice(prefix.length);
143
+ resolved = vpGetBundledPackPath(packId, eventKey);
144
+ isBundled = true;
145
+ break;
146
+ }
147
+ }
148
+ if (!isBundled) {
149
+ resolved = vpGetUserAudioPath(LOG_DIR, tail);
150
+ }
151
+ if (!resolved) {
152
+ res.writeHead(404, { 'Content-Type': 'application/json' });
153
+ res.end(JSON.stringify({ error: 'Not found' }));
154
+ return;
155
+ }
156
+ // Cache strategy:
157
+ // - default-pack: short max-age + must-revalidate, no `immutable` — the on-disk
158
+ // file *can* change when a placeholder is replaced by a real recording at the
159
+ // same path. `must-revalidate` keeps the file out of the stale bucket once
160
+ // max-age expires. Paired with the ETag below, this lets browsers
161
+ // conditional-request and pick up regenerated audio after a `gen-default-voicepack`
162
+ // run — without an ETag they'd silently serve cached stale content.
163
+ // - user audio: content-addressed by UUID (delete + re-upload always mints a
164
+ // new id), so safe to mark immutable for a full day. Loopback-only writes,
165
+ // so the LAN audience cannot mutate.
166
+ const cacheControl = isBundled
167
+ ? 'public, max-age=300, must-revalidate'
168
+ : 'private, max-age=86400, immutable';
169
+ try {
170
+ // Symlink hardening: refuse to serve symlinks even though the routing layer
171
+ // already enforces the id whitelist. A local attacker who can write to
172
+ // LOG_DIR/voice-packs/ could otherwise drop `<uuid>.mp3 → /etc/passwd` and
173
+ // have it streamed over LAN. Same family as the file-access-policy realpath
174
+ // check used elsewhere in server.js for /api/read-file.
175
+ const ls = lstatSync(resolved.path);
176
+ if (ls.isSymbolicLink()) {
177
+ res.writeHead(404, { 'Content-Type': 'application/json' });
178
+ res.end(JSON.stringify({ error: 'Not found' }));
179
+ return;
180
+ }
181
+ const stat = statSync(resolved.path);
182
+ const fileSize = stat.size;
183
+ // ETag = "<size>-<mtime ms>" — cheap, stable across restarts, changes whenever
184
+ // the file is rewritten. Honors If-None-Match → 304 so a regenerated default
185
+ // pack actually reaches the browser instead of being silently served stale.
186
+ const etag = `"${fileSize.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
187
+ if (req.headers['if-none-match'] === etag) {
188
+ res.writeHead(304, { ETag: etag, 'Cache-Control': cacheControl });
189
+ res.end();
190
+ return;
191
+ }
192
+ const mime = vpMime(resolved.format);
193
+ const range = req.headers.range;
194
+ if (range) {
195
+ const m = range.match(/bytes=(\d+)-(\d*)/);
196
+ if (m) {
197
+ const start = parseInt(m[1], 10);
198
+ const end = m[2] ? parseInt(m[2], 10) : fileSize - 1;
199
+ if (Number.isFinite(start) && Number.isFinite(end) && start >= 0 && end < fileSize && start <= end) {
200
+ res.writeHead(206, {
201
+ 'Content-Range': `bytes ${start}-${end}/${fileSize}`,
202
+ 'Accept-Ranges': 'bytes',
203
+ 'Content-Length': end - start + 1,
204
+ 'Content-Type': mime,
205
+ 'Cache-Control': cacheControl,
206
+ ETag: etag,
207
+ });
208
+ createReadStream(resolved.path, { start, end }).pipe(res);
209
+ return;
210
+ }
211
+ }
212
+ res.writeHead(416, { 'Content-Range': `bytes */${fileSize}` });
213
+ res.end();
214
+ return;
215
+ }
216
+ res.writeHead(200, {
217
+ 'Content-Length': fileSize,
218
+ 'Content-Type': mime,
219
+ 'Accept-Ranges': 'bytes',
220
+ 'Cache-Control': cacheControl,
221
+ ETag: etag,
222
+ });
223
+ createReadStream(resolved.path).pipe(res);
224
+ } catch (err) {
225
+ res.writeHead(500, { 'Content-Type': 'application/json' });
226
+ res.end(JSON.stringify({ error: 'Read failed', detail: err?.message }));
227
+ }
228
+ }
229
+
230
+ export const voicePackRoutes = [
231
+ { method: 'GET', match: 'exact', path: '/api/voice-pack/list', handler: voicePackList },
232
+ { method: 'POST', match: 'exact', path: '/api/voice-pack/upload', handler: voicePackUpload },
233
+ { method: 'DELETE', match: 'prefix', path: '/api/voice-pack/delete/', handler: voicePackDelete },
234
+ { method: 'GET', match: 'prefix', path: '/api/voice-pack/audio/', handler: voicePackAudio },
235
+ ];
@@ -0,0 +1,171 @@
1
+ // Workspace routes (moved verbatim from server.js handleRequest).
2
+ import { existsSync, statSync, unwatchFile } from 'node:fs';
3
+ import { basename } from 'node:path';
4
+ import { LOG_FILE, initForWorkspace, resetWorkspace } from '../interceptor.js';
5
+ import { watchLogFile, getWatchedFiles } from '../lib/log-watcher.js';
6
+ import { readClaudeProjectModel } from '../lib/context-watcher.js';
7
+ import { countLogEntries, streamRawEntriesAsync } from '../lib/log-stream.js';
8
+
9
+ function workspacesList(req, res, parsedUrl, isLocal, deps) {
10
+ import('../workspace-registry.js').then(({ getWorkspaces }) => {
11
+ const workspaces = getWorkspaces();
12
+ res.writeHead(200, { 'Content-Type': 'application/json' });
13
+ res.end(JSON.stringify({ workspaces, workspaceMode: deps.isWorkspaceMode && !deps.workspaceLaunched }));
14
+ }).catch(err => {
15
+ res.writeHead(500, { 'Content-Type': 'application/json' });
16
+ res.end(JSON.stringify({ error: err.message }));
17
+ });
18
+ }
19
+
20
+ function workspacesLaunch(req, res, parsedUrl, isLocal, deps) {
21
+ let body = '';
22
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
23
+ req.on('end', async () => {
24
+ try {
25
+ const { path: wsPath, extraArgs: launchExtraArgs } = JSON.parse(body);
26
+ if (!wsPath || !existsSync(wsPath) || !statSync(wsPath).isDirectory()) {
27
+ res.writeHead(400, { 'Content-Type': 'application/json' });
28
+ res.end(JSON.stringify({ error: 'Invalid directory path' }));
29
+ return;
30
+ }
31
+
32
+ const { registerWorkspace } = await import('../workspace-registry.js');
33
+ registerWorkspace(wsPath);
34
+
35
+ // Electron multi-tab 模式:管理 server 只触发 callback,不做日志初始化
36
+ // 所有日志相关操作(initForWorkspace、watchLogFile、spawnClaude)由 tab-worker 子进程负责
37
+ if (process.env.CCV_ELECTRON_MULTITAB === '1') {
38
+ if (deps.launchCallback) {
39
+ deps.launchCallback(wsPath, Array.isArray(launchExtraArgs) ? launchExtraArgs : []);
40
+ }
41
+ deps.setWorkspaceLaunched(true);
42
+ res.writeHead(200, { 'Content-Type': 'application/json' });
43
+ res.end(JSON.stringify({ ok: true, projectName: basename(wsPath) }));
44
+ return;
45
+ }
46
+
47
+ // 非 Electron 模式(web / CLI):完整逻辑
48
+ const result = initForWorkspace(wsPath);
49
+ process.env.CCV_PROJECT_DIR = wsPath;
50
+
51
+ // 启动日志监听
52
+ watchLogFile(deps.logWatcherOpts(LOG_FILE));
53
+
54
+ // 启动 stats worker(如果尚未启动)
55
+ if (!deps.statsWorker) deps.startStatsWorker();
56
+ deps.startStreamingStatusTimer();
57
+
58
+ // 启动 PTY
59
+ const proxyPort = process.env.CCV_PROXY_PORT;
60
+ if (proxyPort) {
61
+ const { spawnClaude } = await import('../pty-manager.js');
62
+ const mergedArgs = [...deps.workspaceClaudeArgs, ...(Array.isArray(launchExtraArgs) ? launchExtraArgs : [])];
63
+ await spawnClaude(parseInt(proxyPort), wsPath, mergedArgs, deps.workspaceClaudePath, deps.workspaceIsNpmVersion, deps.actualPort, deps.protocol, deps.INTERNAL_TOKEN);
64
+ }
65
+
66
+ deps.setWorkspaceLaunched(true);
67
+
68
+ // 通知所有 SSE 客户端
69
+ deps.clients.forEach(client => {
70
+ try {
71
+ client.write(`event: workspace_started\ndata: ${JSON.stringify({ projectName: result.projectName, path: wsPath, claudeProjectModel: readClaudeProjectModel(wsPath) })}\n\n`);
72
+ } catch {}
73
+ });
74
+
75
+ // 流式分段广播以刷新会话区域,避免全量加载 OOM
76
+ const wsReloadTotal = countLogEntries(LOG_FILE);
77
+ deps.clients.forEach(client => {
78
+ try { client.write(`event: load_start\ndata: ${JSON.stringify({ total: wsReloadTotal, incremental: false })}\n\n`); } catch {}
79
+ });
80
+ await streamRawEntriesAsync(LOG_FILE, (raw) => {
81
+ deps.clients.forEach(client => {
82
+ try { client.write('event: load_chunk\ndata: ['); client.write(raw.replace(/\n/g, '')); client.write(']\n\n'); } catch {}
83
+ });
84
+ });
85
+ deps.clients.forEach(client => {
86
+ try { client.write(`event: load_end\ndata: {}\n\n`); } catch {}
87
+ });
88
+
89
+ res.writeHead(200, { 'Content-Type': 'application/json' });
90
+ res.end(JSON.stringify({ ok: true, projectName: result.projectName }));
91
+ } catch (err) {
92
+ res.writeHead(500, { 'Content-Type': 'application/json' });
93
+ res.end(JSON.stringify({ error: err.message }));
94
+ }
95
+ });
96
+ }
97
+
98
+ function workspacesAdd(req, res, parsedUrl, isLocal, deps) {
99
+ let body = '';
100
+ req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
101
+ req.on('end', async () => {
102
+ try {
103
+ const { path: wsPath } = JSON.parse(body);
104
+ if (!wsPath || !existsSync(wsPath) || !statSync(wsPath).isDirectory()) {
105
+ res.writeHead(400, { 'Content-Type': 'application/json' });
106
+ res.end(JSON.stringify({ error: 'Invalid directory path' }));
107
+ return;
108
+ }
109
+ const { registerWorkspace } = await import('../workspace-registry.js');
110
+ const entry = registerWorkspace(wsPath);
111
+ res.writeHead(200, { 'Content-Type': 'application/json' });
112
+ res.end(JSON.stringify({ ok: true, workspace: entry }));
113
+ } catch (err) {
114
+ res.writeHead(500, { 'Content-Type': 'application/json' });
115
+ res.end(JSON.stringify({ error: err.message }));
116
+ }
117
+ });
118
+ }
119
+
120
+ function workspacesDelete(req, res, parsedUrl) {
121
+ const url = parsedUrl.pathname;
122
+ const id = url.split('/').pop();
123
+ import('../workspace-registry.js').then(({ removeWorkspace }) => {
124
+ const removed = removeWorkspace(id);
125
+ res.writeHead(200, { 'Content-Type': 'application/json' });
126
+ res.end(JSON.stringify({ ok: removed }));
127
+ }).catch(err => {
128
+ res.writeHead(500, { 'Content-Type': 'application/json' });
129
+ res.end(JSON.stringify({ error: err.message }));
130
+ });
131
+ }
132
+
133
+ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
134
+ Promise.all([
135
+ import('../pty-manager.js').then(({ killPty }) => killPty()),
136
+ import('../scratch-pty-manager.js').then(({ killAllScratch }) => killAllScratch()).catch(() => {}),
137
+ ]).then(() => {
138
+ // 接续原有清理流程
139
+
140
+ // 停止日志监听
141
+ for (const logFile of getWatchedFiles().keys()) {
142
+ unwatchFile(logFile);
143
+ }
144
+ getWatchedFiles().clear();
145
+
146
+ // 重置 interceptor 状态
147
+ resetWorkspace();
148
+ deps.setWorkspaceLaunched(false);
149
+
150
+ // 通知所有 SSE 客户端
151
+ deps.clients.forEach(client => {
152
+ try {
153
+ client.write(`event: workspace_stopped\ndata: {}\n\n`);
154
+ } catch {}
155
+ });
156
+
157
+ res.writeHead(200, { 'Content-Type': 'application/json' });
158
+ res.end(JSON.stringify({ ok: true }));
159
+ }).catch(err => {
160
+ res.writeHead(500, { 'Content-Type': 'application/json' });
161
+ res.end(JSON.stringify({ error: err.message }));
162
+ });
163
+ }
164
+
165
+ export const workspacesRoutes = [
166
+ { method: 'GET', match: 'exact', path: '/api/workspaces', handler: workspacesList },
167
+ { method: 'POST', match: 'exact', path: '/api/workspaces/launch', handler: workspacesLaunch },
168
+ { method: 'POST', match: 'exact', path: '/api/workspaces/add', handler: workspacesAdd },
169
+ { method: 'DELETE', match: 'prefix', path: '/api/workspaces/', handler: workspacesDelete },
170
+ { method: 'POST', match: 'exact', path: '/api/workspaces/stop', handler: workspacesStop },
171
+ ];