cc-viewer 1.9.4 → 1.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/cli.js +5 -2
  2. package/dist/assets/App-GhvyKB0E.js +2 -0
  3. package/dist/assets/{MdxEditorPanel-C8Cij93y.js → MdxEditorPanel-DwwKGPIQ.js} +1 -1
  4. package/dist/assets/{Mobile-OKCVi8un.js → Mobile-CGiDFGRO.js} +1 -1
  5. package/dist/assets/{ProxyStatsModal-DGlngtM9.js → ProxyStatsModal-CI5PkF_B.js} +1 -1
  6. package/dist/assets/{index-Clt4dw8Y.js → index-D0_0_HIv.js} +2 -2
  7. package/dist/assets/seqResourceLoaders-DHr1E1Ye.js +2 -0
  8. package/dist/index.html +1 -1
  9. package/package.json +1 -1
  10. package/server/interceptor.js +165 -14
  11. package/server/lib/git-diff.js +14 -8
  12. package/server/lib/scope-router.js +68 -0
  13. package/server/lib/sdk-manager.js +155 -32
  14. package/server/lib/sdk-session-index.js +25 -0
  15. package/server/lib/task-state.js +74 -34
  16. package/server/lib/turn-end-bridge.js +6 -1
  17. package/server/lib/v2/live-feed.js +7 -6
  18. package/server/lib/v2/view-router.js +48 -0
  19. package/server/lib/view-root.js +30 -10
  20. package/server/lib/workflow-watcher.js +13 -6
  21. package/server/lib/ws-mux.js +492 -0
  22. package/server/pty-manager.js +53 -0
  23. package/server/routes/ask-perm.js +13 -8
  24. package/server/routes/events.js +185 -86
  25. package/server/routes/project-meta.js +11 -4
  26. package/server/routes/proxy-stats.js +8 -4
  27. package/server/routes/resume.js +64 -10
  28. package/server/routes/session-pin.js +12 -2
  29. package/server/routes/workflow-journal.js +5 -2
  30. package/server/routes/workspaces.js +7 -1
  31. package/server/server.js +353 -120
  32. package/dist/assets/App-DtBVNYE7.js +0 -2
  33. package/dist/assets/seqResourceLoaders-zlIVjapi.js +0 -2
@@ -32,6 +32,8 @@ import { evaluateImDeny } from './im-deny.js';
32
32
  import { APPROVAL_TOOLS, isPublishCommand } from './approval-policy.js';
33
33
  import { reportSwallowed } from '@ccv/core/error-report';
34
34
  import { resolve as _pathResolve } from 'node:path';
35
+ import { createHash } from 'node:crypto';
36
+ import { registerSdkSessionLister } from './sdk-session-index.js';
35
37
 
36
38
  let _query;
