cc-viewer 1.9.5 → 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.
package/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="./assets/index-CloXv0sm.js"></script>
24
+ <script type="module" crossorigin src="./assets/index-D0_0_HIv.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="./assets/vendor-antd-CS_S2mBN.js">
26
26
  <link rel="modulepreload" crossorigin href="./assets/vendor-codemirror-B5l-VbBO.js">
27
27
  <link rel="modulepreload" crossorigin href="./assets/vendor-mdxeditor-Cz09a1_I.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.9.5",
3
+ "version": "1.9.6",
4
4
  "description": "Claude Code logging, visualization, and management toolkit — launch a web viewer alongside Claude Code with full request/response tracing, proxy, and mobile support",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -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
 
@@ -837,7 +869,7 @@ function _createSession(key, cwd) {
837
869
  ? resultMsg.errors.join('; ')
838
870
  : (typeof resultMsg.result === 'string' && resultMsg.result) || resultMsg.subtype || 'unknown error';
839
871
  if (_broadcastWs) {
840
- 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); }
841
873
  }
842
874
  }
843
875
 
@@ -915,7 +947,7 @@ function _createSession(key, cwd) {
915
947
  _ctxUsageFetching = false;
916
948
  _ctxUsageQueued = false;
917
949
  if (had && _broadcastWs) {
918
- 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 }); }
919
951
  catch (err) { reportSwallowed('sdk-manager.context-usage-broadcast', err); }
920
952
  }
921
953
  }
@@ -963,7 +995,7 @@ function _createSession(key, cwd) {
963
995
  const had = _lastMcpStatus !== null;
964
996
  _lastMcpStatus = null;
965
997
  if (had && _broadcastWs) {
966
- 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 }); }
967
999
  catch (err) { reportSwallowed('sdk-manager.mcp-status-broadcast', err); }
968
1000
  }
969
1001
  }
@@ -1134,7 +1166,7 @@ function _createSession(key, cwd) {
1134
1166
  : kind === 'plan' ? 'sdk-plan-resolved'
1135
1167
  : 'perm-hook-timeout';
1136
1168
  try {
1137
- _broadcastWs({ type, id, reason, cwd: _cwd, sessionId: _sessionId || null });
1169
+ _broadcastWs({ type, id, reason, cwd: _cwd, sessionId: _sessionId || null, instanceKey });
1138
1170
  } catch (err) { console.warn('[sdk-manager] approval-dismiss broadcast threw:', err?.message); }
1139
1171
  }
1140
1172
 
