cc-viewer 1.9.4 → 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/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,7 @@ 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
+ let streamMux = null; // /ws/mux dialogue-channel stream mux (SSE stays the fallback)
329
331
  let _writeToPty = null; // PTY write function reference (set by setupTerminalWebSocket)
330
332
  let _onPtyData = null; // PTY data listener registration (set by setupTerminalWebSocket)
331
333
  export function setWorkspaceClaudeArgs(args) {
@@ -662,6 +664,9 @@ const deps = {
662
664
  get retryConfig() { return _retryConfigState; },
663
665
  get workspaceLaunched() { return _workspaceLaunched; },
664
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 || ''; },
665
670
  get launchCallback() { return _launchCallback; },
666
671
  get workspaceClaudeArgs() { return _workspaceClaudeArgs; },
667
672
  get workspaceClaudePath() { return _workspaceClaudePath; },
@@ -695,15 +700,24 @@ const deps = {
695
700
  stopLogWatch,
696
701
  ensureProjectFeed: _ensureProjectFeed,
697
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),
698
709
  // Raw Stop-hook POST arrival (routes/events.js turnEndNotify): clears the
699
710
  // turn-level loading flag immediately — the debounced `turn_end` SSE frame is
700
711
  // voice-pack semantics and too slow for the spinner. This is the ONLY clear
701
712
  // path when the turn produced no API traffic (hook-only turn), so it must not
702
713
  // wait on the debounce timer.
703
- onTurnActiveClear: () => { _setTurnActive(false, null); },
714
+ onTurnActiveClear: (scope = null) => { _setTurnActive(false, null, scope); },
704
715
  // SSE connect replay: a page refresh mid-turn restores the spinner without
705
716
  // waiting for the next streaming tick (routes/events.js events() connect block).
706
- getPtyTurnActive: () => turnActive.active,
717
+ getPtyTurnActive: (scopeKey = BOUND_SCOPE) => turnStateFor(scopeKey).active,
718
+ resolveScopeForHookPayload: _resolveScopeForHookPayload,
719
+ getTurnActiveFor: (scopeKey = BOUND_SCOPE) => turnStateFor(scopeKey).active,
720
+ filterClientsByViewProject: _filterClientsByViewProject,
707
721
  // SessionStart hook notify → conversation-switch signal (in-terminal
708
722
  // /resume). The source gate + V2Writer re-bind live in the interceptor.
709
723
  onSessionStartNotify: markSessionStart,
@@ -713,25 +727,35 @@ const deps = {
713
727
  // path broadcasts the empty snapshot — no separate reset route/hook.
714
728
  onTaskEvent: (payload) => {
715
729
  try {
716
- applyTaskEvent(payload);
717
- _scheduleTaskUpdateBroadcast();
730
+ const sc = _resolveScopeForHookPayload(payload);
731
+ const key = scopeKeyOf(sc.project, sc.instance);
732
+ applyTaskEvent(payload, key);
733
+ _scheduleTaskUpdateBroadcast(key);
718
734
  // Turn-level loading: a new main-agent prompt marks the turn active.
719
735
  // Subagent/teammate events are excluded inside the predicate (agentId +
720
- // the load-bearing sessionId gate against the currently tracked turn).
721
- if (isTurnStartTaskEvent(payload, _turnSessionId)) {
722
- _turnSessionId = payload.sessionId;
723
- _setTurnActive(true, payload.sessionId);
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);
724
741
  }
725
742
  } catch (err) { reportSwallowed('task-state.apply', err); }
726
743
  },
727
744
  // Session-boundary reset for the task checklist. The gate itself lives in
728
745
  // task-state.shouldResetTasks (pure, unit-tested): main-agent startup/clear
729
746
  // resets; resume/fork to a different session resets; subagent/teammate
730
- // 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.
731
750
  onTaskSessionBoundary: (payload) => {
732
- if (!shouldResetTasks(payload, getTaskSnapshot().sessionId)) return;
733
- resetTasks();
734
- _emitTaskUpdate();
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);
735
759
  },
736
760
  // Per-connection snapshot replay for fresh SSE clients (page refresh /
737
761
  // reconnect) — the checklist lives only in memory, not in the cold load.
