cc-viewer 1.9.3 → 1.9.4

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
@@ -2,7 +2,7 @@ import { createServer } from 'node:http';
2
2
  import { createServer as createHttpsServer } from 'node:https';
3
3
  import { createConnection } from 'node:net';
4
4
  import { randomBytes } from 'node:crypto';
5
- import { readFileSync, existsSync, unwatchFile, statSync, renameSync, unlinkSync } from 'node:fs';
5
+ import { readFileSync, existsSync, unwatchFile, statSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
6
6
  import { join, extname, resolve, sep } from 'node:path';
7
7
  import { platform, networkInterfaces, tmpdir } from 'node:os';
8
8
  import { execFile, exec, spawn } from 'node:child_process';
@@ -41,6 +41,8 @@ import { authRoutes } from './routes/auth.js';
41
41
  import { dingtalkRoutes } from './routes/dingtalk.js';
42
42
  import { imRoutes } from './routes/im.js';
43
43
  import { resumeRoutes } from './routes/resume.js';
44
+ import { sdkContextRoutes } from './routes/sdk-context.js';
45
+ import { sdkMcpStatusRoutes } from './routes/sdk-mcp-status.js';
44
46
  import { proxyStatsRoutes } from './routes/proxy-stats.js';
45
47
  import { setProxyStatsListener } from './lib/proxy/proxy-stats.js';
46
48
  import * as imCore from './lib/im/im-bridge-core.js';
@@ -95,7 +97,7 @@ function execWithStdin(cmd, args, input, options) {
95
97
  }
96
98
  });
97
99
  }
98
- import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, PROFILE_PATH, RETRY_CONFIG_PATH, _retryConfigState, setLivePort, getImLiveText, resetImLiveText, markSessionStart } from './interceptor.js';
100
+ import { _initPromise, _projectName, _logDir, _v2Writer, streamingState, resetStreamingState, turnActive, setTurnActive, PROFILE_PATH, RETRY_CONFIG_PATH, _retryConfigState, setLivePort, getImLiveText, resetImLiveText, markSessionStart } from './interceptor.js';
99
101
  import { V2LiveFeed } from './lib/v2/live-feed.js';
100
102
  import { filterClientsByViewProject, projectOfSessionDir, resolveActivityFeedKey } from './lib/v2/view-router.js';
101
103
  import { sanitizePathComponent } from './lib/v2/layout.js';
@@ -117,6 +119,7 @@ import { loadPlugins, runWaterfallHook, runParallelHook } from './lib/plugin-loa
117
119
  import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher.js';
118
120
  import { sendEventToClients } from './lib/log-watcher.js';
119
121
  import { applyTaskEvent, resetTasks, getTaskSnapshot, shouldResetTasks } from './lib/task-state.js';
122
+ import { projectKeyForCwd } from './lib/system-prompt-snapshots.js';
120
123
  import { createImLogWatcher } from './lib/im/im-log-watcher.js';
121
124
  import { unwatchAllWorkflows } from './lib/workflow-watcher.js';
122
125
  import { backupConfigs } from './lib/config-backup.js';
@@ -692,6 +695,15 @@ const deps = {
692
695
  stopLogWatch,
693
696
  ensureProjectFeed: _ensureProjectFeed,
694
697
  scheduleTurnEndBroadcast: _scheduleTurnEndBroadcast,
698
+ // Raw Stop-hook POST arrival (routes/events.js turnEndNotify): clears the
699
+ // turn-level loading flag immediately — the debounced `turn_end` SSE frame is
700
+ // voice-pack semantics and too slow for the spinner. This is the ONLY clear
701
+ // path when the turn produced no API traffic (hook-only turn), so it must not
702
+ // wait on the debounce timer.
703
+ onTurnActiveClear: () => { _setTurnActive(false, null); },
704
+ // SSE connect replay: a page refresh mid-turn restores the spinner without
705
+ // waiting for the next streaming tick (routes/events.js events() connect block).
706
+ getPtyTurnActive: () => turnActive.active,
695
707
  // SessionStart hook notify → conversation-switch signal (in-terminal
696
708
  // /resume). The source gate + V2Writer re-bind live in the interceptor.
697
709
  onSessionStartNotify: markSessionStart,
@@ -703,6 +715,13 @@ const deps = {
703
715
  try {
704
716
  applyTaskEvent(payload);
705
717
  _scheduleTaskUpdateBroadcast();
718
+ // Turn-level loading: a new main-agent prompt marks the turn active.
719
+ // 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);
724
+ }
706
725
  } catch (err) { reportSwallowed('task-state.apply', err); }
707
726
  },
708
727
  // Session-boundary reset for the task checklist. The gate itself lives in
@@ -814,6 +833,8 @@ const _routes = [
814
833
  ...imRoutes,
815
834
  ...proxyStatsRoutes,
816
835
  ...resumeRoutes,
836
+ ...sdkContextRoutes,
837
+ ...sdkMcpStatusRoutes,
817
838
  ];
818
839
  const dispatch = createDispatcher(_routes);
819
840
 
