cc-viewer 1.6.304 → 1.6.306

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-BDK4eIE5.js"></script>
24
+ <script type="module" crossorigin src="/assets/index-1bh2o4MD.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.304",
3
+ "version": "1.6.306",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -64,7 +64,7 @@ let _defaultConfig = null; // { origin, authType, model }
64
64
 
65
65
  function _getActiveProfileFilePath() {
66
66
  // _projectName/_logDir 声明在 ~line 218;本函数只会在这些变量初始化后被调用
67
- // (_loadProxyProfile 的初始调用被挪到 line ~237 之后;watchFile 回调、HTTP handler 也都在之后)
67
+ // (_loadProxyProfile 的初始调用与 watchFile 挂载都被挪到 _projectName/_logDir 初始化之后;watchFile 回调、HTTP handler 也都在之后)
68
68
  if (!_projectName || !_logDir) return null;
69
69
  return join(_logDir, 'active-profile.json');
70
70
  }
@@ -159,7 +159,7 @@ function getActiveProfileId() {
159
159
  }
160
160
 
161
161
  // _loadProxyProfile 的初始调用 + watchFile 挂载挪到 _projectName/_logDir 初始化之后
162
- // (见 "初始化日志文件路径" 段后的 _kickoffProxyProfileWatcher 调用),避免 TDZ。
162
+ // (见 "初始化日志文件路径" 段后的 _loadProxyProfile() + watchFile(PROFILE_PATH, …) 挂载),避免 TDZ。
163
163
 
164
164
  // 纯函数:把 headers 里任意大小写的 authorization / x-api-key 替换为 profile 的 apiKey;
165
165
  // 两者都不存在时强制植入 x-api-key(第三方代理最常见的鉴权形式)。
@@ -5,7 +5,7 @@
5
5
  // fields (modalEnabled, soundEnabled, notifyOnlyWhenHidden) plus the voicePack subtree.
6
6
  // voice-pack-manager.js stays focused on the file/audio backing store.
7
7
  //
8
- // Both server/server.js (handles POST /api/preferences) and src/AppBase.jsx (hydrate +
8
+ // Both server/routes/preferences.js (handles POST /api/preferences) and src/AppBase.jsx (hydrate +
9
9
  // handleVoicePackChange) use this so the merge contract is single-sourced.
10
10
 
11
11
  import { EVENT_KEYS } from './voice-pack-events.js';
@@ -6,7 +6,7 @@
6
6
  * 策略:
7
7
  * - 项目候选:从 cwd 实际路径(realpath 后)向上走,每层若 <dir>/CLAUDE.md 存在且为文件,收一条。
8
8
  * 终止条件:dir === dirname(dir)(fs root) / dir === homedir() / <dir>/.git 存在 / depth 达 8(任一即停)。
9
- * hit homedir hit .git 时仍包含当前层再停,hit fs root 立即停。
9
+ * 三种终止条件(homedir / .git / fs root)都先收当前层再停——pushIfFile 在终止判定之前无条件执行。
10
10
  * - 全局候选:~/.claude/CLAUDE.md(以参数 claudeConfigDir 为准,由调用方传入,便于沙箱测试)。
11
11
  * - 排序:项目候选按"靠近 cwd"在前,全局总是最后一条。
12
12
  * - 去重:基于 realpath 去重;同一物理文件被多入口指向只留一条(保留先入者)。
@@ -29,7 +29,9 @@ class FileApiError extends Error {
29
29
  }
30
30
 
