cc-viewer 1.8.20 → 1.9.1

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 (114) hide show
  1. package/cli.js +33 -4
  2. package/dist/assets/App-DM-fbhNd.js +2 -0
  3. package/dist/assets/App-DOZI5HME.css +1 -0
  4. package/dist/assets/{MdxEditorPanel-Dpn-4ydO.css → MdxEditorPanel-DTfdj6GS.css} +1 -1
  5. package/dist/assets/{MdxEditorPanel-Dsnv2wIw.js → MdxEditorPanel-DjwOQDh0.js} +1 -1
  6. package/dist/assets/Mobile-BD-hefIH.js +1 -0
  7. package/dist/assets/ProxyStatsModal-pYa_0QVL.js +1 -0
  8. package/dist/assets/{_baseUniq-B3e4JaL0.js → _baseUniq-CwQykUuc.js} +1 -1
  9. package/dist/assets/{arc-DnoPn_qY.js → arc-EdUdBxtG.js} +1 -1
  10. package/dist/assets/{architectureDiagram-Q4EWVU46-DrPKTbxH.js → architectureDiagram-Q4EWVU46-DtBIdElY.js} +1 -1
  11. package/dist/assets/{blockDiagram-DXYQGD6D-CEaO0Vcw.js → blockDiagram-DXYQGD6D-guNZIKkZ.js} +1 -1
  12. package/dist/assets/{c4Diagram-AHTNJAMY-BLaawFeg.js → c4Diagram-AHTNJAMY-xV5vB-Z9.js} +1 -1
  13. package/dist/assets/{channel-DnYR1fF4.js → channel-DYEktNap.js} +1 -1
  14. package/dist/assets/{chunk-4BX2VUAB-Bfd4edKZ.js → chunk-4BX2VUAB-ZTkItpvj.js} +1 -1
  15. package/dist/assets/{chunk-4TB4RGXK-t2nMQmY7.js → chunk-4TB4RGXK-dYIewyyR.js} +1 -1
  16. package/dist/assets/{chunk-55IACEB6-C6tLOjGX.js → chunk-55IACEB6-CSsPNgPX.js} +1 -1
  17. package/dist/assets/{chunk-EDXVE4YY-dWCvxbCb.js → chunk-EDXVE4YY-CnMiuqzQ.js} +1 -1
  18. package/dist/assets/{chunk-FMBD7UC4-BHELZLPK.js → chunk-FMBD7UC4-CVpATPZ3.js} +1 -1
  19. package/dist/assets/{chunk-OYMX7WX6-C8XTkH1S.js → chunk-OYMX7WX6-Dpq4s8dW.js} +1 -1
  20. package/dist/assets/{chunk-QZHKN3VN-CD0j6SAs.js → chunk-QZHKN3VN-Bnojh6X6.js} +1 -1
  21. package/dist/assets/{chunk-YZCP3GAM-D4dKfXJJ.js → chunk-YZCP3GAM-BYqK4Dsa.js} +1 -1
  22. package/dist/assets/classDiagram-6PBFFD2Q-ClGF33bF.js +1 -0
  23. package/dist/assets/classDiagram-v2-HSJHXN6E-ClGF33bF.js +1 -0
  24. package/dist/assets/clone-CFj55h49.js +1 -0
  25. package/dist/assets/{cose-bilkent-S5V4N54A-BF2HsiSD.js → cose-bilkent-S5V4N54A-OJ7mmWyV.js} +1 -1
  26. package/dist/assets/{dagre-KV5264BT-f3h967yx.js → dagre-KV5264BT-B1Kwd_Rk.js} +1 -1
  27. package/dist/assets/{diagram-5BDNPKRD-B9PzHQfh.js → diagram-5BDNPKRD-D8qrw8sp.js} +1 -1
  28. package/dist/assets/{diagram-G4DWMVQ6-kR953Bqm.js → diagram-G4DWMVQ6-ip-N4ho8.js} +1 -1
  29. package/dist/assets/{diagram-MMDJMWI5-DAewa2eg.js → diagram-MMDJMWI5-2PZaskUF.js} +1 -1
  30. package/dist/assets/{diagram-TYMM5635-C8HNTH6I.js → diagram-TYMM5635-CeAOQUZP.js} +1 -1
  31. package/dist/assets/{erDiagram-SMLLAGMA-BQ5P8wWK.js → erDiagram-SMLLAGMA-BCBaEYEb.js} +1 -1
  32. package/dist/assets/{flowDiagram-DWJPFMVM-BIpEkrMR.js → flowDiagram-DWJPFMVM-CsGyIqcX.js} +1 -1
  33. package/dist/assets/{ganttDiagram-T4ZO3ILL-CWqSfB5v.js → ganttDiagram-T4ZO3ILL-COsj3FZ-.js} +1 -1
  34. package/dist/assets/{gitGraphDiagram-UUTBAWPF-Ba-9yEbQ.js → gitGraphDiagram-UUTBAWPF-DvQVQGJG.js} +1 -1
  35. package/dist/assets/{graph-D3zXKQ6P.js → graph-BSB9fDSP.js} +1 -1
  36. package/dist/assets/{index-G3xd-gLo.js → index-BRw4Iz2O.js} +1 -1
  37. package/dist/assets/{index-DClD_EkN.js → index-Bo-12bug.js} +1 -1
  38. package/dist/assets/{index-DSd5P3WQ.js → index-BuVComcY.js} +1 -1
  39. package/dist/assets/{index-HLrnYsI1.js → index-CNNAYjkF.js} +1 -1
  40. package/dist/assets/{index-Bm3A5VLb.js → index-DKufsAzx.js} +1 -1
  41. package/dist/assets/{index-C9N0rZ7Z.js → index-DXBfwPYD.js} +1 -1
  42. package/dist/assets/index-DkIhbLjr.js +2 -0
  43. package/dist/assets/{index-Nvk8r3tO.css → index-Dz36p_bA.css} +1 -1
  44. package/dist/assets/{index-Q8wp4Nof.js → index-eM2ih5n7.js} +1 -1
  45. package/dist/assets/{infoDiagram-42DDH7IO-Cxm79ZJR.js → infoDiagram-42DDH7IO-eNucS4iW.js} +1 -1
  46. package/dist/assets/{ishikawaDiagram-UXIWVN3A-Cd0vZP1B.js → ishikawaDiagram-UXIWVN3A-Dxxj-gUX.js} +1 -1
  47. package/dist/assets/{journeyDiagram-VCZTEJTY-B2b57HXk.js → journeyDiagram-VCZTEJTY-yJJzVEBb.js} +1 -1
  48. package/dist/assets/{jszip.min-Bj5uiEb2.js → jszip.min-ByI0ds5u.js} +1 -1
  49. package/dist/assets/{kanban-definition-6JOO6SKY-YCERiUCO.js → kanban-definition-6JOO6SKY-2rfTi2zw.js} +1 -1
  50. package/dist/assets/{layout-Do5TlO2U.js → layout-DTmHj0Wm.js} +1 -1
  51. package/dist/assets/{linear-BiJNTDgN.js → linear-BOA3a9FE.js} +1 -1
  52. package/dist/assets/{mermaid.core-Dg0McRZt.js → mermaid.core-5H-NR79H.js} +3 -3
  53. package/dist/assets/{min-T51iefht.js → min-CpjFTEec.js} +1 -1
  54. package/dist/assets/{mindmap-definition-QFDTVHPH-Cu_GJxY3.js → mindmap-definition-QFDTVHPH-B8KCjFgB.js} +1 -1
  55. package/dist/assets/{pieDiagram-DEJITSTG-DldHHZXi.js → pieDiagram-DEJITSTG-BARy96g5.js} +1 -1
  56. package/dist/assets/{quadrantDiagram-34T5L4WZ-BzyTPNAJ.js → quadrantDiagram-34T5L4WZ-DYoTapuI.js} +1 -1
  57. package/dist/assets/{requirementDiagram-MS252O5E-C5xxFkvG.js → requirementDiagram-MS252O5E-EeANLLK2.js} +1 -1
  58. package/dist/assets/{sankeyDiagram-XADWPNL6-OxdjoaE1.js → sankeyDiagram-XADWPNL6-DmQ-YVPj.js} +1 -1
  59. package/dist/assets/seqResourceLoaders-BdWMUSrC.css +41 -0
  60. package/dist/assets/seqResourceLoaders-Dzy1J6G9.js +2 -0
  61. package/dist/assets/{sequenceDiagram-FGHM5R23-C7c89uIS.js → sequenceDiagram-FGHM5R23-SZtW0rYC.js} +1 -1
  62. package/dist/assets/{stateDiagram-FHFEXIEX-ssdJnDrO.js → stateDiagram-FHFEXIEX-CEygsIld.js} +1 -1
  63. package/dist/assets/{stateDiagram-v2-QKLJ7IA2-BP-0WClK.js → stateDiagram-v2-QKLJ7IA2-BU1XPhBe.js} +1 -1
  64. package/dist/assets/{timeline-definition-GMOUNBTQ-sWScEpSM.js → timeline-definition-GMOUNBTQ-DhPBaQBt.js} +1 -1
  65. package/dist/assets/{vendor-antd-DKIytXho.js → vendor-antd-CS_S2mBN.js} +1 -1
  66. package/dist/assets/{vendor-codemirror-4qgXq03k.js → vendor-codemirror-B5l-VbBO.js} +1 -1
  67. package/dist/assets/{vendor-mdxeditor-D5Z5D9fq.js → vendor-mdxeditor-Cz09a1_I.js} +2 -2
  68. package/dist/assets/{vendor-qrcode-B7lb5qk1.js → vendor-qrcode-qbbntD6-.js} +1 -1
  69. package/dist/assets/{vendor-virtuoso-BqoXlbhH.js → vendor-virtuoso-Sk8fQ4Hx.js} +1 -1
  70. package/dist/assets/{vennDiagram-DHZGUBPP-DNfXR0ge.js → vennDiagram-DHZGUBPP-NniTHxDw.js} +1 -1
  71. package/dist/assets/{wardley-RL74JXVD-JXd--CRD.js → wardley-RL74JXVD-D9SNOrDw.js} +1 -1
  72. package/dist/assets/{wardleyDiagram-NUSXRM2D-DHmNyVPM.js → wardleyDiagram-NUSXRM2D-DvHXKNza.js} +1 -1
  73. package/dist/assets/{xychartDiagram-5P7HB3ND-CTb0gltZ.js → xychartDiagram-5P7HB3ND-CaiwPTQB.js} +1 -1
  74. package/dist/index.html +5 -5
  75. package/package.json +1 -1
  76. package/server/interceptor.js +119 -13
  77. package/server/lib/chat-queue.js +67 -4
  78. package/server/lib/same-origin.js +22 -0
  79. package/server/lib/v2/convert.js +1 -1
  80. package/server/lib/v2/layout.js +28 -3
  81. package/server/lib/v2/live-feed.js +41 -4
  82. package/server/lib/v2/resume-list.js +150 -0
  83. package/server/lib/v2/session-select.js +63 -14
  84. package/server/lib/v2/v2-writer.js +172 -34
  85. package/server/lib/v2/view-router.js +83 -0
  86. package/server/lib/view-root.js +209 -0
  87. package/server/pty-manager.js +790 -159
  88. package/server/routes/ask-perm.js +21 -1
  89. package/server/routes/events.js +123 -7
  90. package/server/routes/expert.js +46 -14
  91. package/server/routes/files-content.js +72 -7
  92. package/server/routes/files-fs.js +28 -3
  93. package/server/routes/git.js +31 -5
  94. package/server/routes/preferences.js +1 -16
  95. package/server/routes/project-meta.js +32 -3
  96. package/server/routes/proxy-stats.js +33 -5
  97. package/server/routes/resume.js +302 -0
  98. package/server/routes/search.js +23 -1
  99. package/server/routes/skills.js +18 -2
  100. package/server/routes/v2.js +6 -1
  101. package/server/routes/workspaces.js +10 -3
  102. package/server/server.js +191 -25
  103. package/server/system-prompt-templates/presets/kimi-k3.md +1 -1
  104. package/server/workspace-registry.js +15 -1
  105. package/dist/assets/App-CesjoFSv.js +0 -2
  106. package/dist/assets/App-M61pqQEx.css +0 -1
  107. package/dist/assets/Mobile-bvGZ392d.js +0 -1
  108. package/dist/assets/ProxyStatsModal-HFk51cI8.js +0 -1
  109. package/dist/assets/classDiagram-6PBFFD2Q-BecPAAuk.js +0 -1
  110. package/dist/assets/classDiagram-v2-HSJHXN6E-BecPAAuk.js +0 -1
  111. package/dist/assets/clone-B0_ghF3F.js +0 -1
  112. package/dist/assets/index-DanpP6qP.js +0 -2
  113. package/dist/assets/seqResourceLoaders-BumAjQDW.css +0 -41
  114. package/dist/assets/seqResourceLoaders-ByHQMNIi.js +0 -2
