cc-viewer 1.6.322 → 1.6.324

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 (93) hide show
  1. package/cli.js +38 -0
  2. package/dist/assets/App-DxhvxOv5.js +2 -0
  3. package/dist/assets/{MdxEditorPanel-CZz9w38A.js → MdxEditorPanel-Cvce-OZ1.js} +1 -1
  4. package/dist/assets/Mobile-eQkx6wDh.js +1 -0
  5. package/dist/assets/{_baseUniq-CKn860hN.js → _baseUniq-Bd-sKzQW.js} +1 -1
  6. package/dist/assets/{arc-C4-SwDEU.js → arc-D_6rT0rI.js} +1 -1
  7. package/dist/assets/{architectureDiagram-Q4EWVU46-zNU8vGgf.js → architectureDiagram-Q4EWVU46-ClH0N8bK.js} +1 -1
  8. package/dist/assets/{blockDiagram-DXYQGD6D-Blq7VyAY.js → blockDiagram-DXYQGD6D-VBVbHV93.js} +1 -1
  9. package/dist/assets/{c4Diagram-AHTNJAMY-CilEO7Pu.js → c4Diagram-AHTNJAMY-CHd3FVhr.js} +1 -1
  10. package/dist/assets/{channel-K9LDJtR8.js → channel-DtAIDDMS.js} +1 -1
  11. package/dist/assets/{chunk-4BX2VUAB-DB6nsrqZ.js → chunk-4BX2VUAB-ldBMt7Zz.js} +1 -1
  12. package/dist/assets/{chunk-4TB4RGXK-O_q60rT5.js → chunk-4TB4RGXK-BpaavRFi.js} +1 -1
  13. package/dist/assets/{chunk-55IACEB6-D4PJNgqD.js → chunk-55IACEB6-CCQR_GqB.js} +1 -1
  14. package/dist/assets/{chunk-EDXVE4YY-T78XNZnN.js → chunk-EDXVE4YY-B94gGHJo.js} +1 -1
  15. package/dist/assets/{chunk-FMBD7UC4-BMajr_WJ.js → chunk-FMBD7UC4-D2pvMCHW.js} +1 -1
  16. package/dist/assets/{chunk-OYMX7WX6-BLXJEzI0.js → chunk-OYMX7WX6-BC1K8PFw.js} +1 -1
  17. package/dist/assets/{chunk-QZHKN3VN-C4ozCDaC.js → chunk-QZHKN3VN-BlKIHWPg.js} +1 -1
  18. package/dist/assets/{chunk-YZCP3GAM-xsMRpY5W.js → chunk-YZCP3GAM-CO46Dpuq.js} +1 -1
  19. package/dist/assets/classDiagram-6PBFFD2Q-BVr-1vS2.js +1 -0
  20. package/dist/assets/classDiagram-v2-HSJHXN6E-BVr-1vS2.js +1 -0
  21. package/dist/assets/clone-BLbDEtop.js +1 -0
  22. package/dist/assets/{cose-bilkent-S5V4N54A-BRm6sjuX.js → cose-bilkent-S5V4N54A-UW2sc3aR.js} +1 -1
  23. package/dist/assets/{dagre-KV5264BT-CapXy71H.js → dagre-KV5264BT-nawhI5CM.js} +1 -1
  24. package/dist/assets/{diagram-5BDNPKRD-CHaUXNHL.js → diagram-5BDNPKRD-BrLDz-85.js} +1 -1
  25. package/dist/assets/{diagram-G4DWMVQ6-YInXouX7.js → diagram-G4DWMVQ6-DDT86i03.js} +1 -1
  26. package/dist/assets/{diagram-MMDJMWI5-Bb9563xL.js → diagram-MMDJMWI5-BQMzmHLU.js} +1 -1
  27. package/dist/assets/{diagram-TYMM5635-BRYSGtnv.js → diagram-TYMM5635-BX7-PwHQ.js} +1 -1
  28. package/dist/assets/{erDiagram-SMLLAGMA-DiYsrHLg.js → erDiagram-SMLLAGMA-IwLVLNv0.js} +1 -1
  29. package/dist/assets/{flowDiagram-DWJPFMVM-CNZRDRgn.js → flowDiagram-DWJPFMVM-B8_X77WK.js} +1 -1
  30. package/dist/assets/{ganttDiagram-T4ZO3ILL-D9QY8NPb.js → ganttDiagram-T4ZO3ILL-DgeHpfKD.js} +1 -1
  31. package/dist/assets/{gitGraphDiagram-UUTBAWPF-R_yUkND4.js → gitGraphDiagram-UUTBAWPF-GImqd44T.js} +1 -1
  32. package/dist/assets/{graph-Bp7rzoPN.js → graph-BFNvFrIw.js} +1 -1
  33. package/dist/assets/{index-C2X1QxuP.js → index-CG43hSOO.js} +1 -1
  34. package/dist/assets/{index-skAVyRFx.js → index-CL43Q_T6.js} +1 -1
  35. package/dist/assets/index-Cw_igOCh.js +2 -0
  36. package/dist/assets/{index-5ATcFzQK.js → index-DH6neeAn.js} +1 -1
  37. package/dist/assets/{index-FjGT9Yrp.js → index-DYZfyIFS.js} +1 -1
  38. package/dist/assets/{index-CQm1mKh-.js → index-DqxWEMZG.js} +1 -1
  39. package/dist/assets/{index-B6uEt4Lm.js → index-SMWE-umF.js} +1 -1
  40. package/dist/assets/{index-D7TrcLgR.js → index-Zcwywgin.js} +1 -1
  41. package/dist/assets/{infoDiagram-42DDH7IO-D7SIo5T_.js → infoDiagram-42DDH7IO-DafQ4Zwr.js} +1 -1
  42. package/dist/assets/{ishikawaDiagram-UXIWVN3A-D1w7_hBw.js → ishikawaDiagram-UXIWVN3A-CsS59UI8.js} +1 -1
  43. package/dist/assets/{journeyDiagram-VCZTEJTY-D2ZcdIwf.js → journeyDiagram-VCZTEJTY-C8OZZC2d.js} +1 -1
  44. package/dist/assets/{jszip.min-BhyPZTcl.js → jszip.min-DWTs-GDW.js} +1 -1
  45. package/dist/assets/{kanban-definition-6JOO6SKY-BtXrBplk.js → kanban-definition-6JOO6SKY-BThryIIr.js} +1 -1
  46. package/dist/assets/{layout-l4xJ0EnV.js → layout-B2c4ocGM.js} +1 -1
  47. package/dist/assets/{linear-BFgL1PM1.js → linear-whLrOj6w.js} +1 -1
  48. package/dist/assets/{mermaid.core-CBJGZX-R.js → mermaid.core-CVxcxPSX.js} +2 -2
  49. package/dist/assets/{min-D7RE9cIU.js → min-DcYONCON.js} +1 -1
  50. package/dist/assets/{mindmap-definition-QFDTVHPH-B2jur7ry.js → mindmap-definition-QFDTVHPH-B7cxL7F7.js} +1 -1
  51. package/dist/assets/{pieDiagram-DEJITSTG-B249cLkg.js → pieDiagram-DEJITSTG-C5W3T882.js} +1 -1
  52. package/dist/assets/{quadrantDiagram-34T5L4WZ-CdZHycc8.js → quadrantDiagram-34T5L4WZ-DOZS2aRU.js} +1 -1
  53. package/dist/assets/{requirementDiagram-MS252O5E-BezSNLdn.js → requirementDiagram-MS252O5E-C_ZtyqTm.js} +1 -1
  54. package/dist/assets/{sankeyDiagram-XADWPNL6-C-RZnw4b.js → sankeyDiagram-XADWPNL6-DImjFcUx.js} +1 -1
  55. package/dist/assets/{seqResourceLoaders-R12WGANd.css → seqResourceLoaders-D6YenrBg.css} +1 -1
  56. package/dist/assets/seqResourceLoaders-sKqNZgOI.js +2 -0
  57. package/dist/assets/{sequenceDiagram-FGHM5R23-Fs0rcjkY.js → sequenceDiagram-FGHM5R23-BQwGiH06.js} +1 -1
  58. package/dist/assets/{stateDiagram-FHFEXIEX-CIcdyOPX.js → stateDiagram-FHFEXIEX-D8zGNMAW.js} +1 -1
  59. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BK2tAdaI.js → stateDiagram-v2-QKLJ7IA2-DY86eLVa.js} +1 -1
  60. package/dist/assets/{timeline-definition-GMOUNBTQ-BXg7hZyc.js → timeline-definition-GMOUNBTQ-LfDkNw95.js} +1 -1
  61. package/dist/assets/{vendor-antd-BSgMQtAG.js → vendor-antd-x2KtHa4R.js} +1 -1
  62. package/dist/assets/{vendor-codemirror-rm3PGybQ.js → vendor-codemirror-z6VSi4ab.js} +1 -1
  63. package/dist/assets/{vendor-mdxeditor-UIfRXbTk.js → vendor-mdxeditor-Zi3mchEB.js} +2 -2
  64. package/dist/assets/{vendor-qrcode-BiBG2gg5.js → vendor-qrcode-Dtu30dr9.js} +1 -1
  65. package/dist/assets/{vendor-virtuoso-DNNzcVZ0.js → vendor-virtuoso-BhHaLmft.js} +1 -1
  66. package/dist/assets/{vennDiagram-DHZGUBPP-IyEu5-2i.js → vennDiagram-DHZGUBPP-DRRBdcoR.js} +1 -1
  67. package/dist/assets/{wardley-RL74JXVD-BEUuSlqC.js → wardley-RL74JXVD-DtWPL3Ou.js} +1 -1
  68. package/dist/assets/{wardleyDiagram-NUSXRM2D-Cv-nt0oQ.js → wardleyDiagram-NUSXRM2D-CDGkjbzC.js} +1 -1
  69. package/dist/assets/{xychartDiagram-5P7HB3ND-B4QuJzX1.js → xychartDiagram-5P7HB3ND-drrUfgKK.js} +1 -1
  70. package/dist/index.html +4 -4
  71. package/package.json +4 -4
  72. package/server/i18n.js +62 -2
  73. package/server/interceptor.js +32 -9
  74. package/server/lib/auth.js +20 -5
  75. package/server/lib/instance-registry.js +57 -0
  76. package/server/lib/interceptor-core.js +53 -5
  77. package/server/lib/log-management.js +57 -25
  78. package/server/lib/prefs-store.js +83 -0
  79. package/server/lib/project-prefs.js +94 -0
  80. package/server/lib/session-pin-store.js +67 -0
  81. package/server/routes/logs.js +5 -2
  82. package/server/routes/preferences.js +62 -27
  83. package/server/routes/project-meta.js +3 -2
  84. package/server/routes/project-prefs.js +132 -0
  85. package/server/routes/session-pin.js +41 -0
  86. package/server/server.js +21 -0
  87. package/dist/assets/App-CafPuJo7.js +0 -2
  88. package/dist/assets/Mobile-CA6pLj7l.js +0 -1
  89. package/dist/assets/classDiagram-6PBFFD2Q-BHoVlsUt.js +0 -1
  90. package/dist/assets/classDiagram-v2-HSJHXN6E-BHoVlsUt.js +0 -1
  91. package/dist/assets/clone-BdK-5KXX.js +0 -1
  92. package/dist/assets/index-BPWe3-MC.js +0 -2
  93. package/dist/assets/seqResourceLoaders-BZN9aWp8.js +0 -2