@@ -1089,6 +1113,10 @@ export async function startViewer() {
1089
1113
  _stoppingPromise = null;
1090
1114
  // 加载插件(需要在创建服务器之前,以便通过 hook 获取 HTTPS 证书)
1091
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(() => {});
1092
1120
 
1093
1121
  // 启动期配置备份:preferences/profile/workspaces → LOG_DIR 外的 cc-viewer-config-backups/
1094
1122
  // (滚动留 10 份)。2026-06-06 事故:配置随 LOG_DIR 整树丢失后无处可恢复。fire-and-forget。
@@ -1446,6 +1474,18 @@ async function setupTerminalWebSocket(httpServer) {
1446
1474
  terminalWss = wss;
1447
1475
  const wssScratch = new WebSocketServer({ noServer: true });
1448
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
+
1449
1489
  // 多客户端共享 PTY 的尺寸冲突解决:
1450
1490
  // 移动端优先——只要有移动端在线,PTY 始终使用移动端尺寸,
1451
1491
  // PC 端的 resize 仅存储不生效,避免宽屏尺寸导致移动端乱码。
@@ -1567,6 +1607,12 @@ async function setupTerminalWebSocket(httpServer) {
1567
1607
  }
1568
1608
  req.ccvScratchCwd = scratchCwd;
1569
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);
1570
1616
  } else {
1571
1617
  socket.destroy();
1572
1618
  }
@@ -2447,7 +2493,13 @@ async function setupTerminalWebSocket(httpServer) {
2447
2493
  // The Stop hook does NOT fire on user interrupt (documented Claude Code
2448
2494
  // behavior), so the web Stop button must clear the turn-level loading
2449
2495
  // flag itself or the spinner would outlive the interrupted turn.
2450
- _setTurnActive(false, null);
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);
2451
2503
  }
2452
2504
  } else if (msg.type === 'image-remove-notify' || msg.type === 'image-upload-notify') {
2453
2505
  // Security: only allow paths within upload directories, reject traversal
@@ -2602,19 +2654,82 @@ const TURN_END_DEBOUNCE_MS = (() => {
2602
2654
  })();
2603
2655
  let _isStopping = false;
2604
2656
  let _lastSdkActive = false;
2605
- let _lastCliActive = false;
2657
+ // Per-scope CLI streaming edge state ('' = bound — the legacy single flag).
2658
+ const _lastCliActiveByScope = new Map();
2606
2659
  let _onTurnEndBroadcastForTests = null;
2607
2660
 
2608
2661
  function _normalizeKey(sessionId) {
2609
2662
  return (typeof sessionId === 'string' && sessionId) ? sessionId : null;
2610
2663
  }
2611
2664
 
2612
- function _emitTurnEnd(sessionId, ts, transcriptPath = null) {
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) {
2613
2724
  const sid = _normalizeKey(sessionId);
2614
2725
  const t = ts || Date.now();
2726
+ const sc = scope || { project: '', instance: null };
2615
2727
  try {
2616
2728
  if (clients.length > 0 && sendEventToClients) {
2617
- sendEventToClients(clients, 'turn_end', { sessionId: sid, ts: t });
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 });
2618
2733
  }
2619
2734
  // Forward the (clean) assistant reply for this turn to whichever IM bridge owns the in-flight
2620
2735
  // turn, if any. Fire-and-forget: a bridge failure must never affect SSE broadcast.
@@ -2632,10 +2747,13 @@ function _emitTurnEnd(sessionId, ts, transcriptPath = null) {
2632
2747
  }
2633
2748
  }
2634
2749
 
2635
- function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null) {
2750
+ function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null, scope = null) {
2636
2751
  if (_isStopping) return;
2637
2752
  const sid = _normalizeKey(sessionId);
2638
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 };
2639
2757
  // chat-queue drain trigger #1: the Stop-hook POST arrival IS the authoritative
2640
2758
  // "query() ended" signal — don't wait out the SSE debounce window (default 10s),
2641
2759
  // or freshly-parked bubbles would sit visibly inert after every turn. The queue's