@@ -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
  }
@@ -17,10 +17,10 @@
17
17
  // correctness dependency.
18
18
 
19
19
  import { statfsSync, existsSync, readdirSync, readFileSync } from 'node:fs';
20
- import { join, basename } from 'node:path';
20
+ import { join, basename, sep } from 'node:path';
21
21
  import { AsyncWriteQueue } from '../async-write-queue.js';
22
22
  import { reportSwallowed } from '@ccv/core/error-report';
23
- import { ensureSessionDirSync, compactLocalTs14, sanitizePathComponent } from './layout.js';
23
+ import { ensureSessionDirSync, compactLocalTs14, sanitizePathComponent, normalizeSessionCwd } from './layout.js';
24
24
  import { resolveSessionDirName, latestMainSession } from './session-select.js';
25
25
  import { acquireSessionClaim, releaseSessionClaim, isForeignLiveOwned } from './session-owner.js';
26
26
  import { BlobStore } from './blob-store.js';
@@ -78,6 +78,10 @@ export class V2Writer {
78
78
  // in-process live cursor can read the fresh appends with zero fs-watch
79
79
  // latency. Purely a nudge — the feed's data source stays the files.
80
80
  this._onActivity = typeof opts.onActivity === 'function' ? opts.onActivity : null;
81
+ // Fired once per request that resolves a (sid, project) pair — lets the
82
+ // server feed a PTY's sessionId index (pty-manager.setPtySessionId) so chat
83
+ // sends can be routed to the PTY owning that conversation (2026-10-05).
84
+ this._onSessionResolved = typeof opts.onSessionResolved === 'function' ? opts.onSessionResolved : null;
81
85
  this._sessions = new Map(); // sessionId → {paths, blobs, journal, convs, resolver}
82
86
  // Multi-window isolation (2026-07-17): session dirs this process has
83
87
  // claimed via owner.lock. Released synchronously in resetSessions()/close()
@@ -98,13 +102,16 @@ export class V2Writer {
98
102
  this._continuationLaunch = false;
99
103
  this._forkSession = false;
100
104
  this._resumeSession = false;
101
- this._adopted = false;
105
+ // Per-project `-c` adoption latch (was a single boolean): each project may
106
+ // adopt at most once. Keyed by sanitized project so a scoped resetSessions(t)
107
+ // clears only project t's latch, not a background project's.
108
+ this._adopted = new Set();
102
109
  // In-terminal /resume switch (SessionStart hook, source:'resume'): the
103
110
  // next MAIN request must be re-routed to the resumed conversation's dir.
104
- // {transcriptUuid, hookSid} | null; last-wins, consumed one-shot, no TTL
105
- // (after a /resume the next main request belongs to the resumed
106
- // conversation no matter how much later it is typed).
107
- this._pendingResumeSwitch = null;
111
+ // Per-project (was a single value): keyed by sanitized project, so a scoped
112
+ // reset clears only the switched-away project's pending switch. Value:
113
+ // {transcriptUuid, hookSid}; consumed one-shot on the next main request.
114
+ this._pendingResumeSwitch = new Map();
108
115
  }
109
116
 
110
117
  /** P2: true once any session's FIRST main wire already carried assistant
@@ -131,6 +138,7 @@ export class V2Writer {
131
138
  /** Late wiring seam: the live feed is constructed by server.js AFTER this
132
139
  * writer exists (module init order), so the nudge callback arrives here. */
133
140
  setOnActivity(fn) { this._onActivity = typeof fn === 'function' ? fn : null; }
141
+ setOnSessionResolved(fn) { this._onSessionResolved = typeof fn === 'function' ? fn : null; }
134
142
 
135
143
  /**
136
144
  * If this request should adopt the previous main session's folder — a `-c`
@@ -145,8 +153,14 @@ export class V2Writer {
145
153
  * history-less requests); and a previous main session exists.
146
154
  * @returns {{dirName:string, identityUUID:string}|null}
147
155
  */
156
+ /** Sanitized per-project key for the per-project latches (_adopted /
157
+ * _pendingResumeSwitch). Single place so the latch keying can't drift. */
158
+ _projKey(project) {
159
+ return sanitizePathComponent(project || '');
160
+ }
161
+
148
162
  _resolveAdoption(sid, project, msgs, entry) {
149
- if (this._adopted || this._leader) return null;
163
+ if (this._adopted.has(this._projKey(project)) || this._leader) return null;
150
164
  if (!this._continuationLaunch || this._forkSession || this._resumeSession) return null;
151
165
  if (this._sessions.has(sid)) return null;
152
166
  const m = Array.isArray(msgs) ? msgs
@@ -181,13 +195,17 @@ export class V2Writer {
181
195
  * conversation was never recorded by ccv (it must not be the old wire sid:
182
196
  * `<ts>_<oldSid>` would be re-resolved back to the OLD dir by
183
197
  * resolveSessionDirName). Last-wins across repeated /resume picks. */
184
- beginResumeSwitch({ transcriptUuid, hookSid } = {}) {
198
+ beginResumeSwitch({ transcriptUuid, hookSid, project } = {}) {
185
199
  try {
186
200
  if (!transcriptUuid || typeof transcriptUuid !== 'string') return;
187
- this._pendingResumeSwitch = {
201
+ // Keyed by the project the resumed conversation belongs to (defaults to the
202
+ // currently-bound project — markSessionStart's guard already ensures the
203
+ // hook's cwd matches it). Per-project so a scoped reset clears only that one.
204
+ const key = sanitizePathComponent(project || this._projectFn() || '');
205
+ this._pendingResumeSwitch.set(key, {
188
206
  transcriptUuid,
189
207
  hookSid: (typeof hookSid === 'string' && hookSid) ? hookSid : null,
190
- };
208
+ });
191
209
  } catch (err) {
192
210
  reportSwallowed('v2-write.resume-switch', err);
193
211
  }
@@ -210,8 +228,9 @@ export class V2Writer {
210
228
  * @returns {{dirName:string, identityUUID:string}}
211
229
  */
212
230
  _resolveResumeSwitch(sid, project, prevSid = null) {
213
- const sw = this._pendingResumeSwitch;
214
- this._pendingResumeSwitch = null; // one-shot
231
+ const key = this._projKey(project);
232
+ const sw = this._pendingResumeSwitch.get(key);
233
+ this._pendingResumeSwitch.delete(key); // one-shot
215
234
  const projectDir = join(this._logDir, sanitizePathComponent(project));
216
235
  let dirName = resolveSessionDirName(projectDir, sw.transcriptUuid, this._sessionsDirName);
217
236
  let identity = sw.transcriptUuid;
@@ -274,8 +293,17 @@ export class V2Writer {
274
293
  this._claimedDirs.clear();
275
294
  }
276
295
 
277
- _session(sessionId, userIdRaw, encoding, project, startTsIso, adoptTarget = null) {
296
+ _session(sessionId, userIdRaw, encoding, project, startTsIso, adoptTarget = null, instanceKey = null, cwd = null) {
278
297
  let s = this._sessions.get(sessionId);
298
+ // Cross-project guard: the map is keyed by bare sid, so a sid reused across two
299
+ // projects (a cross-project /resume takeover running alongside its old project)
300
+ // would otherwise reuse the OTHER project's session object and cross-write into
301
+ // the wrong dir. On a project mismatch, drop the stale binding and rebuild for
302
+ // THIS project. The hot path (same project) stays an O(1) map hit.
303
+ if (s && s.project && project && sanitizePathComponent(s.project) !== sanitizePathComponent(project)) {
304
+ this._sessions.delete(sessionId);
305
+ s = null;
306
+ }
279
307
  if (s) return s; // hot path: map hit is O(1), the scan below only runs on miss
280
308
 
281
309
  // Task C: the dir name carries a creation-time prefix `<ts>_<uuid>`. On a
@@ -291,6 +319,18 @@ export class V2Writer {
291
319
  ...(userIdRaw && { userIdRaw }),
292
320
  ...(encoding && { userIdEncoding: encoding }),
293
321
  ...(this._leader && { leader: this._leader }),
322
+ // Multi-instance (2026-10-06): stamp the owning PTY instanceKey on the session dir's
323
+ // meta.json (first-write-wins), so two concurrent same-cwd claude instances — which
324
+ // share one basename project dir but mint DIFFERENT wire session UUIDs (different
325
+ // session dirs) — can each be told apart at the directory level by readers/feed/cold
326
+ // load. Absent for legacy/external producers (no self-report) → instance-agnostic.
327
+ ...(instanceKey && { instance: instanceKey }),
328
+ // Same-name session isolation (2026-10-07): stamp the session's owning full cwd so
329
+ // the read-side fallback can tighten by path, telling two cross-dir same-basename
330
+ // projects apart (basename routing cannot). First-write-wins, so an adopted/resumed
331
+ // dir keeps its ORIGINAL author's cwd — never relabeled by the adopting entry.
332
+ // Normalized lexically (no realpath) to match the read side's targetCwd.
333
+ ...(cwd && { cwd: normalizeSessionCwd(cwd) }),
294
334
  ...(this._metaExtra || {}),
295
335
  };
296
336
  const projectDir = join(this._logDir, sanitizePathComponent(project));
@@ -326,6 +366,11 @@ export class V2Writer {
326
366
  journal: new Journal(paths, this._queue),
327
367
  convs: new ConversationStore(paths, this._queue, { exactFps: this._exactConvFps }),
328
368
  resolver: new ConvResolver(),
369
+ // The project this session's dir lives under — needed by resetSessions(target)
370
+ // to clear ONLY one project's sessions on a workspace switch, leaving a
371
+ // concurrently-running background project's bindings (a cross-project /resume
372
+ // takeover's old project) intact.
373
+ project,
329
374
  };
330
375
  this._sessions.set(sessionId, s);
331
376
  return s;
@@ -362,7 +407,18 @@ export class V2Writer {
362
407
  ingestRequest(entry, originalMessages) {
363
408
  if (!this._enabled || !entry) return null;
364
409
  try {
365
- const project = this._projectFn();
410
+ // Per-request project override (/resume global sessions): only when the
411
+ // interceptor resolved a child claude's own project from the
412
+ // x-ccv-project-dir header does it set `entry._resumeProject` (already
413
+ // sanitized). We MUST key off that explicit marker, NOT the generic
414
+ // `entry.project` — the latter is set for every request (to the process
415
+ // cwd basename) and would silently misroute the moment it diverges from
416
+ // the writer's bound project (e.g. fixtures / teammate reshuffles).
417
+ // Each entry carries its own override, so concurrent sessions from
418
+ // different projects never cross-assign. Falls back to the bound project.
419
+ const project = (typeof entry._resumeProject === 'string' && entry._resumeProject)
420
+ ? entry._resumeProject
421
+ : this._projectFn();
366
422
  if (!project) return null; // workspace not selected yet — mirrors v1's empty-LOG_FILE no-op
367
423
  if (!this._diskOk()) return null;
368
424
 
@@ -377,7 +433,41 @@ export class V2Writer {
377
433
  let sid;
378
434
  if (parsed) {
379
435
  sid = parsed.sessionId;
380
- this._currentSid = sid;
436
+ // `_currentSid` = the session of the ACTIVE MAIN claude (the one whose cwd
437
+ // matches the server's currently-bound project). Every claude now
438
+ // self-reports its project via `x-ccv-project-dir` (spawnClaude injects it
439
+ // too, not just resume scratches), so `entry._resumeProject` is that
440
+ // claude's own project. Only the claude whose project == the bound project
441
+ // (`this._projectFn()`) is the main one — a kept-alive OLD project's
442
+ // background claude (header = old project ≠ bound) and resume scratches
443
+ // must NOT move `_currentSid`, or the /resume list's "current" blue dot
444
+ // would drift to whichever background session last emitted a request.
445
+ // A request with NO header at all (legacy / external producer) is treated
446
+ // as the main project's own (project === bound), preserving the old
447
+ // no-header behavior.
448
+ const entryProject = (typeof entry._resumeProject === 'string' && entry._resumeProject)
449
+ ? entry._resumeProject
450
+ : this._projectFn();
451
+ if (entryProject && entryProject === this._projectFn()) {
452
+ this._currentSid = sid;
453
+ }
454
+ // Feed the PTY sid index: this request's owning project just resolved a
455
+ // concrete sessionId, so the PTY serving that project can be addressed by
456
+ // sid. Multi-instance (2026-10-06): also forward the self-reported instanceKey
457
+ // (`_ccvInstance`) so the server can pin the sid to THIS exact process — basename
458
+ // routing cannot tell two same-cwd instances apart. Best-effort — a listener
459
+ // error must not break ingestion.
460
+ // MAIN-AGENT GATE (review P1, 2026-10-06): only pin a sid for the main interactive
461
+ // claude's own requests. A teammate/subagent child process INHERITS the parent's
462
+ // `ANTHROPIC_CUSTOM_HEADERS` (so it carries the parent's `x-ccv-instance`) but runs
463
+ // its OWN conversation/sid — without this gate it would overwrite the parent record's
464
+ // sessionId with the teammate's sid, mis-pinning the instance. Teammate / heartbeat /
465
+ // count_tokens requests are skipped.
466
+ const isMainAgentTurn = entry.mainAgent === true && !entry.teammate && !entry.isHeartbeat && !entry.isCountTokens;
467
+ if (this._onSessionResolved && entryProject && isMainAgentTurn) {
468
+ const instKey = (typeof entry._ccvInstance === 'string' && entry._ccvInstance) ? entry._ccvInstance : null;
469
+ try { this._onSessionResolved(sid, entryProject, instKey); } catch (err) { reportSwallowed('v2-write.session-resolved', err); }
470
+ }
381
471
  } else if (this._currentSid) {
382
472
  sid = this._currentSid; // §8.3 fallback: route metadata-less requests to the active session
383
473
  } else {
@@ -406,7 +496,7 @@ export class V2Writer {
406
496
  // probes and heartbeats wear main-agent body shapes but are not the
407
497
  // user's resumed turn — consuming on them would re-bind too early
408
498
  // with the same result, but keep the gate strict for clarity).
409
- if (this._pendingResumeSwitch && entry.mainAgent
499
+ if (this._pendingResumeSwitch.has(this._projKey(project)) && entry.mainAgent
410
500
  && !entry.isCountTokens && !entry.isHeartbeat) {
411
501
  // Locally guarded (review P2): the switch resolution does dir-scan
412
502
  // fs work (resolveSessionDirName → readdirSync) whose residual
@@ -419,7 +509,7 @@ export class V2Writer {
419
509
  }
420
510
  if (!adoptTarget) {
421
511
  adoptTarget = this._resolveAdoption(sid, project, originalMessages, entry);
422
- if (adoptTarget) this._adopted = true;
512
+ if (adoptTarget) this._adopted.add(this._projKey(project));
423
513
  }
424
514
  }
425
515
 
@@ -427,7 +517,9 @@ export class V2Writer {
427
517
  // the dir-name ts prefix (task C). First-write-wins: meta is only written
428
518
  // when the dir is first created, so this is the session's creation time
429
519
  // (live ≈ now; convert = the historical first-entry ts).
430
- const s = this._session(sid, parsed && entry.body.metadata.user_id, parsed && parsed.encoding, project, entry.timestamp, adoptTarget);
520
+ const s = this._session(sid, parsed && entry.body.metadata.user_id, parsed && parsed.encoding, project, entry.timestamp, adoptTarget,
521
+ (typeof entry._ccvInstance === 'string' && entry._ccvInstance) ? entry._ccvInstance : null,
522
+ (typeof entry._resumeProjectCwd === 'string' && entry._resumeProjectCwd) ? entry._resumeProjectCwd : null);
431
523
 
432
524
  // System-prompt snapshot, Bind A (wire): the FIRST main request of a session
433
525
  // carries the launch's rendered injection in body.system — match it against the
@@ -682,6 +774,14 @@ export class V2Writer {
682
774
  return s ? s.paths.dir : null;
683
775
  }
684
776
 
777
+ /** The writer's current (fallback-routing) session UUID, or null before the
778
+ * first sid-bearing request. Exposed so the /resume list can mark exactly ONE
779
+ * row as the live "current" session (the multi-blue-dot fix) — the row whose
780
+ * sessionUuid matches this id is the one the main process is actively running. */
781
+ currentSessionId() {
782
+ return this._currentSid || null;
783
+ }
784
+
685
785
  /** Lifecycle hook: drop in-memory conversation continuity so the next
686
786
  * request snapshots fresh. Journal seq keeps counting (same process, same
687
787
  * session dirs — seq must stay monotonic per session). NOTE: the
@@ -700,26 +800,64 @@ export class V2Writer {
700
800
  }
701
801
  }
702
802
 
703
- /** Lifecycle hook (workspace switch): drop ALL cached session bindings so the
704
- * next request re-creates its session dir under the newly resolved project.
705
- * A sid that persists across the switch gets a fresh dir (and fresh seq)
706
- * under the new project — different directory, so no seq collision. */
707
- resetSessions() {
803
+ /** Lifecycle hook (workspace switch): drop the TARGET project's cached session
804
+ * bindings so the next request re-creates its session dir under the newly
805
+ * resolved project. `targetProject` is the project being switched AWAY from —
806
+ * only its sessions/claims are dropped, so a concurrently-running BACKGROUND
807
+ * project (a cross-project /resume takeover's old project, still being written
808
+ * via `entry._resumeProject`) keeps its bindings and continues uninterrupted.
809
+ * With no argument (legacy callers) it clears everything, matching the old
810
+ * behavior byte-for-byte. A sid that persists across the switch gets a fresh
811
+ * dir (and fresh seq) under the new project — different directory, so no seq
812
+ * collision. */
813
+ resetSessions(targetProject = null) {
708
814
  try {
815
+ const matchAll = targetProject == null;
816
+ const targetSan = matchAll ? null : sanitizePathComponent(targetProject);
709
817
  // Release live claims FIRST (before the session map is gone): a
710
818
  // switched-away workspace's dirs are dormant and legitimately adoptable
711
- // by other windows from this moment on.
712
- this._releaseAllClaims();
713
- this._sessions.clear();
819
+ // by other windows from this moment on. Only the target project's claims
820
+ // are released on a scoped reset — the background project keeps its lock.
821
+ if (matchAll) {
822
+ this._releaseAllClaims();
823
+ } else {
824
+ // Match by the project dir prefix (<logDir>/<projectSan>) rather than by
825
+ // positional path segment — the old slice(-3,-2) index assumed a fixed
826
+ // <logDir>/<project>/sessions/<ts>_<sid> depth and broke when
827
+ // _sessionsDirName changed the layout (offline converter staging dirs).
828
+ const prefix = join(this._logDir, targetSan) + sep;
829
+ for (const dir of [...this._claimedDirs]) {
830
+ if (dir === join(this._logDir, targetSan) || dir.startsWith(prefix)) {
831
+ releaseSessionClaim(dir);
832
+ this._claimedDirs.delete(dir);
833
+ }
834
+ }
835
+ }
836
+ for (const [sid, s] of [...this._sessions]) {
837
+ if (matchAll || sanitizePathComponent(s.project || '') === targetSan) {
838
+ this._sessions.delete(sid);
839
+ }
840
+ }
714
841
  this._pendingNoSid.length = 0;
715
842
  this._lateHandles.clear();
716
- this._currentSid = null;
717
- // A second workspace's `-c` must be able to adopt afresh — the previous
718
- // workspace's adoption doesn't carry over.
719
- this._adopted = false;
720
- // A /resume signal armed in the previous workspace context is
721
- // meaningless after the switch (its transcript belongs to that project).
722
- this._pendingResumeSwitch = null;
843
+ // _currentSid only clears when it pointed at the dropped project — the
844
+ // background project's live "current" pointer survives a scoped reset.
845
+ if (matchAll) {
846
+ this._currentSid = null;
847
+ } else {
848
+ const cur = this._currentSid ? this._sessions.get(this._currentSid) : null;
849
+ if (this._currentSid && !cur) this._currentSid = null;
850
+ }
851
+ // `-c` adoption latch + pending /resume switch, both per-project now: a
852
+ // scoped reset clears only the switched-away project's latch (a background
853
+ // project's armed resume switch / adoption survives); a full reset clears all.
854
+ if (matchAll) {
855
+ this._adopted.clear();
856
+ this._pendingResumeSwitch.clear();
857
+ } else {
858
+ this._adopted.delete(targetSan);
859
+ this._pendingResumeSwitch.delete(targetSan);
860
+ }
723
861
  } catch (err) {
724
862
  reportSwallowed('v2-write.reset-sessions', err);
725
863
  }
@@ -0,0 +1,83 @@
1
+ // Per-project view routing (2026-10), split out of server.js so the pure logic
2
+ // is unit-testable without the server singleton (same posture as resume-list.js).
3
+ //
4
+ // The server runs ONE V2LiveFeed per project (the bound project plus any project
5
+ // a client is VIEWING via /events?project=). Two pure questions recur:
6
+ // 1. which SSE clients should a given project's feed broadcast to, and
7
+ // 2. which project owns a given session dir (so writer activity reaches the
8
+ // right feed).
9
+ // Both are pure and live here; server.js wires them to the live _projectName and
10
+ // the feed map.
11
+
12
+ /**
13
+ * Filter an SSE client list to those whose VIEWED project is `project`.
14
+ *
15
+ * A client's viewed project is stamped on its SSE response (`res._ccvViewProject`)
16
+ * by /events; unset means the bound project (the pre-view-layer default). So a
17
+ * client belongs to `project` when (stamped project, defaulting to `boundProject`)
18
+ * === `project`. With a single project active this reduces to "keep everyone" for
19
+ * the bound feed (and "keep no one" for any other) — i.e. byte-identical to the
20
+ * legacy broadcast-to-all behavior.
21
+ *
22
+ * @param {Array} clientList - SSE response objects (may carry `_ccvViewProject`).
23
+ * @param {string} project - the feed's project name.
24
+ * @param {string} boundProject - the server's bound project (`_projectName`).
25
+ * @returns {Array} the subset of clients viewing `project` (same references).
26
+ */
27
+ export function filterClientsByViewProject(clientList, project, boundProject) {
28
+ if (!project) return clientList;
29
+ const bound = boundProject || '';
30
+ // Fast path: nothing to split when this IS the bound project AND no client is
31
+ // viewing a different project (the overwhelmingly common case) — return the
32
+ // SAME array reference (no filter alloc), preserving legacy identity.
33
+ if (project === bound) {
34
+ let anyForeign = false;
35
+ for (const c of clientList) { if (c && c._ccvViewProject && c._ccvViewProject !== bound) { anyForeign = true; break; } }
36
+ if (!anyForeign) return clientList;
37
+ }
38
+ return clientList.filter((c) => c && ((c._ccvViewProject || bound) === project));
39
+ }
40
+
41
+ /**
42
+ * Extract the owning project dir name from a session dir path of the form
43
+ * `<LOG_DIR>/<projectSan>/sessions/<dirName>`. Returns the on-disk `<projectSan>`
44
+ * segment, or '' when `dir` is not under `<LOG_DIR>/`. Pure string parsing — the
45
+ * caller maps the segment to a feed via its own key scheme.
46
+ *
47
+ * @param {string} dir - absolute session dir.
48
+ * @param {string} logDir - LOG_DIR root (e.g. ~/.claude/cc-viewer).
49
+ * @param {string} sep - path separator (injected for tests / win32).
50
+ * @returns {string} the project dir name, or ''.
51
+ */
52
+ export function projectOfSessionDir(dir, logDir, sep) {
53
+ if (typeof dir !== 'string' || !dir || typeof logDir !== 'string' || !logDir) return '';
54
+ const root = logDir.endsWith(sep) ? logDir : logDir + sep;
55
+ if (!dir.startsWith(root)) return '';
56
+ const rest = dir.slice(root.length);
57
+ const projectSan = rest.split(sep)[0];
58
+ return projectSan || '';
59
+ }
60
+
61
+ /**
62
+ * Resolve which feed key owns writer activity for a session dir, and whether a
63
+ * bound-feed fallback is allowed. Pure decision extracted from server.js's
64
+ * `_feedForSessionDir` so the routing rule is behavior-testable (2026-10-05).
65
+ *
66
+ * Rule:
67
+ * - dir owns a project (`<LOG_DIR>/<projectSan>/...`): return that project's
68
+ * feed key and `allowBoundFallback:false`. When that project has no live
69
+ * feed the caller MUST drop the broadcast — never re-route a foreign
70
+ * project's activity to the bound project's viewers (the bleed).
71
+ * - dir owns no project (outside `<LOG_DIR>/`): `feedKey:null` and
72
+ * `allowBoundFallback:true` (legacy single-project behavior).
73
+ *
74
+ * @param {string} dir - absolute session dir.
75
+ * @param {string} logDir - LOG_DIR root.
76
+ * @param {string} sep - path separator.
77
+ * @returns {{feedKey:string|null, allowBoundFallback:boolean}}
78
+ */
79
+ export function resolveActivityFeedKey(dir, logDir, sep) {
80
+ const projectSan = projectOfSessionDir(dir, logDir, sep);
81
+ if (projectSan) return { feedKey: projectSan, allowBoundFallback: false };
82
+ return { feedKey: null, allowBoundFallback: true };
83
+ }