@@ -1380,7 +1401,7 @@ export async function startViewer() {
1380
1401
  async function setupTerminalWebSocket(httpServer) {
1381
1402
  try {
1382
1403
  const { WebSocketServer } = await import('ws');
1383
- const { writeToPty, writeToPtySequential, writeToPtyFor, writeToPtySequentialFor, resizePty, onPtyData, onPtyExit, getPtyState, getPtyKind, getOutputBuffer, getCurrentWorkspace, spawnShell, findSafeSliceStart, attachPtyFor } = await import('./pty-manager.js');
1404
+ const { writeToPty, writeToPtySequential, writeToPtyFor, writeToPtySequentialFor, resizePty, onPtyData, onPtyExit, getPtyState, getPtyKind, getOutputBuffer, getCurrentWorkspace, spawnShell, findSafeSliceStart, attachPtyFor, listLivePtys } = await import('./pty-manager.js');
1384
1405
  const {
1385
1406
  spawnScratch,
1386
1407
  writeScratch,
@@ -1392,10 +1413,18 @@ async function setupTerminalWebSocket(httpServer) {
1392
1413
  getScratchOutputBuffer,
1393
1414
  getScratchShellBasename,
1394
1415
  getScratchPtyCount,
1416
+ getScratchPtyCountForCwd,
1395
1417
  hasScratchPty,
1396
1418
  } = await import('./scratch-pty-manager.js');
1419
+ const { loadWorkspaces } = await import('./workspace-registry.js');
1420
+ const { resolveViewRoot } = await import('./lib/view-root.js');
1397
1421
  const SCRATCH_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
1398
- const MAX_SCRATCH_PTYS = 16;
1422
+ // Per-workspace cap = 2x the client's tab cap (8), same semantics as the old global
1423
+ // 16 for single-workspace use. Hidden workspaces keep their PTYs alive by design
1424
+ // (per-workspace tab groups restore on switch-back), so the global bound must be
1425
+ // larger yet finite — each entry is a real shell process.
1426
+ const MAX_SCRATCH_PTYS_PER_CWD = 16;
1427
+ const MAX_SCRATCH_PTYS_TOTAL = 64;
1399
1428
  _writeToPty = writeToPty;
1400
1429
  _onPtyData = onPtyData;
1401
1430
  // Chat-composer busy queue (terminal-hidden PTY mode): holds Enter-while-busy messages
@@ -1473,7 +1502,15 @@ async function setupTerminalWebSocket(httpServer) {
1473
1502
  if (viewProject || viewInstance) {
1474
1503
  try { attachPtyFor({ project: viewProject || undefined, instanceKey: viewInstance || undefined }); } catch { }
1475
1504
  }
1505
+ // SDK multi-session (2026-10-12): remember this connection's viewed project so
1506
+ // broadcastWsMessage can route cwd-tagged SDK frames only to the client(s) watching
1507
+ // that workspace. No ?project= → bound view → receives every project (old behavior).
1508
+ // Set it directly in THIS upgrade's callback (fires once per connection, so reconnects
1509
+ // get re-stamped) — NOT via a one-shot wss.once('connection') listener, which would
1510
+ // only ever stamp the FIRST connection and leave every later/reconnected client
1511
+ // unstamped (receiving all workspaces' frames).
1476
1512
  wss.handleUpgrade(req, socket, head, (ws) => {
1513
+ try { ws._ccvViewProject = viewProject || null; } catch {}
1477
1514
  wss.emit('connection', ws, req);
1478
1515
  });
1479
1516
  } else if (pathname === '/ws/terminal-scratch') {
@@ -1483,17 +1520,53 @@ async function setupTerminalWebSocket(httpServer) {
1483
1520
  socket.destroy();
1484
1521
  return;
1485
1522
  }
1486
- // 硬上限基于后端 ptys Map 大小(含 running 与已退出未回收),
1487
- // 已有 id 走重连路径不计入新增配额;防止用户关浏览器后老 pty 仍存活、
1488
- // 新会话又能开 16 个导致总量翻番的累积膨胀
1489
- if (!hasScratchPty(scratchId) && getScratchPtyCount() >= MAX_SCRATCH_PTYS) {
1490
- socket.destroy();
1523
+ // Single upgrade hand-off used by the accept and both reject branches below.
1524
+ const handOffScratch = () => {
1525
+ req.ccvScratchId = scratchId;
1526
+ wssScratch.handleUpgrade(req, socket, head, (ws) => {
1527
+ wssScratch.emit('connection', ws, req);
1528
+ });
1529
+ };
1530
+ // Multi-project (2026-10-12): ?project=/?instance= pin the scratch shell's cwd to
1531
+ // the VIEWED workspace — same resolution as every project-scoped READ route
1532
+ // (resolveViewRoot is sync: fs + sync registry read). No params → bound root.
1533
+ // Accepted: with no instance and a same-basename twin live, the name path can
1534
+ // resolve to the twin's dir (the single-live-PTY rule designed for READ routes);
1535
+ // an unresolvable/ambiguous name takes the fatal-toast path below instead, so a
1536
+ // wrong-cwd spawn is bounded to that deliberate arbitration, never silent.
1537
+ const vr = resolveViewRoot({
1538
+ projectParam: wsUrl.searchParams.get('project'),
1539
+ instanceParam: wsUrl.searchParams.get('instance'),
1540
+ boundCwd: process.env.CCV_PROJECT_DIR || process.cwd(),
1541
+ loadWorkspaces,
1542
+ listLivePtys,
1543
+ });
1544
+ if (!vr.ok) {
1545
+ // Unresolvable/deleted/ambiguous workspace: do NOT bare-destroy — the client
1546
+ // would retry every 2s forever. Complete the upgrade, report once with a fatal
1547
+ // toast (client stops reconnecting), then close. Nothing spawns on this path.
1548
+ req.ccvScratchReject = vr.error || 'workspace unavailable';
1549
+ handOffScratch();
1491
1550
  return;
1492
1551
  }
1493
- req.ccvScratchId = scratchId;
1494
- wssScratch.handleUpgrade(req, socket, head, (ws) => {
1495
- wssScratch.emit('connection', ws, req);
1496
- });
1552
+ // Normalize once: the bound branch of resolveViewRoot returns the root un-normalized,
1553
+ // named branches realpathSync — without this the same dir can key two buckets.
1554
+ let scratchCwd = vr.root;
1555
+ try { scratchCwd = realpathSync(vr.root); } catch { }
1556
+ // Hard caps (per-workspace + global) on the ptys Map size (running and
1557
+ // exited-not-yet-reaped entries included); an existing id+cwd reconnect is exempt
1558
+ // so it can't be locked out by its own still-alive entry. Rejection goes through
1559
+ // the same fatal-toast hand-off as a resolve failure (never a bare destroy) so the
1560
+ // client stops reconnecting instead of retrying every 2s against a full bucket.
1561
+ if (!hasScratchPty(scratchId, { cwd: scratchCwd })
1562
+ && (getScratchPtyCount() >= MAX_SCRATCH_PTYS_TOTAL
1563
+ || getScratchPtyCountForCwd(scratchCwd) >= MAX_SCRATCH_PTYS_PER_CWD)) {
1564
+ req.ccvScratchReject = 'scratch terminal limit reached';
1565
+ handOffScratch();
1566
+ return;
1567
+ }
1568
+ req.ccvScratchCwd = scratchCwd;
1569
+ handOffScratch();
1497
1570
  } else {
1498
1571
  socket.destroy();
1499
1572
  }
@@ -1561,19 +1634,30 @@ async function setupTerminalWebSocket(httpServer) {
1561
1634
  // scratch 终端 WS:极简版,仅承载 input/resize/data/exit + 显式 kill;不掺杂 hook/SDK/preset
1562
1635
  wssScratch.on('connection', async (ws, req) => {
1563
1636
  const id = req.ccvScratchId;
1637
+ // Resolve-failure branch from the upgrade handler: report once, close, spawn nothing.
1638
+ // The client treats fatal toasts as stop-reconnecting (no 2s retry loop against a
1639
+ // deleted/ambiguous workspace).
1640
+ if (req.ccvScratchReject) {
1641
+ try { ws.send(JSON.stringify({ type: 'toast', message: req.ccvScratchReject, fatal: true })); } catch {}
1642
+ try { ws.close(); } catch {}
1643
+ return;
1644
+ }
1645
+ // cwd resolved once at upgrade time; every manager call for this connection uses it
1646
+ // so no call site can drift to a different (id-only/STARTUP_CWD) bucket.
1647
+ const opts = req.ccvScratchCwd ? { cwd: req.ccvScratchCwd } : undefined;
1564
1648
  // 懒启动 scratch shell(首次连接才 spawn)
1565
1649
  try {
1566
- if (!getScratchState(id).running) {
1567
- await spawnScratch(id);
1650
+ if (!getScratchState(id, opts).running) {
1651
+ await spawnScratch(id, opts);
1568
1652
  }
1569
1653
  } catch (err) {
1570
1654
  try { ws.send(JSON.stringify({ type: 'toast', message: `scratch spawn failed: ${err.message}` })); } catch {}
1571
1655
  }
1572
1656
 
1573
- const state = getScratchState(id);
1574
- try { ws.send(JSON.stringify({ type: 'state', running: state.running, exitCode: state.exitCode, shellBasename: getScratchShellBasename() })); } catch {}
1657
+ const state = getScratchState(id, opts);
1658
+ try { ws.send(JSON.stringify({ type: 'state', running: state.running, exitCode: state.exitCode, cwd: state.cwd, shellBasename: getScratchShellBasename() })); } catch {}
1575
1659
 
1576
- const buffer = getScratchOutputBuffer(id);
1660
+ const buffer = getScratchOutputBuffer(id, opts);
1577
1661
  if (buffer) {
1578
1662
  try { ws.send(JSON.stringify({ type: 'data', data: buffer })); } catch {}
1579
1663
  }
@@ -1590,7 +1674,7 @@ async function setupTerminalWebSocket(httpServer) {
1590
1674
  // 调用前须 floodGate.reset()(快照已含 pending 字节,残留会在快照后回灌重复)。
1591
1675
  const sendScratchResync = () => {
1592
1676
  if (ws.readyState !== 1) return;
1593
- try { ws.send(JSON.stringify({ type: 'data-resync', data: getScratchOutputBuffer(id) })); } catch {}
1677
+ try { ws.send(JSON.stringify({ type: 'data-resync', data: getScratchOutputBuffer(id, opts) })); } catch {}
1594
1678
  };
1595
1679
  const resyncReqGate = createResyncNudgeGate({ cooldownMs: RESYNC_REQ_COOLDOWN_MS });
1596
1680
  const bpGate = createBackpressureGate({
@@ -1635,25 +1719,25 @@ async function setupTerminalWebSocket(httpServer) {
1635
1719
 
1636
1720
  const removeDataListener = onScratchData(id, (data) => {
1637
1721
  floodGate.offer(data);
1638
- });
1722
+ }, opts);
1639
1723
 
1640
1724
  const removeExitListener = onScratchExit(id, (exitCode) => {
1641
1725
  if (ws.readyState === 1) {
1642
1726
  try { ws.send(JSON.stringify({ type: 'exit', exitCode })); } catch {}
1643
1727
  }
1644
- });
1728
+ }, opts);
1645
1729
 
1646
1730
  ws.on('message', async (raw) => {
1647
1731
  try {
1648
1732
  const msg = JSON.parse(raw.toString());
1649
1733
  if (msg.type === 'input') {
1650
- const s = getScratchState(id);
1734
+ const s = getScratchState(id, opts);
1651
1735
  if (!s.running) {
1652
- try { await spawnScratch(id); } catch {}
1736
+ try { await spawnScratch(id, opts); } catch {}
1653
1737
  }
1654
- writeScratch(id, msg.data);
1738
+ writeScratch(id, msg.data, opts);
1655
1739
  } else if (msg.type === 'resize') {
1656
- resizeScratch(id, msg.cols, msg.rows);
1740
+ resizeScratch(id, msg.cols, msg.rows, opts);
1657
1741
  } else if (msg.type === 'resync-request') {
1658
1742
  // 客户端 write-queue 积压丢弃后请求快照对齐(utils/terminalWriteQueue.js onTrim)
1659
1743
  if (resyncReqGate.shouldNudge()) {
@@ -1662,7 +1746,7 @@ async function setupTerminalWebSocket(httpServer) {
1662
1746
  }
1663
1747
  } else if (msg.type === 'kill') {
1664
1748
  // 用户主动关闭 tab:杀 pty(killScratch 内部 ptys.delete 后配额自动释放);前端会随后 close ws
1665
- killScratch(id);
1749
+ killScratch(id, opts);
1666
1750
  }
1667
1751
  } catch {}
1668
1752
  });
@@ -1672,8 +1756,10 @@ async function setupTerminalWebSocket(httpServer) {
1672
1756
  floodGate.dispose();
1673
1757
  removeDataListener();
1674
1758
  removeExitListener();
1675
- // pty 本身**不杀**(保留以支持刷新重连),由 kill 消息或 /api/workspaces/stop 触发;
1676
- // 配额由 ptys Map 自身大小决定,不需在此手动维护连接集合
1759
+ // The pty itself is deliberately NOT killed (kept for refresh-reconnect and for
1760
+ // per-workspace tab-group restore on switch-back); teardown happens via the kill
1761
+ // message or /api/workspaces/stop. Cap accounting is driven by the ptys Map size,
1762
+ // so no connection set is maintained here.
1677
1763
  });
1678
1764
  });
1679
1765
 
@@ -1719,6 +1805,19 @@ async function setupTerminalWebSocket(httpServer) {
1719
1805
  } catch {}
1720
1806
  }
1721
1807
 
1808
+ // SDK multi-session (2026-10-12): replay frames are per-session too. A reconnected
1809
+ // client must only restore the workspace it is VIEWING — otherwise a background
1810
+ // workspace's approval/spinner/queue would pop into the foreground. Filter each
1811
+ // snapshot by this connection's `_ccvViewProject` (bound view / no ?project= → all).
1812
+ const _viewProj = ws._ccvViewProject || null;
1813
+ const _base = (c) => (typeof c === 'string' && c) ? c.replace(/\\/g, '/').split('/').filter(Boolean).pop() : null;
1814
+ const _sdkFrameForView = (frame) => {
1815
+ if (!frame || typeof frame !== 'object') return true; // no cwd → view-agnostic, replay
1816
+ if (!_viewProj) return true; // bound view → all workspaces
1817
+ const fproj = (typeof frame.cwd === 'string' && frame.cwd) ? _base(frame.cwd) : null;
1818
+ return !fproj || fproj === _viewProj; // no-cwd frame → replay; else must match
1819
+ };
1820
+
1722
1821
  // Replay SDK 模式 pendings(sdk-manager canUseTool 的 ask/plan/perm):
1723
1822
  // 与上面 perm-hook 同理,但快照来自 sdk-manager(_pendingApprovals)。
1724
1823
  // ask 类带 startedAt/timeoutMs —— 与 ask-hook replay 同款剩余时间重算。
@@ -1726,6 +1825,7 @@ async function setupTerminalWebSocket(httpServer) {
1726
1825
  for (const p of _sdkGetPendingApprovals() || []) {
1727
1826
  if (!p || !p.replay || !p.id) continue;
1728
1827
  const payload = { ...p.replay };
1828
+ if (!_sdkFrameForView(payload)) continue; // 别的工作区的待审批不进本视图
1729
1829
  if (p.kind === 'ask') {
1730
1830
  payload.startedAt = now; // 让前端按"还剩 remaining"起算
1731
1831
  payload.timeoutMs = p.remainingMs; // 剩余可用时间
@@ -1738,17 +1838,46 @@ async function setupTerminalWebSocket(httpServer) {
1738
1838
  // surface and model info without waiting for the next session.
1739
1839
  if (isSdkMode && _sdkGetInitSnapshot) {
1740
1840
  const initSnap = _sdkGetInitSnapshot();
1741
- if (initSnap) {
1841
+ if (initSnap && _sdkFrameForView(initSnap)) {
1742
1842
  try { ws.send(JSON.stringify(initSnap)); } catch {}
1743
1843
  }
1744
1844
  }
1745
1845
 
1846
+ // Replay the last sdk-session-state frame so a page refreshed mid-turn restores
1847
+ // the spinner immediately (sdk-init snapshot parity; null → no frame, client
1848
+ // stays at its idle default).
1849
+ if (isSdkMode && _sdkGetSessionStateSnapshot) {
1850
+ const stateSnap = _sdkGetSessionStateSnapshot();
1851
+ if (stateSnap && _sdkFrameForView(stateSnap)) {
1852
+ try { ws.send(JSON.stringify(stateSnap)); } catch {}
1853
+ }
1854
+ }
1855
+
1856
+ // Replay the last sdk-context-usage snapshot so a reconnected panel restores the
1857
+ // latest numbers (null snapshot → no frame, the panel falls back to its HTTP pull).
1858
+ if (isSdkMode && _sdkGetContextUsageSnapshot) {
1859
+ const cuSnap = _sdkGetContextUsageSnapshot();
1860
+ if (cuSnap && _sdkFrameForView(cuSnap)) {
1861
+ try { ws.send(JSON.stringify(cuSnap)); } catch {}
1862
+ }
1863
+ }
1864
+
1865
+ // Replay the last sdk-mcp-status snapshot (sdk-context-usage parity). A null snapshot
1866
+ // sends no frame; the panel then falls back to its HTTP pull.
1867
+ if (isSdkMode && _sdkGetMcpStatusSnapshot) {
1868
+ const mcpSnap = _sdkGetMcpStatusSnapshot();
1869
+ if (mcpSnap && _sdkFrameForView(mcpSnap)) {
1870
+ try { ws.send(JSON.stringify(mcpSnap)); } catch {}
1871
+ }
1872
+ }
1873
+
1746
1874
  // Replay the chat-composer busy queue so a refreshed/reconnected page restores the
1747
1875
  // floating queued-message bubbles (server is the source of truth in both modes).
1748
1876
  {
1749
1877
  const qItems = (isSdkMode && _sdkGetQueueSnapshot) ? _sdkGetQueueSnapshot() : chatQueue.getSnapshot();
1750
1878
  if (Array.isArray(qItems) && qItems.length) {
1751
- try { ws.send(JSON.stringify({ type: 'queue-state', items: qItems })); } catch {}
1879
+ const qFrame = { type: 'queue-state', items: qItems };
1880
+ if (_sdkFrameForView(qFrame)) { try { ws.send(JSON.stringify(qFrame)); } catch {} }
1752
1881
  }
1753
1882
  }
1754
1883
 
@@ -1877,11 +2006,17 @@ async function setupTerminalWebSocket(httpServer) {
1877
2006
  ws.on('message', async (raw) => {
1878
2007
  try {
1879
2008
  const msg = JSON.parse(raw.toString());
1880
- // SDK 模式没有 PTY:PTY 专属消息(input/sequential input/resync/resize/attach)一律 no-op,
2009
+ // SDK 模式没有 PTY:PTY 专属消息(input/sequential input/resync/resize)一律 no-op,
1881
2010
  // 否则 'input' 会在无会话时触发 spawnShell() 拉出一个用户看不见的裸 shell。
1882
2011
  // sdk-*/ask-*/perm-*/image-* 等控制消息不受影响。scratch WS 是独立 handler,不受此闸门影响。
1883
- if (isSdkMode && (msg.type === 'input' || msg.type === 'input-sequential'
1884
- || msg.type === 'resync-request' || msg.type === 'resize' || msg.type === 'attach')) {
2012
+ // attach 例外:SDK 模式虽无 PTY 可挂,但 attach 携带的是视图身份变更(chip 切换),
2013
+ // 必须刷新该连接的 cwd-路由 stamp,否则广播过滤(_ccvViewProject)停留在旧视图,
2014
+ // 新视图的 sdk-session-state/审批/队列帧会被静默丢弃。
2015
+ if (msg.type === 'attach') {
2016
+ try { ws._ccvViewProject = (typeof msg.project === 'string' && msg.project) || null; } catch {}
2017
+ if (isSdkMode) return;
2018
+ } else if (isSdkMode && (msg.type === 'input' || msg.type === 'input-sequential'
2019
+ || msg.type === 'resync-request' || msg.type === 'resize')) {
1885
2020
  return;
1886
2021
  }
1887
2022
  if (msg.type === 'input') {
@@ -2229,9 +2364,11 @@ async function setupTerminalWebSocket(httpServer) {
2229
2364
  });
2230
2365
  }
2231
2366
  } else if (msg.type === 'sdk-user-message') {
2232
- // User message in SDK mode — relay to sdk-manager
2367
+ // User message in SDK mode — relay to sdk-manager, addressed to the viewed
2368
+ // workspace's session (multi-session). msg.project is the workspace basename.
2233
2369
  if (_sdkSendUserMessage && msg.text) {
2234
- _sdkSendUserMessage(msg.text).catch(err => {
2370
+ const anchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
2371
+ _sdkSendUserMessage(msg.text, anchor).catch(err => {
2235
2372
  console.error('[SDK] sendUserMessage error:', err.message);
2236
2373
  });
2237
2374
  }
@@ -2241,7 +2378,8 @@ async function setupTerminalWebSocket(httpServer) {
2241
2378
  // fetch hook → streamingState → SSE), same as PTY mode; sdk-manager
2242
2379
  // no longer emits its own streaming_status.
2243
2380
  if (isSdkMode && _sdkInterruptTurn) {
2244
- const cancelled = _sdkInterruptTurn() || [];
2381
+ const anchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
2382
+ const cancelled = _sdkInterruptTurn(anchor) || [];
2245
2383
  _broadcastApprovalCloses(cancelled, 'Turn interrupted');
2246
2384
  } else {
2247
2385
  // sdk-interrupt 收到却没生效:客户端按 props.sdkMode 发了中断,但本端不是 SDK 模式或
@@ -2257,7 +2395,8 @@ async function setupTerminalWebSocket(httpServer) {
2257
2395
  if (typeof msg.text === 'string' && msg.text) {
2258
2396
  if (isSdkMode) {
2259
2397
  if (_sdkSendUserMessage) {
2260
- _sdkSendUserMessage(msg.text).catch(err => {
2398
+ const anchor = (typeof msg.project === 'string' && msg.project) ? { project: msg.project } : undefined;
2399
+ _sdkSendUserMessage(msg.text, anchor).catch(err => {
2261
2400
  console.error('[SDK] queue-message relay error:', err.message);
2262
2401
  });
2263
2402
  }
@@ -2273,7 +2412,10 @@ async function setupTerminalWebSocket(httpServer) {
2273
2412
  if (qid) {
2274
2413
  if (isSdkMode) {
2275
2414
  if (_sdkSendQueuedNow) {
2276
- try { _broadcastApprovalCloses(_sdkSendQueuedNow(qid) || [], 'Queued message sent now'); }
2415
+ // Multi-session: address the bubble's own workspace (the viewed project), not
2416
+ // 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;
2418
+ try { _broadcastApprovalCloses(_sdkSendQueuedNow(qid, qAnchor) || [], 'Queued message sent now'); }
2277
2419
  catch (err) { reportSwallowed('server.queue-send-now', err); }
2278
2420
  }
2279
2421
  } else {
@@ -2284,7 +2426,7 @@ async function setupTerminalWebSocket(httpServer) {
2284
2426
  const qid = (typeof msg.id === 'string' && /^[a-zA-Z0-9_-]{1,128}$/.test(msg.id)) ? msg.id : null;
2285
2427
  if (qid) {
2286
2428
  if (isSdkMode) {
2287
- if (_sdkRemoveQueued) { try { _sdkRemoveQueued(qid); } catch (err) { reportSwallowed('server.queue-remove', err); } }
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); } }
2288
2430
  } else {
2289
2431
  try { chatQueue.remove(qid); } catch (err) { reportSwallowed('server.queue-remove', err); }
2290
2432
  }
@@ -2293,7 +2435,7 @@ async function setupTerminalWebSocket(httpServer) {
2293
2435
  // Session/workspace switch: drop the parked queue WITHOUT interrupting a running
2294
2436
  // turn. (Stop semantics live elsewhere: PTY queue-suppress, SDK sdk-interrupt.)
2295
2437
  if (isSdkMode) {
2296
- if (_sdkClearQueued) { try { _sdkClearQueued(); } catch (err) { reportSwallowed('server.queue-clear', err); } }
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); } }
2297
2439
  } else {
2298
2440
  try { chatQueue.clear(); } catch (err) { reportSwallowed('server.queue-clear', err); }
2299
2441
  }
@@ -2302,6 +2444,10 @@ async function setupTerminalWebSocket(httpServer) {
2302
2444
  // (SDK mode handles this inside sdk-manager.interruptTurn; no PTY queue exists.)
2303
2445
  if (!isSdkMode) {
2304
2446
  try { chatQueue.suppressNextDrain(); } catch (err) { reportSwallowed('server.queue-suppress', err); }
2447
+ // The Stop hook does NOT fire on user interrupt (documented Claude Code
2448
+ // behavior), so the web Stop button must clear the turn-level loading
2449
+ // flag itself or the spinner would outlive the interrupted turn.
2450
+ _setTurnActive(false, null);
2305
2451
  }
2306
2452
  } else if (msg.type === 'image-remove-notify' || msg.type === 'image-upload-notify') {
2307
2453
  // Security: only allow paths within upload directories, reject traversal
@@ -2418,6 +2564,9 @@ export function getAuthConfig() {
2418
2564
  // 的 hook(如 Stop 输入带 `is_final_turn:true`、SDK `result` 带 `subtype:'final'`、
2419
2565
  // 或 `onUserTurnEnd` 回调)就拆掉。拆除时一并 grep 上面 SUNSET-MARKER 标签
2420
2566
  // 定位所有相关代码(server / voicePackPlayer / test / history.md)。
2567
+ // 相邻信号说明(2026-10-12):本防抖 `turn_end` 只服务语音包/IM/队列;对话 spinner 的
2568
+ // 回合级信号是 `turn_active`(PTY,见下方 Turn-level active state 段)与 SDK 的
2569
+ // `sdk-session-state`(WS)。三者语义不同,勿混用。
2421
2570
  //
2422
2571
  // **用户原始语义**:「等 10 秒钟。如果这 10 秒钟内没有新的请求发起,那么就认为
2423
2572
  // 任务真的已经完成了,开始执行播放」—— 隐含:10s 内**有**新请求 → 不算完成 →
@@ -2553,6 +2702,90 @@ function _onStreamingActivated() {
2553
2702
  try { chatQueue.onStreamingActivated(); } catch { /* best-effort */ }
2554
2703
  }
2555
2704
 
2705
+ // ── Turn-level active state ────────────────────────────────────────────────
2706
+ // `streamingState.active` flips per API call, so the spinner dies during local
2707
+ // tool execution between calls (see turn-end-bridge.js header). The turn-level
2708
+ // truth lives in interceptor.js (`turnActive` + setTurnActive, shared by PTY
2709
+ // hook paths here and the SDK push path in cli.js) so it survives server.js
2710
+ // stop/start cycles the same way streamingState does.
2711
+ // PTY: set by the UserPromptSubmit hook (task-bridge → /api/task-event),
2712
+ // cleared by the raw Stop-hook POST arrival (pre-debounce, pre-SSE) or an
2713
+ // explicit user interrupt. No streaming rising-edge fallback by design:
2714
+ // housekeeping/telemetry requests after query() end would re-light it for the
2715
+ // 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).
2721
+ //
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
2728
+ // backstop still catches a process-death/stuck-server within one window.
2729
+
2730
+ // Pure predicate (unit-tested): does this /api/task-event payload mark a new
2731
+ // main-agent user prompt? Mirrors task-state.js's shouldResetTasksOnPrompt gate:
2732
+ // agentId excludes teammate/subagent when present, but agentId is NOT reliably
2733
+ // present on UserPromptSubmit (task-bridge header), so the sessionId check is the
2734
+ // load-bearing one — a prompt from a DIFFERENT session than the one we track must
2735
+ // not light the main view. currentSessionId follows the same convention as
2736
+ // task-state: null window (nothing tracked yet) passes, matching the checklist.
2737
+ export function isTurnStartTaskEvent(payload, currentSessionId) {
2738
+ if (!payload || payload.hookEventName !== 'UserPromptSubmit') return false;
2739
+ if (payload.agentId) return false;
2740
+ if (!payload.sessionId) return false;
2741
+ if (currentSessionId && payload.sessionId !== currentSessionId) return false;
2742
+ return true;
2743
+ }
2744
+
2745
+ const TURN_ACTIVE_HEARTBEAT_MS = 10 * 1000;
2746
+ 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) {
2754
+ if (_turnActiveHeartbeat) return; // already armed
2755
+ _turnActiveHeartbeat = setInterval(() => {
2756
+ if (_isStopping || !turnActive.active) { _clearTurnActiveHeartbeat(); return; }
2757
+ _broadcastTurnActive(true, sessionId || null);
2758
+ }, TURN_ACTIVE_HEARTBEAT_MS);
2759
+ if (typeof _turnActiveHeartbeat.unref === 'function') _turnActiveHeartbeat.unref();
2760
+ }
2761
+
2762
+ function _clearTurnActiveHeartbeat() {
2763
+ if (_turnActiveHeartbeat) { clearInterval(_turnActiveHeartbeat); _turnActiveHeartbeat = null; }
2764
+ }
2765
+
2766
+ function _broadcastTurnActive(active, sessionId) {
2767
+ if (_isStopping) return;
2768
+ if (!sendEventToClients || clients.length === 0) return;
2769
+ try {
2770
+ sendEventToClients(
2771
+ _filterClientsByViewProject(clients, _projectName || ''),
2772
+ 'turn_active',
2773
+ { active, sessionId: sessionId || null, ts: Date.now() },
2774
+ );
2775
+ } catch (err) { reportSwallowed('turn-active.broadcast', err); }
2776
+ }
2777
+
2778
+ // State-change guard: only broadcast on real transitions (SDK paths share the
2779
+ // 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) {
2782
+ const next = !!active;
2783
+ if (next === turnActive.active) return;
2784
+ setTurnActive(next);
2785
+ if (next) _armTurnActiveHeartbeat(sessionId); else _clearTurnActiveHeartbeat();
2786
+ _broadcastTurnActive(next, sessionId);
2787
+ }
2788
+
2556
2789
  // 统一的 streaming-state 观察入口。production polling 和 SDK push 都调它,测试也走它,
2557
2790
  // 不再为「测试桩」单独维护 mirror state(杜绝逻辑漂移)。返回是否检测到 rising edge。
2558
2791
  // `_isStopping` 时直接返回 false:shutdown 中迟到的 tick 不应再去 flush。
@@ -2582,6 +2815,9 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
2582
2815
  _lastCliActive = false;
2583
2816
  _stoppingPromise = null;
2584
2817
  _onTurnEndBroadcastForTests = null;
2818
+ setTurnActive(false);
2819
+ _clearTurnActiveHeartbeat();
2820
+ _turnSessionId = null;
2585
2821
  },
2586
2822
  onBroadcast(fn) { _onTurnEndBroadcastForTests = fn; },
2587
2823
  getPendingKeys() { return [..._pendingTurnEndTimers.keys()]; },
@@ -2589,6 +2825,21 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
2589
2825
  observeStreamingTick(activeNow, mode = 'cli') { return _observeStreamingTick(activeNow, mode); },
2590
2826
  scheduleTurnEnd(sessionId, ts) { _scheduleTurnEndBroadcast(sessionId, ts); },
2591
2827
  getDebounceMs() { return TURN_END_DEBOUNCE_MS; },
2828
+ getPtyTurnActive() { return turnActive.active; },
2829
+ setPtyTurnActive(v, sessionId = null) { _setTurnActive(v, sessionId); },
2830
+ // Routes the payload through the SAME predicate the deps.onTaskEvent closure
2831
+ // uses, so tests exercise the production gate rather than a mirror of it.
2832
+ notifyTaskEvent(payload) {
2833
+ if (isTurnStartTaskEvent(payload, _turnSessionId)) {
2834
+ _turnSessionId = payload?.sessionId || null;
2835
+ _setTurnActive(true, payload?.sessionId);
2836
+ }
2837
+ },
2838
+ // Clear drops the flag but KEEPS the session tag: the next prompt of the SAME
2839
+ // session re-lights fast (tag match), while a foreign session's prompt stays
2840
+ // rejected. The tag resets only on test reset / server stop.
2841
+ clearPtyTurnActive() { _setTurnActive(false, null); },
2842
+ getTurnSessionId() { return _turnSessionId; },
2592
2843
  } : Object.freeze({
2593
2844
  reset() {},
2594
2845
  onBroadcast() {},
@@ -2597,6 +2848,10 @@ export const __testing = (process.env.NODE_ENV === 'test') ? {
2597
2848
  observeStreamingTick() { return false; },
2598
2849
  scheduleTurnEnd() {},
2599
2850
  getDebounceMs() { return TURN_END_DEBOUNCE_MS; },
2851
+ getPtyTurnActive() { return false; },
2852
+ setPtyTurnActive() {},
2853
+ notifyTaskEvent() {},
2854
+ clearPtyTurnActive() {},
2600
2855
  });
2601
2856
 
2602
2857
  /**
@@ -2665,6 +2920,7 @@ async function _doStop() {
2665
2920
  // 对称 startViewer:下一次启动后第一次 active 才算 rising edge
2666
2921
  _lastSdkActive = false;
2667
2922
  _lastCliActive = false;
2923
+ _clearTurnActiveHeartbeat();
2668
2924
  // Tear down all IM bridge connections so a stop/start cycle (Electron tab switch, tests) never
2669
2925
  // leaks a second WS to the same app. Idempotent + swallows errors.
2670
2926
  // IM teardown + serverStopping hook 共用一个 3s 总预算(保持串行语义):
@@ -2729,6 +2985,11 @@ async function _doStop() {
2729
2985
  }
2730
2986
  resetTasks();
2731
2987
  resetStreamingState();
2988
+ // Turn-level flag dies with the server, cleared AFTER connections close (same slot
2989
+ // as resetStreamingState): a task-event arriving during the 3s IM-teardown window
2990
+ // must not re-light the flag for the fresh process's first SSE replay.
2991
+ setTurnActive(false);
2992
+ _clearTurnActiveHeartbeat();
2732
2993
  // 清 interceptor 的 live-port,避免 stop/start 循环(Electron tab 切换 / 测试)间隙内
2733
2994
  // 早期请求向已关闭的端口 POST 丢包。新 startViewer 的 listen 回调会再次 setLivePort
2734
2995
  setLivePort(null);
@@ -2744,9 +3005,21 @@ async function _doStop() {
2744
3005
  /** Broadcast a message to all terminal WS clients (for SDK canUseTool). */
2745
3006
  export function broadcastWsMessage(msg) {
2746
3007
  if (terminalWss) {
3008
+ // SDK multi-session (2026-10-12): a cwd-tagged frame belongs to one workspace's session.
3009
+ // Route it only to clients viewing that workspace (or the bound view with no ?project=),
3010
+ // so a background workspace's approval/queue/spinner never bleeds into the foreground.
3011
+ // Untagged frames keep the historical broadcast-to-all behavior.
3012
+ const frameCwd = (msg && typeof msg === 'object' && typeof msg.cwd === 'string' && msg.cwd) ? msg.cwd : null;
3013
+ // The client stamp is the SANITIZED project key (projectKeyForCwd), so the raw
3014
+ // basename would misroute any cwd with chars outside [a-zA-Z0-9._-] (spaces, CJK)
3015
+ // — every frame for such a workspace would be silently dropped for its viewer.
3016
+ let frameProj = null;
3017
+ if (frameCwd) frameProj = projectKeyForCwd(frameCwd) || null;
2747
3018
  const str = typeof msg === 'string' ? msg : JSON.stringify(msg);
2748
3019
  terminalWss.clients.forEach((c) => {
2749
- if (c.readyState === 1) try { c.send(str); } catch {}
3020
+ if (c.readyState !== 1) return;
3021
+ if (frameProj && c._ccvViewProject && c._ccvViewProject !== frameProj) return; // other workspace's frame
3022
+ try { c.send(str); } catch {}
2750
3023
  });
2751
3024
  }
2752
3025
  // 仅对 ask 类型转译给主进程;perm-hook-* / sdk-plan-* 维持 inline-only(红线)。
@@ -2802,6 +3075,18 @@ export function setSdkGetPendingApprovals(fn) { _sdkGetPendingApprovals = fn; }
2802
3075
  let _sdkGetInitSnapshot = null;
2803
3076
  export function setSdkGetInitSnapshot(fn) { _sdkGetInitSnapshot = fn; }
2804
3077
 
3078
+ /** Reference to sdk-manager's getContextUsageSnapshot (set by cli.js after import). */
3079
+ let _sdkGetContextUsageSnapshot = null;
3080
+ export function setSdkGetContextUsageSnapshot(fn) { _sdkGetContextUsageSnapshot = fn; }
3081
+
3082
+ /** Reference to sdk-manager's getMcpStatusSnapshot (set by cli.js after import). */
3083
+ let _sdkGetMcpStatusSnapshot = null;
3084
+ export function setSdkGetMcpStatusSnapshot(fn) { _sdkGetMcpStatusSnapshot = fn; }
3085
+
3086
+ /** Reference to sdk-manager's getSessionStateSnapshot (set by cli.js after import). */
3087
+ let _sdkGetSessionStateSnapshot = null;
3088
+ export function setSdkGetSessionStateSnapshot(fn) { _sdkGetSessionStateSnapshot = fn; }
3089
+
2805
3090
  /** Reference to sdk-manager's sendQueuedNow (set by cli.js after import). */
2806
3091
  let _sdkSendQueuedNow = null;
2807
3092
  export function setSdkSendQueuedNow(fn) { _sdkSendQueuedNow = fn; }