wezard 1.1.1 → 1.1.4

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.
@@ -62,6 +62,7 @@
62
62
  // ── PreToolUse approval (all tools → button card; align with auth-helper) ─
63
63
  "approval": {
64
64
  "enabled": true,
65
+ "mode": "all", // all = 每次调用都发卡;danger = 只有命中下面危险名单的才发卡,其余静默放行
65
66
  "matcher": ".*", // 全量拦截;Bash 只读命令(grep/ls/cat/...)走脚本 fast-path
66
67
  "approvers": [], // ["user:zhangsan"] empty → defaultChat
67
68
  "hookTimeoutSec": 43210, // hook curl --max-time (12h + 10s)
@@ -1,7 +1,8 @@
1
- import { createPending, getPending, getResolvedSnapshot, resolvePending, resolvePendingsByChat, failPending } from "./pending.js";
1
+ import { closeSync, fstatSync, openSync, readFileSync, readSync } from "node:fs";
2
+ import { createPending, getPending, getResolvedSnapshot, resolvePending, resolvePendingsByChat, failPending, stashResolved, markCardSent, isReloadError } from "./pending.js";
2
3
  import { cacheGet, cachePut, cacheKey, isAutoWindowActive, autoWindowRemainingMs, setAutoWindow, clearAutoWindow, getWindowMeta, } from "./session-cache.js";
3
4
  import { redact } from "./redact.js";
4
- import { dangerOf } from "./danger.js";
5
+ import { dangerOf, dangerModeSkips } from "./danger.js";
5
6
  import { recordApproval, recordApprovalDecision, buildDetailUrl } from "./detail.js";
6
7
  import { json, readBody } from "./http.js";
7
8
  import { tagBadge, withTagHeader } from "../shared/session-label.js";
@@ -499,6 +500,53 @@ const parsePlanInput = (i) => {
499
500
  const p = i.plan;
500
501
  return typeof p === "string" ? p : "";
501
502
  };
503
+ // CodeBuddy 的 ExitPlanMode 不带 plan 正文 (arguments=="{}"); plan 写在
504
+ // ~/.codebuddy/plans/<slug>.md (plan mode 系统消息指定, 模型用 Write/Edit 逐步
505
+ // 成型)。从 transcript 尾部倒查最后一次指向 plans 目录的 file_path, 再读文件
506
+ // 取最新内容。arguments 是转义后的内嵌 JSON 串 (\"file_path\":\"...\") 且嵌着
507
+ // 整份 plan (单行可能数百 KB), 用正则抠路径 (\\? 兼容转义/未转义), 不做 parse。
508
+ const PLAN_PATH_RE = /\\?"file_path\\?"\s*:\s*\\?"([^"\\]*\/\.codebuddy\/plans\/[^"\\]+\.md)\\?"/;
509
+ const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
510
+ const PLAN_FILE_CAP = 64 * 1024;
511
+ const tailText = (p, bytes) => {
512
+ const fd = openSync(p, "r");
513
+ try {
514
+ const size = fstatSync(fd).size;
515
+ const start = Math.max(0, size - bytes);
516
+ const buf = Buffer.alloc(size - start);
517
+ readSync(fd, buf, 0, buf.length, start);
518
+ return buf.toString("utf8");
519
+ }
520
+ finally {
521
+ closeSync(fd);
522
+ }
523
+ };
524
+ const planPathFromTranscript = (transcriptPath) => {
525
+ const lines = tailText(transcriptPath, TRANSCRIPT_TAIL_BYTES).split("\n");
526
+ for (let i = lines.length - 1; i >= 0; i--) {
527
+ const m = PLAN_PATH_RE.exec(lines[i]);
528
+ if (m)
529
+ return m[1];
530
+ }
531
+ return undefined;
532
+ };
533
+ // tool_input.plan (Claude) → transcript 定位 plans 文件 (CodeBuddy) → ""。
534
+ const resolvePlanText = (toolInput, transcriptPath) => {
535
+ const direct = parsePlanInput(toolInput);
536
+ if (direct)
537
+ return direct;
538
+ if (!transcriptPath)
539
+ return "";
540
+ try {
541
+ const p = planPathFromTranscript(transcriptPath);
542
+ if (!p)
543
+ return "";
544
+ return readFileSync(p, "utf8").slice(0, PLAN_FILE_CAP);
545
+ }
546
+ catch {
547
+ return "";
548
+ }
549
+ };
502
550
  const PLAN_TITLE_MAX = 26;
503
551
  const PLAN_PREVIEW_LINES = 25;
504
552
  const PLAN_PREVIEW_CHARS = 1500;
@@ -512,7 +560,7 @@ const buildPlanMarkdown = (plan) => {
512
560
  }
513
561
  return `**📋 计划待审批**\n\n${body}`;
514
562
  };