31
31
  /**
32
- * Resolve and validate a file path. Used by readFileContent and file-raw handler.
32
+ * Resolve and validate a file path. No production callers (readFileContent does its own inline
33
+ * path validation); kept as a standalone, path-traversal-defending helper covered by
34
+ * test/file-api-gap.test.js — don't delete as "dead code" without removing that test.
33
35
  * @param {string} cwd - project working directory
34
36
  * @param {string} reqPath - requested path (relative or absolute)
35
37
  * @param {boolean} isEditorSession - whether this is an editor session
@@ -5,7 +5,7 @@
5
5
  // - 获取用 openSync(path,'wx') 原子哨兵(与 workspace-registry.js 同款),保证并发只有一个赢家。
6
6
  // - 内容写入走 temp + renameSyncWithRetry(与 file-api.js / saveWorkspaces 一致),避免读到半写 JSON。
7
7
  // - 读方对 JSON.parse 失败一律容忍(返回 null),绝不据此删锁。
8
- // - 活性判定三态:dead(无锁)/ booting(已建锁未写 port 且在启动窗内)/ ready(已写 port 且 HTTP 身份探测通过)。
8
+ // - 活性判定四态:dead(无锁)/ booting(已建锁未写 port 且在启动窗内)/ ready(已写 port 且 HTTP 身份探测通过)/ hung(pid 存活但探测失败或超启动窗)。
9
9
  // 长跑 bot 不会更新 mtime,故不沿用 workspace-registry 的 mtime 陈旧判据,改用 PID 存活 + HTTP 身份探测。
10
10
  // - 释放按身份(仅当锁的 pid === 调用方 pid 才 unlink),避免误删后继进程的锁。
11
11
  import { openSync, closeSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
@@ -127,7 +127,7 @@ function extractDedupKey(raw) {
127
127
  const ts = extractTimestamp(raw);
128
128
  const urlMatch = raw.match(/"url"\s*:\s*"([^"]+)"/);
129
129
  if (ts && urlMatch) return `${ts}|${urlMatch[1]}`;
130
- // fallback: 无法提取 key 则用内容哈希
130
+ // fallback: 无法提取 key 时返回 null;调用方改用位置键 __nokey_<index> 入表(非内容哈希)。
131
131
  return null;
132
132
  }
133
133
 
@@ -8,7 +8,7 @@
8
8
  * (allow/deny) in the web UI, then outputs hookSpecificOutput.
9
9
  *
10
10
  * Exit 0 = success (stdout contains hookSpecificOutput with permissionDecision)
11
- * Exit 1 = fallback (Claude Code proceeds with normal terminal UI)
11
+ * Exit 1 = error (malformed/missing stdin or invalid env); makes Claude Code log a hook error. Graceful fallback (cc-viewer not running / no decision) instead exits 0 with { continue: true } so the terminal UI proceeds without an error log.
12
12
  */
13
13
 
14
14
  import { readFileSync } from 'node:fs';