@@ -1451,6 +1483,7 @@ function _createSession(key, cwd) {
1451
1483
  // body, now per-session). `init` = config adoption + that teardown/reset pair.
1452
1484
  return {
1453
1485
  key,
1486
+ instanceKey,
1454
1487
  get cwd() { return _cwd; },
1455
1488
  get config() {
1456
1489
  return {
@@ -1503,6 +1536,13 @@ function _createSession(key, cwd) {
1503
1536
  },
1504
1537
  switchToSession: _switchToSession,
1505
1538
  getSessionId: () => _sessionId,
1539
+ close: () => {
1540
+ for (const { id, kind } of _cancelAllApprovals()) {
1541
+ _broadcastApprovalDismiss(kind, id, 'session-closed');
1542
+ }
1543
+ _teardownResident();
1544
+ _resetFullState();
1545
+ },
1506
1546
  teardown: () => {
1507
1547
  _teardownResident();
1508
1548
  _resetFullState();
@@ -1567,20 +1607,19 @@ export function initSdkSession(cwd, projectName, { onTurnEnd, onQueryError, broa
1567
1607
  */
1568
1608
  export async function sendUserMessage(text, anchor) {
1569
1609
  if (!_query) throw new Error('Agent SDK not available');
1570
- const sess = _active();
1571
- if (!sess) {
1572
- // Uninitialized — the old module's state machine only came alive after
1573
- // initSdkSession; a pre-init send just resolves (the SDK-unavailable guard
1574
- // above already covered the only documented throw).
1575
- return;
1576
- }
1577
- if (anchor && (anchor.cwd || anchor.project)) {
1610
+ if (anchor && (anchor.cwd || anchor.project || anchor.instanceKey)) {
1578
1611
  const target = _sessionForAnchor(anchor);
1579
1612
  if (!target) {
1580
- // A mis-resolved anchor must not silently drop the user's message — log + report.
1581
- 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;
1582
1617
  console.warn(`[sdk-manager] no SDK session registered for "${targetName}" — message dropped`);
1583
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
+ }
1584
1623
  return;
1585
1624
  }
1586
1625
  if (target.key !== _activeKey) {
@@ -1588,6 +1627,13 @@ export async function sendUserMessage(text, anchor) {
1588
1627
  }
1589
1628
  return target.sendUserMessage(text);
1590
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
+ }
1591
1637
  return sess.sendUserMessage(text);
1592
1638
  }
1593
1639
 
@@ -1711,6 +1757,80 @@ export function setActiveSdkSession(cwd) {
1711
1757
  return true;
1712
1758
  }
1713
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
+
1714
1834
  /** The active session's cwd (header/switch addressing). '' when none. */
1715
1835
  export function getActiveSdkCwd() {
1716
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
+ }
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { realpathSync, existsSync, statSync } from 'node:fs';
28
28
  import { projectKeyForCwd } from './system-prompt-snapshots.js';
29
+ import { listSdkSessionInstances } from './sdk-session-index.js';
29
30
 
30
31
  // A project name must be sanitizer-stable: projectKeyForCwd maps every legal
31
32
  // basename to [a-zA-Z0-9_\-\.] (collapsing only ALL-dot degenerate names to
@@ -49,11 +50,13 @@ function isValidProjectName(name) {
49
50
  * @param {string} opts.boundCwd - the bound project root (CCV_PROJECT_DIR || process.cwd()).
50
51
  * @param {() => Array<{path:string, projectName:string}>} opts.loadWorkspaces - registry reader.
51
52
  * @param {() => Array<{cwd:string, instanceKey?:string}>} opts.listLivePtys - live PTY records.
52
- * @returns {{ ok:true, root:string, via:'bound'|'registry'|'live'|'live-instance' } | { ok:false, status:number, error:string }}
53
+ * @returns {{ ok:true, root:string, projectName:string, via:'bound'|'registry'|'live'|'live-instance' } | { ok:false, status:number, error:string }}
54
+ * `root` is the realpath'd real dir; `projectName` is the RAW-path store key
55
+ * (basename of the matched candidate BEFORE realpath) — the wire/log-store name.
56
+ * They differ for a leaf-symlinked workspace, and the store is keyed by the raw name.
53
57
  */
54
- export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWorkspaces, listLivePtys } = {}) {
55
- const bound = boundCwd || process.cwd();
56
- // Multi-instance (2026-10-06): `instance` is the per-spawn instanceKey, the
58
+ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWorkspaces, listLivePtys, listSdkSessions = listSdkSessionInstances } = {}) {
59
+ const bound = boundCwd || process.cwd();// Multi-instance (2026-10-06): `instance` is the per-spawn instanceKey, the
57
60
  // authoritative disambiguator when two same-basename projects run at once —
58
61
  // the name path below would 400 on that pair as "ambiguous". Resolve it FIRST
59
62
  // (before the empty-name bound short-circuit AND name validation): a valid live
@@ -67,13 +70,21 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
67
70
  const rec = (listLivePtys() || []).find((p) => p && p.instanceKey === instance);
68
71
  if (rec && typeof rec.cwd === 'string' && rec.cwd) {
69
72
  if (existsSync(rec.cwd) && statSync(rec.cwd).isDirectory()) {
70
- return { ok: true, root: realpathSync(rec.cwd), via: 'live-instance' };
73
+ return { ok: true, root: realpathSync(rec.cwd), projectName: projectKeyForCwd(rec.cwd), via: 'live-instance' };
71
74
  }
72
75
  }
73
76
  } catch { /* pty map unavailable / fs race → fall through to name path */ }
77
+ try {
78
+ const rec = (listSdkSessions() || []).find((s) => s && s.instanceKey === instance);
79
+ if (rec && typeof rec.cwd === 'string' && rec.cwd) {
80
+ if (existsSync(rec.cwd) && statSync(rec.cwd).isDirectory()) {
81
+ return { ok: true, root: realpathSync(rec.cwd), projectName: projectKeyForCwd(rec.cwd), via: 'live-instance' };
82
+ }
83
+ }
84
+ } catch { /* SDK registry unavailable / fs race → fall through to name path */ }
74
85
  }
75
86
  const name = (typeof projectParam === 'string' && projectParam) || '';
76
- if (!name) return { ok: true, root: bound, via: 'bound' };
87
+ if (!name) return { ok: true, root: bound, projectName: projectKeyForCwd(bound), via: 'bound' };
77
88
  if (!isValidProjectName(name)) {
78
89
  return { ok: false, status: 400, error: 'invalid project name' };
79
90
  }
@@ -103,6 +114,14 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
103
114
  }
104
115
  }
105
116
  } catch { /* pty map unavailable → registry-only */ }
117
+ try {
118
+ for (const s of listSdkSessions() || []) {
119
+ const cwd = s && s.cwd;
120
+ if (typeof cwd === 'string' && cwd && projectKeyForCwd(cwd) === name) {
121
+ candidates.push({ path: cwd, via: 'live' });
122
+ }
123
+ }
124
+ } catch { /* SDK registry unavailable → PTY/registry-only */ }
106
125
 
107
126
  // Dedupe by path string (a project live AND registered under the SAME path
108
127
  // is one candidate). Keys are the raw candidate paths — not realpathSync —
@@ -132,7 +151,7 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
132
151
  if (!existsSync(root) || !statSync(root).isDirectory()) {
133
152
  return { ok: false, status: 404, error: 'project dir missing' };
134
153
  }
135
- return { ok: true, root: realpathSync(root), via: 'live' };
154
+ return { ok: true, root: realpathSync(root), projectName: projectKeyForCwd(root), via: 'live' };
136
155
  } catch {
137
156
  return { ok: false, status: 404, error: 'project dir unreadable' };
138
157
  }
@@ -156,7 +175,7 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
156
175
  }