515
- const buildPlanCard = (reqId, _sessionId, cwd, transcriptTail, approver) => {
563
+ const buildPlanCard = (reqId, _sessionId, cwd, transcriptTail, approver, hasPlan) => {
516
564
  const tail = oneLine(transcriptTail).trim();
517
565
  const tag = emojiFor(approver);
518
566
  const dir = dirName(cwd);
@@ -520,7 +568,9 @@ const buildPlanCard = (reqId, _sessionId, cwd, transcriptTail, approver) => {
520
568
  card_type: "button_interaction",
521
569
  source: buildSource(tail),
522
570
  main_title: { title: TRUNC(`📋 计划审批 · ${tag}${dir}/`, PLAN_TITLE_MAX + 12) },
523
- sub_title_text: "审阅上方计划后选择:同意开始执行,或让 Claude 继续完善。",
571
+ sub_title_text: hasPlan
572
+ ? "审阅上方计划后选择:同意开始执行,或让 Claude 继续完善。"
573
+ : "完整计划见 CLI。选择:同意开始执行,或继续完善。",
524
574
  task_id: reqId,
525
575
  button_list: [
526
576
  { text: "✏️ 继续改", style: 4, key: encodePlanKey(reqId, "revise") },
@@ -541,6 +591,8 @@ const buildPlanResolvedCard = (reqId, action, cwd, transcriptTail, approver) =>
541
591
  };
542
592
  };
543
593
  const handleExitPlanMode = async ({ cfg, log, client, body, getMirrorTarget, flushBeforeCard }) => {
594
+ // 此分支只服务 Claude 家族 (hook 即时触发, tool_input.plan 带正文)。
595
+ // codebuddy 的 ExitPlanMode 不过 hook → 见 runMirrorPlanFlow。
544
596
  const plan = parsePlanInput(body.tool_input);
545
597
  if (!plan)
546
598
  return { decision: "ask", reason: "plan_unparsable" };
@@ -576,18 +628,20 @@ const handleExitPlanMode = async ({ cfg, log, client, body, getMirrorTarget, flu
576
628
  catch (e) {
577
629
  log.warn({ err: e.message }, "plan flushBeforeCard failed; sending card anyway");
578
630
  }
579
- try {
580
- await client.sendMessage(target, {
581
- msgtype: "markdown",
582
- markdown: { content: withTagHeader(approver, buildPlanMarkdown(plan)) },
583
- });
584
- }
585
- catch (e) {
586
- log.warn({ err: e.message }, "plan markdown prelude send failed");
631
+ if (plan) {
632
+ try {
633
+ await client.sendMessage(target, {
634
+ msgtype: "markdown",
635
+ markdown: { content: withTagHeader(approver, buildPlanMarkdown(plan)) },
636
+ });
637
+ }
638
+ catch (e) {
639
+ log.warn({ err: e.message }, "plan markdown prelude send failed");
640
+ }
587
641
  }
588
642
  await client.sendMessage(target, {
589
643
  msgtype: "template_card",
590
- template_card: buildPlanCard(reqId, body.session_id, body.cwd ?? "", body.transcript_tail ?? "", approver),
644
+ template_card: buildPlanCard(reqId, body.session_id, body.cwd ?? "", body.transcript_tail ?? "", approver, true),
591
645
  });
592
646
  log.info({ reqId, approver }, "plan card sent");
593
647
  }
@@ -648,7 +702,39 @@ const freshSlot = (sessionId) => {
648
702
  return s;
649
703
  };
650
704
  export const hasMirrorAskq = (sessionId) => freshSlot(sessionId) !== undefined;
651
- export const runMirrorAskqFlow = async ({ log, client, sessionId, chatKey, toolInput, voteTimeoutMs, inject }) => {
705
+ export const buildAskqDriveActions = (questions, picks) => {
706
+ const maxOpts = questions.reduce((m, q) => Math.max(m, q.options.length), 0);
707
+ const acts = [{ kind: "keys", keys: Array(maxOpts + 3).fill("Up") }];
708
+ questions.forEach((q, i) => {
709
+ const N = q.options.length;
710
+ const picked = (picks[i] ?? []).filter((n) => Number.isInteger(n) && n >= 0 && n < N).sort((a, b) => a - b);
711
+ if (!q.multiSelect) {
712
+ const n = picked[0] ?? 0;
713
+ acts.push({ kind: "keys", keys: [...Array(n).fill("Down"), "Enter"] });
714
+ return;
715
+ }
716
+ const keys = [];
717
+ let cur = 0;
718
+ for (const n of picked) {
719
+ keys.push(...Array(n - cur).fill("Down"), "Enter");
720
+ cur = n;
721
+ }
722
+ // 多选的前进行在第 N+1 位 (0..N-1 选项, N 自定义行, N+1 下一题行)。
723
+ keys.push(...Array(N + 1 - cur).fill("Down"), "Enter");
724
+ acts.push({ kind: "keys", keys });
725
+ });
726
+ // 提交页光标初始 0 = "1. Submit answers"。
727
+ acts.push({ kind: "keys", keys: ["Enter"] });
728
+ return acts;
729
+ };
730
+ // 「聊聊这个」: 光标钳 0 后落到第 N 行 (自定义文本行), 贴入引导语提交 — 自由文本
731
+ // 答案本身即语义完整 (hook 若触发, deny+reason 会再覆盖成同款文案)。
732
+ export const buildAskqChatDriveActions = (q) => [
733
+ { kind: "keys", keys: [...Array(q.options.length + 3).fill("Up"), ...Array(q.options.length).fill("Down")] },
734
+ { kind: "text", text: "先不回答,我想和你讨论一下这个问题" },
735
+ { kind: "keys", keys: ["Enter"] },
736
+ ];
737
+ export const runMirrorAskqFlow = async ({ log, client, sessionId, chatKey, toolInput, voteTimeoutMs, drive }) => {
652
738
  const questions = parseAskqInput(toolInput);
653
739
  if (!questions || questions.length === 0)
654
740
  return;
@@ -659,6 +745,7 @@ export const runMirrorAskqFlow = async ({ log, client, sessionId, chatKey, toolI
659
745
  const target = targetChatId(chatKey);
660
746
  const total = questions.length;
661
747
  const answers = [];
748
+ const picks = [];
662
749
  const flowStart = Date.now();
663
750
  const note = async (content) => {
664
751
  try {
@@ -722,7 +809,7 @@ export const runMirrorAskqFlow = async ({ log, client, sessionId, chatKey, toolI
722
809
  await note("⌛ 问题卡已超时,请在 CLI 中作答。");
723
810
  return;
724
811
  }
725
- // 本地先答, hook 侧作废了这张卡 — 中止整流, 不注入不覆盖。
812
+ // 本地先答, hook 侧作废了这张卡 — 中止整流, 不驱动不覆盖。
726
813
  if (raw === "moot") {
727
814
  log.info({ reqId, sessionId }, "mirror askq mooted by local answer");
728
815
  mirrorAskqSlots.delete(sessionId);
@@ -739,34 +826,166 @@ export const runMirrorAskqFlow = async ({ log, client, sessionId, chatKey, toolI
739
826
  reason: `Instead of answering "${q.header || q.question}", the user wants to chat about it first. Discuss the question with them before re-asking.`,
740
827
  at: Date.now(),
741
828
  });
742
- const r = await inject("先不回答,我想和你讨论一下这个问题");
829
+ const r = await drive(buildAskqChatDriveActions(q));
743
830
  if (!r.ok) {
744
- log.warn({ sessionId, reason: r.reason }, "mirror askq chat inject failed");
745
- await note("⚠️ 已记录,但注入 CLI 面板失败,请回到 CLI 手动处理。");
831
+ log.warn({ sessionId, reason: r.reason }, "mirror askq chat drive failed");
832
+ await note("⚠️ 已记录,但驱动 CLI 面板失败,请回到 CLI 手动处理。");
833
+ }
834
+ else {
835
+ mirrorAskqSlots.delete(sessionId);
746
836
  }
747
837
  return;
748
838
  }
749
839
  answers.push(`"${q.header || q.question}": ${ans.labels}`);
840
+ // 按键驱动要的是选项下标, raw 编码 picked:i,j 里直接取 (与 interpret 同源)。
841
+ picks.push(raw.slice(ASKQ_PICKED_PREFIX.length)
842
+ .split(",")
843
+ .map((s) => parseInt(s, 10))
844
+ .filter((n) => Number.isInteger(n) && n >= 0 && n < q.options.length));
750
845
  }
751
846
  const reason = total === 1
752
847
  ? `User answered ${answers[0]} via WeCom`
753
848
  : `User answered ${total} questions via WeCom — ${answers.join("; ")}`;
754
849
  mirrorAskqSlots.set(sessionId, { reason, at: Date.now() });
755
- // 触发文本只是面板提交的载体 hook 到达后 deny+reason 会覆盖它。万一 hook
756
- // 不触发, 它本身也是一段有意义的自由文本答案 (降级通道)
757
- const trigger = total === 1 ? (answers[0] ?? "已在企业微信作答") : answers.join("; ");
758
- const r = await inject(trigger);
850
+ // 驱动前再确认槽还是自己的: 用户若在收票期间本地答了 (hook 到达会清槽),
851
+ // 面板已经消失, 按键会落进主输入框 (Down 翻历史 / Enter 误提交) — 必须放弃。
852
+ if (freshSlot(sessionId)?.reason !== reason) {
853
+ log.info({ sessionId }, "mirror askq slot lost before drive — local answer won");
854
+ return;
855
+ }
856
+ const acts = buildAskqDriveActions(questions, picks);
857
+ log.info({ sessionId, acts: acts.length, picks }, "mirror askq drive start");
858
+ const r = await drive(acts);
759
859
  if (!r.ok) {
760
- log.warn({ sessionId, reason: r.reason }, "mirror askq inject failed");
761
- await note("⚠️ 答案已记录,但注入 CLI 面板失败,请回到 CLI 手动处理。");
860
+ // 驱动失败: 保留 reason 用户手动提交面板时 hook 到达仍以记录答案覆盖。
861
+ log.warn({ sessionId, reason: r.reason }, "mirror askq drive failed");
862
+ await note("⚠️ 答案已记录,但驱动 CLI 面板失败,请回到 CLI 手动处理。");
762
863
  return;
763
864
  }
865
+ log.info({ sessionId }, "mirror askq drive done");
866
+ // 驱动成功 = 面板已按 WeCom 答案提交, 结果随工具自然落地。清槽放掉后续提问;
867
+ // hook 若迟到, 无槽 → allow → 本地 (即我们驱动的) 答案原样生效, 语义一致。
868
+ mirrorAskqSlots.delete(sessionId);
764
869
  // 回执同 hook 流: 答完后 model 进入不可见长 thinking, 先确认答案已落地。
765
870
  await note(total === 1 ? "✅ 已回传,Claude 处理中…" : `✅ ${total} 题已回传,Claude 处理中…`);
766
871
  };
872
+ const mirrorPlanSlots = new Map();
873
+ const MIRROR_PLAN_SLOT_TTL_MS = 12 * 3600_000; // 与 approval.longPollSec 对齐
874
+ const freshPlanSlot = (sessionId) => {
875
+ const s = mirrorPlanSlots.get(sessionId);
876
+ if (!s)
877
+ return undefined;
878
+ if (Date.now() - s.at > MIRROR_PLAN_SLOT_TTL_MS) {
879
+ mirrorPlanSlots.delete(sessionId);
880
+ return undefined;
881
+ }
882
+ return s;
883
+ };
884
+ export const hasMirrorPlan = (sessionId) => freshPlanSlot(sessionId) !== undefined;
885
+ export const mootMirrorPlan = (sessionId) => {
886
+ const s = freshPlanSlot(sessionId);
887
+ if (!s)
888
+ return;
889
+ if (s.reqId)
890
+ resolvePending(s.reqId, "moot");
891
+ mirrorPlanSlots.delete(sessionId);
892
+ };
893
+ export const runMirrorPlanFlow = async ({ log, client, sessionId, chatKey, cwd, jsonlPath, voteTimeoutMs, sendKey }) => {
894
+ if (!client.isConnected)
895
+ return;
896
+ const target = targetChatId(chatKey);
897
+ const note = async (content) => {
898
+ try {
899
+ await client.sendMessage(target, { msgtype: "markdown", markdown: { content: withTagHeader(chatKey, content) } });
900
+ }
901
+ catch { /* best-effort */ }
902
+ };
903
+ const plan = resolvePlanText(undefined, jsonlPath);
904
+ if (!plan)
905
+ log.warn({ sessionId }, "mirror plan: plan text not found — card without prelude");
906
+ const { reqId, promise } = createPending({
907
+ meta: {
908
+ kind: "generic",
909
+ createdAt: Date.now(),
910
+ toolName: "ExitPlanMode",
911
+ toolInput: {},
912
+ cwd,
913
+ sessionId,
914
+ chatKey,
915
+ transcriptTail: "",
916
+ },
917
+ timeoutMs: voteTimeoutMs,
918
+ });
919
+ mirrorPlanSlots.set(sessionId, { reqId, at: Date.now() });
920
+ try {
921
+ if (plan) {
922
+ try {
923
+ await client.sendMessage(target, {
924
+ msgtype: "markdown",
925
+ markdown: { content: withTagHeader(chatKey, buildPlanMarkdown(plan)) },
926
+ });
927
+ }
928
+ catch (e) {
929
+ log.warn({ err: e.message }, "mirror plan markdown prelude send failed");
930
+ }
931
+ }
932
+ await client.sendMessage(target, {
933
+ msgtype: "template_card",
934
+ template_card: buildPlanCard(reqId, sessionId, cwd, "", chatKey, Boolean(plan)),
935
+ });
936
+ log.info({ reqId, chatKey }, "mirror plan card sent");
937
+ }
938
+ catch (e) {
939
+ log.error({ err: e.message }, "mirror plan send failed");
940
+ resolvePending(reqId, "deny");
941
+ mirrorPlanSlots.delete(sessionId);
942
+ return;
943
+ }
944
+ let raw;
945
+ try {
946
+ raw = (await promise);
947
+ }
948
+ catch {
949
+ mirrorPlanSlots.delete(sessionId);
950
+ await note("⌛ 计划卡已超时,请在 CLI 中处理。");
951
+ return;
952
+ }
953
+ // 本地先答 (function_call_result 落盘触发的 moot)。
954
+ if (raw === "moot") {
955
+ log.info({ reqId, sessionId }, "mirror plan mooted by local answer");
956
+ mirrorPlanSlots.delete(sessionId);
957
+ return;
958
+ }
959
+ const approve = raw === `${PLAN_PICKED_PREFIX}approve`;
960
+ const r = await sendKey(approve ? "Enter" : "Escape");
961
+ mirrorPlanSlots.delete(sessionId);
962
+ if (!r.ok) {
963
+ log.warn({ sessionId, reason: r.reason }, "mirror plan sendKey failed");
964
+ await note("⚠️ 已记录选择,但本地对话框按键注入失败,请回到 CLI 手动处理。");
965
+ return;
966
+ }
967
+ log.info({ reqId, sessionId, approve }, "mirror plan resolved via send-keys");
968
+ await note(approve ? "✅ 已同意,退出 plan mode 开始执行…" : "✏️ 已选择继续完善计划。");
969
+ };
767
970
  // 多问题: 逐题顺序发卡 → 收答 → 下一题。任一题选「CLI」整体转 CLI,选「聊聊」
768
971
  // 整体转讨论; 全部答完合并成单个 deny+reason 注入。卡不会一次性轰炸 N 张。
769
972
  const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget, flushBeforeCard, clientGone }) => {
973
+ // ── codebuddy 镜像流去重 ────────────────────────────────────────────────
974
+ // codebuddy 的 hook 只在本地面板被提交后到达。mirror 早已发过 vote 卡:
975
+ // - 槽里有 reason = 面板是被我们的注入提交的 → 用记录的 reason 覆盖, 不再发卡。
976
+ // - 无记录 = 用户在本地答的 → allow 让本地答案生效, 并作废挂着的 vote 卡。
977
+ const slot = freshSlot(body.session_id);
978
+ if (slot?.reason) {
979
+ mirrorAskqSlots.delete(body.session_id);
980
+ log.info({ sessionId: body.session_id }, "askq hook: overriding with mirror-collected answer");
981
+ return { decision: "deny", reason: slot.reason };
982
+ }
983
+ if (body.cli_backend === "codebuddy") {
984
+ if (slot?.reqId)
985
+ resolvePending(slot.reqId, "moot");
986
+ mirrorAskqSlots.delete(body.session_id);
987
+ return { decision: "allow", reason: "codebuddy_local_panel" };
988
+ }
770
989
  const questions = parseAskqInput(body.tool_input);
771
990
  if (!questions || questions.length === 0)
772
991
  return { decision: "ask", reason: "askq_unparsable" };
@@ -957,6 +1176,11 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
957
1176
  template_card: card,
958
1177
  });
959
1178
  log.info({ batchId: batch.batchId, count: batch.members.length, multi: isMulti, approver: batch.approver, tool: batch.toolName }, "batch flushed");
1179
+ // 只给单卡打「可续接」标: 批量卡的点击要靠内存里的 batchById 才能一次
1180
+ // resolve N 个成员, 那张表撑不过重启 —— 续接了反而会让成员干等一张点了
1181
+ // 没反应的卡。批量成员维持原行为 (drain → fallbackOnError)。
1182
+ if (!isMulti)
1183
+ markCardSent(batch.members[0].reqId);
960
1184
  }
961
1185
  catch (e) {
962
1186
  log.error({ batchId: batch.batchId, err: e.message }, "batch send failed");
@@ -1022,6 +1246,7 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1022
1246
  tool_input: toolInput,
1023
1247
  cwd,
1024
1248
  transcript_tail: transcriptTail,
1249
+ cli_backend: body.cli_backend,
1025
1250
  },
1026
1251
  });
1027
1252
  json(res, 200, resp);
@@ -1041,6 +1266,8 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1041
1266
  tool_input: toolInput,
1042
1267
  cwd,
1043
1268
  transcript_tail: transcriptTail,
1269
+ cli_backend: body.cli_backend,
1270
+ transcript_path: body.transcript_path,
1044
1271
  },
1045
1272
  });
1046
1273
  json(res, 200, resp);
@@ -1054,6 +1281,11 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1054
1281
  const danger = dangerOf(cfg, toolName, toolInput);
1055
1282
  if (danger)
1056
1283
  log.info({ toolName, sessionId, rule: danger.rule }, "danger hit — forcing single approval");
1284
+ // danger 模式: 名单之外的调用不打扰人, 直接放行 (卡只留给真正危险的操作)。
1285
+ if (dangerModeSkips(cfg, danger)) {
1286
+ json(res, 200, { decision: "allow", reason: "danger_mode_skip" });
1287
+ return;
1288
+ }
1057
1289
  // Auto-approve window: while active for THIS chat, requests short-circuit to allow.
1058
1290
  if (!danger && approver && isAutoWindowActive(approver)) {
1059
1291
  const remainSec = Math.ceil(autoWindowRemainingMs(approver) / 1000);
@@ -1096,6 +1328,9 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1096
1328
  }
1097
1329
  })();