@@ -2648,10 +2766,10 @@ function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null) {
2648
2766
  if (existing) clearTimeout(existing.timer);
2649
2767
  const timer = setTimeout(() => {
2650
2768
  _pendingTurnEndTimers.delete(sid);
2651
- _emitTurnEnd(sid, t, transcriptPath);
2769
+ _emitTurnEnd(sid, t, transcriptPath, sc);
2652
2770
  }, TURN_END_DEBOUNCE_MS);
2653
2771
  if (typeof timer.unref === 'function') timer.unref();
2654
- _pendingTurnEndTimers.set(sid, { timer, ts: t });
2772
+ _pendingTurnEndTimers.set(sid, { timer, ts: t, scopeKey: scopeKeyOf(sc.project, sc.instance) });
2655
2773
  }
2656
2774
 
2657
2775
  // Task-checklist broadcast (task-bridge.js → /api/task-event → deps.onTaskEvent).
@@ -2660,28 +2778,40 @@ function _scheduleTurnEndBroadcast(sessionId, ts, transcriptPath = null) {
2660
2778
  // trailing debounce coalesces multi-task flips (e.g. a batch TaskCreate turn)
2661
2779
  // into one SSE frame.
2662
2780
  const TASK_UPDATE_DEBOUNCE_MS = 120;
2663
- let _taskBroadcastTimer = null;
2664
-
2665
- function _emitTaskUpdate() {
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
2666
2789
  if (clients.length === 0 || !sendEventToClients) return;
2667
2790
  // Parity with _emitTurnEnd: a broadcast failure must never surface as an
2668
2791
  // uncaught exception from the debounce timer.
2669
2792
  try {
2670
- const snap = getTaskSnapshot();
2671
- sendEventToClients(clients, 'task_update', { sessionId: snap.sessionId, tasks: snap.tasks, ts: Date.now() });
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 });
2672
2799
  } catch (err) {
2673
2800
  console.warn('[task-update] broadcast failed:', err && err.message);
2674
2801
  }
2675
2802
  }
2676
2803
 
2677
- function _scheduleTaskUpdateBroadcast() {
2804
+ function _scheduleTaskUpdateBroadcast(scopeKey = BOUND_SCOPE) {
2678
2805
  if (_isStopping) return;
2679
- if (_taskBroadcastTimer) clearTimeout(_taskBroadcastTimer);
2680
- _taskBroadcastTimer = setTimeout(() => {
2681
- _taskBroadcastTimer = null;
2682
- _emitTaskUpdate();
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);
2683
2812
  }, TASK_UPDATE_DEBOUNCE_MS);
2684
- if (typeof _taskBroadcastTimer.unref === 'function') _taskBroadcastTimer.unref();
2813
+ if (typeof timer.unref === 'function') timer.unref();
2814
+ _taskBroadcastTimers.set(key, timer);
2685
2815
  }
2686
2816
 
2687
2817
  // 直接丢弃所有 pending —— 用于两条路径:(1) `_doStop` shutdown,(2) rising-edge 新请求
@@ -2692,11 +2822,21 @@ function _cancelAllPendingTurnEndBroadcasts() {
2692
2822
  _pendingTurnEndTimers.clear();
2693
2823
  }
2694
2824
 
2695
- function _onStreamingActivated() {
2696
- // rising-edge:Claude 又开始新一轮 query(),按用户原始语义「10s 内有新请求 → 不算完成 →
2697
- // 不播放」,直接 cancel 所有 pending(**不** flush)。这正是用户键入「请等待 10 秒钟」
2698
- // 的本意:被打断就当没完成。
2699
- _cancelAllPendingTurnEndBroadcasts();
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);
2700
2840
  // A new turn also lifts the chat-queue Stop-park: parked bubbles resume auto-draining
2701
2841
  // at the next turn end.
2702
2842
  try { chatQueue.onStreamingActivated(); } catch { /* best-effort */ }
@@ -2713,18 +2853,18 @@ function _onStreamingActivated() {
2713
2853
  // explicit user interrupt. No streaming rising-edge fallback by design:
2714
2854
  // housekeeping/telemetry requests after query() end would re-light it for the
2715
2855
  // whole staleness window (see HISTORY note at _scheduleTurnEndBroadcast).
2716
- // Single global flag, same constraint as streamingState (one streaming session
2717
- // per server process). Broadcasts/replay reach only viewers of the BOUND project
2718
- // (more filtered than streaming_status, which broadcasts bound-project state to
2719
- // all clients): a chip-switched parallel view sees no spinner, matching the
2720
- // pre-multi-PTY single-project posture (see the streaming_status timer comment).
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).
2721
2860
  //
