wezard 1.2.12 → 1.2.16

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.
Files changed (54) hide show
  1. package/README.md +2 -3
  2. package/config.example.jsonc +32 -0
  3. package/dist/cli/init.js +53 -2
  4. package/dist/cli/init.js.map +1 -1
  5. package/dist/daemon/approval.js +463 -105
  6. package/dist/daemon/approval.js.map +1 -1
  7. package/dist/daemon/danger.js +23 -0
  8. package/dist/daemon/danger.js.map +1 -1
  9. package/dist/daemon/graph.js +22 -4
  10. package/dist/daemon/graph.js.map +1 -1
  11. package/dist/daemon/inbound.js +112 -71
  12. package/dist/daemon/inbound.js.map +1 -1
  13. package/dist/daemon/index.js +67 -17
  14. package/dist/daemon/index.js.map +1 -1
  15. package/dist/daemon/mirror-bridge.js +561 -133
  16. package/dist/daemon/mirror-bridge.js.map +1 -1
  17. package/dist/daemon/peers.js +67 -6
  18. package/dist/daemon/peers.js.map +1 -1
  19. package/dist/daemon/pending.js +4 -3
  20. package/dist/daemon/pending.js.map +1 -1
  21. package/dist/daemon/session-name.js +133 -0
  22. package/dist/daemon/session-name.js.map +1 -0
  23. package/dist/daemon/session-scan.js +18 -2
  24. package/dist/daemon/session-scan.js.map +1 -1
  25. package/dist/daemon/spawn-tmux.js +103 -16
  26. package/dist/daemon/spawn-tmux.js.map +1 -1
  27. package/dist/daemon/ws.js +8 -1
  28. package/dist/daemon/ws.js.map +1 -1
  29. package/dist/mcp/server.js +4 -4
  30. package/dist/mcp/server.js.map +1 -1
  31. package/dist/shared/allow-rules.js +391 -0
  32. package/dist/shared/allow-rules.js.map +1 -0
  33. package/dist/shared/chat-render.js +2 -0
  34. package/dist/shared/chat-render.js.map +1 -1
  35. package/dist/shared/chat-view.js +37 -2
  36. package/dist/shared/chat-view.js.map +1 -1
  37. package/dist/shared/claude-config-path.js +132 -0
  38. package/dist/shared/claude-config-path.js.map +1 -0
  39. package/dist/shared/claude-permissions.js +63 -0
  40. package/dist/shared/claude-permissions.js.map +1 -0
  41. package/dist/shared/cli-backends.js +15 -2
  42. package/dist/shared/cli-backends.js.map +1 -1
  43. package/dist/shared/config.js +42 -2
  44. package/dist/shared/config.js.map +1 -1
  45. package/dist/shared/detail-render.js +22 -1
  46. package/dist/shared/detail-render.js.map +1 -1
  47. package/dist/shared/detail-store.js.map +1 -1
  48. package/dist/shared/modal-pane.js +101 -0
  49. package/dist/shared/modal-pane.js.map +1 -0
  50. package/dist/shared/session-label.js +26 -3
  51. package/dist/shared/session-label.js.map +1 -1
  52. package/package.json +1 -1
  53. package/web/chat.css +30 -0
  54. package/web/chat.js +60 -6
