cc-viewer 1.6.302 → 1.6.304

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.
@@ -7,6 +7,21 @@ import { discoverClaudeMdCandidates, readCandidateById } from '../lib/claude-md-
7
7
  import { getClaudeConfigDir } from '../../findcc.js';
8
8
  import { _projectName } from '../interceptor.js';
9
9
 
10
+ // file-raw 扩展名 → MIME 映射。图片走 <img> 预览;html 走 iframe 预览(带下方 CSP sandbox);
11
+ // 其余为 HTML 预览的同目录子资源(c8/nyc/lcov-genhtml 等静态报告):CSP sandbox 使文档
12
+ // 处于 opaque origin,对一切子资源都是跨源——跨源样式表受浏览器严格 MIME 校验,
13
+ // octet-stream 的 CSS 会被直接拒用(报告渲染成裸 HTML)。js 虽为 classic script 不受
14
+ // MIME 门禁,配正确类型属卫生项 + 为未来 nosniff 铺路。
15
+ // 注意:值不带 charset 后缀——fileRaw 内 CSP 判等依赖 mime === 'text/html'。
16
+ const FILE_RAW_MIME = {
17
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
18
+ '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
19
+ '.webp': 'image/webp', '.html': 'text/html', '.htm': 'text/html',
20
+ '.css': 'text/css', '.js': 'text/javascript', '.mjs': 'text/javascript',
21
+ '.json': 'application/json', '.map': 'application/json', '.txt': 'text/plain',
22
+ '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
23
+ };
24
+
10
25
  function planFile(req, res, parsedUrl) {
11
26
  try {
12
27
  const raw = parsedUrl.searchParams.get('path') || '';
@@ -290,13 +305,8 @@ function fileRaw(req, res, parsedUrl) {
290
305
  res.end(JSON.stringify({ error: 'File too large' }));
291
306
  return;
292
307
  }
293
- const extMime = {
294
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
295
- '.gif': 'image/gif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon',
296
- '.webp': 'image/webp', '.html': 'text/html', '.htm': 'text/html',
297
- };
298
308
  const ext = (targetFile.match(/\.[^.]+$/) || [''])[0].toLowerCase();
299
- const mime = extMime[ext] || 'application/octet-stream';
309
+ const mime = FILE_RAW_MIME[ext] || 'application/octet-stream';
300
310
  const data = method === 'HEAD' ? null : readFileSync(targetFile);
301
311
  const size = method === 'HEAD' ? stat.size : data.length;
302
312
  const headers = { 'Content-Type': mime, 'Content-Length': size };
@@ -0,0 +1,78 @@
1
+ // Workflow run journal route: serve normalized panel data + arm live watch.
2
+ // 完成后读权威快照 <runId>.json;运行中(快照未落盘)回退到 subagents/workflows/<runId>
3
+ // 的逐帧推导,并武装运行中目录监视,使面板逐帧实时刷新。
4
+ import { resolveJournalPath, resolveWorkflowsDir, readNormalizedJournal } from '../lib/workflow-journal.js';
5
+ import { resolveRunDir, deriveLiveJournal } from '../lib/workflow-live.js';
6
+ import { armWorkflowWatch, armWorkflowLiveWatch } from '../lib/workflow-watcher.js';
7
+
8
+ // session 会进入 findTranscriptPath 的路径拼接(<root>/<dir>/<session>.jsonl),
9
+ // 限制为无路径分隔符/`..` 的安全字符集,纵深防御穿越(底层 realpath 容器校验之外的早拒)。
10
+ const SESSION_RE = /^[A-Za-z0-9_-]+$/;
11
+
12
+ function workflowJournal(req, res, parsedUrl, isLocal, deps) {
13
+ try {
14
+ const session = parsedUrl.searchParams.get('session') || '';
15
+ const runId = parsedUrl.searchParams.get('runId') || '';
16
+ const taskId = parsedUrl.searchParams.get('taskId') || '';
17
+ const project = parsedUrl.searchParams.get('project') || undefined;
18
+
19
+ if (!session) {
20
+ res.writeHead(400, { 'Content-Type': 'application/json' });
21
+ res.end(JSON.stringify({ ok: false, error: 'missing session' }));
22
+ return;
23
+ }
24
+ if (!SESSION_RE.test(session)) {
25
+ res.writeHead(400, { 'Content-Type': 'application/json' });
26
+ res.end(JSON.stringify({ ok: false, error: 'invalid session' }));
27
+ return;
28
+ }
29
+ if (!runId && !taskId) {
30
+ res.writeHead(400, { 'Content-Type': 'application/json' });
31
+ res.end(JSON.stringify({ ok: false, error: 'missing runId or taskId' }));
32
+ return;
33
+ }
34
+
35
+ // 惰性 arm:只监视真有人在看的 session workflows 目录,后续 journal 覆写经 SSE 实时推送。
36
+ const wfDir = resolveWorkflowsDir(session, project);
37
+ if (wfDir && deps && Array.isArray(deps.clients)) {
38
+ try {
39
+ armWorkflowWatch({ workflowsDir: wfDir, sessionId: session, project, clients: deps.clients });
40
+ } catch {}
41
+ }
42
+
43
+ // 1) 完成快照优先(权威,含 phase 分组)
44
+ const journalPath = resolveJournalPath({ sessionId: session, projectHint: project, runId, taskId });
45
+ if (journalPath) {
46
+ const data = readNormalizedJournal(journalPath);
47
+ if (data) {
48
+ res.writeHead(200, { 'Content-Type': 'application/json' });
49
+ res.end(JSON.stringify({ ok: true, data }));
50
+ return;
51
+ }
52
+ }
53
+
54
+ // 2) 运行中逐帧推导(快照尚未落盘)+ 武装运行中目录监视
55
+ if (runId) {
56
+ const runDir = resolveRunDir(session, project, runId);
57
+ const live = runDir ? deriveLiveJournal(runDir, runId) : null;
58
+ if (live) {
59
+ if (deps && Array.isArray(deps.clients)) {
60
+ try { armWorkflowLiveWatch({ runDir, runId, sessionId: session, project, clients: deps.clients }); } catch {}
61
+ }
62
+ res.writeHead(200, { 'Content-Type': 'application/json' });
63
+ res.end(JSON.stringify({ ok: true, data: live }));
64
+ return;
65
+ }
66
+ }
67
+
68
+ res.writeHead(404, { 'Content-Type': 'application/json' });
69
+ res.end(JSON.stringify({ ok: false, error: 'not found' }));
70
+ } catch (e) {
71
+ res.writeHead(500, { 'Content-Type': 'application/json' });
72
+ res.end(JSON.stringify({ ok: false, error: String(e?.message || e) }));
73
+ }
74
+ }
75
+
76
+ export const workflowJournalRoutes = [
77
+ { method: 'GET', match: 'exact', path: '/api/workflow-journal', handler: workflowJournal },
78
+ ];
@@ -3,6 +3,7 @@ import { existsSync, statSync } from 'node:fs';
3
3
  import { basename } from 'node:path';
4
4
  import { LOG_FILE, initForWorkspace, resetWorkspace } from '../interceptor.js';
5
5
  import { watchLogFile, unwatchAll } from '../lib/log-watcher.js';
6
+ import { unwatchAllWorkflows } from '../lib/workflow-watcher.js';
6
7
  import { readClaudeProjectModel } from '../lib/context-watcher.js';
7
8
  import { countLogEntries, streamRawEntriesAsync } from '../lib/log-stream.js';
8
9
 
@@ -139,6 +140,7 @@ function workspacesStop(req, res, parsedUrl, isLocal, deps) {
139
140
 
140
141
  // 停止日志监听
141
142
  unwatchAll();
143
+ unwatchAllWorkflows();
142
144
 
143
145
  // 重置 interceptor 状态
144
146
  resetWorkspace();
package/server/server.js CHANGED
@@ -24,6 +24,7 @@ import { voicePackRoutes } from './routes/voice-pack.js';
24
24
  import { skillsRoutes } from './routes/skills.js';
25
25
  import { ultraAgentsRoutes } from './routes/ultra-agents.js';
26
26
  import { filesContentRoutes } from './routes/files-content.js';
27
+ import { workflowJournalRoutes } from './routes/workflow-journal.js';
27
28
  import { filesFsRoutes } from './routes/files-fs.js';
28
29
  import { workspacesRoutes } from './routes/workspaces.js';
29
30
  import { eventsRoutes } from './routes/events.js';
@@ -76,12 +77,14 @@ import { checkAndUpdate } from './lib/updater.js';
76
77
  import { loadPlugins, runWaterfallHook, runParallelHook } from './lib/plugin-loader.js';
77
78
  import { CONTEXT_WINDOW_FILE, readModelContextSize } from './lib/context-watcher.js';
78
79
  import { watchLogFile, startWatching, unwatchAll, sendEventToClients, sendToClients } from './lib/log-watcher.js';
80
+ import { unwatchAllWorkflows } from './lib/workflow-watcher.js';
79
81
  import { cleanupExtractCache } from './lib/jsonl-archive.js';
80
82
  import { backupConfigs } from './lib/config-backup.js';
81
83
  import { normalizeBasePath, validateBasePath, stripBasePath } from './lib/base-path.js';
82
84
  import { createHardenedCleanup } from './lib/term-signals.js';
83
85
  import { createBackpressureGate } from './lib/ws-backpressure.js';
84
- import { createFloodCoalescer } from './lib/pty-flood-coalescer.js';
86
+ import { createFloodCoalescer, envIntAllowZero } from './lib/pty-flood-coalescer.js';
87
+ import { createResyncNudgeGate } from './lib/resync-nudge-gate.js';
85
88
 
86
89
 
87
90
  // 动态获取 getPrefsFile()(LOG_DIR 可能在运行时被 setLogDir 修改)
@@ -555,6 +558,7 @@ const _routes = [
555
558
  ...skillsRoutes,
556
559
  ...ultraAgentsRoutes,
557
560
  ...filesContentRoutes,
561
+ ...workflowJournalRoutes,
558
562
  ...filesFsRoutes,
559
563
  ...workspacesRoutes,
560
564
  ...eventsRoutes,
@@ -1121,20 +1125,30 @@ async function setupTerminalWebSocket(httpServer) {
1121
1125
 
1122
1126
  // 反压状态转换日志(observability:线上排"页面卡"时可直接判断是否触发过反压、几次、积压量)。
1123
1127
  // behind/resume 在持续洪泛下以亚秒级周期振荡,5s 节流防刷屏;timeout 是终态必记。
1128
+ // resyncTotal/nudgeSkipped 判别 resync 循环:resync 涨 + skipped 同涨 = nudge 冷却在救;
1129
+ // resync 涨 + skipped≈0 = resume 间隔超过冷却期的慢振荡,需更激进策略(如洪泛期禁 nudge)。
1124
1130
  const makeBpLogger = (label, ws) => {
1125
1131
  let behindCount = 0;
1132
+ let resyncCount = 0;
1133
+ let nudgeSkipped = 0;
1126
1134
  let lastLogAt = 0;
1127
1135
  return (event, buffered) => {
1128
1136
  if (event === 'behind') behindCount++;
1137
+ if (event === 'resume') resyncCount++;
1138
+ if (event === 'nudge-skip') { nudgeSkipped++; return; } // 只计数,随下一条节流日志带出
1129
1139
  const now = Date.now();
1130
1140
  if (event !== 'timeout' && now - lastLogAt < 5000) return;
1131
1141
  lastLogAt = now;
1132
- console.warn(`[${label}] ws backpressure ${event}: client=${ws._socket?.remoteAddress || '?'} bufferedAmount=${buffered} behindTotal=${behindCount}`);
1142
+ console.warn(`[${label}] ws backpressure ${event}: client=${ws._socket?.remoteAddress || '?'} bufferedAmount=${buffered} behindTotal=${behindCount} resyncTotal=${resyncCount} nudgeSkipped=${nudgeSkipped}`);
1133
1143
  };
1134
1144
  };
1135
1145
 
1146
+ // resync nudge 冷却(CCV_RESYNC_NUDGE_COOLDOWN_MS,0 = 不冷却;详见 lib/resync-nudge-gate.js)
1147
+ const RESYNC_NUDGE_COOLDOWN_MS = envIntAllowZero('CCV_RESYNC_NUDGE_COOLDOWN_MS', 3000);
1148
+
1136
1149
  // 洪泛限流器状态日志(与 makeBpLogger 同款 5s 节流,独立实例不共享计数)。
1137
1150
  // Windows 实机排"切主题/大流量卡死"时据此确认 ConPTY 洪泛是否触发、几次、量级。
1151
+ // 'rate' 事件 = 直通态 ws 消息率告警(msgsPerSec,计数在 send 闭包),判别消息数风暴。
1138
1152
  const makeFloodLogger = (label, ws) => {
1139
1153
  let floodCount = 0;
1140
1154
  let lastLogAt = 0;
@@ -1143,7 +1157,23 @@ async function setupTerminalWebSocket(httpServer) {
1143
1157
  const now = Date.now();
1144
1158
  if (now - lastLogAt < 5000) return;
1145
1159
  lastLogAt = now;
1146
- console.warn(`[${label}] pty flood ${event}: client=${ws._socket?.remoteAddress || '?'} winBytes=${bytes} floodTotal=${floodCount}`);
1160
+ const metric = event === 'rate' ? 'msgsPerSec' : 'winBytes';
1161
+ console.warn(`[${label}] pty flood ${event}: client=${ws._socket?.remoteAddress || '?'} ${metric}=${bytes} floodTotal=${floodCount}`);
1162
+ };
1163
+ };
1164
+
1165
+ // 直通态消息率计数器工厂:1s 整数桶,桶滚动时超过阈值经 floodLog('rate') 告警(5s 节流兜底)。
1166
+ const makeMsgRateCounter = (floodLog, warnAbove = 60) => {
1167
+ let count = 0;
1168
+ let winStart = 0;
1169
+ return () => {
1170
+ const now = Date.now();
1171
+ if (now - winStart >= 1000) {
1172
+ if (count > warnAbove) floodLog('rate', count);
1173
+ count = 0;
1174
+ winStart = now;
1175
+ }
1176
+ count++;
1147
1177
  };
1148
1178
  };
1149
1179
 
@@ -1196,10 +1226,12 @@ async function setupTerminalWebSocket(httpServer) {
1196
1226
  // 洪泛限流器:字节率超阈值时按窗口合并 + last-wins 截断(ConPTY 全屏重绘洪泛防卡死,
1197
1227
  // 与 bpGate 互补——bpGate 管慢网络写缓冲,floodGate 管快 LAN 字节率,详见 lib/pty-flood-coalescer.js)
1198
1228
  const _floodLog = makeFloodLogger('scratch-ws', ws);
1229
+ const _countMsg = makeMsgRateCounter(_floodLog);
1199
1230
  floodGate = createFloodCoalescer({
1200
1231
  send: (data) => {
1201
1232
  if (ws.readyState === 1 && bpGate.offer()) {
1202
1233
  try { ws.send(JSON.stringify({ type: 'data', data })); } catch {}
1234
+ _countMsg();
1203
1235
  }
1204
1236
  },
1205
1237
  findSafeSliceStart,
@@ -1293,6 +1325,10 @@ async function setupTerminalWebSocket(httpServer) {
1293
1325
  // floodGate 前向引用(构造顺序同 scratch 路径):onBehind/onResume 必清 coalescer
1294
1326
  // pending——resync 快照是唯一真相源,旧 pending 回灌会导致画面回退。
1295
1327
  let floodGate = null;
1328
+ // nudge 冷却门:快照每次 resume 无条件发(修复 behind 期间跳发的数据),但重绘 nudge
1329
+ // 走冷却——nudge 让 ConPTY 再吐全屏重绘 = 新洪泛燃料,紧 behind→resume 循环里反复
1330
+ // nudge 会自我维持(客户端每轮 reset+重放快照 = 永久冻结表象)。详见 lib/resync-nudge-gate.js。
1331
+ const nudgeGate = createResyncNudgeGate({ cooldownMs: RESYNC_NUDGE_COOLDOWN_MS });
1296
1332
  const bpGate = createBackpressureGate({
1297
1333
  getBufferedAmount: () => ws.bufferedAmount,
1298
1334
  onBehind: (buffered) => {
@@ -1304,6 +1340,7 @@ async function setupTerminalWebSocket(httpServer) {
1304
1340
  floodGate?.reset();
1305
1341
  if (ws.readyState !== 1) return;
1306
1342
  try { ws.send(JSON.stringify({ type: 'data-resync', data: getOutputBuffer() })); } catch {}
1343
+ if (!nudgeGate.shouldNudge()) { _bpLog('nudge-skip', buffered); return; }
1307
1344
  try {
1308
1345
  if (process.platform !== 'win32') {
1309
1346
  // POSIX:与下方 _needRedrawBootstrap 同款 SIGWINCH 兜底
@@ -1333,10 +1370,12 @@ async function setupTerminalWebSocket(httpServer) {
1333
1370
  // 洪泛限流器:字节率超阈值时按窗口合并 + last-wins 截断(ConPTY 全屏重绘洪泛防卡死,
1334
1371
  // 与 bpGate 互补——bpGate 管慢网络写缓冲,floodGate 管快 LAN 字节率,详见 lib/pty-flood-coalescer.js)
1335
1372
  const _floodLog = makeFloodLogger('terminal-ws', ws);
1373
+ const _countMsg = makeMsgRateCounter(_floodLog);
1336
1374
  floodGate = createFloodCoalescer({
1337
1375
  send: (data) => {
1338
1376
  if (ws.readyState === 1 && bpGate.offer()) {
1339
1377
  try { ws.send(JSON.stringify({ type: 'data', data })); } catch {}
1378
+ _countMsg();
1340
1379
  }
1341
1380
  },
1342
1381
  findSafeSliceStart,
@@ -2017,6 +2056,7 @@ async function _doStop() {
2017
2056
  } catch { }
2018
2057
  }
2019
2058
  unwatchAll();
2059
+ unwatchAllWorkflows();
2020
2060
  unwatchFile(CONTEXT_WINDOW_FILE);
2021
2061
  clients.forEach(client => client.end());
2022
2062
  // Truncate in place (not `clients = []`) so the array reference stays stable across