2722
- // Heartbeat: while the flag is up, re-broadcast `turn_active` every
2723
- // TURN_ACTIVE_HEARTBEAT_MS. Local tool execution (Bash > 30s builds/tests) produces
2724
- // NO turn-level frames and NO API traffic — without a heartbeat the client's
2725
- // staleness escape cannot distinguish "silent tool" from "lost Stop POST" and
2726
- // would kill the spinner mid-turn. The heartbeat rides the same view-project
2727
- // filter; when the flag drops (Stop POST / interrupt), it stops, and the client
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
2728
2868
  // backstop still catches a process-death/stuck-server within one window.
2729
2869
 
2730
2870
  // Pure predicate (unit-tested): does this /api/task-event payload mark a new
@@ -2744,17 +2884,24 @@ export function isTurnStartTaskEvent(payload, currentSessionId) {
2744
2884
 
2745
2885
  const TURN_ACTIVE_HEARTBEAT_MS = 10 * 1000;
2746
2886
  let _turnActiveHeartbeat = null;
2747
- // Session tag of the turn that lit the flag — the load-bearing gate for
2748
- // UserPromptSubmit (agentId is unreliable on this hook; see task-state.js). A Stop
2749
- // POST clears the flag but keeps the tag: the NEXT prompt re-tags, and a stale tag
2750
- // must not reject it (the tag only rejects prompts from a DIFFERENT live session).
2751
- let _turnSessionId = null;
2752
-
2753
- function _armTurnActiveHeartbeat(sessionId) {
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() {
2754
2896
  if (_turnActiveHeartbeat) return; // already armed
2755
2897
  _turnActiveHeartbeat = setInterval(() => {
2756
- if (_isStopping || !turnActive.active) { _clearTurnActiveHeartbeat(); return; }
2757
- _broadcastTurnActive(true, sessionId || null);
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
+ }
2758
2905
  }, TURN_ACTIVE_HEARTBEAT_MS);
2759
2906
  if (typeof _turnActiveHeartbeat.unref === 'function') _turnActiveHeartbeat.unref();
2760
2907
  }
@@ -2763,39 +2910,51 @@ function _clearTurnActiveHeartbeat() {
2763
2910
  if (_turnActiveHeartbeat) { clearInterval(_turnActiveHeartbeat); _turnActiveHeartbeat = null; }
2764
2911
  }
2765
2912
 
2766
- function _broadcastTurnActive(active, sessionId) {
2913
+ function _broadcastTurnActive(active, sessionId, scope = null) {
2767
2914
  if (_isStopping) return;
2768
2915
  if (!sendEventToClients || clients.length === 0) return;
2916
+ const sc = scope || { project: '', instance: null };
2769
2917
  try {
2770
2918
  sendEventToClients(
2771
- _filterClientsByViewProject(clients, _projectName || ''),
2919
+ sc.project
2920
+ ? filterClientsByScope(clients, sc.project, sc.instance, _projectName || '')
2921
+ : _filterClientsByViewProject(clients, _projectName || ''),
2772
2922
  'turn_active',
2773
- { active, sessionId: sessionId || null, ts: Date.now() },
2923
+ { active, sessionId: sessionId || null, ts: Date.now(), project: sc.project || (_projectName || ''), instance: sc.instance || null },
2774
2924
  );
2775
2925
  } catch (err) { reportSwallowed('turn-active.broadcast', err); }
2776
2926
  }
2777
2927
 
2778
2928
  // State-change guard: only broadcast on real transitions (SDK paths share the
2779
2929
  // turn-end entry points and would otherwise emit duplicate frames). The heartbeat
2780
- // is armed/disarmed here so it tracks the flag exactly.
2781
- function _setTurnActive(active, sessionId) {
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);
2782
2936
  const next = !!active;
2783
- if (next === turnActive.active) return;
2784
- setTurnActive(next);
2785
- if (next) _armTurnActiveHeartbeat(sessionId); else _clearTurnActiveHeartbeat();
2786
- _broadcastTurnActive(next, sessionId);
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);
2787
2943
  }
2788
2944
 
2789
2945
  // 统一的 streaming-state 观察入口。production polling 和 SDK push 都调它,测试也走它,
2790
2946
  // 不再为「测试桩」单独维护 mirror state(杜绝逻辑漂移)。返回是否检测到 rising edge。
