cc-viewer 1.9.14 → 1.9.16

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 (57) hide show
  1. package/cli.js +1 -0
  2. package/concepts/ar/UltraPlan.md +9 -9
  3. package/concepts/da/UltraPlan.md +9 -9
  4. package/concepts/de/UltraPlan.md +9 -9
  5. package/concepts/en/UltraPlan.md +9 -9
  6. package/concepts/es/UltraPlan.md +9 -9
  7. package/concepts/fr/UltraPlan.md +9 -9
  8. package/concepts/it/UltraPlan.md +9 -9
  9. package/concepts/ja/UltraPlan.md +9 -9
  10. package/concepts/ko/UltraPlan.md +9 -9
  11. package/concepts/no/UltraPlan.md +9 -9
  12. package/concepts/pl/UltraPlan.md +9 -9
  13. package/concepts/pt-BR/UltraPlan.md +9 -9
  14. package/concepts/ru/UltraPlan.md +9 -9
  15. package/concepts/th/UltraPlan.md +9 -9
  16. package/concepts/tr/UltraPlan.md +9 -9
  17. package/concepts/uk/UltraPlan.md +9 -9
  18. package/concepts/zh/UltraPlan.md +9 -9
  19. package/concepts/zh-TW/UltraPlan.md +9 -9
  20. package/dist/assets/App-BvVdj099.css +1 -0
  21. package/dist/assets/App-CYdOioAz.js +2 -0
  22. package/dist/assets/{MdxEditorPanel-DI3B4gsM.js → MdxEditorPanel-CuCAe1OG.js} +1 -1
  23. package/dist/assets/Mobile-CfNYOgRa.js +1 -0
  24. package/dist/assets/{ProxyStatsModal-B8K0AtAq.js → ProxyStatsModal-Dhj4EVnh.js} +1 -1
  25. package/dist/assets/index-sgW8MlD3.js +2 -0
  26. package/dist/assets/seqResourceLoaders-B-5xGs9q.css +41 -0
  27. package/dist/assets/seqResourceLoaders-Cj0pdz-l.js +2 -0
  28. package/dist/index.html +1 -1
  29. package/node_modules/@ccv/core/src/contentFilter.js +3 -0
  30. package/node_modules/@ccv/core/src/queued-prompt.js +66 -0
  31. package/node_modules/@ccv/core/src/v2-transcript-normalizer.js +78 -14
  32. package/package.json +1 -1
  33. package/server/lib/ask/perm-bridge.js +4 -1
  34. package/server/lib/auto-approve.js +160 -0
  35. package/server/lib/chat-queue.js +350 -120
  36. package/server/lib/file-access-policy.js +24 -2
  37. package/server/lib/scheduled-task-executor.js +3 -1
  38. package/server/lib/sdk-manager.js +377 -76
  39. package/server/lib/v2/conversation-store.js +15 -11
  40. package/server/lib/v2/v2-writer.js +1 -1
  41. package/server/lib/view-root.js +4 -4
  42. package/server/pty-manager.js +8 -8
  43. package/server/routes/ask-perm.js +54 -3
  44. package/server/routes/files-content.js +352 -16
  45. package/server/routes/files-fs.js +122 -36
  46. package/server/routes/resume.js +1 -1
  47. package/server/routes/sdk-context.js +13 -2
  48. package/server/routes/skills.js +19 -8
  49. package/server/server.js +205 -34
  50. package/server/system-prompt-templates/presets/kimi-k3.md +1 -0
  51. package/ultraAgents/code-expert.json +1 -1
  52. package/dist/assets/App-BzV1ieDH.js +0 -2
  53. package/dist/assets/App-DU5jmGrx.css +0 -1
  54. package/dist/assets/Mobile-qBkAnXy3.js +0 -1
  55. package/dist/assets/index-CU0gt6m1.js +0 -2
  56. package/dist/assets/seqResourceLoaders-CxUFE36z.css +0 -41
  57. package/dist/assets/seqResourceLoaders-nGCs11zu.js +0 -2
@@ -7,16 +7,17 @@ import { join, dirname, basename, resolve, sep } from 'node:path';
7
7
  import { homedir, tmpdir } from 'node:os';
