weclaude 0.0.4 → 0.1.1

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 (48) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +105 -28
  3. package/cli/{wrc.sh → weclaude.sh} +34 -18
  4. package/commands/wrc.md +4 -4
  5. package/config.example.jsonc +6 -6
  6. package/dist/cli/init.js +10 -10
  7. package/dist/cli/init.js.map +1 -1
  8. package/dist/cli/sync.js +35 -18
  9. package/dist/cli/sync.js.map +1 -1
  10. package/dist/daemon/approval.js +487 -37
  11. package/dist/daemon/approval.js.map +1 -1
  12. package/dist/daemon/cc-bridge.js +37 -20
  13. package/dist/daemon/cc-bridge.js.map +1 -1
  14. package/dist/daemon/claim.js +20 -1
  15. package/dist/daemon/claim.js.map +1 -1
  16. package/dist/daemon/detail.js +500 -0
  17. package/dist/daemon/detail.js.map +1 -0
  18. package/dist/daemon/http.js +2 -1
  19. package/dist/daemon/http.js.map +1 -1
  20. package/dist/daemon/inbound.js +115 -21
  21. package/dist/daemon/inbound.js.map +1 -1
  22. package/dist/daemon/index.js +30 -8
  23. package/dist/daemon/index.js.map +1 -1
  24. package/dist/daemon/mirror-bridge.js +1010 -153
  25. package/dist/daemon/mirror-bridge.js.map +1 -1
  26. package/dist/daemon/mirror-store.js +39 -0
  27. package/dist/daemon/mirror-store.js.map +1 -0
  28. package/dist/daemon/pending.js +46 -0
  29. package/dist/daemon/pending.js.map +1 -1
  30. package/dist/daemon/session-cache.js +71 -3
  31. package/dist/daemon/session-cache.js.map +1 -1
  32. package/dist/daemon/spawn-tmux.js +132 -0
  33. package/dist/daemon/spawn-tmux.js.map +1 -0
  34. package/dist/mcp/server.js +104 -65
  35. package/dist/mcp/server.js.map +1 -1
  36. package/dist/shared/config-writer.js +1 -1
  37. package/dist/shared/config.js +34 -20
  38. package/dist/shared/config.js.map +1 -1
  39. package/dist/shared/paths.js +6 -0
  40. package/dist/shared/paths.js.map +1 -1
  41. package/docs/DESIGN-INIT.md +6 -6
  42. package/docs/ONBOARDING.md +25 -25
  43. package/hooks/pre-tool-use.sh +42 -7
  44. package/launchd/{com.cc-wecom.daemon.plist.template → com.weclaude.daemon.plist.template} +3 -3
  45. package/package.json +10 -11
  46. package/scripts/install.sh +6 -6
  47. package/scripts/uninstall.sh +3 -3
  48. package/systemd/{cc-wecom.service.template → weclaude.service.template} +3 -3
@@ -1,6 +1,7 @@
1
- import { createPending, getPending, resolvePending } from "./pending.js";
2
- import { cacheGet, cachePut, cacheKey, isAutoWindowActive, autoWindowRemainingMs, setAutoWindow, } from "./session-cache.js";
1
+ import { createPending, getPending, getResolvedSnapshot, resolvePending, resolvePendingsBySession } from "./pending.js";
2
+ import { cacheGet, cachePut, cacheKey, isAutoWindowActive, autoWindowRemainingMs, setAutoWindow, clearAutoWindow, getWindowMeta, } from "./session-cache.js";
3
3
  import { redact } from "./redact.js";
4
+ import { recordApproval, recordApprovalDecision, buildDetailUrl } from "./detail.js";
4
5
  import { json, readBody } from "./http.js";
5
6
  // ── Routing helpers ────────────────────────────────────────────────────