@@ -0,0 +1,94 @@
1
+ // Per-project preference forks — pure helpers around the `prefsByProject` map inside
2
+ // preferences.json. Mirrors the per-project scheme already used by lib/auth.js
3
+ // (authByProject): keyed by the FULL project directory so two projects that share a
4
+ // basename ("~/work/app" vs "~/play/app") never collapse into one fork.
5
+ //
6
+ // preferences.json:
7
+ // prefsByProject: { "<projectDir>": { ...forkedPrefs } }
8
+ //
9
+ // A project "has a fork" iff a key exists for it (hasOwnProperty). Removing a fork
10
+ // (toggle off) deletes the key so the project inherits global again.
11
+ import { basename } from 'node:path';
12
+ import { mergeApprovalModalPrefs } from './approval-modal-prefs.js';
13
+ import { listPlatforms } from './im-config.js';
14
+ import { _projectName } from '../interceptor.js';
15
+
16
+ // SINGLE SOURCE of "what must never live inside a fork": secrets (auth password + IM creds),
17
+ // machine-level paths (logDir/claudeConfigDir), and the internal forks map. snapshotForFork /
18
+ // cleanPatch / the admin list read all funnel through stripSensitive so the rule can't drift.
19
+ const STRIP_KEYS = ['auth', 'authByProject', 'prefsByProject', 'logDir', 'claudeConfigDir'];
20
+
21
+ /** Strip secrets + IM credentials + machine-level keys from a prefs-shaped object in place. */
22
+ export function stripSensitive(obj) {
23
+ if (!obj || typeof obj !== 'object') return obj;
24
+ for (const k of STRIP_KEYS) delete obj[k];
25
+ for (const id of listPlatforms()) delete obj[id];
26
+ return obj;
27
+ }
28
+
29
+ /** Full project dir used as the fork key — same scheme as server.js AUTH_PROJECT. */
30
+ export function getCurrentProjectKey() {
31
+ return process.env.CCV_PROJECT_DIR || process.cwd();
32
+ }
33
+
34
+ /** Human-readable project name for display (interceptor's sanitized basename). */
35
+ export function getCurrentProjectName() {
36
+ return _projectName || basename(getCurrentProjectKey());
37
+ }
38
+
39
+ /**
40
+ * Whether the server is currently serving a concrete project. In workspace mode the
41
+ * project is unknown until a workspace is launched (interceptor _projectName / env are
42
+ * empty) — refuse to fork under the server's own cwd in that window.
43
+ */
44
+ export function hasActiveProject() {
45
+ if (process.env.CCV_WORKSPACE_MODE === '1') {
46
+ return !!(process.env.CCV_PROJECT_DIR || _projectName);
47
+ }
48
+ return true;
49
+ }
50
+
51
+ /** Does `key` have a fork? (key present, regardless of contents) — mirror auth.hasOverride. */
52
+ export function hasFork(prefs, key) {
53
+ return !!(key && prefs && prefs.prefsByProject &&
54
+ Object.prototype.hasOwnProperty.call(prefs.prefsByProject, key));
55
+ }
56
+
57
+ /** List of project dirs that currently have a fork. */
58
+ export function listForks(prefs) {
59
+ return (prefs && prefs.prefsByProject && typeof prefs.prefsByProject === 'object')
60
+ ? Object.keys(prefs.prefsByProject)
61
+ : [];
62
+ }
63
+
64
+ /**
65
+ * Curated snapshot of the current GLOBAL prefs to seed a new fork. Deep-copied from the
66
+ * RAW file object (not a GET payload — avoids baking the resumeAutoChoice='continue'
67
+ * virtual default), with secrets / machine-level / internal / `_`-meta keys removed.
68
+ */
69
+ export function snapshotForFork(prefsRaw) {
70
+ const copy = JSON.parse(JSON.stringify(prefsRaw || {}));
71
+ for (const k of Object.keys(copy)) if (k[0] === '_') delete copy[k]; // 元字段
72
+ return stripSensitive(copy); // 密码/IM/机器级/forks 单一来源剥离
73
+ }
74
+
75
+ /**
76
+ * Build the effective prefs a scoped (non-loopback) client should see: fork values win
77
+ * over global for scalars, approvalModal deep-merged (fork over global). Keys absent from
78
+ * the fork fall through to global so newly-added global settings stay forward-compatible.
79
+ * Machine-level fields (logDir/claudeConfigDir) are NOT handled here — the GET handler
80
+ * stamps them after this runs.
81
+ */
82
+ export function resolveScoped(globalPrefs, fork) {
83
+ const { approvalModal: gAM, ...gRest } = globalPrefs || {};
84
+ const { approvalModal: fAM, ...fRest } = fork || {};
85
+ const merged = { ...gRest, ...fRest };
86
+ if (gAM || fAM) {
87
+ // 防引用别名:fork 无 approvalModal 时也返回浅拷贝,避免调用方(preferencesGet 的
88
+ // voicePack reconcile)回写到解析出的全局对象(当前每请求新解析虽无害,仍保持"绝不外泄内部引用")。
89
+ merged.approvalModal = fAM
90
+ ? mergeApprovalModalPrefs(gAM, fAM, {})
91
+ : { ...gAM };
92
+ }
93
+ return merged;
94
+ }
@@ -0,0 +1,67 @@
1
+ // Server-side "current session" pin store, per project (+ optional instance id).
2
+ //
3
+ // The "仅展示当前会话" view filter needs a single "current session pointer". It used to
4
+ // live in the browser's localStorage (keyed by project name) — so two devices hitting the
5
+ // SAME ccv process diverged. We move it server-side: one tiny JSON per project, optionally
6
+ // suffixed by an instance id (`--pid`) so multiple ccv processes of one project can isolate.
7
+ //
8
+ // `logDir` here is the PER-PROJECT directory (interceptor._logDir = LOG_DIR/<projectName>);
9
+ // '' means no active project (workspace mode not yet launched) → read = null / write = no-op.
10
+ // Writes are atomic (tmp + rename) so a torn read can't happen when several writers race
11
+ // (mirrors server/lib/prefs-store.js).
12
+ import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { randomBytes } from 'node:crypto';
15
+ import { renameSyncWithRetry } from './file-api.js';
16
+
17
+ // Sanitize an instance id into a filesystem-safe token (same charset as projectName).
18
+ // Returns '' for empty/falsy → caller treats it as the no-instance (shared) key.
19
+ export function sanitizeInstanceId(instanceId) {
20
+ if (!instanceId || typeof instanceId !== 'string') return '';
21
+ return instanceId.replace(/[^a-zA-Z0-9_\-.]/g, '_');
22
+ }
23
+
24
+ /** Pin file path inside the project dir: `.session-pin.json` or `.session-pin.<id>.json`. */
25
+ export function pinFilePath(logDir, instanceId) {
26
+ const id = sanitizeInstanceId(instanceId);
27
+ return join(logDir, id ? `.session-pin.${id}.json` : '.session-pin.json');
28
+ }
29
+
30
+ /** Read the pinned session id. No project / missing file / corrupt → null. */
31
+ export function readPin(logDir, instanceId) {
32
+ if (!logDir) return null;
33
+ try {
34
+ const file = pinFilePath(logDir, instanceId);
35
+ if (!existsSync(file)) return null;
36
+ const obj = JSON.parse(readFileSync(file, 'utf-8'));
37
+ const v = obj && typeof obj === 'object' ? obj.pinnedSessionId : null;
38
+ return (typeof v === 'string' && v) ? v : null;
39
+ } catch { return null; }
40
+ }
41
+
42
+ /**
43
+ * Write (or clear) the pinned session id. No project (logDir = '') → no-op, returns false.
44
+ * A null/empty pinnedSessionId deletes the file (back to "show latest"). Atomic tmp+rename.
45
+ * Returns true on success, false on no-project / write failure (view state is best-effort).
46
+ */
47
+ export function writePin(logDir, instanceId, pinnedSessionId) {
48
+ if (!logDir) return false;
49
+ const file = pinFilePath(logDir, instanceId);
50
+ const id = (typeof pinnedSessionId === 'string' && pinnedSessionId) ? pinnedSessionId : null;
51
+ try {
52
+ if (id == null) {
53
+ try { unlinkSync(file); } catch { /* already absent */ }
54
+ return true;
55
+ }
56
+ mkdirSync(logDir, { recursive: true });
57
+ const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
58
+ try {
59
+ writeFileSync(tmp, JSON.stringify({ pinnedSessionId: id }));
60
+ renameSyncWithRetry(tmp, file);
61
+ } catch (err) {
62
+ try { unlinkSync(tmp); } catch {}
63
+ throw err;
64
+ }
65
+ return true;
66
+ } catch { return false; }
67
+ }
@@ -6,9 +6,12 @@ import { _projectName } from '../interceptor.js';
6
6
  import { listLocalLogs, deleteLogFiles, mergeLogFiles, archiveLogFiles, validateLogPath } from '../lib/log-management.js';