8
8
  import { execFile, spawn } from 'node:child_process';
9
9
  import { bumpWorkspacesVersion, isReadAllowed } from '../lib/file-access-policy.js';
10
- import { validateImportDir } from '../lib/file-api.js';
10
+ import { isPathContained, validateImportDir, renameSyncWithRetry } from '../lib/file-api.js';
11
+ import { reportSwallowed } from '../lib/error-report.js';
11
12
  import { viewRootOrReply } from '../lib/view-root.js';
12
13
  import { loadWorkspaces } from '../workspace-registry.js';
13
14
  import { listLivePtys } from '../pty-manager.js';
14
15
  import { PROFILE_PATH, _projectName, _logDir } from '../interceptor.js';
15
16
  import { LOG_DIR, getClaudeConfigDir } from '../../findcc.js';
16
17
 
17
- // Multi-project (2026-10): project-scoped READ routes resolve the viewed
18
- // project via the shared viewRootOrReply (?project=, or POST body {project}
19
- // for the body-carrying read probes). WRITE routes keep the bound root.
18
+ // Project-scoped file operations resolve the viewed project via the shared
19
+ // viewRootOrReply (?project=, or POST body {project}/{instance}). Mutations
20
+ // still enforce realpath containment in that root before touching the disk.
20
21
  // SYNCHRONOUS: the affected handlers were sync before multi-project and their
21
22
  // tests assert on res immediately after the call.
