wezard 0.3.3

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 (117) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/.claude-plugin/plugin.json +14 -0
  3. package/CLAUDE.md +83 -0
  4. package/LICENSE +21 -0
  5. package/README.md +410 -0
  6. package/cli/weclaude.sh +236 -0
  7. package/commands/audit.md +7 -0
  8. package/commands/wrc.md +7 -0
  9. package/config.example.jsonc +102 -0
  10. package/dist/cli/audit.js +156 -0
  11. package/dist/cli/audit.js.map +1 -0
  12. package/dist/cli/init.js +311 -0
  13. package/dist/cli/init.js.map +1 -0
  14. package/dist/cli/sync.js +175 -0
  15. package/dist/cli/sync.js.map +1 -0
  16. package/dist/daemon/approval.js +1301 -0
  17. package/dist/daemon/approval.js.map +1 -0
  18. package/dist/daemon/ask.js +101 -0
  19. package/dist/daemon/ask.js.map +1 -0
  20. package/dist/daemon/audit.js +168 -0
  21. package/dist/daemon/audit.js.map +1 -0
  22. package/dist/daemon/cc-bridge.js +204 -0
  23. package/dist/daemon/cc-bridge.js.map +1 -0
  24. package/dist/daemon/cfg-sync.js +240 -0
  25. package/dist/daemon/cfg-sync.js.map +1 -0
  26. package/dist/daemon/claim.js +95 -0
  27. package/dist/daemon/claim.js.map +1 -0
  28. package/dist/daemon/detail.js +153 -0
  29. package/dist/daemon/detail.js.map +1 -0
  30. package/dist/daemon/graph.js +176 -0
  31. package/dist/daemon/graph.js.map +1 -0
  32. package/dist/daemon/http.js +83 -0
  33. package/dist/daemon/http.js.map +1 -0
  34. package/dist/daemon/inbound.js +919 -0
  35. package/dist/daemon/inbound.js.map +1 -0
  36. package/dist/daemon/index.js +569 -0
  37. package/dist/daemon/index.js.map +1 -0
  38. package/dist/daemon/last-response.js +59 -0
  39. package/dist/daemon/last-response.js.map +1 -0
  40. package/dist/daemon/mirror-bridge.js +3512 -0
  41. package/dist/daemon/mirror-bridge.js.map +1 -0
  42. package/dist/daemon/mirror-store.js +39 -0
  43. package/dist/daemon/mirror-store.js.map +1 -0
  44. package/dist/daemon/outbound.js +42 -0
  45. package/dist/daemon/outbound.js.map +1 -0
  46. package/dist/daemon/peers.js +146 -0
  47. package/dist/daemon/peers.js.map +1 -0
  48. package/dist/daemon/pending.js +90 -0
  49. package/dist/daemon/pending.js.map +1 -0
  50. package/dist/daemon/quota.js +164 -0
  51. package/dist/daemon/quota.js.map +1 -0
  52. package/dist/daemon/redact.js +27 -0
  53. package/dist/daemon/redact.js.map +1 -0
  54. package/dist/daemon/session-cache.js +149 -0
  55. package/dist/daemon/session-cache.js.map +1 -0
  56. package/dist/daemon/session-scan.js +273 -0
  57. package/dist/daemon/session-scan.js.map +1 -0
  58. package/dist/daemon/sessions.js +35 -0
  59. package/dist/daemon/sessions.js.map +1 -0
  60. package/dist/daemon/spawn-tmux.js +213 -0
  61. package/dist/daemon/spawn-tmux.js.map +1 -0
  62. package/dist/daemon/tips.js +25 -0
  63. package/dist/daemon/tips.js.map +1 -0
  64. package/dist/daemon/topics.js +197 -0
  65. package/dist/daemon/topics.js.map +1 -0
  66. package/dist/daemon/usage.js +358 -0
  67. package/dist/daemon/usage.js.map +1 -0
  68. package/dist/daemon/wedoc.js +210 -0
  69. package/dist/daemon/wedoc.js.map +1 -0
  70. package/dist/daemon/ws.js +67 -0
  71. package/dist/daemon/ws.js.map +1 -0
  72. package/dist/mcp/server.js +385 -0
  73. package/dist/mcp/server.js.map +1 -0
  74. package/dist/shared/ansi.js +134 -0
  75. package/dist/shared/ansi.js.map +1 -0
  76. package/dist/shared/chat-http.js +195 -0
  77. package/dist/shared/chat-http.js.map +1 -0
  78. package/dist/shared/chat-render.js +53 -0
  79. package/dist/shared/chat-render.js.map +1 -0
  80. package/dist/shared/chat-view.js +116 -0
  81. package/dist/shared/chat-view.js.map +1 -0
  82. package/dist/shared/cli-backends.js +304 -0
  83. package/dist/shared/cli-backends.js.map +1 -0
  84. package/dist/shared/config-writer.js +39 -0
  85. package/dist/shared/config-writer.js.map +1 -0
  86. package/dist/shared/config.js +278 -0
  87. package/dist/shared/config.js.map +1 -0
  88. package/dist/shared/detail-render.js +836 -0
  89. package/dist/shared/detail-render.js.map +1 -0
  90. package/dist/shared/detail-store.js +205 -0
  91. package/dist/shared/detail-store.js.map +1 -0
  92. package/dist/shared/highlight.js +154 -0
  93. package/dist/shared/highlight.js.map +1 -0
  94. package/dist/shared/lan-ip.js +35 -0
  95. package/dist/shared/lan-ip.js.map +1 -0
  96. package/dist/shared/log.js +18 -0
  97. package/dist/shared/log.js.map +1 -0
  98. package/dist/shared/md-chunk.js +99 -0
  99. package/dist/shared/md-chunk.js.map +1 -0
  100. package/dist/shared/paths.js +12 -0
  101. package/dist/shared/paths.js.map +1 -0
  102. package/dist/shared/session-label.js +67 -0
  103. package/dist/shared/session-label.js.map +1 -0
  104. package/dist/shared/web-assets.js +51 -0
  105. package/dist/shared/web-assets.js.map +1 -0
  106. package/dist/svr/index.js +246 -0
  107. package/dist/svr/index.js.map +1 -0
  108. package/hooks/hooks.json +16 -0
  109. package/hooks/pre-tool-use.sh +167 -0
  110. package/launchd/com.weclaude.daemon.plist.template +45 -0
  111. package/package.json +97 -0
  112. package/scripts/install.sh +73 -0
  113. package/scripts/postinstall.sh +12 -0
  114. package/scripts/uninstall.sh +44 -0
  115. package/systemd/weclaude.service.template +17 -0
  116. package/web/chat.css +171 -0
  117. package/web/chat.js +367 -0