6
7
  const targetChatId = (principal) => {
@@ -35,13 +36,13 @@ const EDIT_SNIPPET_LEN = 160;
35
36
  const WRITE_PREVIEW_LINES = 4;
36
37
  const WRITE_PREVIEW_CHARS = 200;
37
38
  const SOURCE_BASE = {
38
- icon_url: "https://wwcdn.weixin.qq.com/node/wework/images/icon.44f2d37c14.jpg",
39
+ icon_url: "https://wwcdn.weixin.qq.com/node/wework/images/3d-claude-ai-logo.bce0ddae70.jpg",
39
40
  desc: "Claude Code",
40
41
  desc_color: 0,
41
42
  };
42
43
  // Source bar sits ABOVE main_title — only place we can hoist transcript context.
43
44
  const buildSource = (tail) => {
44
- const desc = tail ? `💬 ${TRUNC(tail, 80)}` : SOURCE_BASE.desc;
45
+ const desc = tail ? TRUNC(tail, 80) : SOURCE_BASE.desc;
45
46
  return { ...SOURCE_BASE, desc };
46
47
  };
47
48
  const prefixLines = (s, prefix) => s.split("\n").map((l) => `${prefix} ${l}`).join("\n");
@@ -117,56 +118,114 @@ const renderInput = (toolName, toolInput, _toolInputStr, cwd) => {
117
118
  };
118
119
  const quoteArea = (text) => ({ type: 0, quote_text: text });
119
120
  const dirName = (cwd) => cwd.replace(/^.*\//, "") || cwd;
121
+ const detailJumpList = (url) => url ? [{ type: 1, title: "🔍 详情", url }] : undefined;
120
122
  const buildCard = (a) => {
121
123
  const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
122
124
  const dir = dirName(a.cwd);
123
125
  const tail = oneLine(a.transcriptTail).trim();
126
+ const jl = detailJumpList(a.detailUrl);
124
127
  return {
125
128
  card_type: "button_interaction",
126
129
  source: buildSource(tail),
127
130
  main_title: {
128
- title: `🔐 授权 · ${a.toolName} · 📁 ${dir}/`,
131
+ title: `🔐 授权 · ${a.toolName} · ${dir}/`,
129
132
  },
130
133
  ...(r.body ? { quote_area: quoteArea(r.body) } : {}),
134
+ ...(jl ? { jump_list: jl } : {}),
131
135
  task_id: a.reqId,
132
136
  button_list: [
133
- { text: "❌", style: 3, key: encodeKey(a.reqId, "deny") },
134
- { text: "⚠️", style: 3, key: encodeKey(a.reqId, "allow_window") },
135
- { text: "✅", style: 3, key: encodeKey(a.reqId, "allow") },
137
+ { text: "❌", style: 4, key: encodeKey(a.reqId, "deny") },
138
+ { text: "10min", style: 3, key: encodeKey(a.reqId, "allow_window") },
139
+ { text: "✅", style: 4, key: encodeKey(a.reqId, "allow") },
136
140
  ],
137
141
  };
138
142
  };
139
143
  const verbOf = (d, windowMinutes) => {
140
144
  switch (d) {
141
145
  case "deny": return "已拒绝";
142
- case "allow_window": return `${windowMinutes}分钟此会话内通过所有`;
146
+ case "allow_window": return `${windowMinutes}min会话内通过所有`;
143
147
  case "allow_session": return "本会话通过";
144
148
  default: return "已通过";
145
149
  }
146
150
  };
147
151
  const emojiOf = (d) => (d === "deny" ? "❌" : "✅");
148
- const resolvedButtonText = (d, windowMinutes) => `${emojiOf(d)} ${verbOf(d, windowMinutes)}`;
152
+ // allow_window 仍可点击以取消自动窗口;其余决策为最终态 noop。
153
+ const resolvedButton = (d, windowMinutes, reqId, sessionId) => {
154
+ if (d === "allow_window") {
155
+ return {
156
+ text: `${verbOf(d, windowMinutes)}(点击取消)`,
157
+ style: 4,
158
+ key: encodeCancelKey(sessionId),
159
+ };
160
+ }
161
+ return {
162
+ text: `${emojiOf(d)} ${verbOf(d, windowMinutes)}`,
163
+ style: 4,
164
+ key: `noop:${reqId}`,
165
+ };
166
+ };
149
167
  const buildResolvedCard = (a) => {
150
168
  const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
151
169
  const dir = dirName(a.cwd);
152
170
  const tail = oneLine(a.transcriptTail).trim();
171
+ const jl = detailJumpList(a.detailUrl);
153
172
  return {
154
173
  card_type: "button_interaction",
155
174
  source: buildSource(tail),
156
175
  main_title: {
157
- title: `${a.toolName} · 📁 ${dir}/`,
176
+ title: `${a.toolName} · ${dir}/`,
158
177
  },
159
178
  ...(r.body ? { quote_area: quoteArea(r.body) } : {}),
179
+ ...(jl ? { jump_list: jl } : {}),
180
+ task_id: a.reqId,
181
+ button_list: [resolvedButton(a.decision, a.windowMinutes, a.reqId, a.sessionId)],
182
+ };
183
+ };
184
+ const buildCancelledCard = (a) => {
185
+ const r = renderInput(a.toolName, a.toolInput, a.toolInputStr, a.cwd);
186
+ const dir = dirName(a.cwd);
187
+ const tail = oneLine(a.transcriptTail).trim();
188
+ const jl = detailJumpList(a.detailUrl);
189
+ return {
190
+ card_type: "button_interaction",
191
+ source: buildSource(tail),
192
+ main_title: { title: `${a.toolName} · ${dir}/` },
193
+ ...(r.body ? { quote_area: quoteArea(r.body) } : {}),
194
+ ...(jl ? { jump_list: jl } : {}),
160
195
  task_id: a.reqId,
161
196
  button_list: [
162
- { text: resolvedButtonText(a.decision, a.windowMinutes), style: 4, key: `noop:${a.reqId}` },
197
+ { text: "已取消自动通过", style: 4, key: `noop:cancelled:${a.reqId}` },
163
198
  ],
164
199
  };
165
200
  };
201
+ // 已 resolved 的卡再次被点击 — 仅作视觉反馈, 不改变任何状态。
202
+ const buildAlreadyResolvedCard = (a) => {
203
+ 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
+ const jl = detailJumpList(a.detailUrl);
207
+ return {
208
+ card_type: "button_interaction",
209
+ source: buildSource(tail),
210
+ main_title: { title: `${a.toolName} · ${dir}/` },
211
+ ...(r.body ? { quote_area: quoteArea(r.body) } : {}),
212
+ ...(jl ? { jump_list: jl } : {}),
213
+ task_id: a.reqId,
214
+ button_list: [{ text: "已经放行", style: 4, key: `noop:${a.reqId}` }],
215
+ };
216
+ };
166
217
  const encodeKey = (reqId, decision) => `${reqId}|${decision}`;
218
+ const NOOP_PREFIX = "noop:";
219
+ const CANCEL_PREFIX = "cancel_window:";
220
+ const encodeCancelKey = (sessionId) => `${CANCEL_PREFIX}${sessionId}`;
167
221
  const decodeKey = (key) => {
168
- if (key.startsWith("noop:"))
169
- return {};
222
+ if (key.startsWith(NOOP_PREFIX)) {
223
+ // noop:cancelled:<id> 也走这里,noopReqId 取剩余部分作为 task_id 兜底。
224
+ return { noopReqId: key.slice(NOOP_PREFIX.length) };
225
+ }
226
+ if (key.startsWith(CANCEL_PREFIX)) {
227
+ return { cancelSessionId: key.slice(CANCEL_PREFIX.length) };
228
+ }
170
229
  const [reqId, d] = key.split("|");
171
230
  if (!reqId || !d)
172
231
  return {};
@@ -174,12 +233,198 @@ const decodeKey = (key) => {
174
233
  return {};
175
234
  return { reqId, decision: d };
176
235
  };
236
+ // ── AskUserQuestion 投票卡分支 ─────────────────────────────────────────
237
+ // PreToolUse 协议端只能输出 allow/deny/ask, 没有「合成 tool_result」通道。
238
+ // 取舍: 用户在 WeCom 选了选项 → 走 deny + 把答案塞进 reason, model 把 reason
239
+ // 当作上下文继续推理(CLI 不会弹原生 picker, 流程不被打断);
240
+ // 选「🖥️ CLI 处理」哨兵选项 → 返回 ask, CLI 弹原生 picker 由用户本地作答。两路互斥。
241
+ // vote_interaction 不支持 button_list (SDK 类型注释明写「button_interaction 类型卡片使用」),
242
+ // 微信侧静默吞掉, 所以 cli 入口只能塞进 checkbox option_list 作为哨兵 id。
243
+ const ASKQ_PREFIX = "ASKQ|";
244
+ const ASKQ_PICKED_PREFIX = "picked:";
245
+ const ASKQ_CLI_OPTION_ID = "__cli__";
246
+ const ASKQ_NOOP_PREFIX = "askq_noop:";
247
+ const encodeAskqKey = (reqId) => `${ASKQ_PREFIX}${reqId}|submit`;
248
+ const encodeAskqNoopKey = (reqId) => `${ASKQ_NOOP_PREFIX}${reqId}`;
249
+ const decodeAskqKey = (key) => {
250
+ if (!key.startsWith(ASKQ_PREFIX))
251
+ return undefined;
252
+ const [reqId, action] = key.slice(ASKQ_PREFIX.length).split("|");
253
+ if (!reqId || action !== "submit")
254
+ return undefined;
255
+ return { reqId, action };
256
+ };
257
+ const decodeAskqNoopKey = (key) => key.startsWith(ASKQ_NOOP_PREFIX) ? key.slice(ASKQ_NOOP_PREFIX.length) : undefined;
258
+ const parseAskqInput = (i) => {
259
+ if (!i || typeof i !== "object")
260
+ return undefined;
261
+ const qs = i.questions;
262
+ if (!Array.isArray(qs))
263
+ return undefined;
264
+ return qs.map((q) => {
265
+ const qq = (q ?? {});
266
+ const opts = Array.isArray(qq.options) ? qq.options : [];
267
+ return {
268
+ question: typeof qq.question === "string" ? qq.question : "",
269
+ header: typeof qq.header === "string" ? qq.header : "",
270
+ multiSelect: Boolean(qq.multiSelect),
271
+ options: opts.flatMap((o) => {
272
+ const oo = (o ?? {});
273
+ return typeof oo.label === "string"
274
+ ? [{
275
+ label: oo.label,
276
+ description: typeof oo.description === "string" ? oo.description : undefined,
277
+ }]
278
+ : [];
279
+ }),
280
+ };
281
+ });
282
+ };
283
+ const ASKQ_OPTION_TEXT_MAX = 11;
284
+ const ASKQ_TITLE_MAX = 26;
285
+ const ASKQ_SUB_MAX = 480;
286
+ const buildAskqCard = (reqId, q, transcriptTail) => {
287
+ const lines = [];
288
+ if (q.question)
289
+ lines.push(q.question);
290
+ q.options.forEach((o, idx) => {
291
+ if (o.description)
292
+ lines.push(`${idx + 1}. ${o.label} — ${o.description}`);
293
+ });
294
+ const tail = oneLine(transcriptTail).trim();
295
+ return {
296
+ card_type: "vote_interaction",
297
+ source: buildSource(tail),
298
+ main_title: { title: TRUNC(`🤔 ${q.header || "请选择"}`, ASKQ_TITLE_MAX) },
299
+ sub_title_text: TRUNC(lines.join("\n"), ASKQ_SUB_MAX),
300
+ task_id: reqId,
301
+ checkbox: {
302
+ question_key: "q",
303
+ mode: q.multiSelect ? 1 : 0,
304
+ option_list: [
305
+ ...q.options.map((o, idx) => ({
306
+ id: String(idx),
307
+ text: TRUNC(o.label, ASKQ_OPTION_TEXT_MAX),
308
+ })),
309
+ { id: ASKQ_CLI_OPTION_ID, text: "🖥️ CLI 处理" },
310
+ ],
311
+ },
312
+ submit_button: { text: "提交", key: encodeAskqKey(reqId) },
313
+ };
314
+ };
315
+ const askqResolvedStash = new Map();
316
+ const ASKQ_RESOLVED_TTL_MS = 30 * 60_000;
317
+ const ASKQ_RESOLVED_MAX = 200;
318
+ const stashAskqResolved = (reqId, snap) => {
319
+ askqResolvedStash.set(reqId, { ...snap, at: Date.now() });
320
+ if (askqResolvedStash.size > ASKQ_RESOLVED_MAX) {
321
+ const cutoff = Date.now() - ASKQ_RESOLVED_TTL_MS;
322
+ for (const [k, v] of askqResolvedStash)
323
+ if (v.at < cutoff)
324
+ askqResolvedStash.delete(k);
325
+ }
326
+ };
327
+ const getAskqResolved = (reqId) => {
328
+ const e = askqResolvedStash.get(reqId);
329
+ if (!e)
330
+ return undefined;
331
+ if (Date.now() - e.at > ASKQ_RESOLVED_TTL_MS) {
332
+ askqResolvedStash.delete(reqId);
333
+ return undefined;
334
+ }
335
+ return e;
336
+ };
337
+ const buildAskqResolvedCard = (reqId, q, outcome, transcriptTail) => {
338
+ const summary = outcome.kind === "cli"
339
+ ? "🖥️ 已转 CLI 处理"
340
+ : outcome.kind === "empty"
341
+ ? "⚠️ 未选择"
342
+ : `✅ ${outcome.picked.map((i) => q.options[i]?.label ?? `#${i}`).join(", ")}`;
343
+ const tail = oneLine(transcriptTail).trim();
344
+ return {
345
+ card_type: "button_interaction",
346
+ source: buildSource(tail),
347
+ main_title: { title: TRUNC(`🤔 ${q.header || "已回答"}`, ASKQ_TITLE_MAX) },
348
+ sub_title_text: TRUNC(q.question, ASKQ_SUB_MAX),
349
+ task_id: reqId,
350
+ button_list: [{ text: TRUNC(summary, 30), style: 4, key: encodeAskqNoopKey(reqId) }],
351
+ };
352
+ };
353
+ const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget }) => {
354
+ const questions = parseAskqInput(body.tool_input);
355
+ if (!questions || questions.length === 0)
356
+ return { decision: "ask", reason: "askq_unparsable" };
357
+ if (questions.length > 1)
358
+ return { decision: "ask", reason: "askq_multi_unsupported" };
359
+ const q = questions[0];
360
+ if (q.options.length === 0)
361
+ return { decision: "ask", reason: "askq_no_options" };
362
+ const approver = resolveApprover(cfg, body.session_id, getMirrorTarget);
363
+ if (!approver)
364
+ return { decision: "ask", reason: "no_approver" };
365
+ if (!client.isConnected)
366
+ return { decision: "ask", reason: "ws_disconnected" };
367
+ const longPollMs = cfg.approval.longPollSec * 1000;
368
+ // toolInput 存原始 input,事件 listener 通过 getPending 重解析。
369
+ // transcriptTail 一并存进 meta, resolved 卡渲染时复用同一份 source。
370
+ const { reqId, promise } = createPending({
371
+ meta: {
372
+ kind: "generic",
373
+ createdAt: Date.now(),
374
+ toolName: "AskUserQuestion",
375
+ toolInput: body.tool_input,
376
+ cwd: body.cwd,
377
+ sessionId: body.session_id,
378
+ transcriptTail: body.transcript_tail ?? "",
379
+ },
380
+ timeoutMs: longPollMs,
381
+ });
382
+ try {
383
+ await client.sendMessage(targetChatId(approver), {
384
+ msgtype: "template_card",
385
+ template_card: buildAskqCard(reqId, q, body.transcript_tail ?? ""),
386
+ });
387
+ log.info({ reqId, approver }, "askq card sent");
388
+ }
389
+ catch (e) {
390
+ log.error({ err: e.message }, "askq send failed");
391
+ resolvePending(reqId, "deny"); // 释放 pending 槽
392
+ return { decision: "ask", reason: `askq_send_fail:${e.message}` };
393
+ }
394
+ let raw;
395
+ try {
396
+ raw = (await promise);
397
+ }
398
+ catch {
399
+ return { decision: "ask", reason: "askq_timeout" };
400
+ }
401
+ if (raw === "cli")
402
+ return { decision: "ask", reason: "askq_cli" };
403
+ if (raw.startsWith(ASKQ_PICKED_PREFIX)) {
404
+ const idxs = raw.slice(ASKQ_PICKED_PREFIX.length)
405
+ .split(",")
406
+ .map((s) => parseInt(s, 10))
407
+ .filter((n) => Number.isInteger(n) && n >= 0 && n < q.options.length);
408
+ if (idxs.length === 0)
409
+ return { decision: "ask", reason: "askq_empty_pick" };
410
+ const labels = idxs.map((i) => q.options[i].label).join(", ");
411
+ return {
412
+ decision: "deny",
413
+ reason: `User answered "${q.header || q.question}" via WeCom: ${labels}`,
414
+ };
415
+ }
416
+ return { decision: "ask", reason: "askq_unknown" };
417
+ };
177
418
  const decisionToHook = (d) => (d === "deny" ? "deny" : "allow");
178
419
  const fallback = (cfg, reason) => ({
179
420
  decision: cfg.approval.fallbackOnError,
180
421
  reason,
181
422
  });
182
- export const makeApproveHandler = ({ cfg, log, client }) => {
423
+ const resolveApprover = (cfg, sessionId, getMirrorTarget) => {
424
+ const mirror = sessionId ? getMirrorTarget?.(sessionId) : undefined;
425
+ return mirror || pickApprover(cfg);
426
+ };
427
+ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget }) => {
183
428
  return async (req, res) => {
184
429
  if (!cfg.approval.enabled) {
185
430
  json(res, 200, { decision: "ask", reason: "approval_disabled" });
@@ -200,6 +445,24 @@ export const makeApproveHandler = ({ cfg, log, client }) => {
200
445
  json(res, 200, { decision: "allow", reason: "matcher_skip" });
201
446
  return;
202
447
  }
448
+ // AskUserQuestion 走单独的投票卡分支(deny+reason 注入答案 / ask 转 CLI)。
449
+ if (toolName === "AskUserQuestion") {
450
+ const resp = await handleAskUserQuestion({
451
+ cfg,
452
+ log,
453
+ client,
454
+ getMirrorTarget,
455
+ body: {
456
+ session_id: sessionId,
457
+ tool_name: toolName,
458
+ tool_input: toolInput,
459
+ cwd,
460
+ transcript_tail: transcriptTail,
461
+ },
462
+ });
463
+ json(res, 200, resp);
464
+ return;
465
+ }
203
466
  // Auto-approve window: while active for THIS session, requests short-circuit to allow.
204
467
  if (isAutoWindowActive(sessionId)) {
205
468
  const remainSec = Math.ceil(autoWindowRemainingMs(sessionId) / 1000);
@@ -221,7 +484,7 @@ export const makeApproveHandler = ({ cfg, log, client }) => {
221
484
  });
222
485
  return;
223
486
  }
224
- const approver = pickApprover(cfg);
487
+ const approver = resolveApprover(cfg, sessionId, getMirrorTarget);
225
488
  if (!approver) {
226
489
  log.warn("no approver configured");
227
490
  json(res, 200, fallback(cfg, "no_approver"));
@@ -256,6 +519,15 @@ export const makeApproveHandler = ({ cfg, log, client }) => {
256
519
  },
257
520
  timeoutMs: longPollMs,
258
521
  });
522
+ const detailUrl = buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, reqId);
523
+ recordApproval({
524
+ id: reqId,
525
+ toolName,
526
+ toolInput: display,
527
+ cwd,
528
+ sessionId,
529
+ transcriptTail,
530
+ });
259
531
  const card = buildCard({
260
532
  reqId,
261
533
  toolName,
@@ -265,6 +537,7 @@ export const makeApproveHandler = ({ cfg, log, client }) => {
265
537
  sessionShort,
266
538
  transcriptTail,
267
539
  windowMinutes: cfg.approval.windowMinutes,
540
+ detailUrl,
268
541
  });
269
542
  try {
270
543
  await client.sendMessage(targetChatId(approver), {
@@ -292,8 +565,32 @@ export const makeApproveHandler = ({ cfg, log, client }) => {
292
565
  cachePut(ck, decision, cfg.approval.sessionCacheMinutes * 60_000);
293
566
  }
294
567
  if (decision === "allow_window" && cfg.approval.windowMinutes > 0) {
295
- setAutoWindow(sessionId, cfg.approval.windowMinutes * 60_000);
296
- log.info({ sessionId, minutes: cfg.approval.windowMinutes }, "auto-window opened");
568
+ setAutoWindow(sessionId, cfg.approval.windowMinutes * 60_000, {
569
+ toolName,
570
+ toolInput: display,
571
+ cwd,
572
+ transcriptTail,
573
+ });
574
+ // 同 turn 并发触发的其它 pending 卡 — 一并放行,免得用户逐个点。
575
+ // 我们没有那些卡的事件 frame, 不能 updateTemplateCard 改文案;
576
+ // 改用一条 markdown 消息回执让用户知道发生了什么。
577
+ const swept = resolvePendingsBySession(sessionId, "allow_window", reqId);
578
+ log.info({ sessionId, minutes: cfg.approval.windowMinutes, swept: swept.length }, "auto-window opened");
579
+ if (swept.length > 0) {
580
+ try {
581
+ const tools = swept
582
+ .map(({ meta }) => meta.toolName)
583
+ .filter((s) => Boolean(s));
584
+ const summary = tools.length > 0 ? tools.join(" / ") : `${swept.length} 个`;
585
+ await client.sendMessage(targetChatId(approver), {
586
+ msgtype: "markdown",
587
+ markdown: { content: `⚡ 已批量自动放行其他 ${swept.length} 个并发请求:${summary}` },
588
+ });
589
+ }
590
+ catch (e) {
591
+ log.warn({ err: e.message }, "sweep notice send failed");
592
+ }
593
+ }
297
594
  }
298
595
  // Resolved-card refresh happens inline in the click listener via
299
596
  // `updateTemplateCard` (5-sec window). No follow-up sendMessage here.
@@ -304,7 +601,7 @@ export const makeApproveHandler = ({ cfg, log, client }) => {
304
601
  };
305
602
  };
306
603
  // ── Card click event → resolvePending + update card in place ────────────
307
- export const installApprovalEventListener = (client, log, cfg) => {
604
+ export const installApprovalEventListener = (client, log, cfg, onApproved) => {
308
605
  client.on("event", (frame) => {
309
606
  try {
310
607
  log.info({ raw: JSON.stringify(frame.body).slice(0, 1200) }, "raw event frame");
@@ -318,18 +615,154 @@ export const installApprovalEventListener = (client, log, cfg) => {
318
615
  // SDK d.ts says ev.event_key, but the actual payload nests it under
319
616
  // ev.template_card_event.event_key. Fall back across both for safety.
320
617
  const key = ev?.template_card_event?.event_key ?? ev?.event_key ?? "";
321
- const { reqId, decision } = decodeKey(key);
618
+ // Update task_id 必须跟回调里的一致,否则微信会拒掉更新。
619
+ const cbTaskId = ev?.template_card_event?.task_id ?? ev?.task_id ?? "";
620
+ // ── AskUserQuestion 投票卡: 在普通 approval 解码前先匹配 ASKQ| 前缀。
621
+ const askq = decodeAskqKey(key);
622
+ if (askq) {
623
+ const meta = getPending(askq.reqId);
624
+ const q = parseAskqInput(meta?.toolInput)?.[0];
625
+ // 实际 payload 是 XML→JSON 双层包装, 不能直接 [0].option_ids; 同时
626
+ // 兼容 SDK 文档里那个扁平形态(以防固件升级)。
627
+ const si = ev?.template_card_event?.selected_items;
628
+ const firstItem = Array.isArray(si)
629
+ ? si[0]
630
+ : si?.selected_item?.[0];
631
+ const oids = firstItem?.option_ids;
632
+ const rawIds = Array.isArray(oids)
633
+ ? oids
634
+ : (oids?.option_id ?? []);
635
+ const cliPicked = rawIds.includes(ASKQ_CLI_OPTION_ID);
636
+ const numericIdxs = rawIds
637
+ .filter((s) => s !== ASKQ_CLI_OPTION_ID)
638
+ .map((s) => parseInt(s, 10))
639
+ .filter((n) => Number.isInteger(n) && n >= 0);
640
+ // CLI 哨兵优先 (即便和其它选项混选,也按转 CLI 处理)。
641
+ const outcome = cliPicked
642
+ ? { kind: "cli" }
643
+ : numericIdxs.length === 0
644
+ ? { kind: "empty" }
645
+ : { kind: "picked", picked: numericIdxs };
646
+ const resolved = outcome.kind === "cli"
647
+ ? "cli"
648
+ : `${ASKQ_PICKED_PREFIX}${numericIdxs.join(",")}`;
649
+ const ok = resolvePending(askq.reqId, resolved);
650
+ log.info({ reqId: askq.reqId, outcome, ok }, "askq event resolved");
651
+ if (q) {
652
+ // 存一份给后续在 resolved 卡上重复点击 askq_noop 用 — resolvePending
653
+ // 已经把 pending entry 删掉了,不再 stash 这次拿不到原 question。
654
+ stashAskqResolved(askq.reqId, { q, outcome, transcriptTail: meta?.transcriptTail ?? "" });
655
+ try {
656
+ await client.updateTemplateCard(frame, buildAskqResolvedCard(cbTaskId || askq.reqId, q, outcome, meta?.transcriptTail ?? ""));
657
+ }
658
+ catch (e) {
659
+ log.warn({ err: e.message, reqId: askq.reqId }, "askq updateTemplateCard failed");
660
+ }
661
+ }
662
+ return;
663
+ }
664
+ const decoded = decodeKey(key);
665
+ const detailUrlFor = (id) => buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id);
666
+ // Askq-noop 分支: askq 投票卡 submit 后那张「已回答」卡再次被点。
667
+ // 必须在普通 noop 分支前匹配 — 否则会落到 buildAlreadyResolvedCard
668
+ // 渲染成「授权 · 已经放行」,污染原卡。
669
+ const askqNoopReqId = decodeAskqNoopKey(key);
670
+ if (askqNoopReqId !== undefined) {
671
+ const snap = getAskqResolved(askqNoopReqId);
672
+ if (snap) {
673
+ try {
674
+ await client.updateTemplateCard(frame, buildAskqResolvedCard(cbTaskId || askqNoopReqId, snap.q, snap.outcome, snap.transcriptTail));
675
+ log.info({ reqId: askqNoopReqId }, "askq noop click — resolved card refreshed");
676
+ }
677
+ catch (e) {
678
+ log.warn({ err: e.message, reqId: askqNoopReqId }, "updateTemplateCard (askq-noop) failed");
679
+ }
680
+ }
681
+ else {
682
+ log.info({ reqId: askqNoopReqId }, "askq noop click — stash expired, ignored");
683
+ }
684
+ return;
685
+ }
686
+ // Noop branch: 卡片已 resolved, 用户再次点击 — 给一次视觉反馈。
687
+ if (decoded.noopReqId !== undefined) {
688
+ const reqId = decoded.noopReqId;
689
+ const snap = getResolvedSnapshot(reqId);
690
+ try {
691
+ await client.updateTemplateCard(frame, buildAlreadyResolvedCard({
692
+ reqId: cbTaskId || reqId,
693
+ toolName: snap?.meta.toolName ?? "授权",
694
+ toolInput: snap?.meta.toolInput ?? {},
695
+ toolInputStr: "",
696
+ cwd: snap?.meta.cwd ?? "",
697
+ sessionShort: snap?.meta.sessionId ? snap.meta.sessionId.slice(-8) : "?",
698
+ transcriptTail: snap?.meta.transcriptTail ?? "",
699
+ windowMinutes: cfg.approval.windowMinutes,
700
+ detailUrl: detailUrlFor(reqId),
701
+ }));
702
+ log.info({ reqId }, "noop click — already-resolved card refreshed");
703
+ }
704
+ catch (e) {
705
+ log.warn({ err: e.message, reqId }, "updateTemplateCard (noop) failed");
706
+ }
707
+ return;
708
+ }
709
+ // Cancel branch: resolved allow_window card was clicked again to cancel
710
+ // the auto-approve window for that session. No pending to resolve.
711
+ if (decoded.cancelSessionId) {
712
+ const wmeta = getWindowMeta(decoded.cancelSessionId);
713
+ clearAutoWindow(decoded.cancelSessionId);
714
+ log.info({ sessionId: decoded.cancelSessionId }, "auto-window cancelled by click");
715
+ try {
716
+ await client.updateTemplateCard(frame, buildCancelledCard({
717
+ reqId: cbTaskId, // 必须用回调的 task_id,否则微信拒更新
718
+ toolName: wmeta?.toolName ?? "授权",
719
+ toolInput: wmeta?.toolInput ?? {},
720
+ toolInputStr: "",
721
+ cwd: wmeta?.cwd ?? "",
722
+ sessionShort: decoded.cancelSessionId.slice(-8),
723
+ transcriptTail: wmeta?.transcriptTail ?? "",
724
+ windowMinutes: cfg.approval.windowMinutes,
725
+ detailUrl: cbTaskId ? detailUrlFor(cbTaskId) : undefined,
726
+ }));
727
+ log.info({ sessionId: decoded.cancelSessionId, cbTaskId }, "cancel card updated in place");
728
+ }
729
+ catch (e) {
730
+ log.warn({ err: e.message }, "updateTemplateCard (cancel) failed");
731
+ }
732
+ return;
733
+ }
734
+ const { reqId, decision } = decoded;
322
735
  if (!reqId || !decision) {
323
736
  log.info({ key }, "card event ignored (bad key)");
324
737
  return;
325
738
  }
326
739
  // Snapshot meta BEFORE resolve (resolve deletes the entry).
327
- const meta = getPending(reqId);
740
+ const livePending = getPending(reqId);
328
741
  const ok = resolvePending(reqId, decision);
742
+ // 兜底: 这张卡如果是被 sweep 提前 resolve 掉的"鬼卡", livePending 已经没了
743
+ // (resolvePendingsBySession 删过); 从 resolvedStash 里捞回原始 meta + 真实
744
+ // decision, 渲染成"已自动放行"形态而非 (probe)。
745
+ const snap = livePending ? undefined : getResolvedSnapshot(reqId);
746
+ const meta = livePending ?? snap?.meta;
747
+ const effectiveDecision = snap?.decision ?? decision;
748
+ const by = frame.body?.from?.userid ?? "?";
749
+ recordApprovalDecision(reqId, snap ? "swept" : effectiveDecision, by);
329
750
  if (ok)
330
751
  log.info({ reqId, decision }, "card event resolved");
331
- else
332
- log.warn({ reqId, decision }, "card event for unknown reqId (probe? expired?) still updating card");
752
+ else if (snap)
753
+ log.info({ reqId, snap: snap.decision }, "card event on swept cardrendering snapshot");
754
+ else {
755
+ // meta 完全缺失 (daemon 重启 / stash 过期 / 真探测包)。任何 update 都会用
756
+ // 空字段把原卡覆盖成 "(probe) · /",比保留原卡更糟。直接放弃 update。
757
+ log.warn({ reqId, decision }, "card event for unknown reqId (probe? expired?) — skipping update to avoid clobbering original card");
758
+ return;
759
+ }
760
+ // 用户实际点击的那一下 — 通知 mirror 立刻 finalize 当前 liveStream,
761
+ // 后续的 tool_use / tool_result 走防抖 standalone 路径,避免点击后仍把
762
+ // 内容续写进同一个气泡。sweep 二次点击 (snap 命中, ok=false) 不触发,
763
+ // 避免重复 finalize。
764
+ if (ok && meta?.sessionId)
765
+ onApproved?.(meta.sessionId);
333
766
  // Refresh original card in place (must be within 5s of click).
334
767
  // Independent of pending resolution: we always want visual ACK on any
335
768
  // well-formed click so the user knows the click landed.
@@ -342,21 +775,38 @@ export const installApprovalEventListener = (client, log, cfg) => {
342
775
  }
343
776
  })();
344
777
  const sessionShort = meta?.sessionId ? meta.sessionId.slice(-8) : "?";
345
- const by = frame.body?.from?.userid ?? "?";
778
+ // snap 命中 = 这张卡已被 sweep / 重复点击, 没有真正状态变更, 渲染成
779
+ // 「已经放行」而不是再画一遍 allow_window「点击取消」按钮 — 否则用户
780
+ // 在被批量放行的卡上会看到一个会去取消整个窗口的按钮, 误导。
346
781
  try {
347
- await client.updateTemplateCard(frame, buildResolvedCard({
348
- reqId,
349
- toolName: meta?.toolName ?? "(probe)",
350
- toolInput: meta?.toolInput,
351
- toolInputStr,
352
- cwd: meta?.cwd ?? "",
353
- sessionShort,
354
- transcriptTail: meta?.transcriptTail ?? "",
355
- windowMinutes: cfg.approval.windowMinutes,
356
- decision,
357
- by,
358
- }));
359
- log.info({ reqId, decision }, "card updated in place");
782
+ const card = snap
783
+ ? buildAlreadyResolvedCard({
784
+ reqId: cbTaskId || reqId,
785
+ toolName: meta?.toolName ?? "授权",
786
+ toolInput: meta?.toolInput ?? {},
787
+ toolInputStr,
788
+ cwd: meta?.cwd ?? "",
789
+ sessionShort,
790
+ transcriptTail: meta?.transcriptTail ?? "",
791
+ windowMinutes: cfg.approval.windowMinutes,
792
+ detailUrl: detailUrlFor(reqId),
793
+ })
794
+ : buildResolvedCard({
795
+ reqId,
796
+ toolName: meta?.toolName ?? "授权",
797
+ toolInput: meta?.toolInput,
798
+ toolInputStr,
799
+ cwd: meta?.cwd ?? "",
800
+ sessionShort,
801
+ transcriptTail: meta?.transcriptTail ?? "",
802
+ windowMinutes: cfg.approval.windowMinutes,
803
+ decision: effectiveDecision,
804
+ by,
805
+ sessionId: meta?.sessionId ?? "",
806
+ detailUrl: detailUrlFor(reqId),
807
+ });
808
+ await client.updateTemplateCard(frame, card);
809
+ log.info({ reqId, decision, swept: Boolean(snap) }, "card updated in place");
360
810
  }
361
811
  catch (e) {
362
812
  log.warn({ err: e.message, reqId }, "updateTemplateCard failed");