22
23
  function _viewRootOrReply(req, res, parsedUrl, bodyProject, bodyInstance) {
@@ -161,11 +162,31 @@ function importFile(req, res, parsedUrl, isLocal, deps) {
161
162
  req.on('end', () => {
162
163
  if (aborted) return;
163
164
  try {
164
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
165
+ const cwd = _viewRootOrReply(req, res, parsedUrl);
166
+ if (!cwd) return;
165
167
  const targetDir = join(cwd, dir);
166
- mkdirSync(targetDir, { recursive: true });
167
- const realDir = realpathSync(targetDir);
168
168
  const realCwd = realpathSync(cwd);
169
+ let cursor = targetDir;
170
+ const missing = [];
171
+ while (!existsSync(cursor)) {
172
+ missing.unshift(basename(cursor));
173
+ const parent = dirname(cursor);
174
+ if (parent === cursor) break;
175
+ cursor = parent;
176
+ }
177
+ const realAncestor = realpathSync(cursor);
178
+ if (realAncestor !== realCwd && !realAncestor.startsWith(realCwd + sep)) {
179
+ res.writeHead(400, { 'Content-Type': 'application/json' });
180
+ res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
181
+ return;
182
+ }
183
+ // Create only after the nearest existing ancestor is known to be inside
184
+ // the view; mkdir first would let an escaping symlink create outside dirs.
185
+ for (const segment of missing) {
186
+ cursor = join(cursor, segment);
187
+ mkdirSync(cursor);
188
+ }
189
+ const realDir = realpathSync(targetDir);
169
190
  if (realDir !== realCwd && !realDir.startsWith(realCwd + sep)) {
170
191
  res.writeHead(400, { 'Content-Type': 'application/json' });
171
192
  res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
@@ -339,7 +360,8 @@ function renameFile(req, res, parsedUrl, isLocal, deps) {
339
360
  res.end(JSON.stringify({ error: 'Invalid path' }));
340
361
  return;
341
362
  }
342
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
363
+ const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
364
+ if (!cwd) return;
343
365
  const oldFullPath = join(cwd, oldPath);
344
366
  const parentDir = dirname(oldFullPath);
345
367
  const newFullPath = join(parentDir, newName);
@@ -349,13 +371,48 @@ function renameFile(req, res, parsedUrl, isLocal, deps) {
349
371
  res.end(JSON.stringify({ error: 'File not found' }));
350
372
  return;
351
373
  }
374
+ if (!isPathContained(oldFullPath, cwd)) {
375
+ res.writeHead(400, { 'Content-Type': 'application/json' });
376
+ res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
377
+ return;
378
+ }
379
+ // On a case-insensitive filesystem the differently-cased destination is
380
+ // the source itself, not a collision. A temporary name makes the case
381
+ // change work on NTFS and APFS even when realpath keeps the old casing.
382
+ const oldName = basename(oldPath);
383
+ if (oldName !== newName && oldName.toLowerCase() === newName.toLowerCase()) {
384
+ try {
385
+ if (realpathSync.native(newFullPath) === realpathSync.native(oldFullPath)) {
386
+ const tempPath = join(parentDir, `.ccv-rename-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
387
+ renameSyncWithRetry(oldFullPath, tempPath);
388
+ try { renameSyncWithRetry(tempPath, newFullPath); }
389
+ catch (err) {
390
+ try { renameSyncWithRetry(tempPath, oldFullPath); }
391
+ catch (rollbackErr) { reportSwallowed('filesFs.renameRollback', rollbackErr, { tempPath }); }
392
+ throw err;
393
+ }
394
+ const newRelPath = oldPath.includes('/') ? oldPath.substring(0, oldPath.lastIndexOf('/') + 1) + newName : newName;
395
+ res.writeHead(200, { 'Content-Type': 'application/json' });
396
+ res.end(JSON.stringify({ ok: true, newPath: newRelPath }));
397
+ return;
398
+ }
399
+ } catch (err) {
400
+ // Case-sensitive filesystems have no destination to realpath yet;
401
+ // that is the normal rename case, not a server error.
402
+ if (err.code !== 'ENOENT') {
403
+ res.writeHead(500, { 'Content-Type': 'application/json' });
404
+ res.end(JSON.stringify({ error: err.message }));
405
+ return;
406
+ }
407
+ }
408
+ }
352
409
  // 检查目标是否已存在
353
410
  if (existsSync(newFullPath)) {
354
411
  res.writeHead(409, { 'Content-Type': 'application/json' });
355
412
  res.end(JSON.stringify({ error: 'Target already exists' }));
356
413
  return;
357
414
  }
358
- renameSync(oldFullPath, newFullPath);
415
+ renameSyncWithRetry(oldFullPath, newFullPath);
359
416
  const newRelPath = oldPath.includes('/') ? oldPath.substring(0, oldPath.lastIndexOf('/') + 1) + newName : newName;
360
417
  res.writeHead(200, { 'Content-Type': 'application/json' });
361
418
  res.end(JSON.stringify({ ok: true, newPath: newRelPath }));
@@ -391,7 +448,8 @@ function moveFile(req, res, parsedUrl, isLocal, deps) {
391
448
  res.end(JSON.stringify({ error: 'Invalid path' }));
392
449
  return;
393
450
  }
394
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
451
+ const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
452
+ if (!cwd) return;
395
453
  const oldFullPath = join(cwd, fromPath);
396
454
  const toDirFull = join(cwd, toDir);
397
455
  // 检查源文件/目录存在
@@ -417,6 +475,11 @@ function moveFile(req, res, parsedUrl, isLocal, deps) {
417
475
  }
418
476
  }
419
477
  const name = basename(fromPath);
478
+ if (!isPathContained(oldFullPath, cwd) || !isPathContained(toDirFull, cwd)) {
479
+ res.writeHead(400, { 'Content-Type': 'application/json' });
480
+ res.end(JSON.stringify({ error: 'Path traversal not allowed' }));
481
+ return;
482
+ }
420
483
  const newFullPath = join(toDirFull, name);
421
484
  // 检查目标位置不存在同名文件
422
485
  if (existsSync(newFullPath)) {
@@ -487,7 +550,8 @@ function deleteFile(req, res, parsedUrl, isLocal, deps) {
487
550
  res.end(JSON.stringify({ error: 'Invalid path' }));
488
551
  return;
489
552
  }
490
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
553
+ const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
554
+ if (!cwd) return;
491
555
  const fullPath = join(cwd, filePath);
492
556
  if (!existsSync(fullPath)) {
493
557
  res.writeHead(404, { 'Content-Type': 'application/json' });
@@ -565,7 +629,8 @@ function revealFile(req, res, parsedUrl, isLocal, deps) {
565
629
  res.end(JSON.stringify({ error: 'Invalid path' }));
566
630
  return;
567
631
  }
568
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
632
+ const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
633
+ if (!cwd) return;
569
634
  const fullPath = join(cwd, filePath);
570
635
  if (!existsSync(fullPath)) {
571
636
  res.writeHead(404, { 'Content-Type': 'application/json' });
@@ -623,7 +688,8 @@ function openFile(req, res, parsedUrl, isLocal, deps) {
623
688
  res.end(JSON.stringify({ error: 'Invalid path' }));
624
689
  return;
625
690
  }
626
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
691
+ const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
692
+ if (!cwd) return;
627
693
  const fullPath = join(cwd, filePath);
628
694
  if (!existsSync(fullPath)) {
629
695
  res.writeHead(404, { 'Content-Type': 'application/json' });
@@ -671,12 +737,11 @@ function resolvePath(req, res, parsedUrl, isLocal, deps) {
671
737
  res.end(JSON.stringify({ error: 'Invalid path' }));
672
738
  return;
673
739
  }
674
- // resolve-path is an internal UI helper (resolve a relative path against
675
- // the project root for reveal/open actions). It stays BOUND-only (review
676
- // P2-5): resolving a foreign project's root and echoing its absolute path
677
- // back would make this endpoint a weak path-leak oracle for projects the
678
- // caller is not viewing.
679
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
740
+ // Resolve a project-relative path against the same viewed root used by
741
+ // reveal/open. Registered projects and live instances use the shared
742
+ // resolver, so same-name projects cannot silently fall back to the bound cwd.
743
+ const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
744
+ if (!cwd) return;
680
745
  const fullPath = relPath ? join(cwd, relPath) : cwd;
681
746
  res.writeHead(200, { 'Content-Type': 'application/json' });
682
747
  res.end(JSON.stringify({ ok: true, fullPath }));
@@ -790,7 +855,8 @@ function createFile(req, res, parsedUrl, isLocal, deps) {
790
855
  res.end(JSON.stringify({ error: 'Invalid path' }));
791
856
  return;
792
857
  }
793
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
858
+ const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
859
+ if (!cwd) return;
794
860
  const fullDirPath = relDir ? join(cwd, relDir) : cwd;
795
861
  if (!existsSync(fullDirPath) || !statSync(fullDirPath).isDirectory()) {
796
862
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -838,7 +904,8 @@ function openTerminal(req, res, parsedUrl, isLocal, deps) {
838
904
  res.end(JSON.stringify({ error: 'Invalid path' }));
839
905
  return;
840
906
  }
841
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
907
+ const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
908
+ if (!cwd) return;
842
909
  const fullDir = relDir ? join(cwd, relDir) : cwd;
843
910
  if (!existsSync(fullDir) || !statSync(fullDir).isDirectory()) {
844
911
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -860,23 +927,40 @@ function openTerminal(req, res, parsedUrl, isLocal, deps) {
860
927
  } else {
861
928
  // Linux: try common terminal emulators
862
929
  const terminals = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'xterm'];
863
- let launched = false;
864
- for (const term of terminals) {
930
+ // spawn reports a missing executable asynchronously, outside try/catch.
931
+ // Probe each candidate with piped stdio so the next one can be attempted
932
+ // after ENOENT; detach only after a successful spawn.
933
+ const tryTerminal = (index) => {
934
+ if (index >= terminals.length) {
935
+ const child = spawn('xdg-open', [fullDir], { stdio: 'ignore', detached: true });
936
+ child.on('error', (err) => { reportSwallowed('filesFs.openTerminal', err, { terminal: 'xdg-open' }); });
937
+ child.unref();
938
+ return;
939
+ }
940
+ const term = terminals[index];
941
+ let child;
865
942
  try {
866
943
  if (term === 'gnome-terminal') {
867
- spawn(term, ['--working-directory=' + fullDir], { stdio: 'ignore', detached: true }).unref();
944
+ child = spawn(term, ['--working-directory=' + fullDir], { stdio: 'pipe' });
868
945
  } else if (term === 'konsole') {
869
- spawn(term, ['--workdir', fullDir], { stdio: 'ignore', detached: true }).unref();
946
+ child = spawn(term, ['--workdir', fullDir], { stdio: 'pipe' });
870
947
  } else {
871
- spawn(term, [], { cwd: fullDir, stdio: 'ignore', detached: true }).unref();
948
+ child = spawn(term, [], { cwd: fullDir, stdio: 'pipe' });
872
949
  }
873
- launched = true;
874
- break;
875
- } catch { continue; }
876
- }
877
- if (!launched) {
878
- spawn('xdg-open', [fullDir], { stdio: 'ignore', detached: true }).unref();
879
- }
950
+ } catch {
951
+ tryTerminal(index + 1);
952
+ return;
953
+ }
954
+ child.once('error', (err) => {
955
+ reportSwallowed('filesFs.openTerminal', err, { terminal: term });
956
+ tryTerminal(index + 1);
957
+ });
958
+ child.once('spawn', () => {
959
+ for (const stream of [child.stdin, child.stdout, child.stderr]) stream?.destroy();
960
+ child.unref();
961
+ });
962
+ };
963
+ tryTerminal(0);
880
964
  }
881
965
  res.writeHead(200, { 'Content-Type': 'application/json' });
882
966
  res.end(JSON.stringify({ ok: true }));
@@ -915,7 +999,8 @@ function createDir(req, res, parsedUrl, isLocal, deps) {
915
999
  res.end(JSON.stringify({ error: 'Invalid path' }));
916
1000
  return;
917
1001
  }
918
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
1002
+ const cwd = _viewRootOrReply(req, res, parsedUrl, parsed.project, parsed.instance);
1003
+ if (!cwd) return;
919
1004
  const fullDirPath = relDir ? join(cwd, relDir) : cwd;
920
1005
  if (!existsSync(fullDirPath) || !statSync(fullDirPath).isDirectory()) {
921
1006
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -965,8 +1050,9 @@ function openProfileDir(req, res) {
965
1050
  res.end(JSON.stringify({ ok: true, dir }));
966
1051
  }
967
1052
 
968
- function openProjectDir(req, res) {
969
- const dir = process.env.CCV_PROJECT_DIR || process.cwd();
1053
+ function openProjectDir(req, res, parsedUrl) {
1054
+ const dir = _viewRootOrReply(req, res, parsedUrl);
1055
+ if (!dir) return;
970
1056
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'explorer' : 'xdg-open';
971
1057
  execFile(cmd, [dir], { windowsHide: true }, () => {});
972
1058
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -560,7 +560,7 @@ function postResumeSessionHandler(req, res, parsedUrl, isLocal, deps) {
560
560
  if (r.ok) {
561
561
  // Session identity just changed — drop any still-queued composer messages so a
562
562
  // message typed for the OLD conversation never drains into the resumed one.
563
- try { chatQueue.clear(); } catch (err) { reportSwallowed('resume-route.clear-queue', err); }
563
+ try { chatQueue.clear({ project: targetProject || undefined, instanceKey: instanceKey || undefined }); } catch (err) { reportSwallowed('resume-route.clear-queue', err); }
564
564
  sendJson(res, 200, { ok: true });
565
565
  return;
566
566
  }
@@ -2,14 +2,25 @@
2
2
  // the automatic turn-end/compact refreshes flow over the WS broadcast instead. A
3
3
  // `session-inactive` outcome is a NORMAL state (no turn run yet / query dead), so it
4
4
  // returns 200 with { ok:false, reason } rather than an HTTP error.
5
+ //
6
+ // Multi-session: `?project=` and `?instance=` pin the pull to the VIEWED workspace (same
7
+ // query-param names as the other view-scoped routes). No params → active session. Unknown
8
+ // or ambiguous anchors stay `session-inactive` (never fall back to the active session).
5
9
  import { reportSwallowed } from '../lib/error-report.js';
6
10
 
7
- async function sdkContextUsage(req, res) {
11
+ async function sdkContextUsage(req, res, parsedUrl) {
8
12
  try {
9
13
  // Dynamic import: sdk-manager is only loaded in SDK mode (resume.js parity). The
10
14
  // literal specifier satisfies the boundary verifier's R7 rule.
11
15
  const sdk = await import('../lib/sdk-manager.js');
12
- const r = await sdk.fetchContextUsage();
16
+ const params = parsedUrl && parsedUrl.searchParams;
17
+ const project = (params && params.get('project')) || '';
18
+ const instance = (params && params.get('instance')) || '';
19
+ // No params must stay `undefined` so fetchContextUsage keeps the active-session fallback.
20
+ const anchor = (project || instance)
21
+ ? { project: project || undefined, instanceKey: instance || undefined }
22
+ : undefined;
23
+ const r = await sdk.fetchContextUsage(anchor);
13
24
  res.writeHead(200, { 'Content-Type': 'application/json' });
14
25
  res.end(JSON.stringify(r.ok ? { ok: true, snapshot: r.snapshot } : { ok: false, reason: r.reason }));
15
26
  } catch (err) {
@@ -9,13 +9,20 @@ import { loadWorkspaces } from '../workspace-registry.js';
9
9
  import { listLivePtys } from '../pty-manager.js';
10
10
 
11
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) {
12
+ // the shared viewRootOrReply (?project=). toggle/delete used to keep the
13
+ // bound root — that was a deliberate safety choice, but it breaks multi-view
14
+ // semantics: the list shows skills for project B while a click on toggle or
15
+ // delete silently rewrote project A's same-named skill. Resolve mutations
16
+ // through the same viewRootOrReply (with body-level project/instance) so the
17
+ // action targets the project the user is actually looking at; the helper
18
+ // still constrains the resolved root to bound/registered/live projects.
19
+ function _viewRootOrReply(req, res, parsedUrl, bodyProject, bodyInstance) {
15
20
  return viewRootOrReply(req, res, parsedUrl, {
16
21
  boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
17
22
  loadWorkspaces,
18
23
  listLivePtys,
24
+ bodyProject,
25
+ bodyInstance,
19
26
  });
20
27
  }
21
28
 
@@ -34,18 +41,20 @@ async function skillsList(req, res, parsedUrl) {
34
41
  }
35
42
 
36
43
  // Skill 动态装卸 —— 切换单个 skill(在 skills/ 和 skills-skip/ 之间 move)
37
- function skillsToggle(req, res) {
44
+ function skillsToggle(req, res, parsedUrl) {
38
45
  let body = '';
39
46
  req.on('data', chunk => { body += chunk; if (body.length > 4096) req.destroy(); });
40
47
  req.on('end', async () => {
41
48
  try {
42
- const { source, name, enable } = JSON.parse(body);
49
+ const { source, name, enable, project, instance } = JSON.parse(body);
50
+ const projectDir = _viewRootOrReply(req, res, parsedUrl, project, instance);
51
+ if (!projectDir) return;
43
52
  // 不加进程锁:moveSkill 里 existsSync 前置 + renameSync 原子性已能让并发
44
53
  // toggle 落到合理分支(一个成功、另一个拿 SOURCE_MISSING 或 DUPLICATE);
45
54
  // 前端 toggling:Set 也已防同 tab 连点,两者叠加足够安全
46
55
  moveSkill({
47
56
  source, name, enable: !!enable,
48
- projectDir: process.env.CCV_PROJECT_DIR || process.cwd(),
57
+ projectDir,
49
58
  });
50
59
  res.writeHead(200, { 'Content-Type': 'application/json' });
51
60
  res.end(JSON.stringify({ ok: true }));
@@ -75,10 +84,12 @@ function skillsDelete(req, res, parsedUrl, isLocal) {
75
84
  req.on('data', chunk => { body += chunk; if (body.length > 4096) req.destroy(); });
76
85
  req.on('end', async () => {
77
86
  try {
78
- const { source, name, enabled } = JSON.parse(body);
87
+ const { source, name, enabled, project, instance } = JSON.parse(body);
88
+ const projectDir = _viewRootOrReply(req, res, parsedUrl, project, instance);
89
+ if (!projectDir) return;
79
90
  deleteSkill({
80
91
  source, name, enabled: !!enabled,
81
- projectDir: process.env.CCV_PROJECT_DIR || process.cwd(),
92
+ projectDir,
82
93
  });
83
94
  res.writeHead(200, { 'Content-Type': 'application/json' });
84
95
  res.end(JSON.stringify({ ok: true }));