wezard 1.1.0 → 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";
@@ -388,7 +389,7 @@ const decodeAskqKey = (key) => {
388
389
  return { reqId, action };
389
390
  };
390
391
  const decodeAskqNoopKey = (key) => key.startsWith(ASKQ_NOOP_PREFIX) ? key.slice(ASKQ_NOOP_PREFIX.length) : undefined;
391
- const parseAskqInput = (i) => {
392
+ export const parseAskqInput = (i) => {
392
393
  if (!i || typeof i !== "object")
393
394
  return undefined;
394
395
  const qs = i.questions;
@@ -419,7 +420,7 @@ const ASKQ_LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
419
420
  const askqLabel = (idx) => ASKQ_LETTERS[idx] ?? String(idx + 1);
420
421
  // vote_interaction 只支持 card_type/source/main_title/checkbox/submit_button/task_id,
421
422
  // sub_title_text/quote_area 等会被静默吞掉 → 题目和选项必须走前置 markdown 消息。
422
- const buildAskqMarkdown = (q, prefix = "") => {
423
+ export const buildAskqMarkdown = (q, prefix = "") => {
423
424
  const title = q.question || q.header || "请选择";
424
425
  const head = `**🤔 ${prefix}${title}**`;
425
426
  const opts = q.options.map((o, idx) => {
@@ -428,7 +429,7 @@ const buildAskqMarkdown = (q, prefix = "") => {
428
429
  });
429
430
  return [head, "", ...opts].join("\n");
430
431
  };
431
- const buildAskqCard = (reqId, q, transcriptTail, approver) => {
432
+ export const buildAskqCard = (reqId, q, transcriptTail, approver) => {
432
433
  const tail = oneLine(transcriptTail).trim();
433
434
  return {
434
435
  card_type: "vote_interaction",
@@ -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
  }
@@ -619,7 +673,7 @@ const handleExitPlanMode = async ({ cfg, log, client, body, getMirrorTarget, flu
619
673
  return { decision: "ask", reason: "plan_unknown" };
620
674
  };
621
675
  // raw 是 click 事件 listener 塞回 pending 的字符串编码 (cli / chat / picked:i,j)。
622
- const interpretAskqRaw = (raw, q) => {
676
+ export const interpretAskqRaw = (raw, q) => {
623
677
  if (raw === "cli")
624
678
  return { kind: "cli" };
625
679
  if (raw === "chat")
@@ -635,9 +689,303 @@ const interpretAskqRaw = (raw, q) => {
635
689
  }
636
690
  return { kind: "empty" };
637
691
  };
692
+ const mirrorAskqSlots = new Map();
693
+ const MIRROR_ASKQ_SLOT_TTL_MS = 30 * 60_000;
694
+ const freshSlot = (sessionId) => {
695
+ const s = mirrorAskqSlots.get(sessionId);
696
+ if (!s)
697
+ return undefined;
698
+ if (Date.now() - s.at > MIRROR_ASKQ_SLOT_TTL_MS) {
699
+ mirrorAskqSlots.delete(sessionId);
700
+ return undefined;
701
+ }
702
+ return s;
703
+ };
704
+ export const hasMirrorAskq = (sessionId) => freshSlot(sessionId) !== undefined;
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 }) => {
738
+ const questions = parseAskqInput(toolInput);
739
+ if (!questions || questions.length === 0)
740
+ return;
741
+ if (questions.some((q) => q.options.length === 0))
742
+ return;
743
+ if (!client.isConnected)
744
+ return;
745
+ const target = targetChatId(chatKey);
746
+ const total = questions.length;
747
+ const answers = [];
748
+ const picks = [];
749
+ const flowStart = Date.now();
750
+ const note = async (content) => {
751
+ try {
752
+ await client.sendMessage(target, { msgtype: "markdown", markdown: { content: withTagHeader(chatKey, content) } });
753
+ }
754
+ catch { /* best-effort */ }
755
+ };
756
+ for (let i = 0; i < total; i++) {
757
+ const q = questions[i];
758
+ // 剩余预算: N 题共享一份 voteTimeoutSec, 语义同 hook 流的 longPollSec 分摊。
759
+ const remainMs = voteTimeoutMs - (Date.now() - flowStart);
760
+ if (remainMs < 10_000) {
761
+ mirrorAskqSlots.delete(sessionId);
762
+ await note("⌛ 问题卡已超时,请在 CLI 中作答。");
763
+ return;
764
+ }
765
+ // meta 形状与 hook 流一致: click listener 用 toolInput(单题 wrapper)/chatKey
766
+ // 渲染 resolved 卡, 监听侧零改动。
767
+ const { reqId, promise } = createPending({
768
+ meta: {
769
+ kind: "generic",
770
+ createdAt: Date.now(),
771
+ toolName: "AskUserQuestion",
772
+ toolInput: { questions: [q] },
773
+ sessionId,
774
+ chatKey,
775
+ transcriptTail: "",
776
+ },
777
+ timeoutMs: remainMs,
778
+ });
779
+ mirrorAskqSlots.set(sessionId, { reqId, at: Date.now() });
780
+ const prefix = total > 1 ? `(${i + 1}/${total}) ` : "";
781
+ try {
782
+ try {
783
+ await client.sendMessage(target, {
784
+ msgtype: "markdown",
785
+ markdown: { content: withTagHeader(chatKey, buildAskqMarkdown(q, prefix)) },
786
+ });
787
+ }
788
+ catch (e) {
789
+ log.warn({ err: e.message }, "mirror askq markdown prelude send failed");
790
+ }
791
+ await client.sendMessage(target, {
792
+ msgtype: "template_card",
793
+ template_card: buildAskqCard(reqId, q, "", chatKey),
794
+ });
795
+ log.info({ reqId, chatKey, idx: i, total }, "mirror askq card sent");
796
+ }
797
+ catch (e) {
798
+ log.error({ err: e.message }, "mirror askq send failed");
799
+ resolvePending(reqId, "deny"); // 释放 pending 槽
800
+ mirrorAskqSlots.delete(sessionId);
801
+ return;
802
+ }
803
+ let raw;
804
+ try {
805
+ raw = (await promise);
806
+ }
807
+ catch {
808
+ mirrorAskqSlots.delete(sessionId);
809
+ await note("⌛ 问题卡已超时,请在 CLI 中作答。");
810
+ return;
811
+ }
812
+ // 本地先答, hook 侧作废了这张卡 — 中止整流, 不驱动不覆盖。
813
+ if (raw === "moot") {
814
+ log.info({ reqId, sessionId }, "mirror askq mooted by local answer");
815
+ mirrorAskqSlots.delete(sessionId);
816
+ return;
817
+ }
818
+ const ans = interpretAskqRaw(raw, q);
819
+ // 「去 CLI 处理」/ 空选 → 交还本地面板, 之后 hook 到达走 allow 分支。
820
+ if (ans.kind === "cli" || ans.kind === "empty") {
821
+ mirrorAskqSlots.delete(sessionId);
822
+ return;
823
+ }
824
+ if (ans.kind === "chat") {
825
+ mirrorAskqSlots.set(sessionId, {
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.`,
827
+ at: Date.now(),
828
+ });
829
+ const r = await drive(buildAskqChatDriveActions(q));
830
+ if (!r.ok) {
831
+ log.warn({ sessionId, reason: r.reason }, "mirror askq chat drive failed");
832
+ await note("⚠️ 已记录,但驱动 CLI 面板失败,请回到 CLI 手动处理。");
833
+ }
834
+ else {
835
+ mirrorAskqSlots.delete(sessionId);
836
+ }
837
+ return;
838
+ }
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));
845
+ }
846
+ const reason = total === 1
847
+ ? `User answered ${answers[0]} via WeCom`
848
+ : `User answered ${total} questions via WeCom — ${answers.join("; ")}`;
849
+ mirrorAskqSlots.set(sessionId, { reason, at: Date.now() });
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);
859
+ if (!r.ok) {
860
+ // 驱动失败: 保留 reason 槽 — 用户手动提交面板时 hook 到达仍以记录答案覆盖。
861
+ log.warn({ sessionId, reason: r.reason }, "mirror askq drive failed");
862
+ await note("⚠️ 答案已记录,但驱动 CLI 面板失败,请回到 CLI 手动处理。");
863
+ return;
864
+ }
865
+ log.info({ sessionId }, "mirror askq drive done");
866
+ // 驱动成功 = 面板已按 WeCom 答案提交, 结果随工具自然落地。清槽放掉后续提问;
867
+ // hook 若迟到, 无槽 → allow → 本地 (即我们驱动的) 答案原样生效, 语义一致。
868
+ mirrorAskqSlots.delete(sessionId);
869
+ // 回执同 hook 流: 答完后 model 进入不可见长 thinking, 先确认答案已落地。
870
+ await note(total === 1 ? "✅ 已回传,Claude 处理中…" : `✅ ${total} 题已回传,Claude 处理中…`);
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
+ };
638
970
  // 多问题: 逐题顺序发卡 → 收答 → 下一题。任一题选「CLI」整体转 CLI,选「聊聊」
639
971
  // 整体转讨论; 全部答完合并成单个 deny+reason 注入。卡不会一次性轰炸 N 张。
640
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
+ }
641
989
  const questions = parseAskqInput(body.tool_input);
642
990
  if (!questions || questions.length === 0)
643
991
  return { decision: "ask", reason: "askq_unparsable" };
@@ -828,6 +1176,11 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
828
1176
  template_card: card,
829
1177
  });
830
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);
831
1184
  }
832
1185
  catch (e) {
833
1186
  log.error({ batchId: batch.batchId, err: e.message }, "batch send failed");
@@ -893,6 +1246,7 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
893
1246
  tool_input: toolInput,
894
1247
  cwd,
895
1248
  transcript_tail: transcriptTail,
1249
+ cli_backend: body.cli_backend,
896
1250
  },
897
1251
  });
898
1252
  json(res, 200, resp);
@@ -912,6 +1266,8 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
912
1266
  tool_input: toolInput,
913
1267
  cwd,
914
1268
  transcript_tail: transcriptTail,
1269
+ cli_backend: body.cli_backend,
1270
+ transcript_path: body.transcript_path,
915
1271
  },
916
1272
  });
917
1273
  json(res, 200, resp);
@@ -925,6 +1281,11 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
925
1281
  const danger = dangerOf(cfg, toolName, toolInput);
926
1282
  if (danger)
927
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
+ }
928
1289
  // Auto-approve window: while active for THIS chat, requests short-circuit to allow.
929
1290
  if (!danger && approver && isAutoWindowActive(approver)) {
930
1291
  const remainSec = Math.ceil(autoWindowRemainingMs(approver) / 1000);
@@ -967,6 +1328,9 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
967
1328
  }
968
1329
  })();
969
1330
  const longPollMs = cfg.approval.longPollSec * 1000;
1331
+ // Reload 续接: hook 带着上一轮的 req_id 回来, 复用同一个 id 重新挂起 ——
1332
+ // WeCom 上那张卡的按钮编的就是它, 于是旧卡的点击照样能 resolve 这次长轮询。
1333
+ const resumeId = (body.resume_req_id ?? "").trim();
970
1334
  const { reqId, promise } = createPending({
971
1335
  meta: {
972
1336
  kind: "approval",
@@ -978,8 +1342,10 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
978
1342
  chatKey: approver,
979
1343
  transcriptTail,
980
1344
  danger: danger?.rule,
1345
+ cardSent: Boolean(resumeId), // 续接的前提就是卡已经在群里
981
1346
  },
982
1347
  timeoutMs: longPollMs,
1348
+ reqId: resumeId || undefined,
983
1349
  });
984
1350
  recordApproval({
985
1351
  id: reqId,
@@ -992,11 +1358,15 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
992
1358
  // Batch coalesce: 同 session 同 tool 的并发请求合流为一张卡。窗口内首位
993
1359
  // 创建 batch + 计时器, 后续到达者只追加成员, 不发卡。flush 时依据成员数
994
1360
  // 选择普通 buildCard 或 buildBatchCard。0 = 关闭聚合, 立即 flush。
1361
+ // 续接的请求不再进 batch: 卡已经在群里, 重发就是重复轰炸。
995
1362
  const member = { reqId, toolInput: display, originalToolInput: toolInput, toolInputStr, cwd, transcriptTail };
996
1363
  const bk = batchKeyOf(sessionId, toolName);
997
1364
  // 危险请求既不 join 也不被 join — 一次危险操作 = 一张卡 = 一次点击。
998
1365
  const existing = danger ? undefined : activeBatches.get(bk);
999
- if (existing && !existing.flushed) {
1366
+ if (resumeId) {
1367
+ // no-op: 直接进下面的长轮询, 等旧卡上的点击。
1368
+ }
1369
+ else if (existing && !existing.flushed) {
1000
1370
  existing.members.push(member);
1001
1371
  log.info({ batchId: existing.batchId, count: existing.members.length, reqId }, "batch joined");
1002
1372
  }
@@ -1027,6 +1397,13 @@ export const makeApproveHandler = ({ cfg, log, client, getMirrorTarget, flushBef
1027
1397
  decision = await promise;
1028
1398
  }
1029
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
+ }
1030
1407
  log.warn({ err: e.message, reqId }, "approval timed out");
1031
1408
  json(res, 200, fallback(cfg, "approver_timeout", danger));
1032
1409
  return;
@@ -1125,6 +1502,9 @@ export const installApprovalEventListener = (client, log, cfg, onApproved) => {
1125
1502
  ? "chat"
1126
1503
  : `${ASKQ_PICKED_PREFIX}${numericIdxs.join(",")}`;
1127
1504
  const ok = resolvePending(askq.reqId, resolved);
1505
+ // 留 resolved 快照: 重复点击已决卡时据此跳过"已失效"重绘 (卡已是终态)。
1506
+ if (ok && meta)
1507
+ stashResolved(askq.reqId, meta, resolved);
1128
1508
  log.info({ reqId: askq.reqId, outcome, ok }, "askq event resolved");
1129
1509
  if (q) {
1130
1510
  try {
@@ -1134,6 +1514,23 @@ export const installApprovalEventListener = (client, log, cfg, onApproved) => {
1134
1514
  log.warn({ err: e.message, reqId: askq.reqId }, "askq updateTemplateCard failed");
1135
1515
  }
1136
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
+ }
1137
1534
  return;
1138
1535
  }
1139
1536
  // ── ExitPlanMode 计划审批卡: 在普通 approval 解码前匹配 PLAN| 前缀。