2791
2947
  // `_isStopping` 时直接返回 false:shutdown 中迟到的 tick 不应再去 flush。
2792
- function _observeStreamingTick(activeNow, mode /* 'cli' | 'sdk' */) {
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) {
2793
2952
  if (_isStopping) return false;
2794
2953
  const isActive = !!activeNow;
2795
- const wasActive = mode === 'sdk' ? _lastSdkActive : _lastCliActive;
2796
- if (mode === 'sdk') _lastSdkActive = isActive; else _lastCliActive = isActive;
2954
+ const wasActive = mode === 'sdk' ? _lastSdkActive : (_lastCliActiveByScope.get(scopeKey) || false);
2955
+ if (mode === 'sdk') _lastSdkActive = isActive; else _lastCliActiveByScope.set(scopeKey, isActive);
2797
2956
  if (isActive && !wasActive) {
2798
- _onStreamingActivated();
2957
+ _onStreamingActivated(scopeKey);
2799
2958
  return true;
2800
2959
  }
2801
2960
  return false;
@@ -2812,34 +2971,38 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
2812
2971
  _cancelAllPendingTurnEndBroadcasts();
2813
2972
  _isStopping = false;
2814
2973
  _lastSdkActive = false;
2815
- _lastCliActive = false;
2974
+ _lastCliActiveByScope.clear();
2816
2975
  _stoppingPromise = null;
2817
2976
  _onTurnEndBroadcastForTests = null;
2818
2977
  setTurnActive(false);
2819
2978
  _clearTurnActiveHeartbeat();
2820
- _turnSessionId = null;
2979
+ _turnSessionIdByScope.clear();
2980
+ clearScopeState();
2821
2981
  },
2822
2982
  onBroadcast(fn) { _onTurnEndBroadcastForTests = fn; },
2823
2983
  getPendingKeys() { return [..._pendingTurnEndTimers.keys()]; },
2824
2984
  setIsStopping(v) { _isStopping = !!v; },
2825
- observeStreamingTick(activeNow, mode = 'cli') { return _observeStreamingTick(activeNow, mode); },
2826
- 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); },
2827
2987
  getDebounceMs() { return TURN_END_DEBOUNCE_MS; },
2828
- getPtyTurnActive() { return turnActive.active; },
2829
- setPtyTurnActive(v, sessionId = null) { _setTurnActive(v, sessionId); },
2988
+ getPtyTurnActive(scopeKey = BOUND_SCOPE) { return turnStateFor(scopeKey).active; },
2989
+ setPtyTurnActive(v, sessionId = null, scope = null) { _setTurnActive(v, sessionId, scope); },
2830
2990
  // Routes the payload through the SAME predicate the deps.onTaskEvent closure
2831
2991
  // uses, so tests exercise the production gate rather than a mirror of it.
2832
2992
  notifyTaskEvent(payload) {
2833
- if (isTurnStartTaskEvent(payload, _turnSessionId)) {
2834
- _turnSessionId = payload?.sessionId || null;
2835
- _setTurnActive(true, payload?.sessionId);
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);
2836
2998
  }
2837
2999
  },
2838
3000
  // Clear drops the flag but KEEPS the session tag: the next prompt of the SAME
2839
3001
  // session re-lights fast (tag match), while a foreign session's prompt stays
2840
3002
  // rejected. The tag resets only on test reset / server stop.
