cc-viewer 1.8.19 → 1.8.21

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 (130) hide show
  1. package/README.md +3 -1
  2. package/cli.js +33 -4
  3. package/dist/assets/App-BmZyzd9Q.js +2 -0
  4. package/dist/assets/App-DOZI5HME.css +1 -0
  5. package/dist/assets/{MdxEditorPanel-DU_5CRtW.js → MdxEditorPanel-BHvoYOF-.js} +1 -1
  6. package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
  7. package/dist/assets/Mobile-C10tOIQo.js +1 -0
  8. package/dist/assets/ProxyStatsModal-Do4qrHT7.js +1 -0
  9. package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
  10. package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
  11. package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
  12. package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
  13. package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
  14. package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
  15. package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
  16. package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
  17. package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
  18. package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
  19. package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
  20. package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
  21. package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
  22. package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
  23. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
  24. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
  25. package/dist/assets/clone-CFj55h49.js +1 -0
  26. package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
  27. package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
  28. package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
  29. package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
  30. package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
  31. package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
  32. package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
  33. package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
  34. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
  35. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
  36. package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
  37. package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
  38. package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
  39. package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
  40. package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
  41. package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
  42. package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
  43. package/dist/assets/index-DyVKoGOX.js +2 -0
  44. package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
  45. package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
  46. package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
  47. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
  48. package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
  49. package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
  50. package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
  51. package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
  52. package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
  53. package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
  54. package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
  55. package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
  56. package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
  57. package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
  58. package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
  59. package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
  60. package/dist/assets/seqResourceLoaders-B-wmkeam.js +2 -0
  61. package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
  62. package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
  63. package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
  64. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
  65. package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
  66. package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
  67. package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
  68. package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
  69. package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
  70. package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
  71. package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
  72. package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
  73. package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
  74. package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
  75. package/dist/index.html +5 -5
  76. package/package.json +1 -1
  77. package/server/interceptor.js +192 -24
  78. package/server/lib/ask/ask-store.js +7 -3
  79. package/server/lib/async-file-lock.js +29 -5
  80. package/server/lib/auth.js +71 -54
  81. package/server/lib/chat-queue.js +67 -4
  82. package/server/lib/config-backup.js +8 -5
  83. package/server/lib/credential-access.js +233 -0
  84. package/server/lib/credential-migrate.js +147 -0
  85. package/server/lib/credential-store.js +120 -0
  86. package/server/lib/credential-vault.js +112 -0
  87. package/server/lib/file-access-policy.js +16 -0
  88. package/server/lib/im/im-config.js +81 -31
  89. package/server/lib/im-deny.js +22 -8
  90. package/server/lib/json-store.js +264 -0
  91. package/server/lib/prefs-store.js +8 -33
  92. package/server/lib/same-origin.js +22 -0
  93. package/server/lib/session-pin-store.js +7 -14
  94. package/server/lib/v2/convert.js +1 -1
  95. package/server/lib/v2/layout.js +28 -3
  96. package/server/lib/v2/live-feed.js +41 -4
  97. package/server/lib/v2/resume-list.js +150 -0
  98. package/server/lib/v2/session-select.js +63 -14
  99. package/server/lib/v2/v2-writer.js +172 -34
  100. package/server/lib/v2/view-router.js +83 -0
  101. package/server/lib/view-root.js +209 -0
  102. package/server/pty-manager.js +800 -163
  103. package/server/routes/ask-perm.js +21 -1
  104. package/server/routes/auth.js +21 -1
  105. package/server/routes/events.js +123 -7
  106. package/server/routes/expert.js +46 -14
  107. package/server/routes/files-content.js +72 -7
  108. package/server/routes/files-fs.js +28 -3
  109. package/server/routes/git.js +31 -5
  110. package/server/routes/preferences.js +59 -46
  111. package/server/routes/project-meta.js +32 -3
  112. package/server/routes/proxy-stats.js +33 -5
  113. package/server/routes/resume.js +302 -0
  114. package/server/routes/search.js +23 -1
  115. package/server/routes/skills.js +18 -2
  116. package/server/routes/v2.js +6 -1
  117. package/server/routes/workspaces.js +10 -3
  118. package/server/server.js +199 -25
  119. package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
  120. package/server/workspace-registry.js +41 -35
  121. package/dist/assets/App-C9Tx6Iqw.js +0 -2
  122. package/dist/assets/App-M61pqQEx.css +0 -1
  123. package/dist/assets/Mobile--EECC49-.js +0 -1
  124. package/dist/assets/ProxyStatsModal-DJu0xq3Z.js +0 -1
  125. package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
  126. package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
  127. package/dist/assets/clone-B0_ghF3F.js +0 -1
  128. package/dist/assets/index-CJdRSHTP.js +0 -2
  129. package/dist/assets/seqResourceLoaders-BumAjQDW.css +0 -41
  130. package/dist/assets/seqResourceLoaders-C5UX6ecI.js +0 -2
