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/README.md +5 -5
- package/cli.js +24 -0
- package/dist/assets/App-DtBVNYE7.js +2 -0
- package/dist/assets/App-DuDeDOuX.css +1 -0
- package/dist/assets/{MdxEditorPanel-Ck8B5a5v.js → MdxEditorPanel-C8Cij93y.js} +1 -1
- package/dist/assets/{Mobile-B8AkKb0z.js → Mobile-OKCVi8un.js} +1 -1
- package/dist/assets/{ProxyStatsModal-DsDVMf-c.js → ProxyStatsModal-DGlngtM9.js} +1 -1
- package/dist/assets/index-Clt4dw8Y.js +2 -0
- package/dist/assets/{seqResourceLoaders-BsMFNfvR.css → seqResourceLoaders-Bg9Lz8pH.css} +1 -1
- package/dist/assets/seqResourceLoaders-zlIVjapi.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/interceptor.js +11 -0
- package/server/lib/sdk-manager.js +1521 -895
- package/server/routes/events.js +20 -0
- package/server/routes/resume.js +62 -1
- package/server/routes/sdk-context.js +24 -0
- package/server/routes/sdk-mcp-status.js +24 -0
- package/server/routes/workspaces.js +24 -1
- package/server/scratch-pty-manager.js +127 -46
- package/server/server.js +326 -41
- package/dist/assets/App-BzXO6ATr.js +0 -2
- package/dist/assets/App-DJ4yNvwz.css +0 -1
- package/dist/assets/index-Dq5UPr6v.js +0 -2
- package/dist/assets/seqResourceLoaders-BsQ6KXpV.js +0 -2
package/server/server.js
CHANGED
|
@@ -2,7 +2,7 @@ import { createServer } from 'node:http';
|
|
|
2
2
|
import { createServer as createHttpsServer } from 'node:https';
|
|
3
3
|
import { createConnection } from 'node:net';
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
5
|
-
import { readFileSync, existsSync, unwatchFile, statSync, renameSync, unlinkSync } from 'node:fs';
|
|
5
|
+
import { readFileSync, existsSync, unwatchFile, statSync, renameSync, unlinkSync, realpathSync } from 'node:fs';
|
|
6
6
|
import { join, extname, resolve, sep } from 'node:path';
|
|
7
7
|
import { platform, networkInterfaces, tmpdir } from 'node:os';
|
|
8
8
|
import { execFile, exec, spawn } from 'node:child_process';
|
|
@@ -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
|
-
|
|
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
|
-
//
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
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
|
|
1676
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1884
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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; }
|