2841
- clearPtyTurnActive() { _setTurnActive(false, null); },
2842
- getTurnSessionId() { return _turnSessionId; },
3003
+ clearPtyTurnActive(scope = null) { _setTurnActive(false, null, scope); },
3004
+ getTurnSessionId(scopeKey = BOUND_SCOPE) { return _turnSessionIdByScope.get(scopeKey) || null; },
3005
+ resolveScopeForHookPayload(payload) { return _resolveScopeForHookPayload(payload); },
2843
3006
  } : Object.freeze({
2844
3007
  reset() {},
2845
3008
  onBroadcast() {},
@@ -2857,10 +3020,16 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
2857
3020
  /**
2858
3021
  * Schedule a debounced turn_end SSE broadcast. **Returns immediately**; actual SSE write
2859
3022
  * happens up to TURN_END_DEBOUNCE_MS later (default 10s; clamped [100,60000]).
2860
- * 详细语义见 SUNSET-MARKER patch-note。
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.)
2861
3027
  */
2862
- export function broadcastTurnEnd(sessionId = null, ts = Date.now()) {
2863
- _scheduleTurnEndBroadcast(sessionId, ts);
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);
2864
3033
  }
2865
3034
 
2866
3035
  // 流式状态 SSE 推送定时器:检测 streamingState 变化并广播给所有客户端。
@@ -2876,27 +3045,48 @@ let _updateCheckTimer = null;
2876
3045
  // Exported for tests: workspace-mode startViewer defers this timer to initPostLaunch, but
2877
3046
  // lifecycle tests need to drive the streaming_status poll directly (setSdkStreamingState
2878
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
+
2879
3075
  export function startStreamingStatusTimer() {
2880
3076
  if (_streamingStatusTimer) return;
2881
3077
  _streamingStatusTimer = setInterval(() => {
2882
- const isActive = streamingState.active;
2883
- const wasActive = _lastCliActive;
2884
- // 统一走 _observeStreamingTick:内部负责 rising-edge cancel(丢弃 pending turn_end,不 flush)+ 更新 _lastCliActive。
2885
- _observeStreamingTick(isActive, 'cli');
2886
- const changed = wasActive !== isActive;
2887
- if (changed || isActive) {
2888
- const data = isActive
2889
- ? { ...streamingState, elapsed: Date.now() - streamingState.startTime }
2890
- : { active: false };
2891
- // Multi-project (2026-10): streamingState is a global singleton tracking the
2892
- // BOUND project's main process — route the spinner only to clients viewing
2893
- // that project, so a parallel project's view never shows the bound
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
- }
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);
2900
3090
  }
2901
3091
  }, 500);
2902
3092
  _streamingStatusTimer.unref();
@@ -2908,6 +3098,32 @@ export function stopViewer() {
2908
3098
  _stoppingPromise = _doStop();
2909
3099
  return _stoppingPromise;
2910
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
+
2911
3127
  async function _doStop() {
2912
3128
  // _isStopping 设 true 后:①新 schedule 全部入口短路(_scheduleTurnEndBroadcast 入口检查)
2913
3129
  // ②迟到的 streaming tick 也短路(_observeStreamingTick 入口检查)
@@ -2917,10 +3133,12 @@ async function _doStop() {
2917
3133
  // Drop chat-queue timers (rescue / idle poll) so a stop/start cycle never injects a stale
2918
3134
  // queued message into a fresh PTY session.
2919
3135
  try { chatQueue.stop(); } catch { /* best-effort */ }
2920
- // 对称 startViewer:下一次启动后第一次 active 才算 rising edge
2921
- _lastSdkActive = false;
2922
- _lastCliActive = false;
2923
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();
2924
3142
  // Tear down all IM bridge connections so a stop/start cycle (Electron tab switch, tests) never
2925
3143
  // leaks a second WS to the same app. Idempotent + swallows errors.
2926
3144
  // IM teardown + serverStopping hook 共用一个 3s 总预算(保持串行语义):
@@ -2944,6 +3162,13 @@ async function _doStop() {
2944
3162
  // Truncate in place (not `clients = []`) so the array reference stays stable across
2945
3163
  // stop/start cycles — deps.clients holds this same reference.
2946
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
+ }
2947
3172
  if (server) {
2948
3173
  // 销毁所有活跃连接,防止 keep-alive 阻止进程退出
2949
3174
  server.closeAllConnections();
@@ -2979,11 +3204,9 @@ async function _doStop() {
2979
3204
  // empty), but the module-level checklist itself must not leak into the next
2980
3205
  // stop/start cycle — a client connecting before the new session's startup
2981
3206
  // boundary would otherwise get the PREVIOUS session's snapshot.
2982
- if (_taskBroadcastTimer) {
2983
- clearTimeout(_taskBroadcastTimer);
2984
- _taskBroadcastTimer = null;
2985
- }
2986
- resetTasks();
3207
+ for (const t of _taskBroadcastTimers.values()) clearTimeout(t);
3208
+ _taskBroadcastTimers.clear();
3209
+ resetAllTasks();
2987
3210
  resetStreamingState();
2988
3211
  // Turn-level flag dies with the server, cleared AFTER connections close (same slot
2989
3212
  // as resetStreamingState): a task-event arriving during the 3s IM-teardown window