cc-viewer 1.6.301 → 1.6.303

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-DpIkVZv8.js"></script>
24
+ <script type="module" crossorigin src="/assets/index-BmKQfyia.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/findcc.js CHANGED
@@ -201,6 +201,20 @@ export function resolveNpmClaudePath() {
201
201
  return null;
202
202
  }
203
203
 
204
+ /**
205
+ * 从 which/where 的原始输出中挑出能直接 CreateProcess/exec 的候选行。
206
+ * Windows 的 `where` 会列出 PATH 中全部同名匹配——npm 全局安装时第一行往往是给
207
+ * git-bash 用的**无扩展名 sh shim**(#!/bin/sh 文本文件),其后是 .cmd/.ps1,都不是
208
+ * PE:node-pty/ConPTY 直接 spawn 会抛 "Cannot create process, error code: 193"
209
+ * (ERROR_BAD_EXE_FORMAT)。win32 只接受 .exe 行;POSIX 取第一行。
210
+ * 导出供单测;生产代码经 resolveNativePath 调用。
211
+ */
212
+ export function pickSpawnableLookupResult(rawOut, platform = process.platform) {
213
+ const lines = String(rawOut || '').split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
214
+ if (platform === 'win32') return lines.find((l) => l.toLowerCase().endsWith('.exe')) || null;
215
+ return lines[0] || null;
216
+ }
217
+
204
218
  export function resolveNativePath() {
205
219
  const globalRoot = getGlobalNodeModulesDir();
206
220
 
@@ -220,7 +234,8 @@ export function resolveNativePath() {
220
234
  for (const cmd of lookupCmds) {
221
235
  try {
222
236
  const rawOut = execSync(cmd, { encoding: 'utf-8', shell: true, env: process.env, windowsHide: true });
223
- const result = rawOut.split(/\r?\n/)[0].trim();
237
+ // win32 过滤掉 sh shim / .cmd / .ps1,只取 .exe(否则 ConPTY spawn 报 error 193)
238
+ const result = pickSpawnableLookupResult(rawOut);
224
239
  if (result && existsSync(result)) {
225
240
  // 只排除 .js 文件(老版本 npm 分发的 cli.js,需要 node 运行,
226
241
  // 由 resolveNpmClaudePath 处理)。Claude Code 2.x+ 的 npm 包内
@@ -249,6 +264,11 @@ export function resolveNativePath() {
249
264
  if (existsSync(p)) {
250
265
  return p;
251
266
  }
267
+ // Windows 原生安装器(install.ps1)落的是 claude.exe(如 ~/.local/bin/claude.exe),
268
+ // 无扩展名候选在 win32 上永远 miss,这里补查 .exe 变体。
269
+ if (process.platform === 'win32' && existsSync(p + '.exe')) {
270
+ return p + '.exe';
271
+ }
252
272
  }
253
273
 
254
274
  // 4. 兜底:wrapper 包的 bin/claude(.exe)(可能是 postinstall 后的真实二进制,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.301",
3
+ "version": "1.6.303",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
@@ -0,0 +1,255 @@
1
+ /**
2
+ * PTY → WS 洪泛限流器(Windows ConPTY 洪泛防卡死,server 侧字节率上限)。
3
+ *
4
+ * 背景:pty-manager 的 setImmediate 合帧已把"消息数"压到每 tick 一条,但**字节量
5
+ * 没有上限**——ConPTY 把 TUI 全屏对话框/重绘转译成 macOS forkpty 10~100 倍的字节流
6
+ * (如 /theme 选择器开/关、全屏 TUI 重绘),ws-backpressure gate 只在慢网络
7
+ * (bufferedAmount > 1MB)介入,快 LAN 上洪泛字节全量到达前端,xterm 逐帧解析渲染
8
+ * 稠密 SGR+CJK 重绘把主线程打满。本器在发送侧限流:
9
+ *
10
+ * - 直通态:leading-edge 微合并——空窗期首 chunk 立即 send(回显零延迟)并开
11
+ * ptCoalesceMs(默认 16ms)窗,同窗后续 chunk 并入 ptBuffer、到点合为一条 send
12
+ * (上限 2 条/窗 = 1000ms÷16ms×2 ≈125 msg/s)。低于洪泛阈值的持续小 chunk 流(/plugins 菜单导航
13
+ * 等 ConPTY 重绘)每 chunk 单发会打出数百条 ws 消息/秒,客户端逐条 MessageEvent
14
+ * 分发 + JSON.parse + xterm 主线程解析,**消息数风暴**即可锁死页面——字节率
15
+ * 限流(下方限流态)封不住这个维度。字节率仍按固定 flushMs 桶统计,
16
+ * 当前桶累计超 floodThresholdBytesPerWin → 进入限流态(ptBuffer 按序折入 pending)。
17
+ * 打字回显 / 正常 token 流是稀疏 chunk,每条都走 leading 立即发,不受影响。
18
+ * - 限流态:chunk 剥掉自带的 DEC 2026 标记后追加进 pending(pending 内部因此
19
+ * **绝无 2026 标记**,截断永不切坏配对);每 flushMs 把 pending 用单对
20
+ * SYNC_BEGIN/END 重新包裹成**一条** send 发出并清空(无论下游是否跳发,
21
+ * flush 后必清——下游 bpGate 跳发时由其 data-resync 快照对齐,不在这里重试)。
22
+ * 发送前若 pending 超 flushBudgetBytes → findSafeSliceStart 截到尾部预算内:
23
+ * 这是真正的速率上限(≈ flushBudgetBytes / flushMs ≈ 1.9MB/s),与前端
24
+ * TerminalWriteQueue 32KB/帧的消化速率同量级,洪泛期客户端入速 ≈ 出速不积压。
25
+ * pending 超 pendingCap → 同样截到 trimTo(flush 间隔内的内存上界)。中间全屏
26
+ * 重绘帧 last-wins 可丢,ConPTY 重绘流自愈。findSafeSliceStart 只保 ANSI 边界、
27
+ * 不保 2026 配对——配平靠剥标记+重包裹兜底,截断切坏配对会让 xterm 卡在同步
28
+ * 缓冲态(黑屏)。scratch PTY 的 chunk 本就无 2026 标记,剥除为 no-op、重包裹
29
+ * 无害(不支持的终端忽略该序列),两条路径共用同一实现。
30
+ * - 回落:连续 fallbackWins 个桶低于阈值 → flush 残余后回直通态。
31
+ * - reset():清 pending + timer + 回直通态。bpGate onBehind/onResume 时必须调用:
32
+ * data-resync 快照(getOutputBuffer)是唯一真相源,残留 pending 若不清会把早于
33
+ * 快照的旧字节回灌导致画面回退。
34
+ *
35
+ * 纯逻辑、时钟可注入(setTimer/clearTimer/now),便于单测。仿 ws-backpressure.js 惯例。
36
+ */
37
+
38
+ const SYNC_BEGIN = '\x1b[?2026h';
39
+ const SYNC_END = '\x1b[?2026l';
40
+ // 全局替换两种标记。pty-manager flushBatch 只在首尾各加一对,但限流态 pending 由
41
+ // 多个 chunk 拼接而成,内部会出现多对交替——统一剥净再整体包一对。
42
+ const SYNC_MARKS_RE = /\x1b\[\?2026[hl]/g;
43
+
44
+ // 默认常量可经 CCV_FLOOD_* 环境变量覆盖(仿 CCV_FORCE_POLL 先例),便于 Windows
45
+ // 实机排障时调参而不改源码。严格十进制白名单:parseInt 遇非数字字符即截停——
46
+ // '1e9' 解析成 1、'0x10' 解析成 0,静默生效远比回落默认值危险,非纯数字一律回落。
47
+ // 位数上限 15(< Number.MAX_SAFE_INTEGER):超长数字串 parseInt 溢出为 Infinity 会
48
+ // 穿透 v>0 判断,setTimeout(fn, Infinity) 被 Node 钳到 1ms——33ms 桶宽静默变 1ms。
49
+ // 导出供 server.js 等复用(knob 解析逻辑收敛在此,不再各处内联)。
50
+ export function envInt(name, fallback) {
51
+ const s = (process.env[name] ?? '').trim();
52
+ if (!/^\d{1,15}$/.test(s)) return fallback;
53
+ const v = parseInt(s, 10);
54
+ return v > 0 ? v : fallback;
55
+ }
56
+
57
+ // 同 envInt 但接受 0(0 = 关闭该功能的逃生口,envInt 的 v>0 会把 0 误回落默认值)。
58
+ export function envIntAllowZero(name, fallback) {
59
+ const s = (process.env[name] ?? '').trim();
60
+ if (!/^\d{1,15}$/.test(s)) return fallback;
61
+ return parseInt(s, 10);
62
+ }
63
+
64
+ const DEFAULT_FLUSH_MS = envInt('CCV_FLOOD_FLUSH_MS', 33); // 限流态合并窗口 = 字节率统计桶宽
65
+ const DEFAULT_FLOOD_THRESHOLD = envInt('CCV_FLOOD_THRESHOLD', 8 * 1024); // 单桶超 8KB(≈256KB/s)判定洪泛
66
+ const DEFAULT_FALLBACK_WINS = envInt('CCV_FLOOD_FALLBACK_WINS', 3); // 连续 N 个低于阈值的桶才回直通(迟滞)
67
+ const DEFAULT_PENDING_CAP = envInt('CCV_FLOOD_PENDING_CAP', 256 * 1024); // 限流态 pending 上限(flush 间隔内的内存上界)
68
+ const DEFAULT_TRIM_TO = envInt('CCV_FLOOD_TRIM_TO', 128 * 1024); // pendingCap 截断后保留的尾部量
69
+ // 单次 flush 发送预算 = 真速率上限:64KB / 33ms ≈ 1.9MB/s,与前端 32KB/帧消化速率同量级
70
+ const DEFAULT_FLUSH_BUDGET = envInt('CCV_FLOOD_FLUSH_BUDGET', 64 * 1024);
71
+ // 直通态微合并窗口:低于洪泛阈值的持续小 chunk 流(如 /plugins 菜单导航的 ConPTY 重绘)
72
+ // 每 chunk 单发会打出每秒数百条 ws 消息——客户端每条都付 MessageEvent 分发 + JSON.parse +
73
+ // xterm 主线程同步解析,**消息数风暴**(非字节率)即可锁死页面(xterm.js#3368)。
74
+ // leading-edge 立即发(回显零延迟)+ 同窗后续合并 trailing 一条
75
+ // → 上限 2 条/窗 ≈125 msg/s(1000ms ÷ 16ms × 2 条)。0 = 禁用。
76
+ const DEFAULT_PT_COALESCE_MS = envIntAllowZero('CCV_FLOOD_PT_COALESCE_MS', 16);
77
+
78
+ /**
79
+ * @param {object} opts
80
+ * @param {(data: string) => void} opts.send - 实际发送回调(调用方在内部接 bpGate + ws.send)
81
+ * @param {(buf: string, rawStart: number) => number} opts.findSafeSliceStart - ANSI 安全截断(pty-manager 导出)
82
+ * @param {(buffered: number) => void} [opts.onFloodStart] - 进入限流态(observability 埋点)
83
+ * @param {() => void} [opts.onFloodEnd] - 回落直通态
84
+ * @param {number} [opts.flushMs]
85
+ * @param {number} [opts.floodThresholdBytesPerWin]
86
+ * @param {number} [opts.fallbackWins]
87
+ * @param {number} [opts.pendingCap]
88
+ * @param {number} [opts.trimTo]
89
+ * @param {number} [opts.flushBudgetBytes]
90
+ * @param {number} [opts.ptCoalesceMs] - 直通态微合并窗口(0 = 禁用,每 chunk 单发)
91
+ * @param {(fn: Function, ms: number) => any} [opts.setTimer] - 测试注入
92
+ * @param {(t: any) => void} [opts.clearTimer] - 测试注入
93
+ * @returns {{ offer: (chunk: string) => void, reset: () => void, dispose: () => void, isFlooding: () => boolean }}
94
+ */
95
+ export function createFloodCoalescer({
96
+ send,
97
+ findSafeSliceStart,
98
+ onFloodStart,
99
+ onFloodEnd,
100
+ flushMs = DEFAULT_FLUSH_MS,
101
+ floodThresholdBytesPerWin = DEFAULT_FLOOD_THRESHOLD,
102
+ fallbackWins = DEFAULT_FALLBACK_WINS,
103
+ pendingCap = DEFAULT_PENDING_CAP,
104
+ trimTo = DEFAULT_TRIM_TO,
105
+ flushBudgetBytes = DEFAULT_FLUSH_BUDGET,
106
+ ptCoalesceMs = DEFAULT_PT_COALESCE_MS,
107
+ setTimer = setTimeout,
108
+ clearTimer = clearTimeout,
109
+ }) {
110
+ let flooding = false;
111
+ let pending = '';
112
+ let winBytes = 0; // 当前桶累计字节(直通态由 offer 累计,限流态由 flush 结算)
113
+ let calmWins = 0; // 连续低于阈值的桶数
114
+ let flushTimer = null; // 限流态周期 flush;直通态下亦作为桶边界 timer(见 offer)
115
+ let ptBuffer = ''; // 直通态微合并缓冲(窗口开启期间到达的后续 chunk)
116
+ let ptTimer = null; // 直通态微合并窗口 timer(16ms),与 flushTimer(33ms 字节桶)并存、职责正交
117
+ let disposed = false;
118
+
119
+ const stopTimer = () => {
120
+ if (flushTimer) {
121
+ clearTimer(flushTimer);
122
+ flushTimer = null;
123
+ }
124
+ };
125
+
126
+ const stopPtTimer = () => {
127
+ if (ptTimer) {
128
+ clearTimer(ptTimer);
129
+ ptTimer = null;
130
+ }
131
+ };
132
+
133
+ // 微合并窗口到点:缓冲非空则一条发出,窗口关闭(不自动续约——下一 chunk 重新 leading 立即发)。
134
+ // ptBuffer 不剥 SYNC 标记:terminal 路径每 chunk 经 pty-manager flushBatch 已自配平(拼接守恒),
135
+ // scratch 路径 chunk 本就无标记(拼接平凡守恒);只有洪泛路径(有截断)才需要剥。
136
+ const onPtFlush = () => {
137
+ ptTimer = null;
138
+ if (disposed || flooding || !ptBuffer) return;
139
+ const out = ptBuffer;
140
+ ptBuffer = '';
141
+ try { send(out); } catch { }
142
+ };
143
+
144
+ // 直通态的桶边界:到点清零计数。无流量时 timer 不存在,零常驻开销。
145
+ const armPassthroughWindow = () => {
146
+ if (flushTimer) return;
147
+ flushTimer = setTimer(() => {
148
+ flushTimer = null;
149
+ winBytes = 0;
150
+ }, flushMs);
151
+ flushTimer.unref?.();
152
+ };
153
+
154
+ const flushPending = () => {
155
+ if (!pending) return;
156
+ // 单次 flush 发送预算 = 真正的速率上限:超预算截到尾部(last-wins),
157
+ // 保证洪泛期送达客户端的字节率 ≤ flushBudgetBytes/flushMs,与前端消化速率同量级。
158
+ if (pending.length > flushBudgetBytes) {
159
+ const rawStart = pending.length - flushBudgetBytes;
160
+ pending = pending.slice(findSafeSliceStart(pending, rawStart));
161
+ }
162
+ const merged = SYNC_BEGIN + pending + SYNC_END;
163
+ pending = '';
164
+ try { send(merged); } catch { }
165
+ };
166
+
167
+ const onFloodTick = () => {
168
+ flushTimer = null;
169
+ if (disposed || !flooding) return;
170
+ // 本桶结算:低于阈值累计 calm 桶数,连续 fallbackWins 个即回落
171
+ if (winBytes <= floodThresholdBytesPerWin) {
172
+ calmWins++;
173
+ } else {
174
+ calmWins = 0;
175
+ }
176
+ winBytes = 0;
177
+ flushPending();
178
+ if (calmWins >= fallbackWins) {
179
+ flooding = false;
180
+ calmWins = 0;
181
+ try { onFloodEnd?.(); } catch { }
182
+ return; // 不再续约 timer,回直通
183
+ }
184
+ flushTimer = setTimer(onFloodTick, flushMs);
185
+ flushTimer.unref?.();
186
+ };
187
+
188
+ return {
189
+ /** 每条 PTY chunk 调用。直通态立即 send;限流态进 pending 等周期 flush。 */
190
+ offer(chunk) {
191
+ if (disposed || !chunk) return;
192
+ winBytes += chunk.length; // 缓冲与直发都全量计账:微合并不致盲洪泛判定
193
+ if (!flooding) {
194
+ if (winBytes > floodThresholdBytesPerWin) {
195
+ // 进入限流态:当前 chunk 是压垮桶的那条,连同微合并缓冲中未发的旧 chunk
196
+ // 按序一并纳入 pending(已 leading 发出的部分不回收——量级在阈值内)。
197
+ // 注意 stopTimer 只清 flushTimer,ptTimer 须显式清,否则残留窗口会在洪泛
198
+ // 期间触发 onPtFlush(虽有 flooding 守卫兜底,仍以显式清为准)。
199
+ flooding = true;
200
+ calmWins = 0;
201
+ stopTimer();
202
+ stopPtTimer();
203
+ pending = (ptBuffer + chunk).replace(SYNC_MARKS_RE, '');
204
+ ptBuffer = '';
205
+ flushTimer = setTimer(onFloodTick, flushMs);
206
+ flushTimer.unref?.();
207
+ try { onFloodStart?.(winBytes); } catch { }
208
+ return;
209
+ }
210
+ armPassthroughWindow();
211
+ if (ptCoalesceMs > 0) {
212
+ // 微合并:窗口开启(ptTimer 在跑)→ 追加缓冲不发;窗口关闭 → leading 立即发
213
+ // (单次回显零延迟)并开窗。上限 2 条/窗(leading + trailing flush)。
214
+ if (ptTimer) {
215
+ ptBuffer += chunk;
216
+ return;
217
+ }
218
+ ptTimer = setTimer(onPtFlush, ptCoalesceMs);
219
+ ptTimer.unref?.();
220
+ }
221
+ try { send(chunk); } catch { }
222
+ return;
223
+ }
224
+ pending += chunk.replace(SYNC_MARKS_RE, '');
225
+ // 单 chunk 可超 cap:pty-manager 每 tick 合帧,一个 tick(如 /resume 重放)可达数百 KB,
226
+ // 故该分支并非不可达——它是 flush 间隔内的内存上界,与 flushPending 的速率预算各司其职。
227
+ if (pending.length > pendingCap) {
228
+ const rawStart = pending.length - trimTo;
229
+ const safeStart = findSafeSliceStart(pending, rawStart);
230
+ pending = pending.slice(safeStart);
231
+ }
232
+ },
233
+ /** bpGate onBehind/onResume 时调用:resync 快照是唯一真相源,清掉旧 pending/ptBuffer 防回灌。 */
234
+ reset() {
235
+ stopTimer();
236
+ stopPtTimer();
237
+ pending = '';
238
+ ptBuffer = '';
239
+ winBytes = 0;
240
+ calmWins = 0;
241
+ flooding = false;
242
+ },
243
+ isFlooding() {
244
+ return flooding;
245
+ },
246
+ /** ws close 时调用,终态。 */
247
+ dispose() {
248
+ disposed = true;
249
+ stopTimer();
250
+ stopPtTimer();
251
+ pending = '';
252
+ ptBuffer = '';
253
+ },
254
+ };
255
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * resync 重绘 nudge 冷却门(防 behind→resume 死循环的退避器)。
3
+ *
4
+ * 背景:ws-backpressure onResume 在发 data-resync 快照后会做一次重绘 nudge
5
+ * (POSIX SIGWINCH / Windows resize 抖动),让 claude TUI 全屏重绘以免画面停在
6
+ * 快照静止态。但 nudge 本身让 ConPTY 再吐 1~2 次全屏重绘 = 新洪泛燃料:客户端
7
+ * 仍慢 → bufferedAmount 再越线 → behind → resume → 再 nudge → 死循环,客户端
8
+ * 每轮 terminal.reset + 重放快照,表现为永久冻结。
9
+ *
10
+ * 语义:快照每次 resume 仍无条件发(修复 behind 期间被跳发的数据,不能省);
11
+ * 只有 nudge 走冷却——紧循环中 PTY 输出仍在流动,"画面停在快照"的风险不存在,
12
+ * 该风险只在 resume 稀疏时成立,而稀疏 resume 必然过冷却期、照常 nudge。
13
+ *
14
+ * 纯逻辑、时钟可注入(now),便于单测。仿 pty-flood-coalescer.js 惯例。
15
+ *
16
+ * @param {object} [opts]
17
+ * @param {number} [opts.cooldownMs=3000] - 两次 nudge 最小间隔;0 = 不冷却(恒放行,逃生口)
18
+ * @param {() => number} [opts.now=Date.now] - 测试注入
19
+ * @returns {{ shouldNudge: () => boolean }}
20
+ */
21
+ export function createResyncNudgeGate({ cooldownMs = 3000, now = Date.now } = {}) {
22
+ let lastNudgeAt = -Infinity; // 首次必放行
23
+ return {
24
+ /** resume 时调用:放行则记账并返回 true,冷却期内返回 false(调用方跳过 nudge)。 */
25
+ shouldNudge() {
26
+ if (cooldownMs <= 0) return true;
27
+ const t = now();
28
+ if (t - lastNudgeAt < cooldownMs) return false;
29
+ lastNudgeAt = t;
30
+ return true;
31
+ },
32
+ };
33
+ }
@@ -47,8 +47,11 @@ async function getPty() {
47
47
  * 在 outputBuffer 截断时,找到安全的截断位置,
48
48
  * 避免从 ANSI 转义序列中间开始导致终端状态紊乱。
49
49
  * 策略:从截断点向后扫描,跳过可能被截断的不完整转义序列。
50
+ * 注意:只保 ANSI 序列边界,不保 DEC 2026 同步标记的配对——
51
+ * 跨 2026 块截断的配平由调用方负责(见 lib/pty-flood-coalescer.js)。
52
+ * export 供洪泛限流器复用同一截断语义。
50
53
  */
51
- function findSafeSliceStart(buf, rawStart) {
54
+ export function findSafeSliceStart(buf, rawStart) {
52
55
  // 从 rawStart 开始,向后最多扫描 64 字节寻找安全起点
53
56
  const scanLimit = Math.min(rawStart + 64, buf.length);
54
57
  let i = rawStart;
@@ -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 };
@@ -560,8 +560,12 @@ function revealFile(req, res, parsedUrl, isLocal, deps) {
560
560
  if (plat === 'darwin') {
561
561
  execFile('open', ['-R', fullPath], () => {});
562
562
  } else if (plat === 'win32') {
563
- // explorer /select,<path> 必须合到一个 arg;分两个会让含空格/中文路径 escape 失败。
564
- spawn('explorer.exe', [`/select,${fullPath}`], { shell: false, windowsHide: true });
563
+ // explorer /select 的规范形式是 /select,"<path>"(仅路径部分加引号、整体一个 arg)。
564
+ // 必须 windowsVerbatimArguments 透传:否则 Node 对含空格 arg 整体加引号生成
565
+ // explorer.exe "/select,C:\My Proj\x.txt",explorer 解析不了 → 功能失效。
566
+ // Windows 文件名不允许 ",且不经 cmd.exe,无元字符注入面。
567
+ const child = spawn('explorer.exe', [`/select,"${fullPath}"`], { windowsVerbatimArguments: true, windowsHide: true });
568
+ child.on('error', () => {}); // 防 async ENOENT 变 uncaughtException 砸进程
565
569
  } else {
566
570
  execFile('xdg-open', [dirname(fullPath)], () => {});
567
571
  }
@@ -18,6 +18,14 @@ function stripImConfigs(obj) {
18
18
  if (obj) for (const id of listPlatforms()) delete obj[id];
19
19
  }
20
20
 
21
+ // /theme 选择器特征:选项文案高特异、不太可能出现在普通生成输出里(ESC 兜底的门控签名)
22
+ const THEME_PICKER_RE = /Auto \(match terminal\)|colorblind-friendly/;
23
+
24
+ // 并发切主题防重入:双端同时 POST themeColor 时只允许一条 /theme 同步链路在途,
25
+ // 防止双监听器 + 双 /theme 注入 + 双 ESC(后到的 POST 仅落盘偏好,跳过 PTY 同步)。
26
+ let _themeSyncInFlight = false;
27
+ export function _resetThemeSyncForTests() { _themeSyncInFlight = false; }
28
+
21
29
  function preferencesGet(req, res, parsedUrl, isLocal, deps) {
22
30
  let prefs = {};
23
31
  try { if (existsSync(deps.getPrefsFile())) prefs = JSON.parse(readFileSync(deps.getPrefsFile(), 'utf-8')); } catch { }
@@ -84,11 +92,18 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
84
92
  // preferences.json 可能携带 auth 的 base64 密码 —— 与 lib/auth.js writePrefs 一致地
85
93
  // 重申 0600,避免该路径(无 mode/不 chmod)把密码文件留成默认 umask 的可读权限。
86
94
  try { chmodSync(prefsFile, 0o600); } catch { /* best-effort; non-POSIX or race */ }
87
- // 主题切换时同步到 Claude Code CLI:发 /theme,监听输出验证结果,不对就再发一次
88
- if (incoming.themeColor && deps.writeToPty && deps.onPtyData) {
95
+ // 主题切换时同步到 Claude Code CLI:发 /theme,监听输出验证结果。
96
+ // 现代 CLI(≥2.x)的 /theme 是交互式选择器(args 被忽略),注入后对话框可能
97
+ // 残留在终端:
98
+ // - mismatch 时**不再重发** /theme —— toggle 语义已不存在,重发只会把选择器
99
+ // 再次打开,把终端困进"确认-重开"循环(Windows ConPTY 下每轮全屏重绘洪泛)。
100
+ // - 5s 超时若 buf 检出选择器特征(选项文案,见 THEME_PICKER_RE),补发一次
101
+ // ESC 关闭残留对话框。无特征绝不发 ESC —— CLI 正在流式生成时 /theme 只是被
102
+ // 排队、对话框未开,误发 ESC 会 interrupt 用户正在跑的任务(宁漏关不误发)。
103
+ if (incoming.themeColor && deps.writeToPty && deps.onPtyData && !_themeSyncInFlight) {
104
+ _themeSyncInFlight = true;
89
105
  const target = incoming.themeColor === 'light' ? 'light' : 'dark';
90
106
  let buf = '';
91
- let retried = false;
92
107
  const removeListener = deps.onPtyData((data) => {
93
108
  buf += data;
94
109
  if (buf.length > 4096) buf = buf.slice(-2048); // 限制 buf 大小
@@ -97,15 +112,20 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
97
112
  if (match) {
98
113
  removeListener();
99
114
  clearTimeout(timeout);
100
- if (match[1] !== target && !retried) {
101
- // 结果与目标不一致,再 toggle 一次
102
- retried = true;
103
- try { deps.writeToPty('/theme\r'); } catch {}
115
+ _themeSyncInFlight = false;
116
+ if (match[1] !== target) {
117
+ console.warn(`[preferences] CLI theme sync mismatch: got ${match[1]}, wanted ${target} (no retry; modern /theme is an interactive picker)`);
104
118
  }
105
119
  }
106
120
  });
107
- // 5 秒超时,避免监听器泄漏
108
- const timeout = setTimeout(() => { removeListener(); }, 5000);
121
+ // 5 秒超时,避免监听器泄漏;检出选择器残留时 ESC 兜底关闭
122
+ const timeout = setTimeout(() => {
123
+ removeListener();
124
+ _themeSyncInFlight = false;
125
+ if (THEME_PICKER_RE.test(buf)) {
126
+ try { deps.writeToPty('\x1b'); } catch {}
127
+ }
128
+ }, 5000);
109
129
  try { deps.writeToPty('/theme\r'); } catch {}
110
130
  }
111
131
  // 回显里也剥离 auth/authByProject(含 base64 密码) —— GET 已剥离,POST 回显同样不能漏给