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
package/server/server.js
CHANGED
|
@@ -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';
|
|
@@ -97,9 +97,10 @@ function execWithStdin(cmd, args, input, options) {
|
|
|
97
97
|
}
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
|
-
import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, turnActive, setTurnActive, 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';
|
|
101
101
|
import { V2LiveFeed } from './lib/v2/live-feed.js';
|
|
102
|
-
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';
|
|
103
104
|
import { sanitizePathComponent } from './lib/v2/layout.js';
|
|
104
105
|
import { maybeResumeConvert } from './lib/v2/convert-manager.js';
|
|
105
106
|
import { LOG_DIR, setLogDir, getClaudeConfigDir, isBrowserOpenSuppressed } from '../findcc.js';
|
|
@@ -118,7 +119,7 @@ import { checkAndUpdate } from './lib/updater.js';
|
|
|
118
119
|
import { loadPlugins, runWaterfallHook, runParallelHook } from './lib/plugin-loader.js';
|
|
119
120
|
import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher.js';
|
|
120
121
|
import { sendEventToClients } from './lib/log-watcher.js';
|
|
121
|
-
import { applyTaskEvent, resetTasks, getTaskSnapshot, shouldResetTasks } from './lib/task-state.js';
|
|
122
|
+
import { applyTaskEvent, resetTasks, resetAllTasks, getTaskSnapshot, shouldResetTasks } from './lib/task-state.js';
|
|
122
123
|
import { projectKeyForCwd } from './lib/system-prompt-snapshots.js';
|
|
123
124
|
import { createImLogWatcher } from './lib/im/im-log-watcher.js';
|
|
124
125
|
import { unwatchAllWorkflows } from './lib/workflow-watcher.js';
|
|
@@ -326,6 +327,10 @@ const _editorCleanupTimer = setInterval(() => {
|
|
|
326
327
|
}, 60000);
|
|
327
328
|
_editorCleanupTimer.unref(); // Don't keep process alive for cleanup
|
|
328
329
|
let terminalWss = null; // WebSocketServer reference for broadcasting
|
|
330
|
+
// Test seam: inject a stubbed client set so broadcastWsMessage's instance-narrowing
|
|
331
|
+
// predicate is directly unit-testable (ws-instance-isolation.test.js).
|
|
332
|
+
export function setTerminalWssForTests(wss) { terminalWss = wss; }
|
|
333
|
+
let streamMux = null; // /ws/mux dialogue-channel stream mux (SSE stays the fallback)
|
|
329
334
|
let _writeToPty = null; // PTY write function reference (set by setupTerminalWebSocket)
|
|
330
335
|
let _onPtyData = null; // PTY data listener registration (set by setupTerminalWebSocket)
|
|
331
336
|
export function setWorkspaceClaudeArgs(args) {
|
|
@@ -662,6 +667,9 @@ const deps = {
|
|
|
662
667
|
get retryConfig() { return _retryConfigState; },
|
|
663
668
|
get workspaceLaunched() { return _workspaceLaunched; },
|
|
664
669
|
setWorkspaceLaunched(v) { _workspaceLaunched = v; },
|
|
670
|
+
// Bound project name (reassignable via interceptor) — getter so routes read
|
|
671
|
+
// the live value at request time (e.g. resume.js scope-key resolution).
|
|
672
|
+
get projectName() { return _projectName || ''; },
|
|
665
673
|
get launchCallback() { return _launchCallback; },
|
|
666
674
|
get workspaceClaudeArgs() { return _workspaceClaudeArgs; },
|
|
667
675
|
get workspaceClaudePath() { return _workspaceClaudePath; },
|
|
@@ -695,15 +703,24 @@ const deps = {
|
|
|
695
703
|
stopLogWatch,
|
|
696
704
|
ensureProjectFeed: _ensureProjectFeed,
|
|
697
705
|
scheduleTurnEndBroadcast: _scheduleTurnEndBroadcast,
|
|
706
|
+
// Full per-scope reap on workspace stop (interceptor buckets + the two
|
|
707
|
+
// server-side scope maps); a PTY killed here fires no Stop POST, so without
|
|
708
|
+
// this the killed scopes leak active buckets and stale session tags.
|
|
709
|
+
resetScopeRuntime: (...args) => resetScopeRuntime(...args),
|
|
710
|
+
// Single-scope reap for /api/live-processes/close (see that route).
|
|
711
|
+
clearScopeRuntimeFor: (...args) => clearScopeRuntimeFor(...args),
|
|
698
712
|
// Raw Stop-hook POST arrival (routes/events.js turnEndNotify): clears the
|
|
699
713
|
// turn-level loading flag immediately — the debounced `turn_end` SSE frame is
|
|
700
714
|
// voice-pack semantics and too slow for the spinner. This is the ONLY clear
|
|
701
715
|
// path when the turn produced no API traffic (hook-only turn), so it must not
|
|
702
716
|
// wait on the debounce timer.
|
|
703
|
-
onTurnActiveClear: () => { _setTurnActive(false, null); },
|
|
717
|
+
onTurnActiveClear: (scope = null) => { _setTurnActive(false, null, scope); },
|
|
704
718
|
// SSE connect replay: a page refresh mid-turn restores the spinner without
|
|
705
719
|
// waiting for the next streaming tick (routes/events.js events() connect block).
|
|
706
|
-
getPtyTurnActive: () =>
|
|
720
|
+
getPtyTurnActive: (scopeKey = BOUND_SCOPE) => turnStateFor(scopeKey).active,
|
|
721
|
+
resolveScopeForHookPayload: _resolveScopeForHookPayload,
|
|
722
|
+
getTurnActiveFor: (scopeKey = BOUND_SCOPE) => turnStateFor(scopeKey).active,
|
|
723
|
+
filterClientsByViewProject: _filterClientsByViewProject,
|
|
707
724
|
// SessionStart hook notify → conversation-switch signal (in-terminal
|
|
708
725
|
// /resume). The source gate + V2Writer re-bind live in the interceptor.
|
|
709
726
|
onSessionStartNotify: markSessionStart,
|
|
@@ -713,25 +730,35 @@ const deps = {
|
|
|
713
730
|
// path broadcasts the empty snapshot — no separate reset route/hook.
|
|
714
731
|
onTaskEvent: (payload) => {
|
|
715
732
|
try {
|
|
716
|
-
|
|
717
|
-
|
|
733
|
+
const sc = _resolveScopeForHookPayload(payload);
|
|
734
|
+
const key = scopeKeyOf(sc.project, sc.instance);
|
|
735
|
+
applyTaskEvent(payload, key);
|
|
736
|
+
_scheduleTaskUpdateBroadcast(key);
|
|
718
737
|
// Turn-level loading: a new main-agent prompt marks the turn active.
|
|
719
738
|
// Subagent/teammate events are excluded inside the predicate (agentId +
|
|
720
|
-
// the load-bearing sessionId gate against the currently tracked turn
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
739
|
+
// the load-bearing sessionId gate against the currently tracked turn of
|
|
740
|
+
// THIS scope — a foreign workspace's prompt must not light this one).
|
|
741
|
+
if (isTurnStartTaskEvent(payload, _turnSessionIdByScope.get(key) || null)) {
|
|
742
|
+
_turnSessionIdByScope.set(key, payload.sessionId);
|
|
743
|
+
_setTurnActive(true, payload.sessionId, sc);
|
|
724
744
|
}
|
|
725
745
|
} catch (err) { reportSwallowed('task-state.apply', err); }
|
|
726
746
|
},
|
|
727
747
|
// Session-boundary reset for the task checklist. The gate itself lives in
|
|
728
748
|
// task-state.shouldResetTasks (pure, unit-tested): main-agent startup/clear
|
|
729
749
|
// resets; resume/fork to a different session resets; subagent/teammate
|
|
730
|
-
// events (payload.agentId) must not wipe the shared list.
|
|
750
|
+
// events (payload.agentId) must not wipe the shared list. Per-workspace: the
|
|
751
|
+
// bucket is resolved from the same hook-payload scope — a background
|
|
752
|
+
// workspace's SessionStart must never clear the bound project's checklist.
|
|
731
753
|
onTaskSessionBoundary: (payload) => {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
754
|
+
const sc = _resolveScopeForHookPayload(payload);
|
|
755
|
+
const key = scopeKeyOf(sc.project, sc.instance);
|
|
756
|
+
if (!shouldResetTasks(payload, getTaskSnapshot(key).sessionId)) return;
|
|
757
|
+
resetTasks(key);
|
|
758
|
+
// Coalesce with any TaskCreated events that follow in the same SessionStart
|
|
759
|
+
// batch, and honor the _isStopping freeze — a synchronous _emitTaskUpdate
|
|
760
|
+
// here would duplicate the batch frame and write to dying connections.
|
|
761
|
+
_scheduleTaskUpdateBroadcast(key);
|
|
735
762
|
},
|
|
736
763
|
// Per-connection snapshot replay for fresh SSE clients (page refresh /
|
|
737
764
|
// reconnect) — the checklist lives only in memory, not in the cold load.
|
|
@@ -1089,6 +1116,10 @@ export async function startViewer() {
|
|
|
1089
1116
|
_stoppingPromise = null;
|
|
1090
1117
|
// 加载插件(需要在创建服务器之前,以便通过 hook 获取 HTTPS 证书)
|
|
1091
1118
|
await loadPlugins();
|
|
1119
|
+
// Per-workspace: warm the pty-manager namespace for the synchronous hook
|
|
1120
|
+
// scope resolver (fire-and-forget; the import completes long before the
|
|
1121
|
+
// first hook POST in practice, and the resolver degrades to cwd/bound hints).
|
|
1122
|
+
_warmScopeResolver().catch(() => {});
|
|
1092
1123
|
|
|
1093
1124
|
// 启动期配置备份:preferences/profile/workspaces → LOG_DIR 外的 cc-viewer-config-backups/
|
|
1094
1125
|
// (滚动留 10 份)。2026-06-06 事故:配置随 LOG_DIR 整树丢失后无处可恢复。fire-and-forget。
|
|
@@ -1446,6 +1477,18 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1446
1477
|
terminalWss = wss;
|
|
1447
1478
|
const wssScratch = new WebSocketServer({ noServer: true });
|
|
1448
1479
|
|
|
1480
|
+
// /ws/mux dialogue-channel stream mux (WS-mux migration phase B). Each logical stream is
|
|
1481
|
+
// served by routes/events.js's serveEventStream — the SAME implementation behind the SSE
|
|
1482
|
+
// /events route, so a mux stream produces the identical frame sequence (SSE stays the
|
|
1483
|
+
// fallback transport). serveStream takes the sink + the open frame's raw query string,
|
|
1484
|
+
// re-parsed through the SAME paramsFromParsedUrl so the two transports never drift.
|
|
1485
|
+
const { createStreamMuxServer } = await import('./lib/ws-mux.js');
|
|
1486
|
+
streamMux = createStreamMuxServer({
|
|
1487
|
+
WebSocketServer,
|
|
1488
|
+
serveStream: (sink, qs) => serveEventStream(sink, paramsFromParsedUrl(new URL(`http://x/?${qs || ''}`)), deps),
|
|
1489
|
+
reportSwallowed,
|
|
1490
|
+
});
|
|
1491
|
+
|
|
1449
1492
|
// 多客户端共享 PTY 的尺寸冲突解决:
|
|
1450
1493
|
// 移动端优先——只要有移动端在线,PTY 始终使用移动端尺寸,
|
|
1451
1494
|
// PC 端的 resize 仅存储不生效,避免宽屏尺寸导致移动端乱码。
|
|
@@ -1567,6 +1610,12 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
1567
1610
|
}
|
|
1568
1611
|
req.ccvScratchCwd = scratchCwd;
|
|
1569
1612
|
handOffScratch();
|
|
1613
|
+
} else if (pathname === '/ws/mux') {
|
|
1614
|
+
// Dialogue-channel stream mux. Auth already enforced by decideAuth above (token from
|
|
1615
|
+
// ?token= / ccv_auth cookie — tokenInterceptor/appendToken put it on the ws:// URL
|
|
1616
|
+
// exactly like /ws/terminal). No socket-level ?project= stamp: each logical stream
|
|
1617
|
+
// carries its own scope in its `open` frame (one mux socket serves many workspaces).
|
|
1618
|
+
streamMux.handleUpgrade(req, socket, head);
|
|
1570
1619
|
} else {
|
|
1571
1620
|
socket.destroy();
|
|
1572
1621
|
}
|
|
@@ -2014,6 +2063,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2014
2063
|
// 新视图的 sdk-session-state/审批/队列帧会被静默丢弃。
|
|
2015
2064
|
if (msg.type === 'attach') {
|
|
2016
2065
|
try { ws._ccvViewProject = (typeof msg.project === 'string' && msg.project) || null; } catch {}
|
|
2066
|
+
try { ws._ccvViewInstance = (typeof msg.instanceKey === 'string' && msg.instanceKey) || null; } catch {}
|
|
2017
2067
|
if (isSdkMode) return;
|
|
2018
2068
|
} else if (isSdkMode && (msg.type === 'input' || msg.type === 'input-sequential'
|
|
2019
2069
|
|| msg.type === 'resync-request' || msg.type === 'resize')) {
|
|
@@ -2367,7 +2417,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2367
2417
|
// User message in SDK mode — relay to sdk-manager, addressed to the viewed
|
|
2368
2418
|
// workspace's session (multi-session). msg.project is the workspace basename.
|
|
2369
2419
|
if (_sdkSendUserMessage && msg.text) {
|
|
2370
|
-
const anchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
|
|
2420
|
+
const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
|
|
2371
2421
|
_sdkSendUserMessage(msg.text, anchor).catch(err => {
|
|
2372
2422
|
console.error('[SDK] sendUserMessage error:', err.message);
|
|
2373
2423
|
});
|
|
@@ -2378,7 +2428,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2378
2428
|
// fetch hook → streamingState → SSE), same as PTY mode; sdk-manager
|
|
2379
2429
|
// no longer emits its own streaming_status.
|
|
2380
2430
|
if (isSdkMode && _sdkInterruptTurn) {
|
|
2381
|
-
const anchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
|
|
2431
|
+
const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
|
|
2382
2432
|
const cancelled = _sdkInterruptTurn(anchor) || [];
|
|
2383
2433
|
_broadcastApprovalCloses(cancelled, 'Turn interrupted');
|
|
2384
2434
|
} else {
|
|
@@ -2395,7 +2445,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2395
2445
|
if (typeof msg.text === 'string' && msg.text) {
|
|
2396
2446
|
if (isSdkMode) {
|
|
2397
2447
|
if (_sdkSendUserMessage) {
|
|
2398
|
-
const anchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
|
|
2448
|
+
const anchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
|
|
2399
2449
|
_sdkSendUserMessage(msg.text, anchor).catch(err => {
|
|
2400
2450
|
console.error('[SDK] queue-message relay error:', err.message);
|
|
2401
2451
|
});
|
|
@@ -2414,7 +2464,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2414
2464
|
if (_sdkSendQueuedNow) {
|
|
2415
2465
|
// Multi-session: address the bubble's own workspace (the viewed project), not
|
|
2416
2466
|
// the global active session — bubbles are cwd-scoped, the action must match.
|
|
2417
|
-
const qAnchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
|
|
2467
|
+
const qAnchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined;
|
|
2418
2468
|
try { _broadcastApprovalCloses(_sdkSendQueuedNow(qid, qAnchor) || [], 'Queued message sent now'); }
|
|
2419
2469
|
catch (err) { reportSwallowed('server.queue-send-now', err); }
|
|
2420
2470
|
}
|
|
@@ -2426,7 +2476,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2426
2476
|
const qid = (typeof msg.id === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(msg.id)) ? msg.id : null;
|
|
2427
2477
|
if (qid) {
|
|
2428
2478
|
if (isSdkMode) {
|
|
2429
|
-
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); } }
|
|
2479
|
+
if (_sdkRemoveQueued) { const qAnchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined; try { _sdkRemoveQueued(qid, qAnchor); } catch (err) { reportSwallowed('server.queue-remove', err); } }
|
|
2430
2480
|
} else {
|
|
2431
2481
|
try { chatQueue.remove(qid); } catch (err) { reportSwallowed('server.queue-remove', err); }
|
|
2432
2482
|
}
|
|
@@ -2435,7 +2485,7 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2435
2485
|
// Session/workspace switch: drop the parked queue WITHOUT interrupting a running
|
|
2436
2486
|
// turn. (Stop semantics live elsewhere: PTY queue-suppress, SDK sdk-interrupt.)
|
|
2437
2487
|
if (isSdkMode) {
|
|
2438
|
-
if (_sdkClearQueued) { const qAnchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined; try { _sdkClearQueued(qAnchor); } catch (err) { reportSwallowed('server.queue-clear', err); } }
|
|
2488
|
+
if (_sdkClearQueued) { const qAnchor = ((typeof msg.project === 'string' && msg.project) || (typeof msg.instanceKey === 'string' && msg.instanceKey)) ? { project: msg.project, instanceKey: typeof msg.instanceKey === 'string' ? msg.instanceKey : undefined } : undefined; try { _sdkClearQueued(qAnchor); } catch (err) { reportSwallowed('server.queue-clear', err); } }
|
|
2439
2489
|
} else {
|
|
2440
2490
|
try { chatQueue.clear(); } catch (err) { reportSwallowed('server.queue-clear', err); }
|
|
2441
2491
|
}
|
|
@@ -2447,7 +2497,13 @@ async function setupTerminalWebSocket(httpServer) {
|
|
|
2447
2497
|
// The Stop hook does NOT fire on user interrupt (documented Claude Code
|
|
2448
2498
|
// behavior), so the web Stop button must clear the turn-level loading
|
|
2449
2499
|
// flag itself or the spinner would outlive the interrupted turn.
|
|
2450
|
-
|
|
2500
|
+
// Per-workspace: the message carries the view's project/instance so
|
|
2501
|
+
// the clear lands on exactly that scope (absent ⇒ bound).
|
|
2502
|
+
const qScope = normalizeProducerScope(
|
|
2503
|
+
{ project: (typeof msg.project === 'string' && msg.project) || null, instance: (typeof msg.instanceKey === 'string' && msg.instanceKey) || null },
|
|
2504
|
+
_projectName || '',
|
|
2505
|
+
);
|
|
2506
|
+
_setTurnActive(false, null, qScope);
|
|
2451
2507
|
}
|
|
2452
2508
|
} else if (msg.type === 'image-remove-notify' || msg.type === 'image-upload-notify') {
|
|
2453
2509
|
// Security: only allow paths within upload directories, reject traversal
|
|
@@ -2602,19 +2658,82 @@ const TURN_END_DEBOUNCE_MS = (() => {
|
|
|
2602
2658
|
})();
|
|
2603
2659
|
let _isStopping = false;
|
|
2604
2660
|
let _lastSdkActive = false;
|
|
2605
|
-
|
|
2661
|
+
// Per-scope CLI streaming edge state ('' = bound — the legacy single flag).
|
|
2662
|
+
const _lastCliActiveByScope = new Map();
|
|
2606
2663
|
let _onTurnEndBroadcastForTests = null;
|
|
2607
2664
|
|
|
2608
2665
|
function _normalizeKey(sessionId) {
|
|
2609
2666
|
return (typeof sessionId === 'string' && sessionId) ? sessionId : null;
|
|
2610
2667
|
}
|
|
2611
2668
|
|
|
2612
|
-
|
|
2669
|
+
// ── Per-workspace scope resolution (2026-10) ────────────────────────────────
|
|
2670
|
+
// ONE resolver for every hook-origin frame (turn_end / task_update / turn
|
|
2671
|
+
// boundary): the same precedence everywhere so a workspace's frames can never
|
|
2672
|
+
// split across two scope buckets. Order:
|
|
2673
|
+
// 1. sessionId → the live PTY record that owns it (exact project+instance);
|
|
2674
|
+
// 2. payload.cwd → projectKeyForCwd (no instance — hooks report no instance);
|
|
2675
|
+
// 3. the bound project ('' sentinel).
|
|
2676
|
+
// A dead record / a foreign sessionId yields null from the resolver and falls
|
|
2677
|
+
// through to the cwd/bound hints, never to a corpse scope.
|
|
2678
|
+
function _resolveScopeForHookPayload(payload) {
|
|
2679
|
+
const p = payload || {};
|
|
2680
|
+
try {
|
|
2681
|
+
const sid = _normalizeKey(p.sessionId);
|
|
2682
|
+
if (sid) {
|
|
2683
|
+
// Lazy import kept out of the module graph (pty-manager pulls node-pty).
|
|
2684
|
+
const pm = _ptyManagerForScope || null;
|
|
2685
|
+
const rec = pm && typeof pm.resolvePtyForSession === 'function' ? pm.resolvePtyForSession(sid) : null;
|
|
2686
|
+
if (rec && rec.project) {
|
|
2687
|
+
return normalizeProducerScope({ project: rec.project, instance: rec.instanceKey || null }, _projectName || '');
|
|
2688
|
+
}
|
|
2689
|
+
}
|
|
2690
|
+
} catch (err) { reportSwallowed('scope.resolve-sid', err); }
|
|
2691
|
+
const cwd = (typeof p.cwd === 'string' && p.cwd) || '';
|
|
2692
|
+
if (cwd) {
|
|
2693
|
+
try {
|
|
2694
|
+
const proj = projectKeyForCwd(cwd);
|
|
2695
|
+
if (proj) {
|
|
2696
|
+
// Upgrade to the exact instance ONLY when exactly one live record owns
|
|
2697
|
+
// this cwd — a project-level frame (instance=null) reaches EVERY viewer
|
|
2698
|
+
// of the project, over-delivering a same-cwd twin's hook to its sibling.
|
|
2699
|
+
// Two live twins → no honest pick → keep project-level (today's behavior,
|
|
2700
|
+
// and the twins' viewers share the cwd anyway).
|
|
2701
|
+
const pm = _ptyManagerForScope || null;
|
|
2702
|
+
const inst = pm && typeof pm.resolveInstanceForCwd === 'function' ? pm.resolveInstanceForCwd(cwd) : null;
|
|
2703
|
+
if (!inst && pm && typeof pm.resolvePtyForSession === 'function') {
|
|
2704
|
+
// Diagnostic only: a project-level frame for a cwd that HAS live
|
|
2705
|
+
// instances is the over-delivery window — observable on real hardware.
|
|
2706
|
+
try {
|
|
2707
|
+
const liveCount = (pm.listLivePtys?.() || []).filter((r) => r && r.cwd && projectKeyForCwd(r.cwd) === proj).length;
|
|
2708
|
+
if (liveCount > 1) reportSwallowed('scope.cwd-ambiguous', new Error(`cwd has ${liveCount} live instances, scope stays project-level`), { cwd: proj });
|
|
2709
|
+
} catch { /* best-effort */ }
|
|
2710
|
+
}
|
|
2711
|
+
return normalizeProducerScope({ project: proj, instance: inst }, _projectName || '');
|
|
2712
|
+
}
|
|
2713
|
+
} catch (err) { reportSwallowed('scope.resolve-cwd', err); }
|
|
2714
|
+
}
|
|
2715
|
+
return { project: '', instance: null };
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
// Cached pty-manager namespace (first successful lazy import). Scope resolution
|
|
2719
|
+
// is synchronous (hook POST handlers are not async); the import completes at
|
|
2720
|
+
// startup via _warmScopeResolver, so production never sees the null window.
|
|
2721
|
+
let _ptyManagerForScope = null;
|
|
2722
|
+
async function _warmScopeResolver() {
|
|
2723
|
+
try { _ptyManagerForScope = await import('./pty-manager.js'); }
|
|
2724
|
+
catch (err) { reportSwallowed('scope.resolver-warm', err); }
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
function _emitTurnEnd(sessionId, ts, transcriptPath = null, scope = null) {
|
|
2613
2728
|
const sid = _normalizeKey(sessionId);
|
|
2614
2729
|
const t = ts || Date.now();
|
|
2730
|
+
const sc = scope || { project: '', instance: null };
|
|
2615
2731
|
try {
|
|
2616
2732
|
if (clients.length > 0 && sendEventToClients) {
|
|
2617
|
-
|
|
2733
|
+
const target = sc.project
|
|
2734
|
+
? filterClientsByScope(clients, sc.project, sc.instance, _projectName || '')
|
|
2735
|
+
: _filterClientsByViewProject(clients, _projectName || '');
|
|
2736
|
+
sendEventToClients(target, 'turn_end', { sessionId: sid, ts: t, project: sc.project || (_projectName || ''), instance: sc.instance || null });
|
|
2618
2737
|
}
|
|
2619
2738
|
// Forward the (clean) assistant reply for this turn to whichever IM bridge owns the in-flight
|
|
2620
2739
|
// turn, if any. Fire-and-forget: a bridge failure must never affect SSE broadcast.
|
|
@@ -2632,10 +2751,13 @@ function _emitTurnEnd(sessionId, ts, transcriptPath = null) {
|
|
|
2632
2751
|
}
|
|
2633
2752
|
}
|
|
2634
2753
|
|
|
2635
|
-
function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null) {
|
|
2754
|
+
function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null, scope = null) {
|
|
2636
2755
|
if (_isStopping) return;
|
|
2637
2756
|
const sid = _normalizeKey(sessionId);
|
|
2638
2757
|
const t = ts || Date.now();
|
|
2758
|
+
// Resolve the scope ONCE at POST time: the debounced emit must not re-derive
|
|
2759
|
+
// it from live state that a later switch may have changed.
|
|
2760
|
+
const sc = scope || { project: '', instance: null };
|
|
2639
2761
|
// chat-queue drain trigger #1: the Stop-hook POST arrival IS the authoritative
|
|
2640
2762
|
// "query() ended" signal — don't wait out the SSE debounce window (default 10s),
|
|
2641
2763
|
// or freshly-parked bubbles would sit visibly inert after every turn. The queue's
|
|
@@ -2648,10 +2770,10 @@ function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null) {
|
|
|
2648
2770
|
if (existing) clearTimeout(existing.timer);
|
|
2649
2771
|
const timer = setTimeout(() => {
|
|
2650
2772
|
_pendingTurnEndTimers.delete(sid);
|
|
2651
|
-
_emitTurnEnd(sid, t, transcriptPath);
|
|
2773
|
+
_emitTurnEnd(sid, t, transcriptPath, sc);
|
|
2652
2774
|
}, TURN_END_DEBOUNCE_MS);
|
|
2653
2775
|
if (typeof timer.unref === 'function') timer.unref();
|
|
2654
|
-
_pendingTurnEndTimers.set(sid, { timer, ts: t });
|
|
2776
|
+
_pendingTurnEndTimers.set(sid, { timer, ts: t, scopeKey: scopeKeyOf(sc.project, sc.instance) });
|
|
2655
2777
|
}
|
|
2656
2778
|
|
|
2657
2779
|
// Task-checklist broadcast (task-bridge.js → /api/task-event → deps.onTaskEvent).
|
|
@@ -2660,28 +2782,40 @@ function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null) {
|
|
|
2660
2782
|
// trailing debounce coalesces multi-task flips (e.g. a batch TaskCreate turn)
|
|
2661
2783
|
// into one SSE frame.
|
|
2662
2784
|
const TASK_UPDATE_DEBOUNCE_MS = 120;
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2785
|
+
// Per-workspace: one debounce timer PER SCOPE — a single shared timer let a
|
|
2786
|
+
// later scope's schedule clearTimeout an earlier scope's pending frame and
|
|
2787
|
+
// drop it whole (a scope with a single TaskCreated then going quiet would
|
|
2788
|
+
// never show it). Mirrored after _pendingTurnEndTimers.
|
|
2789
|
+
const _taskBroadcastTimers = new Map(); // scopeKey → timer
|
|
2790
|
+
|
|
2791
|
+
function _emitTaskUpdate(scopeKey = BOUND_SCOPE) {
|
|
2792
|
+
if (_isStopping) return; // frozen state machine after stop: no late frames
|
|
2666
2793
|
if (clients.length === 0 || !sendEventToClients) return;
|
|
2667
2794
|
// Parity with _emitTurnEnd: a broadcast failure must never surface as an
|
|
2668
2795
|
// uncaught exception from the debounce timer.
|
|
2669
2796
|
try {
|
|
2670
|
-
const snap = getTaskSnapshot();
|
|
2671
|
-
|
|
2797
|
+
const snap = getTaskSnapshot(scopeKey);
|
|
2798
|
+
const sc = parseScopeKey(scopeKey);
|
|
2799
|
+
const target = sc.project
|
|
2800
|
+
? filterClientsByScope(clients, sc.project, sc.instance, _projectName || '')
|
|
2801
|
+
: _filterClientsByViewProject(clients, _projectName || '');
|
|
2802
|
+
sendEventToClients(target, 'task_update', { sessionId: snap.sessionId, tasks: snap.tasks, ts: Date.now(), project: sc.project || (_projectName || ''), instance: sc.instance || null });
|
|
2672
2803
|
} catch (err) {
|
|
2673
2804
|
console.warn('[task-update] broadcast failed:', err && err.message);
|
|
2674
2805
|
}
|
|
2675
2806
|
}
|
|
2676
2807
|
|
|
2677
|
-
function _scheduleTaskUpdateBroadcast() {
|
|
2808
|
+
function _scheduleTaskUpdateBroadcast(scopeKey = BOUND_SCOPE) {
|
|
2678
2809
|
if (_isStopping) return;
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2810
|
+
const key = scopeKey || BOUND_SCOPE;
|
|
2811
|
+
const existing = _taskBroadcastTimers.get(key);
|
|
2812
|
+
if (existing) clearTimeout(existing);
|
|
2813
|
+
const timer = setTimeout(() => {
|
|
2814
|
+
_taskBroadcastTimers.delete(key);
|
|
2815
|
+
_emitTaskUpdate(key);
|
|
2683
2816
|
}, TASK_UPDATE_DEBOUNCE_MS);
|
|
2684
|
-
if (typeof
|
|
2817
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
2818
|
+
_taskBroadcastTimers.set(key, timer);
|
|
2685
2819
|
}
|
|
2686
2820
|
|
|
2687
2821
|
// 直接丢弃所有 pending —— 用于两条路径:(1) `_doStop` shutdown,(2) rising-edge 新请求
|
|
@@ -2692,11 +2826,21 @@ function _cancelAllPendingTurnEndBroadcasts() {
|
|
|
2692
2826
|
_pendingTurnEndTimers.clear();
|
|
2693
2827
|
}
|
|
2694
2828
|
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2829
|
+
// Cancel the pending turn_end broadcasts of ONE scope (rising-edge new request
|
|
2830
|
+
// in that scope — by the user's original semantics "a new request within 10s ⇒
|
|
2831
|
+
// not really finished ⇒ don't play"). Other scopes' pending timers are
|
|
2832
|
+
// untouched (a foreign workspace's turn must never cancel this one's).
|
|
2833
|
+
function _cancelPendingTurnEndForScope(scopeKey) {
|
|
2834
|
+
if (_pendingTurnEndTimers.size === 0) return;
|
|
2835
|
+
for (const [sid, entry] of _pendingTurnEndTimers) {
|
|
2836
|
+
if ((entry.scopeKey || BOUND_SCOPE) !== scopeKey) continue;
|
|
2837
|
+
clearTimeout(entry.timer);
|
|
2838
|
+
_pendingTurnEndTimers.delete(sid);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
function _onStreamingActivated(scopeKey = BOUND_SCOPE) {
|
|
2843
|
+
_cancelPendingTurnEndForScope(scopeKey);
|
|
2700
2844
|
// A new turn also lifts the chat-queue Stop-park: parked bubbles resume auto-draining
|
|
2701
2845
|
// at the next turn end.
|
|
2702
2846
|
try { chatQueue.onStreamingActivated(); } catch { /* best-effort */ }
|
|
@@ -2713,18 +2857,18 @@ function _onStreamingActivated() {
|
|
|
2713
2857
|
// explicit user interrupt. No streaming rising-edge fallback by design:
|
|
2714
2858
|
// housekeeping/telemetry requests after query() end would re-light it for the
|
|
2715
2859
|
// whole staleness window (see HISTORY note at _scheduleTurnEndBroadcast).
|
|
2716
|
-
//
|
|
2717
|
-
//
|
|
2718
|
-
//
|
|
2719
|
-
//
|
|
2720
|
-
// pre-multi-PTY single-project posture (see the streaming_status timer comment).
|
|
2860
|
+
// Per-workspace (2026-10): the flag is bucketed per producer scope ('' = the
|
|
2861
|
+
// bound project — the legacy singleton). Broadcasts/replay reach exactly that
|
|
2862
|
+
// scope's viewers, so a parallel workspace's prompt lights ITS OWN spinner
|
|
2863
|
+
// (previously bound-only — a chip-switched parallel view saw none).
|
|
2721
2864
|
//
|
|
2722
|
-
// Heartbeat: while
|
|
2723
|
-
// TURN_ACTIVE_HEARTBEAT_MS
|
|
2724
|
-
// NO turn-level frames and NO
|
|
2725
|
-
//
|
|
2726
|
-
//
|
|
2727
|
-
//
|
|
2865
|
+
// Heartbeat: while any scope's flag is up, re-broadcast `turn_active` per scope
|
|
2866
|
+
// every TURN_ACTIVE_HEARTBEAT_MS (each scope with its own session tag). Local
|
|
2867
|
+
// tool execution (Bash > 30s builds/tests) produces NO turn-level frames and NO
|
|
2868
|
+
// API traffic — without a heartbeat the client's staleness escape cannot
|
|
2869
|
+
// distinguish "silent tool" from "lost Stop POST" and would kill the spinner
|
|
2870
|
+
// mid-turn. The heartbeat rides the same view-project filter; when a scope's
|
|
2871
|
+
// flag drops (Stop POST / interrupt) it leaves the iteration, and the client
|
|
2728
2872
|
// backstop still catches a process-death/stuck-server within one window.
|
|
2729
2873
|
|
|
2730
2874
|
// Pure predicate (unit-tested): does this /api/task-event payload mark a new
|
|
@@ -2744,17 +2888,24 @@ export function isTurnStartTaskEvent(payload, currentSessionId) {
|
|
|
2744
2888
|
|
|
2745
2889
|
const TURN_ACTIVE_HEARTBEAT_MS = 10 * 1000;
|
|
2746
2890
|
let _turnActiveHeartbeat = null;
|
|
2747
|
-
//
|
|
2748
|
-
// UserPromptSubmit (agentId is unreliable on this hook; see
|
|
2749
|
-
//
|
|
2750
|
-
//
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2891
|
+
// Per-scope session tags of the turns that lit each flag — the load-bearing
|
|
2892
|
+
// gate for UserPromptSubmit (agentId is unreliable on this hook; see
|
|
2893
|
+
// task-state.js). Keyed by scope ('' = bound). A Stop POST clears the flag but
|
|
2894
|
+
// keeps the tag: the NEXT prompt of the same session re-tags, and a stale tag
|
|
2895
|
+
// must not reject it (the tag only rejects prompts from a DIFFERENT live
|
|
2896
|
+
// session of the SAME scope).
|
|
2897
|
+
const _turnSessionIdByScope = new Map();
|
|
2898
|
+
|
|
2899
|
+
function _armTurnActiveHeartbeat() {
|
|
2754
2900
|
if (_turnActiveHeartbeat) return; // already armed
|
|
2755
2901
|
_turnActiveHeartbeat = setInterval(() => {
|
|
2756
|
-
|
|
2757
|
-
|
|
2902
|
+
const scopes = activeTurnScopes();
|
|
2903
|
+
if (_isStopping || scopes.length === 0) { _clearTurnActiveHeartbeat(); return; }
|
|
2904
|
+
// Broadcast each scope's own tag — a foreign session's sid must never ride
|
|
2905
|
+
// this scope's heartbeat (the connect replay validates against it).
|
|
2906
|
+
for (const key of scopes) {
|
|
2907
|
+
_broadcastTurnActive(true, _turnSessionIdByScope.get(key) || null, parseScopeKey(key));
|
|
2908
|
+
}
|
|
2758
2909
|
}, TURN_ACTIVE_HEARTBEAT_MS);
|
|
2759
2910
|
if (typeof _turnActiveHeartbeat.unref === 'function') _turnActiveHeartbeat.unref();
|
|
2760
2911
|
}
|
|
@@ -2763,39 +2914,51 @@ function _clearTurnActiveHeartbeat() {
|
|
|
2763
2914
|
if (_turnActiveHeartbeat) { clearInterval(_turnActiveHeartbeat); _turnActiveHeartbeat = null; }
|
|
2764
2915
|
}
|
|
2765
2916
|
|
|
2766
|
-
function _broadcastTurnActive(active, sessionId) {
|
|
2917
|
+
function _broadcastTurnActive(active, sessionId, scope = null) {
|
|
2767
2918
|
if (_isStopping) return;
|
|
2768
2919
|
if (!sendEventToClients || clients.length === 0) return;
|
|
2920
|
+
const sc = scope || { project: '', instance: null };
|
|
2769
2921
|
try {
|
|
2770
2922
|
sendEventToClients(
|
|
2771
|
-
|
|
2923
|
+
sc.project
|
|
2924
|
+
? filterClientsByScope(clients, sc.project, sc.instance, _projectName || '')
|
|
2925
|
+
: _filterClientsByViewProject(clients, _projectName || ''),
|
|
2772
2926
|
'turn_active',
|
|
2773
|
-
{ active, sessionId: sessionId || null, ts: Date.now() },
|
|
2927
|
+
{ active, sessionId: sessionId || null, ts: Date.now(), project: sc.project || (_projectName || ''), instance: sc.instance || null },
|
|
2774
2928
|
);
|
|
2775
2929
|
} catch (err) { reportSwallowed('turn-active.broadcast', err); }
|
|
2776
2930
|
}
|
|
2777
2931
|
|
|
2778
2932
|
// State-change guard: only broadcast on real transitions (SDK paths share the
|
|
2779
2933
|
// turn-end entry points and would otherwise emit duplicate frames). The heartbeat
|
|
2780
|
-
// is armed/disarmed here so it tracks the flag exactly.
|
|
2781
|
-
|
|
2934
|
+
// is armed/disarmed here so it tracks the flag exactly. Scope defaults to bound
|
|
2935
|
+
// ('') — the legacy single-workspace behavior is byte-identical.
|
|
2936
|
+
function _setTurnActive(active, sessionId, scope = null) {
|
|
2937
|
+
const sc = scope || { project: '', instance: null };
|
|
2938
|
+
const key = scopeKeyOf(sc.project, sc.instance);
|
|
2939
|
+
const bucket = turnStateFor(key);
|
|
2782
2940
|
const next = !!active;
|
|
2783
|
-
if (next ===
|
|
2784
|
-
|
|
2785
|
-
if (
|
|
2786
|
-
|
|
2941
|
+
if (next === bucket.active) return;
|
|
2942
|
+
bucket.active = next;
|
|
2943
|
+
if (key === BOUND_SCOPE) setTurnActive(next);
|
|
2944
|
+
if (next) _turnSessionIdByScope.set(key, sessionId || null);
|
|
2945
|
+
if (activeTurnScopes().length > 0) _armTurnActiveHeartbeat(); else _clearTurnActiveHeartbeat();
|
|
2946
|
+
_broadcastTurnActive(next, sessionId, sc);
|
|
2787
2947
|
}
|
|
2788
2948
|
|
|
2789
2949
|
// 统一的 streaming-state 观察入口。production polling 和 SDK push 都调它,测试也走它,
|
|
2790
2950
|
// 不再为「测试桩」单独维护 mirror state(杜绝逻辑漂移)。返回是否检测到 rising edge。
|
|
2791
2951
|
// `_isStopping` 时直接返回 false:shutdown 中迟到的 tick 不应再去 flush。
|
|
2792
|
-
|
|
2952
|
+
// Per-workspace: scopeKey defaults to bound (''); the rising-edge cancel only
|
|
2953
|
+
// clears THAT scope's pending turn_end timers. The 'sdk' mode lane has no
|
|
2954
|
+
// production caller (tests only) and stays a single bound-scope flag.
|
|
2955
|
+
function _observeStreamingTick(activeNow, mode /* 'cli' | 'sdk' */, scopeKey = BOUND_SCOPE) {
|
|
2793
2956
|
if (_isStopping) return false;
|
|
2794
2957
|
const isActive = !!activeNow;
|
|
2795
|
-
const wasActive = mode === 'sdk' ? _lastSdkActive :
|
|
2796
|
-
if (mode === 'sdk') _lastSdkActive = isActive; else
|
|
2958
|
+
const wasActive = mode === 'sdk' ? _lastSdkActive : (_lastCliActiveByScope.get(scopeKey) || false);
|
|
2959
|
+
if (mode === 'sdk') _lastSdkActive = isActive; else _lastCliActiveByScope.set(scopeKey, isActive);
|
|
2797
2960
|
if (isActive && !wasActive) {
|
|
2798
|
-
_onStreamingActivated();
|
|
2961
|
+
_onStreamingActivated(scopeKey);
|
|
2799
2962
|
return true;
|
|
2800
2963
|
}
|
|
2801
2964
|
return false;
|
|
@@ -2812,34 +2975,38 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
|
|
|
2812
2975
|
_cancelAllPendingTurnEndBroadcasts();
|
|
2813
2976
|
_isStopping = false;
|
|
2814
2977
|
_lastSdkActive = false;
|
|
2815
|
-
|
|
2978
|
+
_lastCliActiveByScope.clear();
|
|
2816
2979
|
_stoppingPromise = null;
|
|
2817
2980
|
_onTurnEndBroadcastForTests = null;
|
|
2818
2981
|
setTurnActive(false);
|
|
2819
2982
|
_clearTurnActiveHeartbeat();
|
|
2820
|
-
|
|
2983
|
+
_turnSessionIdByScope.clear();
|
|
2984
|
+
clearScopeState();
|
|
2821
2985
|
},
|
|
2822
2986
|
onBroadcast(fn) { _onTurnEndBroadcastForTests = fn; },
|
|
2823
2987
|
getPendingKeys() { return [..._pendingTurnEndTimers.keys()]; },
|
|
2824
2988
|
setIsStopping(v) { _isStopping = !!v; },
|
|
2825
|
-
observeStreamingTick(activeNow, mode = 'cli') { return _observeStreamingTick(activeNow, mode); },
|
|
2826
|
-
scheduleTurnEnd(sessionId, ts) { _scheduleTurnEndBroadcast(sessionId, ts); },
|
|
2989
|
+
observeStreamingTick(activeNow, mode = 'cli', scopeKey = BOUND_SCOPE) { return _observeStreamingTick(activeNow, mode, scopeKey); },
|
|
2990
|
+
scheduleTurnEnd(sessionId, ts, scope = null) { _scheduleTurnEndBroadcast(sessionId, ts, null, scope); },
|
|
2827
2991
|
getDebounceMs() { return TURN_END_DEBOUNCE_MS; },
|
|
2828
|
-
getPtyTurnActive() { return
|
|
2829
|
-
setPtyTurnActive(v, sessionId = null) { _setTurnActive(v, sessionId); },
|
|
2992
|
+
getPtyTurnActive(scopeKey = BOUND_SCOPE) { return turnStateFor(scopeKey).active; },
|
|
2993
|
+
setPtyTurnActive(v, sessionId = null, scope = null) { _setTurnActive(v, sessionId, scope); },
|
|
2830
2994
|
// Routes the payload through the SAME predicate the deps.onTaskEvent closure
|
|
2831
2995
|
// uses, so tests exercise the production gate rather than a mirror of it.
|
|
2832
2996
|
notifyTaskEvent(payload) {
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2997
|
+
const sc = _resolveScopeForHookPayload(payload);
|
|
2998
|
+
const key = scopeKeyOf(sc.project, sc.instance);
|
|
2999
|
+
if (isTurnStartTaskEvent(payload, _turnSessionIdByScope.get(key) || null)) {
|
|
3000
|
+
_turnSessionIdByScope.set(key, payload?.sessionId || null);
|
|
3001
|
+
_setTurnActive(true, payload?.sessionId, sc);
|
|
2836
3002
|
}
|
|
2837
3003
|
},
|
|
2838
3004
|
// Clear drops the flag but KEEPS the session tag: the next prompt of the SAME
|
|
2839
3005
|
// session re-lights fast (tag match), while a foreign session's prompt stays
|
|
2840
3006
|
// rejected. The tag resets only on test reset / server stop.
|
|
2841
|
-
clearPtyTurnActive() { _setTurnActive(false, null); },
|
|
2842
|
-
getTurnSessionId() { return
|
|
3007
|
+
clearPtyTurnActive(scope = null) { _setTurnActive(false, null, scope); },
|
|
3008
|
+
getTurnSessionId(scopeKey = BOUND_SCOPE) { return _turnSessionIdByScope.get(scopeKey) || null; },
|
|
3009
|
+
resolveScopeForHookPayload(payload) { return _resolveScopeForHookPayload(payload); },
|
|
2843
3010
|
} : Object.freeze({
|
|
2844
3011
|
reset() {},
|
|
2845
3012
|
onBroadcast() {},
|
|
@@ -2857,10 +3024,16 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
|
|
|
2857
3024
|
/**
|
|
2858
3025
|
* Schedule a debounced turn_end SSE broadcast. **Returns immediately**; actual SSE write
|
|
2859
3026
|
* happens up to TURN_END_DEBOUNCE_MS later (default 10s; clamped [100,60000]).
|
|
2860
|
-
*
|
|
3027
|
+
* 详细语义见上方 SUNSET-MARKER 注释块(ccv-turn-end-debounce)。
|
|
3028
|
+
* Per-workspace: `cwd` (SDK 'result' path) routes the frame to that workspace's
|
|
3029
|
+
* scope; absent ⇒ the bound project (legacy). (A pre-resolved scope can instead
|
|
3030
|
+
* be passed straight to `_scheduleTurnEndBroadcast` by the hook routes.)
|
|
2861
3031
|
*/
|
|
2862
|
-
export function broadcastTurnEnd(sessionId = null, ts = Date.now()) {
|
|
2863
|
-
|
|
3032
|
+
export function broadcastTurnEnd(sessionId = null, ts = Date.now(), transcriptPath = null, cwd = null) {
|
|
3033
|
+
const scope = cwd
|
|
3034
|
+
? normalizeProducerScope({ project: projectKeyForCwd(cwd) || '', instance: null }, _projectName || '')
|
|
3035
|
+
: null;
|
|
3036
|
+
_scheduleTurnEndBroadcast(sessionId, ts, transcriptPath, scope);
|
|
2864
3037
|
}
|
|
2865
3038
|
|
|
2866
3039
|
// 流式状态 SSE 推送定时器:检测 streamingState 变化并广播给所有客户端。
|
|
@@ -2876,27 +3049,48 @@ let _updateCheckTimer = null;
|
|
|
2876
3049
|
// Exported for tests: workspace-mode startViewer defers this timer to initPostLaunch, but
|
|
2877
3050
|
// lifecycle tests need to drive the streaming_status poll directly (setSdkStreamingState
|
|
2878
3051
|
// was retired in the wire architecture; the timer reads interceptor's streamingState).
|
|
3052
|
+
// One 500ms tick over ONE scope's streaming bucket: rising-edge cancel + the
|
|
3053
|
+
// changed-or-active broadcast, routed to exactly that scope's viewers.
|
|
3054
|
+
function _streamingTickOnce(scopeKey) {
|
|
3055
|
+
const st = streamingStateFor(scopeKey);
|
|
3056
|
+
const isActive = st.active;
|
|
3057
|
+
const wasActive = _lastCliActiveByScope.get(scopeKey) || false;
|
|
3058
|
+
// 统一走 _observeStreamingTick:内部负责 rising-edge cancel(丢弃该 scope 的
|
|
3059
|
+
// pending turn_end,不 flush)+ 更新该 scope 的 edge 记录。
|
|
3060
|
+
_observeStreamingTick(isActive, 'cli', scopeKey);
|
|
3061
|
+
const changed = wasActive !== isActive;
|
|
3062
|
+
if (changed || isActive) {
|
|
3063
|
+
const data = isActive
|
|
3064
|
+
? { ...st, elapsed: Date.now() - st.startTime }
|
|
3065
|
+
: { active: false };
|
|
3066
|
+
// Per-workspace: each scope's spinner reaches only its own viewers. The
|
|
3067
|
+
// bound scope ('') rides the legacy project filter — byte-identical to the
|
|
3068
|
+
// old bound-only broadcast when no parallel workspace exists.
|
|
3069
|
+
if (clients.length > 0 && sendEventToClients) {
|
|
3070
|
+
const sc = parseScopeKey(scopeKey);
|
|
3071
|
+
const target = sc.project
|
|
3072
|
+
? filterClientsByScope(clients, sc.project, sc.instance, _projectName || '')
|
|
3073
|
+
: _filterClientsByViewProject(clients, _projectName || '');
|
|
3074
|
+
sendEventToClients(target, 'streaming_status', { ...data, project: sc.project || (_projectName || ''), instance: sc.instance || null });
|
|
3075
|
+
}
|
|
3076
|
+
}
|
|
3077
|
+
}
|
|
3078
|
+
|
|
2879
3079
|
export function startStreamingStatusTimer() {
|
|
2880
3080
|
if (_streamingStatusTimer) return;
|
|
2881
3081
|
_streamingStatusTimer = setInterval(() => {
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
2891
|
-
|
|
2892
|
-
|
|
2893
|
-
|
|
2894
|
-
// project's streaming flag (cross-project spinner bleed). (Per-project
|
|
2895
|
-
// streaming state is a follow-up; today a parallel view simply shows no
|
|
2896
|
-
// spinner, matching the pre-multi-PTY single-project behavior.)
|
|
2897
|
-
if (clients.length > 0 && sendEventToClients) {
|
|
2898
|
-
sendEventToClients(_filterClientsByViewProject(clients, _projectName || ''), 'streaming_status', data);
|
|
2899
|
-
}
|
|
3082
|
+
// Bound scope first (the common single-workspace case is exactly one
|
|
3083
|
+
// iteration), then any parallel-workspace scopes with their own buckets.
|
|
3084
|
+
_streamingTickOnce(BOUND_SCOPE);
|
|
3085
|
+
// Tick active scopes AND every scope we have a last-active edge record for.
|
|
3086
|
+
// activeStreamingScopes() alone only yields active:true buckets — a scope
|
|
3087
|
+
// that just fell inactive would never be ticked again, so its falling edge
|
|
3088
|
+
// is never observed (_lastCliActiveByScope stays true) and the rising-edge
|
|
3089
|
+
// pending-turn_end cancel never fires for it from the second round on.
|
|
3090
|
+
const keys = new Set(activeStreamingScopes());
|
|
3091
|
+
for (const key of _lastCliActiveByScope.keys()) keys.add(key);
|
|
3092
|
+
for (const key of keys) {
|
|
3093
|
+
if (key !== BOUND_SCOPE) _streamingTickOnce(key);
|
|
2900
3094
|
}
|
|
2901
3095
|
}, 500);
|
|
2902
3096
|
_streamingStatusTimer.unref();
|
|
@@ -2908,6 +3102,32 @@ export function stopViewer() {
|
|
|
2908
3102
|
_stoppingPromise = _doStop();
|
|
2909
3103
|
return _stoppingPromise;
|
|
2910
3104
|
}
|
|
3105
|
+
|
|
3106
|
+
// Reset every per-scope runtime structure: interceptor scope buckets plus the
|
|
3107
|
+
// two server-side maps that must not outlive the workspaces they describe —
|
|
3108
|
+
// _turnSessionIdByScope (a stale tag rejects the fresh session's first
|
|
3109
|
+
// UserPromptSubmit) and _lastCliActiveByScope (a stale edge breaks the
|
|
3110
|
+
// rising-edge cancel). Shared by _doStop and workspacesStop so both stop
|
|
3111
|
+
// paths reap the same set.
|
|
3112
|
+
export function resetScopeRuntime() {
|
|
3113
|
+
_lastSdkActive = false;
|
|
3114
|
+
_lastCliActiveByScope.clear();
|
|
3115
|
+
_turnSessionIdByScope.clear();
|
|
3116
|
+
try { clearScopeState(); } catch { /* best-effort */ }
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
// Reap ONE scope's runtime state (a single workspace's PTY was killed via
|
|
3120
|
+
// /api/live-processes/close — no Stop POST fires). Without this the killed
|
|
3121
|
+
// scope's bucket stays `active:true` forever: the heartbeat/streaming timer
|
|
3122
|
+
// keeps re-broadcasting it and the client's 30s staleness escape is
|
|
3123
|
+
// perpetually reset by those same frames (the permanent-spinner leak).
|
|
3124
|
+
export function clearScopeRuntimeFor(scopeKey) {
|
|
3125
|
+
const key = (typeof scopeKey === 'string' && scopeKey) || BOUND_SCOPE;
|
|
3126
|
+
try { clearScopeStateFor(key); } catch { /* best-effort */ }
|
|
3127
|
+
_turnSessionIdByScope.delete(key);
|
|
3128
|
+
_lastCliActiveByScope.delete(key);
|
|
3129
|
+
}
|
|
3130
|
+
|
|
2911
3131
|
async function _doStop() {
|
|
2912
3132
|
// _isStopping 设 true 后:①新 schedule 全部入口短路(_scheduleTurnEndBroadcast 入口检查)
|
|
2913
3133
|
// ②迟到的 streaming tick 也短路(_observeStreamingTick 入口检查)
|
|
@@ -2917,10 +3137,12 @@ async function _doStop() {
|
|
|
2917
3137
|
// Drop chat-queue timers (rescue / idle poll) so a stop/start cycle never injects a stale
|
|
2918
3138
|
// queued message into a fresh PTY session.
|
|
2919
3139
|
try { chatQueue.stop(); } catch { /* best-effort */ }
|
|
2920
|
-
// 对称 startViewer:下一次启动后第一次 active 才算 rising edge
|
|
2921
|
-
_lastSdkActive = false;
|
|
2922
|
-
_lastCliActive = false;
|
|
2923
3140
|
_clearTurnActiveHeartbeat();
|
|
3141
|
+
// Per-workspace: tear down every scope bucket plus the per-scope session tags
|
|
3142
|
+
// and streaming-edge records, so a restarted server never replays a stale
|
|
3143
|
+
// spinner / rejects a fresh session's first prompt for a workspace that no
|
|
3144
|
+
// longer exists.
|
|
3145
|
+
resetScopeRuntime();
|
|
2924
3146
|
// Tear down all IM bridge connections so a stop/start cycle (Electron tab switch, tests) never
|
|
2925
3147
|
// leaks a second WS to the same app. Idempotent + swallows errors.
|
|
2926
3148
|
// IM teardown + serverStopping hook 共用一个 3s 总预算(保持串行语义):
|
|
@@ -2944,6 +3166,13 @@ async function _doStop() {
|
|
|
2944
3166
|
// Truncate in place (not `clients = []`) so the array reference stays stable across
|
|
2945
3167
|
// stop/start cycles — deps.clients holds this same reference.
|
|
2946
3168
|
clients.length = 0;
|
|
3169
|
+
// Tear down the /ws/mux dialogue mux: server.close()/closeAllConnections() do NOT reach
|
|
3170
|
+
// upgraded WebSocket sockets, so the mux must terminate its own sockets (and its per-socket
|
|
3171
|
+
// ping timers / streams) or an Electron stop/start cycle would leak them.
|
|
3172
|
+
if (streamMux) {
|
|
3173
|
+
try { streamMux.close(); } catch { /* best-effort */ }
|
|
3174
|
+
streamMux = null;
|
|
3175
|
+
}
|
|
2947
3176
|
if (server) {
|
|
2948
3177
|
// 销毁所有活跃连接,防止 keep-alive 阻止进程退出
|
|
2949
3178
|
server.closeAllConnections();
|
|
@@ -2979,11 +3208,9 @@ async function _doStop() {
|
|
|
2979
3208
|
// empty), but the module-level checklist itself must not leak into the next
|
|
2980
3209
|
// stop/start cycle — a client connecting before the new session's startup
|
|
2981
3210
|
// boundary would otherwise get the PREVIOUS session's snapshot.
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
}
|
|
2986
|
-
resetTasks();
|
|
3211
|
+
for (const t of _taskBroadcastTimers.values()) clearTimeout(t);
|
|
3212
|
+
_taskBroadcastTimers.clear();
|
|
3213
|
+
resetAllTasks();
|
|
2987
3214
|
resetStreamingState();
|
|
2988
3215
|
// Turn-level flag dies with the server, cleared AFTER connections close (same slot
|
|
2989
3216
|
// as resetStreamingState): a task-event arriving during the 3s IM-teardown window
|
|
@@ -3003,8 +3230,7 @@ async function _doStop() {
|
|
|
3003
3230
|
// are intentionally no pushSdkEntry/setSdkStreamingState shortcuts anymore.
|
|
3004
3231
|
|
|
3005
3232
|
/** Broadcast a message to all terminal WS clients (for SDK canUseTool). */
|
|
3006
|
-
export function broadcastWsMessage(msg) {
|
|
3007
|
-
if (terminalWss) {
|
|
3233
|
+
export function broadcastWsMessage(msg) { if (terminalWss) {
|
|
3008
3234
|
// SDK multi-session (2026-10-12): a cwd-tagged frame belongs to one workspace's session.
|
|
3009
3235
|
// Route it only to clients viewing that workspace (or the bound view with no ?project=),
|
|
3010
3236
|
// so a background workspace's approval/queue/spinner never bleeds into the foreground.
|
|
@@ -3015,10 +3241,17 @@ export function broadcastWsMessage(msg) {
|
|
|
3015
3241
|
// — every frame for such a workspace would be silently dropped for its viewer.
|
|
3016
3242
|
let frameProj = null;
|
|
3017
3243
|
if (frameCwd) frameProj = projectKeyForCwd(frameCwd) || null;
|
|
3244
|
+
// Same-name twin isolation (review P2): a frame tagged with an instance key narrows
|
|
3245
|
+
// delivery to clients viewing THAT instance — otherwise two same-basename workspaces
|
|
3246
|
+
// (same frameProj) would cross-deliver (e.g. a close teardown's idle/queue-state wipe
|
|
3247
|
+
// killing the surviving twin viewer's spinner and queue bubbles). Untagged/cwd-only
|
|
3248
|
+
// frames keep the historical project-level behavior.
|
|
3249
|
+
const frameInstance = (msg && typeof msg === 'object' && typeof msg.instanceKey === 'string' && msg.instanceKey) ? msg.instanceKey : null;
|
|
3018
3250
|
const str = typeof msg === 'string' ? msg : JSON.stringify(msg);
|
|
3019
3251
|
terminalWss.clients.forEach((c) => {
|
|
3020
3252
|
if (c.readyState !== 1) return;
|
|
3021
3253
|
if (frameProj && c._ccvViewProject && c._ccvViewProject !== frameProj) return; // other workspace's frame
|
|
3254
|
+
if (frameInstance && c._ccvViewInstance && c._ccvViewInstance !== frameInstance) return; // other instance of the same project
|
|
3022
3255
|
try { c.send(str); } catch {}
|
|
3023
3256
|
});
|
|
3024
3257
|
}
|