157
176
  const root = alive[0].path;
158
177
  try {
159
- return { ok: true, root: realpathSync(root), via: alive[0].via };
178
+ return { ok: true, root: realpathSync(root), projectName: projectKeyForCwd(root), via: alive[0].via };
160
179
  } catch {
161
180
  return { ok: false, status: 404, error: 'project dir unreadable' };
162
181
  }
@@ -166,7 +185,7 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
166
185
  if (!existsSync(root) || !statSync(root).isDirectory()) {
167
186
  return { ok: false, status: 404, error: 'project dir missing' };
168
187
  }
169
- return { ok: true, root: realpathSync(root), via: uniq[0].via };
188
+ return { ok: true, root: realpathSync(root), projectName: projectKeyForCwd(root), via: uniq[0].via };
170
189
  } catch {
171
190
  return { ok: false, status: 404, error: 'project dir unreadable' };
172
191
  }
@@ -192,13 +211,14 @@ export function resolveViewRoot({ projectParam, instanceParam, boundCwd, loadWor
192
211
  * @param {string} [opts.bodyInstance] - POST-body instance override (wins over query).
193
212
  * @returns {string|null} the resolved absolute root, or null (already replied).
194
213
  */
195
- export function viewRootOrReply(req, res, parsedUrl, { boundCwd, loadWorkspaces, listLivePtys, bodyProject, bodyInstance } = {}) {
214
+ export function viewRootOrReply(req, res, parsedUrl, { boundCwd, loadWorkspaces, listLivePtys, listSdkSessions, bodyProject, bodyInstance } = {}) {
196
215
  const r = resolveViewRoot({
197
216
  projectParam: (typeof bodyProject === 'string' && bodyProject) || parsedUrl?.searchParams?.get('project'),
198
217
  instanceParam: (typeof bodyInstance === 'string' && bodyInstance) || parsedUrl?.searchParams?.get('instance'),
199
218
  boundCwd,
200
219
  loadWorkspaces,
201
220
  listLivePtys,
221
+ listSdkSessions,
202
222
  });
203
223
  if (!r.ok) {
204
224
  res.writeHead(r.status, { 'Content-Type': 'application/json' });
@@ -50,9 +50,14 @@ const HEARTBEAT_SEND_MS = 25_000; // app-level ping cadence (chisel: proxies k
50
50
  const HEARTBEAT_REAP_MS = 60_000; // no inbound frame for this long → terminate the socket
51
51
  const MAX_STREAMS_PER_SOCKET = 8;
52
52
  // Per-socket queue is a pure MEMORY SAFETY NET (the primary slow-consumer eviction is the
53
- // per-stream 30s backpressure rule inherited from _safeSseWrite). Sized well above any
54
- // legitimate burst so it only trips on a wedged socket, never on a fast-producer burst.
55
- const QUEUE_MAX_BYTES = 8 * 1024 * 1024;
53
+ // per-stream 30s backpressure rule inherited from _safeSseWrite). Sized above any legitimate
54
+ // cold-load burst so it only trips on a genuinely wedged socket, never on a fast-producer
55
+ // burst. Postmortem (2026-10-15): cc-viewer's own ~11MB v3 cold load exceeded the old 8MB
56
+ // cap — the CAP RAISE is the load-bearing fix; the perMessageDeflate disable below is
57
+ // prophylactic (it removes bufferedAmount inflation so the gate stops false-tripping, but
58
+ // even with deflate on the 32MB cap absorbs the burst: measured peak 10.6MB).
59
+ // 32MB covers ~40k-row sessions while still bounding a truly dead client's damage.
60
+ export const QUEUE_MAX_BYTES = 32 * 1024 * 1024;
56
61
  const HIGH_WATER = 1024 * 1024; // mirrors terminal's hysteresis band
57
62
  const LOW_WATER = 256 * 1024;
58
63
 
@@ -241,11 +246,25 @@ export function createStreamMuxServer({
241
246
  clearTimeout: clearT = clearTimeout,
242
247
  now = Date.now,
243
248
  } = {}) {
244
- // perMessageDeflate: the dialogue channel carries the app's largest transfers (cold-load
245
- // windows); without it the mux would send verbatim JSON where the SSE path gets per-connection
246
- // brotli (measured 21-88x on real sessions). ws's built-in permessage-deflate (zlib) recovers
247
- // most of that with no new dependency. (SSE keeps its stronger brotli; this closes the gap.)
248
- const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: true });
249
+ // perMessageDeflate DISABLED (2026-10-15, queue-overflow postmortem): with deflate on,
250
+ // ws.send() enqueues into Node's zlib pending queue and ws.bufferedAmount counts those
251
+ // bytes BEFORE they reach TCP (sender.js adds the UNCOMPRESSED size to _bufferedBytes
252
+ // pre-compress, subtracting only in the zlib callback). During a cold-load burst the gate
253
+ // then sees a phantom backlog, marks the socket behind, and queues frames — pings included —
254
+ // so the queue grows until the cap kills the connection (1011 queue-overflow) and the
255
+ // client's channel watchdog fires into a reconnect→overflow loop.
256
+ //
257
+ // Measured (real ws, ~11MB burst): peak bufferedAmount ~1.05MB in BOTH modes — deflate's
258
+ // contribution is queue DWELL TIME, not peak depth. The cap raise above is what actually
259
+ // prevents the overflow; disabling deflate keeps the gate's behind-detection honest.
260
+ //
261
+ // Trade-off (accepted): the mux path is now UNCOMPRESSED — MuxSink._wireEnc stays falsy,
262
+ // so the SSE brotli encoder never runs here (only the SSE fallback compresses). On
263
+ // localhost/LAN the wire growth is irrelevant; on a slow remote link a cold load is
264
+ // slower than it would be compressed, and an SSE-fallback user can cold-load FASTER than
265
+ // a healthy-mux user. The structural fix (cold-load history over one compressed HTTP
266
+ // fetch, WS reserved for control + live deltas) is tracked as follow-up work.
267
+ const wss = new WebSocketServer({ noServer: true, maxPayload: 1024 * 1024, perMessageDeflate: false });
249
268
  const sockets = new Set();
250
269
  wss.on('error', (err) => reportSwallowed('ws.mux.server', err));
251
270
  wss.on('connection', (ws) => handleConnection(ws));
@@ -13,6 +13,7 @@ import { readV2SingleEntry } from '../lib/v2/adapter.js';
13
13
  import { resolveSessionDirName, latestMainSessionDir } from '../lib/v2/session-select.js';
14
14
  import { sanitizePathComponent, normalizeSessionCwd } from '../lib/v2/layout.js';
15
15
  import { listLivePtys } from '../pty-manager.js';
16
+ import { findSdkSessionByInstance } from '../lib/sdk-session-index.js';
16
17
  import { enrichRawIfNeeded } from '../lib/enrich-plan-input.js';
17
18
  import { validateLogPath } from '../lib/log-management.js';
18
19
  import { isMainAgentEntry, extractCachedContent } from '../lib/kv-cache-analyzer.js';
@@ -351,7 +352,8 @@ export async function serveEventStream(sink, params, deps) {
351
352
  let targetCwd = '';
352
353
  if (instanceParam) {
353
354
  try {
354
- const rec = listLivePtys().find((p) => p && p.instanceKey === instanceParam);
355
+ const rec = listLivePtys().find((p) => p && p.instanceKey === instanceParam)
356
+ || findSdkSessionByInstance(instanceParam);
355
357
  targetCwd = rec && rec.cwd ? normalizeSessionCwd(rec.cwd) : '';
356
358
  } catch (err) { targetCwd = ''; reportSwallowed('events.target-cwd', err); }
357
359
  }
@@ -391,7 +393,8 @@ export async function serveEventStream(sink, params, deps) {
391
393
  let instanceAlive = false;
392
394
  if (instanceNoSession) {
393
395
  try {
394
- instanceAlive = listLivePtys().some((p) => p && p.instanceKey === instanceParam);
396
+ instanceAlive = listLivePtys().some((p) => p && p.instanceKey === instanceParam)
397
+ || !!findSdkSessionByInstance(instanceParam);
395
398
  } catch (err) {
396
399
  // Lookup failure degrades to the explicit dead-instance posture (never
397
400
  // silently blank the client on a lookup error).
@@ -14,10 +14,11 @@ import { detectHomebrewInstall } from '../lib/updater.js';
14
14
  // not become a presence oracle over LOG_DIR — `?project=` is accepted ONLY
15
15
  // when it resolves to a registered workspace / live PTY (or the bound
16
16
  // project), never an arbitrary sanitized name.
17
- function _resolveStatsName(projectParam, res) {
18
- if (!projectParam) return _projectName || '';
17
+ function _resolveStatsName(projectParam, instanceParam, res) {
18
+ if (!projectParam && !instanceParam) return _projectName || '';
19
19
  const r = resolveViewRoot({
20
20
  projectParam,
21
+ instanceParam,
21
22
  boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
22
23
  loadWorkspaces,
23
24
  listLivePtys,
@@ -27,7 +28,12 @@ function _resolveStatsName(projectParam, res) {
27
28
  res.end(JSON.stringify({ error: r.error }));
28
29
  return null;
29
30
  }
30
- return sanitizePathComponent(projectParam);
31
+ // The log-store name derives from the RAW matched path (basename BEFORE the
32
+ // resolver's realpath), which the wire/store layer keys on — never from the raw
33
+ // ?project= (forged names + a live instance would otherwise become a LOG_DIR
34
+ // presence oracle) and never from the realpath'd root (a leaf-symlinked workspace
35
+ // would then read the target's store name instead of its own).
36
+ return sanitizePathComponent(r.projectName);
31
37
  }
32
38
 
33
39
  // 判定当前 cc-viewer 的安装渠道,供前端精准匹配升级命令。
@@ -73,7 +79,8 @@ function projectStats(req, res, parsedUrl) {
73
79
  // Multi-project (2026-10): project-stats resolves the VIEWED project
74
80
  // (?project=, registered/live only — never an arbitrary name).
75
81
  const projectParam = parsedUrl && parsedUrl.searchParams ? parsedUrl.searchParams.get('project') : null;
76
- const name = _resolveStatsName(projectParam, res);
82
+ const instanceParam = parsedUrl && parsedUrl.searchParams ? parsedUrl.searchParams.get('instance') : null;
83
+ const name = _resolveStatsName(projectParam, instanceParam, res);
77
84
  if (name === null) return; // error already replied
78
85
  if (!name) {
79
86
  res.writeHead(404, { 'Content-Type': 'application/json' });