37
39
  try {
@@ -71,7 +73,13 @@ function _getOrCreateSession(cwd) {
71
73
  // The ACTIVE session (the one the composer talks to). May be null before any init.
72
74
  function _active() { return _activeKey != null ? _sessions.get(_activeKey) : null; }
73
75
 
74
- // Resolve an anchor { cwd|project } to a Session WITHOUT switching active (read ops).
76
+ function _matchesProject(sess, project) {
77
+ const rawBase = String(sess.cwd || '').replace(/\\/g, '/').split('/').filter(Boolean).pop();
78
+ const sanitized = rawBase ? rawBase.replace(/[^a-zA-Z0-9_\-.]/g, '_') : null;
79
+ return sanitized === project || rawBase === project;
80
+ }
81
+
82
+ // Resolve an anchor { instanceKey|cwd|project } to a Session WITHOUT switching active (read ops).
75
83
  // `cwd` is an absolute path (exact key match); `project` is the SANITIZED project name the
76
84
  // web client sends (ChatView stamps `viewProject || projectName`, where projectName =
77
85
  // projectKeyForCwd(cwd) — non [a-zA-Z0-9_-] chars become `_`, so a dir with spaces/CJK sends
@@ -80,8 +88,18 @@ function _active() { return _activeKey != null ? _sessions.get(_activeKey) : nul
80
88
  // guess). Returns null when the anchor resolves to no registered session.
81
89
  function _sessionForAnchor(anchor) {
82
90
  if (!anchor || typeof anchor !== 'object') return null;
91
+ const instanceKey = (typeof anchor.instanceKey === 'string' && anchor.instanceKey) || null;
83
92
  const cwd = (typeof anchor.cwd === 'string' && anchor.cwd) || null;
84
93
  const project = (typeof anchor.project === 'string' && anchor.project) || null;
94
+ if (instanceKey) {
95
+ for (const s of _sessions.values()) {
96
+ if (s.instanceKey === instanceKey) return s;
97
+ }
98
+ // An exact instance that no longer exists must never fall back to a
99
+ // same-basename sibling — that would execute the user's action in the
100
+ // wrong workspace. The caller's unknown-anchor path drops + reports it.
101
+ return null;
102
+ }
85
103
  if (cwd) return _sessions.get(_normalizeCwd(cwd)) || null;
86
104
  if (!project) return null;
87
105
  // exact-path form of a project (an absolute path was passed as `project`) — try key first.
@@ -89,9 +107,7 @@ function _sessionForAnchor(anchor) {
89
107
  if (byKey) return byKey;
90
108
  let found = null;
91
109
  for (const s of _sessions.values()) {
92
- const rawBase = String(s.cwd || '').replace(/\\/g, '/').split('/').filter(Boolean).pop();
93
- const sanitized = rawBase ? rawBase.replace(/[^a-zA-Z0-9_\-.]/g, '_') : null;
94
- if (sanitized === project || rawBase === project) { if (found) return null; found = s; } // ambiguous → null
110
+ if (_matchesProject(s, project)) { if (found) return null; found = s; } // ambiguous → null
95
111
  }
96
112
  return found;
97
113
  }
@@ -100,12 +116,17 @@ function _sessionForAnchor(anchor) {
100
116
  export function __listSessionsForTests() { return [..._sessions.keys()]; }
101
117
  export function __getSessionKeyForTests(cwd) { return _normalizeCwd(cwd); }
102
118
 
103
- /** Enumerate the per-cwd SDK session registry for the header's live list. Each entry:
104
- * { key, cwd, active }. Read-only. */
119
+ /** Enumerate the per-cwd SDK session registry for live workspace routing. Each entry:
120
+ * { key, cwd, active, instanceKey }. Read-only. NOTE: `key` is the registry's
121
+ * normalized-cwd key; attach/close responses instead alias `key` to the instanceKey
122
+ * for killPtyFor-shape parity (resume.js consumes `r.key === reattachedTo`). Callers
123
+ * must not mix the two namespaces. */
105
124
  export function listSdkSessions() {
106
- return [..._sessions.values()].map((s) => ({ key: s.key, cwd: s.cwd, active: s.key === _activeKey }));
125
+ return [..._sessions.values()].map((s) => ({ key: s.key, cwd: s.cwd, active: s.key === _activeKey, instanceKey: s.instanceKey }));
107
126
  }
108
127
 
128
+ registerSdkSessionLister(listSdkSessions);
129
+
109
130
  // Silence window for the watchdog. Must stay above the approval ceilings the turn can
110
131
  // legally wait on: perm/plan = 5min, AskUserQuestion = 24h ("GUI effectively has no
111
132
  // timeout" is a product contract). A pending approval exempts the turn from the
@@ -275,6 +296,7 @@ function _normalizeSdkTask(msg) {
275
296
  // `let` (rebinding, not a frozen property) so assignment paths like
276
297
  // `_messageQueue = []` keep working.
277
298
  function _createSession(key, cwd) {
299
+ const instanceKey = `ccv-${createHash('sha256').update(key).digest('hex').slice(0, 32)}`;
278
300
  // Launch config (per-session identity; adopted on lazy reconstruction)
279
301
  let _cwd = cwd;
280
302
  let _permissionMode = 'default';
@@ -337,13 +359,14 @@ function _createSession(key, cwd) {
337
359
  let _suppressDrain = false;
338
360
 
339
361
  // Wrap the process-global _broadcastWs so EVERY frame this session emits carries
340
- // its routing identity (cwd + sessionId): with concurrent sessions on one WS, the
341
- // frontend filters frames by cwd — without it every workspace's clients would see
342
- // every other workspace's spinner/queue/modal frames.
362
+ // its routing identity (cwd + sessionId + instanceKey): with concurrent sessions on
363
+ // one WS, the frontend/server filter frames by workspace+instance — without it every
364
+ // workspace's (or same-basename twin's) clients would see every other workspace's
365
+ // spinner/queue/modal frames.
343
366
  function _broadcast(payload) {
344
367
  if (!_broadcastWs) return;
345
368
  try {
346
- _broadcastWs({ cwd: _cwd, sessionId: _sessionId || null, ...payload });
369
+ _broadcastWs({ cwd: _cwd, sessionId: _sessionId || null, instanceKey, ...payload });
347
370
  } catch (err) { reportSwallowed('sdk-manager.broadcast', err); }
348
371
  }
349
372
 
@@ -440,7 +463,7 @@ function _createSession(key, cwd) {
440
463
  // Negative ack: the frontend cleared the composer optimistically — never fail
441
464
  // silently. Only the reject frame goes out (no queue-state — nothing changed).
442
465
  console.warn('[sdk-manager] queue full, rejecting message');
443
- try { if (_broadcastWs) _broadcastWs({ type: 'queue-rejected', reason: 'full', cwd: _cwd, sessionId: _sessionId || null }); }
466
+ try { if (_broadcastWs) _broadcastWs({ type: 'queue-rejected', reason: 'full', cwd: _cwd, sessionId: _sessionId || null, instanceKey }); }
444
467
  catch (err) { reportSwallowed('sdk-manager.reject-broadcast', err); }
445
468
  return;
446
469
  }
@@ -459,7 +482,7 @@ function _createSession(key, cwd) {
459
482
  function _broadcastQueueState() {
460
483
  if (!_broadcastWs) return;
461
484
  try {
462
- _broadcastWs({ type: 'queue-state', cwd: _cwd, sessionId: _sessionId || null, items: _messageQueue.map(({ id, text, ts }) => ({ id, text, ts })) });
485
+ _broadcastWs({ type: 'queue-state', cwd: _cwd, sessionId: _sessionId || null, instanceKey, items: _messageQueue.map(({ id, text, ts }) => ({ id, text, ts })) });
463
486
  } catch (err) { console.warn('[sdk-manager] queue-state broadcast threw:', err?.message); }
464
487
  }
465
488
 
@@ -531,8 +554,15 @@ function _createSession(key, cwd) {
531
554
  const headerCwd = String(_cwd).replace(/[\r\n\x00-\x1f\x7f]/g, '');
532
555
  if (headerCwd) {
533
556
  const projectHeader = `x-ccv-project-dir: ${headerCwd}`;
557
+ // Same-name twin isolation (review P2): also self-report this session's stable
558
+ // instanceKey so the interceptor stamps `_ccvInstance` → v2 meta.instance →
559
+ // scoped instance routing, exactly like a PTY spawn. Without it, two same-basename
560
+ // SDK workspaces share project-scoped frames (close teardown kills the twin's
561
+ // spinner/queue bubbles) and cold-load instance filters never match SDK sessions.
562
+ const instanceHeader = `x-ccv-instance: ${instanceKey}`;
563
+ const injected = `${projectHeader}\n${instanceHeader}`;
534
564
  options.env.ANTHROPIC_CUSTOM_HEADERS = options.env.ANTHROPIC_CUSTOM_HEADERS
535
- ? `${options.env.ANTHROPIC_CUSTOM_HEADERS}\n${projectHeader}` : projectHeader;
565
+ ? `${options.env.ANTHROPIC_CUSTOM_HEADERS}\n${injected}` : injected;
536
566
  }
537
567
  }
538
568
  if (_settings) options.settings = _settings;
@@ -695,8 +725,8 @@ function _createSession(key, cwd) {
695
725
  const valid = state === 'idle' || state === 'running' || state === 'requires_action';
696
726
  if (!valid || state === _lastSessionState) return;
697
727
  _lastSessionState = state;
698
- // _broadcast carries the routing envelope (cwd + sessionId) — every frame needs
699
- // it so concurrent sessions' clients filter correctly.
728
+ // _broadcast carries the routing envelope (cwd + sessionId + instanceKey) — every
729
+ // frame needs it so concurrent sessions' clients filter correctly.
700
730
  _broadcast({ type: 'sdk-session-state', state });
701
731
  }
702
732
 
@@ -728,6 +758,7 @@ function _createSession(key, cwd) {
728
758
  slashCommands: msg.slash_commands,
729
759
  tools: msg.tools,
730
760
  cwd: _cwd,
761
+ instanceKey,
731
762
  };
732
763
  _broadcastWs(_lastInitSnapshot);
733
764
  } else if (msg.subtype === 'compact_boundary' && _broadcastWs) {
@@ -739,6 +770,7 @@ function _createSession(key, cwd) {
739
770
  postTokens: meta && meta.post_tokens,
740
771
  cwd: _cwd,
741
772
  sessionId: _sessionId || null,
773
+ instanceKey,
742
774
  });
743
775
  // Compaction is the only non-turn event that materially shrinks context — refresh
744
776
  // the usage panel. Coalesced with the turn-end refresh via the in-flight gate.
@@ -752,7 +784,7 @@ function _createSession(key, cwd) {
752
784
  // failed/stopped/killed terminal states the TODO HUD vocabulary lacks). The
753
785
  // server keeps no task table here — pure forward, merge semantics left to the
754
786
  // client (task_updated.patch is a wire-safe delta, not a full snapshot).
755
- _broadcastWs({ type: 'sdk-task', cwd: _cwd, ..._normalizeSdkTask(msg) });
787
+ _broadcastWs({ type: 'sdk-task', cwd: _cwd, instanceKey, ..._normalizeSdkTask(msg) });
756
788
  }
757
789
  break;
758
790
 
@@ -798,7 +830,10 @@ function _createSession(key, cwd) {
798
830
  // doesn't go through ensureHooks() so this in-process callback is the
799
831
  // only way the renderer learns the turn is over.
800
832
  if (_onTurnEnd) {
801
- try { _onTurnEnd({ sessionId: _sessionId, ts: Date.now() }); }
833
+ // Per-workspace (2026-10): carry the session's own cwd so the server
834
+ // can route turn_end to THIS workspace's SSE scope (SDK mode has no
835
+ // Stop-hook POST, so this callback is the only turn-end producer).
836
+ try { _onTurnEnd({ sessionId: _sessionId, ts: Date.now(), cwd: _cwd }); }
802
837
  catch (err) { console.warn('[sdk-manager] onTurnEnd threw:', err?.message); }
803
838
  }
804
839
  if (rec) _settleTurn(rec);
@@ -834,7 +869,7 @@ function _createSession(key, cwd) {
834
869
  ? resultMsg.errors.join('; ')
835
870
  : (typeof resultMsg.result === 'string' && resultMsg.result) || resultMsg.subtype || 'unknown error';
836
871
  if (_broadcastWs) {
837
- try { _broadcastWs({ type: 'sdk-error', message: String(errs), cwd: _cwd, sessionId: _sessionId || null }); } catch (err) { console.warn('[sdk-manager] sdk-error broadcast threw:', err?.message); }
872
+ try { _broadcastWs({ type: 'sdk-error', message: String(errs), cwd: _cwd, sessionId: _sessionId || null, instanceKey }); } catch (err) { console.warn('[sdk-manager] sdk-error broadcast threw:', err?.message); }
838
873
  }
839
874
  }
840
875
 
@@ -912,7 +947,7 @@ function _createSession(key, cwd) {
912
947
  _ctxUsageFetching = false;
913
948
  _ctxUsageQueued = false;
914
949
  if (had && _broadcastWs) {
915
- try { _broadcastWs({ type: 'sdk-context-usage', sessionId: _sessionId, ts: Date.now(), trigger, usage: null, cwd: _cwd }); }
950
+ try { _broadcastWs({ type: 'sdk-context-usage', sessionId: _sessionId, ts: Date.now(), trigger, usage: null, cwd: _cwd, instanceKey }); }
916
951
  catch (err) { reportSwallowed('sdk-manager.context-usage-broadcast', err); }
917
952
  }
918
953
  }
@@ -960,7 +995,7 @@ function _createSession(key, cwd) {
960
995
  const had = _lastMcpStatus !== null;
961
996
  _lastMcpStatus = null;
962
997
  if (had && _broadcastWs) {
963
- try { _broadcastWs({ type: 'sdk-mcp-status', sessionId: _sessionId, ts: Date.now(), trigger, servers: null, cwd: _cwd }); }
998
+ try { _broadcastWs({ type: 'sdk-mcp-status', sessionId: _sessionId, ts: Date.now(), trigger, servers: null, cwd: _cwd, instanceKey }); }
964
999
  catch (err) { reportSwallowed('sdk-manager.mcp-status-broadcast', err); }
965
1000
  }
966
1001
  }
@@ -1131,7 +1166,7 @@ function _createSession(key, cwd) {
1131
1166
  : kind === 'plan' ? 'sdk-plan-resolved'
1132
1167
  : 'perm-hook-timeout';
1133
1168
  try {
1134
- _broadcastWs({ type, id, reason, cwd: _cwd, sessionId: _sessionId || null });
1169
+ _broadcastWs({ type, id, reason, cwd: _cwd, sessionId: _sessionId || null, instanceKey });
1135
1170
  } catch (err) { console.warn('[sdk-manager] approval-dismiss broadcast threw:', err?.message); }
1136
1171
  }
1137
1172
 
@@ -1448,6 +1483,7 @@ function _createSession(key, cwd) {
1448
1483
  // body, now per-session). `init` = config adoption + that teardown/reset pair.
1449
1484
  return {
1450
1485
  key,
1486
+ instanceKey,
1451
1487
  get cwd() { return _cwd; },
1452
1488
  get config() {
1453
1489
  return {
@@ -1500,6 +1536,13 @@ function _createSession(key, cwd) {
1500
1536
  },
1501
1537
  switchToSession: _switchToSession,
1502
1538
  getSessionId: () => _sessionId,
1539
+ close: () => {
1540
+ for (const { id, kind } of _cancelAllApprovals()) {
1541
+ _broadcastApprovalDismiss(kind, id, 'session-closed');
1542
+ }
1543
+ _teardownResident();
1544
+ _resetFullState();
1545
+ },
1503
1546
  teardown: () => {
1504
1547
  _teardownResident();
1505
1548
  _resetFullState();
@@ -1564,20 +1607,19 @@ export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broa
1564
1607
  */
1565
1608
  export async function sendUserMessage(text, anchor) {
1566
1609
  if (!_query) throw new Error('Agent SDK not available');
1567
- const sess = _active();
1568
- if (!sess) {
1569
- // Uninitialized — the old module's state machine only came alive after
1570
- // initSdkSession; a pre-init send just resolves (the SDK-unavailable guard
1571
- // above already covered the only documented throw).
1572
- return;
1573
- }
1574
- if (anchor && (anchor.cwd || anchor.project)) {
1610
+ if (anchor && (anchor.cwd || anchor.project || anchor.instanceKey)) {
1575
1611
  const target = _sessionForAnchor(anchor);
1576
1612
  if (!target) {
1577
- // A mis-resolved anchor must not silently drop the user's message — log + report.
1578
- const targetName = anchor.cwd || anchor.project;
1613
+ // A mis-resolved anchor must not silently drop the user's message — log + report,
1614
+ // and surface a toast through the same channel query errors use (the composer was
1615
+ // already cleared client-side; without this the text vanishes without a trace).
1616
+ const targetName = anchor.cwd || anchor.project || anchor.instanceKey;
1579
1617
  console.warn(`[sdk-manager] no SDK session registered for "${targetName}" — message dropped`);
1580
1618
  reportSwallowed('sdk-manager.route', new Error(`no SDK session for ${targetName}`));
1619
+ if (_onQueryError) {
1620
+ try { _onQueryError(`Workspace "${targetName}" is no longer available — message not sent.`); }
1621
+ catch (err) { console.warn('[sdk-manager] onQueryError threw:', err?.message); }
1622
+ }
1581
1623
  return;
1582
1624
  }
1583
1625
  if (target.key !== _activeKey) {
@@ -1585,6 +1627,13 @@ export async function sendUserMessage(text, anchor) {
1585
1627
  }
1586
1628
  return target.sendUserMessage(text);
1587
1629
  }
1630
+ const sess = _active();
1631
+ if (!sess) {
1632
+ // Uninitialized — the old module's state machine only came alive after
1633
+ // initSdkSession; a pre-init send just resolves (the SDK-unavailable guard
1634
+ // above already covered the only documented throw).
1635
+ return;
1636
+ }
1588
1637
  return sess.sendUserMessage(text);
1589
1638
  }
1590
1639
 
@@ -1708,6 +1757,80 @@ export function setActiveSdkSession(cwd) {
1708
1757
  return true;
1709
1758
  }
1710
1759
 
1760
+ /** Activate a workspace by the same exact instance/cwd/project anchor used by SDK actions. */
1761
+ export function attachSdkSession(anchor) {
1762
+ if (anchor && anchor.project && !anchor.instanceKey && !anchor.cwd) {
1763
+ const matches = [..._sessions.values()].filter((s) => _matchesProject(s, anchor.project));
1764
+ if (matches.length > 1) {
1765
+ return {
1766
+ ok: false,
1767
+ reason: 'ambiguous',
1768
+ candidates: matches.map((s) => ({ key: s.instanceKey /* aliases instanceKey for killPtyFor-shape parity */, instanceKey: s.instanceKey, cwd: s.cwd })),
1769
+ };
1770
+ }
1771
+ }
1772
+ const sess = _sessionForAnchor(anchor);
1773
+ if (!sess) return { ok: false, reason: 'not-found' };
1774
+ const switched = sess.key !== _activeKey;
1775
+ _activeKey = sess.key;
1776
+ // `key` aliases instanceKey for killPtyFor-shape parity (NOT the registry cwd key).
1777
+ return { ok: true, key: sess.instanceKey, instanceKey: sess.instanceKey, cwd: sess.cwd, switched };
1778
+ }
1779
+
1780
+ /**
1781
+ * Close exactly one registered SDK workspace. A name-only anchor refuses
1782
+ * same-basename ambiguity; an exact instance key never falls back to a name.
1783
+ */
1784
+ export function closeSdkSession(anchor = {}) {
1785
+ const instanceKey = typeof anchor.instanceKey === 'string' ? anchor.instanceKey : '';
1786
+ let sess = null;
1787
+ if (instanceKey) {
1788
+ sess = _sessionForAnchor({ instanceKey });
1789
+ if (!sess) return { ok: false, reason: 'not-found' };
1790
+ } else if (anchor.cwd) {
1791
+ sess = _sessionForAnchor({ cwd: anchor.cwd });
1792
+ } else if (anchor.project) {
1793
+ const matches = [..._sessions.values()].filter((s) => _matchesProject(s, anchor.project));
1794
+ if (matches.length > 1) {
1795
+ return {
1796
+ ok: false,
1797
+ reason: 'ambiguous',
1798
+ candidates: matches.map((s) => ({ key: s.instanceKey /* aliases instanceKey for killPtyFor-shape parity */, instanceKey: s.instanceKey, cwd: s.cwd })),
1799
+ };
1800
+ }
1801
+ sess = matches[0] || null;
1802
+ }
1803
+ if (!sess) return { ok: false, reason: 'not-found' };
1804
+
1805
+ const killedActive = sess.key === _activeKey;
1806
+ const result = {
1807
+ ok: true,
1808
+ // `key` aliases instanceKey here (killPtyFor-shape parity: resume.js matches
1809
+ // `p.key === killResult.reattachedTo`). It is NOT the registry's cwd key —
1810
+ // the registry key is `sess.key`, a different namespace. Do not conflate.
1811
+ key: sess.instanceKey,
1812
+ instanceKey: sess.instanceKey,
1813
+ cwd: sess.cwd,
1814
+ killedActive,
1815
+ reattachedTo: null,
1816
+ };
1817
+ // Remove the registry identity BEFORE user-callback-facing teardown frames. A
1818
+ // close/relaunch triggered by one of those frames must register a fresh session,
1819
+ // never be deleted by the tail of the older close operation.
1820
+ _sessions.delete(sess.key);
1821
+ if (killedActive) {
1822
+ const survivor = _sessions.values().next().value || null;
1823
+ _activeKey = survivor ? survivor.key : null;
1824
+ result.reattachedTo = survivor ? survivor.instanceKey : null;
1825
+ }
1826
+ try {
1827
+ sess.close();
1828
+ } catch (err) {
1829
+ reportSwallowed('sdk-manager.close', err);
1830
+ }
1831
+ return result;
1832
+ }
1833
+
1711
1834
  /** The active session's cwd (header/switch addressing). '' when none. */
1712
1835
  export function getActiveSdkCwd() {
1713
1836
  const sess = _active();
@@ -0,0 +1,25 @@
1
+ // Synchronous read-only projection of the SDK session registry. Generic view
2
+ // routing must not import sdk-manager (and eagerly load the Agent SDK) in PTY mode.
3
+ import { reportSwallowed } from '@ccv/core/error-report';
4
+
5
+ let _listSessions = null;
6
+
7
+ export function registerSdkSessionLister(lister) {
8
+ _listSessions = typeof lister === 'function' ? lister : null;
9
+ }
10
+
11
+ export function listSdkSessionInstances() {
12
+ if (!_listSessions) return [];
13
+ try {
14
+ const rows = _listSessions();
15
+ return Array.isArray(rows) ? rows : [];
16
+ } catch (err) {
17
+ reportSwallowed('sdk-session-index.list', err);
18
+ return [];
19
+ }
20
+ }
21
+
22
+ export function findSdkSessionByInstance(instanceKey) {
23
+ if (typeof instanceKey !== 'string' || !/^ccv-[0-9a-f]+$/.test(instanceKey)) return null;
24
+ return listSdkSessionInstances().find((s) => s && s.instanceKey === instanceKey) || null;
25
+ }
@@ -1,26 +1,56 @@
1
1
  /**
2
2
  * task-state.js — in-memory reducer for the Claude Code task checklist.
3
3
  *
4
- * Maintains the single shared checklist of the session this cc-viewer process
5
- * hosts (fed by task-bridge.js via POST /api/task-event → deps.onTaskEvent).
6
- * The checklist is deliberately ONE map, not per-session: the main agent and
7
- * its teammates share the same task list in Claude Code, and each cc-viewer
8
- * server process only receives hooks from its own claude child tree
9
- * (CCVIEWER_PORT is injected per child). Cross-session leftovers are cleared
10
- * by resetTasks() on session boundaries (startup/clear from the MAIN agent —
11
- * see server.js onTaskSessionBoundary, which skips subagent events via
12
- * agentId).
4
+ * Per-workspace (2026-10): the checklist is bucketed by producer scope
5
+ * ('' = the bound project — the legacy singleton bucket; a parallel workspace
6
+ * gets its own bucket) so two concurrent claudes' task lists never clobber
7
+ * each other over SSE. Within a bucket the original single-map semantics are
8
+ * preserved: the main agent and its teammates share one list, cleared at
9
+ * session boundaries.
10
+ *
11
+ * The bucket KEY is computed by the caller (server.js's hook-payload scope
12
+ * resolver) — this module stays import-free (L1-lib, boundary gate) and never
13
+ * sees pty-manager. Callers that pass no bucket get the legacy '' bucket, so
14
+ * the single-workspace behavior is byte-identical.
13
15
  *
14
16
  * L1-lib: pure module, Node builtins only, no imports.
15
17
  */
16
18
 
17
- /** @type {string|null} last seen main session id (SSE tagging only) */
18
- let _sessionId = null;
19
- /** @type {Map<string, object>} insertion order = stable display order */
20
- const _tasks = new Map();
19
+ /** @type {Map<string, {sessionId: string|null, tasks: Map<string, object>, lastUsedAt: number}>} */
20
+ const _buckets = new Map();
21
+ const _BUCKET_CAP = 16;
21
22
 
22
23
  const VALID_STATUSES = new Set(['pending', 'in_progress', 'completed', 'deleted']);
23
24
 
25
+ function _normKey(bucket) {
26
+ return (typeof bucket === 'string' && bucket) ? bucket : '';
27
+ }
28
+
29
+ /** Get (or create) one scope's bucket. LRU-capped; the '' bucket is never
30
+ * evicted (it is the legacy singleton view). */
31
+ function _bucketFor(bucket) {
32
+ const k = _normKey(bucket);
33
+ let b = _buckets.get(k);
34
+ if (b) {
35
+ _buckets.delete(k);
36
+ _buckets.set(k, b);
37
+ return b;
38
+ }
39
+ b = { sessionId: null, tasks: new Map(), lastUsedAt: Date.now() };
40
+ _buckets.set(k, b);
41
+ while (_buckets.size > _BUCKET_CAP) {
42
+ let evicted = false;
43
+ for (const [bk] of _buckets) {
44
+ if (bk === '') continue; // never evict the bound bucket
45
+ _buckets.delete(bk);
46
+ evicted = true;
47
+ break;
48
+ }
49
+ if (!evicted) break;
50
+ }
51
+ return b;
52
+ }
53
+
24
54
  function _touch(task) {
25
55
  task.updatedAt = Date.now();
26
56
  return task;
@@ -30,26 +60,29 @@ function _touch(task) {
30
60
  * Apply one normalized task event (the camelCase envelope task-bridge.js
31
61
  * POSTs). Unknown/malformed payloads are ignored.
32
62
  * @param {object} payload
63
+ * @param {string} [bucket] - producer scope key ('' = bound, the legacy view)
33
64
  */
34
- export function applyTaskEvent(payload) {
65
+ export function applyTaskEvent(payload, bucket = '') {
35
66
  if (!payload || typeof payload !== 'object') return;
67
+ const b = _bucketFor(bucket);
68
+ b.lastUsedAt = Date.now();
36
69
  const { hookEventName } = payload;
37
70
  // New-prompt reset. MUST stay above the taskId guard (this event has no
38
- // task_id) and MUST NOT move _sessionId — a foreign/malformed prompt event
39
- // must not change the shouldResetTasks comparison base. resetTasks() nulls
40
- // the tag anyway, so the next TaskCreated re-tags the session.
71
+ // task_id) and MUST NOT move the sessionId tag — a foreign/malformed prompt
72
+ // event must not change the shouldResetTasks comparison base. resetTasks()
73
+ // nulls the tag anyway, so the next TaskCreated re-tags the session.
41
74
  if (hookEventName === 'UserPromptSubmit') {
42
- if (shouldResetTasksOnPrompt(payload, _sessionId)) resetTasks();
75
+ if (shouldResetTasksOnPrompt(payload, b.sessionId)) resetTasks(bucket);
43
76
  return;
44
77
  }
45
78
  const taskId = payload.taskId != null ? String(payload.taskId) : null;
46
79
  // Validate BEFORE touching the session tag: a malformed/unknown event must
47
80
  // not move the shouldResetTasks comparison base.
48
81
  if (!taskId) return;
49
- if (payload.sessionId && !payload.agentId) _sessionId = payload.sessionId;
82
+ if (payload.sessionId && !payload.agentId) b.sessionId = payload.sessionId;
50
83
 
51
84
  if (hookEventName === 'TaskCreated') {
52
- const existing = _tasks.get(taskId);
85
+ const existing = b.tasks.get(taskId);
53
86
  if (existing) {
54
87
  // Idempotent re-fire: keep the current status, refresh the text fields
55
88
  // and the teammate attribution (same field set as TaskCompleted).
@@ -59,7 +92,7 @@ export function applyTaskEvent(payload) {
59
92
  _touch(existing);
60
93
  return;
61
94
  }
62
- _tasks.set(taskId, _touch({
95
+ b.tasks.set(taskId, _touch({
63
96
  taskId,
64
97
  subject: payload.taskSubject ?? null,
65
98
  description: payload.taskDescription ?? null,
@@ -74,8 +107,8 @@ export function applyTaskEvent(payload) {
74
107
  }
75
108
 
76
109
  if (hookEventName === 'TaskCompleted') {
77
- const existing = _tasks.get(taskId) || _stub(taskId, payload);
78
- if (!_tasks.has(taskId)) _tasks.set(taskId, existing);
110
+ const existing = b.tasks.get(taskId) || _stub(taskId, payload);
111
+ if (!b.tasks.has(taskId)) b.tasks.set(taskId, existing);
79
112
  existing.status = 'completed';
80
113
  if (payload.taskSubject != null) existing.subject = payload.taskSubject;
81
114
  if (payload.taskDescription != null) existing.description = payload.taskDescription;
@@ -87,11 +120,11 @@ export function applyTaskEvent(payload) {
87
120
  if (hookEventName === 'PostToolUse' && payload.toolName === 'TaskUpdate') {
88
121
  const status = VALID_STATUSES.has(payload.status) ? payload.status : null;
89
122
  if (status === 'deleted') {
90
- _tasks.delete(taskId);
123
+ b.tasks.delete(taskId);
91
124
  return;
92
125
  }
93
- const existing = _tasks.get(taskId) || _stub(taskId, payload);
94
- if (!_tasks.has(taskId)) _tasks.set(taskId, existing);
126
+ const existing = b.tasks.get(taskId) || _stub(taskId, payload);
127
+ if (!b.tasks.has(taskId)) b.tasks.set(taskId, existing);
95
128
  // Patch only the fields present in the event; a TaskUpdate may carry any
96
129
  // subset (status flips, renames, owner claims). Re-opening a completed
97
130
  // task (completed → in_progress → pending) is valid and passed through.
@@ -124,10 +157,16 @@ function _stub(taskId, payload) {
124
157
  });
125
158
  }
126
159
 
127
- /** Clear the whole checklist (session boundary: startup/clear by main agent). */
128
- export function resetTasks() {
129
- _tasks.clear();
130
- _sessionId = null;
160
+ /** Clear ONE bucket's checklist (session boundary: startup/clear by main agent). */
161
+ export function resetTasks(bucket = '') {
162
+ const b = _bucketFor(bucket);
163
+ b.tasks.clear();
164
+ b.sessionId = null;
165
+ }
166
+
167
+ /** Tear down every bucket (server stop/start cycles). */
168
+ export function resetAllTasks() {
169
+ _buckets.clear();
131
170
  }
132
171
 
133
172
  /**
@@ -171,12 +210,13 @@ export function shouldResetTasksOnPrompt(payload, currentSessionId) {
171
210
  return true;
172
211
  }
173
212
 
174
- /** Full-snapshot view for SSE broadcast; insertion order preserved. */
175
- export function getTaskSnapshot() {
176
- return { sessionId: _sessionId, tasks: [..._tasks.values()] };
213
+ /** Full-snapshot view of ONE bucket for SSE broadcast; insertion order preserved. */
214
+ export function getTaskSnapshot(bucket = '') {
215
+ const b = _bucketFor(bucket);
216
+ return { sessionId: b.sessionId, tasks: [...b.tasks.values()] };
177
217
  }
178
218
 
179
219
  /** Test hook: restore pristine module state. */
180
220
  export function __resetForTests() {
181
- resetTasks();
221
+ resetAllTasks();
182
222
  }
@@ -60,16 +60,21 @@ try {
60
60
  } catch { /* stdin may not be piped — fine, still notify */ }
61
61
  let sessionId = null;
62
62
  let transcriptPath = null;
63
+ let cwd = null;
63
64
  try {
64
65
  const parsed = JSON.parse(stdinData);
65
66
  sessionId = parsed?.session_id || null;
66
67
  // transcript_path lets the DingTalk bridge read the final assistant turn straight from
67
68
  // Claude's own JSONL (the clean source, free of ANSI noise / the log-dedup bug).
68
69
  transcriptPath = parsed?.transcript_path || null;
70
+ // Per-workspace routing (2026-10): the Stop-hook stdin carries the session cwd —
71
+ // the server prefers sessionId→PTY resolution, with this cwd as the fallback
72
+ // scope hint (same precedence the task bridge already uses).
73
+ cwd = parsed?.cwd || null;
69
74
  } catch { /* fine */ }
70
75
 
71
76
  const internalToken = process.env.CCVIEWER_INTERNAL_TOKEN || '';
72
- const body = JSON.stringify({ sessionId, transcriptPath, ts: Date.now() });
77
+ const body = JSON.stringify({ sessionId, transcriptPath, cwd, ts: Date.now() });
73
78
  const reqOpts = {
74
79
  hostname: '127.0.0.1',
75
80
  port: parseInt(port, 10),
@@ -27,6 +27,7 @@ import { join } from 'node:path';
27
27
  import { reportSwallowed } from '@ccv/core/error-report';
28
28
  import { createIncrementalReconstructor } from '@ccv/core/delta-reconstructor';
29
29
  import { processWatchedEntry, sendEventToClients, sendEventRawToClients } from '../log-watcher.js';
30
+ import { filterClientsByInstance } from './view-router.js';
30
31
  import { SessionSynthesizer } from './adapter.js';
31
32
  import { isDiscardableSession } from './session-select.js';
32
33
  import { isForeignLiveOwned } from './session-owner.js';
@@ -214,14 +215,14 @@ export class V2LiveFeed {
214
215
  * Backward compatible: a client with NO `_ccvViewInstance` stamp (the default / legacy /
215
216
  * single-instance view) sees every session regardless of `sessionInstance`; a session with
216
217
  * no stamped instance (legacy/external) is likewise visible to all.
218
+ *
219
+ * The project half keeps going through `_clientsFor()` (the server's filter
220
+ * closure — not necessarily the pure predicate); only the instance narrowing
221
+ * is delegated to the shared rule (view-router.filterClientsByInstance) so it
222
+ * can never drift from the server-side scoped broadcasts.
217
223
  */
218
224
  _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
+ return filterClientsByInstance(this._clientsFor(), sessionInstance || null);
225
226
  }
226
227
 
227
228
  /** Begin (or switch to) live-following a project's sessions/ root. */