cc-viewer 1.6.296 → 1.6.297

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/dist/index.html CHANGED
@@ -21,7 +21,7 @@
21
21
  // 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
22
22
  // electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
23
23
  </script>
24
- <script type="module" crossorigin src="/assets/index-DtpelJc4.js"></script>
24
+ <script type="module" crossorigin src="/assets/index-Be7o3L97.js"></script>
25
25
  <link rel="modulepreload" crossorigin href="/assets/vendor-antd-Bur5ZxWE.js">
26
26
  <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-Si44UqBp.js">
27
27
  <link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-Cco3AQJS.js">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.296",
3
+ "version": "1.6.297",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -40,6 +40,17 @@ if (!port) {
40
40
  process.exit(0);
41
41
  }
42
42
 
43
+ // Opt-out: skip cc-viewer's web AskUserQuestion interception and let the prompt fall
44
+ // through to the terminal TUI / any downstream PreToolUse|PermissionRequest hooks
45
+ // (e.g. a desktop notifier that renders the options elsewhere). Runtime env gate,
46
+ // mirroring CCV_BYPASS_PERMISSIONS in perm-bridge.js — the hook stays registered, so
47
+ // this toggles per-launch with no settings.json churn. Emitting continue:true (rather
48
+ // than exit 1) avoids Claude Code logging a "hook error" on every AskUserQuestion call.
49
+ if (process.env.CCV_DISABLE_ASK_HOOK === '1') {
50
+ process.stdout.write(JSON.stringify({ continue: true, suppressOutput: true }) + '\n');
51
+ process.exit(0);
52
+ }
53
+
43
54
  let stdinData;