@@ -7,11 +7,8 @@
7
7
  // callers serialize via withFileLockAsync's per-lockPath Promise chain; cross-process
8
8
  // callers mutex on the lock file. This prevents a concurrent writer from clobbering the
9
9
  // password-bearing file or losing a fork update.
10
- import { existsSync, readFileSync, writeFileSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
11
10
  import { join, dirname } from 'node:path';
12
- import { randomBytes } from 'node:crypto';
13
- import { renameSyncWithRetry } from './file-api.js';
14
- import { withFileLockAsync } from './async-file-lock.js';
11
+ import { mutateJson, readJsonSafe, writeJsonAtomic } from './json-store.js';
15
12
  import { mergeApprovalModalPrefs } from '@ccv/core/approval-modal-prefs';
16
13
  import { reconcileVoicePackPrefs } from './voice-pack-manager.js';
17
14
  import { LOG_DIR } from '../../findcc.js';
@@ -22,46 +19,24 @@ import { LOG_DIR } from '../../findcc.js';
22
19
  // override on the helpers below exists only so the preferences route can forward its
23
20
  // deps.getPrefsFile() seam (used by branch tests) and stay symmetric with the GET read.
24
21
  export function getPrefsFile() { return join(LOG_DIR, 'preferences.json'); }
25
- // Lock lives next to the file so every writer of the SAME preferences.json shares one lock
26
- // (global POST, fork ops, …). In prod all paths are canonical ⇒ one lock ⇒ serialized.
27
- function getPrefsLock(file) { return join(dirname(file), 'preferences.lock'); }
28
22
 
29
23
  /** Read the raw on-disk prefs object (no stripping, no virtual defaults). {} on miss/corrupt. */
30
24
  export function readPrefsRaw(file = getPrefsFile()) {
31
- try {
32
- if (!existsSync(file)) return {};
33
- const obj = JSON.parse(readFileSync(file, 'utf-8'));
34
- return obj && typeof obj === 'object' ? obj : {};
35
- } catch { return {}; }
36
- }
37
-
38
- /** Atomic write (tmp + rename) with 0600 — the file may carry the base64 password. */
39
- function writePrefsAtomic(prefs, file) {
40
- mkdirSync(dirname(file), { recursive: true });
41
- const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
42
- try {
43
- writeFileSync(tmp, JSON.stringify(prefs, null, 2), { mode: 0o600 });
44
- renameSyncWithRetry(tmp, file);
45
- // writeFileSync's mode only applies on creation; re-assert 0600 on a pre-existing file.
46
- try { chmodSync(file, 0o600); } catch { /* best-effort; non-POSIX or race */ }
47
- } catch (err) {
48
- try { unlinkSync(tmp); } catch {}
49
- throw err;
50
- }
25
+ return readJsonSafe(file, {});
51
26
  }
52
27
 
53
28
  /**
54
29
  * Locked read-modify-write. Reads the raw prefs inside the lock, runs mutator(prefs)
55
30
  * (mutate in place; may be async), atomically writes, and returns the mutator's return
56
31
  * value when defined, else the mutated prefs object. `file` defaults to the canonical path.
32
+ *
33
+ * Delegates to the unified json-store kernel (mutateJson): one async file lock derived from
34
+ * the file name + atomic tmp→rename at 0600. The lock is shared with the SYNC writers of the
35
+ * same file (auth.js / im-config.js via mutateJsonSync), so all preferences.json writers now
36
+ * mutex on the same `preferences.json.lock`.
57
37
  */
58
38
  export async function mutatePrefs(mutator, file = getPrefsFile()) {
59
- return withFileLockAsync(getPrefsLock(file), async () => {
60
- const prefs = readPrefsRaw(file);
61
- const result = await mutator(prefs);
62
- writePrefsAtomic(prefs, file);
63
- return result !== undefined ? result : prefs;
64
- }, { ensureDir: dirname(file) });
39
+ return mutateJson(file, mutator, { mode: 0o600, fallback: {}, ensureDir: dirname(file) });
65
40
  }
66
41
 
67
42
  /**
@@ -0,0 +1,22 @@
1
+ // Same-origin guard for sensitive browser-facing endpoints.
2
+ //
3
+ // Loopback identifies the socket peer, not the browser page that initiated a
4
+ // request. Since the server intentionally has permissive CORS for legacy APIs,
5
+ // sensitive endpoints (executable selection, process kill, ...) need their own
6
+ // same-origin guard stacked on the admin check: a cross-site page riding the
7
+ // user's browser (token cookie / ?token= URL) must not reach them, while
8
+ // CLI/native clients (no Origin header) and same-origin pages pass through.
9
+ //
10
+ // Canonical implementation — every call site imports from here.
11
+ export function isSameOriginBrowserRequest(req, parsedUrl) {
12
+ const origin = req.headers?.origin;
13
+ if (!origin) return true; // CLI/native clients do not send Origin.
14
+ if (req.headers?.['sec-fetch-site'] === 'cross-site') return false;
15
+ try {
16
+ const originUrl = new URL(origin);
17
+ if (parsedUrl?.origin) return originUrl.origin.toLowerCase() === parsedUrl.origin.toLowerCase();
18
+ return originUrl.host.toLowerCase() === String(req.headers?.host || '').toLowerCase();
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
@@ -8,10 +8,9 @@
8
8
  // '' means no active project (workspace mode not yet launched) → read = null / write = no-op.
9
9
  // Writes are atomic (tmp + rename) so a torn read can't happen when several writers race
10
10
  // (mirrors server/lib/prefs-store.js).
11
- import { existsSync, readFileSync, writeFileSync, mkdirSync, unlinkSync } from 'node:fs';
11
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs';
12
12
  import { join } from 'node:path';
13
- import { randomBytes } from 'node:crypto';
14
- import { renameSyncWithRetry } from './file-api.js';
13
+ import { writeJsonAtomic } from './json-store.js';
15
14
 
16
15
  /** Pin file path inside the project dir: `.session-pin.json`. */
17
16
  export function pinFilePath(logDir) {
@@ -32,8 +31,10 @@ export function readPin(logDir) {
32
31
 
33
32
  /**
34
33
  * Write (or clear) the pinned session id. No project (logDir = '') → no-op, returns false.
35
- * A null/empty pinnedSessionId deletes the file (back to "show latest"). Atomic tmp+rename.
36
- * Returns true on success, false on no-project / write failure (view state is best-effort).
34
+ * A null/empty pinnedSessionId deletes the file (back to "show latest"). Atomic via the
35
+ * json-store kernel (tmp + rename). Non-secret → mode:false (umask); the pin is a view
36
+ * preference, not a credential. Returns true on success, false on no-project / write
37
+ * failure (view state is best-effort).
37
38
  */
38
39
  export function writePin(logDir, pinnedSessionId) {
39
40
  if (!logDir) return false;
@@ -44,15 +45,7 @@ export function writePin(logDir, pinnedSessionId) {
44
45
  try { unlinkSync(file); } catch { /* already absent */ }
45
46
  return true;
46
47
  }
47
- mkdirSync(logDir, { recursive: true });
48
- const tmp = `${file}.tmp-${process.pid}-${randomBytes(4).toString('hex')}`;
49
- try {
50
- writeFileSync(tmp, JSON.stringify({ pinnedSessionId: id }));
51
- renameSyncWithRetry(tmp, file);
52
- } catch (err) {
53
- try { unlinkSync(tmp); } catch {}
54
- throw err;
55
- }
48
+ writeJsonAtomic(file, { pinnedSessionId: id }, { mode: false, pretty: false });
56
49
  return true;
57
50
  } catch { return false; }
58
51
  }
@@ -53,7 +53,7 @@ const STOP_CHECK_EVERY = 50; // entries between shouldStop() polls
53
53
  const PROXY_SHARD_NAME = /^proxy_\d{4}-\d{2}-\d{2}\.jsonl$/;
54
54
 
55
55
  /** Enumerate a project's v1 log files, ascending by filename timestamp.
56
- * Excludes `_temp.jsonl` (resume scratch) and `proxy_*.jsonl` (retry stats). */
56
+ * Excludes `_temp.jsonl` and `proxy_*.jsonl` (retry stats). */
57
57
  export function listV1Files(projectDir) {
58
58
  if (!existsSync(projectDir)) return [];
59
59
  return readdirSync(projectDir)
@@ -6,7 +6,7 @@
6
6
  // and the swallowed ENOENT would silently drop v2 lines (plan risk F2).
7
7
 
8
8
  import { mkdirSync, writeFileSync, renameSync, existsSync, openSync, writeSync, fsyncSync, closeSync, readdirSync, statSync } from 'node:fs';
9
- import { join } from 'node:path';
9
+ import { join, resolve } from 'node:path';
10
10
 
11
11
  // The wire-format version this build writes AND the only one it reads.
12
12
  // Stamped into meta.json and the journal sentinel at session creation; the
@@ -28,6 +28,29 @@ export function sanitizePathComponent(name) {
28
28
  return cleaned;
29
29
  }
30
30
 
31
+ /**
32
+ * Normalize a session's owning cwd for cross-dir same-basename comparison
33
+ * (2026-10-07). Lexical ONLY — `resolve` collapses `.`/`..` and duplicate
34
+ * separators without touching the filesystem, then a trailing-separator strip
35
+ * evens `/a/x` vs `/a/x/`. (On POSIX `resolve` already drops trailing slashes,
36
+ * so the strip is redundant there; it still guards Windows `\` and the root
37
+ * case.) Deliberately NOT realpath: the write side stamps the raw spawn cwd and
38
+ * the read side reads the live PTY record's raw cwd, and both are the SAME raw
39
+ * string — realpath would introduce a symlink-mismatch (/tmp → /private/tmp)
40
+ * where the two raw sides already agree byte-for-byte. Applied identically on
41
+ * the write (meta.cwd stamp), read-candidate, and read-target sides so the
42
+ * comparison stays symmetric across a respawn. Absolute cwds pass through
43
+ * deterministically; a relative cwd would resolve against process.cwd() (never
44
+ * occurs for a spawn cwd, which is always absolute).
45
+ * @param {string} cwd
46
+ * @returns {string} '' for empty/non-string input.
47
+ */
48
+ export function normalizeSessionCwd(cwd) {
49
+ if (!cwd || typeof cwd !== 'string') return '';
50
+ const out = resolve(cwd);
51
+ return out.length > 1 ? out.replace(/[\\/]+$/, '') : out;
52
+ }
53
+
31
54
  /** Session creation time (ISO) → a 14-digit LOCAL-time stamp `yyyymmddhhmmss`
32
55
  * for the session dir-name prefix (task C). Deliberately NO internal separator
33
56
  * (unlike log-management's `compactLocalTs`, which emits `YYYYMMDD_HHMMSS`) so
@@ -122,8 +145,10 @@ export function writeFileAtomicSync(finalPath, data) {
122
145
  * targeting this session. Returns the paths object.
123
146
  *
124
147
  * meta: { wireFormat:2, sessionId, project, pid, startTs, userIdRaw,
125
- * userIdEncoding, leader?, im? } — spec §3. Existing meta is never
126
- * rewritten here (later additive updates go through updateMetaSync).
148
+ * userIdEncoding, leader?, im?, cwd? } — spec §3. Existing meta is never
149
+ * rewritten here (later additive updates go through updateMetaSync). `cwd` is
150
+ * the session's owning full working directory (same-name isolation, 2026-10-07);
151
+ * absent for legacy/external producers.
127
152
  */
128
153
  export function ensureSessionDirSync(logDir, project, sessionId, meta = {}, sessionsDirName = SESSIONS_DIR_NAME, dirName = sessionId) {
129
154
  // Path uses dirName (`<ts>_<uuid>` for live, task C); IDENTITY (meta.sessionId
@@ -183,6 +183,14 @@ export class V2LiveFeed {
183
183
  // row (v2_requests_delta). Explicit ctor param — this module has no access
184
184
  // to the server deps object.
185
185
  this._wireV3 = !!opts.wireV3;
186
+ // Per-project view layer (2026-10): this feed's project name + an optional
187
+ // client filter. The server runs ONE feed per project (bound + any viewed);
188
+ // each broadcast must reach only the clients whose viewed project matches
189
+ // THIS feed's project. `clientFilter(clients, project)` returns the subset;
190
+ // the default (no filter) is the legacy broadcast-to-all behavior, so the
191
+ // single-project case is byte-identical.
192
+ this._project = typeof opts.project === 'string' ? opts.project : '';
193
+ this._clientFilter = typeof opts.clientFilter === 'function' ? opts.clientFilter : null;
186
194
  this._sessions = new Map(); // dir → cursor bundle
187
195
  this._seenDirs = new Map(); // dir → last observed journal mtimeMs (attached or not)
188
196
  this._discardGated = new Set(); // dirs refused by the discard gate — first real attach must NOT suppress history
@@ -193,6 +201,29 @@ export class V2LiveFeed {
193
201
  this._active = false;
194
202
  }
195
203
 
204
+ /** Clients this feed's broadcasts should reach right now: the full list, or
205
+ * the per-project filtered subset when a filter is installed. */
206
+ _clientsFor() {
207
+ return this._clientFilter ? this._clientFilter(this._clients, this._project) : this._clients;
208
+ }
209
+
210
+ /**
211
+ * Instance-scoped variant (multi-instance, 2026-10-06): narrow the per-project clients to
212
+ * those viewing the SAME instance as this session. Two concurrent same-cwd instances share
213
+ * one project feed, so this keeps instance A's entries out of instance B's viewers.
214
+ * Backward compatible: a client with NO `_ccvViewInstance` stamp (the default / legacy /
215
+ * single-instance view) sees every session regardless of `sessionInstance`; a session with
216
+ * no stamped instance (legacy/external) is likewise visible to all.
217
+ */
218
+ _clientsForInstance(sessionInstance) {
219
+ const base = this._clientsFor();
220
+ if (!sessionInstance) return base; // session carries no instance → visible to all viewers
221
+ let anyInstanceViewer = false;
222
+ for (const c of base) { if (c && c._ccvViewInstance) { anyInstanceViewer = true; break; } }
223
+ if (!anyInstanceViewer) return base; // nobody is instance-scoped → keep legacy broadcast
224
+ return base.filter((c) => !c || !c._ccvViewInstance || c._ccvViewInstance === sessionInstance);
225
+ }
226
+
196
227
  /** Begin (or switch to) live-following a project's sessions/ root. */
197
228
  start(projectDir) {
198
229
  this.stop();
@@ -374,9 +405,14 @@ export class V2LiveFeed {
374
405
  // 4GB main-thread OOM). Seek their cursors to EOF instead: nothing is
375
406
  // read, cloned, or broadcast; the cold-load channel owns their history.
376
407
  let convertOrigin = false;
408
+ let sessionInstance = null;
377
409
  try {
378
410
  const meta = JSON.parse(readFileSync(join(dir, 'meta.json'), 'utf-8'));
379
411
  convertOrigin = !!meta && meta.origin === 'convert';
412
+ // Multi-instance (2026-10-06): the owning PTY instanceKey stamped on meta.json (by the
413
+ // writer) lets this feed route the session's entries to exactly the clients viewing
414
+ // THAT instance of a same-cwd project. Absent for legacy/convert/external sessions.
415
+ sessionInstance = (meta && typeof meta.instance === 'string' && meta.instance) ? meta.instance : null;
380
416
  } catch (err) {
381
417
  // meta.json is written synchronously BEFORE the journal's first async
382
418
  // drain (ensureSessionDirSync), so journal-present + meta-ABSENT is
@@ -409,6 +445,7 @@ export class V2LiveFeed {
409
445
  dirty: false,
410
446
  suppress: !!suppressExisting,
411
447
  convertOrigin,
448
+ sessionInstance,
412
449
  // Journal req seqs with no done yet. The journal is only touched at
413
450
  // request start and completion, so a single >10min request would look
414
451
  // "idle" to the mtime-based eviction and get detached mid-flight — its
@@ -611,7 +648,7 @@ export class V2LiveFeed {
611
648
  try {
612
649
  processWatchedEntry(parsed, {
613
650
  reconstructor: cur.reconstructor,
614
- clients: this._clients,
651
+ clients: this._clientsForInstance(cur.sessionInstance),
615
652
  getClaudePid: this._getClaudePid,
616
653
  runParallelHook: this._runParallelHook,
617
654
  suppressEntryBroadcast: this._wireV3,
@@ -648,7 +685,7 @@ export class V2LiveFeed {
648
685
  ? `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"channel":${JSON.stringify(channel)},"line":${raw}}`
649
686
  : `{"sessionId":${JSON.stringify(cur.synth.sessionId)},"line":${raw}}`;
650
687
  const eventName = kind === 'conv' ? 'v3_conv' : 'v3_resp';
651
- sendEventRawToClients(this._clients, eventName, payload);
688
+ sendEventRawToClients(this._clientsForInstance(cur.sessionInstance), eventName, payload);
652
689
  } catch (err) {
653
690
  reportSwallowed('v2-live.native-forward', err);
654
691
  }
@@ -721,12 +758,12 @@ export class V2LiveFeed {
721
758
  const corrected = withAgentNameSubType(classifyRequest(prev.entry, parsed), prev.entry);
722
759
  if (JSON.stringify(corrected) !== JSON.stringify(prev.row.typeTag)) {
723
760
  prev.row.typeTag = corrected;
724
- sendEventToClients(this._clients, 'v2_requests_delta', prev.row);
761
+ sendEventToClients(this._clientsForInstance(cur.sessionInstance), 'v2_requests_delta', prev.row);
725
762
  }
726
763
  } catch (err) { reportSwallowed('v2-live.row-classify', err); }
727
764
  }
728
765
  cur._v3Last = { row, entry: parsed };
729
- sendEventToClients(this._clients, 'v2_requests_delta', row);
766
+ sendEventToClients(this._clientsForInstance(cur.sessionInstance), 'v2_requests_delta', row);
730
767
  }
731
768
 
732
769
  // ── safety poll ────────────────────────────────────────────────────────────
@@ -0,0 +1,150 @@
1
+ // Per-project "recent sessions" list for the /resume hover list (2026-10).
2
+ //
3
+ // Serves the N most-recent sessions of ONE project (the currently-bound one),
4
+ // for the header's current-project hover dropdown. Kept as a pure lib function
5
+ // (no HTTP, no interceptor state) so it is unit-testable with a temp LOG_DIR and
6
+ // an injectable `now` seam — same test posture as listV2LogsPage / deleteLogFiles.
7
+ //
8
+ // Scope: ONLY ccv's own v2 store is listed (sessions ccv actually proxied).
9
+ //
10
+ // Cost shape: a CHEAP meta.json pass per session (startTs + leader), plus a
11
+ // bounded journal head-scan (sessionHasMainOrTeammateReq) that drops quota-probe
12
+ // orphan dirs BEFORE they can occupy a top slot. Candidates are sorted
13
+ // newest-first, sliced to the top `limit`, and only THEN run the EXPENSIVE
14
+ // summarize (journal fold + dir walk + prompts head) via summarizeSessionPage —
15
+ // which shares listV2Sessions' freshness-keyed row cache, so repeat hover-opens
16
+ // are ~1-3ms. Fields the cheap pass cannot supply (sessionUuid from
17
+ // meta.sessionId, running, pinnedId) are read fresh per call for the surviving
18
+ // rows only, never from the cached row.
19
+
20
+ import { existsSync, readFileSync, statSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { sanitizePathComponent } from './layout.js';
23
+ import { listSessionIds } from './replay.js';
24
+ import { summarizeSessionPage } from './session-list.js';
25
+ import { sessionHasMainOrTeammateReq } from './session-select.js';
26
+ import { readPin } from '../session-pin-store.js';
27
+ import { LIVE_SESSION_MTIME_MS } from '../log-file-utils.js';
28
+ import { reportSwallowed } from '@ccv/core/error-report';
29
+
30
+ /**
31
+ * Liveness heuristic for the "running" placeholder column. A session is
32
+ * treated as possibly-live when its journal was appended within
33
+ * LIVE_SESSION_MTIME_MS. Coarse by design (a background-but-idle >5min session
34
+ * reads as not-running) — this is a display hint only. `now` is injectable for tests.
35
+ * @param {string} sessionDir
36
+ * @param {number} now - ms epoch
37
+ * @returns {boolean}
38
+ */
39
+ function isRecentlyActive(sessionDir, now) {
40
+ try {
41
+ const st = statSync(join(sessionDir, 'journal.jsonl'));
42
+ return (now - st.mtimeMs) < LIVE_SESSION_MTIME_MS;
43
+ } catch { return false; }
44
+ }
45
+
46
+ /**
47
+ * List the `limit` most-recent v2 sessions of ONE project under `logDir`,
48
+ * newest-first. Row shape:
49
+ * { source:'ccv', project, file, sessionUuid, startTs, turns, preview, running, pinnedId }
50
+ * - `project` — the LOG_DIR subdirectory name (already sanitize-safe).
51
+ * - `file` — v2 addressing token `v2:<project>/<dirName>` (dirName is
52
+ * `<ts>_<uuid>` or legacy `<uuid>`), for the existing
53
+ * open-log / live-tail paths.
54
+ * - `sessionUuid` — the Claude Code session UUID from meta.sessionId (NOT the
55
+ * dir name), the key the client uses to scope a `?sid=` view.
56
+ * - `pinnedId` — the project's persisted pin value verbatim (readPin), a
57
+ * ts-based display id. The SERVER cannot map it to this row
58
+ * (the ts stable id is a frontend runtime _timestamp), so the
59
+ * row just carries it through; the CLIENT decides `selected`
60
+ * by comparing against its own session stable ids.
61
+ * - `running` — coarse liveness placeholder (see isRecentlyActive).
62
+ *
63
+ * @param {string} logDir - LOG_DIR root (e.g. ~/.claude/cc-viewer).
64
+ * @param {object} [opts]
65
+ * @param {string} opts.project - the single project to list (required).
66
+ * @param {number} [opts.limit=10]
67
+ * @param {number} [opts.now] - ms epoch, injectable for tests.
68
+ * @returns {{ items: Array, total: number }}
69
+ */
70
+ export function listResumeSessions(logDir, { project, limit = 10, now = Date.now() } = {}) {
71
+ const out = { items: [], total: 0 };
72
+ if (!project || project !== sanitizePathComponent(project)) return out;
73
+
74
+ const projectDir = join(logDir, project);
75
+ if (!logDir || !existsSync(projectDir)) return out;
76
+
77
+ // ── ccv's own v2 store (sessions ccv proxied). Cheap meta.json pass per
78
+ // session for startTs + leader ordering/filtering, no journal fold.
79
+ const candidates = [];
80
+ let sessionIds;
81
+ try { sessionIds = listSessionIds(projectDir); } catch (err) {
82
+ reportSwallowed('resume-list.readdir', err, { project });
83
+ return out;
84
+ }
85
+ for (const dirName of sessionIds) {
86
+ let meta = null;
87
+ try { meta = JSON.parse(readFileSync(join(projectDir, 'sessions', dirName, 'meta.json'), 'utf-8')); } catch { /* journal is self-describing */ }
88
+ if (meta && meta.leader) continue; // teammate — folded into its leader's row
89
+ // Cheap discardable-session pre-check (bounded journal head-scan, early
90
+ // exit on the first main/teammate req): drops quota-probe orphan dirs
91
+ // BEFORE they can occupy a top-`limit` slot — without paying the full
92
+ // summarize (journal fold + dir walk) for every candidate. Same source
93
+ // predicate as session-list's isDiscardableSession (leader-absent + no
94
+ // main/teammate req), so a probe is filtered here and the summarize-time
95
+ // `discard` re-check below becomes a no-op for survivors.
96
+ if (!sessionHasMainOrTeammateReq(join(projectDir, 'sessions', dirName))) continue;
97
+ candidates.push({
98
+ project,
99
+ projectDir,
100
+ dirName,
101
+ startTs: (meta && meta.startTs) || '',
102
+ sessionUuid: (meta && meta.sessionId) || null,
103
+ });
104
+ }
105
+
106
+ // Order newest-first, then take the top `limit` BEFORE the expensive
107
+ // summarize (journal fold + dir walk + prompts head) — so a large history
108
+ // pays the summarize for at most `limit` rows, not for every session. Rows
109
+ // dropped by the summarize-time filters (teammate fold / quota-probe orphan /
110
+ // unreadable) leave their slot empty rather than being back-filled; such rows
111
+ // are rare and `limit` is small, so the occasional short list is accepted in
112
+ // exchange for the bounded cost.
113
+ candidates.sort((a, b) =>
114
+ b.startTs.localeCompare(a.startTs)
115
+ || String(b.dirName || '').localeCompare(String(a.dirName || '')));
116
+ const top = candidates.slice(0, limit);
117
+
118
+ const ccvRows = [];
119
+ for (const c of top) {
120
+ let s = null;
121
+ try { s = summarizeSessionPage(c.projectDir, c.dirName); } catch (err) {
122
+ reportSwallowed('resume-list.summarize', err, { project: c.project, dirName: c.dirName });
123
+ continue;
124
+ }
125
+ if (!s) continue;
126
+ if (s.leader) continue;
127
+ if (s.size === 0) continue;
128
+ if (s.discard) continue; // quota-probe orphans: never listed
129
+ const sessionDir = join(c.projectDir, 'sessions', c.dirName);
130
+ ccvRows.push({
131
+ source: 'ccv',
132
+ project: c.project,
133
+ file: `v2:${c.project}/${s.sid}`,
134
+ sessionUuid: c.sessionUuid,
135
+ startTs: s.startTs,
136
+ turns: s.turns,
137
+ preview: s.preview || [],
138
+ running: isRecentlyActive(sessionDir, now),
139
+ // The pin lives at the PROJECT dir and holds a ts-based display id, not a
140
+ // uuid — pass it through verbatim; the client maps it to a row.
141
+ pinnedId: readPin(c.projectDir),
142
+ });
143
+ }
144
+
145
+ // Already newest-first from the candidate sort (the summarize filter only
146
+ // drops rows, never reorders). total = surviving rows shown (≤ limit).
147
+ out.total = ccvRows.length;
148
+ out.items = ccvRows;
149
+ return out;
150
+ }
@@ -23,7 +23,7 @@ import { existsSync, readFileSync, openSync, readSync, closeSync } from 'node:fs
23
23
  import { join } from 'node:path';
24
24
  import { StringDecoder } from 'node:string_decoder';
25
25
  import { listSessionIds } from './replay.js';
26
- import { isSupportedWireFormat } from './layout.js';
26
+ import { isSupportedWireFormat, normalizeSessionCwd } from './layout.js';
27
27
  import { isForeignLiveOwned } from './session-owner.js';
28
28
 
29
29
  /**
@@ -261,12 +261,21 @@ export function isDiscardableSession(dir, meta) {
261
261
  * (getLiveLogSource fallback, _resolveAdoption) pass true; default false keeps
262
262
  * the raw picker semantics for everything else.
263
263
  * @param {string} projectDir - absolute LOG_DIR/<project>
264
- * @param {{excludeDir?: string, skipForeignLive?: boolean}} [opts]
264
+ * @param {{excludeDir?: string, skipForeignLive?: boolean, instanceKey?: string, targetCwd?: string}} [opts]
265
+ * `instanceKey` (multi-instance, 2026-10-06): when set, only consider session dirs whose
266
+ * meta.json `instance` matches it — so two concurrent same-cwd instances (sharing one
267
+ * basename project dir but distinct session dirs) each resolve to THEIR OWN latest session.
268
+ * Dirs with no `instance` (legacy / external producers) are EXCLUDED under this filter.
269
+ * `targetCwd` (same-name isolation, 2026-10-07): when set WITH `instanceKey`, scopes the
270
+ * cross-instance FALLBACK pool to sessions stamped with this cwd (or with NO meta.cwd —
271
+ * keep-missing). Applies ONLY to the fallback pool, never the instance filter; an empty
272
+ * value disables the cwd filter. No effect without `instanceKey`.
265
273
  * @returns {{dir:string, sessionId:string}|null}
266
274
  */
267
- export function latestMainSession(projectDir, { excludeDir = '', skipForeignLive = false } = {}) {
275
+ export function latestMainSession(projectDir, { excludeDir = '', skipForeignLive = false, instanceKey = '', targetCwd = '' } = {}) {
268
276
  if (!projectDir) return null;
269
- const candidates = []; // { dir, sessionId, startTs }
277
+ const instanceCandidates = []; // session dirs stamped with the requested instanceKey
278
+ const projectCandidates = []; // every eligible session dir of the project (any/no instance)
270
279
  for (const name of listSessionIds(projectDir)) {
271
280
  const dir = join(projectDir, 'sessions', name);
272
281
  if (excludeDir && dir === excludeDir) continue;
@@ -282,24 +291,64 @@ export function latestMainSession(projectDir, { excludeDir = '', skipForeignLive
282
291
  // folds in its siblings when rendered, so a teammate dir loaded directly
283
292
  // renders wrong. Keep only main (leader-less) sessions.
284
293
  if (meta.leader) continue;
285
- candidates.push({ dir, sessionId: meta.sessionId || '', startTs: meta.startTs || '' });
294
+ const cand = {
295
+ dir,
296
+ sessionId: meta.sessionId || '',
297
+ startTs: meta.startTs || '',
298
+ // Same-name isolation (2026-10-07): the session's owning cwd (absent on legacy
299
+ // dirs → null). Read once here from the already-parsed meta; used ONLY to scope
300
+ // the cross-instance fallback pool below, never the instance filter. Normalized
301
+ // with the SAME lexical normalizer the writer/read side use, so the comparison
302
+ // stays symmetric even when a raw cwd carries `.`/`..`/duplicate separators.
303
+ metaCwd: (typeof meta.cwd === 'string' && meta.cwd) ? normalizeSessionCwd(meta.cwd) : null,
304
+ };
305
+ projectCandidates.push(cand);
306
+ if (instanceKey && meta.instance === instanceKey) instanceCandidates.push(cand);
286
307
  }
287
- // Newest first, then return the first that actually has a main turn — skipping
288
- // a not-yet-activated newest session (the refresh-bug fix).
289
- candidates.sort((a, b) => (a.startTs < b.startTs ? 1 : a.startTs > b.startTs ? -1 : 0));
290
- for (const c of candidates) {
291
- if (sessionHasMainTurn(c.dir)) return { dir: c.dir, sessionId: c.sessionId };
308
+ const byNewest = (a, b) => (a.startTs < b.startTs ? 1 : a.startTs > b.startTs ? -1 : 0);
309
+ const newestWithMainTurn = (list) => {
310
+ const sorted = [...list].sort(byNewest);
311
+ for (const c of sorted) { if (sessionHasMainTurn(c.dir)) return c; }
312
+ return null;
313
+ };
314
+ // Multi-instance (2026-10-06): "show the instance's session when it has content, else fall
315
+ // back to the project's latest session WITH content" (user-pinned). An instance that exists
316
+ // but has only sub/system requests (no user main turn — a freshly-spawned same-cwd twin, or
317
+ // one rebuilt by a server restart) must NOT blank the view with its empty shell, and must
318
+ // NOT hard-error as "not found" either. Scope: instance-with-content → that; else → the
319
+ // project's newest content-bearing session (the history the user actually wants back).
320
+ if (instanceKey) {
321
+ const own = newestWithMainTurn(instanceCandidates);
322
+ if (own) return own;
323
+ // Same-name isolation (2026-10-07): scope the cross-instance fallback to the
324
+ // TARGET cwd so a cross-dir same-basename project sharing this pool can't be
325
+ // served. Keep-missing: legacy dirs with NO meta.cwd (null) stay eligible —
326
+ // excluding them would re-create the historical "excluded history" regression
327
+ // (sid-not-found / mis-detach). Same-cwd candidates always match, so same-cwd
328
+ // twin collaboration is unaffected. An empty targetCwd (instance dead / cwd
329
+ // unknown) disables the filter entirely — conservative, identical to today.
330
+ // This filters ONLY the fallback pool (projectCandidates), NEVER
331
+ // instanceCandidates above — an adopted/resumed dir's meta.cwd belongs to its
332
+ // original author and must not disqualify it from its own instance filter.
333
+ const fallbackPool = targetCwd
334
+ ? projectCandidates.filter((c) => c.metaCwd == null || c.metaCwd === targetCwd)
335
+ : projectCandidates;
336
+ const proj = newestWithMainTurn(fallbackPool);
337
+ if (proj) return proj;
338
+ // Nothing with a main turn anywhere: degrade to the instance's own newest dir (a truly
339
+ // fresh project) — still strictly this instance, never another's.
340
+ const ownAny = [...instanceCandidates].sort(byNewest)[0];
341
+ return ownAny || null;
292
342
  }
293
- return null;
343
+ return newestWithMainTurn(projectCandidates);
294
344
  }
295
345
 
296
346
  /**
297
347
  * Absolute dir of the newest readable, non-teammate session that HAS a main
298
348
  * turn, or '' if there is none. Thin wrapper over {@link latestMainSession}.
299
349
  * @param {string} projectDir - absolute LOG_DIR/<project>
300
- * @param {{excludeDir?: string, skipForeignLive?: boolean}} [opts] - see {@link latestMainSession}
350
+ * @param {{excludeDir?: string, skipForeignLive?: boolean, instanceKey?: string, targetCwd?: string}} [opts] - see {@link latestMainSession}
301
351
  * @returns {string} absolute session dir, or ''
302
352
  */
303
- export function latestMainSessionDir(projectDir, opts) {
304
- return latestMainSession(projectDir, opts)?.dir || '';
353
+ export function latestMainSessionDir(projectDir, opts) { return latestMainSession(projectDir, opts)?.dir || '';
305
354
  }