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.
- package/cli.js +5 -2
- package/dist/assets/App-GhvyKB0E.js +2 -0
- package/dist/assets/{MdxEditorPanel-C8Cij93y.js → MdxEditorPanel-DwwKGPIQ.js} +1 -1
- package/dist/assets/{Mobile-OKCVi8un.js → Mobile-CGiDFGRO.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DGlngtM9.js → ProxyStatsModal-CI5PkF_B.js} +1 -1
- package/dist/assets/{index-Clt4dw8Y.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/interceptor.js +165 -14
- package/server/lib/git-diff.js +14 -8
- package/server/lib/scope-router.js +68 -0
- package/server/lib/sdk-manager.js +155 -32
- package/server/lib/sdk-session-index.js +25 -0
- package/server/lib/task-state.js +74 -34
- package/server/lib/turn-end-bridge.js +6 -1
- package/server/lib/v2/live-feed.js +7 -6
- package/server/lib/v2/view-router.js +48 -0
- package/server/lib/view-root.js +30 -10
- package/server/lib/workflow-watcher.js +13 -6
- package/server/lib/ws-mux.js +492 -0
- package/server/pty-manager.js +53 -0
- package/server/routes/ask-perm.js +13 -8
- package/server/routes/events.js +185 -86
- package/server/routes/project-meta.js +11 -4
- package/server/routes/proxy-stats.js +8 -4
- package/server/routes/resume.js +64 -10
- package/server/routes/session-pin.js +12 -2
- package/server/routes/workflow-journal.js +5 -2
- package/server/routes/workspaces.js +7 -1
- package/server/server.js +353 -120
- package/dist/assets/App-DtBVNYE7.js +0 -2
- 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
|
-
|
|
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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/server/lib/task-state.js
CHANGED
|
@@ -1,26 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* task-state.js — in-memory reducer for the Claude Code task checklist.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
39
|
-
// must not change the shouldResetTasks comparison base. resetTasks()
|
|
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,
|
|
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)
|
|
82
|
+
if (payload.sessionId && !payload.agentId) b.sessionId = payload.sessionId;
|
|
50
83
|
|
|
51
84
|
if (hookEventName === 'TaskCreated') {
|
|
52
|
-
const existing =
|
|
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
|
-
|
|
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 =
|
|
78
|
-
if (!
|
|
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
|
-
|
|
123
|
+
b.tasks.delete(taskId);
|
|
91
124
|
return;
|
|
92
125
|
}
|
|
93
|
-
const existing =
|
|
94
|
-
if (!
|
|
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
|
|
128
|
-
export function resetTasks() {
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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. */
|