44
55
  try {
45
56
  stdinData = readFileSync(0, 'utf-8');
@@ -2,7 +2,7 @@ import { appendFileSync, existsSync, readdirSync, readFileSync, statSync, unlink
2
2
  import { renameSyncWithRetry } from './file-api.js';
3
3
  import { join } from 'node:path';
4
4
 
5
- const SUBAGENT_SYSTEM_RE = /(?:command execution|file search|planning) specialist|general-purpose agent/i;
5
+ const SUBAGENT_SYSTEM_RE = /(?:command execution|file search|planning) specialist|general-purpose agent|security monitor|performing a web search/i;
6
6
 
7
7
  export function getSystemText(body) {
8
8
  const system = body?.system;
@@ -36,9 +36,9 @@ export function isMainAgentRequest(body) {
36
36
  // v2.1.81+: 轻量 MainAgent 初始请求工具数可能 < 10,降低阈值兼容
37
37
  if (body.tools.length > 5) {
38
38
  const hasEdit = body.tools.some(t => t.name === 'Edit');
39
- const hasBash = body.tools.some(t => t.name === 'Bash');
39
+ const hasShell = body.tools.some(t => t.name === 'Bash' || t.name === 'PowerShell');
40
40
  const hasTaskOrAgent = body.tools.some(t => t.name === 'Task' || t.name === 'Agent');
41
- if (hasEdit && hasBash && hasTaskOrAgent) {
41
+ if (hasEdit && hasShell && hasTaskOrAgent) {
42
42
  return true;
43
43
  }
44
44
  }
@@ -6,7 +6,7 @@
6
6
  import { formatToolAsXml } from './tools-xml-formatter.js';
7
7
  export { formatToolAsXml };
8
8
 
9
- const SUBAGENT_SYSTEM_RE = /command execution specialist|file search specialist|planning specialist|general-purpose agent/i;
9
+ const SUBAGENT_SYSTEM_RE = /command execution specialist|file search specialist|planning specialist|general-purpose agent|security monitor|performing a web search/i;
10
10
  const TEAMMATE_SYSTEM_RE = /running as an agent in a team|Agent Teammate Communication/i;
11
11
 
12
12
  function getSystemText(body) {
@@ -56,9 +56,9 @@ export function isMainAgentEntry(entry) {
56
56
  // v2.1.81+: lightweight MainAgent may have < 10 tools, lowered threshold
57
57
  if (body.tools.length > 5) {
58
58
  const hasEdit = body.tools.some(t => t.name === 'Edit');
59
- const hasBash = body.tools.some(t => t.name === 'Bash');
59
+ const hasShell = body.tools.some(t => t.name === 'Bash' || t.name === 'PowerShell');
60
60
  const hasTaskOrAgent = body.tools.some(t => t.name === 'Task' || t.name === 'Agent');
61
- if (hasEdit && hasBash && hasTaskOrAgent) return true;
61
+ if (hasEdit && hasShell && hasTaskOrAgent) return true;
62
62
  }
63
63
 
64
64
  return false;
@@ -55,7 +55,10 @@ export function readLogFile(logFile) {
55
55
  }
56
56
  }
57
57
 
58
- const SSE_BACKPRESSURE_TIMEOUT_MS = 5000;
58
+ // SSE 单客户端 backpressure 容忍上限:连续未排空 > 此时长则视为 dead 客户端剔除。
59
+ // 与 server.js 同名常量值保持一致(避免循环依赖,此处单独 mirror)。
60
+ // 30s:避免大会话/重连重放时把短暂忙碌的渲染器误判为 dead 并触发重连风暴,详见 server.js 注释。
61
+ const SSE_BACKPRESSURE_TIMEOUT_MS = 30000;
59
62
 
60
63
  function _removeClient(clients, client) {
61
64
  const idx = clients.indexOf(client);
@@ -0,0 +1,106 @@
1
+ /**
2
+ * WebSocket per-client 反压闸门(Windows ConPTY 洪泛防卡死,server 侧第一道闸)。
3
+ *
4
+ * 背景:node-pty 在 Windows 经 ConPTY 把 TUI 输出转译成全屏重绘序列,字节量可达
5
+ * macOS forkpty 透传的 10~100 倍。onPtyData → ws.send 若不检查 ws.bufferedAmount,
6
+ * 慢客户端的写缓冲会无限堆积(server 内存膨胀),且数据到前端也只是堆进写队列
7
+ * 被丢弃。这里按 VS Code terminal / xterm.js flow-control 指南的思路做停发-追赶:
8
+ *
9
+ * - offer():每条 data 调用。bufferedAmount 越过 highWater → 进入 behind 态,
10
+ * 返回 false(调用方跳发该条);期间 pollMs 轮询 bufferedAmount。
11
+ * - 降回 lowWater 以下 → 退出 behind,调 onResume()(调用方发 data-resync +
12
+ * outputBuffer 快照让前端一步对齐,无需补发洪泛期间的全部数据)。
13
+ * - behind 持续超 timeoutMs(客户端网络硬断时 TCP close 可能数分钟后才到,
14
+ * bufferedAmount 永不下降)→ 调 onTimeout()(调用方 ws.terminate(),前端
15
+ * 自动重连走全量 replay 恢复),并自行 dispose。
16
+ * - 高/低水位形成迟滞带,防止在阈值附近反复开关。
17
+ *
18
+ * 纯逻辑、零 ws 依赖(bufferedAmount 由 getter 注入),便于单测。
19
+ */
20
+
21
+ const DEFAULT_HIGH_WATER = 1024 * 1024; // 写缓冲超 1MB:客户端已明显消费不动
22
+ const DEFAULT_LOW_WATER = 256 * 1024; // 降回 256KB 以下才恢复(迟滞带)
23
+ const DEFAULT_POLL_MS = 100; // behind 期间轮询间隔,对终端延迟无感
24
+ const DEFAULT_TIMEOUT_MS = 60000; // behind 超时:判定客户端死连接
25
+
26
+ /**
27
+ * @param {object} opts
28
+ * @param {() => number} opts.getBufferedAmount - 返回当前 ws.bufferedAmount
29
+ * @param {(buffered: number) => void} opts.onResume - 退出 behind 时回调(发 data-resync)
30
+ * @param {(buffered: number) => void} [opts.onBehind] - 进入 behind 时回调(observability 埋点)
31
+ * @param {(buffered: number) => void} [opts.onTimeout] - behind 超时回调(建议 ws.terminate())
32
+ * @param {number} [opts.highWater]
33
+ * @param {number} [opts.lowWater]
34
+ * @param {number} [opts.pollMs]
35
+ * @param {number} [opts.timeoutMs]
36
+ * @returns {{ offer: () => boolean, isBehind: () => boolean, dispose: () => void }}
37
+ */
38
+ export function createBackpressureGate({
39
+ getBufferedAmount,
40
+ onResume,
41
+ onBehind,
42
+ onTimeout,
43
+ highWater = DEFAULT_HIGH_WATER,
44
+ lowWater = DEFAULT_LOW_WATER,
45
+ pollMs = DEFAULT_POLL_MS,
46
+ timeoutMs = DEFAULT_TIMEOUT_MS,
47
+ }) {
48
+ let behind = false;
49
+ let pollTimer = null;
50
+ let behindSince = 0;
51
+ let disposed = false;
52
+
53
+ const stopPolling = () => {
54
+ if (pollTimer) {
55
+ clearInterval(pollTimer);
56
+ pollTimer = null;
57
+ }
58
+ };
59
+
60
+ const poll = () => {
61
+ if (disposed) return;
62
+ let buffered = 0;
63
+ // poll 在 behind 中:读取失败按"未排空"保守处理(继续 hold,最终走超时断连);
64
+ // offer 在正常态:读取失败按 0 乐观放行(不因瞬时读取失败误杀活跃连接)。方向刻意相反。
65
+ try { buffered = getBufferedAmount(); } catch { buffered = Infinity; }
66
+ if (buffered <= lowWater) {
67
+ behind = false;
68
+ stopPolling();
69
+ try { onResume(buffered); } catch {}
70
+ return;
71
+ }
72
+ if (Date.now() - behindSince >= timeoutMs) {
73
+ stopPolling();
74
+ disposed = true;
75
+ try { onTimeout?.(buffered); } catch {}
76
+ }
77
+ };
78
+
79
+ return {
80
+ /** 每条 data 调用:true = 可发;false = 跳发(behind 中) */
81
+ offer() {
82
+ if (disposed) return false;
83
+ if (behind) return false;
84
+ let buffered = 0;
85
+ try { buffered = getBufferedAmount(); } catch { buffered = 0; }
86
+ if (buffered > highWater) {
87
+ behind = true;
88
+ behindSince = Date.now();
89
+ pollTimer = setInterval(poll, pollMs);
90
+ // 进程退出不被轮询拖住(与 server.js 其余 interval 的 unref 约定一致)
91
+ pollTimer.unref?.();
92
+ try { onBehind?.(buffered); } catch {}
93
+ return false;
94
+ }
95
+ return true;
96
+ },
97
+ isBehind() {
98
+ return behind;
99
+ },
100
+ /** ws close 时调用,清掉轮询 interval */
101
+ dispose() {
102
+ disposed = true;
103
+ stopPolling();
104
+ },
105
+ };
106
+ }
package/server/server.js CHANGED
@@ -77,6 +77,7 @@ import { loadPlugins, runWaterfallHook, runParallelHook } from './lib/plugin-loa
77
77
  import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher.js';
78
78
  import { watchLogFile, startWatching, unwatchAll, sendEventToClients, sendToClients } from './lib/log-watcher.js';
79
79
  import { cleanupExtractCache } from './lib/jsonl-archive.js';
80
+ import { createBackpressureGate } from './lib/ws-backpressure.js';
80
81
 
81
82
 
82
83
  // 动态获取 getPrefsFile()(LOG_DIR 可能在运行时被 setLogDir 修改)
@@ -292,7 +293,10 @@ const MAX_POST_BODY = 10 * 1024 * 1024;
292
293
  // 用户显式 ?limit=0 可恢复全量加载(power-user 逃生口)。
293
294
  const DEFAULT_EVENTS_LIMIT = 1000;
294
295
  // SSE 单客户端 backpressure 容忍上限:连续未排空 > 此时长则视为 dead 客户端剔除。
295
- const SSE_BACKPRESSURE_TIMEOUT_MS = 5000;
296
+ // 调高至 30s:大会话首屏/重连重放时,渲染器(尤其 Windows 浏览器,大 DOM layout 更重)
297
+ // 可能短暂忙到来不及排空 socket。过早剔除会触发「断开→EventSource 自动重连→再次重放」
298
+ // 风暴,把瞬时卡顿放大成持续卡死。30s 仍能剔除真正死掉的连接。
299
+ const SSE_BACKPRESSURE_TIMEOUT_MS = 30000;
296
300
 
297
301
 
298
302
 
@@ -559,7 +563,15 @@ const dispatch = createDispatcher(_routes);
559
563
 
560
564
  async function handleRequest(req, res) {
561
565
  const parsedUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
562
- const url = parsedUrl.pathname;
566
+ let url = parsedUrl.pathname;
567
+
568
+ // CCV_BASE_PATH reverse proxy: strip prefix at TOP so API/WS/static/SPA
569
+ // all work with original unprefixed paths. basePath normalized.
570
+ const bpRaw = process.env.CCV_BASE_PATH || '';
571
+ const bp = bpRaw && bpRaw !== '/' ? bpRaw.replace(/\/?$/, '/') : '';
572
+ if (bp && url.startsWith(bp)) {
573
+ url = url.slice(bp.length) || '/';
574
+ }
563
575
  const method = req.method;
564
576
 
565
577
  // WebSocket 路径不处理,交给 upgrade 事件
@@ -664,7 +676,15 @@ async function handleRequest(req, res) {
664
676
 
665
677
  // 静态文件服务
666
678
  if (method === 'GET') {
667
- let filePath = url === '/' ? '/index.html' : url;
679
+ const rawBase = process.env.CCV_BASE_PATH || '';
680
+ // Normalize to ensure trailing slash; prevents /proxy/ws from
681
+ // incorrectly matching /proxy/ws-other due to startsWith ambiguity.
682
+ const basePath = rawBase && rawBase !== '/' ? rawBase.replace(/\/?$/, '/') : '';
683
+ let filePath = url;
684
+ if (basePath && url.startsWith(basePath)) {
685
+ filePath = url.slice(basePath.length) || '/';
686
+ }
687
+ if (filePath === '/') filePath = '/index.html';
668
688
  // 去掉 query string
669
689
  filePath = filePath.split('?')[0];
670
690
 
@@ -705,6 +725,15 @@ async function handleRequest(req, res) {
705
725
  console.warn('[serveIndexHtml] dist/index.html 没有 <html data-theme="..."> 属性,SSR theme 注入将不生效。检查 index.html 模板。');
706
726
  }
707
727
  html = html.replace(/<html([^>]*?)data-theme="[^"]*"/, `<html$1data-theme="${themeColor}"`);
728
+ // 运行时注入 <base> 标签:当 CCV_BASE_PATH 设置为非空非根路径时,
729
+ // 使浏览器将所有相对 URL 解析到代理子路径下。配合 Vite base='' 输出相对路径。
730
+ const basePath = process.env.CCV_BASE_PATH || '';
731
+ if (basePath && basePath !== '/') {
732
+ const safeBase = basePath.replace(/\/?$/, '/');
733
+ const escapedBase = safeBase.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
734
+ const jsSafeBase = safeBase.replace(/"/g, '"').replace(/<\//g, '<\/');
735
+ html = html.replace(/<head[^>]*>/i, m => m + `<base href="${escapedBase}"><script>window.__CCV_BASE_PATH__="${jsSafeBase}"</script>`);
736
+ }
708
737
  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-cache' });
709
738
  res.end(html);
710
739
  return true;
@@ -1030,7 +1059,12 @@ async function setupTerminalWebSocket(httpServer) {
1030
1059
 
1031
1060
  httpServer.on('upgrade', (req, socket, head) => {
1032
1061
  const wsUrl = new URL(req.url, `${serverProtocol}://${req.headers.host}`);
1033
- const pathname = wsUrl.pathname;
1062
+ let pathname = wsUrl.pathname;
1063
+ const bpRaw = process.env.CCV_BASE_PATH || '';
1064
+ const wsBp = bpRaw && bpRaw !== '/' ? bpRaw.replace(/\/?$/, '/') : '';
1065
+ if (wsBp && pathname.startsWith(wsBp)) {
1066
+ pathname = '/' + pathname.slice(wsBp.length);
1067
+ }
1034
1068
  // 与 HTTP 一致的鉴权(此前 WS upgrade 完全不校验 token,远程终端实为无门禁——本次堵洞)。
1035
1069
  // 在此显式计算 isLocal(与 handleRequest 同款三态判断),WS 视作非 HTML 请求。
1036
1070
  const wsRemoteIp = req.socket.remoteAddress;
@@ -1077,6 +1111,20 @@ async function setupTerminalWebSocket(httpServer) {
1077
1111
  }
1078
1112
  });
1079
1113
 
1114
+ // 反压状态转换日志(observability:线上排"页面卡"时可直接判断是否触发过反压、几次、积压量)。
1115
+ // behind/resume 在持续洪泛下以亚秒级周期振荡,5s 节流防刷屏;timeout 是终态必记。
1116
+ const makeBpLogger = (label, ws) => {
1117
+ let behindCount = 0;
1118
+ let lastLogAt = 0;
1119
+ return (event, buffered) => {
1120
+ if (event === 'behind') behindCount++;
1121
+ const now = Date.now();
1122
+ if (event !== 'timeout' && now - lastLogAt < 5000) return;
1123
+ lastLogAt = now;
1124
+ console.warn(`[${label}] ws backpressure ${event}: client=${ws._socket?.remoteAddress || '?'} bufferedAmount=${buffered} behindTotal=${behindCount}`);
1125
+ };
1126
+ };
1127
+
1080
1128
  // scratch 终端 WS:极简版,仅承载 input/resize/data/exit + 显式 kill;不掺杂 hook/SDK/preset
1081
1129
  wssScratch.on('connection', async (ws, req) => {
1082
1130
  const id = req.ccvScratchId;
@@ -1097,8 +1145,27 @@ async function setupTerminalWebSocket(httpServer) {
1097
1145
  try { ws.send(JSON.stringify({ type: 'data', data: buffer })); } catch {}
1098
1146
  }
1099
1147
 
1148
+ // 反压闸门:写缓冲堆积时停发 data,恢复后用 outputBuffer 快照 resync 追赶
1149
+ // (防 Windows ConPTY 洪泛把慢客户端 / server 内存拖垮,详见 lib/ws-backpressure.js)。
1150
+ // 快照自身有界:scratch outputBuffer 50KB 滚动截断(scratch-pty-manager.js MAX_BUFFER),
1151
+ // behind 期间继续灌也不会撑爆 resync 响应。
1152
+ const _bpLog = makeBpLogger('scratch-ws', ws);
1153
+ const bpGate = createBackpressureGate({
1154
+ getBufferedAmount: () => ws.bufferedAmount,
1155
+ onBehind: (buffered) => _bpLog('behind', buffered),
1156
+ onResume: (buffered) => {
1157
+ _bpLog('resume', buffered);
1158
+ if (ws.readyState !== 1) return;
1159
+ try { ws.send(JSON.stringify({ type: 'data-resync', data: getScratchOutputBuffer(id) })); } catch {}
1160
+ },
1161
+ onTimeout: (buffered) => {
1162
+ _bpLog('timeout', buffered);
1163
+ try { ws.terminate(); } catch {}
1164
+ },
1165
+ });
1166
+
1100
1167
  const removeDataListener = onScratchData(id, (data) => {
1101
- if (ws.readyState === 1) {
1168
+ if (ws.readyState === 1 && bpGate.offer()) {
1102
1169
  try { ws.send(JSON.stringify({ type: 'data', data })); } catch {}
1103
1170
  }
1104
1171
  });
@@ -1128,6 +1195,7 @@ async function setupTerminalWebSocket(httpServer) {
1128
1195
  });
1129
1196
 
1130
1197
  ws.on('close', () => {
1198
+ bpGate.dispose();
1131
1199
  removeDataListener();
1132
1200
  removeExitListener();
1133
1201
  // pty 本身**不杀**(保留以支持刷新重连),由 kill 消息或 /api/workspaces/stop 触发;
@@ -1174,9 +1242,48 @@ async function setupTerminalWebSocket(httpServer) {
1174
1242
  // 注:仅 PTY 已运行时才需要兜底;shell 不在 alternate-screen 不需要。
1175
1243
  let _needRedrawBootstrap = state.running === true;
1176
1244
 
1245
+ // 反压闸门:写缓冲堆积时停发 data,恢复后用 outputBuffer 快照 resync 追赶;
1246
+ // resync 后强制 claude TUI 全屏重绘,避免洪泛结束于 TUI 静止态时画面停在快照
1247
+ // (防 Windows ConPTY 洪泛拖垮慢客户端 / server 内存,详见 lib/ws-backpressure.js)。
1248
+ // 快照自身有界:outputBuffer 200KB 滚动截断(pty-manager.js MAX_BUFFER + findSafeSliceStart
1249
+ // ANSI 安全起点),behind 期间 PTY 继续灌也不会撑爆 resync 响应。
1250
+ const _bpLog = makeBpLogger('terminal-ws', ws);
1251
+ const bpGate = createBackpressureGate({
1252
+ getBufferedAmount: () => ws.bufferedAmount,
1253
+ onBehind: (buffered) => _bpLog('behind', buffered),
1254
+ onResume: (buffered) => {
1255
+ _bpLog('resume', buffered);
1256
+ if (ws.readyState !== 1) return;
1257
+ try { ws.send(JSON.stringify({ type: 'data-resync', data: getOutputBuffer() })); } catch {}
1258
+ try {
1259
+ if (process.platform !== 'win32') {
1260
+ // POSIX:与下方 _needRedrawBootstrap 同款 SIGWINCH 兜底
1261
+ const pid = getClaudePid();
1262
+ if (pid && pid !== process.pid) process.kill(pid, 'SIGWINCH');
1263
+ } else {
1264
+ // Windows 无 SIGWINCH:resize 抖动经 ConPTY 通知重绘(恢复路径偶发,闪烁可接受)。
1265
+ // 尺寸仲裁与 resize 消息处理一致:移动端优先,否则活跃客户端(activeWs 为 null 时
1266
+ // 本 ws 视为所有者)——恢复的 ws 可能是非权威的慢后台 tab,用它自己的尺寸抖动会把
1267
+ // 共享 PTY 永久改成它的尺寸、挤掉活跃/移动端画面;无权威尺寸则跳过抖动。
1268
+ const mSize = getMobileSize();
1269
+ const size = mSize
1270
+ || ((activeWs === ws || activeWs === null) ? clientSizes.get(ws) : clientSizes.get(activeWs));
1271
+ if (size) {
1272
+ resizePty(size.cols, size.rows + 1);
1273
+ resizePty(size.cols, size.rows);
1274
+ }
1275
+ }
1276
+ } catch {}
1277
+ },
1278
+ onTimeout: (buffered) => {
1279
+ _bpLog('timeout', buffered);
1280
+ try { ws.terminate(); } catch {}
1281
+ },
1282
+ });
1283
+
1177
1284
  // PTY 输出 → WebSocket(合并 ws 后客户端自行按 msg.type 分发,server 端不再 role 过滤)
1178
1285
  const removeDataListener = onPtyData((data) => {
1179
- if (ws.readyState === 1) {
1286
+ if (ws.readyState === 1 && bpGate.offer()) {
1180
1287
  ws.send(JSON.stringify({ type: 'data', data }));
1181
1288
  }
1182
1289
  });
@@ -1555,6 +1662,7 @@ async function setupTerminalWebSocket(httpServer) {
1555
1662
  });
1556
1663
 
1557
1664
  ws.on('close', () => {
1665
+ bpGate.dispose();
1558
1666
  removeDataListener();
1559
1667
  removeExitListener();
1560
1668
  clientSizes.delete(ws);