@@ -0,0 +1,1301 @@
1
+ import { createPending, getPending, getResolvedSnapshot, resolvePending, resolvePendingsByChat, failPending } from "./pending.js";
2
+ import { cacheGet, cachePut, cacheKey, isAutoWindowActive, autoWindowRemainingMs, setAutoWindow, clearAutoWindow, getWindowMeta, } from "./session-cache.js";
3
+ import { redact } from "./redact.js";
4
+ import { recordApproval, recordApprovalDecision, buildDetailUrl } from "./detail.js";
5
+ import { json, readBody } from "./http.js";
6
+ import { tagBadge, withTagHeader } from "../shared/session-label.js";
7
+ // ── Routing helpers ────────────────────────────────────────────────────
8
+ const targetChatId = (principal) => {
9
+ // "user:abc" → "abc" (DM chatid == userid for aibot)
10
+ // "chat:wc..." → "wc..."
11
+ // "user:abc#tag" → "abc" (drop the routing tag; WeCom SDK only knows chatids)
12
+ // raw fallthrough
13
+ const i = principal.indexOf(":");
14
+ const rest = i >= 0 ? principal.slice(i + 1) : principal;
15
+ const h = rest.indexOf("#");
16
+ return h >= 0 ? rest.slice(0, h) : rest;
17
+ };
18
+ const pickApprover = (cfg) => {
19
+ if (cfg.approval.approvers.length > 0)
20
+ return cfg.approval.approvers[0];
21
+ if (cfg.defaultChat)
22
+ return cfg.defaultChat;
23
+ return undefined;
24
+ };
25
+ const TRUNC = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
26
+ const HOME_RE = /^\/Users\/[^/]+/;
27
+ const fmtPath = (p) => p.replace(HOME_RE, "~");
28
+ const relToCwd = (p, cwd) => {
29
+ if (!p)
30
+ return "";
31
+ if (cwd && p === cwd)
32
+ return ".";
33
+ if (cwd && p.startsWith(`${cwd}/`))
34
+ return p.slice(cwd.length + 1);
35
+ return fmtPath(p);
36
+ };
37
+ const oneLine = (s) => s.replace(/\s*\n\s*/g, " ⏎ ");
38
+ const takeFirstLines = (s, lines, maxChars) => TRUNC(s.split("\n").slice(0, lines).join("\n"), maxChars);
39
+ const EDIT_SNIPPET_LEN = 160;
40
+ const WRITE_PREVIEW_LINES = 4;
41
+ const WRITE_PREVIEW_CHARS = 200;
42
+ const SOURCE_BASE = {
43
+ icon_url: "https://wwcdn.weixin.qq.com/node/wework/images/3d-claude-ai-logo.bce0ddae70.jpg",
44
+ desc: "Claude Code",
45
+ desc_color: 0,
46
+ };
47
+ // Source bar sits ABOVE main_title — only place we can hoist transcript context.
48
+ const buildSource = (tail) => {
49
+ const desc = tail ? TRUNC(tail, 80) : SOURCE_BASE.desc;
50
+ return { ...SOURCE_BASE, desc };
51
+ };
52
+ const prefixLines = (s, prefix) => s.split("\n").map((l) => `${prefix} ${l}`).join("\n");
53
+ // Flat key:val summary for unknown tools — never dump raw JSON.
54
+ const UNKNOWN_VAL_LEN = 140;
55
+ const UNKNOWN_TOTAL_LEN = 480;
56
+ // Path values (Glob target_directory, open_result_view target_file, …) get
57
+ // cwd/home-collapsed first, then LEFT-truncated — the filename is the
58
+ // informative tail; right-truncation cuts it off mid-path.
59
+ const TRUNC_LEFT = (s, n) => s.length > n ? `…${s.slice(s.length - (n - 1))}` : s;
60
+ const summarizeUnknown = (i, cwd) => {
61
+ const lines = [];
62
+ let total = 0;
63
+ for (const [k, v] of Object.entries(i)) {
64
+ if (total >= UNKNOWN_TOTAL_LEN) {
65
+ lines.push("…");
66
+ break;
67
+ }
68
+ const s = typeof v === "string" ? v : JSON.stringify(v);
69
+ const isPath = typeof v === "string" && v.startsWith("/");
70
+ const shown = isPath ? TRUNC_LEFT(relToCwd(v, cwd), UNKNOWN_VAL_LEN) : TRUNC(oneLine(s), UNKNOWN_VAL_LEN);
71
+ const line = `${k}: ${shown}`;
72
+ lines.push(line);
73
+ total += line.length;
74
+ }
75
+ return lines.join("\n");
76
+ };
77
+ const QUOTE_MAX = 600;
78
+ const join = (...parts) => parts.filter(Boolean).join("\n");
79
+ // Render tool input as a multi-line "code-block / quote" body.
80
+ const renderInput = (toolName, toolInput, _toolInputStr, cwd) => {
81
+ const i = toolInput;
82
+ if (!i || typeof i !== "object")
83
+ return { body: "" };
84
+ if (toolName === "Bash") {
85
+ const cmd = typeof i.command === "string" ? i.command : "";
86
+ const desc = typeof i.description === "string" ? i.description : "";
87
+ return { body: TRUNC(cmd, QUOTE_MAX), desc: desc || undefined };
88
+ }
89
+ if (toolName === "Read") {
90
+ const fp = typeof i.file_path === "string" ? relToCwd(i.file_path, cwd) : "";
91
+ return { body: TRUNC(fp, QUOTE_MAX) };
92
+ }
93
+ if (toolName === "Write") {
94
+ const fp = typeof i.file_path === "string" ? relToCwd(i.file_path, cwd) : "";
95
+ const content = typeof i.content === "string" ? i.content : "";
96
+ const lc = content ? content.split("\n").length : 0;
97
+ const preview = takeFirstLines(content, WRITE_PREVIEW_LINES, WRITE_PREVIEW_CHARS);
98
+ return {
99
+ body: TRUNC(join(fp, `✏️ 写入 ${lc} 行`, preview ? prefixLines(preview, "+") : ""), QUOTE_MAX),
100
+ };
101
+ }
102
+ if (toolName === "Edit") {
103
+ const fp = typeof i.file_path === "string" ? relToCwd(i.file_path, cwd) : "";
104
+ const oldS = TRUNC(typeof i.old_string === "string" ? i.old_string : "", EDIT_SNIPPET_LEN);
105
+ const newS = TRUNC(typeof i.new_string === "string" ? i.new_string : "", EDIT_SNIPPET_LEN);
106
+ return {
107
+ body: TRUNC(join(fp, prefixLines(oldS, "−"), prefixLines(newS, "+")), QUOTE_MAX),
108
+ };
109
+ }
110
+ if (toolName === "MultiEdit") {
111
+ const fp = typeof i.file_path === "string" ? relToCwd(i.file_path, cwd) : "";
112
+ const edits = Array.isArray(i.edits) ? i.edits : [];
113
+ const first = edits[0] ?? {};
114
+ const oldS = TRUNC(typeof first.old_string === "string" ? first.old_string : "", EDIT_SNIPPET_LEN);
115
+ const newS = TRUNC(typeof first.new_string === "string" ? first.new_string : "", EDIT_SNIPPET_LEN);
116
+ return {
117
+ body: TRUNC(join(`${fp} (✏️ ${edits.length} 处)`, prefixLines(oldS, "−"), prefixLines(newS, "+")), QUOTE_MAX),
118
+ };
119
+ }
120
+ if (toolName === "Agent" || toolName === "Task") {
121
+ const desc = typeof i.description === "string" ? i.description : "";
122
+ const sa = typeof i.subagent_type === "string" ? i.subagent_type : "";
123
+ const prompt = typeof i.prompt === "string" ? i.prompt : "";
124
+ const head = [sa, desc].filter(Boolean).join(": ");
125
+ return { body: TRUNC(prompt, QUOTE_MAX), desc: head || undefined };
126
+ }
127
+ return { body: summarizeUnknown(i, cwd) };
128
+ };
129
+ const quoteArea = (text) => ({ type: 0, quote_text: text });
130
+ const MAIN_DESC_MAX = 30;
131
+ const mainTitle = (title, desc) => desc ? { title, desc: TRUNC(desc, MAIN_DESC_MAX) } : { title };
132
+ const dirName = (cwd) => cwd.replace(/^.*\//, "") || cwd;
133
+ const detailJumpList = (url) => url ? [{ type: 1, title: "🔍 详情", url }] : undefined;
134
+ // Stable per-session animal emoji, matching list_claude_sessions, so the user
135
+ // can tell which session a card belongs to when several un-mirrored sessions
136
+ // fall back to the same WeCom chat. Needs the FULL sessionId; returns "" when
137
+ // only a short id / none is available.
138
+ // Show the session emoji ONLY on cards bound to a tagged session — the tag
139
+ // (`user:xxx#foo`) is the visual disambiguator for chats hosting parallel
140
+ // sessions. Untagged (default-session) cards drop the emoji so single-session
141
+ // users don't see a chunk of extra glyphs in every approval title. Emoji is
142
+ // keyed on the tag STRING (not sessionId) so it matches the `emoji #tag`
143
+ // prefix that outbound mirror bubbles carry — one visual per tag, regardless
144
+ // of how many times `/clear` rotates the underlying sessionId.
145
+ const emojiFor = tagBadge;
146
+ const tagOf = (a) => emojiFor(a.chatKey);
147
+ const buildCard = (a) => {
148
+ const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
149
+ const dir = dirName(a.cwd);
150
+ const tail = oneLine(a.transcriptTail).trim();
151
+ const jl = detailJumpList(a.detailUrl);
152
+ return {
153
+ card_type: "button_interaction",
154
+ source: buildSource(tail),
155
+ main_title: mainTitle(`🔐 授权 · ${tagOf(a)}${a.toolName} · ${dir}/`, r.desc),
156
+ ...(r.body ? { quote_area: quoteArea(r.body) } : {}),
157
+ ...(jl ? { jump_list: jl } : {}),
158
+ task_id: a.reqId,
159
+ button_list: [
160
+ { text: "❌", style: 4, key: encodeKey(a.reqId, "deny") },
161
+ { text: fmtWindow(a.windowMinutes), style: 3, key: encodeKey(a.reqId, "allow_window") },
162
+ { text: "✅", style: 4, key: encodeKey(a.reqId, "allow") },
163
+ ],
164
+ };
165
+ };
166
+ const fmtWindow = (min) => min % 60 === 0 ? `${min / 60}h` : `${min}min`;
167
+ const verbOf = (d, windowMinutes) => {
168
+ switch (d) {
169
+ case "deny": return "已拒绝";
170
+ case "allow_window": return `${fmtWindow(windowMinutes)}会话内全过`;
171
+ case "allow_session": return "本会话通过";
172
+ default: return "已通过";
173
+ }
174
+ };
175
+ const emojiOf = (d) => (d === "deny" ? "❌" : "✅");
176
+ // allow_window 仍可点击以取消自动窗口;其余决策为最终态 noop。
177
+ const resolvedButton = (d, windowMinutes, reqId, chatKey) => {
178
+ if (d === "allow_window") {
179
+ return {
180
+ text: `${verbOf(d, windowMinutes)} · 点击取消`,
181
+ style: 4,
182
+ key: encodeCancelKey(chatKey),
183
+ };
184
+ }
185
+ return {
186
+ text: `${emojiOf(d)} ${verbOf(d, windowMinutes)}`,
187
+ style: 4,
188
+ key: `noop:${reqId}`,
189
+ };
190
+ };
191
+ const buildResolvedCard = (a) => {
192
+ const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
193
+ const dir = dirName(a.cwd);
194
+ const tail = oneLine(a.transcriptTail).trim();
195
+ const jl = detailJumpList(a.detailUrl);
196
+ return {
197
+ card_type: "button_interaction",
198
+ source: buildSource(tail),
199
+ main_title: mainTitle(`${tagOf(a)}${a.toolName} · ${dir}/`, r.desc),
200
+ ...(r.body ? { quote_area: quoteArea(r.body) } : {}),
201
+ ...(jl ? { jump_list: jl } : {}),
202
+ task_id: a.reqId,
203
+ button_list: [resolvedButton(a.decision, a.windowMinutes, a.reqId, a.chatKey ?? "")],
204
+ };
205
+ };
206
+ const buildCancelledCard = (a) => {
207
+ const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
208
+ const dir = dirName(a.cwd);
209
+ const tail = oneLine(a.transcriptTail).trim();
210
+ const jl = detailJumpList(a.detailUrl);
211
+ return {
212
+ card_type: "button_interaction",
213
+ source: buildSource(tail),
214
+ main_title: mainTitle(`${tagOf(a)}${a.toolName} · ${dir}/`, r.desc),
215
+ ...(r.body ? { quote_area: quoteArea(r.body) } : {}),
216
+ ...(jl ? { jump_list: jl } : {}),
217
+ task_id: a.reqId,
218
+ button_list: [
219
+ { text: "已取消自动通过", style: 4, key: `noop:cancelled:${a.reqId}` },
220
+ ],
221
+ };
222
+ };
223
+ // 已 resolved 的卡再次被点击 — 仅作视觉反馈, 不改变任何状态。
224
+ const buildAlreadyResolvedCard = (a) => {
225
+ const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
226
+ const dir = dirName(a.cwd);
227
+ const tail = oneLine(a.transcriptTail).trim();
228
+ const jl = detailJumpList(a.detailUrl);
229
+ return {
230
+ card_type: "button_interaction",
231
+ source: buildSource(tail),
232
+ main_title: mainTitle(`${tagOf(a)}${a.toolName} · ${dir}/`, r.desc),
233
+ ...(r.body ? { quote_area: quoteArea(r.body) } : {}),
234
+ ...(jl ? { jump_list: jl } : {}),
235
+ task_id: a.reqId,
236
+ button_list: [{ text: "已经放行", style: 4, key: `noop:${a.reqId}` }],
237
+ };
238
+ };
239
+ const activeBatches = new Map(); // 仅 collecting 期; flush 后摘除
240
+ const batchById = new Map(); // 长留, 供 click event 解析
241
+ const BATCH_BY_ID_TTL_MS = 30 * 60_000;
242
+ const BATCH_BY_ID_MAX = 200;
243
+ const evictBatches = () => {
244
+ if (batchById.size <= BATCH_BY_ID_MAX)
245
+ return;
246
+ const cutoff = Date.now() - BATCH_BY_ID_TTL_MS;
247
+ for (const [k, v] of batchById) {
248
+ // members[0].reqId 总在 createPending 之后立刻入批, 用首个 reqId 的
249
+ // pending meta.createdAt 也行; 这里近似用 batchId 后 8 位的时间戳。
250
+ const ts = parseInt(v.batchId.slice(1, 1 + 8), 36);
251
+ if (Number.isFinite(ts) && ts < cutoff)
252
+ batchById.delete(k);
253
+ }
254
+ };
255
+ const newBatchId = () => `b${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
256
+ const batchKeyOf = (sessionId, toolName) => `${sessionId}|${toolName}`;
257
+ // 单条成员渲染: 展开第 N 项的输入摘要, 单行化以适配批量列表的紧凑布局。
258
+ const PER_MEMBER_MAX = 120;
259
+ const BATCH_MAX_VISIBLE = 8;
260
+ const renderBatchBody = (batch) => {
261
+ const visible = batch.members.slice(0, BATCH_MAX_VISIBLE);
262
+ const lines = visible.map((m, idx) => {
263
+ const r = renderInput(batch.toolName, m.toolInput, m.toolInputStr, m.cwd);
264
+ const flat = r.body ? oneLine(r.body) : "(no input)";
265
+ return `${idx + 1}. ${TRUNC(flat, PER_MEMBER_MAX)}`;
266
+ });
267
+ const overflow = batch.members.length - visible.length;
268
+ if (overflow > 0)
269
+ lines.push(`…还有 ${overflow} 项`);
270
+ return TRUNC(lines.join("\n"), QUOTE_MAX);
271
+ };
272
+ const buildBatchCard = (batch, transcriptTail) => {
273
+ const dir = dirName(batch.members[0]?.cwd ?? "");
274
+ const tail = oneLine(transcriptTail).trim();
275
+ const emoji = emojiFor(batch.approver);
276
+ return {
277
+ card_type: "button_interaction",
278
+ source: buildSource(tail),
279
+ main_title: { title: `🔐 授权 · ${emoji}${batch.toolName} ×${batch.members.length} · ${dir}/` },
280
+ quote_area: quoteArea(renderBatchBody(batch)),
281
+ task_id: batch.batchId,
282
+ button_list: [
283
+ { text: "❌", style: 4, key: encodeBatchKey(batch.batchId, "deny") },
284
+ { text: fmtWindow(batch.windowMinutes), style: 3, key: encodeBatchKey(batch.batchId, "allow_window") },
285
+ { text: "✅", style: 4, key: encodeBatchKey(batch.batchId, "allow") },
286
+ ],
287
+ };
288
+ };
289
+ const buildBatchResolvedCard = (batch, decision, transcriptTail) => {
290
+ const dir = dirName(batch.members[0]?.cwd ?? "");
291
+ const tail = oneLine(transcriptTail).trim();
292
+ const button = decision === "allow_window"
293
+ ? {
294
+ text: `${verbOf(decision, batch.windowMinutes)} · 点击取消`,
295
+ style: 4,
296
+ key: encodeCancelKey(batch.approver),
297
+ }
298
+ : {
299
+ text: `${emojiOf(decision)} ${verbOf(decision, batch.windowMinutes)} ×${batch.members.length}`,
300
+ style: 4,
301
+ key: encodeBatchNoopKey(batch.batchId),
302
+ };
303
+ return {
304
+ card_type: "button_interaction",
305
+ source: buildSource(tail),
306
+ main_title: { title: `${emojiFor(batch.approver)}${batch.toolName} ×${batch.members.length} · ${dir}/` },
307
+ quote_area: quoteArea(renderBatchBody(batch)),
308
+ task_id: batch.batchId,
309
+ button_list: [button],
310
+ };
311
+ };
312
+ const buildBatchAlreadyResolvedCard = (batch, transcriptTail) => {
313
+ const dir = dirName(batch.members[0]?.cwd ?? "");
314
+ const tail = oneLine(transcriptTail).trim();
315
+ return {
316
+ card_type: "button_interaction",
317
+ source: buildSource(tail),
318
+ main_title: { title: `${emojiFor(batch.approver)}${batch.toolName} ×${batch.members.length} · ${dir}/` },
319
+ quote_area: quoteArea(renderBatchBody(batch)),
320
+ task_id: batch.batchId,
321
+ button_list: [{ text: "已经放行", style: 4, key: encodeBatchNoopKey(batch.batchId) }],
322
+ };
323
+ };
324
+ const encodeKey = (reqId, decision) => `${reqId}|${decision}`;
325
+ const NOOP_PREFIX = "noop:";
326
+ const CANCEL_PREFIX = "cancel_window:";
327
+ const BATCH_PREFIX = "B|";
328
+ const BATCH_NOOP_PREFIX = "B-noop:";
329
+ const encodeCancelKey = (chatKey) => `${CANCEL_PREFIX}${chatKey}`;
330
+ const encodeBatchKey = (batchId, decision) => `${BATCH_PREFIX}${batchId}|${decision}`;
331
+ const encodeBatchNoopKey = (batchId) => `${BATCH_NOOP_PREFIX}${batchId}`;
332
+ const decodeBatchKey = (key) => {
333
+ if (!key.startsWith(BATCH_PREFIX))
334
+ return undefined;
335
+ const [batchId, d] = key.slice(BATCH_PREFIX.length).split("|");
336
+ if (!batchId || !d)
337
+ return undefined;
338
+ if (d !== "allow" && d !== "allow_session" && d !== "allow_window" && d !== "deny")
339
+ return undefined;
340
+ return { batchId, decision: d };
341
+ };
342
+ const decodeBatchNoopKey = (key) => key.startsWith(BATCH_NOOP_PREFIX) ? key.slice(BATCH_NOOP_PREFIX.length) : undefined;
343
+ const decodeKey = (key) => {
344
+ if (key.startsWith(NOOP_PREFIX)) {
345
+ // noop:cancelled:<id> 也走这里,noopReqId 取剩余部分作为 task_id 兜底。
346
+ return { noopReqId: key.slice(NOOP_PREFIX.length) };
347
+ }
348
+ if (key.startsWith(CANCEL_PREFIX)) {
349
+ return { cancelChatKey: key.slice(CANCEL_PREFIX.length) };
350
+ }
351
+ const [reqId, d] = key.split("|");
352
+ if (!reqId || !d)
353
+ return {};
354
+ if (d !== "allow" && d !== "allow_session" && d !== "allow_window" && d !== "deny")
355
+ return {};
356
+ return { reqId, decision: d };
357
+ };
358
+ // ── AskUserQuestion 投票卡分支 ─────────────────────────────────────────
359
+ // PreToolUse 协议端只能输出 allow/deny/ask, 没有「合成 tool_result」通道。
360
+ // 取舍: 用户在 WeCom 选了选项 → 走 deny + 把答案塞进 reason, model 把 reason
361
+ // 当作上下文继续推理(CLI 不会弹原生 picker, 流程不被打断);
362
+ // 选「🖥️ CLI 处理」哨兵选项 → 返回 ask, CLI 弹原生 picker 由用户本地作答。两路互斥。
363
+ // vote_interaction 不支持 button_list (SDK 类型注释明写「button_interaction 类型卡片使用」),
364
+ // 微信侧静默吞掉, 所以 cli 入口只能塞进 checkbox option_list 作为哨兵 id。
365
+ const ASKQ_PREFIX = "ASKQ|";
366
+ const ASKQ_PICKED_PREFIX = "picked:";
367
+ const ASKQ_CLI_OPTION_ID = "__cli__";
368
+ const ASKQ_CHAT_OPTION_ID = "__chat__";
369
+ const ASKQ_NOOP_PREFIX = "askq_noop:";
370
+ const encodeAskqKey = (reqId) => `${ASKQ_PREFIX}${reqId}|submit`;
371
+ const encodeAskqNoopKey = (reqId) => `${ASKQ_NOOP_PREFIX}${reqId}`;
372
+ const decodeAskqKey = (key) => {
373
+ if (!key.startsWith(ASKQ_PREFIX))
374
+ return undefined;
375
+ const [reqId, action] = key.slice(ASKQ_PREFIX.length).split("|");
376
+ if (!reqId || action !== "submit")
377
+ return undefined;
378
+ return { reqId, action };
379
+ };
380
+ const decodeAskqNoopKey = (key) => key.startsWith(ASKQ_NOOP_PREFIX) ? key.slice(ASKQ_NOOP_PREFIX.length) : undefined;
381
+ const parseAskqInput = (i) => {
382
+ if (!i || typeof i !== "object")
383
+ return undefined;
384
+ const qs = i.questions;
385
+ if (!Array.isArray(qs))
386
+ return undefined;
387
+ return qs.map((q) => {
388
+ const qq = (q ?? {});
389
+ const opts = Array.isArray(qq.options) ? qq.options : [];
390
+ return {
391
+ question: typeof qq.question === "string" ? qq.question : "",
392
+ header: typeof qq.header === "string" ? qq.header : "",
393
+ multiSelect: Boolean(qq.multiSelect),
394
+ options: opts.flatMap((o) => {
395
+ const oo = (o ?? {});
396
+ return typeof oo.label === "string"
397
+ ? [{
398
+ label: oo.label,
399
+ description: typeof oo.description === "string" ? oo.description : undefined,
400
+ }]
401
+ : [];
402
+ }),
403
+ };
404
+ });
405
+ };
406
+ const ASKQ_TITLE_MAX = 26;
407
+ const ASKQ_SUB_MAX = 480;
408
+ const ASKQ_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
409
+ const askqLabel = (idx) => ASKQ_LETTERS[idx] ?? String(idx + 1);
410
+ // vote_interaction 只支持 card_type/source/main_title/checkbox/submit_button/task_id,
411
+ // sub_title_text/quote_area 等会被静默吞掉 → 题目和选项必须走前置 markdown 消息。
412
+ const buildAskqMarkdown = (q, prefix = "") => {
413
+ const title = q.question || q.header || "请选择";
414
+ const head = `**🤔 ${prefix}${title}**`;
415
+ const opts = q.options.map((o, idx) => {
416
+ const desc = o.description ? ` — ${o.description}` : "";
417
+ return `**${askqLabel(idx)}.** ${o.label}${desc}`;
418
+ });
419
+ return [head, "", ...opts].join("\n");
420
+ };
421
+ const buildAskqCard = (reqId, q, transcriptTail, approver) => {
422
+ const tail = oneLine(transcriptTail).trim();
423
+ return {
424
+ card_type: "vote_interaction",
425
+ source: buildSource(tail),
426
+ main_title: { title: TRUNC(`🤔 ${emojiFor(approver)}${q.header || "请选择"}`, ASKQ_TITLE_MAX) },
427
+ task_id: reqId,
428
+ checkbox: {
429
+ question_key: "q",
430
+ mode: q.multiSelect ? 1 : 0,
431
+ // 完整文案在前置 markdown 消息里以 ABCD 编号呈现,这里只显示编号,
432
+ // 避免长 label 被卡片选项栏截断成无意义前缀。
433
+ option_list: [
434
+ ...q.options.map((_, idx) => ({
435
+ id: String(idx),
436
+ text: askqLabel(idx),
437
+ })),
438
+ { id: ASKQ_CHAT_OPTION_ID, text: "💬 聊聊这个" },
439
+ { id: ASKQ_CLI_OPTION_ID, text: "🖥️ 去 CLI 中处理" },
440
+ ],
441
+ },
442
+ submit_button: { text: "提交", key: encodeAskqKey(reqId) },
443
+ };
444
+ };
445
+ // 投票卡 submit 后那张卡再被点 (askq_noop:<id>) → 终态 identity, 直接 return,
446
+ // 不再 stash 任何 outcome / question 副本。
447
+ const buildAskqResolvedCard = (reqId, q, outcome, transcriptTail, approver) => {
448
+ const summary = outcome.kind === "cli"
449
+ ? "🖥️ 已转 CLI 中处理"
450
+ : outcome.kind === "chat"
451
+ ? "💬 就此展开讨论"
452
+ : outcome.kind === "empty"
453
+ ? "⚠️ 未选择"
454
+ : `✅ ${outcome.picked.map((i) => q.options[i]?.label ?? `#${i}`).join(", ")}`;
455
+ const tail = oneLine(transcriptTail).trim();
456
+ return {
457
+ card_type: "button_interaction",
458
+ source: buildSource(tail),
459
+ main_title: { title: TRUNC(`🤔 ${emojiFor(approver)}${q.header || "已回答"}`, ASKQ_TITLE_MAX) },
460
+ sub_title_text: TRUNC(q.question, ASKQ_SUB_MAX),
461
+ task_id: reqId,
462
+ button_list: [{ text: TRUNC(summary, 30), style: 4, key: encodeAskqNoopKey(reqId) }],
463
+ };
464
+ };
465
+ // ── ExitPlanMode 计划审批卡分支 ────────────────────────────────────────
466
+ // plan mode 的 ExitPlanMode 是个交互模态(同意/继续改),TUI 里弹 1/2/3 picker,
467
+ // 不写 jsonl → mirror 看不到。这里拦下来推一张 button_interaction 卡:
468
+ // ✅ 同意 → allow (跳过本地 picker, 退出 plan mode 开始执行)
469
+ // ✏️ 继续改 → deny + reason (reason 回传 model, 留在 plan mode 据此调整)
470
+ // plan 正文可能很长, vote 卡/按钮卡正文字段都吃不下 → 走前置 markdown 消息。
471
+ const PLAN_PREFIX = "PLAN|";
472
+ const PLAN_NOOP_PREFIX = "plan_noop:";
473
+ const encodePlanKey = (reqId, action) => `${PLAN_PREFIX}${reqId}|${action}`;
474
+ const encodePlanNoopKey = (reqId) => `${PLAN_NOOP_PREFIX}${reqId}`;
475
+ const decodePlanKey = (key) => {
476
+ if (!key.startsWith(PLAN_PREFIX))
477
+ return undefined;
478
+ const [reqId, action] = key.slice(PLAN_PREFIX.length).split("|");
479
+ if (!reqId || (action !== "approve" && action !== "revise"))
480
+ return undefined;
481
+ return { reqId, action };
482
+ };
483
+ const decodePlanNoopKey = (key) => key.startsWith(PLAN_NOOP_PREFIX) ? key.slice(PLAN_NOOP_PREFIX.length) : undefined;
484
+ const PLAN_PICKED_PREFIX = "plan:";
485
+ const PLAN_REVISE_REASON = "用户希望继续完善计划,请询问还需要调整哪些地方,不要直接开始执行。";
486
+ const parsePlanInput = (i) => {
487
+ if (!i || typeof i !== "object")
488
+ return "";
489
+ const p = i.plan;
490
+ return typeof p === "string" ? p : "";
491
+ };
492
+ const PLAN_TITLE_MAX = 26;
493
+ const PLAN_PREVIEW_LINES = 25;
494
+ const PLAN_PREVIEW_CHARS = 1500;
495
+ // plan 正文走前置 markdown(卡片正文字段塞不下长 plan)。截断保留前若干行。
496
+ const buildPlanMarkdown = (plan) => {
497
+ const lines = plan.split("\n");
498
+ const clipped = lines.slice(0, PLAN_PREVIEW_LINES).join("\n");
499
+ let body = TRUNC(clipped, PLAN_PREVIEW_CHARS);
500
+ if (lines.length > PLAN_PREVIEW_LINES || plan.length > PLAN_PREVIEW_CHARS) {
501
+ body += "\n\n_…计划较长,完整内容见 CLI。_";
502
+ }
503
+ return `**📋 计划待审批**\n\n${body}`;
504
+ };
505
+ const buildPlanCard = (reqId, _sessionId, cwd, transcriptTail, approver) => {
506
+ const tail = oneLine(transcriptTail).trim();
507
+ const tag = emojiFor(approver);
508
+ const dir = dirName(cwd);
509
+ return {
510
+ card_type: "button_interaction",
511
+ source: buildSource(tail),
512
+ main_title: { title: TRUNC(`📋 计划审批 · ${tag}${dir}/`, PLAN_TITLE_MAX + 12) },
513
+ sub_title_text: "审阅上方计划后选择:同意开始执行,或让 Claude 继续完善。",
514
+ task_id: reqId,
515
+ button_list: [
516
+ { text: "✏️ 继续改", style: 4, key: encodePlanKey(reqId, "revise") },
517
+ { text: "✅ 同意", style: 3, key: encodePlanKey(reqId, "approve") },
518
+ ],
519
+ };
520
+ };
521
+ const buildPlanResolvedCard = (reqId, action, cwd, transcriptTail, approver) => {
522
+ const tail = oneLine(transcriptTail).trim();
523
+ const dir = dirName(cwd);
524
+ const summary = action === "approve" ? "✅ 已同意 · 开始执行" : "✏️ 继续完善计划";
525
+ return {
526
+ card_type: "button_interaction",
527
+ source: buildSource(tail),
528
+ main_title: { title: TRUNC(`📋 计划 · ${emojiFor(approver)}${dir}/`, PLAN_TITLE_MAX + 12) },
529
+ task_id: reqId,
530
+ button_list: [{ text: summary, style: 4, key: encodePlanNoopKey(reqId) }],
531
+ };
532
+ };
533
+ const handleExitPlanMode = async ({ cfg, log, client, body, getMirrorTarget, flushBeforeCard }) => {
534
+ const plan = parsePlanInput(body.tool_input);
535
+ if (!plan)
536
+ return { decision: "ask", reason: "plan_unparsable" };
537
+ const approver = resolveApprover(cfg, body.session_id, getMirrorTarget);
538
+ if (!approver)
539
+ return { decision: "ask", reason: "no_approver" };
540
+ if (!client.isConnected)
541
+ return { decision: "ask", reason: "ws_disconnected" };
542
+ const longPollMs = cfg.approval.longPollSec * 1000;
543
+ const { reqId, promise } = createPending({
544
+ meta: {
545
+ kind: "generic",
546
+ createdAt: Date.now(),
547
+ toolName: "ExitPlanMode",
548
+ toolInput: body.tool_input,
549
+ cwd: body.cwd,
550
+ sessionId: body.session_id,
551
+ // kind:"generic" keeps this out of the allow_window sweep; chatKey is
552
+ // carried purely so the click listener can re-render the resolved card
553
+ // with the same `#tag` emoji the live card showed.
554
+ chatKey: approver,
555
+ transcriptTail: body.transcript_tail ?? "",
556
+ },
557
+ timeoutMs: longPollMs,
558
+ });
559
+ try {
560
+ const target = targetChatId(approver);
561
+ // 发卡前先把 mirror 那条管道里同 turn 的"思考过程"text 推到 WeCom — 否则
562
+ // hook 直发的卡片可能赛过 mirror 的 250ms/3s 防抖, 用户看到先卡片后解释。
563
+ try {
564
+ await flushBeforeCard?.(body.session_id, { toolName: "ExitPlanMode", toolInput: body.tool_input });
565
+ }
566
+ catch (e) {
567
+ log.warn({ err: e.message }, "plan flushBeforeCard failed; sending card anyway");
568
+ }
569
+ try {
570
+ await client.sendMessage(target, {
571
+ msgtype: "markdown",
572
+ markdown: { content: withTagHeader(approver, buildPlanMarkdown(plan)) },
573
+ });
574
+ }
575
+ catch (e) {
576
+ log.warn({ err: e.message }, "plan markdown prelude send failed");
577
+ }
578
+ await client.sendMessage(target, {
579
+ msgtype: "template_card",
580
+ template_card: buildPlanCard(reqId, body.session_id, body.cwd ?? "", body.transcript_tail ?? "", approver),
581
+ });
582
+ log.info({ reqId, approver }, "plan card sent");
583
+ }
584
+ catch (e) {
585
+ log.error({ err: e.message }, "plan send failed");
586
+ resolvePending(reqId, "deny");
587
+ return { decision: "ask", reason: `plan_send_fail:${e.message}` };
588
+ }
589
+ let raw;
590
+ try {
591
+ raw = (await promise);
592
+ }
593
+ catch {
594
+ return { decision: "ask", reason: "plan_timeout" };
595
+ }
596
+ if (raw === `${PLAN_PICKED_PREFIX}approve`) {
597
+ // 同意也走 deny+reason,而不是 allow:实测 allow 在非 auto-mode 会话里仍会弹
598
+ // 本地 1/2/3 picker(plan 退出的 auto/manual 选择是 TUI 本地交互,PreToolUse
599
+ // allow 替代不了)。deny 的 reason 直接回传 model 且不弹 picker,所以用一段
600
+ // "已批准,去执行"的明确指令驱动 Claude 退出 plan 开始干活。
601
+ return {
602
+ decision: "deny",
603
+ reason: "✅ 用户已在企业微信批准此计划。这不是拒绝——ExitPlanMode 返回 deny 只是 weclaude 的远程批准信号。请视为计划已通过,立即开始执行计划内容,不要再次调用 ExitPlanMode,也不要重新规划。",
604
+ };
605
+ }
606
+ if (raw === `${PLAN_PICKED_PREFIX}revise`) {
607
+ return { decision: "deny", reason: PLAN_REVISE_REASON };
608
+ }
609
+ return { decision: "ask", reason: "plan_unknown" };
610
+ };
611
+ // raw 是 click 事件 listener 塞回 pending 的字符串编码 (cli / chat / picked:i,j)。
612
+ const interpretAskqRaw = (raw, q) => {
613
+ if (raw === "cli")
614
+ return { kind: "cli" };
615
+ if (raw === "chat")
616
+ return { kind: "chat" };
617
+ if (raw.startsWith(ASKQ_PICKED_PREFIX)) {
618
+ const idxs = raw.slice(ASKQ_PICKED_PREFIX.length)
619
+ .split(",")
620
+ .map((s) => parseInt(s, 10))
621
+ .filter((n) => Number.isInteger(n) && n >= 0 && n < q.options.length);
622
+ if (idxs.length === 0)
623
+ return { kind: "empty" };
624
+ return { kind: "picked", labels: idxs.map((i) => q.options[i].label).join(", ") };
625
+ }
626
+ return { kind: "empty" };
627
+ };
628
+ // 多问题: 逐题顺序发卡 → 收答 → 下一题。任一题选「CLI」整体转 CLI,选「聊聊」
629
+ // 整体转讨论; 全部答完合并成单个 deny+reason 注入。卡不会一次性轰炸 N 张。
630
+ const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget, flushBeforeCard, clientGone }) => {
631
+ const questions = parseAskqInput(body.tool_input);
632
+ if (!questions || questions.length === 0)
633
+ return { decision: "ask", reason: "askq_unparsable" };
634
+ if (questions.some((q) => q.options.length === 0))
635
+ return { decision: "ask", reason: "askq_no_options" };
636
+ const approver = resolveApprover(cfg, body.session_id, getMirrorTarget);
637
+ if (!approver)
638
+ return { decision: "ask", reason: "no_approver" };
639
+ if (!client.isConnected)
640
+ return { decision: "ask", reason: "ws_disconnected" };
641
+ const target = targetChatId(approver);
642
+ const longPollMs = cfg.approval.longPollSec * 1000;
643
+ const total = questions.length;
644
+ const answers = [];
645
+ const flowStart = Date.now();
646
+ // hook 客户端断开后整个流程只是往死管道灌数据 — 记标志, 循环内 race、
647
+ // 循环后回执都以它止血。正常完成时 clientGone 永不 resolve, 无副作用。
648
+ let gone = false;
649
+ void clientGone?.then(() => { gone = true; });
650
+ for (let i = 0; i < total; i++) {
651
+ const q = questions[i];
652
+ // 剩余预算: N 题共享一份 longPollSec。若每题都给整份, 多题总时长上限是
653
+ // N×longPollSec, 必然越过 hook curl 的 --max-time (longPollSec+10s),
654
+ // 之后的答案全部写进死 socket。预算耗尽 → ask, CC 弹本地 picker 兜底。
655
+ const remainMs = longPollMs - (Date.now() - flowStart);
656
+ if (remainMs < 10_000)
657
+ return { decision: "ask", reason: "askq_timeout" };
658
+ // 每题独立 pending: meta.toolInput 只塞本题(包成单题 wrapper),click 事件
659
+ // listener 里的 parseAskqInput(meta.toolInput)?.[0] 渲染的就是当前题 — 监听
660
+ // 侧零改动。transcriptTail 一并存进 meta, resolved 卡复用同一份 source。
661
+ const { reqId, promise } = createPending({
662
+ meta: {
663
+ kind: "generic",
664
+ createdAt: Date.now(),
665
+ toolName: "AskUserQuestion",
666
+ toolInput: { questions: [q] },
667
+ cwd: body.cwd,
668
+ sessionId: body.session_id,
669
+ chatKey: approver, // 同 plan: 只为 resolved 卡复原 `#tag` emoji, 不参与 sweep
670
+ transcriptTail: body.transcript_tail ?? "",
671
+ },
672
+ timeoutMs: remainMs,
673
+ });
674
+ const prefix = total > 1 ? `(${i + 1}/${total}) ` : "";
675
+ try {
676
+ // 发卡前先把同 turn 在 mirror 那条管道里 pending 的 assistant 文本推干净 —
677
+ // hook 直发的卡片如果赛过 mirror 的防抖, 用户会先看到卡片再看到为什么。
678
+ try {
679
+ await flushBeforeCard?.(body.session_id, { toolName: "AskUserQuestion", toolInput: body.tool_input });
680
+ }
681
+ catch (e) {
682
+ log.warn({ err: e.message }, "askq flushBeforeCard failed; sending card anyway");
683
+ }
684
+ // 先发 markdown 列出题目+ABCD 选项 (vote 卡不支持正文字段),
685
+ // 失败不阻断,卡片仍按字母编号显示。
686
+ try {
687
+ await client.sendMessage(target, {
688
+ msgtype: "markdown",
689
+ markdown: { content: withTagHeader(approver, buildAskqMarkdown(q, prefix)) },
690
+ });
691
+ }
692
+ catch (e) {
693
+ log.warn({ err: e.message }, "askq markdown prelude send failed");
694
+ }
695
+ await client.sendMessage(target, {
696
+ msgtype: "template_card",
697
+ template_card: buildAskqCard(reqId, q, body.transcript_tail ?? "", approver),
698
+ });
699
+ log.info({ reqId, approver, idx: i, total }, "askq card sent");
700
+ }
701
+ catch (e) {
702
+ log.error({ err: e.message }, "askq send failed");
703
+ resolvePending(reqId, "deny"); // 释放 pending 槽
704
+ return { decision: "ask", reason: `askq_send_fail:${e.message}` };
705
+ }
706
+ let raw;
707
+ try {
708
+ const racers = [promise];
709
+ if (clientGone)
710
+ racers.push(clientGone);
711
+ raw = await Promise.race(racers);
712
+ }
713
+ catch {
714
+ return { decision: "ask", reason: "askq_timeout" };
715
+ }
716
+ if (raw === "client_gone") {
717
+ resolvePending(reqId, "deny"); // 释放 pending 槽, 这张卡的后续点击变 no-op
718
+ log.warn({ reqId, idx: i, total }, "askq client gone — flow aborted");
719
+ try {
720
+ await client.sendMessage(target, {
721
+ msgtype: "markdown",
722
+ markdown: { content: withTagHeader(approver, "⚠️ CLI 侧连接已断开,本轮问题卡已失效,请回到 CLI 处理。") },
723
+ });
724
+ }
725
+ catch { /* best-effort */ }
726
+ return { decision: "ask", reason: "askq_client_gone" };
727
+ }
728
+ const ans = interpretAskqRaw(raw, q);
729
+ if (ans.kind === "cli")
730
+ return { decision: "ask", reason: "askq_cli" };
731
+ if (ans.kind === "chat") {
732
+ return {
733
+ decision: "deny",
734
+ reason: `Instead of answering "${q.header || q.question}", the user wants to chat about it first. Discuss the question with them before re-asking.`,
735
+ };
736
+ }
737
+ if (ans.kind === "empty")
738
+ return { decision: "ask", reason: "askq_empty_pick" };
739
+ answers.push(`"${q.header || q.question}": ${ans.labels}`);
740
+ }
741
+ const reason = total === 1
742
+ ? `User answered ${answers[0]} via WeCom`
743
+ : `User answered ${total} questions via WeCom — ${answers.join("; ")}`;
744
+ // 答完最后一题后 model 进入不可见的长 thinking (隐藏式 thinking 不下发,
745
+ // WeCom 端全静默,极易被当成"卡住")。推一条回执确认答案已落地。失败不阻断。
746
+ // 客户端已断开时回执是谎言 (答案送不回去了), 跳过。
747
+ if (!gone) {
748
+ try {
749
+ await client.sendMessage(target, {
750
+ msgtype: "markdown",
751
+ markdown: {
752
+ content: withTagHeader(approver, total === 1 ? "✅ 已回传,Claude 处理中…" : `✅ ${total} 题已回传,Claude 处理中…`),
753
+ },
754
+ });
755
+ }
756
+ catch (e) {
757
+ log.warn({ err: e.message }, "askq ack send failed");
758
+ }
759
+ }
760
+ return { decision: "deny", reason };
761
+ };
762
+ const decisionToHook = (d) => (d === "deny" ? "deny" : "allow");
763
+ const fallback = (cfg, reason) => ({
764
+ decision: cfg.approval.fallbackOnError,
765
+ reason,
766
+ });
767
+ const resolveApprover = (cfg, sessionId, getMirrorTarget) => {
768
+ const mirror = sessionId ? getMirrorTarget?.(sessionId) : undefined;
769
+ return mirror || pickApprover(cfg);
770
+ };
771
+ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBeforeCard }) => {
772
+ const detailUrlFor = (id) => buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id);
773
+ // Flush 一个 batch: 单成员 → 普通卡 (与未启用聚合一致); 多成员 → 批量卡。
774
+ // 发送失败时调用 failPending 让每位成员的 handler 走 fallbackOnError 路径,
775
+ // 与单卡路径上 sendMessage 抛错时的语义一致。
776
+ const flushBatch = async (batch) => {
777
+ if (batch.flushed)
778
+ return;
779
+ batch.flushed = true;
780
+ activeBatches.delete(batchKeyOf(batch.sessionId, batch.toolName));
781
+ const isMulti = batch.members.length > 1;
782
+ const card = isMulti
783
+ ? buildBatchCard(batch, batch.members[0]?.transcriptTail ?? "")
784
+ : (() => {
785
+ const m = batch.members[0];
786
+ return buildCard({
787
+ reqId: m.reqId,
788
+ toolName: batch.toolName,
789
+ toolInput: m.toolInput,
790
+ toolInputStr: m.toolInputStr,
791
+ cwd: m.cwd,
792
+ sessionId: batch.sessionId ?? "",
793
+ sessionShort: batch.sessionId ? batch.sessionId.slice(-8) : "?",
794
+ // Approver target — needed for `tagOf` to decide whether to prefix
795
+ // the emoji (only tagged sessions get it now). Also flows through
796
+ // to the resolved card's cancel key path.
797
+ chatKey: batch.approver,
798
+ transcriptTail: m.transcriptTail,
799
+ windowMinutes: batch.windowMinutes,
800
+ detailUrl: detailUrlFor(m.reqId),
801
+ });
802
+ })();
803
+ try {
804
+ // 卡片直发会赛过 mirror 那条管道里 pending 的 assistant 文本(防抖窗内),
805
+ // 先 await 排干 — 不然用户先看到卡片再看到为什么。批量卡按 batch 第一位
806
+ // 成员的 sessionId 走(同 sessionId 才会被合到一起, 任取一个都对)。
807
+ try {
808
+ await flushBeforeCard?.(batch.sessionId, { toolName: batch.toolName, toolInput: batch.members[0].originalToolInput });
809
+ }
810
+ catch (e) {
811
+ log.warn({ batchId: batch.batchId, err: e.message }, "flushBatch flushBeforeCard failed; sending card anyway");
812
+ }
813
+ await client.sendMessage(targetChatId(batch.approver), {
814
+ msgtype: "template_card",
815
+ template_card: card,
816
+ });
817
+ log.info({ batchId: batch.batchId, count: batch.members.length, multi: isMulti, approver: batch.approver, tool: batch.toolName }, "batch flushed");
818
+ }
819
+ catch (e) {
820
+ log.error({ batchId: batch.batchId, err: e.message }, "batch send failed");
821
+ const err = new Error(`send_card_fail:${e.message}`);
822
+ for (const m of batch.members)
823
+ failPending(m.reqId, err);
824
+ // 单成员 batch 摘除自己的 batchById 条目, 多成员保留 (后续 click 兜底
825
+ // 时 build*Card 期望能找到 batch — 但失败时也没人会点了, 留着也无害)。
826
+ if (!isMulti)
827
+ batchById.delete(batch.batchId);
828
+ }
829
+ };
830
+ return async (req, res) => {
831
+ if (!cfg.approval.enabled) {
832
+ json(res, 200, { decision: "ask", reason: "approval_disabled" });
833
+ return;
834
+ }
835
+ const body = (await readBody(req));
836
+ const sessionId = body.session_id ?? "";
837
+ const toolName = body.tool_name ?? "";
838
+ const toolInput = body.tool_input ?? {};
839
+ const cwd = body.cwd ?? "";
840
+ const transcriptTail = body.transcript_tail ?? "";
841
+ if (!toolName) {
842
+ json(res, 400, { decision: "ask", reason: "missing_tool_name" });
843
+ return;
844
+ }
845
+ // Matcher: only intercept matching tools — others pass.
846
+ if (!new RegExp(cfg.approval.matcher).test(toolName)) {
847
+ json(res, 200, { decision: "allow", reason: "matcher_skip" });
848
+ return;
849
+ }
850
+ // EnterPlanMode: block model-initiated plan mode. deny + reason 回传 model,
851
+ // 让它别进 plan mode、直接干活。用户仍可在本地 Shift+Tab 手动进 plan mode
852
+ // (那条路径不过 hook)。由 config.approval.blockAutoPlanMode 控制(默认 true)。
853
+ if (toolName === "EnterPlanMode" && cfg.approval.blockAutoPlanMode) {
854
+ json(res, 200, {
855
+ decision: "deny",
856
+ reason: "请不要进入 plan mode。直接开始执行任务;如果需要先讨论方案,用文字说明即可,不要调用 EnterPlanMode。(用户已设置:仅在其本地手动 Shift+Tab 时才进 plan mode。)",
857
+ });
858
+ return;
859
+ }
860
+ // AskUserQuestion 走单独的投票卡分支(deny+reason 注入答案 / ask 转 CLI)。
861
+ if (toolName === "AskUserQuestion") {
862
+ // writableEnded 为 false 时的 close = 响应还没写就断线 = hook 客户端先死。
863
+ // 正常完成时 json() 先置 writableEnded, close 到来后 resolve 不再发生。
864
+ const clientGone = new Promise((resolve) => {
865
+ res.on("close", () => {
866
+ if (!res.writableEnded)
867
+ resolve("client_gone");
868
+ });
869
+ });
870
+ const resp = await handleAskUserQuestion({
871
+ cfg,
872
+ log,
873
+ client,
874
+ getMirrorTarget,
875
+ flushBeforeCard,
876
+ clientGone,
877
+ body: {
878
+ session_id: sessionId,
879
+ tool_name: toolName,
880
+ tool_input: toolInput,
881
+ cwd,
882
+ transcript_tail: transcriptTail,
883
+ },
884
+ });
885
+ json(res, 200, resp);
886
+ return;
887
+ }
888
+ // ExitPlanMode 走计划审批卡分支(allow 同意 / deny+reason 继续改 / ask 转 CLI)。
889
+ if (toolName === "ExitPlanMode") {
890
+ const resp = await handleExitPlanMode({
891
+ cfg,
892
+ log,
893
+ client,
894
+ getMirrorTarget,
895
+ flushBeforeCard,
896
+ body: {
897
+ session_id: sessionId,
898
+ tool_name: toolName,
899
+ tool_input: toolInput,
900
+ cwd,
901
+ transcript_tail: transcriptTail,
902
+ },
903
+ });
904
+ json(res, 200, resp);
905
+ return;
906
+ }
907
+ // Approver 提前解析: auto-window 现在按 chat 维度生效, isAutoWindowActive
908
+ // 检查要拿到 chatKey (= approver principal) 才能查。approver 缺失时 window
909
+ // 检查跳过 (window 需要一个 chat 才存在), 走后面的 no_approver fallback。
910
+ const approver = resolveApprover(cfg, sessionId, getMirrorTarget);
911
+ // Auto-approve window: while active for THIS chat, requests short-circuit to allow.
912
+ if (approver && isAutoWindowActive(approver)) {
913
+ const remainSec = Math.ceil(autoWindowRemainingMs(approver) / 1000);
914
+ log.info({ toolName, sessionId, chatKey: approver, remainSec }, "auto-window allow");
915
+ json(res, 200, {
916
+ decision: "allow",
917
+ reason: `auto_window:${remainSec}s`,
918
+ });
919
+ return;
920
+ }
921
+ // Session cache
922
+ const ck = cacheKey(sessionId, toolName, toolInput);
923
+ const cached = cacheGet(ck);
924
+ if (cached) {
925
+ log.info({ ck, cached }, "cache hit");
926
+ json(res, 200, {
927
+ decision: decisionToHook(cached),
928
+ reason: `cached:${cached}`,
929
+ });
930
+ return;
931
+ }
932
+ if (!approver) {
933
+ log.warn("no approver configured");
934
+ json(res, 200, fallback(cfg, "no_approver"));
935
+ return;
936
+ }
937
+ if (!client.isConnected) {
938
+ log.warn("ws not connected");
939
+ json(res, 200, fallback(cfg, "ws_disconnected"));
940
+ return;
941
+ }
942
+ // Build pending + card
943
+ const display = cfg.approval.sensitiveArgRedact ? redact(toolInput) : toolInput;
944
+ const toolInputStr = (() => {
945
+ try {
946
+ return JSON.stringify(display, null, 2);
947
+ }
948
+ catch {
949
+ return String(display);
950
+ }
951
+ })();
952
+ const longPollMs = cfg.approval.longPollSec * 1000;
953
+ const { reqId, promise } = createPending({
954
+ meta: {
955
+ kind: "approval",
956
+ createdAt: Date.now(),
957
+ toolName,
958
+ toolInput: display,
959
+ cwd,
960
+ sessionId,
961
+ chatKey: approver,
962
+ transcriptTail,
963
+ },
964
+ timeoutMs: longPollMs,
965
+ });
966
+ recordApproval({
967
+ id: reqId,
968
+ toolName,
969
+ toolInput: display,
970
+ cwd,
971
+ sessionId,
972
+ transcriptTail,
973
+ });
974
+ // Batch coalesce: 同 session 同 tool 的并发请求合流为一张卡。窗口内首位
975
+ // 创建 batch + 计时器, 后续到达者只追加成员, 不发卡。flush 时依据成员数
976
+ // 选择普通 buildCard 或 buildBatchCard。0 = 关闭聚合, 立即 flush。
977
+ const member = { reqId, toolInput: display, originalToolInput: toolInput, toolInputStr, cwd, transcriptTail };
978
+ const bk = batchKeyOf(sessionId, toolName);
979
+ const existing = activeBatches.get(bk);
980
+ if (existing && !existing.flushed) {
981
+ existing.members.push(member);
982
+ log.info({ batchId: existing.batchId, count: existing.members.length, reqId }, "batch joined");
983
+ }
984
+ else {
985
+ const batch = {
986
+ batchId: newBatchId(),
987
+ sessionId,
988
+ toolName,
989
+ approver,
990
+ windowMinutes: cfg.approval.windowMinutes,
991
+ members: [member],
992
+ flushed: false,
993
+ flushTimer: undefined, // set below
994
+ };
995
+ const coalesceMs = cfg.approval.batchCoalesceMs;
996
+ const fire = () => void flushBatch(batch);
997
+ batch.flushTimer = coalesceMs > 0 ? setTimeout(fire, coalesceMs) : setImmediate(fire);
998
+ activeBatches.set(bk, batch);
999
+ batchById.set(batch.batchId, batch);
1000
+ evictBatches();
1001
+ log.info({ batchId: batch.batchId, reqId, coalesceMs }, "batch opened");
1002
+ }
1003
+ // Long-poll
1004
+ let decision;
1005
+ try {
1006
+ decision = await promise;
1007
+ }
1008
+ catch (e) {
1009
+ log.warn({ err: e.message, reqId }, "approval timed out");
1010
+ json(res, 200, fallback(cfg, "approver_timeout"));
1011
+ return;
1012
+ }
1013
+ if (decision === "allow_session" && cfg.approval.sessionCacheMinutes > 0) {
1014
+ cachePut(ck, decision, cfg.approval.sessionCacheMinutes * 60_000);
1015
+ }
1016
+ if (decision === "allow_window" && cfg.approval.windowMinutes > 0) {
1017
+ setAutoWindow(approver, cfg.approval.windowMinutes * 60_000, {
1018
+ toolName,
1019
+ toolInput: display,
1020
+ cwd,
1021
+ transcriptTail,
1022
+ });
1023
+ // 同一个 chat 里其它 pending 卡 (可能来自并发同 turn, 也可能来自绑到
1024
+ // 同一个 chat 的别的 session) — 一并放行,免得用户逐个点。
1025
+ // 我们没有那些卡的事件 frame, 不能 updateTemplateCard 改文案;
1026
+ // 改用一条 markdown 消息回执让用户知道发生了什么。
1027
+ const swept = resolvePendingsByChat(approver, "allow_window", reqId);
1028
+ log.info({ sessionId, chatKey: approver, minutes: cfg.approval.windowMinutes, swept: swept.length }, "auto-window opened");
1029
+ if (swept.length > 0) {
1030
+ try {
1031
+ const tools = swept
1032
+ .map(({ meta }) => meta.toolName)
1033
+ .filter((s) => Boolean(s));
1034
+ const summary = tools.length > 0 ? tools.join(" / ") : `${swept.length} 个`;
1035
+ await client.sendMessage(targetChatId(approver), {
1036
+ msgtype: "markdown",
1037
+ markdown: { content: withTagHeader(approver, `⚡ 已批量自动放行其他 ${swept.length} 个并发请求:${summary}`) },
1038
+ });
1039
+ }
1040
+ catch (e) {
1041
+ log.warn({ err: e.message }, "sweep notice send failed");
1042
+ }
1043
+ }
1044
+ }
1045
+ // Resolved-card refresh happens inline in the click listener via
1046
+ // `updateTemplateCard` (5-sec window). No follow-up sendMessage here.
1047
+ json(res, 200, {
1048
+ decision: decisionToHook(decision),
1049
+ reason: decision,
1050
+ });
1051
+ };
1052
+ };
1053
+ // ── Card click event → resolvePending + update card in place ────────────
1054
+ export const installApprovalEventListener = (client, log, cfg, onApproved) => {
1055
+ client.on("event", (frame) => {
1056
+ try {
1057
+ log.info({ raw: JSON.stringify(frame.body).slice(0, 1200) }, "raw event frame");
1058
+ }
1059
+ catch {
1060
+ /* ignore */
1061
+ }
1062
+ });
1063
+ client.on("event.template_card_event", async (frame) => {
1064
+ const ev = frame.body?.event;
1065
+ // SDK d.ts says ev.event_key, but the actual payload nests it under
1066
+ // ev.template_card_event.event_key. Fall back across both for safety.
1067
+ const key = ev?.template_card_event?.event_key ?? ev?.event_key ?? "";
1068
+ // Update 时 task_id 必须跟回调里的一致,否则微信会拒掉更新。
1069
+ const cbTaskId = ev?.template_card_event?.task_id ?? ev?.task_id ?? "";
1070
+ // ── AskUserQuestion 投票卡: 在普通 approval 解码前先匹配 ASKQ| 前缀。
1071
+ const askq = decodeAskqKey(key);
1072
+ if (askq) {
1073
+ const meta = getPending(askq.reqId);
1074
+ const q = parseAskqInput(meta?.toolInput)?.[0];
1075
+ // 实际 payload 是 XML→JSON 双层包装, 不能直接 [0].option_ids; 同时
1076
+ // 兼容 SDK 文档里那个扁平形态(以防固件升级)。
1077
+ const si = ev?.template_card_event?.selected_items;
1078
+ const firstItem = Array.isArray(si)
1079
+ ? si[0]
1080
+ : si?.selected_item?.[0];
1081
+ const oids = firstItem?.option_ids;
1082
+ const rawIds = Array.isArray(oids)
1083
+ ? oids
1084
+ : (oids?.option_id ?? []);
1085
+ const cliPicked = rawIds.includes(ASKQ_CLI_OPTION_ID);
1086
+ const chatPicked = rawIds.includes(ASKQ_CHAT_OPTION_ID);
1087
+ const numericIdxs = rawIds
1088
+ .filter((s) => s !== ASKQ_CLI_OPTION_ID && s !== ASKQ_CHAT_OPTION_ID)
1089
+ .map((s) => parseInt(s, 10))
1090
+ .filter((n) => Number.isInteger(n) && n >= 0);
1091
+ // 哨兵优先级: CLI > chat > 数字选项 (混选也按哨兵语义处理)。
1092
+ const outcome = cliPicked
1093
+ ? { kind: "cli" }
1094
+ : chatPicked
1095
+ ? { kind: "chat" }
1096
+ : numericIdxs.length === 0
1097
+ ? { kind: "empty" }
1098
+ : { kind: "picked", picked: numericIdxs };
1099
+ const resolved = outcome.kind === "cli"
1100
+ ? "cli"
1101
+ : outcome.kind === "chat"
1102
+ ? "chat"
1103
+ : `${ASKQ_PICKED_PREFIX}${numericIdxs.join(",")}`;
1104
+ const ok = resolvePending(askq.reqId, resolved);
1105
+ log.info({ reqId: askq.reqId, outcome, ok }, "askq event resolved");
1106
+ if (q) {
1107
+ try {
1108
+ await client.updateTemplateCard(frame, buildAskqResolvedCard(cbTaskId || askq.reqId, q, outcome, meta?.transcriptTail ?? "", meta?.chatKey));
1109
+ }
1110
+ catch (e) {
1111
+ log.warn({ err: e.message, reqId: askq.reqId }, "askq updateTemplateCard failed");
1112
+ }
1113
+ }
1114
+ return;
1115
+ }
1116
+ // ── ExitPlanMode 计划审批卡: 在普通 approval 解码前匹配 PLAN| 前缀。
1117
+ const planClick = decodePlanKey(key);
1118
+ if (planClick) {
1119
+ const meta = getPending(planClick.reqId);
1120
+ const ok = resolvePending(planClick.reqId, `${PLAN_PICKED_PREFIX}${planClick.action}`);
1121
+ log.info({ reqId: planClick.reqId, action: planClick.action, ok }, "plan event resolved");
1122
+ try {
1123
+ await client.updateTemplateCard(frame, buildPlanResolvedCard(cbTaskId || planClick.reqId, planClick.action, meta?.cwd ?? "", meta?.transcriptTail ?? "", meta?.chatKey));
1124
+ }
1125
+ catch (e) {
1126
+ log.warn({ err: e.message, reqId: planClick.reqId }, "plan updateTemplateCard failed");
1127
+ }
1128
+ return;
1129
+ }
1130
+ // 已决计划卡再次被点 (plan_noop:<id>) → 终态 identity, 直接吞掉。
1131
+ if (decodePlanNoopKey(key) !== undefined)
1132
+ return;
1133
+ const decoded = decodeKey(key);
1134
+ const detailUrlFor = (id) => buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id);
1135
+ // Batch 卡分支: 一次性 resolve N 个成员 pending, 单次 update 卡片状态。
1136
+ // 必须在普通 decodeKey 分支前匹配 — 否则 batchId 形如 "b...|allow" 也
1137
+ // 能被 split("|") 当作 reqId|decision 误解出来。
1138
+ const batchDec = decodeBatchKey(key);
1139
+ if (batchDec) {
1140
+ const batch = batchById.get(batchDec.batchId);
1141
+ if (!batch) {
1142
+ log.info({ batchId: batchDec.batchId }, "batch click — unknown id, ignored");
1143
+ return;
1144
+ }
1145
+ const by = frame.body?.from?.userid ?? "?";
1146
+ let resolved = 0;
1147
+ for (const m of batch.members) {
1148
+ recordApprovalDecision(m.reqId, batchDec.decision, by);
1149
+ if (resolvePending(m.reqId, batchDec.decision))
1150
+ resolved++;
1151
+ }
1152
+ // 全部成员都已被先前的 allow_window sweep 拿走 → 渲染「已经放行」形态
1153
+ // 而非再画一遍三按钮的 resolved 卡, 跟单卡 swept 路径语义对齐。
1154
+ const allSwept = resolved === 0 && batch.members.length > 0;
1155
+ log.info({ batchId: batch.batchId, decision: batchDec.decision, resolved, total: batch.members.length, allSwept }, "batch resolved");
1156
+ if (resolved > 0)
1157
+ onApproved?.(batch.sessionId);
1158
+ try {
1159
+ const tail = batch.members[0]?.transcriptTail ?? "";
1160
+ const card = allSwept
1161
+ ? buildBatchAlreadyResolvedCard(batch, tail)
1162
+ : buildBatchResolvedCard(batch, batchDec.decision, tail);
1163
+ await client.updateTemplateCard(frame, card);
1164
+ log.info({ batchId: batch.batchId, decision: batchDec.decision, allSwept }, "batch card updated in place");
1165
+ }
1166
+ catch (e) {
1167
+ log.warn({ err: e.message, batchId: batch.batchId }, "batch updateTemplateCard failed");
1168
+ }
1169
+ return;
1170
+ }
1171
+ // Batch-noop / Askq-noop: 终态再点 = identity, 见下方统一 noop 分支。
1172
+ // 这里把分支提前吃掉, 防止 decodeKey 把 B-noop / askq_noop 误解成普通 noop。
1173
+ if (decodeBatchNoopKey(key) !== undefined || decodeAskqNoopKey(key) !== undefined) {
1174
+ log.info({ key }, "terminal re-click (batch/askq) — identity, no update");
1175
+ return;
1176
+ }
1177
+ // Noop branch: 任意 *_noop 前缀 = 终态卡再点 = identity。
1178
+ // 卡面已是 buildResolvedCard / buildBatchResolvedCard / buildCancelledCard
1179
+ // 渲染出的最终形态, 重画只会丢信息(把"✅ 已通过"糊成"已经放行")。
1180
+ // 不更新, 让 WeCom 端的卡面保持不变。
1181
+ if (decoded.noopReqId !== undefined) {
1182
+ log.info({ key }, "terminal re-click — identity, no update");
1183
+ return;
1184
+ }
1185
+ // Cancel branch: resolved allow_window card was clicked again to cancel
1186
+ // the auto-approve window for that chat. No pending to resolve.
1187
+ if (decoded.cancelChatKey) {
1188
+ const wmeta = getWindowMeta(decoded.cancelChatKey);
1189
+ clearAutoWindow(decoded.cancelChatKey);
1190
+ log.info({ chatKey: decoded.cancelChatKey }, "auto-window cancelled by click");
1191
+ try {
1192
+ await client.updateTemplateCard(frame, buildCancelledCard({
1193
+ reqId: cbTaskId, // 必须用回调的 task_id,否则微信拒更新
1194
+ toolName: wmeta?.toolName ?? "授权",
1195
+ toolInput: wmeta?.toolInput ?? {},
1196
+ toolInputStr: "",
1197
+ cwd: wmeta?.cwd ?? "",
1198
+ sessionShort: "?",
1199
+ chatKey: decoded.cancelChatKey,
1200
+ transcriptTail: wmeta?.transcriptTail ?? "",
1201
+ windowMinutes: cfg.approval.windowMinutes,
1202
+ detailUrl: cbTaskId ? detailUrlFor(cbTaskId) : undefined,
1203
+ }));
1204
+ log.info({ chatKey: decoded.cancelChatKey, cbTaskId }, "cancel card updated in place");
1205
+ }
1206
+ catch (e) {
1207
+ log.warn({ err: e.message }, "updateTemplateCard (cancel) failed");
1208
+ }
1209
+ return;
1210
+ }
1211
+ const { reqId, decision } = decoded;
1212
+ if (!reqId || !decision) {
1213
+ log.info({ key }, "card event ignored (bad key)");
1214
+ return;
1215
+ }
1216
+ // Snapshot meta BEFORE resolve (resolve deletes the entry).
1217
+ const livePending = getPending(reqId);
1218
+ const ok = resolvePending(reqId, decision);
1219
+ // 兜底: 这张卡如果是被 sweep 提前 resolve 掉的"鬼卡", livePending 已经没了
1220
+ // (resolvePendingsBySession 删过); 从 resolvedStash 里捞回原始 meta + 真实
1221
+ // decision, 渲染成"已自动放行"形态而非 (probe)。
1222
+ const snap = livePending ? undefined : getResolvedSnapshot(reqId);
1223
+ const meta = livePending ?? snap?.meta;
1224
+ const effectiveDecision = snap?.decision ?? decision;
1225
+ const by = frame.body?.from?.userid ?? "?";
1226
+ recordApprovalDecision(reqId, snap ? "swept" : effectiveDecision, by);
1227
+ if (ok)
1228
+ log.info({ reqId, decision }, "card event resolved");
1229
+ else if (snap)
1230
+ log.info({ reqId, snap: snap.decision }, "card event on swept card — rendering snapshot");
1231
+ else {
1232
+ // meta 完全缺失 (daemon 重启 / stash 过期 / 真探测包)。任何 update 都会用
1233
+ // 空字段把原卡覆盖成 "(probe) · /",比保留原卡更糟。直接放弃 update。
1234
+ log.warn({ reqId, decision }, "card event for unknown reqId (probe? expired?) — skipping update to avoid clobbering original card");
1235
+ return;
1236
+ }
1237
+ // 用户实际点击的那一下 — 通知 mirror 立刻 finalize 当前 liveStream,
1238
+ // 后续的 tool_use / tool_result 走防抖 standalone 路径,避免点击后仍把
1239
+ // 内容续写进同一个气泡。sweep 二次点击 (snap 命中, ok=false) 不触发,
1240
+ // 避免重复 finalize。
1241
+ if (ok && meta?.sessionId)
1242
+ onApproved?.(meta.sessionId);
1243
+ // Refresh original card in place (must be within 5s of click).
1244
+ // Independent of pending resolution: we always want visual ACK on any
1245
+ // well-formed click so the user knows the click landed.
1246
+ const toolInputStr = (() => {
1247
+ try {
1248
+ return JSON.stringify(meta?.toolInput ?? {}, null, 2);
1249
+ }
1250
+ catch {
1251
+ return String(meta?.toolInput);
1252
+ }
1253
+ })();
1254
+ const sessionShort = meta?.sessionId ? meta.sessionId.slice(-8) : "?";
1255
+ // snap 命中分支拆开判:
1256
+ // - snap.decision == allow_window → 这张卡是被 sweep 提前放行的"鬼卡",
1257
+ // 渲染「已经放行」, 别画 allow_window 的「点击取消」 — 不然用户在被
1258
+ // 批量放行的卡上点一下就会误取消整个自动窗口。
1259
+ // - 其它 decision → 已是 resolvePending 留下的快照(普通 resolve 也 stash),
1260
+ // 用 buildResolvedCard 重画原文案 ("✅ 已通过" / "❌ 已拒绝" / 本会话通过),
1261
+ // 避免降级到信息量更少的「已经放行」。
1262
+ const isSwept = Boolean(snap && snap.decision === "allow_window");
1263
+ try {
1264
+ const card = isSwept
1265
+ ? buildAlreadyResolvedCard({
1266
+ reqId: cbTaskId || reqId,
1267
+ toolName: meta?.toolName ?? "授权",
1268
+ toolInput: meta?.toolInput ?? {},
1269
+ toolInputStr,
1270
+ cwd: meta?.cwd ?? "",
1271
+ sessionShort,
1272
+ sessionId: meta?.sessionId ?? "",
1273
+ chatKey: meta?.chatKey ?? "",
1274
+ transcriptTail: meta?.transcriptTail ?? "",
1275
+ windowMinutes: cfg.approval.windowMinutes,
1276
+ detailUrl: detailUrlFor(reqId),
1277
+ })
1278
+ : buildResolvedCard({
1279
+ reqId,
1280
+ toolName: meta?.toolName ?? "授权",
1281
+ toolInput: meta?.toolInput,
1282
+ toolInputStr,
1283
+ cwd: meta?.cwd ?? "",
1284
+ sessionShort,
1285
+ transcriptTail: meta?.transcriptTail ?? "",
1286
+ windowMinutes: cfg.approval.windowMinutes,
1287
+ decision: effectiveDecision,
1288
+ by,
1289
+ sessionId: meta?.sessionId ?? "",
1290
+ chatKey: meta?.chatKey ?? "",
1291
+ detailUrl: detailUrlFor(reqId),
1292
+ });
1293
+ await client.updateTemplateCard(frame, card);
1294
+ log.info({ reqId, decision, swept: Boolean(snap) }, "card updated in place");
1295
+ }
1296
+ catch (e) {
1297
+ log.warn({ err: e.message, reqId }, "updateTemplateCard failed");
1298
+ }
1299
+ });
1300
+ };
1301
+ //# sourceMappingURL=approval.js.map