cc-viewer 1.9.3 → 1.9.5
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/README.md +5 -5
- package/cli.js +29 -2
- package/dist/assets/App-Bj_-wfrB.js +2 -0
- package/dist/assets/App-DuDeDOuX.css +1 -0
- package/dist/assets/{MdxEditorPanel-Ck8B5a5v.js → MdxEditorPanel-DD2IrRnW.js} +1 -1
- package/dist/assets/{Mobile-B8AkKb0z.js → Mobile-BQBQkr2V.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DsDVMf-c.js → ProxyStatsModal-8VYl6n39.js} +1 -1
- package/dist/assets/index-CloXv0sm.js +2 -0
- package/dist/assets/{seqResourceLoaders-BsMFNfvR.css → seqResourceLoaders-Bg9Lz8pH.css} +1 -1
- package/dist/assets/seqResourceLoaders-M4XGblXm.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +176 -14
- package/server/lib/git-diff.js +14 -8
- package/server/lib/scope-router.js +68 -0
- package/server/lib/sdk-manager.js +1524 -895
- 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/workflow-watcher.js +13 -6
- package/server/lib/ws-mux.js +473 -0
- package/server/pty-manager.js +53 -0
- package/server/routes/ask-perm.js +13 -8
- package/server/routes/events.js +190 -74
- package/server/routes/resume.js +79 -1
- package/server/routes/sdk-context.js +24 -0
- package/server/routes/sdk-mcp-status.js +24 -0
- package/server/routes/session-pin.js +12 -2
- package/server/routes/workflow-journal.js +5 -2
- package/server/routes/workspaces.js +31 -2
- package/server/scratch-pty-manager.js +127 -46
- package/server/server.js +616 -108
- package/dist/assets/App-BzXO6ATr.js +0 -2
- package/dist/assets/App-DJ4yNvwz.css +0 -1
- package/dist/assets/index-Dq5UPr6v.js +0 -2
- package/dist/assets/seqResourceLoaders-BsQ6KXpV.js +0 -2
package/server/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createServer } from 'node:http';
|
|
|
2
2
|
import { createServer as createHttpsServer } from 'node:https';
|
|
3
3
|
import { createConnection } from 'node:net';
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
5
|
-
import { readFileSync, existsSync, unwatchFile, statSync, renameSync, unlinkSync } from 'node:fs';
|
|
5
|
+
import { readFileSync, existsSync, unwatchFile, statSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
|
|
6
6
|
import { join, extname, resolve, sep } from 'node:path';
|
|
7
7
|
import { platform, networkInterfaces, tmpdir } from 'node:os';
|
|
8
8
|
import { execFile, exec, spawn } from 'node:child_process';
|
|
@@ -33,7 +33,7 @@ import { filesFsRoutes } from './routes/files-fs.js';
|
|
|
33
33
|
import { searchRoutes } from './routes/search.js';
|
|
34
34
|
import { workspacesRoutes } from './routes/workspaces.js';
|
|
35
35
|
import { expertRoutes } from './routes/expert.js';
|
|
36
|
-
import { eventsRoutes } from './routes/events.js';
|
|
36
|
+
import { eventsRoutes, serveEventStream, paramsFromParsedUrl } from './routes/events.js';
|
|
37
37
|
import { v2Routes } from './routes/v2.js';
|
|
38
38
|
import { askPermRoutes } from './routes/ask-perm.js';
|
|
39
39
|
import { teamRoutes } from './routes/team.js';
|
|
@@ -41,6 +41,8 @@ import { authRoutes } from './routes/auth.js';
|
|
|
41
41
|
import { dingtalkRoutes } from './routes/dingtalk.js';
|
|
42
42
|
import { imRoutes } from './routes/im.js';
|
|
43
43
|
import { resumeRoutes } from './routes/resume.js';
|
|
44
|
+
import { sdkContextRoutes } from './routes/sdk-context.js';
|
|
45
|
+
import { sdkMcpStatusRoutes } from './routes/sdk-mcp-status.js';
|
|
44
46
|
import { proxyStatsRoutes } from './routes/proxy-stats.js';
|
|
45
47
|
import { setProxyStatsListener } from './lib/proxy/proxy-stats.js';
|
|
46
48
|
import * as imCore from './lib/im/im-bridge-core.js';
|
|
@@ -95,9 +97,10 @@ function execWithStdin(cmd, args, input, options) {
|
|
|
95
97
|
}
|
|
96
98
|
});
|
|
97
99
|
}
|
|
98
|
-
import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, RETRY_CONFIG_PATH, _retryConfigState, setLivePort, getImLiveText, resetImLiveText, markSessionStart } from './interceptor.js';
|
|
100
|
+
import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, turnActive, setTurnActive, PROFILE_PATH, RETRY_CONFIG_PATH, _retryConfigState, setLivePort, getImLiveText, resetImLiveText, markSessionStart, streamingStateFor, turnStateFor, activeStreamingScopes, activeTurnScopes, clearScopeState, clearScopeStateFor } from './interceptor.js';
|
|
99
101
|
import { V2LiveFeed } from './lib/v2/live-feed.js';
|
|
100
|
-
import { filterClientsByViewProject, projectOfSessionDir, resolveActivityFeedKey } from './lib/v2/view-router.js';
|
|
102
|
+
import { filterClientsByViewProject, filterClientsByScope, projectOfSessionDir, resolveActivityFeedKey } from './lib/v2/view-router.js';
|
|
103
|
+
import { BOUND_SCOPE, scopeKeyOf, parseScopeKey, normalizeProducerScope } from './lib/scope-router.js';
|
|
101
104
|
import { sanitizePathComponent } from './lib/v2/layout.js';
|
|
102
105
|
import { maybeResumeConvert } from './lib/v2/convert-manager.js';
|
|
103
106
|
import { LOG_DIR, setLogDir, getClaudeConfigDir, isBrowserOpenSuppressed } from '../findcc.js';
|
|
@@ -116,7 +119,8 @@ import { checkAndUpdate } from './lib/updater.js';
|
|
|
116
119
|
import { loadPlugins, runWaterfallHook, runParallelHook } from './lib/plugin-loader.js';
|
|
117
120
|
import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher.js';
|
|
118
121
|
import { sendEventToClients } from './lib/log-watcher.js';
|
|
119
|
-
import { applyTaskEvent, resetTasks, getTaskSnapshot, shouldResetTasks } from './lib/task-state.js';
|
|
122
|
+
import { applyTaskEvent, resetTasks, resetAllTasks, getTaskSnapshot, shouldResetTasks } from './lib/task-state.js';
|
|
123
|
+
import { projectKeyForCwd } from './lib/system-prompt-snapshots.js';
|
|
120
124
|
import { createImLogWatcher } from './lib/im/im-log-watcher.js';
|
|
121
125
|
import { unwatchAllWorkflows } from './lib/workflow-watcher.js';
|
|
122
126
|
import { backupConfigs } from './lib/config-backup.js';
|
|
@@ -323,6 +327,7 @@ const _editorCleanupTimer = setInterval(() => {
|
|
|
323
327
|
}, 60000);
|
|
324
328
|
_editorCleanupTimer.unref(); // Don't keep process alive for cleanup
|
|
325
329
|
let terminalWss = null; // WebSocketServer reference for broadcasting
|
|
330
|
+
let streamMux = null; // /ws/mux dialogue-channel stream mux (SSE stays the fallback)
|
|
326
331
|
let _writeToPty = null; // PTY write function reference (set by setupTerminalWebSocket)
|
|
327
332
|
let _onPtyData = null; // PTY data listener registration (set by setupTerminalWebSocket)
|
|
328
333
|
export function setWorkspaceClaudeArgs(args) {
|
|
@@ -659,6 +664,9 @@ const deps = {
|
|
|
659
664
|
get retryConfig() { return _retryConfigState; },
|
|
660
665
|
get workspaceLaunched() { return _workspaceLaunched; },
|
|
661
666
|
setWorkspaceLaunched(v) { _workspaceLaunched = v; },
|
|
667
|
+
// Bound project name (reassignable via interceptor) — getter so routes read
|
|
668
|
+
// the live value at request time (e.g. resume.js scope-key resolution).
|
|
669
|
+
get projectName() { return _projectName || ''; },
|
|
662
670
|
get launchCallback() { return _launchCallback; },
|
|
663
671
|
get workspaceClaudeArgs() { return _workspaceClaudeArgs; },
|
|
664
672
|
get workspaceClaudePath() { return _workspaceClaudePath; },
|
|
@@ -692,6 +700,24 @@ const deps = {
|
|
|
692
700
|
stopLogWatch,
|
|
693
701
|
ensureProjectFeed: _ensureProjectFeed,
|
|
694
702
|
scheduleTurnEndBroadcast: _scheduleTurnEndBroadcast,
|
|
703
|
+
// Full per-scope reap on workspace stop (interceptor buckets + the two
|
|
704
|
+
// server-side scope maps); a PTY killed here fires no Stop POST, so without
|
|
705
|
+
// this the killed scopes leak active buckets and stale session tags.
|
|
706
|
+
resetScopeRuntime: (...args) => resetScopeRuntime(...args),
|
|
707
|
+
// Single-scope reap for /api/live-processes/close (see that route).
|
|
708
|
+
clearScopeRuntimeFor: (...args) => clearScopeRuntimeFor(...args),
|
|
709
|
+
// Raw Stop-hook POST arrival (routes/events.js turnEndNotify): clears the
|
|
710
|
+
// turn-level loading flag immediately — the debounced `turn_end` SSE frame is
|
|
711
|
+
// voice-pack semantics and too slow for the spinner. This is the ONLY clear
|
|
712
|
+
// path when the turn produced no API traffic (hook-only turn), so it must not
|
|
713
|
+
// wait on the debounce timer.
|
|
714
|
+
onTurnActiveClear: (scope = null) => { _setTurnActive(false, null, scope); },
|
|
715
|
+
// SSE connect replay: a page refresh mid-turn restores the spinner without
|
|
716
|
+
// waiting for the next streaming tick (routes/events.js events() connect block).
|
|
717
|
+
getPtyTurnActive: (scopeKey = BOUND_SCOPE) => turnStateFor(scopeKey).active,
|
|
718
|
+
resolveScopeForHookPayload: _resolveScopeForHookPayload,
|
|
719
|
+
getTurnActiveFor: (scopeKey = BOUND_SCOPE) => turnStateFor(scopeKey).active,
|
|
720
|
+
filterClientsByViewProject: _filterClientsByViewProject,
|
|
695
721
|
// SessionStart hook notify → conversation-switch signal (in-terminal
|
|
696
722
|
// /resume). The source gate + V2Writer re-bind live in the interceptor.
|
|
697
723
|
onSessionStartNotify: markSessionStart,
|
|
@@ -701,18 +727,35 @@ const deps = {
|
|
|
701
727
|
// path broadcasts the empty snapshot — no separate reset route/hook.
|
|
702
728
|
onTaskEvent: (payload) => {
|
|
703
729
|
try {
|
|
704
|
-
|
|
705
|
-
|
|
730
|
+
const sc = _resolveScopeForHookPayload(payload);
|
|
731
|
+
const key = scopeKeyOf(sc.project, sc.instance);
|
|
732
|
+
applyTaskEvent(payload, key);
|
|
733
|
+
_scheduleTaskUpdateBroadcast(key);
|
|
734
|
+
// Turn-level loading: a new main-agent prompt marks the turn active.
|
|
735
|
+
// Subagent/teammate events are excluded inside the predicate (agentId +
|
|
736
|
+
// the load-bearing sessionId gate against the currently tracked turn of
|
|
737
|
+
// THIS scope — a foreign workspace's prompt must not light this one).
|
|
738
|
+
if (isTurnStartTaskEvent(payload, _turnSessionIdByScope.get(key) || null)) {
|
|
739
|
+
_turnSessionIdByScope.set(key, payload.sessionId);
|
|
740
|
+
_setTurnActive(true, payload.sessionId, sc);
|
|
741
|
+
}
|
|
706
742
|
} catch (err) { reportSwallowed('task-state.apply', err); }
|
|
707
743
|
},
|
|
708
744
|
// Session-boundary reset for the task checklist. The gate itself lives in
|
|
709
745
|
// task-state.shouldResetTasks (pure, unit-tested): main-agent startup/clear
|
|
710
746
|
// resets; resume/fork to a different session resets; subagent/teammate
|
|
711
|
-
// events (payload.agentId) must not wipe the shared list.
|
|
747
|
+
// events (payload.agentId) must not wipe the shared list. Per-workspace: the
|
|
748
|
+
// bucket is resolved from the same hook-payload scope — a background
|
|
749
|
+
// workspace's SessionStart must never clear the bound project's checklist.
|
|
712
750
|
onTaskSessionBoundary: (payload) => {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
751
|
+
const sc = _resolveScopeForHookPayload(payload);
|
|
752
|
+
const key = scopeKeyOf(sc.project, sc.instance);
|
|
753
|
+
if (!shouldResetTasks(payload, getTaskSnapshot(key).sessionId)) return;
|
|
754
|
+
resetTasks(key);
|
|
755
|
+
// Coalesce with any TaskCreated events that follow in the same SessionStart
|
|
756
|
+
// batch, and honor the _isStopping freeze — a synchronous _emitTaskUpdate
|
|
757
|
+
// here would duplicate the batch frame and write to dying connections.
|
|
758
|
+
_scheduleTaskUpdateBroadcast(key);
|
|
716
759
|
},
|
|
717
760
|
// Per-connection snapshot replay for fresh SSE clients (page refresh /
|
|
718
761
|
// reconnect) — the checklist lives only in memory, not in the cold load.
|
|
@@ -814,6 +857,8 @@ const _routes = [
|
|
|
814
857
|
...imRoutes,
|
|
815
858
|
...proxyStatsRoutes,
|
|
816
859
|
...resumeRoutes,
|
|
860
|
+
...sdkContextRoutes,
|
|
861
|
+
...sdkMcpStatusRoutes,
|
|
817
862
|
];
|
|
818
863
|
const dispatch = createDispatcher(_routes);
|
|
819
864
|
|
|
@@ -1068,6 +1113,10 @@ export async function startViewer() {
|
|
|
1068
1113
|
_stoppingPromise = null;
|
|
1069
1114
|
// 加载插件(需要在创建服务器之前,以便通过 hook 获取 HTTPS 证书)
|
|
1070
1115
|
await loadPlugins();
|
|
1116
|
+
// Per-workspace: warm the pty-manager namespace for the synchronous hook
|
|
1117
|
+
// scope resolver (fire-and-forget; the import completes long before the
|
|
1118
|
+
// first hook POST in practice, and the resolver degrades to cwd/bound hints).
|
|
1119
|
+
_warmScopeResolver().catch(() => {});
|
|
1071
1120
|
|
|
1072
1121
|
// 启动期配置备份:preferences/profile/workspaces → LOG_DIR 外的 cc-viewer-config-backups/
|
|
1073
1122
|
// (滚动留 10 份)。2026-06-06 事故:配置随 LOG_DIR 整树丢失后无处可恢复。fire-and-forget。
|
|
@@ -1380,7 +1429,7 @@ export async function startViewer() {
|
|
|
1380
1429
|
async function setupTerminalWebSocket(httpServer) {
|
|
1381
1430
|
try {
|
|
1382
1431
|
const { WebSocketServer } = await import('ws');
|
|
1383
|
-
const { writeToPty, writeToPtySequential, writeToPtyFor, writeToPtySequentialFor, resizePty, onPtyData, onPtyExit, getPtyState, getPtyKind, getOutputBuffer, getCurrentWorkspace, spawnShell, findSafeSliceStart, attachPtyFor } = await import('./pty-manager.js');
|
|
1432
|
+
const { writeToPty, writeToPtySequential, writeToPtyFor, writeToPtySequentialFor, resizePty, onPtyData, onPtyExit, getPtyState, getPtyKind, getOutputBuffer, getCurrentWorkspace, spawnShell, findSafeSliceStart, attachPtyFor, listLivePtys } = await import('./pty-manager.js');
|
|
1384
1433
|
const {
|
|
1385
1434
|
spawnScratch,
|
|
1386
1435
|
writeScratch,
|
|
@@ -1392,10 +1441,18 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1392
1441
|
getScratchOutputBuffer,
|
|
1393
1442
|
getScratchShellBasename,
|
|
1394
1443
|
getScratchPtyCount,
|
|
1444
|
+
getScratchPtyCountForCwd,
|
|
1395
1445
|
hasScratchPty,
|
|
1396
1446
|
} = await import('./scratch-pty-manager.js');
|
|
1447
|
+
const { loadWorkspaces } = await import('./workspace-registry.js');
|
|
1448
|
+
const { resolveViewRoot } = await import('./lib/view-root.js');
|
|
1397
1449
|
const SCRATCH_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
1398
|
-
|
|
1450
|
+
// Per-workspace cap = 2x the client's tab cap (8), same semantics as the old global
|
|
1451
|
+
// 16 for single-workspace use. Hidden workspaces keep their PTYs alive by design
|
|
1452
|
+
// (per-workspace tab groups restore on switch-back), so the global bound must be
|
|
1453
|
+
// larger yet finite — each entry is a real shell process.
|
|
1454
|
+
const MAX_SCRATCH_PTYS_PER_CWD = 16;
|
|
1455
|
+
const MAX_SCRATCH_PTYS_TOTAL = 64;
|
|
1399
1456
|
_writeToPty = writeToPty;
|
|
1400
1457
|
_onPtyData = onPtyData;
|
|
1401
1458
|
// Chat-composer busy queue (terminal-hidden PTY mode): holds Enter-while-busy messages
|
|
@@ -1417,6 +1474,18 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1417
1474
|
terminalWss = wss;
|
|
1418
1475
|
const wssScratch = new WebSocketServer({ noServer: true });
|
|
1419
1476
|
|
|
1477
|
+
// /ws/mux dialogue-channel stream mux (WS-mux migration phase B). Each logical stream is
|
|
1478
|
+
// served by routes/events.js's serveEventStream — the SAME implementation behind the SSE
|
|
1479
|
+
// /events route, so a mux stream produces the identical frame sequence (SSE stays the
|
|
1480
|
+
// fallback transport). serveStream takes the sink + the open frame's raw query string,
|
|
1481
|
+
// re-parsed through the SAME paramsFromParsedUrl so the two transports never drift.
|
|
1482
|
+
const { createStreamMuxServer } = await import('./lib/ws-mux.js');
|
|
1483
|
+
streamMux = createStreamMuxServer({
|
|
1484
|
+
WebSocketServer,
|
|
1485
|
+
serveStream: (sink, qs) => serveEventStream(sink, paramsFromParsedUrl(new URL(`http://x/?${qs || ''}`)), deps),
|
|
1486
|
+
reportSwallowed,
|
|
1487
|
+
});
|
|
1488
|
+
|
|
1420
1489
|
// 多客户端共享 PTY 的尺寸冲突解决:
|
|
1421
1490
|
// 移动端优先——只要有移动端在线,PTY 始终使用移动端尺寸,
|
|
1422
1491
|
// PC 端的 resize 仅存储不生效,避免宽屏尺寸导致移动端乱码。
|
|
@@ -1473,7 +1542,15 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1473
1542
|
if (viewProject || viewInstance) {
|
|
1474
1543
|
try { attachPtyFor({ project: viewProject || undefined, instanceKey: viewInstance || undefined }); } catch { }
|
|
1475
1544
|
}
|
|
1545
|
+
// SDK multi-session (2026-10-12): remember this connection's viewed project so
|
|
1546
|
+
// broadcastWsMessage can route cwd-tagged SDK frames only to the client(s) watching
|
|
1547
|
+
// that workspace. No ?project= → bound view → receives every project (old behavior).
|
|
1548
|
+
// Set it directly in THIS upgrade's callback (fires once per connection, so reconnects
|
|
1549
|
+
// get re-stamped) — NOT via a one-shot wss.once('connection') listener, which would
|
|
1550
|
+
// only ever stamp the FIRST connection and leave every later/reconnected client
|
|
1551
|
+
// unstamped (receiving all workspaces' frames).
|
|
1476
1552
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
1553
|
+
try { ws._ccvViewProject = viewProject || null; } catch {}
|
|
1477
1554
|
wss.emit('connection', ws, req);
|
|
1478
1555
|
});
|
|
1479
1556
|
} else if (pathname === '/ws/terminal-scratch') {
|
|
@@ -1483,17 +1560,59 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1483
1560
|
socket.destroy();
|
|
1484
1561
|
return;
|
|
1485
1562
|
}
|
|
1486
|
-
//
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1563
|
+
// Single upgrade hand-off used by the accept and both reject branches below.
|
|
1564
|
+
const handOffScratch = () => {
|
|
1565
|
+
req.ccvScratchId = scratchId;
|
|
1566
|
+
wssScratch.handleUpgrade(req, socket, head, (ws) => {
|
|
1567
|
+
wssScratch.emit('connection', ws, req);
|
|
1568
|
+
});
|
|
1569
|
+
};
|
|
1570
|
+
// Multi-project (2026-10-12): ?project=/?instance= pin the scratch shell's cwd to
|
|
1571
|
+
// the VIEWED workspace — same resolution as every project-scoped READ route
|
|
1572
|
+
// (resolveViewRoot is sync: fs + sync registry read). No params → bound root.
|
|
1573
|
+
// Accepted: with no instance and a same-basename twin live, the name path can
|
|
1574
|
+
// resolve to the twin's dir (the single-live-PTY rule designed for READ routes);
|
|
1575
|
+
// an unresolvable/ambiguous name takes the fatal-toast path below instead, so a
|
|
1576
|
+
// wrong-cwd spawn is bounded to that deliberate arbitration, never silent.
|
|
1577
|
+
const vr = resolveViewRoot({
|
|
1578
|
+
projectParam: wsUrl.searchParams.get('project'),
|
|
1579
|
+
instanceParam: wsUrl.searchParams.get('instance'),
|
|
1580
|
+
boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
|
|
1581
|
+
loadWorkspaces,
|
|
1582
|
+
listLivePtys,
|
|
1583
|
+
});
|
|
1584
|
+
if (!vr.ok) {
|
|
1585
|
+
// Unresolvable/deleted/ambiguous workspace: do NOT bare-destroy — the client
|
|
1586
|
+
// would retry every 2s forever. Complete the upgrade, report once with a fatal
|
|
1587
|
+
// toast (client stops reconnecting), then close. Nothing spawns on this path.
|
|
1588
|
+
req.ccvScratchReject = vr.error || 'workspace unavailable';
|
|
1589
|
+
handOffScratch();
|
|
1491
1590
|
return;
|
|
1492
1591
|
}
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1592
|
+
// Normalize once: the bound branch of resolveViewRoot returns the root un-normalized,
|
|
1593
|
+
// named branches realpathSync — without this the same dir can key two buckets.
|
|
1594
|
+
let scratchCwd = vr.root;
|
|
1595
|
+
try { scratchCwd = realpathSync(vr.root); } catch { }
|
|
1596
|
+
// Hard caps (per-workspace + global) on the ptys Map size (running and
|
|
1597
|
+
// exited-not-yet-reaped entries included); an existing id+cwd reconnect is exempt
|
|
1598
|
+
// so it can't be locked out by its own still-alive entry. Rejection goes through
|
|
1599
|
+
// the same fatal-toast hand-off as a resolve failure (never a bare destroy) so the
|
|
1600
|
+
// client stops reconnecting instead of retrying every 2s against a full bucket.
|
|
1601
|
+
if (!hasScratchPty(scratchId, { cwd: scratchCwd })
|
|
1602
|
+
&& (getScratchPtyCount() >= MAX_SCRATCH_PTYS_TOTAL
|
|
1603
|
+
|| getScratchPtyCountForCwd(scratchCwd) >= MAX_SCRATCH_PTYS_PER_CWD)) {
|
|
1604
|
+
req.ccvScratchReject = 'scratch terminal limit reached';
|
|
1605
|
+
handOffScratch();
|
|
1606
|
+
return;
|
|
1607
|
+
}
|
|
1608
|
+
req.ccvScratchCwd = scratchCwd;
|
|
1609
|
+
handOffScratch();
|
|
1610
|
+
} else if (pathname === '/ws/mux') {
|
|
1611
|
+
// Dialogue-channel stream mux. Auth already enforced by decideAuth above (token from
|
|
1612
|
+
// ?token= / ccv_auth cookie — tokenInterceptor/appendToken put it on the ws:// URL
|
|
1613
|
+
// exactly like /ws/terminal). No socket-level ?project= stamp: each logical stream
|
|
1614
|
+
// carries its own scope in its `open` frame (one mux socket serves many workspaces).
|
|
1615
|
+
streamMux.handleUpgrade(req, socket, head);
|
|
1497
1616
|
} else {
|
|
1498
1617
|
socket.destroy();
|
|
1499
1618
|
}
|
|
@@ -1561,19 +1680,30 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1561
1680
|
// scratch 终端 WS:极简版,仅承载 input/resize/data/exit + 显式 kill;不掺杂 hook/SDK/preset
|
|
1562
1681
|
wssScratch.on('connection', async (ws, req) => {
|
|
1563
1682
|
const id = req.ccvScratchId;
|
|
1683
|
+
// Resolve-failure branch from the upgrade handler: report once, close, spawn nothing.
|
|
1684
|
+
// The client treats fatal toasts as stop-reconnecting (no 2s retry loop against a
|
|
1685
|
+
// deleted/ambiguous workspace).
|
|
1686
|
+
if (req.ccvScratchReject) {
|
|
1687
|
+
try { ws.send(JSON.stringify({ type: 'toast', message: req.ccvScratchReject, fatal: true })); } catch {}
|
|
1688
|
+
try { ws.close(); } catch {}
|
|
1689
|
+
return;
|
|
1690
|
+
}
|
|
1691
|
+
// cwd resolved once at upgrade time; every manager call for this connection uses it
|
|
1692
|
+
// so no call site can drift to a different (id-only/STARTUP_CWD) bucket.
|
|
1693
|
+
const opts = req.ccvScratchCwd ? { cwd: req.ccvScratchCwd } : undefined;
|
|
1564
1694
|
// 懒启动 scratch shell(首次连接才 spawn)
|
|
1565
1695
|
try {
|
|
1566
|
-
if (!getScratchState(id).running) {
|
|
1567
|
-
await spawnScratch(id);
|
|
1696
|
+
if (!getScratchState(id, opts).running) {
|
|
1697
|
+
await spawnScratch(id, opts);
|
|
1568
1698
|
}
|
|
1569
1699
|
} catch (err) {
|
|
1570
1700
|
try { ws.send(JSON.stringify({ type: 'toast', message: `scratch spawn failed: ${err.message}` })); } catch {}
|
|
1571
1701
|
}
|
|
1572
1702
|
|
|
1573
|
-
const state = getScratchState(id);
|
|
1574
|
-
try { ws.send(JSON.stringify({ type: 'state', running: state.running, exitCode: state.exitCode, shellBasename: getScratchShellBasename() })); } catch {}
|
|
1703
|
+
const state = getScratchState(id, opts);
|
|
1704
|
+
try { ws.send(JSON.stringify({ type: 'state', running: state.running, exitCode: state.exitCode, cwd: state.cwd, shellBasename: getScratchShellBasename() })); } catch {}
|
|
1575
1705
|
|
|
1576
|
-
const buffer = getScratchOutputBuffer(id);
|
|
1706
|
+
const buffer = getScratchOutputBuffer(id, opts);
|
|
1577
1707
|
if (buffer) {
|
|
1578
1708
|
try { ws.send(JSON.stringify({ type: 'data', data: buffer })); } catch {}
|
|
1579
1709
|
}
|
|
@@ -1590,7 +1720,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1590
1720
|
// 调用前须 floodGate.reset()(快照已含 pending 字节,残留会在快照后回灌重复)。
|
|
1591
1721
|
const sendScratchResync = () => {
|
|
1592
1722
|
if (ws.readyState !== 1) return;
|
|
1593
|
-
try { ws.send(JSON.stringify({ type: 'data-resync', data: getScratchOutputBuffer(id) })); } catch {}
|
|
1723
|
+
try { ws.send(JSON.stringify({ type: 'data-resync', data: getScratchOutputBuffer(id, opts) })); } catch {}
|
|
1594
1724
|
};
|
|
1595
1725
|
const resyncReqGate = createResyncNudgeGate({ cooldownMs: RESYNC_REQ_COOLDOWN_MS });
|
|
1596
1726
|
const bpGate = createBackpressureGate({
|
|
@@ -1635,25 +1765,25 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1635
1765
|
|
|
1636
1766
|
const removeDataListener = onScratchData(id, (data) => {
|
|
1637
1767
|
floodGate.offer(data);
|
|
1638
|
-
});
|
|
1768
|
+
}, opts);
|
|
1639
1769
|
|
|
1640
1770
|
const removeExitListener = onScratchExit(id, (exitCode) => {
|
|
1641
1771
|
if (ws.readyState === 1) {
|
|
1642
1772
|
try { ws.send(JSON.stringify({ type: 'exit', exitCode })); } catch {}
|
|
1643
1773
|
}
|
|
1644
|
-
});
|
|
1774
|
+
}, opts);
|
|
1645
1775
|
|
|
1646
1776
|
ws.on('message', async (raw) => {
|
|
1647
1777
|
try {
|
|
1648
1778
|
const msg = JSON.parse(raw.toString());
|
|
1649
1779
|
if (msg.type === 'input') {
|
|
1650
|
-
const s = getScratchState(id);
|
|
1780
|
+
const s = getScratchState(id, opts);
|
|
1651
1781
|
if (!s.running) {
|
|
1652
|
-
try { await spawnScratch(id); } catch {}
|
|
1782
|
+
try { await spawnScratch(id, opts); } catch {}
|
|
1653
1783
|
}
|
|
1654
|
-
writeScratch(id, msg.data);
|
|
1784
|
+
writeScratch(id, msg.data, opts);
|
|
1655
1785
|
} else if (msg.type === 'resize') {
|
|
1656
|
-
resizeScratch(id, msg.cols, msg.rows);
|
|
1786
|
+
resizeScratch(id, msg.cols, msg.rows, opts);
|
|
1657
1787
|
} else if (msg.type === 'resync-request') {
|
|
1658
1788
|
// 客户端 write-queue 积压丢弃后请求快照对齐(utils/terminalWriteQueue.js onTrim)
|
|
1659
1789
|
if (resyncReqGate.shouldNudge()) {
|
|
@@ -1662,7 +1792,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1662
1792
|
}
|
|
1663
1793
|
} else if (msg.type === 'kill') {
|
|
1664
1794
|
// 用户主动关闭 tab:杀 pty(killScratch 内部 ptys.delete 后配额自动释放);前端会随后 close ws
|
|
1665
|
-
killScratch(id);
|
|
1795
|
+
killScratch(id, opts);
|
|
1666
1796
|
}
|
|
1667
1797
|
} catch {}
|
|
1668
1798
|
});
|
|
@@ -1672,8 +1802,10 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1672
1802
|
floodGate.dispose();
|
|
1673
1803
|
removeDataListener();
|
|
1674
1804
|
removeExitListener();
|
|
1675
|
-
// pty
|
|
1676
|
-
//
|
|
1805
|
+
// The pty itself is deliberately NOT killed (kept for refresh-reconnect and for
|
|
1806
|
+
// per-workspace tab-group restore on switch-back); teardown happens via the kill
|
|
1807
|
+
// message or /api/workspaces/stop. Cap accounting is driven by the ptys Map size,
|
|
1808
|
+
// so no connection set is maintained here.
|
|
1677
1809
|
});
|
|
1678
1810
|
});
|
|
1679
1811
|
|
|
@@ -1719,6 +1851,19 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1719
1851
|
} catch {}
|
|
1720
1852
|
}
|
|
1721
1853
|
|
|
1854
|
+
// SDK multi-session (2026-10-12): replay frames are per-session too. A reconnected
|
|
1855
|
+
// client must only restore the workspace it is VIEWING — otherwise a background
|
|
1856
|
+
// workspace's approval/spinner/queue would pop into the foreground. Filter each
|
|
1857
|
+
// snapshot by this connection's `_ccvViewProject` (bound view / no ?project= → all).
|
|
1858
|
+
const _viewProj = ws._ccvViewProject || null;
|
|
1859
|
+
const _base = (c) => (typeof c === 'string' && c) ? c.replace(/\\/g, '/').split('/').filter(Boolean).pop() : null;
|
|
1860
|
+
const _sdkFrameForView = (frame) => {
|
|
1861
|
+
if (!frame || typeof frame !== 'object') return true; // no cwd → view-agnostic, replay
|
|
1862
|
+
if (!_viewProj) return true; // bound view → all workspaces
|
|
1863
|
+
const fproj = (typeof frame.cwd === 'string' && frame.cwd) ? _base(frame.cwd) : null;
|
|
1864
|
+
return !fproj || fproj === _viewProj; // no-cwd frame → replay; else must match
|
|
1865
|
+
};
|
|
1866
|
+
|
|
1722
1867
|
// Replay SDK 模式 pendings(sdk-manager canUseTool 的 ask/plan/perm):
|
|
1723
1868
|
// 与上面 perm-hook 同理,但快照来自 sdk-manager(_pendingApprovals)。
|
|
1724
1869
|
// ask 类带 startedAt/timeoutMs —— 与 ask-hook replay 同款剩余时间重算。
|
|
@@ -1726,6 +1871,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1726
1871
|
for (const p of _sdkGetPendingApprovals() || []) {
|
|
1727
1872
|
if (!p || !p.replay || !p.id) continue;
|
|
1728
1873
|
const payload = { ...p.replay };
|
|
1874
|
+
if (!_sdkFrameForView(payload)) continue; // 别的工作区的待审批不进本视图
|
|
1729
1875
|
if (p.kind === 'ask') {
|
|
1730
1876
|
payload.startedAt = now; // 让前端按"还剩 remaining"起算
|
|
1731
1877
|
payload.timeoutMs = p.remainingMs; // 剩余可用时间
|
|
@@ -1738,17 +1884,46 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1738
1884
|
// surface and model info without waiting for the next session.
|
|
1739
1885
|
if (isSdkMode && _sdkGetInitSnapshot) {
|
|
1740
1886
|
const initSnap = _sdkGetInitSnapshot();
|
|
1741
|
-
if (initSnap) {
|
|
1887
|
+
if (initSnap && _sdkFrameForView(initSnap)) {
|
|
1742
1888
|
try { ws.send(JSON.stringify(initSnap)); } catch {}
|
|
1743
1889
|
}
|
|
1744
1890
|
}
|
|
1745
1891
|
|
|
1892
|
+
// Replay the last sdk-session-state frame so a page refreshed mid-turn restores
|
|
1893
|
+
// the spinner immediately (sdk-init snapshot parity; null → no frame, client
|
|
1894
|
+
// stays at its idle default).
|
|
1895
|
+
if (isSdkMode && _sdkGetSessionStateSnapshot) {
|
|
1896
|
+
const stateSnap = _sdkGetSessionStateSnapshot();
|
|
1897
|
+
if (stateSnap && _sdkFrameForView(stateSnap)) {
|
|
1898
|
+
try { ws.send(JSON.stringify(stateSnap)); } catch {}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
// Replay the last sdk-context-usage snapshot so a reconnected panel restores the
|
|
1903
|
+
// latest numbers (null snapshot → no frame, the panel falls back to its HTTP pull).
|
|
1904
|
+
if (isSdkMode && _sdkGetContextUsageSnapshot) {
|
|
1905
|
+
const cuSnap = _sdkGetContextUsageSnapshot();
|
|
1906
|
+
if (cuSnap && _sdkFrameForView(cuSnap)) {
|
|
1907
|
+
try { ws.send(JSON.stringify(cuSnap)); } catch {}
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
// Replay the last sdk-mcp-status snapshot (sdk-context-usage parity). A null snapshot
|
|
1912
|
+
// sends no frame; the panel then falls back to its HTTP pull.
|
|
1913
|
+
if (isSdkMode && _sdkGetMcpStatusSnapshot) {
|
|
1914
|
+
const mcpSnap = _sdkGetMcpStatusSnapshot();
|
|
1915
|
+
if (mcpSnap && _sdkFrameForView(mcpSnap)) {
|
|
1916
|
+
try { ws.send(JSON.stringify(mcpSnap)); } catch {}
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1746
1920
|
// Replay the chat-composer busy queue so a refreshed/reconnected page restores the
|
|
1747
1921
|
// floating queued-message bubbles (server is the source of truth in both modes).
|
|
1748
1922
|
{
|
|
1749
1923
|
const qItems = (isSdkMode && _sdkGetQueueSnapshot) ? _sdkGetQueueSnapshot() : chatQueue.getSnapshot();
|
|
1750
1924
|
if (Array.isArray(qItems) && qItems.length) {
|
|
1751
|
-
|
|
1925
|
+
const qFrame = { type: 'queue-state', items: qItems };
|
|
1926
|
+
if (_sdkFrameForView(qFrame)) { try { ws.send(JSON.stringify(qFrame)); } catch {} }
|
|
1752
1927
|
}
|
|
1753
1928
|
}
|
|
1754
1929
|
|
|
@@ -1877,11 +2052,17 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1877
2052
|
ws.on('message', async (raw) => {
|
|
1878
2053
|
try {
|
|
1879
2054
|
const msg = JSON.parse(raw.toString());
|
|
1880
|
-
// SDK 模式没有 PTY:PTY 专属消息(input/sequential input/resync/resize
|
|
2055
|
+
// SDK 模式没有 PTY:PTY 专属消息(input/sequential input/resync/resize)一律 no-op,
|
|
1881
2056
|
// 否则 'input' 会在无会话时触发 spawnShell() 拉出一个用户看不见的裸 shell。
|
|
1882
2057
|
// sdk-*/ask-*/perm-*/image-* 等控制消息不受影响。scratch WS 是独立 handler,不受此闸门影响。
|
|
1883
|
-
|
|
1884
|
-
|
|
2058
|
+
// attach 例外:SDK 模式虽无 PTY 可挂,但 attach 携带的是视图身份变更(chip 切换),
|
|
2059
|
+
// 必须刷新该连接的 cwd-路由 stamp,否则广播过滤(_ccvViewProject)停留在旧视图,
|
|
2060
|
+
// 新视图的 sdk-session-state/审批/队列帧会被静默丢弃。
|
|
2061
|
+
if (msg.type === 'attach') {
|
|
2062
|
+
try { ws._ccvViewProject = (typeof msg.project === 'string' && msg.project) || null; } catch {}
|
|
2063
|
+
if (isSdkMode) return;
|
|
2064
|
+
} else if (isSdkMode && (msg.type === 'input' || msg.type === 'input-sequential'
|
|
2065
|
+
|| msg.type === 'resync-request' || msg.type === 'resize')) {
|
|
1885
2066
|
return;
|
|
1886
2067
|
}
|
|
1887
2068
|
if (msg.type === 'input') {
|
|
@@ -2229,9 +2410,11 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2229
2410
|
});
|
|
2230
2411
|
}
|
|
2231
2412
|
} else if (msg.type === 'sdk-user-message') {
|
|
2232
|
-
// User message in SDK mode — relay to sdk-manager
|
|
2413
|
+
// User message in SDK mode — relay to sdk-manager, addressed to the viewed
|
|
2414
|
+
// workspace's session (multi-session). msg.project is the workspace basename.
|
|
2233
2415
|
if (_sdkSendUserMessage && msg.text) {
|
|
2234
|
-
|
|
2416
|
+
const anchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
|
|
2417
|
+
_sdkSendUserMessage(msg.text, anchor).catch(err => {
|
|
2235
2418
|
console.error('[SDK] sendUserMessage error:', err.message);
|
|
2236
2419
|
});
|
|
2237
2420
|
}
|
|
@@ -2241,7 +2424,8 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2241
2424
|
// fetch hook → streamingState → SSE), same as PTY mode; sdk-manager
|
|
2242
2425
|
// no longer emits its own streaming_status.
|
|
2243
2426
|
if (isSdkMode && _sdkInterruptTurn) {
|
|
2244
|
-
const
|
|
2427
|
+
const anchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
|
|
2428
|
+
const cancelled = _sdkInterruptTurn(anchor) || [];
|
|
2245
2429
|
_broadcastApprovalCloses(cancelled, 'Turn interrupted');
|
|
2246
2430
|
} else {
|
|
2247
2431
|
// sdk-interrupt 收到却没生效:客户端按 props.sdkMode 发了中断,但本端不是 SDK 模式或
|
|
@@ -2257,7 +2441,8 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2257
2441
|
if (typeof msg.text === 'string' && msg.text) {
|
|
2258
2442
|
if (isSdkMode) {
|
|
2259
2443
|
if (_sdkSendUserMessage) {
|
|
2260
|
-
|
|
2444
|
+
const anchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
|
|
2445
|
+
_sdkSendUserMessage(msg.text, anchor).catch(err => {
|
|
2261
2446
|
console.error('[SDK] queue-message relay error:', err.message);
|
|
2262
2447
|
});
|
|
2263
2448
|
}
|
|
@@ -2273,7 +2458,10 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2273
2458
|
if (qid) {
|
|
2274
2459
|
if (isSdkMode) {
|
|
2275
2460
|
if (_sdkSendQueuedNow) {
|
|
2276
|
-
|
|
2461
|
+
// Multi-session: address the bubble's own workspace (the viewed project), not
|
|
2462
|
+
// the global active session — bubbles are cwd-scoped, the action must match.
|
|
2463
|
+
const qAnchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
|
|
2464
|
+
try { _broadcastApprovalCloses(_sdkSendQueuedNow(qid, qAnchor) || [], 'Queued message sent now'); }
|
|
2277
2465
|
catch (err) { reportSwallowed('server.queue-send-now', err); }
|
|
2278
2466
|
}
|
|
2279
2467
|
} else {
|
|
@@ -2284,7 +2472,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2284
2472
|
const qid = (typeof msg.id === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(msg.id)) ? msg.id : null;
|
|
2285
2473
|
if (qid) {
|
|
2286
2474
|
if (isSdkMode) {
|
|
2287
|
-
if (_sdkRemoveQueued) { try { _sdkRemoveQueued(qid); } catch (err) { reportSwallowed('server.queue-remove', err); } }
|
|
2475
|
+
if (_sdkRemoveQueued) { const qAnchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined; try { _sdkRemoveQueued(qid, qAnchor); } catch (err) { reportSwallowed('server.queue-remove', err); } }
|
|
2288
2476
|
} else {
|
|
2289
2477
|
try { chatQueue.remove(qid); } catch (err) { reportSwallowed('server.queue-remove', err); }
|
|
2290
2478
|
}
|
|
@@ -2293,7 +2481,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2293
2481
|
// Session/workspace switch: drop the parked queue WITHOUT interrupting a running
|
|
2294
2482
|
// turn. (Stop semantics live elsewhere: PTY queue-suppress, SDK sdk-interrupt.)
|
|
2295
2483
|
if (isSdkMode) {
|
|
2296
|
-
if (_sdkClearQueued) { try { _sdkClearQueued(); } catch (err) { reportSwallowed('server.queue-clear', err); } }
|
|
2484
|
+
if (_sdkClearQueued) { const qAnchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined; try { _sdkClearQueued(qAnchor); } catch (err) { reportSwallowed('server.queue-clear', err); } }
|
|
2297
2485
|
} else {
|
|
2298
2486
|
try { chatQueue.clear(); } catch (err) { reportSwallowed('server.queue-clear', err); }
|
|
2299
2487
|
}
|
|
@@ -2302,6 +2490,16 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2302
2490
|
// (SDK mode handles this inside sdk-manager.interruptTurn; no PTY queue exists.)
|
|
2303
2491
|
if (!isSdkMode) {
|
|
2304
2492
|
try { chatQueue.suppressNextDrain(); } catch (err) { reportSwallowed('server.queue-suppress', err); }
|
|
2493
|
+
// The Stop hook does NOT fire on user interrupt (documented Claude Code
|
|
2494
|
+
// behavior), so the web Stop button must clear the turn-level loading
|
|
2495
|
+
// flag itself or the spinner would outlive the interrupted turn.
|
|
2496
|
+
// Per-workspace: the message carries the view's project/instance so
|
|
2497
|
+
// the clear lands on exactly that scope (absent ⇒ bound).
|
|
2498
|
+
const qScope = normalizeProducerScope(
|
|
2499
|
+
{ project: (typeof msg.project === 'string' && msg.project) || null, instance: (typeof msg.instanceKey === 'string' && msg.instanceKey) || null },
|
|
2500
|
+
_projectName || '',
|
|
2501
|
+
);
|
|
2502
|
+
_setTurnActive(false, null, qScope);
|
|
2305
2503
|
}
|
|
2306
2504
|
} else if (msg.type === 'image-remove-notify' || msg.type === 'image-upload-notify') {
|
|
2307
2505
|
// Security: only allow paths within upload directories, reject traversal
|
|
@@ -2418,6 +2616,9 @@ export function getAuthConfig() {
|
|
|
2418
2616
|
// 的 hook(如 Stop 输入带 `is_final_turn:true`、SDK `result` 带 `subtype:'final'`、
|
|
2419
2617
|
// 或 `onUserTurnEnd` 回调)就拆掉。拆除时一并 grep 上面 SUNSET-MARKER 标签
|
|
2420
2618
|
// 定位所有相关代码(server / voicePackPlayer / test / history.md)。
|
|
2619
|
+
// 相邻信号说明(2026-10-12):本防抖 `turn_end` 只服务语音包/IM/队列;对话 spinner 的
|
|
2620
|
+
// 回合级信号是 `turn_active`(PTY,见下方 Turn-level active state 段)与 SDK 的
|
|
2621
|
+
// `sdk-session-state`(WS)。三者语义不同,勿混用。
|
|
2421
2622
|
//
|
|
2422
2623
|
// **用户原始语义**:「等 10 秒钟。如果这 10 秒钟内没有新的请求发起,那么就认为
|
|
2423
2624
|
// 任务真的已经完成了,开始执行播放」—— 隐含:10s 内**有**新请求 → 不算完成 →
|
|
@@ -2453,19 +2654,82 @@ const TURN_END_DEBOUNCE_MS = (() => {
|
|
|
2453
2654
|
})();
|
|
2454
2655
|
let _isStopping = false;
|
|
2455
2656
|
let _lastSdkActive = false;
|
|
2456
|
-
|
|
2657
|
+
// Per-scope CLI streaming edge state ('' = bound — the legacy single flag).
|
|
2658
|
+
const _lastCliActiveByScope = new Map();
|
|
2457
2659
|
let _onTurnEndBroadcastForTests = null;
|
|
2458
2660
|
|
|
2459
2661
|
function _normalizeKey(sessionId) {
|
|
2460
2662
|
return (typeof sessionId === 'string' && sessionId) ? sessionId : null;
|
|
2461
2663
|
}
|
|
2462
2664
|
|
|
2463
|
-
|
|
2665
|
+
// ── Per-workspace scope resolution (2026-10) ────────────────────────────────
|
|
2666
|
+
// ONE resolver for every hook-origin frame (turn_end / task_update / turn
|
|
2667
|
+
// boundary): the same precedence everywhere so a workspace's frames can never
|
|
2668
|
+
// split across two scope buckets. Order:
|
|
2669
|
+
// 1. sessionId → the live PTY record that owns it (exact project+instance);
|
|
2670
|
+
// 2. payload.cwd → projectKeyForCwd (no instance — hooks report no instance);
|
|
2671
|
+
// 3. the bound project ('' sentinel).
|
|
2672
|
+
// A dead record / a foreign sessionId yields null from the resolver and falls
|
|
2673
|
+
// through to the cwd/bound hints, never to a corpse scope.
|
|
2674
|
+
function _resolveScopeForHookPayload(payload) {
|
|
2675
|
+
const p = payload || {};
|
|
2676
|
+
try {
|
|
2677
|
+
const sid = _normalizeKey(p.sessionId);
|
|
2678
|
+
if (sid) {
|
|
2679
|
+
// Lazy import kept out of the module graph (pty-manager pulls node-pty).
|
|
2680
|
+
const pm = _ptyManagerForScope || null;
|
|
2681
|
+
const rec = pm && typeof pm.resolvePtyForSession === 'function' ? pm.resolvePtyForSession(sid) : null;
|
|
2682
|
+
if (rec && rec.project) {
|
|
2683
|
+
return normalizeProducerScope({ project: rec.project, instance: rec.instanceKey || null }, _projectName || '');
|
|
2684
|
+
}
|
|
2685
|
+
}
|
|
2686
|
+
} catch (err) { reportSwallowed('scope.resolve-sid', err); }
|
|
2687
|
+
const cwd = (typeof p.cwd === 'string' && p.cwd) || '';
|
|
2688
|
+
if (cwd) {
|
|
2689
|
+
try {
|
|
2690
|
+
const proj = projectKeyForCwd(cwd);
|
|
2691
|
+
if (proj) {
|
|
2692
|
+
// Upgrade to the exact instance ONLY when exactly one live record owns
|
|
2693
|
+
// this cwd — a project-level frame (instance=null) reaches EVERY viewer
|
|
2694
|
+
// of the project, over-delivering a same-cwd twin's hook to its sibling.
|
|
2695
|
+
// Two live twins → no honest pick → keep project-level (today's behavior,
|
|
2696
|
+
// and the twins' viewers share the cwd anyway).
|
|
2697
|
+
const pm = _ptyManagerForScope || null;
|
|
2698
|
+
const inst = pm && typeof pm.resolveInstanceForCwd === 'function' ? pm.resolveInstanceForCwd(cwd) : null;
|
|
2699
|
+
if (!inst && pm && typeof pm.resolvePtyForSession === 'function') {
|
|
2700
|
+
// Diagnostic only: a project-level frame for a cwd that HAS live
|
|
2701
|
+
// instances is the over-delivery window — observable on real hardware.
|
|
2702
|
+
try {
|
|
2703
|
+
const liveCount = (pm.listLivePtys?.() || []).filter((r) => r && r.cwd && projectKeyForCwd(r.cwd) === proj).length;
|
|
2704
|
+
if (liveCount > 1) reportSwallowed('scope.cwd-ambiguous', new Error(`cwd has ${liveCount} live instances, scope stays project-level`), { cwd: proj });
|
|
2705
|
+
} catch { /* best-effort */ }
|
|
2706
|
+
}
|
|
2707
|
+
return normalizeProducerScope({ project: proj, instance: inst }, _projectName || '');
|
|
2708
|
+
}
|
|
2709
|
+
} catch (err) { reportSwallowed('scope.resolve-cwd', err); }
|
|
2710
|
+
}
|
|
2711
|
+
return { project: '', instance: null };
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// Cached pty-manager namespace (first successful lazy import). Scope resolution
|
|
2715
|
+
// is synchronous (hook POST handlers are not async); the import completes at
|
|
2716
|
+
// startup via _warmScopeResolver, so production never sees the null window.
|
|
2717
|
+
let _ptyManagerForScope = null;
|
|
2718
|
+
async function _warmScopeResolver() {
|
|
2719
|
+
try { _ptyManagerForScope = await import('./pty-manager.js'); }
|
|
2720
|
+
catch (err) { reportSwallowed('scope.resolver-warm', err); }
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
function _emitTurnEnd(sessionId, ts, transcriptPath = null, scope = null) {
|
|
2464
2724
|
const sid = _normalizeKey(sessionId);
|
|
2465
2725
|
const t = ts || Date.now();
|
|
2726
|
+
const sc = scope || { project: '', instance: null };
|
|
2466
2727
|
try {
|
|
2467
2728
|
if (clients.length > 0 && sendEventToClients) {
|
|
2468
|
-
|
|
2729
|
+
const target = sc.project
|
|
2730
|
+
? filterClientsByScope(clients, sc.project, sc.instance, _projectName || '')
|
|
2731
|
+
: _filterClientsByViewProject(clients, _projectName || '');
|
|
2732
|
+
sendEventToClients(target, 'turn_end', { sessionId: sid, ts: t, project: sc.project || (_projectName || ''), instance: sc.instance || null });
|
|
2469
2733
|
}
|
|
2470
2734
|
// Forward the (clean) assistant reply for this turn to whichever IM bridge owns the in-flight
|
|
2471
2735
|
// turn, if any. Fire-and-forget: a bridge failure must never affect SSE broadcast.
|
|
@@ -2483,10 +2747,13 @@ function _emitTurnEnd(sessionId, ts, transcriptPath = null) {
|
|
|
2483
2747
|
}
|
|
2484
2748
|
}
|
|
2485
2749
|
|
|
2486
|
-
function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null) {
|
|
2750
|
+
function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null, scope = null) {
|
|
2487
2751
|
if (_isStopping) return;
|
|
2488
2752
|
const sid = _normalizeKey(sessionId);
|
|
2489
2753
|
const t = ts || Date.now();
|
|
2754
|
+
// Resolve the scope ONCE at POST time: the debounced emit must not re-derive
|
|
2755
|
+
// it from live state that a later switch may have changed.
|
|
2756
|
+
const sc = scope || { project: '', instance: null };
|
|
2490
2757
|
// chat-queue drain trigger #1: the Stop-hook POST arrival IS the authoritative
|
|
2491
2758
|
// "query() ended" signal — don't wait out the SSE debounce window (default 10s),
|
|
2492
2759
|
// or freshly-parked bubbles would sit visibly inert after every turn. The queue's
|
|
@@ -2499,10 +2766,10 @@ function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null) {
|
|
|
2499
2766
|
if (existing) clearTimeout(existing.timer);
|
|
2500
2767
|
const timer = setTimeout(() => {
|
|
2501
2768
|
_pendingTurnEndTimers.delete(sid);
|
|
2502
|
-
_emitTurnEnd(sid, t, transcriptPath);
|
|
2769
|
+
_emitTurnEnd(sid, t, transcriptPath, sc);
|
|
2503
2770
|
}, TURN_END_DEBOUNCE_MS);
|
|
2504
2771
|
if (typeof timer.unref === 'function') timer.unref();
|
|
2505
|
-
_pendingTurnEndTimers.set(sid, { timer, ts: t });
|
|
2772
|
+
_pendingTurnEndTimers.set(sid, { timer, ts: t, scopeKey: scopeKeyOf(sc.project, sc.instance) });
|
|
2506
2773
|
}
|
|
2507
2774
|
|
|
2508
2775
|
// Task-checklist broadcast (task-bridge.js → /api/task-event → deps.onTaskEvent).
|
|
@@ -2511,28 +2778,40 @@ function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null) {
|
|
|
2511
2778
|
// trailing debounce coalesces multi-task flips (e.g. a batch TaskCreate turn)
|
|
2512
2779
|
// into one SSE frame.
|
|
2513
2780
|
const TASK_UPDATE_DEBOUNCE_MS = 120;
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2781
|
+
// Per-workspace: one debounce timer PER SCOPE — a single shared timer let a
|
|
2782
|
+
// later scope's schedule clearTimeout an earlier scope's pending frame and
|
|
2783
|
+
// drop it whole (a scope with a single TaskCreated then going quiet would
|
|
2784
|
+
// never show it). Mirrored after _pendingTurnEndTimers.
|
|
2785
|
+
const _taskBroadcastTimers = new Map(); // scopeKey → timer
|
|
2786
|
+
|
|
2787
|
+
function _emitTaskUpdate(scopeKey = BOUND_SCOPE) {
|
|
2788
|
+
if (_isStopping) return; // frozen state machine after stop: no late frames
|
|
2517
2789
|
if (clients.length === 0 || !sendEventToClients) return;
|
|
2518
2790
|
// Parity with _emitTurnEnd: a broadcast failure must never surface as an
|
|
2519
2791
|
// uncaught exception from the debounce timer.
|
|
2520
2792
|
try {
|
|
2521
|
-
const snap = getTaskSnapshot();
|
|
2522
|
-
|
|
2793
|
+
const snap = getTaskSnapshot(scopeKey);
|
|
2794
|
+
const sc = parseScopeKey(scopeKey);
|
|
2795
|
+
const target = sc.project
|
|
2796
|
+
? filterClientsByScope(clients, sc.project, sc.instance, _projectName || '')
|
|
2797
|
+
: _filterClientsByViewProject(clients, _projectName || '');
|
|
2798
|
+
sendEventToClients(target, 'task_update', { sessionId: snap.sessionId, tasks: snap.tasks, ts: Date.now(), project: sc.project || (_projectName || ''), instance: sc.instance || null });
|
|
2523
2799
|
} catch (err) {
|
|
2524
2800
|
console.warn('[task-update] broadcast failed:', err && err.message);
|
|
2525
2801
|
}
|
|
2526
2802
|
}
|
|
2527
2803
|
|
|
2528
|
-
function _scheduleTaskUpdateBroadcast() {
|
|
2804
|
+
function _scheduleTaskUpdateBroadcast(scopeKey = BOUND_SCOPE) {
|
|
2529
2805
|
if (_isStopping) return;
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2806
|
+
const key = scopeKey || BOUND_SCOPE;
|
|
2807
|
+
const existing = _taskBroadcastTimers.get(key);
|
|
2808
|
+
if (existing) clearTimeout(existing);
|
|
2809
|
+
const timer = setTimeout(() => {
|
|
2810
|
+
_taskBroadcastTimers.delete(key);
|
|
2811
|
+
_emitTaskUpdate(key);
|
|
2534
2812
|
}, TASK_UPDATE_DEBOUNCE_MS);
|
|
2535
|
-
if (typeof
|
|
2813
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
2814
|
+
_taskBroadcastTimers.set(key, timer);
|
|
2536
2815
|
}
|
|
2537
2816
|
|
|
2538
2817
|
// 直接丢弃所有 pending —— 用于两条路径:(1) `_doStop` shutdown,(2) rising-edge 新请求
|
|
@@ -2543,26 +2822,139 @@ function _cancelAllPendingTurnEndBroadcasts() {
|
|
|
2543
2822
|
_pendingTurnEndTimers.clear();
|
|
2544
2823
|
}
|
|
2545
2824
|
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2825
|
+
// Cancel the pending turn_end broadcasts of ONE scope (rising-edge new request
|
|
2826
|
+
// in that scope — by the user's original semantics "a new request within 10s ⇒
|
|
2827
|
+
// not really finished ⇒ don't play"). Other scopes' pending timers are
|
|
2828
|
+
// untouched (a foreign workspace's turn must never cancel this one's).
|
|
2829
|
+
function _cancelPendingTurnEndForScope(scopeKey) {
|
|
2830
|
+
if (_pendingTurnEndTimers.size === 0) return;
|
|
2831
|
+
for (const [sid, entry] of _pendingTurnEndTimers) {
|
|
2832
|
+
if ((entry.scopeKey || BOUND_SCOPE) !== scopeKey) continue;
|
|
2833
|
+
clearTimeout(entry.timer);
|
|
2834
|
+
_pendingTurnEndTimers.delete(sid);
|
|
2835
|
+
}
|
|
2836
|
+
}
|
|
2837
|
+
|
|
2838
|
+
function _onStreamingActivated(scopeKey = BOUND_SCOPE) {
|
|
2839
|
+
_cancelPendingTurnEndForScope(scopeKey);
|
|
2551
2840
|
// A new turn also lifts the chat-queue Stop-park: parked bubbles resume auto-draining
|
|
2552
2841
|
// at the next turn end.
|
|
2553
2842
|
try { chatQueue.onStreamingActivated(); } catch { /* best-effort */ }
|
|
2554
2843
|
}
|
|
2555
2844
|
|
|
2845
|
+
// ── Turn-level active state ────────────────────────────────────────────────
|
|
2846
|
+
// `streamingState.active` flips per API call, so the spinner dies during local
|
|
2847
|
+
// tool execution between calls (see turn-end-bridge.js header). The turn-level
|
|
2848
|
+
// truth lives in interceptor.js (`turnActive` + setTurnActive, shared by PTY
|
|
2849
|
+
// hook paths here and the SDK push path in cli.js) so it survives server.js
|
|
2850
|
+
// stop/start cycles the same way streamingState does.
|
|
2851
|
+
// PTY: set by the UserPromptSubmit hook (task-bridge → /api/task-event),
|
|
2852
|
+
// cleared by the raw Stop-hook POST arrival (pre-debounce, pre-SSE) or an
|
|
2853
|
+
// explicit user interrupt. No streaming rising-edge fallback by design:
|
|
2854
|
+
// housekeeping/telemetry requests after query() end would re-light it for the
|
|
2855
|
+
// whole staleness window (see HISTORY note at _scheduleTurnEndBroadcast).
|
|
2856
|
+
// Per-workspace (2026-10): the flag is bucketed per producer scope ('' = the
|
|
2857
|
+
// bound project — the legacy singleton). Broadcasts/replay reach exactly that
|
|
2858
|
+
// scope's viewers, so a parallel workspace's prompt lights ITS OWN spinner
|
|
2859
|
+
// (previously bound-only — a chip-switched parallel view saw none).
|
|
2860
|
+
//
|
|
2861
|
+
// Heartbeat: while any scope's flag is up, re-broadcast `turn_active` per scope
|
|
2862
|
+
// every TURN_ACTIVE_HEARTBEAT_MS (each scope with its own session tag). Local
|
|
2863
|
+
// tool execution (Bash > 30s builds/tests) produces NO turn-level frames and NO
|
|
2864
|
+
// API traffic — without a heartbeat the client's staleness escape cannot
|
|
2865
|
+
// distinguish "silent tool" from "lost Stop POST" and would kill the spinner
|
|
2866
|
+
// mid-turn. The heartbeat rides the same view-project filter; when a scope's
|
|
2867
|
+
// flag drops (Stop POST / interrupt) it leaves the iteration, and the client
|
|
2868
|
+
// backstop still catches a process-death/stuck-server within one window.
|
|
2869
|
+
|
|
2870
|
+
// Pure predicate (unit-tested): does this /api/task-event payload mark a new
|
|
2871
|
+
// main-agent user prompt? Mirrors task-state.js's shouldResetTasksOnPrompt gate:
|
|
2872
|
+
// agentId excludes teammate/subagent when present, but agentId is NOT reliably
|
|
2873
|
+
// present on UserPromptSubmit (task-bridge header), so the sessionId check is the
|
|
2874
|
+
// load-bearing one — a prompt from a DIFFERENT session than the one we track must
|
|
2875
|
+
// not light the main view. currentSessionId follows the same convention as
|
|
2876
|
+
// task-state: null window (nothing tracked yet) passes, matching the checklist.
|
|
2877
|
+
export function isTurnStartTaskEvent(payload, currentSessionId) {
|
|
2878
|
+
if (!payload || payload.hookEventName !== 'UserPromptSubmit') return false;
|
|
2879
|
+
if (payload.agentId) return false;
|
|
2880
|
+
if (!payload.sessionId) return false;
|
|
2881
|
+
if (currentSessionId && payload.sessionId !== currentSessionId) return false;
|
|
2882
|
+
return true;
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
const TURN_ACTIVE_HEARTBEAT_MS = 10 * 1000;
|
|
2886
|
+
let _turnActiveHeartbeat = null;
|
|
2887
|
+
// Per-scope session tags of the turns that lit each flag — the load-bearing
|
|
2888
|
+
// gate for UserPromptSubmit (agentId is unreliable on this hook; see
|
|
2889
|
+
// task-state.js). Keyed by scope ('' = bound). A Stop POST clears the flag but
|
|
2890
|
+
// keeps the tag: the NEXT prompt of the same session re-tags, and a stale tag
|
|
2891
|
+
// must not reject it (the tag only rejects prompts from a DIFFERENT live
|
|
2892
|
+
// session of the SAME scope).
|
|
2893
|
+
const _turnSessionIdByScope = new Map();
|
|
2894
|
+
|
|
2895
|
+
function _armTurnActiveHeartbeat() {
|
|
2896
|
+
if (_turnActiveHeartbeat) return; // already armed
|
|
2897
|
+
_turnActiveHeartbeat = setInterval(() => {
|
|
2898
|
+
const scopes = activeTurnScopes();
|
|
2899
|
+
if (_isStopping || scopes.length === 0) { _clearTurnActiveHeartbeat(); return; }
|
|
2900
|
+
// Broadcast each scope's own tag — a foreign session's sid must never ride
|
|
2901
|
+
// this scope's heartbeat (the connect replay validates against it).
|
|
2902
|
+
for (const key of scopes) {
|
|
2903
|
+
_broadcastTurnActive(true, _turnSessionIdByScope.get(key) || null, parseScopeKey(key));
|
|
2904
|
+
}
|
|
2905
|
+
}, TURN_ACTIVE_HEARTBEAT_MS);
|
|
2906
|
+
if (typeof _turnActiveHeartbeat.unref === 'function') _turnActiveHeartbeat.unref();
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2909
|
+
function _clearTurnActiveHeartbeat() {
|
|
2910
|
+
if (_turnActiveHeartbeat) { clearInterval(_turnActiveHeartbeat); _turnActiveHeartbeat = null; }
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
function _broadcastTurnActive(active, sessionId, scope = null) {
|
|
2914
|
+
if (_isStopping) return;
|
|
2915
|
+
if (!sendEventToClients || clients.length === 0) return;
|
|
2916
|
+
const sc = scope || { project: '', instance: null };
|
|
2917
|
+
try {
|
|
2918
|
+
sendEventToClients(
|
|
2919
|
+
sc.project
|
|
2920
|
+
? filterClientsByScope(clients, sc.project, sc.instance, _projectName || '')
|
|
2921
|
+
: _filterClientsByViewProject(clients, _projectName || ''),
|
|
2922
|
+
'turn_active',
|
|
2923
|
+
{ active, sessionId: sessionId || null, ts: Date.now(), project: sc.project || (_projectName || ''), instance: sc.instance || null },
|
|
2924
|
+
);
|
|
2925
|
+
} catch (err) { reportSwallowed('turn-active.broadcast', err); }
|
|
2926
|
+
}
|
|
2927
|
+
|
|
2928
|
+
// State-change guard: only broadcast on real transitions (SDK paths share the
|
|
2929
|
+
// turn-end entry points and would otherwise emit duplicate frames). The heartbeat
|
|
2930
|
+
// is armed/disarmed here so it tracks the flag exactly. Scope defaults to bound
|
|
2931
|
+
// ('') — the legacy single-workspace behavior is byte-identical.
|
|
2932
|
+
function _setTurnActive(active, sessionId, scope = null) {
|
|
2933
|
+
const sc = scope || { project: '', instance: null };
|
|
2934
|
+
const key = scopeKeyOf(sc.project, sc.instance);
|
|
2935
|
+
const bucket = turnStateFor(key);
|
|
2936
|
+
const next = !!active;
|
|
2937
|
+
if (next === bucket.active) return;
|
|
2938
|
+
bucket.active = next;
|
|
2939
|
+
if (key === BOUND_SCOPE) setTurnActive(next);
|
|
2940
|
+
if (next) _turnSessionIdByScope.set(key, sessionId || null);
|
|
2941
|
+
if (activeTurnScopes().length > 0) _armTurnActiveHeartbeat(); else _clearTurnActiveHeartbeat();
|
|
2942
|
+
_broadcastTurnActive(next, sessionId, sc);
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2556
2945
|
// 统一的 streaming-state 观察入口。production polling 和 SDK push 都调它,测试也走它,
|
|
2557
2946
|
// 不再为「测试桩」单独维护 mirror state(杜绝逻辑漂移)。返回是否检测到 rising edge。
|
|
2558
2947
|
// `_isStopping` 时直接返回 false:shutdown 中迟到的 tick 不应再去 flush。
|
|
2559
|
-
|
|
2948
|
+
// Per-workspace: scopeKey defaults to bound (''); the rising-edge cancel only
|
|
2949
|
+
// clears THAT scope's pending turn_end timers. The 'sdk' mode lane has no
|
|
2950
|
+
// production caller (tests only) and stays a single bound-scope flag.
|
|
2951
|
+
function _observeStreamingTick(activeNow, mode /* 'cli' | 'sdk' */, scopeKey = BOUND_SCOPE) {
|
|
2560
2952
|
if (_isStopping) return false;
|
|
2561
2953
|
const isActive = !!activeNow;
|
|
2562
|
-
const wasActive = mode === 'sdk' ? _lastSdkActive :
|
|
2563
|
-
if (mode === 'sdk') _lastSdkActive = isActive; else
|
|
2954
|
+
const wasActive = mode === 'sdk' ? _lastSdkActive : (_lastCliActiveByScope.get(scopeKey) || false);
|
|
2955
|
+
if (mode === 'sdk') _lastSdkActive = isActive; else _lastCliActiveByScope.set(scopeKey, isActive);
|
|
2564
2956
|
if (isActive && !wasActive) {
|
|
2565
|
-
_onStreamingActivated();
|
|
2957
|
+
_onStreamingActivated(scopeKey);
|
|
2566
2958
|
return true;
|
|
2567
2959
|
}
|
|
2568
2960
|
return false;
|
|
@@ -2579,16 +2971,38 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
|
|
|
2579
2971
|
_cancelAllPendingTurnEndBroadcasts();
|
|
2580
2972
|
_isStopping = false;
|
|
2581
2973
|
_lastSdkActive = false;
|
|
2582
|
-
|
|
2974
|
+
_lastCliActiveByScope.clear();
|
|
2583
2975
|
_stoppingPromise = null;
|
|
2584
2976
|
_onTurnEndBroadcastForTests = null;
|
|
2977
|
+
setTurnActive(false);
|
|
2978
|
+
_clearTurnActiveHeartbeat();
|
|
2979
|
+
_turnSessionIdByScope.clear();
|
|
2980
|
+
clearScopeState();
|
|
2585
2981
|
},
|
|
2586
2982
|
onBroadcast(fn) { _onTurnEndBroadcastForTests = fn; },
|
|
2587
2983
|
getPendingKeys() { return [..._pendingTurnEndTimers.keys()]; },
|
|
2588
2984
|
setIsStopping(v) { _isStopping = !!v; },
|
|
2589
|
-
observeStreamingTick(activeNow, mode = 'cli') { return _observeStreamingTick(activeNow, mode); },
|
|
2590
|
-
scheduleTurnEnd(sessionId, ts) { _scheduleTurnEndBroadcast(sessionId, ts); },
|
|
2985
|
+
observeStreamingTick(activeNow, mode = 'cli', scopeKey = BOUND_SCOPE) { return _observeStreamingTick(activeNow, mode, scopeKey); },
|
|
2986
|
+
scheduleTurnEnd(sessionId, ts, scope = null) { _scheduleTurnEndBroadcast(sessionId, ts, null, scope); },
|
|
2591
2987
|
getDebounceMs() { return TURN_END_DEBOUNCE_MS; },
|
|
2988
|
+
getPtyTurnActive(scopeKey = BOUND_SCOPE) { return turnStateFor(scopeKey).active; },
|
|
2989
|
+
setPtyTurnActive(v, sessionId = null, scope = null) { _setTurnActive(v, sessionId, scope); },
|
|
2990
|
+
// Routes the payload through the SAME predicate the deps.onTaskEvent closure
|
|
2991
|
+
// uses, so tests exercise the production gate rather than a mirror of it.
|
|
2992
|
+
notifyTaskEvent(payload) {
|
|
2993
|
+
const sc = _resolveScopeForHookPayload(payload);
|
|
2994
|
+
const key = scopeKeyOf(sc.project, sc.instance);
|
|
2995
|
+
if (isTurnStartTaskEvent(payload, _turnSessionIdByScope.get(key) || null)) {
|
|
2996
|
+
_turnSessionIdByScope.set(key, payload?.sessionId || null);
|
|
2997
|
+
_setTurnActive(true, payload?.sessionId, sc);
|
|
2998
|
+
}
|
|
2999
|
+
},
|
|
3000
|
+
// Clear drops the flag but KEEPS the session tag: the next prompt of the SAME
|
|
3001
|
+
// session re-lights fast (tag match), while a foreign session's prompt stays
|
|
3002
|
+
// rejected. The tag resets only on test reset / server stop.
|
|
3003
|
+
clearPtyTurnActive(scope = null) { _setTurnActive(false, null, scope); },
|
|
3004
|
+
getTurnSessionId(scopeKey = BOUND_SCOPE) { return _turnSessionIdByScope.get(scopeKey) || null; },
|
|
3005
|
+
resolveScopeForHookPayload(payload) { return _resolveScopeForHookPayload(payload); },
|
|
2592
3006
|
} : Object.freeze({
|
|
2593
3007
|
reset() {},
|
|
2594
3008
|
onBroadcast() {},
|
|
@@ -2597,15 +3011,25 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
|
|
|
2597
3011
|
observeStreamingTick() { return false; },
|
|
2598
3012
|
scheduleTurnEnd() {},
|
|
2599
3013
|
getDebounceMs() { return TURN_END_DEBOUNCE_MS; },
|
|
3014
|
+
getPtyTurnActive() { return false; },
|
|
3015
|
+
setPtyTurnActive() {},
|
|
3016
|
+
notifyTaskEvent() {},
|
|
3017
|
+
clearPtyTurnActive() {},
|
|
2600
3018
|
});
|
|
2601
3019
|
|
|
2602
3020
|
/**
|
|
2603
3021
|
* Schedule a debounced turn_end SSE broadcast. **Returns immediately**; actual SSE write
|
|
2604
3022
|
* happens up to TURN_END_DEBOUNCE_MS later (default 10s; clamped [100,60000]).
|
|
2605
|
-
*
|
|
3023
|
+
* 详细语义见上方 SUNSET-MARKER 注释块(ccv-turn-end-debounce)。
|
|
3024
|
+
* Per-workspace: `cwd` (SDK 'result' path) routes the frame to that workspace's
|
|
3025
|
+
* scope; absent ⇒ the bound project (legacy). (A pre-resolved scope can instead
|
|
3026
|
+
* be passed straight to `_scheduleTurnEndBroadcast` by the hook routes.)
|
|
2606
3027
|
*/
|
|
2607
|
-
export function broadcastTurnEnd(sessionId = null, ts = Date.now()) {
|
|
2608
|
-
|
|
3028
|
+
export function broadcastTurnEnd(sessionId = null, ts = Date.now(), transcriptPath = null, cwd = null) {
|
|
3029
|
+
const scope = cwd
|
|
3030
|
+
? normalizeProducerScope({ project: projectKeyForCwd(cwd) || '', instance: null }, _projectName || '')
|
|
3031
|
+
: null;
|
|
3032
|
+
_scheduleTurnEndBroadcast(sessionId, ts, transcriptPath, scope);
|
|
2609
3033
|
}
|
|
2610
3034
|
|
|
2611
3035
|
// 流式状态 SSE 推送定时器:检测 streamingState 变化并广播给所有客户端。
|
|
@@ -2621,27 +3045,48 @@ let _updateCheckTimer = null;
|
|
|
2621
3045
|
// Exported for tests: workspace-mode startViewer defers this timer to initPostLaunch, but
|
|
2622
3046
|
// lifecycle tests need to drive the streaming_status poll directly (setSdkStreamingState
|
|
2623
3047
|
// was retired in the wire architecture; the timer reads interceptor's streamingState).
|
|
3048
|
+
// One 500ms tick over ONE scope's streaming bucket: rising-edge cancel + the
|
|
3049
|
+
// changed-or-active broadcast, routed to exactly that scope's viewers.
|
|
3050
|
+
function _streamingTickOnce(scopeKey) {
|
|
3051
|
+
const st = streamingStateFor(scopeKey);
|
|
3052
|
+
const isActive = st.active;
|
|
3053
|
+
const wasActive = _lastCliActiveByScope.get(scopeKey) || false;
|
|
3054
|
+
// 统一走 _observeStreamingTick:内部负责 rising-edge cancel(丢弃该 scope 的
|
|
3055
|
+
// pending turn_end,不 flush)+ 更新该 scope 的 edge 记录。
|
|
3056
|
+
_observeStreamingTick(isActive, 'cli', scopeKey);
|
|
3057
|
+
const changed = wasActive !== isActive;
|
|
3058
|
+
if (changed || isActive) {
|
|
3059
|
+
const data = isActive
|
|
3060
|
+
? { ...st, elapsed: Date.now() - st.startTime }
|
|
3061
|
+
: { active: false };
|
|
3062
|
+
// Per-workspace: each scope's spinner reaches only its own viewers. The
|
|
3063
|
+
// bound scope ('') rides the legacy project filter — byte-identical to the
|
|
3064
|
+
// old bound-only broadcast when no parallel workspace exists.
|
|
3065
|
+
if (clients.length > 0 && sendEventToClients) {
|
|
3066
|
+
const sc = parseScopeKey(scopeKey);
|
|
3067
|
+
const target = sc.project
|
|
3068
|
+
? filterClientsByScope(clients, sc.project, sc.instance, _projectName || '')
|
|
3069
|
+
: _filterClientsByViewProject(clients, _projectName || '');
|
|
3070
|
+
sendEventToClients(target, 'streaming_status', { ...data, project: sc.project || (_projectName || ''), instance: sc.instance || null });
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
}
|
|
3074
|
+
|
|
2624
3075
|
export function startStreamingStatusTimer() {
|
|
2625
3076
|
if (_streamingStatusTimer) return;
|
|
2626
3077
|
_streamingStatusTimer = setInterval(() => {
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
// project's streaming flag (cross-project spinner bleed). (Per-project
|
|
2640
|
-
// streaming state is a follow-up; today a parallel view simply shows no
|
|
2641
|
-
// spinner, matching the pre-multi-PTY single-project behavior.)
|
|
2642
|
-
if (clients.length > 0 && sendEventToClients) {
|
|
2643
|
-
sendEventToClients(_filterClientsByViewProject(clients, _projectName || ''), 'streaming_status', data);
|
|
2644
|
-
}
|
|
3078
|
+
// Bound scope first (the common single-workspace case is exactly one
|
|
3079
|
+
// iteration), then any parallel-workspace scopes with their own buckets.
|
|
3080
|
+
_streamingTickOnce(BOUND_SCOPE);
|
|
3081
|
+
// Tick active scopes AND every scope we have a last-active edge record for.
|
|
3082
|
+
// activeStreamingScopes() alone only yields active:true buckets — a scope
|
|
3083
|
+
// that just fell inactive would never be ticked again, so its falling edge
|
|
3084
|
+
// is never observed (_lastCliActiveByScope stays true) and the rising-edge
|
|
3085
|
+
// pending-turn_end cancel never fires for it from the second round on.
|
|
3086
|
+
const keys = new Set(activeStreamingScopes());
|
|
3087
|
+
for (const key of _lastCliActiveByScope.keys()) keys.add(key);
|
|
3088
|
+
for (const key of keys) {
|
|
3089
|
+
if (key !== BOUND_SCOPE) _streamingTickOnce(key);
|
|
2645
3090
|
}
|
|
2646
3091
|
}, 500);
|
|
2647
3092
|
_streamingStatusTimer.unref();
|
|
@@ -2653,6 +3098,32 @@ export function stopViewer() {
|
|
|
2653
3098
|
_stoppingPromise = _doStop();
|
|
2654
3099
|
return _stoppingPromise;
|
|
2655
3100
|
}
|
|
3101
|
+
|
|
3102
|
+
// Reset every per-scope runtime structure: interceptor scope buckets plus the
|
|
3103
|
+
// two server-side maps that must not outlive the workspaces they describe —
|
|
3104
|
+
// _turnSessionIdByScope (a stale tag rejects the fresh session's first
|
|
3105
|
+
// UserPromptSubmit) and _lastCliActiveByScope (a stale edge breaks the
|
|
3106
|
+
// rising-edge cancel). Shared by _doStop and workspacesStop so both stop
|
|
3107
|
+
// paths reap the same set.
|
|
3108
|
+
export function resetScopeRuntime() {
|
|
3109
|
+
_lastSdkActive = false;
|
|
3110
|
+
_lastCliActiveByScope.clear();
|
|
3111
|
+
_turnSessionIdByScope.clear();
|
|
3112
|
+
try { clearScopeState(); } catch { /* best-effort */ }
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
// Reap ONE scope's runtime state (a single workspace's PTY was killed via
|
|
3116
|
+
// /api/live-processes/close — no Stop POST fires). Without this the killed
|
|
3117
|
+
// scope's bucket stays `active:true` forever: the heartbeat/streaming timer
|
|
3118
|
+
// keeps re-broadcasting it and the client's 30s staleness escape is
|
|
3119
|
+
// perpetually reset by those same frames (the permanent-spinner leak).
|
|
3120
|
+
export function clearScopeRuntimeFor(scopeKey) {
|
|
3121
|
+
const key = (typeof scopeKey === 'string' && scopeKey) || BOUND_SCOPE;
|
|
3122
|
+
try { clearScopeStateFor(key); } catch { /* best-effort */ }
|
|
3123
|
+
_turnSessionIdByScope.delete(key);
|
|
3124
|
+
_lastCliActiveByScope.delete(key);
|
|
3125
|
+
}
|
|
3126
|
+
|
|
2656
3127
|
async function _doStop() {
|
|
2657
3128
|
// _isStopping 设 true 后:①新 schedule 全部入口短路(_scheduleTurnEndBroadcast 入口检查)
|
|
2658
3129
|
// ②迟到的 streaming tick 也短路(_observeStreamingTick 入口检查)
|
|
@@ -2662,9 +3133,12 @@ async function _doStop() {
|
|
|
2662
3133
|
// Drop chat-queue timers (rescue / idle poll) so a stop/start cycle never injects a stale
|
|
2663
3134
|
// queued message into a fresh PTY session.
|
|
2664
3135
|
try { chatQueue.stop(); } catch { /* best-effort */ }
|
|
2665
|
-
|
|
2666
|
-
|
|
2667
|
-
|
|
3136
|
+
_clearTurnActiveHeartbeat();
|
|
3137
|
+
// Per-workspace: tear down every scope bucket plus the per-scope session tags
|
|
3138
|
+
// and streaming-edge records, so a restarted server never replays a stale
|
|
3139
|
+
// spinner / rejects a fresh session's first prompt for a workspace that no
|
|
3140
|
+
// longer exists.
|
|
3141
|
+
resetScopeRuntime();
|
|
2668
3142
|
// Tear down all IM bridge connections so a stop/start cycle (Electron tab switch, tests) never
|
|
2669
3143
|
// leaks a second WS to the same app. Idempotent + swallows errors.
|
|
2670
3144
|
// IM teardown + serverStopping hook 共用一个 3s 总预算(保持串行语义):
|
|
@@ -2688,6 +3162,13 @@ async function _doStop() {
|
|
|
2688
3162
|
// Truncate in place (not `clients = []`) so the array reference stays stable across
|
|
2689
3163
|
// stop/start cycles — deps.clients holds this same reference.
|
|
2690
3164
|
clients.length = 0;
|
|
3165
|
+
// Tear down the /ws/mux dialogue mux: server.close()/closeAllConnections() do NOT reach
|
|
3166
|
+
// upgraded WebSocket sockets, so the mux must terminate its own sockets (and its per-socket
|
|
3167
|
+
// ping timers / streams) or an Electron stop/start cycle would leak them.
|
|
3168
|
+
if (streamMux) {
|
|
3169
|
+
try { streamMux.close(); } catch { /* best-effort */ }
|
|
3170
|
+
streamMux = null;
|
|
3171
|
+
}
|
|
2691
3172
|
if (server) {
|
|
2692
3173
|
// 销毁所有活跃连接,防止 keep-alive 阻止进程退出
|
|
2693
3174
|
server.closeAllConnections();
|
|
@@ -2723,12 +3204,15 @@ async function _doStop() {
|
|
|
2723
3204
|
// empty), but the module-level checklist itself must not leak into the next
|
|
2724
3205
|
// stop/start cycle — a client connecting before the new session's startup
|
|
2725
3206
|
// boundary would otherwise get the PREVIOUS session's snapshot.
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
}
|
|
2730
|
-
resetTasks();
|
|
3207
|
+
for (const t of _taskBroadcastTimers.values()) clearTimeout(t);
|
|
3208
|
+
_taskBroadcastTimers.clear();
|
|
3209
|
+
resetAllTasks();
|
|
2731
3210
|
resetStreamingState();
|
|
3211
|
+
// Turn-level flag dies with the server, cleared AFTER connections close (same slot
|
|
3212
|
+
// as resetStreamingState): a task-event arriving during the 3s IM-teardown window
|
|
3213
|
+
// must not re-light the flag for the fresh process's first SSE replay.
|
|
3214
|
+
setTurnActive(false);
|
|
3215
|
+
_clearTurnActiveHeartbeat();
|
|
2732
3216
|
// 清 interceptor 的 live-port,避免 stop/start 循环(Electron tab 切换 / 测试)间隙内
|
|
2733
3217
|
// 早期请求向已关闭的端口 POST 丢包。新 startViewer 的 listen 回调会再次 setLivePort
|
|
2734
3218
|
setLivePort(null);
|
|
@@ -2744,9 +3228,21 @@ async function _doStop() {
|
|
|
2744
3228
|
/** Broadcast a message to all terminal WS clients (for SDK canUseTool). */
|
|
2745
3229
|
export function broadcastWsMessage(msg) {
|
|
2746
3230
|
if (terminalWss) {
|
|
3231
|
+
// SDK multi-session (2026-10-12): a cwd-tagged frame belongs to one workspace's session.
|
|
3232
|
+
// Route it only to clients viewing that workspace (or the bound view with no ?project=),
|
|
3233
|
+
// so a background workspace's approval/queue/spinner never bleeds into the foreground.
|
|
3234
|
+
// Untagged frames keep the historical broadcast-to-all behavior.
|
|
3235
|
+
const frameCwd = (msg && typeof msg === 'object' && typeof msg.cwd === 'string' && msg.cwd) ? msg.cwd : null;
|
|
3236
|
+
// The client stamp is the SANITIZED project key (projectKeyForCwd), so the raw
|
|
3237
|
+
// basename would misroute any cwd with chars outside [a-zA-Z0-9._-] (spaces, CJK)
|
|
3238
|
+
// — every frame for such a workspace would be silently dropped for its viewer.
|
|
3239
|
+
let frameProj = null;
|
|
3240
|
+
if (frameCwd) frameProj = projectKeyForCwd(frameCwd) || null;
|
|
2747
3241
|
const str = typeof msg === 'string' ? msg : JSON.stringify(msg);
|
|
2748
3242
|
terminalWss.clients.forEach((c) => {
|
|
2749
|
-
if (c.readyState
|
|
3243
|
+
if (c.readyState !== 1) return;
|
|
3244
|
+
if (frameProj && c._ccvViewProject && c._ccvViewProject !== frameProj) return; // other workspace's frame
|
|
3245
|
+
try { c.send(str); } catch {}
|
|
2750
3246
|
});
|
|
2751
3247
|
}
|
|
2752
3248
|
// 仅对 ask 类型转译给主进程;perm-hook-* / sdk-plan-* 维持 inline-only(红线)。
|
|
@@ -2802,6 +3298,18 @@ export function setSdkGetPendingApprovals(fn) { _sdkGetPendingApprovals = fn; }
|
|
|
2802
3298
|
let _sdkGetInitSnapshot = null;
|
|
2803
3299
|
export function setSdkGetInitSnapshot(fn) { _sdkGetInitSnapshot = fn; }
|
|
2804
3300
|
|
|
3301
|
+
/** Reference to sdk-manager's getContextUsageSnapshot (set by cli.js after import). */
|
|
3302
|
+
let _sdkGetContextUsageSnapshot = null;
|
|
3303
|
+
export function setSdkGetContextUsageSnapshot(fn) { _sdkGetContextUsageSnapshot = fn; }
|
|
3304
|
+
|
|
3305
|
+
/** Reference to sdk-manager's getMcpStatusSnapshot (set by cli.js after import). */
|
|
3306
|
+
let _sdkGetMcpStatusSnapshot = null;
|
|
3307
|
+
export function setSdkGetMcpStatusSnapshot(fn) { _sdkGetMcpStatusSnapshot = fn; }
|
|
3308
|
+
|
|
3309
|
+
/** Reference to sdk-manager's getSessionStateSnapshot (set by cli.js after import). */
|
|
3310
|
+
let _sdkGetSessionStateSnapshot = null;
|
|
3311
|
+
export function setSdkGetSessionStateSnapshot(fn) { _sdkGetSessionStateSnapshot = fn; }
|
|
3312
|
+
|
|
2805
3313
|
/** Reference to sdk-manager's sendQueuedNow (set by cli.js after import). */
|
|
2806
3314
|
let _sdkSendQueuedNow = null;
|
|
2807
3315
|
export function setSdkSendQueuedNow(fn) { _sdkSendQueuedNow = fn; }
|