7
7
  import { countLogEntries, streamRawEntriesAsync, readTailEntries } from '../lib/log-stream.js';
8
8
 
9
- async function localLogs(req, res) {
9
+ async function localLogs(req, res, parsedUrl, isLocal, deps) {
10
10
  try {
11
- const result = await listLocalLogs(LOG_DIR, _projectName);
11
+ // 按当前实例 pid 硬隔离日志列表;`?all=1` 越过过滤看全部实例(顶部「显示全部」开关)。
12
+ const instanceId = (deps && deps.instanceId) || null;
13
+ const showAll = parsedUrl?.searchParams?.get('all') === '1';
14
+ const result = await listLocalLogs(LOG_DIR, _projectName, { instanceId, showAll });
12
15
  res.writeHead(200, { 'Content-Type': 'application/json' });
13
16
  res.end(JSON.stringify(result));
14
17
  } catch (err) {
@@ -1,15 +1,18 @@
1
1
  // Preferences, Claude settings, and proxy-profile routes (moved verbatim from server.js).
2
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs';
2
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
3
  import { join, dirname } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { LOG_DIR, setLogDir, getClaudeConfigDir } from '../../findcc.js';
6
6
  import { PROFILE_PATH, _defaultConfig, getActiveProfileId, setActiveProfileForWorkspace, _loadProxyProfile } from '../interceptor.js';
7
7
  import { setLang } from '../i18n.js';
8
8
  import { reconcileVoicePackPrefs as vpReconcile } from '../lib/voice-pack-manager.js';
9
- import { mergeApprovalModalPrefs as vpMergeAM } from '../lib/approval-modal-prefs.js';
10
9
  import { readClaudeProjectModel } from '../lib/context-watcher.js';
11
10
  import { sendEventToClients } from '../lib/log-watcher.js';
12
11
  import { listPlatforms } from '../lib/im-config.js';
12
+ import { mutatePrefs, applyPrefsPatch, readPrefsRaw } from '../lib/prefs-store.js';
13
+ import {
14
+ getCurrentProjectKey, getCurrentProjectName, hasFork, listForks, resolveScoped,
15
+ } from '../lib/project-prefs.js';
13
16
 
14
17
  // IM bridge configs (dingtalk, feishu, …) carry base64 app secrets and are only exposed via the
15
18
  // admin-only /api/dingtalk/* and /api/im/* surfaces (with secrets masked). Strip every platform's
@@ -35,6 +38,22 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
35
38
  delete prefs.auth;
36
39
  delete prefs.authByProject;
37
40
  stripImConfigs(prefs); // dingtalk / feishu / … — admin-only, never to a LAN client
41
+ // 项目独立配置(多人共用一台 server 时按项目隔离偏好):非本机(LAN)客户端若当前项目有 fork,
42
+ // 解析出该项目的有效偏好;本机(admin)始终看全局。forks blob 绝不下发,仅以 _projectPrefsKeys
43
+ // 元信息告知本机管理入口该不该出现。元字段以 _ 前缀标记,POST 侧会剥离不落盘。
44
+ const _projectKey = getCurrentProjectKey();
45
+ const _scoped = !isLocal && hasFork(prefs, _projectKey);
46
+ const _forkKeys = isLocal ? listForks(prefs) : null; // 计算需早于 delete prefsByProject
47
+ const _fork = _scoped ? prefs.prefsByProject[_projectKey] : null;
48
+ delete prefs.prefsByProject;
49
+ if (_scoped) {
50
+ prefs = resolveScoped(prefs, _fork);
51
+ // 防御性二次剥离:fork 按构造已不含密码/IM(写入侧已剥),但这是唯一把 fork 内容下发给远程
52
+ // 客户端的读路径,与 admin 列表口保持对称——手改 preferences.json 注入的脏 fork 也不会泄露。
53
+ delete prefs.auth;
54
+ delete prefs.authByProject;
55
+ stripImConfigs(prefs);
56
+ }
38
57
  prefs.logDir = LOG_DIR; // 始终返回当前运行时的日志目录
39
58
  // 日志设置出厂默认"继承":键缺失(从未设置过)才注入;显式关闭持久化的是 null(键存在),不覆盖。
40
59
  // 虚拟默认 —— 仅注入回包不落盘(GET 不写文件),直接读 preferences.json 的代码看不到该默认。
@@ -48,6 +67,12 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
48
67
  if (prefs.approvalModal?.voicePack) {
49
68
  prefs.approvalModal.voicePack = vpReconcile(LOG_DIR, prefs.approvalModal.voicePack);
50
69
  }
70
+ // 项目独立配置元信息(_ 前缀 = 仅回包、不落盘;POST 侧统一剥离):前端据此决定
71
+ // 非本机显示"启动项目独立配置"开关、本机在有 fork 时显示"配置管理"入口。
72
+ prefs._isLocal = isLocal;
73
+ prefs._projectName = getCurrentProjectName();
74
+ prefs._projectScoped = _scoped;
75
+ if (isLocal) prefs._projectPrefsKeys = _forkKeys;
51
76
  res.writeHead(200, { 'Content-Type': 'application/json' });
52
77
  res.end(JSON.stringify(prefs));
53
78
  }
@@ -55,43 +80,50 @@ function preferencesGet(req, res, parsedUrl, isLocal, deps) {
55
80
  function preferencesPost(req, res, parsedUrl, isLocal, deps) {
56
81
  let body = '';
57
82
  req.on('data', chunk => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
58
- req.on('end', () => {
83
+ req.on('end', async () => {
84
+ // 解析与持久化分两段 try:JSON 坏 → 400 'Invalid JSON';写盘/取锁失败 → 500/503(不再被误报成
85
+ // 400 让客户端以为是请求体问题、也不重试瞬时锁超时)。
86
+ let incoming;
87
+ try { incoming = JSON.parse(body); }
88
+ catch {
89
+ res.writeHead(400, { 'Content-Type': 'application/json' });
90
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
91
+ return;
92
+ }
59
93
  try {
60
- const incoming = JSON.parse(body);
61
94
  // auth 只能经 admin-only 的 /api/auth/config 修改;剥掉 incoming.auth 与 incoming.authByProject
62
95
  // 防止任意已授权客户端(含远程密码登录用户)借 /api/preferences 改开关/密码、或植入/篡改
63
96
  // 任意项目的覆盖,绕过 admin 门禁。
64
97
  delete incoming.auth;
65
98
  delete incoming.authByProject;
99
+ // prefsByProject(项目独立配置 fork)只能经 admin-only 的 /api/project-prefs/* 修改;元字段
100
+ // (_isLocal/_projectScoped/…)仅 GET 回包,绝不让它们经 /api/preferences 落盘污染。
101
+ delete incoming.prefsByProject;
102
+ for (const k of Object.keys(incoming)) if (k[0] === '_') delete incoming[k];
66
103
  // IM bridge configs 同理:只能经 admin-only 的 /api/dingtalk/config、/api/im/* 修改,禁止借
67
104
  // /api/preferences 植入凭据。
68
105
  stripImConfigs(incoming);
69
- // 如果修改了日志目录,先切换再保存到新位置(新目录下生成 preferences.json)
106
+ // 切日志目录:把旧文件的完整内容(含 auth 密码 / prefsByProject forks / 其它偏好)带到新位置,
107
+ // 避免切目录后密码与各项目 fork 凭空消失;并把写目标在 setLogDir 前后固定为"新文件",让本次
108
+ // 合并只跑在一个文件 / 一把锁上(不再因 LOG_DIR 中途漂移而劈裂进程内锁队列)。
109
+ let targetFile = deps.getPrefsFile();
110
+ let carried = null;
70
111
  if (incoming.logDir && typeof incoming.logDir === 'string') {
112
+ carried = readPrefsRaw(targetFile); // 旧文件全量(锁外读,迁移属罕见 admin 操作)
71
113
  setLogDir(incoming.logDir);
114
+ targetFile = deps.getPrefsFile();
72
115
  }
73
- let prefs = {};
74
- try { if (existsSync(deps.getPrefsFile())) prefs = JSON.parse(readFileSync(deps.getPrefsFile(), 'utf-8')); } catch { }
75
- // Deep-merge approvalModal so partial updates (e.g. `{ voicePack: { events: { askQuestion: id } } }`)
76
- // don't blow away unrelated approval prefs. Shallow Object.assign for everything else.
77
- const { approvalModal: incAM, ...incRest } = incoming;
78
- Object.assign(prefs, incRest);
79
- if (incAM && typeof incAM === 'object') {
80
- prefs.approvalModal = vpMergeAM(prefs.approvalModal, incAM, {
81
- reconcile: (vp) => vpReconcile(LOG_DIR, vp),
82
- });
83
- }
84
- // 确保目录存在
85
- const prefsFile = deps.getPrefsFile();
86
- const prefsDir = dirname(prefsFile);
87
- if (!existsSync(prefsDir)) mkdirSync(prefsDir, { recursive: true });
88
- writeFileSync(prefsFile, JSON.stringify(prefs, null, 2));
116
+ // 全局写入:locked + atomic(prefs-store),与 fork 写共用同一把锁,避免并发写互相覆盖含密码的
117
+ // preferences.json。Deep-merge approvalModal 的逻辑下沉到 applyPrefsPatch,与 /api/project-prefs
118
+ // fork 写共用一份合并实现,防止两路漂移。
119
+ const prefs = await mutatePrefs((p) => {
120
+ // 迁移:把旧文件里新文件尚无的键(auth/forks/其它偏好)带过来,再应用本次补丁
121
+ if (carried) for (const k of Object.keys(carried)) if (!(k in p)) p[k] = carried[k];
122
+ applyPrefsPatch(p, incoming, { logDir: LOG_DIR });
123
+ }, targetFile);
89
124
  // UI 切语言时同步服务端 i18n currentLang,让 DingTalk 桥接等服务端 t() 立即跟随。
90
125
  // setLang 自带 locale 校验,非法值回落 en。
91
126
  if (incoming.lang) setLang(incoming.lang);
92
- // preferences.json 可能携带 auth 的 base64 密码 —— 与 lib/auth.js writePrefs 一致地
93
- // 重申 0600,避免该路径(无 mode/不 chmod)把密码文件留成默认 umask 的可读权限。
94
- try { chmodSync(prefsFile, 0o600); } catch { /* best-effort; non-POSIX or race */ }
95
127
  // 主题切换时同步到 Claude Code CLI:发 /theme,监听输出验证结果。
96
128
  // 现代 CLI(≥2.x)的 /theme 是交互式选择器(args 被忽略),注入后对话框可能
97
129
  // 残留在终端:
@@ -132,15 +164,18 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
132
164
  // 已授权的远程客户端。磁盘上的值已在上面写入,这里只清内存对象供响应用。
133
165
  delete prefs.auth;
134
166
  delete prefs.authByProject;
167
+ delete prefs.prefsByProject; // fork blob 绝不回显(与 GET 一致)
135
168
  stripImConfigs(prefs);
136
169
  prefs.logDir = LOG_DIR;
137
170
  // 与 GET 一致:回显里补齐 resumeAutoChoice 虚拟默认(已在上方落盘,文件不含该默认值)
138
171
  if (!('resumeAutoChoice' in prefs)) prefs.resumeAutoChoice = 'continue';
139
172
  res.writeHead(200, { 'Content-Type': 'application/json' });
140
173
  res.end(JSON.stringify(prefs));
141
- } catch {
142
- res.writeHead(400, { 'Content-Type': 'application/json' });
143
- res.end(JSON.stringify({ error: 'Invalid JSON' }));
174
+ } catch (err) {
175
+ // 写盘/取锁失败:锁超时 503(瞬时,客户端可重试);其余持久化错误 → 500。绝不再用 400。
176
+ const isLockTimeout = /Lock acquisition timeout/.test(err?.message || '');
177
+ res.writeHead(isLockTimeout ? 503 : 500, { 'Content-Type': 'application/json' });
178
+ res.end(JSON.stringify({ error: 'Failed to save preferences' }));
144
179
  }
145
180
  });
146
181
  }
@@ -22,9 +22,10 @@ export function getInstallMethod({
22
22
  return 'npm';
23
23
  }
24
24
 
25
- function projectName(req, res) {
25
+ function projectName(req, res, parsedUrl, isLocal, deps) {
26
26
  res.writeHead(200, { 'Content-Type': 'application/json' });
27
- res.end(JSON.stringify({ projectName: _projectName || '' }));
27
+ // instanceId (`--pid`) lets the client scope its session-pin requests + show 项目(id) in the title.
28
+ res.end(JSON.stringify({ projectName: _projectName || '', instanceId: (deps && deps.instanceId) || null }));
28
29
  }
29
30
 
30
31
  function projectDir(req, res) {
@@ -0,0 +1,132 @@
1
+ // Per-project independent-preferences routes.
2
+ //
3
+ // Lets a project keep its own forked copy of the preferences (so several developers
4
+ // sharing one ccv over the LAN don't stomp each other's global prefs):
5
+ // - POST /api/project-prefs/toggle {enabled} — fork/unfork the CURRENT project
6
+ // - POST /api/project-prefs/update {project?, patch} — edit a fork (current, or any project from loopback)
7
+ // - POST /api/project-prefs/delete {project} — delete a fork (loopback admin only)
8
+ // - GET /api/project-prefs — list every fork + contents (loopback admin only)
9
+ //
10
+ // Forks never carry secrets (auth / IM creds are stripped on snapshot AND on read). All
11
+ // writes go through prefs-store.mutatePrefs (locked + atomic). Admin operations on an
12
+ // arbitrary project are loopback-gated server-side regardless of the client UI.
13
+ import { basename } from 'node:path';
14
+ import { LOG_DIR } from '../../findcc.js';
15
+ import { reconcileVoicePackPrefs as vpReconcile } from '../lib/voice-pack-manager.js';
16
+ import { mutatePrefs, applyPrefsPatch, readPrefsRaw } from '../lib/prefs-store.js';
17
+ import {
18
+ getCurrentProjectKey, hasActiveProject, snapshotForFork, stripSensitive,
19
+ } from '../lib/project-prefs.js';
20
+
21
+ function sendJson(res, code, obj) {
22
+ res.writeHead(code, { 'Content-Type': 'application/json' });
23
+ res.end(JSON.stringify(obj));
24
+ }
25
+
26
+ function readBody(req, deps) {
27
+ return new Promise((resolve, reject) => {
28
+ let body = '';
29
+ req.on('data', (chunk) => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
30
+ req.on('end', () => { try { resolve(body ? JSON.parse(body) : {}); } catch { reject(new Error('bad-json')); } });
31
+ req.on('error', reject);
32
+ });
33
+ }
34
+
35
+ /** Sanitize an incoming patch: no secrets, no machine-level paths, no internal/meta keys. */
36
+ function cleanPatch(patch) {
37
+ if (!patch || typeof patch !== 'object') return {};
38
+ const out = { ...patch };
39
+ for (const k of Object.keys(out)) if (k[0] === '_') delete out[k];
40
+ stripSensitive(out); // 含 auth/IM + logDir/claudeConfigDir + prefsByProject(单一来源)
41
+ return out;
42
+ }
43
+
44
+ // POST /api/project-prefs/toggle — fork (snapshot global) or unfork the current project.
45
+ // Scoped to the current project only (no privilege escalation), so allowed from any client.
46
+ async function toggleHandler(req, res, parsedUrl, isLocal, deps) {
47
+ let body;
48
+ try { body = await readBody(req, deps); } catch { return sendJson(res, 400, { error: 'Invalid JSON' }); }
49
+ if (!hasActiveProject()) return sendJson(res, 409, { error: 'no-active-project' });
50
+ const key = getCurrentProjectKey();
51
+ const enabled = !!body.enabled;
52
+ await mutatePrefs((p) => {
53
+ if (!p.prefsByProject || typeof p.prefsByProject !== 'object') p.prefsByProject = {};
54
+ if (enabled) {
55
+ if (!Object.prototype.hasOwnProperty.call(p.prefsByProject, key)) {
56
+ p.prefsByProject[key] = snapshotForFork(p);
57
+ }
58
+ } else if (Object.prototype.hasOwnProperty.call(p.prefsByProject, key)) {
59
+ delete p.prefsByProject[key];
60
+ }
61
+ if (Object.keys(p.prefsByProject).length === 0) delete p.prefsByProject;
62
+ });
63
+ sendJson(res, 200, { ok: true, projectScoped: enabled });
64
+ }
65
+
66
+ // POST /api/project-prefs/update — merge a patch into a fork. project omitted ⇒ current
67
+ // (any client, requires an existing fork); project given ⇒ loopback admin only.
68
+ async function updateHandler(req, res, parsedUrl, isLocal, deps) {
69
+ let body;
70
+ try { body = await readBody(req, deps); } catch { return sendJson(res, 400, { error: 'Invalid JSON' }); }
71
+ // 指定 project(改任意项目 fork)仅限本机;按"存在且为非空字符串"判定,避免 {project:""}/{project:{}}
72
+ // 绕过门禁或误入当前项目分支(与 deleteHandler 的类型校验对齐)。鉴权早于 cleanPatch。
73
+ const hasProject = typeof body.project === 'string' && body.project;
74
+ let key;
75
+ if (hasProject) {
76
+ if (!isLocal) return sendJson(res, 403, { error: 'forbidden' });
77
+ key = body.project;
78
+ } else {
79
+ if (!hasActiveProject()) return sendJson(res, 409, { error: 'no-active-project' });
80
+ key = getCurrentProjectKey();
81
+ }
82
+ const patch = cleanPatch(body.patch);
83
+ let found = false;
84
+ await mutatePrefs((p) => {
85
+ if (p.prefsByProject && Object.prototype.hasOwnProperty.call(p.prefsByProject, key)) {
86
+ applyPrefsPatch(p.prefsByProject[key], patch, { logDir: LOG_DIR });
87
+ found = true;
88
+ }
89
+ });
90
+ if (!found) return sendJson(res, 404, { error: 'no-fork' });
91
+ sendJson(res, 200, { ok: true });
92
+ }
93
+
94
+ // POST /api/project-prefs/delete — remove a fork. Loopback admin only.
95
+ async function deleteHandler(req, res, parsedUrl, isLocal, deps) {
96
+ if (!isLocal) return sendJson(res, 403, { error: 'forbidden' });
97
+ let body;
98
+ try { body = await readBody(req, deps); } catch { return sendJson(res, 400, { error: 'Invalid JSON' }); }
99
+ const key = body.project;
100
+ if (!key || typeof key !== 'string') return sendJson(res, 400, { error: 'project-required' });
101
+ await mutatePrefs((p) => {
102
+ if (p.prefsByProject && Object.prototype.hasOwnProperty.call(p.prefsByProject, key)) {
103
+ delete p.prefsByProject[key];
104
+ if (Object.keys(p.prefsByProject).length === 0) delete p.prefsByProject;
105
+ }
106
+ });
107
+ sendJson(res, 200, { ok: true });
108
+ }
109
+
110
+ // GET /api/project-prefs — every fork + contents (for the loopback management modal).
111
+ function listHandler(req, res, parsedUrl, isLocal, deps) {
112
+ if (!isLocal) return sendJson(res, 403, { error: 'forbidden' });
113
+ const prefs = readPrefsRaw();
114
+ const forks = (prefs && prefs.prefsByProject && typeof prefs.prefsByProject === 'object') ? prefs.prefsByProject : {};
115
+ const currentKey = getCurrentProjectKey();
116
+ const projects = {};
117
+ for (const [dir, fork] of Object.entries(forks)) {
118
+ const clean = stripSensitive(JSON.parse(JSON.stringify(fork || {})));
119
+ if (clean.approvalModal?.voicePack) {
120
+ clean.approvalModal.voicePack = vpReconcile(LOG_DIR, clean.approvalModal.voicePack);
121
+ }
122
+ projects[dir] = { name: basename(dir), dir, prefs: clean, isCurrent: dir === currentKey };
123
+ }
124
+ sendJson(res, 200, { projects });
125
+ }
126
+
127
+ export const projectPrefsRoutes = [
128
+ { method: 'GET', match: 'exact', path: '/api/project-prefs', handler: listHandler },
129
+ { method: 'POST', match: 'exact', path: '/api/project-prefs/toggle', handler: toggleHandler },
130
+ { method: 'POST', match: 'exact', path: '/api/project-prefs/update', handler: updateHandler },
131
+ { method: 'POST', match: 'exact', path: '/api/project-prefs/delete', handler: deleteHandler },
132
+ ];
@@ -0,0 +1,41 @@
1
+ // Server-side "current session" pin — replaces the old per-browser localStorage pin so a
2
+ // project's "仅展示当前会话" pointer is shared across every device hitting THIS ccv process,
3
+ // and (with `--pid`) isolated between processes. View state only → no loopback gating.
4
+ // - GET /api/session-pin → { pinnedSessionId, instanceId }
5
+ // - POST /api/session-pin {pinnedSessionId: string|null} → persist + SSE-broadcast
6
+ // `_logDir` is the live per-project dir ('' = no active project → GET null / POST no-op).
7
+ import { _logDir } from '../interceptor.js';
8
+ import { readPin, writePin } from '../lib/session-pin-store.js';
9
+ import { sendEventToClients } from '../lib/log-watcher.js';
10
+
11
+ function sendJson(res, code, obj) {
12
+ res.writeHead(code, { 'Content-Type': 'application/json' });
13
+ res.end(JSON.stringify(obj));
14
+ }
15
+
16
+ function getHandler(req, res, parsedUrl, isLocal, deps) {
17
+ const instanceId = deps.instanceId || null;
18
+ sendJson(res, 200, { pinnedSessionId: readPin(_logDir, instanceId), instanceId });
19
+ }
20
+
21
+ function postHandler(req, res, parsedUrl, isLocal, deps) {
22
+ let body = '';
23
+ req.on('data', (chunk) => { body += chunk; if (body.length > deps.MAX_POST_BODY) req.destroy(); });
24
+ req.on('end', () => {
25
+ let incoming;
26
+ try { incoming = body ? JSON.parse(body) : {}; }
27
+ catch { return sendJson(res, 400, { error: 'Invalid JSON' }); }
28
+ const raw = incoming.pinnedSessionId;
29
+ const pinnedSessionId = (typeof raw === 'string' && raw) ? raw : null;
30
+ writePin(_logDir, deps.instanceId || null, pinnedSessionId);
31
+ // Broadcast to THIS process's SSE clients so every device on this instance stays in sync.
32
+ sendEventToClients(deps.clients, 'session_pin', { pinnedSessionId });
33
+ sendJson(res, 200, { ok: true, pinnedSessionId });
34
+ });
35
+ req.on('error', () => { try { sendJson(res, 400, { error: 'read-error' }); } catch {} });
36
+ }
37
+
38
+ export const sessionPinRoutes = [
39
+ { method: 'GET', match: 'exact', path: '/api/session-pin', handler: getHandler },
40
+ { method: 'POST', match: 'exact', path: '/api/session-pin', handler: postHandler },
41
+ ];
package/server/server.js CHANGED
@@ -17,6 +17,8 @@ import { createDispatcher } from './routes/_dispatch.js';
17
17
  import { projectMetaRoutes } from './routes/project-meta.js';
18
18
  import { miscRoutes } from './routes/misc.js';
19
19
  import { preferencesRoutes } from './routes/preferences.js';
20
+ import { projectPrefsRoutes } from './routes/project-prefs.js';
21
+ import { sessionPinRoutes } from './routes/session-pin.js';
20
22
  import { gitRoutes } from './routes/git.js';
21
23
  import { pluginsRoutes } from './routes/plugins.js';
22
24
  import { logsRoutes } from './routes/logs.js';
@@ -70,6 +72,7 @@ function execWithStdin(cmd, args, input, options) {
70
72
  });
71
73
  }
72
74
  import { LOG_FILE, _initPromise, _resumeState, _projectName, _logDir, streamingState, resetStreamingState, PROFILE_PATH, setLivePort } from './interceptor.js';
75
+ import { recordInstance, listInstances } from './lib/instance-registry.js';
73
76
  import { LOG_DIR, setLogDir, getClaudeConfigDir } from '../findcc.js';
74
77
  import { t, getLang, setLang } from './i18n.js';
75
78
  import { loadAuthConfig, loadAuthState, saveAuthConfig, clearProjectOverride, generatePassword, decideAuth, parseCookies, renderLoginPage, localeFromAcceptLanguage } from './lib/auth.js';
@@ -105,6 +108,12 @@ try {
105
108
  const isCliMode = process.env.CCV_CLI_MODE === '1';
106
109
  const isSdkMode = process.env.CCV_SDK_MODE === '1';
107
110
  const isWorkspaceMode = process.env.CCV_WORKSPACE_MODE === '1';
111
+ // Instance id from `ccv --pid <name>` (sanitized in cli.js). null = default mode (no
112
+ // per-instance isolation): the session-pin file falls back to the project-shared key.
113
+ const INSTANCE_ID = process.env.CCV_INSTANCE_ID || null;
114
+ // Remember this id under the project so the CLI banner can list past ids next run (memory
115
+ // aid only). Fire-and-forget; no-ops when there's no active project (e.g. workspace mode).
116
+ if (INSTANCE_ID && _logDir) recordInstance(_logDir, INSTANCE_ID).catch(() => {});
108
117
  const _defaultProxyProfiles = { active: 'max', profiles: [{ id: 'max', name: 'Default' }] };
109
118
  const _maskApiKey = (k) => k && typeof k === 'string' && k.length > 4 ? '****' + k.slice(-4) : k ? '****' : '';
110
119
  const _maskProfiles = (data) => {
@@ -559,6 +568,7 @@ const deps = {
559
568
  isCliMode,
560
569
  isSdkMode,
561
570
  isWorkspaceMode,
571
+ instanceId: INSTANCE_ID,
562
572
  defaultProxyProfiles: _defaultProxyProfiles,
563
573
  };
564
574
 
@@ -572,6 +582,8 @@ const _routes = [
572
582
  ...projectMetaRoutes,
573
583
  ...miscRoutes,
574
584
  ...preferencesRoutes,
585
+ ...projectPrefsRoutes,
586
+ ...sessionPinRoutes,
575
587
  ...gitRoutes,
576
588
  ...pluginsRoutes,
577
589
  ...logsRoutes,
@@ -1909,6 +1921,15 @@ export function getAuthConfig() {
1909
1921
  return authConfig;
1910
1922
  }
1911
1923
 
1924
+ // Instance id (`--pid`) + the project's previously-used ids — printed by cli.js in the
1925
+ // CLI-mode startup banner so the user can recall which id to reuse with `-c` next time.
1926
+ export function getInstanceId() {
1927
+ return INSTANCE_ID;
1928
+ }
1929
+ export function getKnownInstances() {
1930
+ try { return listInstances(_logDir); } catch { return []; }
1931
+ }
1932
+
1912
1933
  // In-process broadcast helper for the `turn_end` SSE event. Two callers:
1913
1934
  // 1. /api/turn-end-notify POST handler (PTY/CLI mode via Stop hook bridge)
1914
1935
  // 2. cli.js runSdkMode → sdkManager.initSdkSession({ onTurnEnd }) (SDK mode —