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/assets/{App-Bj_-wfrB.js → App-GhvyKB0E.js} +2 -2
- package/dist/assets/{MdxEditorPanel-DD2IrRnW.js → MdxEditorPanel-DwwKGPIQ.js} +1 -1
- package/dist/assets/{Mobile-BQBQkr2V.js → Mobile-CGiDFGRO.js} +1 -1
- package/dist/assets/{ProxyStatsModal-8VYl6n39.js → ProxyStatsModal-CI5PkF_B.js} +1 -1
- package/dist/assets/{index-CloXv0sm.js → index-D0_0_HIv.js} +2 -2
- package/dist/assets/seqResourceLoaders-DHr1E1Ye.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/lib/sdk-manager.js +151 -31
- package/server/lib/sdk-session-index.js +25 -0
- package/server/lib/view-root.js +30 -10
- package/server/lib/ws-mux.js +27 -8
- package/server/routes/events.js +5 -2
- package/server/routes/project-meta.js +11 -4
- package/server/routes/proxy-stats.js +8 -4
- package/server/routes/resume.js +47 -10
- package/server/server.js +18 -8
- package/dist/assets/seqResourceLoaders-M4XGblXm.js +0 -2
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-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
341
|
-
// frontend
|
|
342
|
-
// every other workspace's
|
|
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${
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/server/lib/view-root.js
CHANGED
|
@@ -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' });
|
package/server/lib/ws-mux.js
CHANGED
|
@@ -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
|
|
54
|
-
//
|
|
55
|
-
|
|
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
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
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));
|
package/server/routes/events.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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' });
|