1098
1330
  const longPollMs = cfg.approval.longPollSec * 1000;
1331
+ // Reload 续接: hook 带着上一轮的 req_id 回来, 复用同一个 id 重新挂起 ——
1332
+ // WeCom 上那张卡的按钮编的就是它, 于是旧卡的点击照样能 resolve 这次长轮询。
1333
+ const resumeId = (body.resume_req_id ?? "").trim();
1099
1334
  const { reqId, promise } = createPending({
1100
1335
  meta: {
1101
1336
  kind: "approval",
@@ -1107,8 +1342,10 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1107
1342
  chatKey: approver,
1108
1343
  transcriptTail,
1109
1344
  danger: danger?.rule,
1345
+ cardSent: Boolean(resumeId), // 续接的前提就是卡已经在群里
1110
1346
  },
1111
1347
  timeoutMs: longPollMs,
1348
+ reqId: resumeId || undefined,
1112
1349
  });
1113
1350
  recordApproval({
1114
1351
  id: reqId,
@@ -1121,11 +1358,15 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1121
1358
  // Batch coalesce: 同 session 同 tool 的并发请求合流为一张卡。窗口内首位
1122
1359
  // 创建 batch + 计时器, 后续到达者只追加成员, 不发卡。flush 时依据成员数
1123
1360
  // 选择普通 buildCard 或 buildBatchCard。0 = 关闭聚合, 立即 flush。
1361
+ // 续接的请求不再进 batch: 卡已经在群里, 重发就是重复轰炸。
1124
1362
  const member = { reqId, toolInput: display, originalToolInput: toolInput, toolInputStr, cwd, transcriptTail };
1125
1363
  const bk = batchKeyOf(sessionId, toolName);
1126
1364
  // 危险请求既不 join 也不被 join — 一次危险操作 = 一张卡 = 一次点击。
1127
1365
  const existing = danger ? undefined : activeBatches.get(bk);
1128
- if (existing && !existing.flushed) {
1366
+ if (resumeId) {
1367
+ // no-op: 直接进下面的长轮询, 等旧卡上的点击。
1368
+ }
1369
+ else if (existing && !existing.flushed) {
1129
1370
  existing.members.push(member);
1130
1371
  log.info({ batchId: existing.batchId, count: existing.members.length, reqId }, "batch joined");
1131
1372
  }
@@ -1156,6 +1397,13 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1156
1397
  decision = await promise;
1157
1398
  }
1158
1399
  catch (e) {
1400
+ // Reload drain: 卡还挂在 WeCom 上, 让 hook 带着同一个 reqId 等 daemon 回来
1401
+ // 重新长轮询 —— 而不是 fallback 成 ask 把权限框弹回本地 CLI。
1402
+ if (isReloadError(e)) {
1403
+ log.info({ reqId, toolName, sessionId }, "approval parked for reload — telling hook to resume");
1404
+ json(res, 200, { decision: "retry", reason: "daemon_reloading", req_id: reqId });
1405
+ return;
1406
+ }
1159
1407
  log.warn({ err: e.message, reqId }, "approval timed out");
1160
1408
  json(res, 200, fallback(cfg, "approver_timeout", danger));
1161
1409
  return;
@@ -1254,6 +1502,9 @@ export const installApprovalEventListener = (client, log, cfg, onApproved) => {
1254
1502
  ? "chat"
1255
1503
  : `${ASKQ_PICKED_PREFIX}${numericIdxs.join(",")}`;
1256
1504
  const ok = resolvePending(askq.reqId, resolved);
1505
+ // 留 resolved 快照: 重复点击已决卡时据此跳过"已失效"重绘 (卡已是终态)。
1506
+ if (ok && meta)
1507
+ stashResolved(askq.reqId, meta, resolved);
1257
1508
  log.info({ reqId: askq.reqId, outcome, ok }, "askq event resolved");
1258
1509
  if (q) {
1259
1510
  try {
@@ -1263,6 +1514,23 @@ export const installApprovalEventListener = (client, log, cfg, onApproved) => {
1263
1514
  log.warn({ err: e.message, reqId: askq.reqId }, "askq updateTemplateCard failed");
1264
1515
  }
1265
1516
  }
1517
+ else if (!ok && !getResolvedSnapshot(askq.reqId)) {
1518
+ // 死卡点击 (超时 / 本地先答已作废 / daemon 重启丢 pending): 给终态反馈,
1519
+ // 别让用户对着无反应的卡干瞪眼。有 resolved 快照 = 已决卡的重复点击,
1520
+ // 卡片已是终态, 不重绘。
1521
+ try {
1522
+ await client.updateTemplateCard(frame, {
1523
+ card_type: "button_interaction",
1524
+ main_title: { title: "🤔 问题卡已失效" },
1525
+ sub_title_text: "该卡已超时或已在别处处理,请以最新上下文为准。",
1526
+ task_id: cbTaskId || askq.reqId,
1527
+ button_list: [{ text: "⌛ 已失效", style: 4, key: encodeAskqNoopKey(askq.reqId) }],
1528
+ });
1529
+ }
1530
+ catch (e) {
1531
+ log.warn({ err: e.message, reqId: askq.reqId }, "askq stale-card update failed");
1532
+ }
1533
+ }
1266
1534
  return;
1267
1535
  }
1268
1536
  // ── ExitPlanMode 计划审批卡: 在普通 approval 解码前匹配 PLAN| 前缀。