@@ -231,7 +231,7 @@ export function moveSkill({ source, name, enable, projectDir = process.cwd(), ho
231
231
  renameSync(from, to);
232
232
  } catch (e) {
233
233
  if (e.code === 'EXDEV') {
234
- // 跨设备兜底:cp -r + rm -rf(server.js:1337 已有相似模式)
234
+ // 跨设备兜底:cp -r + rm -rf(与 routes/files-fs.js 的 EXDEV 分支同款模式)
235
235
  cpSync(from, to, { recursive: true });
236
236
  rmSync(from, { recursive: true, force: true });
237
237
  } else {
@@ -51,7 +51,7 @@ if (!port) {
51
51
  }
52
52
 
53
53
  // Drain stdin best-effort. Claude Code passes a JSON payload with session_id /
54
- // transcript_path; only session_id is forwarded. Capped to 64 KB to defang any
54
+ // transcript_path; both session_id and transcript_path are forwarded. Capped to 64 KB to defang any
55
55
  // malformed huge payload().
56
56
  let stdinData = '';
57
57
  try {
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // Layout:
4
4
  // <LOG_DIR>/voice-packs/<id>.<ext> ← user-uploaded audio
5
- // <repo>/public/voice-packs/default/ ← bundled default pack (Pixel Buddy chiptune)
5
+ // <repo>/public/voice-packs/default/ ← bundled default pack (default-butler "Butler · 皇上系列")
6
6
  //
7
7
  // Why a UUID-keyed flat dir (no nested user-supplied paths): the audio id ends up
8
8
  // in URL path (/api/voice-pack/audio/:id), so we whitelist [a-f0-9-]{8,64} and
@@ -8,8 +8,10 @@
8
8
  * ├─ agent-<id>.meta.json {"agentType":...}
9
9
  * └─ journal.jsonl started / result 事件(带 agentId)→ running / done 判定
10
10
  *
11
- * 推导出的模型与 normalizeWorkflowJournal 同形(phases 为空 前端走扁平 agent 列表),
12
- * 完成后由权威的 <runId>.json 快照接管(phase 分组)。
11
+ * 推导出的模型与 normalizeWorkflowJournal 同形。phases parsePhasesFromScript 从生成脚本
12
+ * (workflows/scripts/<name>-<runId>.js 顶部 meta.phases)文本解析填充——运行中即可显示阶段列;
13
+ * 但 agent 的 phaseIndex 运行中仍为 null(无权威 agent→phase 映射),故前端按「有 phases 但无
14
+ * agent 带 numeric phaseIndex」走扁平 agent 列表,完成后由权威的 <runId>.json 快照接管 phase 分组。
13
15
  *
14
16
  * token 口径:input + output + cache_creation(运行中单调增;与完成快照略有出入,完成时被
15
17
  * 权威值替换)。工具数与快照一致(统计 tool_use 块)。
@@ -24,6 +26,10 @@ import { _RUN_ID_RE as RUN_ID_RE } from './workflow-journal.js';
24
26
  const MAX_AGENT_BYTES = 64 * 1024 * 1024;
25
27
  const LABEL_MAX = 80;
26
28
  const PROMPT_PREVIEW_MAX = 600; // 头部菱形 hover 预览的 prompt 截断长度(与 journal promptPreview 量级一致)
29
+ const MAX_SCRIPT_BYTES = 2 * 1024 * 1024; // 脚本超过此大小只读头部 SCRIPT_HEAD_BYTES(meta 恒在文件最前)
30
+ const SCRIPT_HEAD_BYTES = 256 * 1024; // 超大脚本时读取的头部字节数,足够覆盖 meta 块
31
+ const MAX_PHASES = 50; // 解析出的 phases 项数上限(防御异常脚本)
32
+ const PHASES_CACHE_MAX = 256; // _phasesCache 条目上限(防长跑服务端无界增长)
27
33
 
28
34
  function projectsDir() {
29
35
  return process.env.CCV_PROJECTS_DIR || join(getClaudeConfigDir(), 'projects');
@@ -45,15 +51,161 @@ function isInsideProjectsDir(realPath) {
45
51
  return realPath === root || realPath.startsWith(root + sep);
46
52
  }
47
53
 
48
- /** 从 workflows/scripts/<name>-<runId>.js 反推 workflowName(运行中即可拿到)。 */
49
- function deriveWorkflowName(sessionDir, runId) {
54
+ /**
55
+ * 从 workflows/scripts/<name>-<runId>.js 反推 workflowName + 脚本绝对路径(运行中即可拿到)。
56
+ * 一次 readdirSync 同时给出 name 与 scriptPath,供 phases 解析复用,避免重复扫目录。
57
+ * @returns {{ name: string, scriptPath: string|null }}
58
+ */
59
+ function deriveWorkflowScript(sessionDir, runId) {
50
60
  try {
51
61
  const scriptsDir = join(sessionDir, 'workflows', 'scripts');
62
+ const suffix = `-${runId}.js`;
52
63
  for (const f of readdirSync(scriptsDir)) {
53
- if (f.endsWith(`-${runId}.js`)) return f.slice(0, -(`-${runId}.js`.length));
64
+ if (f.endsWith(suffix)) {
65
+ return { name: f.slice(0, -suffix.length), scriptPath: join(scriptsDir, f) };
66
+ }
54
67
  }
55
68
  } catch {}
56
- return '';
69
+ return { name: '', scriptPath: null };
70
+ }
71
+
72
+ /**
73
+ * 从一个对象/数组字面量起始的开括号位置,做「字符串感知」的括号配对扫描,
74
+ * 返回配平的闭括号下标(含);不配平返回 -1。
75
+ * 进入 '...' / "..." 串态时不计括号、并处理 `\` 转义,故 detail 文本里的
76
+ * `]` `}` `'` 都不会破坏配对。
77
+ */
78
+ function _matchBalanced(text, openIdx) {
79
+ const open = text[openIdx];
80
+ const close = open === '[' ? ']' : '}';
81
+ let depth = 0, inStr = false, quote = '', esc = false;
82
+ for (let i = openIdx; i < text.length; i++) {
83
+ const ch = text[i];
84
+ if (inStr) {
85
+ if (esc) { esc = false; continue; }
86
+ if (ch === '\\') { esc = true; continue; }
87
+ if (ch === quote) inStr = false;
88
+ continue;
89
+ }
90
+ if (ch === "'" || ch === '"' || ch === '`') { inStr = true; quote = ch; continue; }
91
+ if (ch === '[' || ch === '{') depth++;
92
+ else if (ch === ']' || ch === '}') {
93
+ depth--;
94
+ if (depth === 0) return i;
95
+ }
96
+ }
97
+ return -1;
98
+ }
99
+
100
+ /** 反转义 JS 单/双引号字符串字面量的内容(仅常见序列;够用且安全)。 */
101
+ function _unescapeStr(s) {
102
+ return s.replace(/\\(['"`\\nrt])/g, (_, c) =>
103
+ c === 'n' ? '\n' : c === 'r' ? '\r' : c === 't' ? '\t' : c);
104
+ }
105
+
106
+ /**
107
+ * 在一段对象字面量文本里抽取 `key: '...'`(字符串感知,处理转义)。无则 null。
108
+ * key 前要求边界字符(`{` / `,` / 空白 / 串首),避免 subtitle/subdetail 等更长键名
109
+ * 子串命中 title/detail。
110
+ */
111
+ function _extractStrField(objText, key) {
112
+ const re = new RegExp(`(?:^|[{,\\s])${key}\\s*:\\s*(['"\`])`);
113
+ const m = re.exec(objText);
114
+ if (!m) return null;
115
+ const quote = m[1];
116
+ let i = m.index + m[0].length, esc = false, out = '';
117
+ for (; i < objText.length; i++) {
118
+ const ch = objText[i];
119
+ if (esc) { out += ch; esc = false; continue; }
120
+ if (ch === '\\') { out += ch; esc = true; continue; }
121
+ if (ch === quote) return _unescapeStr(out);
122
+ out += ch;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ /**
128
+ * 纯文本解析 workflow 脚本顶部 `export const meta = { ... phases: [{title, detail?}, ...] }`。
129
+ * 绝不 import/eval 脚本(执行不可信代码)。输出与 normalizeWorkflowJournal 同形:
130
+ * [{ index:(从1), title:string, detail:string('' if missing) }]
131
+ * 任何异常/不匹配/无 phases 一律返回 []。
132
+ */
133
+ export function parsePhasesFromScript(scriptText) {
134
+ if (typeof scriptText !== 'string' || !scriptText) return [];
135
+ // 框定 meta 块(meta 恒为顶部纯字面量)
136
+ const metaKey = scriptText.match(/export\s+const\s+meta\s*=\s*\{/);
137
+ if (!metaKey) return [];
138
+ const metaOpen = scriptText.indexOf('{', metaKey.index);
139
+ const metaClose = _matchBalanced(scriptText, metaOpen);
140
+ if (metaClose === -1) return [];
141
+ const metaBody = scriptText.slice(metaOpen, metaClose + 1);
142
+
143
+ // 在 meta 内定位 phases 数组(字符串感知配对,detail 内的 `]` 不影响)
144
+ const phasesKey = metaBody.match(/phases\s*:\s*\[/);
145
+ if (!phasesKey) return [];
146
+ const arrOpen = metaBody.indexOf('[', phasesKey.index);
147
+ const arrClose = _matchBalanced(metaBody, arrOpen);
148
+ if (arrClose === -1) return [];
149
+ const arrBody = metaBody.slice(arrOpen + 1, arrClose);
150
+
151
+ // 逐项切出 {...} 对象(字符串感知配对),抽 title/detail
152
+ const phases = [];
153
+ let i = 0;
154
+ while (i < arrBody.length && phases.length < MAX_PHASES) {
155
+ const objOpen = arrBody.indexOf('{', i);
156
+ if (objOpen === -1) break;
157
+ const objClose = _matchBalanced(arrBody, objOpen);
158
+ if (objClose === -1) break;
159
+ const objText = arrBody.slice(objOpen, objClose + 1);
160
+ const title = _extractStrField(objText, 'title');
161
+ if (title !== null) {
162
+ phases.push({ index: phases.length + 1, title, detail: _extractStrField(objText, 'detail') || '' });
163
+ }
164
+ i = objClose + 1;
165
+ }
166
+ return phases;
167
+ }
168
+
169
+ // scriptPath → { mtimeMs, size, phases }。脚本运行中不可变,命中即跳过读盘+解析;
170
+ // edit-then-resume(mtime/size 变)则重解析。size 上限淘汰防长跑无界增长。
171
+ const _phasesCache = new Map();
172
+
173
+ /** 安全读取并(带缓存地)解析脚本 phases。失败/越界一律 []。 */
174
+ function readPhasesCached(scriptPath) {
175
+ if (!scriptPath) return [];
176
+ let real;
177
+ try { real = realpathSync(scriptPath); } catch { return []; }
178
+ if (!isInsideProjectsDir(real)) return [];
179
+
180
+ let mtimeMs, size;
181
+ try { ({ mtimeMs, size } = statSync(real)); } catch { return []; }
182
+
183
+ const hit = _phasesCache.get(real);
184
+ if (hit && hit.mtimeMs === mtimeMs && hit.size === size) return hit.phases;
185
+
186
+ let text = '';
187
+ try {
188
+ if (size <= MAX_SCRIPT_BYTES) {
189
+ text = readFileSync(real, 'utf-8');
190
+ } else {
191
+ const fd = openSync(real, 'r');
192
+ try {
193
+ const buf = Buffer.allocUnsafe(SCRIPT_HEAD_BYTES);
194
+ const n = readSync(fd, buf, 0, SCRIPT_HEAD_BYTES, 0);
195
+ text = buf.subarray(0, n).toString('utf-8');
196
+ } finally { closeSync(fd); }
197
+ }
198
+ } catch { return []; }
199
+
200
+ const phases = parsePhasesFromScript(text);
201
+ if (_phasesCache.size >= PHASES_CACHE_MAX) _phasesCache.clear();
202
+ _phasesCache.set(real, { mtimeMs, size, phases });
203
+ return phases;
204
+ }
205
+
206
+ /** 测试辅助:清空 phases 解析缓存。 */
207
+ export function __clearPhasesCacheForTests() {
208
+ _phasesCache.clear();
57
209
  }
58
210
 
59
211
  // 每文件增量解析缓存:filePath → { mtimeMs, size, offset, partial(Buffer), acc }。
@@ -194,7 +346,9 @@ export function deriveLiveJournal(runDir, runId) {
194
346
  const { done } = readResumeJournal(runDir);
195
347
  // sessionDir = runDir 上溯三级(runId → workflows → subagents → sessionDir)
196
348
  const sessionDir = dirname(dirname(dirname(runDir)));
197
- const workflowName = deriveWorkflowName(sessionDir, runId);
349
+ const { name: workflowName, scriptPath } = deriveWorkflowScript(sessionDir, runId);
350
+ // 运行中从生成脚本文本解析 meta.phases 填充阶段列(权威 <runId>.json 落盘后由完成快照接管)。
351
+ const phases = readPhasesCached(scriptPath);
198
352
 
199
353
  const agents = [];
200
354
  let totalTokens = 0, totalToolCalls = 0;
@@ -248,7 +402,7 @@ export function deriveLiveJournal(runDir, runId) {
248
402
  totalToolCalls,
249
403
  defaultModel: '',
250
404
  startTime,
251
- phases: [],
405
+ phases,
252
406
  agents,
253
407
  live: true,
254
408
  };
@@ -162,7 +162,10 @@ export function unwatchWorkflowDir(workflowsDir) {
162
162
  function _liveSignature(data) {
163
163
  if (!data) return 'none';
164
164
  const states = data.agents.map(a => `${a.agentId}:${a.state}:${a.tokens}:${a.toolCalls}`).join('|');
165
- return `${data.status}#${data.agentCount}#${data.totalTokens}#${data.totalToolCalls}#${states}`;
165
+ // 纳入 phases 摘要:edit-then-resume 改写脚本 meta.phases(agent 计数/态未变)时也能触发重播,
166
+ // 否则 readPhasesCached 已按 mtime/size 失效重解析了新 phases,却因签名不变被这里抑制广播。
167
+ const phases = (data.phases || []).map(p => p.title).join(',');
168
+ return `${data.status}#${data.agentCount}#${data.totalTokens}#${data.totalToolCalls}#${phases}#${states}`;
166
169
  }
167
170
 
168
171
  // runDir = <sessionDir>/subagents/workflows/<runId> → 上溯三级到 sessionDir,查权威快照是否已落盘
@@ -123,8 +123,8 @@ function askHook(req, res, parsedUrl, isLocal, deps) {
123
123
  }
124
124
 
125
125
  // TOCTOU 防御:占位 id 之前先注册 res.on('close'),否则 await runWaterfallHook 期间 client
126
- // abort 的 close 事件落空 → entry 5min。占位 set 提前到 await 之前防两条同 ms 并发
127
- // POST 的 do-while 都通过 collision check 后 set 互相覆盖(first res 永泄漏到 5min)。
126
+ // abort 的 close 事件落空 → entry 残到 HOOK_TIMEOUT(24h)。占位 set 提前到 await 之前防两条同 ms 并发
127
+ // POST 的 do-while 都通过 collision check 后 set 互相覆盖(first res 永泄漏到 HOOK_TIMEOUT)。
128
128
  const _placeholderEntry = { questions, res, timer: null, createdAt: Date.now(), shortPoll: shortPollMode };
129
129
  deps.pendingAskHooks.set(id, _placeholderEntry);
130
130
  deps.persistAskEntry(id, _placeholderEntry);
@@ -35,7 +35,7 @@ function upload(req, res, parsedUrl, isLocal, deps) {
35
35
  if (totalSize > MAX_UPLOAD) {
36
36
  aborted = true;
37
37
  res.writeHead(413, { 'Content-Type': 'application/json' });
38
- res.end(JSON.stringify({ error: 'File too large (max 50MB)' }));
38
+ res.end(JSON.stringify({ error: 'File too large (max 100MB)' }));
39
39
  req.destroy();
40
40
  return;
41
41
  }
@@ -3,8 +3,9 @@
3
3
  // GET /api/im/:platform/status — public; remote callers get only enabled+hasSecret+connection,
4
4
  // the local (admin) caller additionally gets plaintext secrets + process info.
5
5
  // POST /api/im/:platform/config — loopback-only; save creds, then drive the process manager
6
- // (enable→stop+spawn worker, disable→stop). Enabling requires a
7
- // non-empty allowlist (the worker runs with --dangerously-skip-permissions).
6
+ // (enable→stop+spawn worker, disable→stop). Allowlist is optional:
7
+ // enabling with an empty one warns (not blocked) since the worker
8
+ // runs with --dangerously-skip-permissions.
8
9
  // POST /api/im/:platform/test — loopback-only; validate creds (fetch an access token).
9
10
  // POST /api/im/:platform/process — loopback-only; {action:start|stop|restart} the detached worker.
10
11
  // GET /api/im/:platform/logs — resolve the worker's latest .jsonl (for the records popup).
@@ -115,6 +116,8 @@ function imConfigPost(req, res, parsedUrl, isLocal, deps) {
115
116
  res.end(JSON.stringify({ error: 'Invalid JSON' }));
116
117
  return;
117
118
  }
119
+ // 【已评审确认 / ACCEPTED】允许空白名单是有意的产品取舍,非缺陷——code review 请勿反复 flag。
120
+ // 缓解层:PreToolUse permissions.deny(perm-bridge/im-deny,独立于白名单)始终硬拦截 + 服务端审计日志。
118
121
  // 发送者白名单为非必填:启用时若白名单为空,不再硬拦截,而是允许保存(前端会弹安全警告)。
119
122
  // 安全提示:worker 以 --dangerously-skip-permissions 运行;白名单为空时运行期退化为
120
123
  // bind-first-conversation(im-bridge-core.js)——首个向机器人发消息的会话被绑定,该会话内任何人
package/server/server.js CHANGED
@@ -1982,7 +1982,7 @@ export function broadcastTurnEnd(sessionId = null, ts = Date.now()) {
1982
1982
  }
1983
1983
 
1984
1984
  // 流式状态 SSE 推送定时器:检测 streamingState 变化并广播给所有客户端。
1985
- // rising-edge → turn_end flush _observeStreamingTick 统一处理。
1985
+ // rising-edge → turn_end cancel(丢弃 pending,不 flush)由 _observeStreamingTick 统一处理。
1986
1986
  let _streamingStatusTimer = null;
1987
1987
  // 启动后 30s 的更新检查 timer 句柄。必须可清理:
1988
1988
  // - .unref() 防止它把事件循环 keep-alive 30s(测试进程靠 --test-force-exit 兜底是时序侥幸);
@@ -1995,7 +1995,7 @@ function startStreamingStatusTimer() {
1995
1995
  if (isSdkMode) return;
1996
1996
  const isActive = streamingState.active;
1997
1997
  const wasActive = _lastCliActive;
1998
- // 统一走 _observeStreamingTick:内部负责 rising-edge cancel(flush pending turn_end)+ 更新 _lastCliActive。
1998
+ // 统一走 _observeStreamingTick:内部负责 rising-edge cancel(丢弃 pending turn_end,不 flush)+ 更新 _lastCliActive。
1999
1999
  _observeStreamingTick(isActive, 'cli');
2000
2000
  const changed = wasActive !== isActive;
2001
2001
  if (changed || isActive) {