cc-viewer 1.9.13 → 1.9.15

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 (33) hide show
  1. package/dist/assets/App-BCDnm2Lh.js +2 -0
  2. package/dist/assets/App-CCnPkf2_.css +1 -0
  3. package/dist/assets/{MdxEditorPanel-Db1nmcJV.js → MdxEditorPanel-DRLFmdU5.js} +1 -1
  4. package/dist/assets/Mobile-C8VJqBwa.js +1 -0
  5. package/dist/assets/{ProxyStatsModal-Ch1BH0Kp.js → ProxyStatsModal-Ba1E1lu4.js} +1 -1
  6. package/dist/assets/{index-CAiC6cw-.js → index-Sp3JYYKo.js} +2 -2
  7. package/dist/assets/seqResourceLoaders-BUfjTaCV.css +41 -0
  8. package/dist/assets/seqResourceLoaders-CHmtPDFO.js +2 -0
  9. package/dist/index.html +1 -1
  10. package/node_modules/@ccv/core/src/contentFilter.js +3 -0
  11. package/node_modules/@ccv/core/src/context-rules.js +6 -2
  12. package/node_modules/@ccv/core/src/queued-prompt.js +66 -0
  13. package/node_modules/@ccv/core/src/v2-transcript-normalizer.js +78 -14
  14. package/package.json +1 -1
  15. package/server/lib/chat-queue.js +350 -120
  16. package/server/lib/model-system-prompts.js +4 -1
  17. package/server/lib/scheduled-task-executor.js +3 -1
  18. package/server/lib/sdk-manager.js +282 -68
  19. package/server/lib/v2/conversation-store.js +15 -11
  20. package/server/lib/v2/v2-writer.js +1 -1
  21. package/server/lib/view-root.js +4 -4
  22. package/server/pty-manager.js +8 -8
  23. package/server/routes/files-content.js +108 -14
  24. package/server/routes/files-fs.js +122 -36
  25. package/server/routes/resume.js +1 -1
  26. package/server/routes/sdk-context.js +13 -2
  27. package/server/server.js +128 -28
  28. package/server/system-prompt-templates/presets/kimi-k3.md +4 -0
  29. package/dist/assets/App-BzOM1_y-.js +0 -2
  30. package/dist/assets/App-DU5jmGrx.css +0 -1
  31. package/dist/assets/Mobile-rjfABa-U.js +0 -1
  32. package/dist/assets/seqResourceLoaders-CxUFE36z.css +0 -41
  33. package/dist/assets/seqResourceLoaders-Ds8-_QA3.js +0 -2
@@ -1,7 +1,6 @@
1
1
  // View-root resolution (multi-project, 2026-10): map an optional `?project=`
2
2
  // name to the REAL filesystem root of that project, so project-scoped READ
3
- // routes (file tree / git / skills / memory / stats / search) can follow the
4
- // viewed parallel project instead of always the bound one.
3
+ // routes and boundary-checked file mutations follow the viewed project.
5
4
  //
6
5
  // Name → path sources (both consulted, in order):
7
6
  // 1. workspace-registry (loadWorkspaces) — persistent { path, projectName },
@@ -22,7 +21,8 @@
22
21
  // 3. still >1 real candidate → 400, never a silent guess.
23
22
  //
24
23
  // Pure + dependency-injected (fs/registry/pty seams) so it is directly
25
- // node-testable. WRITE routes must NOT use this — they stay bound-only.
24
+ // node-testable. File mutation routes use the same resolver as their reads;
25
+ // every target must still pass the resolved root's filesystem boundary checks.
26
26
 
27
27
  import { realpathSync, existsSync, statSync } from 'node:fs';
28
28
  import { projectKeyForCwd } from './system-prompt-snapshots.js';
@@ -192,7 +192,7 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
192
192
  }
193
193
 
194
194
  /**
195
- * HTTP-facing wrapper shared by every project-scoped READ route (multi-project,
195
+ * HTTP-facing wrapper shared by project-scoped routes (multi-project,
196
196
  * 2026-10): resolves `?project=` (or a POST-body override) via resolveViewRoot
197
197
  * and, on failure, writes the error response itself. Returns the resolved root
198
198
  * to continue with, or null after replying (the caller must return early).
@@ -1290,12 +1290,15 @@ export function resolveInstanceForCwd(cwd) {
1290
1290
  * would make resize/scrollback/getPtyState follow whichever project was last
1291
1291
  * SENT to, a new cross-project bleed (review P1, 2026-10-05).
1292
1292
  */
