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.
- package/config.example.jsonc +1 -0
- package/dist/daemon/approval.js +293 -25
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/danger.js +5 -0
- package/dist/daemon/danger.js.map +1 -1
- package/dist/daemon/detail.js +1 -1
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/http.js +6 -2
- package/dist/daemon/http.js.map +1 -1
- package/dist/daemon/index.js +4 -0
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +82 -1
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/pending.js +25 -3
- package/dist/daemon/pending.js.map +1 -1
- package/dist/mcp/server.js +2 -2
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/config.js +10 -0
- package/dist/shared/config.js.map +1 -1
- package/hooks/pre-tool-use.sh +90 -20
- package/package.json +1 -1
package/config.example.jsonc
CHANGED
|
@@ -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)
|
package/dist/daemon/approval.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {
|
|
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:
|
|
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
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
|
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
|
|
829
|
+
const r = await drive(buildAskqChatDriveActions(q));
|
|
743
830
|
if (!r.ok) {
|
|
744
|
-
log.warn({ sessionId, reason: r.reason }, "mirror askq chat
|
|
745
|
-
await note("⚠️
|
|
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
|
-
//
|
|
756
|
-
//
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
|
|
761
|
-
|
|
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 (
|
|
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| 前缀。
|