cc-viewer 1.6.268 → 1.6.270

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.
@@ -54,7 +54,17 @@ export function prepareEmbeddedShellSpawn(shell, env, options = {}) {
54
54
  'if [[ "$__ccv_original_zdotdir" != "$__ccv_wrapper_zdotdir" && -r "$__ccv_original_zdotdir/.zshenv" ]]; then',
55
55
  ' source "$__ccv_original_zdotdir/.zshenv"',
56
56
  'fi',
57
- 'export CCV_EFFECTIVE_ZDOTDIR="${ZDOTDIR:-$__ccv_original_zdotdir}"',
57
+ '# ZDOTDIR was set to the wrapper dir by prepareEmbeddedShellSpawn before zsh',
58
+ "# started, so the previous form `${ZDOTDIR:-$__ccv_original_zdotdir}` always",
59
+ "# selected the wrapper dir; the later .zshrc then compared wrapper==wrapper",
60
+ "# and silently skipped sourcing the user's real ~/.zshrc. Compare against",
61
+ '# __ccv_wrapper_zdotdir instead: only treat the current ZDOTDIR as',
62
+ "# user-overridden when the user's .zshenv (sourced above) actually changed it.",
63
+ 'if [[ "$ZDOTDIR" != "$__ccv_wrapper_zdotdir" ]]; then',
64
+ ' export CCV_EFFECTIVE_ZDOTDIR="$ZDOTDIR"',
65
+ 'else',
66
+ ' export CCV_EFFECTIVE_ZDOTDIR="$__ccv_original_zdotdir"',
67
+ 'fi',
58
68
  'export ZDOTDIR="$__ccv_wrapper_zdotdir"',
59
69
  'unset __ccv_original_zdotdir __ccv_wrapper_zdotdir',
60
70
  '',
@@ -10,11 +10,13 @@
10
10
  // Adding a 6th event meant editing 5+ files and any miss silently dropped audio
11
11
  //(). All consumers now import from here.
12
12
 
13
+ // 注:timeoutWarning5min / timeoutWarning60s 已删除。AskUserQuestion 实质 24h 无超时后
14
+ // 倒计时不再渲染(AskTimeoutCountdown.jsx isInfiniteTimeout → null),剩余时间预警事件失去意义。
15
+ // 老用户 preferences.json 含这两个 key 由 lib/approval-modal-prefs.js _filterEvents 白名单
16
+ // 自动 strip,零迁移工作量。孤儿 audio 文件留待 cleanup CLI(backlog)。
13
17
  export const EVENT_KEYS = [
14
18
  'planApproval',
15
19
  'askQuestion',
16
- 'timeoutWarning5min',
17
- 'timeoutWarning60s',
18
20
  'turnEnd',
19
21
  ];
20
22
 
@@ -26,7 +28,5 @@ export const EVENT_KEYS = [
26
28
  export const DEFAULT_BINDINGS = Object.freeze({
27
29
  planApproval: 'default',
28
30
  askQuestion: 'default',
29
- timeoutWarning5min: 'default',
30
- timeoutWarning60s: 'default',
31
31
  turnEnd: null,
32
32
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.268",
3
+ "version": "1.6.270",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -10,6 +10,8 @@ import { execFile, exec, spawn } from 'node:child_process';
10
10
  import { promisify } from 'node:util';
11
11
  import { Worker } from 'node:worker_threads';
12
12
  import { isPathContained, ERROR_STATUS_MAP, validateImportDir } from './lib/file-api.js';
13
+ import { loadAskStore, setEntry as askStoreSetEntry, deleteEntry as askStoreDeleteEntry, pruneStale as askStorePruneStale, markAnswered as askStoreMarkAnswered, markCancelled as askStoreMarkCancelled, consumeIfFinal as askStoreConsumeIfFinal } from './lib/ask-store.js';
14
+ import { ASK_TIMEOUT_MS } from './lib/ask-constants.js';
13
15
  import { isReadAllowed, reasonToStatus, bumpWorkspacesVersion } from './lib/file-access-policy.js';
14
16
 
15
17
  const execFileAsync = promisify(execFile);
@@ -140,7 +142,66 @@ let _workspaceLaunched = false; // 工作区是否已经启动了会话
140
142
  // Map supports concurrent ask requests (sub-agents / teammates) so a stale unanswered
141
143
  // ask never blocks the next one. Keyed by server-generated id.
142
144
  const pendingAskHooks = new Map(); // Map<id, { questions, res, timer, createdAt }>
143
- const ASK_HOOK_MAP_MAX = 50;
145
+ // 1000 远超任何合理并发场景;保留 LRU 仅作为防恶意/bug 撑爆内存的兜底,
146
+ // 不再用于"正常使用时的容量上限"——用户的 ask 不应该因为 50 个 cap 被强行 evict。
147
+ const ASK_HOOK_MAP_MAX = 1000;
148
+ // 单一来源的"无超时"实质上限——延伸至 24h 兼顾防 entry 泄漏;
149
+ // 任何引用此值的地方(HOOK_TIMEOUT / REPLAY_HOOK_TIMEOUT / 广播 timeoutMs)都从这里取。
150
+ // 实际常量定义在 lib/ask-constants.js(hook 路径 + SDK 路径同源)。
151
+ const ASK_HOOK_TIMEOUT_MS = ASK_TIMEOUT_MS;
152
+
153
+ // 内存 Map 是权威源;ask-store 是镜像(best-effort)。崩溃时只丢"未落盘窗口"内的最新一次变更。
154
+ // 任何 pendingAskHooks.set(...) 后必须调 _persistAskEntry;.delete(...) 后必须调 _persistAskDelete。
155
+ function _persistAskEntry(id, entry) {
156
+ if (!entry || !Array.isArray(entry.questions)) return;
157
+ // 异步触发:磁盘 IO 不阻塞 ask 主流程(落盘失败不影响业务)
158
+ setImmediate(() => {
159
+ try { askStoreSetEntry(id, { questions: entry.questions, createdAt: entry.createdAt }); } catch {}
160
+ });
161
+ }
162
+ function _persistAskDelete(id) {
163
+ setImmediate(() => {
164
+ try { askStoreDeleteEntry(id); } catch {}
165
+ });
166
+ }
167
+
168
+ // Phase 3: short-poll listener registry. Hangs GET /api/ask-hook/:id/result responses
169
+ // until either an answer/cancel arrives or wait ms elapses (then 204).
170
+ const shortPollListeners = new Map(); // id -> Set<{ res, tid, finished }>
171
+
172
+ function _notifyShortPollAnswer(id, answers) {
173
+ const set = shortPollListeners.get(id);
174
+ if (!set) return;
175
+ for (const listener of set) {
176
+ if (listener.finished) continue;
177
+ listener.finished = true;
178
+ clearTimeout(listener.tid);
179
+ try {
180
+ if (!listener.res.headersSent) {
181
+ listener.res.writeHead(200, { 'Content-Type': 'application/json' });
182
+ listener.res.end(JSON.stringify({ answers }));
183
+ }
184
+ } catch {}
185
+ }
186
+ shortPollListeners.delete(id);
187
+ }
188
+
189
+ function _notifyShortPollCancel(id, reason) {
190
+ const set = shortPollListeners.get(id);
191
+ if (!set) return;
192
+ for (const listener of set) {
193
+ if (listener.finished) continue;
194
+ listener.finished = true;
195
+ clearTimeout(listener.tid);
196
+ try {
197
+ if (!listener.res.headersSent) {
198
+ listener.res.writeHead(200, { 'Content-Type': 'application/json' });
199
+ listener.res.end(JSON.stringify({ cancelled: true, reason: reason || '' }));
200
+ }
201
+ } catch {}
202
+ }
203
+ shortPollListeners.delete(id);
204
+ }
144
205
 
145
206
  // Permission hook bridge state (for PreToolUse permission approval)
146
207
  // Map supports concurrent sub-agent/teammate requests (keyed by request id)
@@ -1690,7 +1751,18 @@ async function handleRequest(req, res) {
1690
1751
  const entries = readdirSync(targetDir, { withFileTypes: true });
1691
1752
  const items = entries
1692
1753
  .filter(e => !IGNORED_PATTERNS.has(e.name))
1693
- .map(e => ({ name: e.name, type: e.isDirectory() ? 'directory' : 'file' }))
1754
+ .map(e => {
1755
+ // Dirent.isDirectory() 不解引用 symlink —— 对指向目录的 symlink 也返回 false。
1756
+ // 需要对 symlink 单独 statSync(follow link)才能拿到真实类型,否则前端会把
1757
+ // symlink-to-dir 当成文件渲染,不可展开。断链时 fallback 到 file,避免单个
1758
+ // 坏链接让整个目录返回 404。
1759
+ let type = e.isDirectory() ? 'directory' : 'file';
1760
+ if (e.isSymbolicLink()) {
1761
+ try { type = statSync(join(targetDir, e.name)).isDirectory() ? 'directory' : 'file'; }
1762
+ catch { type = 'file'; }
1763
+ }
1764
+ return { name: e.name, type };
1765
+ })
1694
1766
  .sort((a, b) => {
1695
1767
  if (a.type !== b.type) return a.type === 'directory' ? -1 : 1;
1696
1768
  return a.name.localeCompare(b.name);
@@ -2644,6 +2716,35 @@ async function handleRequest(req, res) {
2644
2716
  return;
2645
2717
  }
2646
2718
 
2719
+ // Ask 持久化恢复:浏览器 WS reconnect 后用来"补"那些 ws 断开期间到达的 pending ask
2720
+ // (server.js 已有 ws reconnect replay 路径,但该路径仅 broadcast 内存 Map 中存活的 entry;
2721
+ // server 自身重启过会丢内存态——此端点从磁盘 ask-store 镜像恢复 disk superset 列表)。
2722
+ if (url === '/api/pending-asks' && method === 'GET') {
2723
+ try {
2724
+ // 优先内存 Map(含活跃 res / timer 的真实状态),用 createdAt 排序;
2725
+ // 再 union disk(hydrate 已死 entry 或本进程未持有的孤儿 entry,用于恢复 UI 列表,
2726
+ // 答案投递仍由 WS ask-hook-answer 路径走 server 端 first-write-wins)。
2727
+ const memEntries = [...pendingAskHooks.entries()].map(([id, e]) => ({
2728
+ id, questions: e.questions, createdAt: e.createdAt, source: 'memory',
2729
+ }));
2730
+ const memIds = new Set(memEntries.map(e => e.id));
2731
+ const diskAll = loadAskStore();
2732
+ // 只暴露 status === 'pending' 的 disk entry —— markAnswered 创建的 questions=[] 终态占位
2733
+ // 也会被 loadAskStore 返回,但它不是真"待回答",前端 inject 会渲染空 ghost ask。
2734
+ // 另外过滤 questions 数组非空(防御性)。
2735
+ const diskOnly = Object.values(diskAll)
2736
+ .filter(e => !memIds.has(e.id) && e.status === 'pending' && Array.isArray(e.questions) && e.questions.length > 0)
2737
+ .map(e => ({ id: e.id, questions: e.questions, createdAt: e.createdAt, source: 'disk' }));
2738
+ const all = [...memEntries, ...diskOnly].sort((a, b) => a.createdAt - b.createdAt);
2739
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2740
+ res.end(JSON.stringify({ pendingAsks: all, askProtocolVersion: 1 }));
2741
+ } catch (err) {
2742
+ res.writeHead(500, { 'Content-Type': 'application/json' });
2743
+ res.end(JSON.stringify({ error: 'failed to read pending asks', detail: String(err?.message || err) }));
2744
+ }
2745
+ return;
2746
+ }
2747
+
2647
2748
  // Ask hook bridge: long-poll endpoint for PreToolUse AskUserQuestion hook
2648
2749
  if (url === '/api/ask-hook' && method === 'POST') {
2649
2750
  let body = '';
@@ -2655,6 +2756,10 @@ async function handleRequest(req, res) {
2655
2756
  req.destroy();
2656
2757
  }
2657
2758
  });
2759
+ // Phase 3: ask-bridge 用 `X-Ask-Poll-Mode: short` 头声明走短轮询。新 server 看到后
2760
+ // 立即返 { id, capability: 'short-poll' }(不挂 res),client 之后 GET /api/ask-hook/:id/result 取答案。
2761
+ // 老 server 不识别此头 → 按长轮询走(仍挂 res 直到答案/24h 超时)→ 完全向后兼容。
2762
+ const shortPollMode = (req.headers['x-ask-poll-mode'] || '').toLowerCase() === 'short';
2658
2763
  req.on('end', async () => {
2659
2764
  if (bodyTooLarge) {
2660
2765
  try { if (!res.headersSent) { res.writeHead(413, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Request body too large' })); } } catch {}
@@ -2688,6 +2793,7 @@ async function handleRequest(req, res) {
2688
2793
  clearTimeout(oldest.timer);
2689
2794
  try { if (!oldest.res.headersSent) { oldest.res.writeHead(429, { 'Content-Type': 'application/json' }); oldest.res.end(JSON.stringify({ error: 'Too many concurrent requests' })); } } catch {}
2690
2795
  pendingAskHooks.delete(oldestId);
2796
+ _persistAskDelete(oldestId);
2691
2797
  if (terminalWss) {
2692
2798
  const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id: oldestId });
2693
2799
  terminalWss.clients.forEach((c) => {
@@ -2698,8 +2804,9 @@ async function handleRequest(req, res) {
2698
2804
  }
2699
2805
  }
2700
2806
 
2701
- const HOOK_TIMEOUT = 60 * 60 * 1000; // 60 minutes 等价 terminal Claude Code 的"无超时"体验
2702
- // (terminal interactiveHandler 本身无 timeout,hook 子进程层 10min;这里 60min 远超人类响应时间)
2807
+ // 实质等同"无超时",仍保留兜底防 entry 泄漏(plugin throw / OOM 等异常路径)。
2808
+ // ask-bridge 不设客户端 timeout,由 server 单向控制 response 终止。
2809
+ const HOOK_TIMEOUT = ASK_HOOK_TIMEOUT_MS;
2703
2810
  // toolUseId 路由策略:
2704
2811
  // - char whitelist + ≤256 长度 防恶意 1MB key 撑大 Map
2705
2812
  // - 已存在同 id 但旧 res 已断(writableEnded/destroyed)→ 复用槽位(ask-bridge 重试场景)
@@ -2717,6 +2824,7 @@ async function handleRequest(req, res) {
2717
2824
  // 旧 res 已断(包含占位 res:null 残留)— ask-bridge 重试同 toolUseId 合理,复用槽位
2718
2825
  if (existing.timer) clearTimeout(existing.timer);
2719
2826
  pendingAskHooks.delete(toolUseId);
2827
+ _persistAskDelete(toolUseId);
2720
2828
  id = toolUseId;
2721
2829
  } else {
2722
2830
  res.writeHead(409, { 'Content-Type': 'application/json' });
@@ -2734,14 +2842,19 @@ async function handleRequest(req, res) {
2734
2842
  // TOCTOU 防御:占位 id 之前先注册 res.on('close'),否则 await runWaterfallHook 期间 client
2735
2843
  // abort 的 close 事件落空 → entry 残 5min。占位 set 提前到 await 之前防两条同 ms 并发
2736
2844
  // POST 的 do-while 都通过 collision check 后 set 互相覆盖(first res 永泄漏到 5min)。
2737
- pendingAskHooks.set(id, { questions, res, timer: null, createdAt: Date.now() });
2845
+ const _placeholderEntry = { questions, res, timer: null, createdAt: Date.now(), shortPoll: shortPollMode };
2846
+ pendingAskHooks.set(id, _placeholderEntry);
2847
+ _persistAskEntry(id, _placeholderEntry);
2738
2848
 
2739
2849
  // res.on('close') 提前注册:handler 用 entry.timer 守卫(占位期 timer:null → 仅 delete Map)
2850
+ // Phase 3: short-poll 模式下 res 主动 end,close 不视为 client cancel;entry 由 24h timer 兜底清理。
2740
2851
  res.on('close', () => {
2741
2852
  const entry = pendingAskHooks.get(id);
2742
2853
  if (entry) {
2854
+ if (entry.shortPoll) return;
2743
2855
  if (entry.timer) clearTimeout(entry.timer);
2744
2856
  pendingAskHooks.delete(id);
2857
+ _persistAskDelete(id);
2745
2858
  if (terminalWss) {
2746
2859
  const tmsg = JSON.stringify({ type: 'ask-hook-timeout', id });
2747
2860
  terminalWss.clients.forEach((c) => {
@@ -2757,8 +2870,15 @@ async function handleRequest(req, res) {
2757
2870
  const hookResult = await runWaterfallHook('onAskRequest', { id, questions, mode: 'hook' });
2758
2871
  if (hookResult.answers) {
2759
2872
  pendingAskHooks.delete(id); // 释放占位 — plugin 直接答了
2760
- res.writeHead(200, { 'Content-Type': 'application/json' });
2761
- res.end(JSON.stringify({ answers: hookResult.answers }));
2873
+ _persistAskDelete(id);
2874
+ // race: plugin 执行期间 client 主动断开 → res.on('close') 已清 entry,
2875
+ // 但本闭包仍持着 res 引用;guard 防 writeHead 抛 ERR_STREAM_WRITE_AFTER_END
2876
+ if (!res.writableEnded && !res.destroyed && !res.headersSent) {
2877
+ try {
2878
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2879
+ res.end(JSON.stringify({ answers: hookResult.answers }));
2880
+ } catch {}
2881
+ }
2762
2882
  return;
2763
2883
  }
2764
2884
  } catch {}
@@ -2767,6 +2887,7 @@ async function handleRequest(req, res) {
2767
2887
  const entry = pendingAskHooks.get(id);
2768
2888
  if (entry) {
2769
2889
  pendingAskHooks.delete(id);
2890
+ _persistAskDelete(id);
2770
2891
  try {
2771
2892
  // null guard:plugin throw + outer body-parse race 极端下占位残留时 fire 防 TypeError
2772
2893
  if (entry.res && !entry.res.headersSent) {
@@ -2785,7 +2906,19 @@ async function handleRequest(req, res) {
2785
2906
  }, HOOK_TIMEOUT);
2786
2907
 
2787
2908
  const askStartedAt = Date.now();
2788
- pendingAskHooks.set(id, { questions, res, timer, createdAt: askStartedAt });
2909
+ const _liveEntry = { questions, res, timer, createdAt: askStartedAt, shortPoll: shortPollMode };
2910
+ pendingAskHooks.set(id, _liveEntry);
2911
+ _persistAskEntry(id, _liveEntry);
2912
+
2913
+ // Phase 3: short-poll 模式立即返 ack,让 client 改走 GET 轮询;entry 留在内存等 ws answer。
2914
+ if (shortPollMode) {
2915
+ try {
2916
+ if (!res.headersSent) {
2917
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2918
+ res.end(JSON.stringify({ id, capability: 'short-poll' }));
2919
+ }
2920
+ } catch {}
2921
+ }
2789
2922
 
2790
2923
  // Broadcast to all terminal WS clients — 附 startedAt + timeoutMs 让前端渲染倒计时
2791
2924
  if (terminalWss) {
@@ -2805,6 +2938,75 @@ async function handleRequest(req, res) {
2805
2938
  return;
2806
2939
  }
2807
2940
 
2941
+ // Phase 3: short-poll handoff endpoint. ask-bridge GET /api/ask-hook/:id/result?wait=30000
2942
+ // 在 wait ms 内若答案/cancel 到达 → 立即返;否则返 204 让 client 重发。
2943
+ // 内存有 entry → 注册 listener;内存无 → 查 disk consume(server 重启场景)。
2944
+ if (url.startsWith('/api/ask-hook/') && url.includes('/result') && method === 'GET') {
2945
+ try {
2946
+ // URL 形如 /api/ask-hook/<id>/result?wait=30000;id 受白名单约束(与 POST 同源)
2947
+ const m = url.match(/^\/api\/ask-hook\/([^/?]+)\/result(?:\?(.*))?$/);
2948
+ if (!m) { res.writeHead(400); res.end(); return; }
2949
+ const id = decodeURIComponent(m[1]);
2950
+ if (!id || id.length > 256 || !/^[a-zA-Z0-9_-]+$/.test(id)) {
2951
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2952
+ res.end(JSON.stringify({ error: 'invalid id' }));
2953
+ return;
2954
+ }
2955
+ const qs = new URLSearchParams(m[2] || '');
2956
+ const wait = Math.max(1000, Math.min(60000, parseInt(qs.get('wait') || '30000', 10)));
2957
+
2958
+ // 1) disk 命中 answered/cancelled:浏览器答得早过 GET 到达 → 立即返并消费(一次性)
2959
+ // 用 consumeIfFinal 单次 withLock 内判 status 决定是否 delete —— 旧设计的
2960
+ // "consume + 若 pending 再 setEntry 写回" 两段是 race window:中间被 markAnswered 命中后,
2961
+ // setEntry 走 status guard 已经不会覆盖;但不删的 pending 也无须重写一遍。
2962
+ const diskEntry = askStoreConsumeIfFinal(id);
2963
+ if (diskEntry && diskEntry.status === 'answered') {
2964
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2965
+ res.end(JSON.stringify({ answers: diskEntry.answers || {} }));
2966
+ return;
2967
+ }
2968
+ if (diskEntry && diskEntry.status === 'cancelled') {
2969
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2970
+ res.end(JSON.stringify({ cancelled: true, reason: diskEntry.cancelReason || '' }));
2971
+ return;
2972
+ }
2973
+ // diskEntry.status === 'pending' → consumeIfFinal 已保留它,无须重写
2974
+
2975
+ // 2) entry 仍 pending:注册 listener 等 wait ms。内存或 disk 至少一个有就允许注册——
2976
+ // server 重启后 disk 是 pending,但内存无 entry,仍要让 GET 挂等(浏览器答案到达时
2977
+ // 会走 ws ask-hook-answer handler 路径,handler 内 markAnswered 落 disk + _notifyShortPollAnswer 直推 listener)。
2978
+ const memEntry = pendingAskHooks.get(id);
2979
+ if (!memEntry && !diskEntry) {
2980
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2981
+ res.end(JSON.stringify({ error: 'no such ask' }));
2982
+ return;
2983
+ }
2984
+ const listener = { res, finished: false, tid: null };
2985
+ if (!shortPollListeners.has(id)) shortPollListeners.set(id, new Set());
2986
+ shortPollListeners.get(id).add(listener);
2987
+ listener.tid = setTimeout(() => {
2988
+ if (listener.finished) return;
2989
+ listener.finished = true;
2990
+ shortPollListeners.get(id)?.delete(listener);
2991
+ try { if (!res.headersSent) { res.writeHead(204); res.end(); } } catch {}
2992
+ }, wait);
2993
+ res.on('close', () => {
2994
+ if (listener.finished) return;
2995
+ listener.finished = true;
2996
+ clearTimeout(listener.tid);
2997
+ shortPollListeners.get(id)?.delete(listener);
2998
+ });
2999
+ } catch (err) {
3000
+ try {
3001
+ if (!res.headersSent) {
3002
+ res.writeHead(500, { 'Content-Type': 'application/json' });
3003
+ res.end(JSON.stringify({ error: String(err?.message || err) }));
3004
+ }
3005
+ } catch {}
3006
+ }
3007
+ return;
3008
+ }
3009
+
2808
3010
  // Permission hook bridge: receive tool permission request from perm-bridge.js, long-poll for user decision
2809
3011
  if (url === '/api/perm-hook' && method === 'POST') {
2810
3012
  let body = '';
@@ -4121,6 +4323,18 @@ export async function startViewer() {
4121
4323
  // 清理过期解压缓存(fire-and-forget;任何错误吞掉)
4122
4324
  setImmediate(() => { try { cleanupExtractCache(); } catch { /* ignore */ } });
4123
4325
 
4326
+ // 启动时清理磁盘上 ASK_HOOK_TIMEOUT_MS 之前的 ask 条目(兜底防泄漏)。
4327
+ // 内存 Map 不 hydrate:旧 res 已死、新 ask-bridge 重连同 toolUseId 会自动复用槽位
4328
+ // (server.js 已有"旧 res 已断 → 复用"分支),无需在这里主动重建内存态。
4329
+ // 留下来的 disk 镜像供 /api/pending-asks 端点查询,让浏览器重连后仍能看见 pending 列表。
4330
+ setImmediate(() => { try { askStorePruneStale(ASK_HOOK_TIMEOUT_MS); } catch {} });
4331
+ // 长跑进程兜底:短轮询路径下 markAnswered 标的终态 entry 若 ask-bridge 已死(GET 不再来 consume),
4332
+ // 仅靠启动 prune 永远清不掉。1h 周期触发一次,.unref() 不阻塞进程退出。
4333
+ const _pruneAskStoreInterval = setInterval(() => {
4334
+ try { askStorePruneStale(ASK_HOOK_TIMEOUT_MS); } catch {}
4335
+ }, 60 * 60 * 1000);
4336
+ _pruneAskStoreInterval.unref();
4337
+
4124
4338
  // 通过插件 hook 获取 HTTPS 证书选项
4125
4339
  let httpsOptions = null;
4126
4340
  try {
@@ -4244,6 +4458,7 @@ export async function startViewer() {
4244
4458
  const { res: hookRes, timer } = entry;
4245
4459
  clearTimeout(timer);
4246
4460
  pendingAskHooks.delete(id);
4461
+ _persistAskDelete(id);
4247
4462
  try {
4248
4463
  if (!hookRes.headersSent) {
4249
4464
  hookRes.writeHead(200, { 'Content-Type': 'application/json' });
@@ -4423,8 +4638,8 @@ async function setupTerminalWebSocket(httpServer) {
4423
4638
  // Replay pending ask-hook 请求:浏览器关 tab 再开(或 ws 重连)时,
4424
4639
  // 让新 ws 立即收到当前 server-side 仍 long-poll 的 ask 列表 + startedAt + 剩余 timeoutMs,
4425
4640
  // 否则前端 askMetaMap 空 → 倒计时不渲染 + lastPendingAskId 派生错。
4426
- // ASK_HOOK_TIMEOUT 在闭包外(line 2425 const HOOK_TIMEOUT),不直接可见 — 用 60min 字面量同源。
4427
- const REPLAY_HOOK_TIMEOUT = 60 * 60 * 1000;
4641
+ // 与上方 HOOK_TIMEOUT 同源(实质无超时)。
4642
+ const REPLAY_HOOK_TIMEOUT = ASK_HOOK_TIMEOUT_MS;
4428
4643
  const now = Date.now();
4429
4644
  for (const [id, entry] of pendingAskHooks) {
4430
4645
  const elapsed = now - (entry.createdAt || now);
@@ -4530,6 +4745,7 @@ async function setupTerminalWebSocket(httpServer) {
4530
4745
  // 老协议 fallback(取最老)已废弃 — 多 pending 时会"答错对象"造成串答;
4531
4746
  // 缺 id 直接 WARN 并丢弃,让前端在 console 里看到为什么答案没生效。
4532
4747
  let askAnswered = false;
4748
+ let alreadyAnswered = false; // first-write-wins 抢答失败信号
4533
4749
  let askId = msg.id;
4534
4750
  let askEntry = null;
4535
4751
  if (askId) {
@@ -4541,12 +4757,58 @@ async function setupTerminalWebSocket(httpServer) {
4541
4757
  const { res: hookRes, timer } = askEntry;
4542
4758
  clearTimeout(timer);
4543
4759
  pendingAskHooks.delete(askId);
4544
- askAnswered = true;
4545
- try {
4546
- if (!hookRes.headersSent) {
4547
- hookRes.writeHead(200, { 'Content-Type': 'application/json' });
4548
- hookRes.end(JSON.stringify({ answers: msg.answers }));
4760
+ // Phase 3: short-poll 模式不立即删 disk —— 落 answered 让 GET listener / disk consume 拿
4761
+ if (askEntry.shortPoll) {
4762
+ let wrote = false;
4763
+ try { wrote = askStoreMarkAnswered(askId, msg.answers); } catch {}
4764
+ if (wrote) {
4765
+ _notifyShortPollAnswer(askId, msg.answers);
4766
+ askAnswered = true;
4767
+ } else {
4768
+ // race 边角:进入 handler 时内存 entry 还在,但 disk 已被另一进程 / 之前的 cancel 写过终态。
4769
+ // 不广播 answer,让发起方知道被抢答;hookRes 也按 cancelled 返回让 ask-bridge 走 deny 路径。
4770
+ alreadyAnswered = true;
4771
+ try {
4772
+ if (!hookRes.headersSent) {
4773
+ hookRes.writeHead(200, { 'Content-Type': 'application/json' });
4774
+ hookRes.end(JSON.stringify({ cancelled: true, reason: 'Already answered by another client' }));
4775
+ }
4776
+ } catch {}
4549
4777
  }
4778
+ } else {
4779
+ _persistAskDelete(askId);
4780
+ askAnswered = true;
4781
+ }
4782
+ if (askAnswered) {
4783
+ try {
4784
+ if (!hookRes.headersSent) {
4785
+ hookRes.writeHead(200, { 'Content-Type': 'application/json' });
4786
+ hookRes.end(JSON.stringify({ answers: msg.answers }));
4787
+ }
4788
+ } catch {}
4789
+ }
4790
+ } else if (askId) {
4791
+ // server 重启 / 内存 entry 已被清,但 ask-bridge 可能仍在短轮询 disk —— 落 answered。
4792
+ // first-write-wins:如果 disk 已被其他 client 抢答(markAnswered 返 false),
4793
+ // 不唤醒 listener(让 listener 等到 GET hit disk 拿到真实抢答者答案)—— 自然 idempotent。
4794
+ // 给当前 ws 发 ack-already-answered 让前端关 modal、不误覆盖灰态。
4795
+ let wrote = false;
4796
+ try { wrote = askStoreMarkAnswered(askId, msg.answers); } catch {}
4797
+ if (wrote) {
4798
+ _notifyShortPollAnswer(askId, msg.answers);
4799
+ askAnswered = true;
4800
+ } else {
4801
+ alreadyAnswered = true;
4802
+ }
4803
+ }
4804
+ // first-write-wins 抢答失败 → 仅 ack 发起方让其关 modal;不广播给其他 client 防覆盖真实 answer。
4805
+ if (alreadyAnswered && askId && ws && ws.readyState === 1) {
4806
+ try {
4807
+ ws.send(JSON.stringify({
4808
+ type: 'ask-hook-already-answered',
4809
+ id: askId,
4810
+ reason: 'Another client answered first (first-write-wins)',
4811
+ }));
4550
4812
  } catch {}
4551
4813
  }
4552
4814
  // Broadcast resolved to other clients so they clear their ask panel
@@ -4639,6 +4901,13 @@ async function setupTerminalWebSocket(httpServer) {
4639
4901
  const { res: hookRes, timer } = askEntry;
4640
4902
  if (timer) clearTimeout(timer);
4641
4903
  pendingAskHooks.delete(cancelId);
4904
+ // Phase 3: short-poll 同样要让 disk + listener 知道,否则 ask-bridge 永远收不到 cancelled
4905
+ if (askEntry.shortPoll) {
4906
+ try { askStoreMarkCancelled(cancelId, cancelReason); } catch {}
4907
+ _notifyShortPollCancel(cancelId, cancelReason);
4908
+ } else {
4909
+ _persistAskDelete(cancelId);
4910
+ }
4642
4911
  handled = true;
4643
4912
  try {
4644
4913
  if (hookRes && !hookRes.headersSent) {
@@ -4646,6 +4915,26 @@ async function setupTerminalWebSocket(httpServer) {
4646
4915
  hookRes.end(JSON.stringify({ cancelled: true, reason: cancelReason }));
4647
4916
  }
4648
4917
  } catch {}
4918
+ } else {
4919
+ // 内存无 entry,但 disk 可能有 pending(server 重启后 ask-bridge 重 POST 之前,
4920
+ // 浏览器从 /api/pending-asks 看到 disk-only ask 并 Cancel 它)。
4921
+ // first-wins:disk 已是终态(如另一 client 抢先 answer)时 markCancelled 返 false,
4922
+ // 不能唤醒 listener 用 cancel 覆盖真实 answer —— 让 listener 下次 GET consumeIfFinal
4923
+ // 拿到真实 disk 终态自然投递。
4924
+ const wrote = askStoreMarkCancelled(cancelId, cancelReason);
4925
+ if (wrote) {
4926
+ _notifyShortPollCancel(cancelId, cancelReason);
4927
+ handled = true;
4928
+ } else if (ws && ws.readyState === 1) {
4929
+ // 抢答失败 ack:只给发起方关 modal,不广播防覆盖其他 client 真实 answer。
4930
+ try {
4931
+ ws.send(JSON.stringify({
4932
+ type: 'ask-hook-already-answered',
4933
+ id: cancelId,
4934
+ reason: 'Already resolved by another client',
4935
+ }));
4936
+ } catch {}
4937
+ }
4649
4938
  }
4650
4939
  }
4651
4940
  // ack 广播分两档:
@@ -1 +0,0 @@
1
- :root,[data-theme=dark]{--bg-base: #0a0a0a;--bg-base-pure: #000;--bg-base-alt: #0d0d0d;--bg-container: #111;--bg-elevated: #1e1e1e;--bg-surface: #2a2a2a;--bg-code: #14141F;--bg-code-dark: #0d1117;--bg-code-system: #060f2a;--border-primary: #2a2a2a;--border-secondary: #303030;--border-hover: #3a3a3a;--border-light: #444;--border-code: #2a2a3e;--border-code-system: #0b2a5c;--text-primary: #e5e5e5;--text-secondary: #ccc;--text-tertiary: #888;--text-muted: #666;--text-disabled: #555;--text-disabled-faint: rgba(255, 255, 255, .45);--text-white: #fff;--text-gray: #6b7280;--text-light: #aaa;--color-primary: #1668dc;--color-primary-light: #3b82f6;--color-primary-lighter: #60a5fa;--color-primary-pale: #4a9eff;--color-primary-dark: #165bbe;--color-primary-dark-bg: #1e3a5f;--color-primary-bg-light: rgba(22, 104, 220, .15);--color-primary-bg-lighter: rgba(22, 104, 220, .2);--color-primary-bg-medium: rgba(22, 104, 220, .35);--color-primary-bg-faint: rgba(22, 104, 220, .1);--color-primary-shadow: rgba(22, 104, 220, .6);--color-success: #22c55e;--color-success-light: #4ade80;--color-success-dark: #2ea043;--color-success-bg-light: rgba(34, 197, 94, .15);--color-success-bg-medium: rgba(34, 197, 94, .3);--color-error: #ef4444;--color-error-rgb: 239, 68, 68;--color-error-light: #f87171;--color-error-muted: #a33;--color-error-dark-bg: #220000;--color-error-bg-light: rgba(239, 68, 68, .15);--color-error-bg-faint: rgba(239, 68, 68, .1);--color-error-bg-medium: rgba(239, 68, 68, .3);--color-warning: #f59e0b;--color-warning-light: #fbbf24;--color-warning-dark-bg: #2a2000;--color-warning-dark-border: #5c4d00;--color-warning-bg-light: rgba(245, 158, 11, .15);--color-warning-bg-medium: rgba(245, 158, 11, .3);--color-selection-bg: #1a2332;--color-selection-alt: #264f78;--color-green-bg: #1a3a1a;--color-green-dark-bg: #162312;--color-diff-add: #73c991;--color-diff-delete: #ef4444;--color-diff-add-bg: rgba(115, 201, 145, .15);--color-diff-delete-bg: rgba(239, 68, 68, .11);--color-tree-selected: #532f00;--color-accent-yellow: #e2c08d;--color-info-light: #7dd3fc;--color-code-purple: #a78bfa;--color-code-orange: #d97757;--code-keyword: #f87171;--code-string: #7dd3fc;--code-comment: #888;--code-number: #7dd3fc;--code-text: #e5e5e5;--code-inline-color: #aeafff;--code-inline-bg: #14141F;--code-error: #e06c75;--color-purple-bg: rgba(139, 92, 246, .18);--color-purple-border: rgba(139, 92, 246, .3);--color-primary-outline: rgba(22, 104, 220, .75);--color-primary-bg-extra-faint: rgba(74, 159, 255, .05);--color-primary-pale-shadow: rgba(74, 159, 255, .5);--color-error-border: rgba(239, 68, 68, .25);--color-error-border-light: rgba(239, 68, 68, .2);--color-warning-bg-faint: rgba(234, 179, 8, .1);--color-warning-border-light: rgba(234, 179, 8, .2);--color-success-bg-faint: rgba(34, 197, 94, .11);--color-diff-delete-text: #fca5a5;--color-green-border: #2a5a3a;--color-green-dark-border: #274916;--color-red-dark-bg: #8b1a1a;--color-red-dark-border: #2a1a1a;--color-thinking-bg: #111108;--color-thinking-border: #2a2a1e;--color-code-orange-border: rgba(217, 119, 87, .27);--color-code-orange-bg: rgba(217, 119, 87, .07);--bg-container-alt: #161616;--bg-model-avatar: #000;--bg-sub-avatar: #3a3a3a;--avatar-bg-0: #5b6abf;--avatar-bg-1: #2a9d8f;--avatar-bg-2: #6366f1;--avatar-bg-3: #d97706;--avatar-bg-4: #3b82f6;--avatar-bg-5: #8b5cf6;--avatar-bg-6: #0e7490;--avatar-bg-7: #ea580c;--avatar-bg-8: #059669;--avatar-bg-9: #e11d48;--avatar-bg-10: #0284c7;--avatar-bg-11: #dc2626;--avatar-bg-12: #65a30d;--avatar-bg-13: #ca8a04;--avatar-bg-14: #9333ea;--avatar-bg-15: #db2777;--avatar-bg-16: #6b7280;--avatar-bg-17: #1d4ed8;--avatar-bg-18: #b45309;--avatar-bg-19: #047857;--model-logo-mono: #fff;--bg-input-bar: rgba(0, 0, 0, .5);--bg-sticky-btn: rgba(0, 0, 0, .5);--chat-boxer-gradient-dir: to top;--chat-boxer-bg-from: var(--bg-surface);--chat-boxer-bg-to: var(--border-secondary);--chat-boxer-hover-from: var(--border-secondary);--chat-boxer-hover-to: var(--border-hover);--shadow-approval-heavy: rgba(0, 0, 0, .6);--shadow-approval-glow: rgba(245, 158, 11, .15);--color-approval-border: #f59e0b;--color-approval-label: #fbbf24;--color-gray-bg-light: rgba(107, 114, 128, .15);--color-gray-bg-medium: rgba(107, 114, 128, .3);--overlay-light-faint: rgba(255, 255, 255, .04);--overlay-light-medium: rgba(255, 255, 255, .1);--overlay-light-strong: rgba(255, 255, 255, .2);--overlay-light-half: rgba(255, 255, 255, .5);--overlay-light-bright: rgba(255, 255, 255, .7);--overlay-dark: rgba(0, 0, 0, .75);--overlay-dark-medium: rgba(0, 0, 0, .6);--overlay-dark-light: rgba(0, 0, 0, .5);--overlay-dark-strong: rgba(0, 0, 0, .88)}[data-theme=light]{--bg-base: #FAFAFA;--bg-base-pure: #FFFFFF;--bg-base-alt: #F5F5F5;--bg-container: #FFFFFF;--bg-elevated: #F9F9F9;--bg-surface: #F0F0F0;--bg-code: #F5F5F5;--bg-code-dark: #F5F5F5;--bg-code-system: #EFF1F3;--border-primary: #E0E0E0;--border-secondary: #EBEBEB;--border-hover: #C0C0C0;--border-light: #D4D4D4;--border-code: #D4D4D4;--border-code-system: #C0D0E0;--text-primary: #1a1a1a;--text-secondary: #333;--text-tertiary: #666;--text-muted: #888;--text-disabled: #AAA;--text-disabled-faint: rgba(0, 0, 0, .35);--text-white: #1a1a1a;--text-gray: #6b7280;--text-light: #555;--color-primary: #0969DA;--color-primary-light: #0969DA;--color-primary-lighter: #0969DA;--color-primary-pale: #2A7DE1;--color-primary-dark: #0550AE;--color-primary-dark-bg: #DDF4FF;--color-primary-bg-light: rgba(9, 105, 218, .1);--color-primary-bg-lighter: rgba(9, 105, 218, .12);--color-primary-bg-medium: rgba(9, 105, 218, .2);--color-primary-bg-faint: rgba(9, 105, 218, .06);--color-primary-shadow: rgba(9, 105, 218, .3);--color-success: #1A7F37;--color-success-light: #2DA44E;--color-success-dark: #116329;--color-success-bg-light: rgba(26, 127, 55, .08);--color-success-bg-medium: rgba(26, 127, 55, .15);--color-error: #CF222E;--color-error-rgb: 207, 34, 46;--color-error-light: #DA3633;--color-error-muted: #82071E;--color-error-dark-bg: #FFEBE9;--color-error-bg-light: rgba(207, 34, 46, .08);--color-error-bg-faint: rgba(207, 34, 46, .06);--color-error-bg-medium: rgba(207, 34, 46, .15);--color-warning: #9A6700;--color-warning-light: #BF8700;--color-warning-dark-bg: #FFF8E1;--color-warning-dark-border: #D4A72C;--color-warning-bg-light: rgba(154, 103, 0, .08);--color-warning-bg-medium: rgba(154, 103, 0, .15);--color-selection-bg: #B4D7FF;--color-selection-alt: #ADD6FF;--color-green-bg: #DAFBE1;--color-green-dark-bg: #DAFBE1;--color-diff-add: #116329;--color-diff-delete: #82071E;--color-diff-add-bg: #DAFBE1;--color-diff-delete-bg: #FFEBE9;--color-tree-selected: #FFF3D5;--color-accent-yellow: #CB7C5E;--color-info-light: #0969DA;--color-code-purple: #8250DF;--color-code-orange: #953800;--code-keyword: #0000FF;--code-string: #A31515;--code-comment: #6A9955;--code-number: #098658;--code-text: #383838;--code-inline-color: #0969DA;--code-inline-bg: #EFF1F3;--code-error: #CF222E;--color-purple-bg: rgba(130, 80, 223, .1);--color-purple-border: rgba(130, 80, 223, .2);--color-primary-outline: rgba(9, 105, 218, .5);--color-primary-bg-extra-faint: rgba(9, 105, 218, .04);--color-primary-pale-shadow: rgba(9, 105, 218, .3);--color-error-border: rgba(207, 34, 46, .2);--color-error-border-light: rgba(207, 34, 46, .15);--color-warning-bg-faint: rgba(154, 103, 0, .06);--color-warning-border-light: rgba(154, 103, 0, .15);--color-success-bg-faint: rgba(26, 127, 55, .08);--color-diff-delete-text: #DA3633;--color-green-border: #2DA44E;--color-green-dark-border: #1A7F37;--color-red-dark-bg: #FFEBE9;--color-red-dark-border: #FFCECB;--color-thinking-bg: #FAFAF5;--color-thinking-border: #E8E8D0;--color-code-orange-border: rgba(149, 56, 0, .2);--color-code-orange-bg: rgba(149, 56, 0, .06);--bg-container-alt: #F5F5F5;--bg-model-avatar: transparent;--bg-sub-avatar: #E5E5E5;--avatar-bg-0: #ced0db;--avatar-bg-1: #9abcb8;--avatar-bg-2: #edeef5;--avatar-bg-3: #cfb99f;--avatar-bg-4: #d6deea;--avatar-bg-5: #efecf5;--avatar-bg-6: #78a8b5;--avatar-bg-7: #d6bcaf;--avatar-bg-8: #72b8a2;--avatar-bg-9: #d6b7be;--avatar-bg-10: #8fb5c8;--avatar-bg-11: #d6bbbb;--avatar-bg-12: #a4bc83;--avatar-bg-13: #cab893;--avatar-bg-14: #d6cae2;--avatar-bg-15: #d6bbc7;--avatar-bg-16: #bbbcbf;--avatar-bg-17: #b2bad2;--avatar-bg-18: #c2a28a;--avatar-bg-19: #5ead97;--model-logo-mono: #000;--bg-input-bar: #ffffff;--bg-sticky-btn: rgba(255, 255, 255, .85);--chat-boxer-gradient-dir: to bottom;--chat-boxer-bg-from: #F7F7F7;--chat-boxer-bg-to: #F0F0F0;--chat-boxer-hover-from: #EEEEEE;--chat-boxer-hover-to: #E8E8E8;--shadow-approval-heavy: rgba(0, 0, 0, .1);--shadow-approval-glow: rgba(245, 158, 11, .08);--color-approval-border: #1f1f1f;--color-approval-label: #1f1f1f;--color-gray-bg-light: rgba(107, 114, 128, .1);--color-gray-bg-medium: rgba(107, 114, 128, .2);--overlay-light-faint: rgba(0, 0, 0, .03);--overlay-light-medium: rgba(0, 0, 0, .06);--overlay-light-strong: rgba(0, 0, 0, .1);--overlay-light-half: rgba(0, 0, 0, .3);--overlay-light-bright: rgba(0, 0, 0, .5);--overlay-dark: rgba(0, 0, 0, .5);--overlay-dark-medium: rgba(0, 0, 0, .4);--overlay-dark-light: rgba(0, 0, 0, .3);--overlay-dark-strong: rgba(0, 0, 0, .6)}body{margin:0;background-color:var(--bg-base-alt);overflow-x:hidden;-webkit-tap-highlight-color:transparent}html,body{overscroll-behavior:none}::selection{background-color:var(--color-selection-alt);color:inherit}*{scrollbar-width:thin;scrollbar-color:var(--border-hover) var(--bg-base-alt)}@media(pointer:coarse){*{scrollbar-width:auto;scrollbar-color:auto}}@media(pointer:fine){*::-webkit-scrollbar{width:6px;height:6px}*::-webkit-scrollbar-track{background:var(--bg-base-alt)}*::-webkit-scrollbar-thumb{background:var(--border-hover);border-radius:3px}*::-webkit-scrollbar-thumb:hover{background:var(--text-disabled)}}.code-highlight{color:var(--code-text)}.hl-keyword{color:var(--code-keyword)}.hl-string{color:var(--code-string)}.hl-comment{color:var(--code-comment);font-style:italic}.hl-number{color:var(--code-number)}.hl-linenum{color:var(--text-disabled);-webkit-user-select:none;user-select:none}.chat-md pre{background:var(--bg-code-dark);border:1px solid var(--border-primary);border-radius:6px;padding:12px;overflow-x:auto;font-size:13px;line-height:1.5}.chat-md code{background:var(--code-inline-bg);padding:2px 6px;border-radius:4px;font-size:13px;color:var(--code-inline-color)}.chat-md pre code{background:none;padding:0}.chat-md p{margin:6px 0}.chat-md ul,.chat-md ol{padding-left:20px;margin:6px 0}.chat-md li{margin:2px 0}.chat-md h1,.chat-md h2,.chat-md h3{margin:12px 0 6px;color:var(--text-white)}.chat-md h1{font-size:1.3em}.chat-md h2{font-size:1.15em}.chat-md h3{font-size:1.05em}.chat-md blockquote{border-left:3px solid var(--color-primary-light);margin:8px 0;padding:4px 12px;color:var(--text-tertiary)}.chat-md table{border-collapse:collapse;margin:8px 0;font-size:13px}.chat-md th,.chat-md td{border:1px solid var(--text-gray);padding:6px 10px}.chat-md th{background:var(--bg-elevated);color:var(--text-white)}.chat-md a{color:var(--color-primary-lighter)}.chat-md img{cursor:pointer;max-width:100%;height:auto;border-radius:6px}.chat-md hr{border:none;border-top:1px solid var(--border-primary);margin:12px 0}.chat-md .chat-boxer hr,.chat-boxer .chat-md hr{border-top-color:var(--border-light);margin:8px 0}.chat-md strong,.chat-md em{color:var(--text-primary)}.mermaid-diagram{display:flex;justify-content:center;margin:12px 0;padding:16px;background:var(--bg-code-dark);border:1px solid var(--border-primary);border-radius:6px;overflow-x:auto}.mermaid-diagram svg{max-width:100%;height:auto}*:focus{outline:none!important;box-shadow:none!important}*:focus-visible{outline:none!important;box-shadow:none!important}.chat-boxer{background:linear-gradient(var(--chat-boxer-gradient-dir),var(--chat-boxer-bg-from),var(--chat-boxer-bg-to));color:var(--text-white);border-radius:6px;padding:2px 10px;margin-bottom:6px;border:1px solid var(--text-muted);box-sizing:border-box}.chat-boxer:hover{background:linear-gradient(var(--chat-boxer-gradient-dir),var(--chat-boxer-hover-from),var(--chat-boxer-hover-to))}.chat-boxer .chat-md tbody{background:var(--bg-base-pure)}.ant-dropdown .logo-dropdown-menu.logo-dropdown-menu{border:1px solid var(--border-hover);border-radius:4px}.ant-dropdown .logo-dropdown-menu.logo-dropdown-menu .ant-dropdown-menu-item,.ant-dropdown .logo-dropdown-menu.logo-dropdown-menu .ant-dropdown-menu-submenu-title{font-size:12px;padding:8px 12px}.ant-dropdown-menu-submenu-popup .ant-dropdown-menu{border:1px solid var(--border-hover);border-radius:4px}.ant-tooltip .ant-tooltip-inner{background-color:var(--bg-base-alt);color:var(--text-primary)}.ant-tooltip .ant-tooltip-arrow:before,.ant-tooltip .ant-tooltip-arrow:after{background:var(--bg-base-alt)}.ant-popover .ant-popover-arrow:before,.ant-popover .ant-popover-arrow:after{background:var(--border-secondary)}[data-theme=light] .ant-popover .ant-popover-arrow:before,[data-theme=light] .ant-popover .ant-popover-arrow:after{background:var(--bg-base-pure)}.ant-typography{font-size:12px}.ant-modal-confirm .ant-modal-content{background-color:var(--bg-elevated);border:1px solid var(--border-primary)}.ant-modal-confirm .ant-modal-confirm-title{color:var(--text-primary)}.ant-modal-confirm .ant-modal-confirm-content{color:var(--text-light)}.ant-modal-confirm .ant-btn-default{background-color:transparent;border-color:var(--border-light);color:var(--text-secondary)}.ant-modal-confirm .ant-btn-default.ant-btn-default:hover,.ant-modal-confirm .ant-btn-default.ant-btn-default:focus{background-color:var(--bg-surface);border-color:var(--text-muted);color:var(--text-white)}.ant-modal-confirm .ant-btn-primary,.ant-modal-confirm .ant-btn-primary:hover{background-color:var(--color-primary);border-color:var(--color-primary)}.ant-tabs .ant-tabs-nav:before{border-bottom-color:var(--border-primary)}[data-theme=light] .hljs{background:var(--bg-code);color:#383838}[data-theme=light] .hljs-keyword{color:#00f}[data-theme=light] .hljs-string{color:#a31515}[data-theme=light] .hljs-comment{color:#6a9955}[data-theme=light] .hljs-number{color:#098658}[data-theme=light] .hljs-title{color:#795e26}[data-theme=light] .hljs-type,[data-theme=light] .hljs-built_in{color:#267f99}[data-theme=light] .hljs-literal{color:#00f}[data-theme=light] .hljs-attr{color:#e50000}[data-theme=light] .hljs-variable{color:#001080}[data-theme=light] .hljs-name,[data-theme=light] .hljs-tag{color:maroon}[data-theme=light] .hljs-attribute{color:red}[data-theme=light] .hljs-symbol{color:#098658}[data-theme=light] .hljs-meta{color:#6a9955}[data-theme=light] .hljs-selector-tag{color:maroon}[data-theme=light] .hljs-selector-class,[data-theme=light] .hljs-selector-id{color:#267f99}