1293
- function _resolveKeyByAnchor({ sessionId, project, instanceKey } = {}) {
1293
+ function _resolveKeyByAnchor({ sessionId, project, instanceKey, strict = false } = {}) {
1294
+ // Explicit strict targets never fall back to the active conversation. Without this,
1295
+ // a dead project/instance could receive a queued prompt meant for another one.
1294
1296
  // Multi-instance: an explicit instanceKey is the strongest anchor — it pins THIS exact
1295
1297
  // process (disambiguating two same-cwd instances that basename routing cannot).
1296
1298
  if (typeof instanceKey === 'string' && /^ccv-[0-9a-f]+$/.test(instanceKey)) {
1297
1299
  const rec = ptys.get(instanceKey);
1298
1300
  if (rec && rec.ptyProcess) return instanceKey;
1301
+ if (strict) return null;
1299
1302
  }
1300
1303
  const projKey = (typeof project === 'string' && project) ? _resolveKey({ project }) : null;
1301
1304
  if (typeof sessionId === 'string' && sessionId) {
@@ -1313,12 +1316,13 @@ function _resolveKeyByAnchor({ sessionId, project, instanceKey } = {}) {
1313
1316
  }
1314
1317
  }
1315
1318
  if (projKey) return projKey;
1319
+ if (strict) return null;
1316
1320
  return activePtyKey != null && ptys.has(activePtyKey) ? activePtyKey : null;
1317
1321
  }
1318
1322
 
1319
1323
  /** writeToPty variant routed by an explicit anchor instead of the global active pointer. */
1320
- export function writeToPtyFor(data, { sessionId, project, instanceKey } = {}) {
1321
- const key = _resolveKeyByAnchor({ sessionId, project, instanceKey });
1324
+ export function writeToPtyFor(data, { sessionId, project, instanceKey, strict = false } = {}) {
1325
+ const key = _resolveKeyByAnchor({ sessionId, project, instanceKey, strict });
1322
1326
  const s = key ? ptys.get(key) : null;
1323
1327
  if (s && s.ptyProcess) {
1324
1328
  s.ptyProcess.write(data);
@@ -1332,11 +1336,7 @@ export function writeToPtyFor(data, { sessionId, project, instanceKey } = {}) {
1332
1336
  * the write fails (onComplete(false)) WITHOUT falling back to the project/active record —
1333
1337
  * scheduled-task injection must never land in the wrong conversation (review P0-2). */
1334
1338
  export function writeToPtySequentialFor(chunks, onComplete, opts = {}, anchor = {}) {
1335
- let key = _resolveKeyByAnchor(anchor);
1336
- if (opts && opts.strict && typeof anchor.instanceKey === 'string' && anchor.instanceKey) {
1337
- const rec = ptys.get(anchor.instanceKey);
1338
- if (!rec || !rec.ptyProcess) key = null; // strict: a dead explicit instance never falls back
1339
- }
1339
+ const key = _resolveKeyByAnchor({ ...anchor, strict: opts.strict === true });
1340
1340
  const s = key ? ptys.get(key) : null;
1341
1341
  if (!s || !s.ptyProcess || !chunks || chunks.length === 0) {
1342
1342
  if (onComplete) onComplete(false);
@@ -2,8 +2,10 @@
2
2
  import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, realpathSync, createReadStream } from 'node:fs';
3
3
  import { join, basename, dirname, resolve, sep } from 'node:path';
4
4
  import { isReadAllowed, reasonToStatus } from '../lib/file-access-policy.js';
5
- import { ERROR_STATUS_MAP } from '../lib/file-api.js';
6
- import { viewRootOrReply } from '../lib/view-root.js';
5
+ import { ERROR_STATUS_MAP, isPathContained } from '../lib/file-api.js';
6
+ import { viewRootOrReply, resolveViewRoot } from '../lib/view-root.js';
7
+ import { findSdkSessionByInstance } from '../lib/sdk-session-index.js';
8
+ import { projectKeyForCwd } from '../lib/system-prompt-snapshots.js';
7
9
  import { loadWorkspaces } from '../workspace-registry.js';
8
10
  import { listLivePtys } from '../pty-manager.js';
9
11
  import { reportSwallowed } from '../lib/error-report.js';
@@ -11,16 +13,20 @@ import { discoverClaudeMdCandidates, readCandidateById } from '../lib/claude-md-
11
13
  import { getClaudeConfigDir } from '../../findcc.js';
12
14
  import { _projectName } from '../interceptor.js';
13
15
 
14
- // Multi-project (2026-10): project-scoped READ routes resolve the viewed
15
- // project via the shared viewRootOrReply (?project=). WRITE routes keep the
16
- // bound root. SYNCHRONOUS: these handlers were sync before multi-project, and
16
+ // Multi-project file operations use the same viewed root for reads and writes.
17
+ // Every file-browser mutation enforces realpath containment in that root;
18
+ // explicit editor sessions retain their existing allowlisted external paths.
17
19
  // their tests (and the dispatcher's sync-call convention) assert on res
18
20
  // immediately after the call — the override must not async-ify them.
19
- function _viewRootOrReply(req, res, parsedUrl) {
21
+ // Both reads and writes resolve the viewed project. Absolute editor paths are
22
+ // still governed by the allowlist; browser writes stay within the selected root.
23
+ function _viewRootOrReply(req, res, parsedUrl, bodyProject, bodyInstance) {
20
24
  return viewRootOrReply(req, res, parsedUrl, {
21
25
  boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
22
26
  loadWorkspaces,
23
27
  listLivePtys,
28
+ bodyProject,
29
+ bodyInstance,
24
30
  });
25
31
  }
26
32
 
@@ -167,6 +173,37 @@ function projectMemory(req, res, parsedUrl) {
167
173
  projectMemoryEntry(req, res, parsedUrl, respondJson);
168
174
  }
169
175
 
176
+ // The SDK context snapshot prices MEMORY.md itself, not every linked detail file.
177
+ // Supplement only the index's existing single-segment .md links with a transparent
178
+ // content-length estimate; never allocate the SDK total across unrelated files.
179
+ export function estimateLinkedMemoryTokens(content, dir) {
180
+ const estimates = {};
181
+ const MAX_BYTES = 512 * 1024;
182
+ const MAX_LINKS = 100;
183
+ if (typeof content !== 'string' || typeof dir !== 'string') return estimates;
184
+ const links = new Set();
185
+ for (const match of content.matchAll(/\]\(\s*([^)\s]+\.md)(?:[?#][^)\s]*)?(?:\s+"[^"]*")?\s*\)/gi)) {
186
+ let name;
187
+ try { name = decodeURIComponent(match[1].split('#')[0].split('?')[0]); } catch { continue; }
188
+ if (!name || name.includes('/') || name.includes('\\') || name.includes('\0') || name.startsWith('.')) continue;
189
+ links.add(name);
190
+ if (links.size >= MAX_LINKS) break;
191
+ }
192
+ let realDir;
193
+ try { realDir = realpathSync(dir); } catch { return estimates; }
194
+ const prefix = realDir.endsWith(sep) ? realDir : realDir + sep;
195
+ for (const name of links) {
196
+ try {
197
+ const realFile = realpathSync(join(realDir, name));
198
+ const stat = statSync(realFile);
199
+ if (!realFile.startsWith(prefix) || !stat.isFile() || stat.size > MAX_BYTES) continue;
200
+ if (!isReadAllowed(realFile).ok) continue;
201
+ estimates[name] = { tokens: Math.ceil(stat.size / 4), source: 'file-content-estimate' };
202
+ } catch { /* Missing/unreadable links remain without an estimate. */ }
203
+ }
204
+ return estimates;
205
+ }
206
+
170
207
  // The entry wraps BOTH the view-root resolution and the read in the endpoint's
171
208
  // single error surface (the searchParams-throw defense test asserts a 500 from
172
209
  // THIS catch, regardless of which stage throws).
@@ -202,7 +239,9 @@ function projectMemoryWithRoot(req, res, parsedUrl, respondJson, cwd) {
202
239
  if (!st.isFile()) return respondJson(400, { error: 'Not a file' });
203
240
  if (st.size > MAX_BYTES) return respondJson(413, { error: 'File too large' });
204
241
  const content = readFileSync(policy.real, 'utf-8');
205
- return respondJson(200, { exists: true, dir, indexPath, content });
242
+ // Only SDK mode consumes these estimates. PTY keeps the original cheap index read.
243
+ const memoryTokenEstimates = process.env.CCV_SDK_MODE === '1' ? estimateLinkedMemoryTokens(content, dir) : undefined;
244
+ return respondJson(200, { exists: true, dir, indexPath, content, memoryTokenEstimates });
206
245
  }
207
246
 
208
247
  // 明细文件: 仅接受单段 basename + .md 后缀
@@ -231,7 +270,7 @@ function projectMemoryWithRoot(req, res, parsedUrl, respondJson, cwd) {
231
270
  if (!st.isFile()) return respondJson(400, { error: 'Not a file' });
232
271
  if (st.size > MAX_BYTES) return respondJson(413, { error: 'File too large' });
233
272
  const content = readFileSync(realFile, 'utf-8');
234
- return respondJson(200, { name: fileParam, path: realFile, content });
273
+ return respondJson(200, { name: fileParam, path: realFile, content, tokens: Math.ceil(Buffer.byteLength(content, 'utf8') / 4), tokenSource: 'file-content-estimate' });
235
274
  } catch (err) {
236
275
  // 与 /api/file-content 一致:已知 errno(ENOENT/EACCES 等)走 ERROR_STATUS_MAP 映射;
237
276
  // 500 时不回显 err.message —— 可能含 ~/.claude/projects/<encoded>/memory/ 路径片段。
@@ -311,13 +350,51 @@ function fileRaw(req, res, parsedUrl) {
311
350
  const url = parsedUrl.pathname;
312
351
  const method = req.method;
313
352
  let reqPath;
314
- if (url.startsWith('/api/file-raw/')) {
353
+ let scopedProject = '';
354
+ let scopedInstance = '';
355
+ if (url.startsWith('/api/file-raw/view/')) {
356
+ const parts = url.slice('/api/file-raw/view/'.length).split('/');
357
+ let project = '';
358
+ let instance = '';
359
+ try {
360
+ project = decodeURIComponent(parts[0] || '');
361
+ instance = decodeURIComponent(parts[1] || '');
362
+ reqPath = decodeURIComponent(parts.slice(2).join('/'));
363
+ } catch {
364
+ res.writeHead(400, { 'Content-Type': 'application/json' });
365
+ res.end(JSON.stringify({ error: 'Invalid path' }));
366
+ return;
367
+ }
368
+ scopedProject = project;
369
+ scopedInstance = instance;
370
+ } else if (url.startsWith('/api/file-raw/')) {
315
371
  const tail = parsedUrl.pathname.slice('/api/file-raw/'.length);
316
372
  try { reqPath = decodeURIComponent(tail); } catch { reqPath = tail; }
317
373
  } else {
318
374
  reqPath = parsedUrl.searchParams.get('path');
319
375
  }
320
- const cwd = _viewRootOrReply(req, res, parsedUrl);
376
+ let resolvedView = null;
377
+ if (scopedProject) {
378
+ resolvedView = resolveViewRoot({
379
+ projectParam: scopedProject,
380
+ instanceParam: scopedInstance,
381
+ boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
382
+ loadWorkspaces,
383
+ listLivePtys,
384
+ });
385
+ // The path already pins its project. A stale/mismatched instance must not
386
+ // silently redirect it to the bound project or a different workspace.
387
+ const instanceRecord = scopedInstance ? findSdkSessionByInstance(scopedInstance) : null;
388
+ const instanceMatches = !scopedInstance || (instanceRecord
389
+ && (instanceRecord.projectName === scopedProject
390
+ || projectKeyForCwd(instanceRecord.cwd) === scopedProject));
391
+ if (!resolvedView.ok || (scopedInstance && !instanceMatches)) {
392
+ res.writeHead(resolvedView.ok ? 400 : resolvedView.status, { 'Content-Type': 'application/json' });
393
+ res.end(JSON.stringify({ error: resolvedView.ok ? 'Project scope mismatch' : resolvedView.error }));
394
+ return;
395
+ }
396
+ }
397
+ const cwd = resolvedView?.root || _viewRootOrReply(req, res, parsedUrl);
321
398
  if (!cwd) return;
322
399
  fileRawWithRoot(req, res, parsedUrl, reqPath, cwd, method);
323
400
  }
@@ -460,7 +537,11 @@ function fileDownloadWithRoot(req, res, parsedUrl, reqPath, cwd) {
460
537
  }
461
538
  }
462
539
 
463
- function fileContentPost(req, res) {
540
+ function fileContentPost(req, res, parsedUrl) {
541
+ // The shared read root keeps its raw cwd for projectMemory's store-key
542
+ // encoding. Normalize only this mutation's containment comparisons.
543
+ let writeRoot;
544
+ const isInsideWriteRoot = (path) => isPathContained(path, writeRoot);
464
545
  const MAX_BODY = 5 * 1024 * 1024; // 5MB,与 GET 路由限制对齐
465
546
  let body = '';
466
547
  let overflow = false;
@@ -475,8 +556,10 @@ function fileContentPost(req, res) {
475
556
  return;
476
557
  }
477
558
  try {
478
- const { path: reqPath, content } = JSON.parse(body);
479
- const cwd = process.env.CCV_PROJECT_DIR || process.cwd();
559
+ const { path: reqPath, content, editorSession } = JSON.parse(body);
560
+ const cwd = _viewRootOrReply(req, res, parsedUrl);
561
+ if (!cwd) return;
562
+ try { writeRoot = realpathSync(cwd); } catch { writeRoot = cwd; }
480
563
  if (!reqPath) {
481
564
  res.writeHead(400, { 'Content-Type': 'application/json' });
482
565
  res.end(JSON.stringify({ error: 'Invalid path' }));
@@ -489,7 +572,6 @@ function fileContentPost(req, res) {
489
572
  }
490
573
  const isAbs = /^([a-zA-Z]:[\\/]|[\\/])/.test(reqPath);
491
574
  const absPath = isAbs ? reqPath : resolve(cwd, reqPath);
492
-
493
575
  // 写路径同样走 policy(收敛 editorSession 后门):允许覆盖现有文件;
494
576
  // 新文件场景递归向上找最近存在的祖先目录,只要它在 allowlist 内即放行。
495
577
  // (旧实现只查 immediate parent,父目录也不存在时误拒嵌套新建。)
@@ -525,6 +607,13 @@ function fileContentPost(req, res) {
525
607
  res.end(JSON.stringify(body));
526
608
  return;
527
609
  }
610
+ // Check containment before creating any missing parent directories.
611
+ // Explicit $EDITOR sessions retain their allowlisted external paths.
612
+ if (!editorSession && !isInsideWriteRoot(ancestorPolicy.real)) {
613
+ res.writeHead(403, { 'Content-Type': 'application/json' });
614
+ res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
615
+ return;
616
+ }
528
617
  // 在祖先 real 路径下重建目标。Denylist 已在祖先 prefix 层生效,
529
618
  // 这里相信 allowlist 祖先的合法性(项目目录 / ~/.claude/cc-viewer 等)。
530
619
  targetReal = join(ancestorPolicy.real, ...descent);
@@ -539,6 +628,11 @@ function fileContentPost(req, res) {
539
628
  return;
540
629
  }
541
630
 
631
+ if (!editorSession && existsSync(targetReal) && !isInsideWriteRoot(targetReal)) {
632
+ res.writeHead(403, { 'Content-Type': 'application/json' });
633
+ res.end(JSON.stringify({ error: 'Forbidden', reason: 'outside-view-root' }));
634
+ return;
635
+ }
542
636
  writeFileSync(targetReal, content, 'utf-8');
543
637
  const stat = statSync(targetReal);
544
638
  res.writeHead(200, { 'Content-Type': 'application/json' });
@@ -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) {