@@ -1,11 +1,15 @@
1
1
  import { closeSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
2
2
  import { createPending, getPending, getResolvedSnapshot, resolvePending, resolvePendingsByChat, failPending, stashResolved, markCardSent, isReloadError } from "./pending.js";
3
3
  import { cacheGet, cachePut, cacheKey, isAutoWindowActive, autoWindowRemainingMs, setAutoWindow, clearAutoWindow, getWindowMeta, } from "./session-cache.js";
4
+ import { evaluateAllow, ruleMatchesAny, alwaysAllowRulesFor, splitSegments, NEVER_RULE_ALLOW } from "../shared/allow-rules.js";
4
5
  import { redact } from "./redact.js";
5
- import { dangerOf, dangerModeSkips, dangerSkips } from "./danger.js";
6
- import { recordApproval, recordApprovalDecision, buildDetailUrl } from "./detail.js";
6
+ import { dangerOf, dangerEarlyExit } from "./danger.js";
7
+ import { appendUnique } from "../shared/config-writer.js";
8
+ import { claudeConfigWrite } from "../shared/claude-config-path.js";
9
+ import { recordApproval, recordApprovalDecision, buildDetailUrl, getDetail } from "./detail.js";
7
10
  import { json, readBody } from "./http.js";
8
11
  import { tagBadge, withTagHeader } from "../shared/session-label.js";
12
+ import { sessionNameFor } from "./session-name.js";
9
13
  // ── Routing helpers ────────────────────────────────────────────────────
10
14
  const targetChatId = (principal) => {
11
15
  // "user:abc" → "abc" (DM chatid == userid for aibot)
@@ -41,16 +45,15 @@ const takeFirstLines = (s, lines, maxChars) => TRUNC(s.split("\n").slice(0, line
41
45
  const EDIT_SNIPPET_LEN = 160;
42
46
  const WRITE_PREVIEW_LINES = 4;
43
47
  const WRITE_PREVIEW_CHARS = 200;
44
- const SOURCE_BASE = {
45
- icon_url: "https://wwcdn.weixin.qq.com/node/wework/images/3d-claude-ai-logo.bce0ddae70.jpg",
46
- desc: "Claude Code",
47
- desc_color: 0,
48
- };
49
- // Source bar sits ABOVE main_title — only place we can hoist transcript context.
50
- const buildSource = (tail) => {
51
- const desc = tail ? TRUNC(tail, 80) : SOURCE_BASE.desc;
52
- return { ...SOURCE_BASE, desc };
53
- };
48
+ const SOURCE_ICON = "https://wwcdn.weixin.qq.com/node/wework/images/3d-claude-ai-logo.bce0ddae70.jpg";
49
+ // Source 行 = 会话身份位: 放会话名 (谁在请求), 让 main_title 整块 13×2 字腾给
50
+ // "想干什么"。危险卡只把这行文字变红 (desc_color: 2), 不换文案不加图标 ——
51
+ // 红色本身就是信号。会话名缺失时回落品牌名, 不留空行。
52
+ const buildSource = (danger, sessionName) => ({
53
+ icon_url: SOURCE_ICON,
54
+ desc: TRUNC(sessionName || "Claude Code", 13),
55
+ desc_color: danger ? 2 : 0,
56
+ });
54
57
  const prefixLines = (s, prefix) => s.split("\n").map((l) => `${prefix} ${l}`).join("\n");
55
58
  // Flat key:val summary for unknown tools — never dump raw JSON.
56
59
  const UNKNOWN_VAL_LEN = 140;
@@ -76,7 +79,25 @@ const summarizeUnknown = (i, cwd) => {
76
79
  }
77
80
  return lines.join("\n");
78
81
  };
79
- const QUOTE_MAX = 600;
82
+ // 卡片 quote 体上限 — 由 approval.cardQuoteMaxChars 注入 (渲染函数调用点太多,
83
+ // 模块级注入一次比层层穿参干净)。发送失败时缩到 SAFE_QUOTE_MAX 重试, 见 flushBatch。
84
+ let QUOTE_MAX = 600;
85
+ export const setCardQuoteMax = (n) => { QUOTE_MAX = n; };
86
+ const SAFE_QUOTE_MAX = 600;
87
+ // 发送失败缩容重试: 正文主体如今在 sub_title_text (v2 布局), quote_area 仅存于
88
+ // 历史路径 —— 两处都缩, 保住审批流比保住展示量重要。
89
+ const shrinkQuote = (c) => {
90
+ let out = c;
91
+ const q = c.quote_area;
92
+ if (q?.quote_text && q.quote_text.length > SAFE_QUOTE_MAX) {
93
+ out = { ...out, quote_area: { ...q, quote_text: TRUNC(q.quote_text, SAFE_QUOTE_MAX) } };
94
+ }
95
+ const st = out.sub_title_text;
96
+ if (st && st.length > SAFE_QUOTE_MAX) {
97
+ out = { ...out, sub_title_text: TRUNC(st, SAFE_QUOTE_MAX) };
98
+ }
99
+ return out;
100
+ };
80
101
  const join = (...parts) => parts.filter(Boolean).join("\n");
81
102
  // Render tool input as a multi-line "code-block / quote" body.
82
103
  const renderInput = (toolName, toolInput, _toolInputStr, cwd) => {
@@ -128,11 +149,18 @@ const renderInput = (toolName, toolInput, _toolInputStr, cwd) => {
128
149
  }
129
150
  return { body: summarizeUnknown(i, cwd) };
130
151
  };
131
- const quoteArea = (text) => ({ type: 0, quote_text: text });
152
+ // Bash/Shell 的原始命令 判断卡片正文是否被截断、以及「展开完整命令」取哪一段。
153
+ const commandOf = (toolName, toolInput) => {
154
+ if (toolName !== "Bash" && toolName !== "Shell")
155
+ return "";
156
+ const i = toolInput;
157
+ return i && typeof i.command === "string" ? i.command : "";
158
+ };
132
159
  const MAIN_DESC_MAX = 30;
133
160
  const mainTitle = (title, desc) => desc ? { title, desc: TRUNC(desc, MAIN_DESC_MAX) } : { title };
134
161
  const dirName = (cwd) => cwd.replace(/^.*\//, "") || cwd;
135
- const detailJumpList = (url) => url ? [{ type: 1, title: "🔍 详情", url }] : undefined;
162
+ // quote_area 弃用后, jump_list 是卡上唯一的 PC 跳转位 (13 字上限)。
163
+ const detailJumpList = (url) => url ? [{ type: 1, title: "🔍 完整命令 · 详情", url }] : undefined;
136
164
  // Stable per-session animal emoji, matching list_claude_sessions, so the user
137
165
  // can tell which session a card belongs to when several un-mirrored sessions
138
166
  // fall back to the same WeCom chat. Needs the FULL sessionId; returns "" when
@@ -145,9 +173,70 @@ const detailJumpList = (url) => url ? [{ type: 1, title: "🔍 详情", url }] :
145
173
  // prefix that outbound mirror bubbles carry — one visual per tag, regardless
146
174
  // of how many times `/clear` rotates the underlying sessionId.
147
175
  const emojiFor = tagBadge;
148
- const tagOf = (a) => emojiFor(a.chatKey);
149
- // 危险卡: 只有 / 不给「N 分钟全过」的入口, 否则一次点击就把后续
150
- // 所有危险操作也放行了, 名单等于失效。
176
+ // ── v3 布局 (2026-07-31 两轮真机反馈后定稿) ──────────────────────────
177
+ // 信息优先级: 谁在问(会话名) > 想干什么(desc) > 具体命令 > 上下文。
178
+ // source 品牌位; 危险卡文字变红 (仅变色, 不换文案)
179
+ // main_title <会话名> · <工具> — 无锁/无 emoji, 纯文字
180
+ // quote_area 命令主体 — 无标题, 整块可点跳详情页 (PC); 3 行截断由
181
+ // 右上⋯展开与详情页兜底
182
+ // horizontal 上文 (最近用户消息); 危险卡多一行规则名
183
+ // jump_list 「🔍 完整命令·详情」
184
+ // 会话名: #tag (企微发起) > CC 会话名 (本地发起, sessions 注册表) > 首条消息。
185
+ const HMETA_VAL_MAX = 26;
186
+ // 一级标题 = Claude 自己写的意图 (tool_input.description), 回答"想干什么" ——
187
+ // 13×2 字里最值钱的内容。没有 description 的工具 (Read/Write/Edit…) 回落
188
+ // 「工具 · 目录/」, 因为具体路径在下面的引用区里已经有了。
189
+ const cardTitle = (a, r) => r.desc || `${shortTool(a.toolName)} · ${dirName(a.cwd)}/`;
190
+ // 「审核」行: 说清这条命令为什么没被白名单放行 —— 与「危险」行同一种表达方式。
191
+ // 能算出「总是」将生成的规则时直接给规则 (点总是会发生什么), 否则给段落定位。
192
+ const denyReasonText = (d) => {
193
+ switch (d.kind) {
194
+ case "segment_unmatched":
195
+ return d.rule
196
+ ? `${d.index}/${d.total}段 → ${d.rule}`
197
+ : `${d.index}/${d.total}段 ${d.segment}`;
198
+ case "substitution": return "含 $() 动态构造,无法白名单";
199
+ case "unparsable": return "引号未闭合或含后台符 &";
200
+ case "tool_not_listed": return `${shortTool(d.tool)} 未在白名单`;
201
+ case "never_allow": return "交互工具,永不免审";
202
+ // 没配白名单规则 = 用户没在用这套机制, 每张卡都挂这行纯噪声。
203
+ case "no_rules": return undefined;
204
+ }
205
+ };
206
+ // mcp__server__tool → server:tool — 标题里的长工具名压短。
207
+ const shortTool = (toolName) => TRUNC(toolName.replace(/^mcp__/, "").replace(/__/g, ":"), 16);
208
+ const metaRows = (a) => {
209
+ const rows = [];
210
+ const tail = oneLine(a.transcriptTail).trim();
211
+ if (tail)
212
+ rows.push({ keyname: "上文", value: TRUNC(tail, HMETA_VAL_MAX) });
213
+ // 为什么要人来点这一下 —— 优先级 危险 > 未白名单。danger 命中时命令很可能
214
+ // 本来就在白名单里, 再报"未放行"是误导。
215
+ if (a.danger) {
216
+ rows.push({ keyname: "危险", value: TRUNC(`命中「${a.danger}」`, HMETA_VAL_MAX) });
217
+ }
218
+ else if (a.denyReason) {
219
+ const why = denyReasonText(a.denyReason);
220
+ if (why)
221
+ rows.push({ keyname: "审核", value: TRUNC(why, HMETA_VAL_MAX) });
222
+ }
223
+ return rows.length > 0 ? rows : undefined;
224
+ };
225
+ // 命令主体 (引用区, 可点) + 元信息行, 各卡共用的中段。
226
+ const bodyBlocks = (a, r) => {
227
+ const rows = metaRows(a);
228
+ return {
229
+ ...(r.body ? { quote_area: quoteArea(r.body, a.detailUrl) } : {}),
230
+ ...(rows ? { horizontal_content_list: rows } : {}),
231
+ };
232
+ };
233
+ // 引用区: 无标题 (省一行), 挂 type:1 + url 整块可点 → 详情页看全文 (PC 好用;
234
+ // 回环链接手机打不开是已知取舍, 手机走右上⋯展开)。
235
+ const quoteArea = (text, url) => (url
236
+ ? { type: 1, url, quote_text: text }
237
+ : { type: 0, quote_text: text });
238
+ // 危险卡: 只有 ❌ / ✅ — 不给「N 分钟全过」「✅总是」的入口, 否则一次点击就把
239
+ // 后续所有危险操作也放行了, 名单等于失效。
151
240
  const approveButtons = (a) => a.danger
152
241
  ? [
153
242
  { text: "❌", style: 4, key: encodeKey(a.reqId, "deny") },
@@ -156,19 +245,42 @@ const approveButtons = (a) => a.danger
156
245
  : [
157
246
  { text: "❌", style: 4, key: encodeKey(a.reqId, "deny") },
158
247
  { text: fmtWindow(a.windowMinutes), style: 3, key: encodeKey(a.reqId, "allow_window") },
248
+ // 「总是」= 由本次调用生成一条 allowRules 并落盘, 对齐 Claude Code 原生
249
+ // 弹窗的 Always allow。危险卡上没有这个入口 (与「⏱全过」同理)。
250
+ { text: "✅总是", style: 4, key: encodeKey(a.reqId, "allow_always") },
159
251
  { text: "✅", style: 4, key: encodeKey(a.reqId, "allow") },
160
252
  ];
253
+ // 正文放不下时的两条出路, 都挂在卡片自己身上 (不再额外发一条完整命令的文本消息):
254
+ // • 引用区可点 → 详情页 (HTML, 有高亮, 但要跳浏览器)
255
+ // • 右上角「⋯」菜单「📄 展开完整命令」→ 按需在群里发全文 (不跳出企微)
256
+ // action_menu 只在正文确实被截断时才挂, 免得短命令的卡也多一个没用的入口。
257
+ const FULLCMD_PREFIX = "FULLCMD|";
258
+ const encodeFullCmdKey = (reqId) => `${FULLCMD_PREFIX}${reqId}`;
259
+ // WeCom markdown 单条上限约 2048 字节, 留出标题与 tag 头的余量。
260
+ const FULLCMD_CHUNK_CHARS = 1800;
261
+ const chunkText = (s, size) => {
262
+ const out = [];
263
+ for (let i = 0; i < s.length; i += size)
264
+ out.push(s.slice(i, i + size));
265
+ return out.length > 0 ? out : [""];
266
+ };
267
+ /** 带命令的卡一律给「看全文」入口, 待决卡与已决卡共用 (已决卡也要能回看批了
268
+ * 什么), 不按长度区分 —— 短命令多一个菜单项无害。
269
+ * 右上「⋯」→ 群里发全文 (哪端都能用); jump_list「🔍 完整命令·详情」→ 详情页
270
+ * (PC 端好用, 回环链接手机打不开是已知取舍)。 */
271
+ const fullCmdMenu = (a) => commandOf(a.toolName, a.toolInput)
272
+ ? { desc: "更多", action_list: [{ text: "📄 展开完整命令", key: encodeFullCmdKey(a.reqId) }] }
273
+ : undefined;
161
274
  const buildCard = (a) => {
162
275
  const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
163
- const dir = dirName(a.cwd);
164
- const tail = oneLine(a.transcriptTail).trim();
165
276
  const jl = detailJumpList(a.detailUrl);
277
+ const menu = fullCmdMenu(a);
166
278
  return {
167
279
  card_type: "button_interaction",
168
- source: buildSource(tail),
169
- main_title: mainTitle(`${a.danger ? "⚠️ 危险" : "🔐 授权"} · ${tagOf(a)}${a.toolName} · ${dir}/`, r.desc),
170
- ...(a.danger ? { sub_title_text: `命中危险名单「${a.danger}」· 需单独确认,不进入自动放行` } : {}),
171
- ...(r.body ? { quote_area: quoteArea(r.body) } : {}),
280
+ source: buildSource(a.danger, a.sessionName),
281
+ main_title: { title: TRUNC(cardTitle(a, r), 26) },
282
+ ...bodyBlocks(a, r),
283
+ ...(menu ? { action_menu: menu } : {}),
172
284
  ...(jl ? { jump_list: jl } : {}),
173
285
  task_id: a.reqId,
174
286
  button_list: approveButtons(a),
@@ -180,6 +292,7 @@ const verbOf = (d, windowMinutes) => {
180
292
  case "deny": return "已拒绝";
181
293
  case "allow_window": return `${fmtWindow(windowMinutes)}会话内全过`;
182
294
  case "allow_session": return "本会话通过";
295
+ case "allow_always": return "已通过·规则已保存";
183
296
  default: return "已通过";
184
297
  }
185
298
  };
@@ -201,14 +314,16 @@ const resolvedButton = (d, windowMinutes, reqId, chatKey) => {
201
314
  };
202
315
  const buildResolvedCard = (a) => {
203
316
  const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
204
- const dir = dirName(a.cwd);
205
- const tail = oneLine(a.transcriptTail).trim();
206
317
  const jl = detailJumpList(a.detailUrl);
318
+ // 已决卡同样保留「看全文」入口 —— 回头想确认"我刚才批的到底是什么"是常态,
319
+ // 而 detail 记录留存 24h, 点了照样能展开。
320
+ const menu = fullCmdMenu(a);
207
321
  return {
208
322
  card_type: "button_interaction",
209
- source: buildSource(tail),
210
- main_title: mainTitle(`${a.danger ? "⚠️ " : ""}${tagOf(a)}${a.toolName} · ${dir}/`, r.desc),
211
- ...(r.body ? { quote_area: quoteArea(r.body) } : {}),
323
+ source: buildSource(a.danger, a.sessionName),
324
+ main_title: { title: TRUNC(cardTitle(a, r), 26) },
325
+ ...bodyBlocks(a, r),
326
+ ...(menu ? { action_menu: menu } : {}),
212
327
  ...(jl ? { jump_list: jl } : {}),
213
328
  task_id: a.reqId,
214
329
  button_list: [resolvedButton(a.decision, a.windowMinutes, a.reqId, a.chatKey ?? "")],
@@ -216,14 +331,12 @@ const buildResolvedCard = (a) => {
216
331
  };
217
332
  const buildCancelledCard = (a) => {
218
333
  const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
219
- const dir = dirName(a.cwd);
220
- const tail = oneLine(a.transcriptTail).trim();
221
334
  const jl = detailJumpList(a.detailUrl);
222
335
  return {
223
336
  card_type: "button_interaction",
224
- source: buildSource(tail),
225
- main_title: mainTitle(`${tagOf(a)}${a.toolName} · ${dir}/`, r.desc),
226
- ...(r.body ? { quote_area: quoteArea(r.body) } : {}),
337
+ source: buildSource(a.danger, a.sessionName),
338
+ main_title: { title: TRUNC(cardTitle(a, r), 26) },
339
+ ...bodyBlocks(a, r),
227
340
  ...(jl ? { jump_list: jl } : {}),
228
341
  task_id: a.reqId,
229
342
  button_list: [
@@ -234,14 +347,12 @@ const buildCancelledCard = (a) => {
234
347
  // 已 resolved 的卡再次被点击 — 仅作视觉反馈, 不改变任何状态。
235
348
  const buildAlreadyResolvedCard = (a) => {
236
349
  const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
237
- const dir = dirName(a.cwd);
238
- const tail = oneLine(a.transcriptTail).trim();
239
350
  const jl = detailJumpList(a.detailUrl);
240
351
  return {
241
352
  card_type: "button_interaction",
242
- source: buildSource(tail),
243
- main_title: mainTitle(`${tagOf(a)}${a.toolName} · ${dir}/`, r.desc),
244
- ...(r.body ? { quote_area: quoteArea(r.body) } : {}),
353
+ source: buildSource(a.danger, a.sessionName),
354
+ main_title: { title: TRUNC(cardTitle(a, r), 26) },
355
+ ...bodyBlocks(a, r),
245
356
  ...(jl ? { jump_list: jl } : {}),
246
357
  task_id: a.reqId,
247
358
  button_list: [{ text: "已经放行", style: 4, key: `noop:${a.reqId}` }],
@@ -280,26 +391,33 @@ const renderBatchBody = (batch) => {
280
391
  lines.push(`…还有 ${overflow} 项`);
281
392
  return TRUNC(lines.join("\n"), QUOTE_MAX);
282
393
  };
283
- const buildBatchCard = (batch, transcriptTail) => {
284
- const dir = dirName(batch.members[0]?.cwd ?? "");
394
+ // 批量卡的标题/中段与单卡同构 (会话名 + 工具×N / 成员列表进引用区)。
395
+ const batchTitle = (batch) => `${batch.sessionName || batch.sessionId.slice(-8)} · ${shortTool(batch.toolName)} ×${batch.members.length}`;
396
+ const batchBlocks = (batch, transcriptTail) => {
397
+ const rows = [];
285
398
  const tail = oneLine(transcriptTail).trim();
286
- const emoji = emojiFor(batch.approver);
399
+ if (tail)
400
+ rows.push({ keyname: "上文", value: TRUNC(tail, HMETA_VAL_MAX) });
401
+ if (batch.danger)
402
+ rows.push({ keyname: "危险", value: TRUNC(`命中「${batch.danger}」`, HMETA_VAL_MAX) });
287
403
  return {
288
- card_type: "button_interaction",
289
- source: buildSource(tail),
290
- main_title: { title: `🔐 授权 · ${emoji}${batch.toolName} ×${batch.members.length} · ${dir}/` },
291
404
  quote_area: quoteArea(renderBatchBody(batch)),
292
- task_id: batch.batchId,
293
- button_list: [
294
- { text: "❌", style: 4, key: encodeBatchKey(batch.batchId, "deny") },
295
- { text: fmtWindow(batch.windowMinutes), style: 3, key: encodeBatchKey(batch.batchId, "allow_window") },
296
- { text: "✅", style: 4, key: encodeBatchKey(batch.batchId, "allow") },
297
- ],
405
+ ...(rows.length > 0 ? { horizontal_content_list: rows } : {}),
298
406
  };
299
407
  };
408
+ const buildBatchCard = (batch, transcriptTail) => ({
409
+ card_type: "button_interaction",
410
+ source: buildSource(batch.danger, batch.sessionName),
411
+ main_title: { title: batchTitle(batch) },
412
+ ...batchBlocks(batch, transcriptTail),
413
+ task_id: batch.batchId,
414
+ button_list: [
415
+ { text: "❌", style: 4, key: encodeBatchKey(batch.batchId, "deny") },
416
+ { text: fmtWindow(batch.windowMinutes), style: 3, key: encodeBatchKey(batch.batchId, "allow_window") },
417
+ { text: `✅ ×${batch.members.length}`, style: 4, key: encodeBatchKey(batch.batchId, "allow") },
418
+ ],
419
+ });
300
420
  const buildBatchResolvedCard = (batch, decision, transcriptTail) => {
301
- const dir = dirName(batch.members[0]?.cwd ?? "");
302
- const tail = oneLine(transcriptTail).trim();
303
421
  const button = decision === "allow_window"
304
422
  ? {
305
423
  text: `${verbOf(decision, batch.windowMinutes)} · 点击取消`,
@@ -313,25 +431,21 @@ const buildBatchResolvedCard = (batch, decision, transcriptTail) => {
313
431
  };
314
432
  return {
315
433
  card_type: "button_interaction",
316
- source: buildSource(tail),
317
- main_title: { title: `${emojiFor(batch.approver)}${batch.toolName} ×${batch.members.length} · ${dir}/` },
318
- quote_area: quoteArea(renderBatchBody(batch)),
434
+ source: buildSource(batch.danger, batch.sessionName),
435
+ main_title: { title: batchTitle(batch) },
436
+ ...batchBlocks(batch, transcriptTail),
319
437
  task_id: batch.batchId,
320
438
  button_list: [button],
321
439
  };
322
440
  };
323
- const buildBatchAlreadyResolvedCard = (batch, transcriptTail) => {
324
- const dir = dirName(batch.members[0]?.cwd ?? "");
325
- const tail = oneLine(transcriptTail).trim();
326
- return {
327
- card_type: "button_interaction",
328
- source: buildSource(tail),
329
- main_title: { title: `${emojiFor(batch.approver)}${batch.toolName} ×${batch.members.length} · ${dir}/` },
330
- quote_area: quoteArea(renderBatchBody(batch)),
331
- task_id: batch.batchId,
332
- button_list: [{ text: "已经放行", style: 4, key: encodeBatchNoopKey(batch.batchId) }],
333
- };
334
- };
441
+ const buildBatchAlreadyResolvedCard = (batch, transcriptTail) => ({
442
+ card_type: "button_interaction",
443
+ source: buildSource(batch.danger, batch.sessionName),
444
+ main_title: { title: batchTitle(batch) },
445
+ ...batchBlocks(batch, transcriptTail),
446
+ task_id: batch.batchId,
447
+ button_list: [{ text: "已经放行", style: 4, key: encodeBatchNoopKey(batch.batchId) }],
448
+ });
335
449
  const encodeKey = (reqId, decision) => `${reqId}|${decision}`;
336
450
  const NOOP_PREFIX = "noop:";
337
451
  const CANCEL_PREFIX = "cancel_window:";
@@ -346,7 +460,7 @@ const decodeBatchKey = (key) => {
346
460
  const [batchId, d] = key.slice(BATCH_PREFIX.length).split("|");
347
461
  if (!batchId || !d)
348
462
  return undefined;
349
- if (d !== "allow" && d !== "allow_session" && d !== "allow_window" && d !== "deny")
463
+ if (d !== "allow" && d !== "allow_session" && d !== "allow_window" && d !== "allow_always" && d !== "deny")
350
464
  return undefined;
351
465
  return { batchId, decision: d };
352
466
  };
@@ -362,7 +476,7 @@ const decodeKey = (key) => {
362
476
  const [reqId, d] = key.split("|");
363
477
  if (!reqId || !d)
364
478
  return {};
365
- if (d !== "allow" && d !== "allow_session" && d !== "allow_window" && d !== "deny")
479
+ if (d !== "allow" && d !== "allow_session" && d !== "allow_window" && d !== "allow_always" && d !== "deny")
366
480
  return {};
367
481
  return { reqId, decision: d };
368
482
  };
@@ -1118,18 +1232,94 @@ const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget,
1118
1232
  return { decision: "deny", reason };
1119
1233
  };
1120
1234
  const decisionToHook = (d) => (d === "deny" ? "deny" : "allow");
1121
- // 危险操作永不走 fallbackOnError:"allow" 超时/断线时降级为 ask, 交回本地 CLI
1122
- // 由人来确认, 而不是静默放行一次 rm。
1123
- const fallback = (cfg, reason, danger) => ({
1124
- decision: danger && cfg.approval.fallbackOnError === "allow" ? "ask" : cfg.approval.fallbackOnError,
1125
- reason: danger ? `${reason}:danger` : reason,
1235
+ // 必发卡的请求 (危险名单 / askRules / `.claude/**` 守卫) 永不走 fallbackOnError:
1236
+ // "allow" — 超时/断线时降级为 ask, 交回本地 CLI 由人来确认, 而不是静默放行一次
1237
+ // rm。只判 danger 的话, 用户自己配的 askRules daemon 挂掉时反而失效。
1238
+ const fallback = (cfg, reason, forceSingle) => ({
1239
+ decision: forceSingle && cfg.approval.fallbackOnError === "allow" ? "ask" : cfg.approval.fallbackOnError,
1240
+ reason: forceSingle ? `${reason}:force_single` : reason,
1126
1241
  });
1127
1242
  const resolveApprover = (cfg, sessionId, getMirrorTarget) => {
1128
1243
  const mirror = sessionId ? getMirrorTarget?.(sessionId) : undefined;
1129
1244
  return mirror || pickApprover(cfg);
1130
1245
  };
1131
- export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBeforeCard }) => {
1246
+ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarget, flushBeforeCard, nativeModal }) => {
1247
+ setCardQuoteMax(cfg.approval.cardQuoteMaxChars);
1132
1248
  const detailUrlFor = (id, approver) => buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, approver ? targetChatId(approver) : undefined);
1249
+ // 手机端卡片 quote 区实测只渲染前 2~3 行 —— 长命令在卡上看不全。默认解法已经
1250
+ // 挪到卡片自身 (引用区可点进详情页 + 右上角「📄 展开完整命令」按需发全文), 所以
1251
+ // fullCommandPreludeChars 默认为 0 = 不自动前置。留着这条路是给两种情况兜底:
1252
+ // 客户端不渲染 action_menu, 或者就是想让全文无条件出现在群里 (设成正数即可)。
1253
+ // 用 display(已脱敏)副本, token 类不外泄。仅单成员卡触发 (批量卡逐成员推会刷屏)。
1254
+ const PRELUDE_TRIGGER_CHARS = 200;
1255
+ const sendCommandPrelude = async (batch) => {
1256
+ const cap = cfg.approval.fullCommandPreludeChars;
1257
+ if (cap <= 0 || batch.members.length !== 1)
1258
+ return;
1259
+ const cmd = commandOf(batch.toolName, batch.members[0].toolInput);
1260
+ if (cmd.length <= PRELUDE_TRIGGER_CHARS)
1261
+ return;
1262
+ const text = cmd.length > cap ? `${cmd.slice(0, cap)}\n…(已截断,完整命令共 ${cmd.length} 字)` : cmd;
1263
+ const chunks = chunkText(text, FULLCMD_CHUNK_CHARS);
1264
+ const target = targetChatId(batch.approver);
1265
+ for (let i = 0; i < chunks.length; i++) {
1266
+ const head = i === 0
1267
+ ? `🔐 待审批完整命令(${cmd.length} 字${chunks.length > 1 ? `,${i + 1}/${chunks.length}` : ""}):\n`
1268
+ : `(${i + 1}/${chunks.length})\n`;
1269
+ await client.sendMessage(target, {
1270
+ msgtype: "markdown",
1271
+ markdown: { content: withTagHeader(batch.approver, head + chunks[i]) },
1272
+ });
1273
+ }
1274
+ };
1275
+ // 「总是」的回执/说明走独立 markdown 消息: 卡片本身已被 updateTemplateCard 收成
1276
+ // 终态, 没有位置再讲"为什么这条规则没存下来"。
1277
+ const notify = async (approver, content) => {
1278
+ try {
1279
+ // withTagHeader: 同一 chat 可能并行跑着多个 `#tag` 会话, 裸消息看不出归属。
1280
+ await client.sendMessage(targetChatId(approver), { msgtype: "markdown", markdown: { content: withTagHeader(approver, content) } });
1281
+ }
1282
+ catch (e) {
1283
+ log.warn({ err: e.message }, "notify send failed");
1284
+ }
1285
+ };
1286
+ // 批准一次 `.claude/**` 写操作之后的收尾: 把 CC 那个不过 hook 的原生确认框按掉。
1287
+ //
1288
+ // 必须在 json(res) **之后**跑 —— CC 只有等 hook 进程退出才会继续走它自己的守卫、
1289
+ // 才会把框渲染出来。所以这里是 fire-and-forget, 由 answer() 内部轮询等框出现。
1290
+ //
1291
+ // 按不掉时不干等: Esc 取消掉这次调用, 再把原因作为一条用户消息注入 pane。模型
1292
+ // 收到的就是明确的"这条路走不通 + 该怎么绕", 而不是静默卡住 —— 等价于预拦截
1293
+ // deny + reason, 只是走镜像通道传达。
1294
+ const settleClaudeConfigModal = async (sessionId, approver, hit) => {
1295
+ const nm = nativeModal;
1296
+ if (!nm)
1297
+ return;
1298
+ const r = await nm.answer(sessionId, { waitMs: cfg.approval.claudeConfigModalWaitMs });
1299
+ if (r.status === "answered") {
1300
+ log.info({ sessionId, path: hit.path, pressed: `${r.index}. ${r.label}` }, "claude-config modal answered");
1301
+ await notify(approver, `🔓 已代按 CLI 原生确认框「${r.title ?? "确认"}」→ 选项 ${r.index}. ${r.label}(${hit.path})`);
1302
+ return;
1303
+ }
1304
+ if (r.status === "no_modal") {
1305
+ log.info({ sessionId, path: hit.path }, "claude-config modal never appeared (nothing to press)");
1306
+ return;
1307
+ }
1308
+ // unparsable / still_modal / no_pane —— 走取消 + 告知。
1309
+ log.warn({ sessionId, path: hit.path, status: r.status }, "claude-config modal not answered, cancelling");
1310
+ const cancelled = await nm.cancel(sessionId);
1311
+ // Esc 之后 TUI 要一拍才回到输入框; 注入本身还有 modal 守卫兜底(框没退就拒绝注入)。
1312
+ if (cancelled.ok)
1313
+ await new Promise((res) => setTimeout(res, 800));
1314
+ const why = `⚠️ 刚才那步(写 \`${hit.path}\`)被 wezard 取消了:`
1315
+ + `改动 \`.claude/**\` 会让 Claude Code 弹它自己的原生确认框,那个框不经过 PreToolUse hook、`
1316
+ + `企微端点不到,我这次也没能安全代按(${r.status})。`
1317
+ + `请改用实体真实路径(例如把 skill 实体放在别处再软链回 \`.claude/skills/\`),`
1318
+ + `或让我在你本地终端旁边时再做这一步。`;
1319
+ const told = cancelled.ok ? await nm.tell(sessionId, why) : { ok: false, reason: cancelled.reason };
1320
+ await notify(approver, `⚠️ 原生确认框未能代按(${r.status})。已${cancelled.ok ? "" : "尝试"}发 Esc 取消本次调用`
1321
+ + `${told.ok ? ",并把原因告知了会话" : `(原因注入失败:${told.reason ?? "unknown"},请到 tmux 里看一眼)`}。`);
1322
+ };
1133
1323
  // Flush 一个 batch: 单成员 → 普通卡 (与未启用聚合一致); 多成员 → 批量卡。
1134
1324
  // 发送失败时调用 failPending 让每位成员的 handler 走 fallbackOnError 路径,
1135
1325
  // 与单卡路径上 sendMessage 抛错时的语义一致。
@@ -1158,6 +1348,8 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1158
1348
  transcriptTail: m.transcriptTail,
1159
1349
  windowMinutes: batch.windowMinutes,
1160
1350
  danger: batch.danger,
1351
+ sessionName: batch.sessionName,
1352
+ denyReason: batch.denyReason,
1161
1353
  detailUrl: detailUrlFor(m.reqId, batch.approver),
1162
1354
  });
1163
1355
  })();
@@ -1171,10 +1363,22 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1171
1363
  catch (e) {
1172
1364
  log.warn({ batchId: batch.batchId, err: e.message }, "flushBatch flushBeforeCard failed; sending card anyway");
1173
1365
  }
1174
- await client.sendMessage(targetChatId(batch.approver), {
1175
- msgtype: "template_card",
1176
- template_card: card,
1177
- });
1366
+ // 前置完整命令消息 (best-effort, 失败不阻断发卡)
1367
+ try {
1368
+ await sendCommandPrelude(batch);
1369
+ }
1370
+ catch (e) {
1371
+ log.warn({ batchId: batch.batchId, err: e.message }, "command prelude send failed");
1372
+ }
1373
+ const sendCard = (c) => client.sendMessage(targetChatId(batch.approver), { msgtype: "template_card", template_card: c });
1374
+ try {
1375
+ await sendCard(card);
1376
+ }
1377
+ catch (e) {
1378
+ // quote 体可能超平台未公开的长度上限 — 缩到安全值重试一次, 保住审批流。
1379
+ log.warn({ batchId: batch.batchId, err: e.message }, "card send failed — retrying with shrunk quote");
1380
+ await sendCard(shrinkQuote(card));
1381
+ }
1178
1382
  log.info({ batchId: batch.batchId, count: batch.members.length, multi: isMulti, approver: batch.approver, tool: batch.toolName }, "batch flushed");
1179
1383
  // 只给单卡打「可续接」标: 批量卡的点击要靠内存里的 batchById 才能一次
1180
1384
  // resolve N 个成员, 那张表撑不过重启 —— 续接了反而会让成员干等一张点了
@@ -1213,6 +1417,52 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1213
1417
  json(res, 200, { decision: "allow", reason: "matcher_skip" });
1214
1418
  return;
1215
1419
  }
1420
+ // Claude-Code 三层规则语义: deny > ask > allow (语法同源, 见 allow-rules.ts)。
1421
+ // denyRules: 命中直接拒, 不发卡 (Bash 复合命令任一段命中即拒)。
1422
+ const denyHit = ruleMatchesAny(cfg.approval.denyRules, toolName, toolInput);
1423
+ if (denyHit) {
1424
+ log.info({ toolName, sessionId, rule: denyHit }, "deny-rule reject");
1425
+ json(res, 200, { decision: "deny", reason: `deny_rule:${denyHit}` });
1426
+ return;
1427
+ }
1428
+ // askRules: 命中必发卡 — 压过 allowRules、自动放行窗口与会话缓存 (对齐
1429
+ // Claude permissions.ask 的"即使 allowlist 命中也要确认"语义)。
1430
+ const askHit = ruleMatchesAny(cfg.approval.askRules, toolName, toolInput);
1431
+ if (askHit) {
1432
+ log.info({ toolName, sessionId, rule: askHit }, "ask-rule force card");
1433
+ }
1434
+ // `.claude/**` 写守卫 (见 shared/claude-config-path.ts): 这类改动会让 CC 立起
1435
+ // 它自己的原生确认框, 那个框不过 hook —— 规则一放行就是"不发卡 + pane 阻塞"的
1436
+ // 静默死锁。命中且该 session 有活 pane 可代按时强制发卡 (压过 allowRules /
1437
+ // ⏱窗口 / 会话缓存), 批准后 settleClaudeConfigModal 去把框按掉。
1438
+ // 没有活 pane (headless / 未镜像的本地会话) → 不介入: 那种情形用户就在键盘前,
1439
+ // 自己按掉即可, 拦下来只是挡工作。
1440
+ const guardHit = cfg.approval.claudeConfigGuard ? claudeConfigWrite(toolName, toolInput) : undefined;
1441
+ const guardActive = Boolean(guardHit && nativeModal?.hasPane(sessionId));
1442
+ if (guardHit) {
1443
+ log.info({ toolName, sessionId, path: guardHit.path, why: guardHit.why, guardActive }, guardActive ? "claude-config guard force card" : "claude-config write detected (no live pane, passing through)");
1444
+ }
1445
+ // 危险名单 (daemon/danger.ts): 内置的 rm / 强推 / DROP / 敏感路径等。语义上
1446
+ // 是「出厂自带的 askRules」—— 所以它必须和用户写的 askRules 站在同一层, 排在
1447
+ // allowRules 之前。放在后面的话, 一条 `Bash(git *)` 这样的宽 allow 规则 (尤其
1448
+ // 是从 Claude settings.json 批量导入来的) 就能让整份危险名单失效。
1449
+ const danger = dangerOf(cfg, toolName, toolInput);
1450
+ if (danger)
1451
+ log.info({ toolName, sessionId, rule: danger.rule }, "danger hit — forcing single approval");
1452
+ // 必发卡 = 危险名单 或 askRules 命中 或 守卫生效。三者都要压过放行/窗口/缓存。
1453
+ const mustCard = Boolean(danger) || Boolean(askHit) || guardActive;
1454
+ // allowRules: matcher 拦下的工具里再挖细粒度豁免 (Bash 可按命令前缀区分)。
1455
+ // 交互卡工具 (AskUserQuestion 等) 在引擎内部硬保护, 规则写了也不放行。
1456
+ // 用 evaluateAllow 而非 ruleAllows: 未命中时要把"因为哪一段"带到卡上,
1457
+ // 光知道"没放行"帮不了用户判断。判定本身零额外开销 (原本就要跑这一次)。
1458
+ const verdict = evaluateAllow(cfg.approval.allowRules, toolName, toolInput);
1459
+ if (!mustCard && verdict.allowed) {
1460
+ const ruleHit = [...new Set(verdict.hits)].join(" + ");
1461
+ log.info({ toolName, sessionId, rule: ruleHit }, "allow-rule skip");
1462
+ json(res, 200, { decision: "allow", reason: `allow_rule:${ruleHit}` });
1463
+ return;
1464
+ }
1465
+ const denyReason = verdict.allowed ? undefined : verdict.reason;
1216
1466
  // EnterPlanMode: block model-initiated plan mode. deny + reason 回传 model,
1217
1467
  // 让它别进 plan mode、直接干活。用户仍可在本地 Shift+Tab 手动进 plan mode
1218
1468
  // (那条路径不过 hook)。由 config.approval.blockAutoPlanMode 控制(默认 true)。
@@ -1277,22 +1527,18 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1277
1527
  // 检查要拿到 chatKey (= approver principal) 才能查。approver 缺失时 window
1278
1528
  // 检查跳过 (window 需要一个 chat 才存在), 走后面的 no_approver fallback。
1279
1529
  const approver = resolveApprover(cfg, sessionId, getMirrorTarget);
1280
- // 危险名单: 命中者跳过 auto-window / session cache / 批量合流, 每次都单独发卡。
1281
- const danger = dangerOf(cfg, toolName, toolInput);
1282
- if (danger)
1283
- log.info({ toolName, sessionId, rule: danger.rule }, "danger hit — forcing single approval");
1284
- // danger.skip: 命中危险名单也直接放行 (跳过 danger)。
1285
- if (dangerSkips(cfg, danger)) {
1286
- json(res, 200, { decision: "allow", reason: "danger_skip" });
1287
- return;
1288
- }
1289
- // danger 模式: 名单之外的调用不打扰人, 直接放行 (卡只留给真正危险的操作)。
1290
- if (dangerModeSkips(cfg, danger)) {
1291
- json(res, 200, { decision: "allow", reason: "danger_mode_skip" });
1530
+ // danger.skip / danger 模式的早退。第三个参数是「除 danger 外还有没有别的必发卡
1531
+ // 理由」—— askRules `.claude/**` 守卫不能被 danger 的开关顺带关掉, 判定与
1532
+ // 理由见 dangerEarlyExit 的注释 (守卫被绕过会让 pane 死锁)
1533
+ const earlyExit = dangerEarlyExit(cfg, danger, Boolean(askHit) || guardActive);
1534
+ if (earlyExit) {
1535
+ log.info({ toolName, sessionId, reason: earlyExit }, "danger switch early exit");
1536
+ json(res, 200, { decision: "allow", reason: earlyExit });
1292
1537
  return;
1293
1538
  }
1294
1539
  // Auto-approve window: while active for THIS chat, requests short-circuit to allow.
1295
- if (!danger && approver && isAutoWindowActive(approver)) {
1540
+ // mustCard (危险名单 / askRules / `.claude/**` 守卫) 的请求不吃窗口 — 即使开着 ⏱ 也逐条确认。
1541
+ if (!mustCard && approver && isAutoWindowActive(approver)) {
1296
1542
  const remainSec = Math.ceil(autoWindowRemainingMs(approver) / 1000);
1297
1543
  log.info({ toolName, sessionId, chatKey: approver, remainSec }, "auto-window allow");
1298
1544
  json(res, 200, {
@@ -1301,9 +1547,11 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1301
1547
  });
1302
1548
  return;
1303
1549
  }
1304
- // Session cache
1550
+ // Session cache (mustCard 的请求同样不吃缓存)
1305
1551
  const ck = cacheKey(sessionId, toolName, toolInput);
1306
- const cached = danger ? undefined : cacheGet(ck);
1552
+ // mustCard(危险名单 / askRules / 守卫)一律不吃缓存 —— 缓存的语义是「这个调用批过一次
1553
+ // 就不再问」, 与「每次都要单独确认」直接冲突。
1554
+ const cached = mustCard ? undefined : cacheGet(ck);
1307
1555
  if (cached) {
1308
1556
  log.info({ ck, cached }, "cache hit");
1309
1557
  json(res, 200, {
@@ -1314,12 +1562,12 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1314
1562
  }
1315
1563
  if (!approver) {
1316
1564
  log.warn("no approver configured");
1317
- json(res, 200, fallback(cfg, "no_approver", danger));
1565
+ json(res, 200, fallback(cfg, "no_approver", mustCard));
1318
1566
  return;
1319
1567
  }
1320
1568
  if (!client.isConnected) {
1321
1569
  log.warn("ws not connected");
1322
- json(res, 200, fallback(cfg, "ws_disconnected", danger));
1570
+ json(res, 200, fallback(cfg, "ws_disconnected", mustCard));
1323
1571
  return;
1324
1572
  }
1325
1573
  // Build pending + card
@@ -1333,6 +1581,9 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1333
1581
  }
1334
1582
  })();
1335
1583
  const longPollMs = cfg.approval.longPollSec * 1000;
1584
+ // 卡片标题上的会话身份 — 发卡前算一次, 存进 meta 供 resolved 卡复用
1585
+ // (#tag 直接取; 否则读 transcript 首条用户消息, 带缓存)。
1586
+ const sessionName = sessionNameFor(approver, body.transcript_path, sessionId);
1336
1587
  // Reload 续接: hook 带着上一轮的 req_id 回来, 复用同一个 id 重新挂起 ——
1337
1588
  // WeCom 上那张卡的按钮编的就是它, 于是旧卡的点击照样能 resolve 这次长轮询。
1338
1589
  const resumeId = (body.resume_req_id ?? "").trim();
@@ -1347,6 +1598,9 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1347
1598
  chatKey: approver,
1348
1599
  transcriptTail,
1349
1600
  danger: danger?.rule,
1601
+ forceSingle: mustCard,
1602
+ sessionName,
1603
+ denyReason,
1350
1604
  cardSent: Boolean(resumeId), // 续接的前提就是卡已经在群里
1351
1605
  },
1352
1606
  timeoutMs: longPollMs,
@@ -1366,8 +1620,9 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1366
1620
  // 续接的请求不再进 batch: 卡已经在群里, 重发就是重复轰炸。
1367
1621
  const member = { reqId, toolInput: display, originalToolInput: toolInput, toolInputStr, cwd, transcriptTail };
1368
1622
  const bk = batchKeyOf(sessionId, toolName);
1369
- // 危险请求既不 join 也不被 join — 一次危险操作 = 一张卡 = 一次点击。
1370
- const existing = danger ? undefined : activeBatches.get(bk);
1623
+ // 必发卡请求既不 join 也不被 join — 一次这样的操作 = 一张卡 = 一次点击。
1624
+ // 合流会把它塞进带「⏱全过 / ✅总是」的批量卡, 一次点击就连它一起放行了。
1625
+ const existing = mustCard ? undefined : activeBatches.get(bk);
1371
1626
  if (resumeId) {
1372
1627
  // no-op: 直接进下面的长轮询, 等旧卡上的点击。
1373
1628
  }
@@ -1383,14 +1638,16 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1383
1638
  approver,
1384
1639
  windowMinutes: cfg.approval.windowMinutes,
1385
1640
  danger: danger?.rule,
1641
+ sessionName,
1642
+ denyReason,
1386
1643
  members: [member],
1387
1644
  flushed: false,
1388
1645
  flushTimer: undefined, // set below
1389
1646
  };
1390
- const coalesceMs = danger ? 0 : cfg.approval.batchCoalesceMs;
1647
+ const coalesceMs = mustCard ? 0 : cfg.approval.batchCoalesceMs;
1391
1648
  const fire = () => void flushBatch(batch);
1392
1649
  batch.flushTimer = coalesceMs > 0 ? setTimeout(fire, coalesceMs) : setImmediate(fire);
1393
- if (!danger)
1650
+ if (!mustCard)
1394
1651
  activeBatches.set(bk, batch);
1395
1652
  batchById.set(batch.batchId, batch);
1396
1653
  evictBatches();
@@ -1410,15 +1667,63 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1410
1667
  return;
1411
1668
  }
1412
1669
  log.warn({ err: e.message, reqId }, "approval timed out");
1413
- json(res, 200, fallback(cfg, "approver_timeout", danger));
1670
+ json(res, 200, fallback(cfg, "approver_timeout", mustCard));
1414
1671
  return;
1415
1672
  }
1416
- // 危险决策一律不留痕: 不写 session cache、不开自动窗口 (卡上本就没这两个
1673
+ // 必发卡的决策一律不留痕: 不写 session cache、不开自动窗口 (卡上本就没这两个
1417
1674
  // 按钮, 这里是防御性兜底 —— 决策也可能来自 sweep / 旧卡)。
1418
- if (!danger && decision === "allow_session" && cfg.approval.sessionCacheMinutes > 0) {
1675
+ if (!mustCard && decision === "allow_session" && cfg.approval.sessionCacheMinutes > 0) {
1419
1676
  cachePut(ck, decision, cfg.approval.sessionCacheMinutes * 60_000);
1420
1677
  }
1421
- if (!danger && decision === "allow_window" && cfg.approval.windowMinutes > 0) {
1678
+ // 「✅ 总是」: 由本次调用生成规则, 热生效 + 写回 config.jsonc (对齐 Claude Code
1679
+ // 原生弹窗的 Always allow)。规则生成必须用**未脱敏**的原始 toolInput —— display
1680
+ // 可能被 sensitiveArgRedact 改写过, 拿它生成的前缀匹配不上真实命令。
1681
+ if (decision === "allow_always") {
1682
+ // 命中 askRules 的调用: allow 规则永远被 ask 压过, 存了也是死规则。提示真实
1683
+ // 的生效路径, 而不是静默写入一条永不生效的配置。
1684
+ if (askHit) {
1685
+ await notify(approver, `⚠️ 该命令命中强制审批规则 \`${askHit}\`(askRules 优先于放行规则),「总是」不会生效,本次已放行。如确要永久放行,需从 config.jsonc 的 askRules 移除该规则。`);
1686
+ }
1687
+ // 危险名单同理 (与 askRules 同层, allow 压不过它)。危险卡上本就没有「总是」
1688
+ // 按钮, 这里是防御性兜底 —— 决策也可能来自 sweep / 旧卡。
1689
+ if (!askHit && danger) {
1690
+ await notify(approver, `⚠️ 该操作命中危险名单「${danger.rule}」,不支持「总是」:这类操作每次都要单独确认。`
1691
+ + `本次已放行。如确要长期免审,请在 config.jsonc 的 \`approval.danger.allowPatterns\` 里加豁免正则。`);
1692
+ }
1693
+ const gen = askHit || danger ? [] : alwaysAllowRulesFor(toolName, toolInput, cfg.approval.allowRules);
1694
+ const added = gen.filter((r) => !cfg.approval.allowRules.includes(r));
1695
+ if (added.length > 0) {
1696
+ cfg.approval.allowRules.push(...added); // 先热生效; 文件写失败也不回滚内存
1697
+ if (sourcePath) {
1698
+ try {
1699
+ for (const r of added)
1700
+ appendUnique(sourcePath, ["approval", "allowRules"], r);
1701
+ }
1702
+ catch (e) {
1703
+ log.warn({ err: e.message }, "allow_always persist failed (in-memory only)");
1704
+ }
1705
+ }
1706
+ log.info({ toolName, added, persisted: Boolean(sourcePath) }, "allow_always rules saved");
1707
+ await notify(approver, `📌 已保存永久放行规则:${added.map((r) => `\`${r}\``).join("、")}`);
1708
+ }
1709
+ else if (!askHit && !danger && gen.length === 0) {
1710
+ // 提炼不出可靠字面规则 —— 本次一次性放行并告知。文案必须指对排查方向:
1711
+ // 成因有四种, 笼统说"引号/结构有问题"会把用户带偏 (例如真凶是 fd 重定向
1712
+ // 的 `&` 被当成后台执行符时, 用户会去查引号)。
1713
+ const cmdStr = typeof toolInput?.command === "string"
1714
+ ? toolInput.command
1715
+ : "";
1716
+ const why = NEVER_RULE_ALLOW.has(toolName)
1717
+ ? `\`${toolName}\` 是交互工具,永不支持免审(引擎内部硬保护)`
1718
+ : /[`]|\$\(/.test(cmdStr)
1719
+ ? "该命令含动态构造($() / 反引号),字面规则无法可靠描述其行为"
1720
+ : splitSegments(cmdStr) === undefined
1721
+ ? "该命令含未闭合引号或后台执行符 `&`,无法安全分段"
1722
+ : "该命令的结构提炼不出可靠前缀(异形段首、或解释器头部拿不到子命令)";
1723
+ await notify(approver, `📌 ${why},本次已一次性放行(未保存规则)。`);
1724
+ }
1725
+ }
1726
+ if (!mustCard && decision === "allow_window" && cfg.approval.windowMinutes > 0) {
1422
1727
  setAutoWindow(approver, cfg.approval.windowMinutes * 60_000, {
1423
1728
  toolName,
1424
1729
  toolInput: display,
@@ -1453,6 +1758,14 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1453
1758
  decision: decisionToHook(decision),
1454
1759
  reason: decision,
1455
1760
  });
1761
+ // 放行 `.claude/**` 写操作后, CC 会在 hook 退出后立起自己的原生确认框 —— 必须
1762
+ // 等响应发出去才能去按 (框此刻还不存在)。fire-and-forget: 这条 HTTP 请求已经
1763
+ // 结束, 失败也只能靠告知, 不能再改判决。
1764
+ if (guardActive && guardHit && decisionToHook(decision) === "allow") {
1765
+ void settleClaudeConfigModal(sessionId, approver, guardHit).catch((e) => {
1766
+ log.warn({ err: e.message, sessionId }, "settleClaudeConfigModal failed");
1767
+ });
1768
+ }
1456
1769
  };
1457
1770
  };
1458
1771
  // ── Card click event → resolvePending + update card in place ────────────
@@ -1472,6 +1785,47 @@ export const installApprovalEventListener = (client, log, cfg, onApproved) => {
1472
1785
  const key = ev?.template_card_event?.event_key ?? ev?.event_key ?? "";
1473
1786
  // Update 时 task_id 必须跟回调里的一致,否则微信会拒掉更新。
1474
1787
  const cbTaskId = ev?.template_card_event?.task_id ?? ev?.task_id ?? "";
1788
+ // ── action_menu「📄 展开完整命令」: 纯展示, 不改判决、不 resolve pending。
1789
+ // 取 detail store 而不是 pending —— 用户很可能是点完 ✅ 之后回头想看全文,
1790
+ // 那时 pending 早被删了; detail 记录留存 24h, 且卡片解决后依然可点。
1791
+ if (key.startsWith(FULLCMD_PREFIX)) {
1792
+ const reqId = key.slice(FULLCMD_PREFIX.length);
1793
+ const rec = getDetail(reqId);
1794
+ // 回复目标: 待决时在 pending 里, 已决时 pending 已删 —— 回落到 resolved
1795
+ // 暂存。两个都没有才用 defaultChat (`#tag` 会话会因此回到主会话, 不理想,
1796
+ // 但那已经是记录过期的边缘情形)。
1797
+ const target = getPending(reqId)?.chatKey
1798
+ ?? getResolvedSnapshot(reqId)?.meta.chatKey
1799
+ ?? cfg.defaultChat;
1800
+ const cmd = rec && rec.kind === "approval" ? commandOf(rec.toolName, rec.toolInput) : "";
1801
+ // 只能走主动 markdown 分块 (≤1800 字单条)。曾试过对点击事件做 stream 被动
1802
+ // 回复 (单条 20480 字节) —— 企微服务端拒绝: errcode=846605 invalid req_id,
1803
+ // 卡片事件的 req_id 仅可用于 updateTemplateCard, 不进被动回复通道。
1804
+ void (async () => {
1805
+ try {
1806
+ if (!cmd) {
1807
+ await client.sendMessage(targetChatId(target), {
1808
+ msgtype: "markdown",
1809
+ markdown: { content: withTagHeader(target, "⌛ 该命令的详情已过期(记录只保留 24 小时),无法展开。") },
1810
+ });
1811
+ return;
1812
+ }
1813
+ for (const [i, chunk] of chunkText(cmd, FULLCMD_CHUNK_CHARS).entries()) {
1814
+ const n = Math.ceil(cmd.length / FULLCMD_CHUNK_CHARS);
1815
+ const head = n > 1 ? `📄 完整命令(${cmd.length} 字,${i + 1}/${n}):\n` : `📄 完整命令(${cmd.length} 字):\n`;
1816
+ await client.sendMessage(targetChatId(target), {
1817
+ msgtype: "markdown",
1818
+ markdown: { content: withTagHeader(target, head + chunk) },
1819
+ });
1820
+ }
1821
+ log.info({ reqId, len: cmd.length, target }, "full command expanded on demand");
1822
+ }
1823
+ catch (e) {
1824
+ log.warn({ err: e.message, reqId }, "full command expand failed");
1825
+ }
1826
+ })();
1827
+ return;
1828
+ }
1475
1829
  // ── AskUserQuestion 投票卡: 在普通 approval 解码前先匹配 ASKQ| 前缀。
1476
1830
  const askq = decodeAskqKey(key);
1477
1831
  if (askq) {
@@ -1698,6 +2052,8 @@ export const installApprovalEventListener = (client, log, cfg, onApproved) => {
1698
2052
  chatKey: meta?.chatKey ?? "",
1699
2053
  transcriptTail: meta?.transcriptTail ?? "",
1700
2054
  windowMinutes: cfg.approval.windowMinutes,
2055
+ sessionName: meta?.sessionName,
2056
+ denyReason: meta?.denyReason,
1701
2057
  detailUrl: detailUrlFor(reqId, meta?.chatKey),
1702
2058
  })
1703
2059
  : buildResolvedCard({
@@ -1714,6 +2070,8 @@ export const installApprovalEventListener = (client, log, cfg, onApproved) => {
1714
2070
  sessionId: meta?.sessionId ?? "",
1715
2071
  chatKey: meta?.chatKey ?? "",
1716
2072
  danger: meta?.danger,
2073
+ sessionName: meta?.sessionName,
2074
+ denyReason: meta?.denyReason,
1717
2075
  detailUrl: detailUrlFor(reqId, meta?.chatKey),
1718
2076
  });
1719
2077
  await client.updateTemplateCard(frame, card);