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.
- package/config.example.jsonc +1 -0
- package/dist/daemon/approval.js +415 -18
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/danger.js +5 -1
- 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";
|
|
@@ -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:
|
|
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
|
}
|
|
@@ -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 (
|
|
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| 前缀。
|