wezard 2.3.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +5 -5
  3. package/config.example.jsonc +12 -0
  4. package/dist/daemon/approval.js +103 -15
  5. package/dist/daemon/approval.js.map +1 -1
  6. package/dist/daemon/chat-name.js +15 -0
  7. package/dist/daemon/chat-name.js.map +1 -1
  8. package/dist/daemon/config-api.js +281 -70
  9. package/dist/daemon/config-api.js.map +1 -1
  10. package/dist/daemon/danger.js +5 -7
  11. package/dist/daemon/danger.js.map +1 -1
  12. package/dist/daemon/detail.js +10 -2
  13. package/dist/daemon/detail.js.map +1 -1
  14. package/dist/daemon/handoff.js +4 -3
  15. package/dist/daemon/handoff.js.map +1 -1
  16. package/dist/daemon/idle-nudge.js +55 -0
  17. package/dist/daemon/idle-nudge.js.map +1 -0
  18. package/dist/daemon/inbound.js +17 -14
  19. package/dist/daemon/inbound.js.map +1 -1
  20. package/dist/daemon/index.js +957 -225
  21. package/dist/daemon/index.js.map +1 -1
  22. package/dist/daemon/jobs.js +122 -13
  23. package/dist/daemon/jobs.js.map +1 -1
  24. package/dist/daemon/mirror-bridge.js +97 -84
  25. package/dist/daemon/mirror-bridge.js.map +1 -1
  26. package/dist/daemon/model-select.js +3 -0
  27. package/dist/daemon/model-select.js.map +1 -1
  28. package/dist/daemon/notices.js +172 -33
  29. package/dist/daemon/notices.js.map +1 -1
  30. package/dist/daemon/peers.js +105 -78
  31. package/dist/daemon/peers.js.map +1 -1
  32. package/dist/daemon/pending-items.js +171 -0
  33. package/dist/daemon/pending-items.js.map +1 -0
  34. package/dist/daemon/receipts.js +58 -24
  35. package/dist/daemon/receipts.js.map +1 -1
  36. package/dist/daemon/route.js +76 -15
  37. package/dist/daemon/route.js.map +1 -1
  38. package/dist/daemon/spawn-tmux.js +2 -2
  39. package/dist/daemon/spawn-tmux.js.map +1 -1
  40. package/dist/daemon/steward-guard.js +32 -0
  41. package/dist/daemon/steward-guard.js.map +1 -0
  42. package/dist/daemon/top-only-nudge.js +29 -0
  43. package/dist/daemon/top-only-nudge.js.map +1 -0
  44. package/dist/daemon/wizard.js +114 -61
  45. package/dist/daemon/wizard.js.map +1 -1
  46. package/dist/mcp/server.js +75 -25
  47. package/dist/mcp/server.js.map +1 -1
  48. package/dist/shared/chat-http.js +23 -13
  49. package/dist/shared/chat-http.js.map +1 -1
  50. package/dist/shared/config-meta.js +91 -0
  51. package/dist/shared/config-meta.js.map +1 -0
  52. package/dist/shared/config-writer.js +5 -6
  53. package/dist/shared/config-writer.js.map +1 -1
  54. package/dist/shared/config.js +279 -294
  55. package/dist/shared/config.js.map +1 -1
  56. package/dist/shared/detail-render.js +121 -32
  57. package/dist/shared/detail-render.js.map +1 -1
  58. package/dist/shared/detail-store.js.map +1 -1
  59. package/dist/shared/keepalive.js +5 -5
  60. package/dist/shared/keepalive.js.map +1 -1
  61. package/dist/shared/role-render.js +52 -12
  62. package/dist/shared/role-render.js.map +1 -1
  63. package/dist/shared/role-view.js +84 -26
  64. package/dist/shared/role-view.js.map +1 -1
  65. package/dist/shared/world.js +82 -13
  66. package/dist/shared/world.js.map +1 -1
  67. package/hooks/pre-tool-use.sh +14 -12
  68. package/package.json +1 -1
  69. package/web/chat.css +63 -37
  70. package/web/chat.js +513 -283
package/CLAUDE.md CHANGED
@@ -55,10 +55,10 @@ Three processes, all coordinating via 127.0.0.1:17890 and `~/.wezard/`:
55
55
  - `inbound.ts` routes `message.text` / `message.image` / `message.mixed` → `mirror-bridge.ts`. Gates on `allowFrom`; one-shot exception is the bootstrap claim (see below).
56
56
  - `mirror-bridge.ts` tails `<projectsDir>/<encoded(cwd)>/<sid>.jsonl` and pushes new `assistant` content to a chat — but only for turns the chat itself started: a turn opened by a human's CLI keystroke is recorded to the turn store (rolepage) and kept out of the chat (`wrc.mirror.chatOriginOnly`, brief mode only); approval/askq cards and `[mirror]` notices bypass that gate. Inbound injection is either `claude --resume <sid> -p` (writes a turn into the same jsonl) or `tmux paste-buffer` against a live TTY (only path that makes WeCom messages visible in the user's CLI window). `recentInjects` ring buffer suppresses the WeCom→CLI echo when tailing (which is why an injected line's text is stamped as the turn's `userQuery` at inject time — the tail never sees it). **Every turn has a channel** (`TurnDetailRecord.channel`): the base of the public chat it was spoken in and where its reply goes — a human can `.name` a wizard homed in another chat and the reply (text and cards) returns to the chat that asked, not the wizard's home; `""` = a private wizard↔wizard turn whose reply is pushed nowhere and lives only on the rolepage.
57
57
  - `claim.ts` is a single-shot bootstrap: an exact-match magic phrase from any sender bypasses `allowFrom`, writes that principal as `defaultChat`, and appends to `allowFrom`. This is the **only** legitimate ingress for first-time setup.
58
- - `peers.ts` is the read side of multi-agent awareness — "who else is working in this chat, and are they busy". A wizard's HOME chat is its target key's base principal (`baseOfKey`): the untagged default plus every slot `chat:xxx#slot`. Pure parsing only — bounded transcript-tail reads (`tailTurns` / `lastAssistantText` / `summarizeTail`, shared with `session-scan.ts`) and pane classification (`paneIsBusy`). **What a wizard reads about another comes from the transcript, never from a pane capture**: text turns only, keepalive ping/pong stripped (`talkTurns` / `withoutKeepalive` — pass the warmer's signature only; a `resumePing` turn is real work), and "where is it stuck" is `openToolUses` (a tool call left hanging on an idle session = parked on an approval card). The pane is consulted for exactly one thing, `busy`. Read-side tools (`wizard_roster` / `peek_peer` / `read_chat`) answer in plain text rendered daemon-side, not json; `read_chat` (`chat-log.ts`) is computed from session transcripts alone, not the turn store: a log = every recently-active session's ask + final answer per round, merged by timestamp, then narrowed on the rolepage's axis `role` → `chat` → `target` and cut by `since` / `until` / `limit`. Which channel a line belongs to and who said it is read back from the envelope riding on that user line (`parseEnvelope`, `shared/reminder.ts`): every wezard `<system-reminder>` carries its facts as opening-tag attributes (`wezard=envelope|mention|roster`, `kind` / `from` / `chat` / `scope`) and its prose only for the model — rewording the prose never breaks parsing; old attribute-less wordings keep parsing via its `LEGACY` table. The rolepage collapses each reminder into a `<reminder>` tag that expands to raw on click; no envelope = a human speaking in the session's home chat. A clone's transcript starts with its parent's forked history, so its rounds older than `bornAt` are dropped. Identity (name / job / memory / lineage) lives in `wizard.ts`, not here. **Busy detection matches the TUI spinner footer** (`… (12m 31s · …)` on current versions, `esc to interrupt` on older ones) in the last 8 non-blank rows — transcript mtime is useless here, it goes quiet during long tool calls.
59
- - `wizard.ts` is the identity layer over those sessions: a target key plus a **globally unique name** — the wizard's only address, written `.name` everywhere a human or model sees it (a chat's default session takes the chat's name, a slot takes its slot id; collisions get `-N` via `pickName`; `settleName` claims lazily, `settleAll` migrates at boot, default sessions first). The slot id inside the key is NOT the name: a rename never rekeys a session. Plus a one-line job description, a long-term `memory` list, and a `parent` link that makes a session a CLONE of another. The registry is a write-through json (`~/.wezard/wizards.json`, `wrc.mirror.wizardsFile`) bound process-wide via `bindWizardStore` so the IM side can read it too; everything else in the module is a pure renderer. **Identity travels as a system prompt, never as a message**: `renderCharter` builds the wizard's charter (who it is, its workspace, its clones, its memory, the chat's and the workspace's shared memory (`wizard-memory.ts`: `~/.wezard/memory/{chats,workspaces}/*.md`, human-editable, appended by `wizard_remember({scope})`), the tool roster, the orchestration playbook, the public-vs-private etiquette) and `spawn-tmux` presses it in with `--append-system-prompt "$(cat <file>)"` — so it survives `/clear`, costs no turn, and never shows in chat. Every spawn path (`/new`, a dead-pane respawn, a clone) pulls it from `MirrorBridge.setCharterProvider`, installed in `index.ts`. Backends with no such flag (codebuddy) spawn without a charter rather than with a rejected flag.
58
+ - `peers.ts` is the read side of multi-agent awareness — "who else is working in this chat, and are they busy". A wizard's HOME chat is its target key's base principal (`baseOfKey`): the untagged default plus every slot `chat:xxx#slot`. Pure parsing only — bounded transcript-tail reads (`tailTurns` / `lastAssistantText` / `summarizeTail`, shared with `session-scan.ts`) and pane classification (`paneIsBusy`). **What a wizard reads about another comes from the transcript, never from a pane capture**: text turns only, keepalive ping/pong stripped (`talkTurns` / `withoutKeepalive` — pass the ping signature), and "where is it stuck" is `openToolUses` (a tool call left hanging on an idle session = parked on an approval card). The pane is consulted for exactly one thing, `busy`. Read-side tools (`wizard_roster` / `peek_peer` / `read_chat`) answer in plain text rendered daemon-side, not json; `read_chat` (`chat-log.ts`) is computed from session transcripts alone, not the turn store: a log = every recently-active session's ask + final answer per round, merged by timestamp, then narrowed on the rolepage's axis `role` → `chat` → `target` and cut by `since` / `until` / `limit`. Which channel a line belongs to and who said it is read back from the envelope riding on that user line (`parseEnvelope`, `shared/reminder.ts`): every wezard `<system-reminder>` carries its facts as opening-tag attributes (`wezard=envelope|mention|roster`, `kind` / `from` / `chat` / `scope`) and its prose only for the model — rewording the prose never breaks parsing; old attribute-less wordings keep parsing via its `LEGACY` table. The rolepage collapses each reminder into a `<reminder>` tag that expands to raw on click; no envelope = a human speaking in the session's home chat. A clone's transcript starts with its parent's forked history, so its rounds older than `bornAt` are dropped. Identity (name / job / memory / lineage) lives in `wizard.ts`, not here. **Busy detection matches the TUI spinner footer** (`… (12m 31s · …)` on current versions, `esc to interrupt` on older ones) in the last 8 non-blank rows — transcript mtime is useless here, it goes quiet during long tool calls.
59
+ - `wizard.ts` is the identity layer over those sessions: a target key plus a **globally unique name** — the wizard's only address, written `.name` everywhere a human or model sees it (a chat's default session takes the chat's name, a slot takes its slot id; collisions get `-N` via `pickName`; `settleName` claims lazily, `settleAll` migrates at boot, default sessions first). The slot id inside the key is NOT the name: a rename never rekeys a session. Plus a one-line job description, a long-term `memory` list, and a `parent` link that makes a session a CLONE of another. The registry is a write-through json (`~/.wezard/wizards.json`, `wrc.mirror.wizardsFile`) bound process-wide via `bindWizardStore` so the IM side can read it too; everything else in the module is a pure renderer. **Identity travels as a system prompt, never as a message**: `renderCharter` builds the wizard's charter (who it is, its workspace, its clones, its memory, the chat's and the workspace's shared memory (`wizard-memory.ts`: `~/.wezard/memory/{chats,workspaces}/*.md`, human-editable, appended by `wizard_remember({scope})`), the tool roster, the orchestration playbook, the public-vs-private etiquette) and `spawn-tmux` presses it in with `--append-system-prompt "$(cat <file>)"` — so it survives `/clear`, costs no turn, and never shows in chat. Every spawn path (`/new`, a dead-pane respawn, a clone) pulls it from `MirrorBridge.setCharterProvider`, installed in `index.ts`. Since a running wizard never sees a new charter, `config_set` renders every chartered wizard's charter before and after the change and tells the affected ones (and the caller) to `handoff` — generic over whatever config the charter reads. Backends with no such flag (codebuddy) spawn without a charter rather than with a rejected flag.
60
60
  - **Cloning is a context fork**: `MirrorBridge.cloneSession` spawns `--resume <parent sid> --fork-session`, so the child starts holding everything the parent had read while the parent is untouched — that is what makes "load the shared material once, then fan out" work. Two invariants: `--fork-session` is mandatory (without it a second pane appends to the parent's jsonl and two chats cross-wire), and the forked transcript only appears when that pane takes its FIRST message — so the clone's opening line is the trigger, and the attach waits for the fork; if it never appears the pane is killed rather than bound to the parent's sid.
61
- - **Chats are public channels, wizard↔wizard is private by default.** `send_peer` injects with `channel:""` and posts no bubble; `public:true` (the model decides) posts `emoji .a → .b` into the sender's current channel and the reply follows there. No spawn/relay/job bubbles remain — only `notify`. A `tell_peer` carrying a `job` is always private (`public` is ignored), so a fan-out's whole process stays on the rolepage; the human sees only the initiator's own closing reply, routed back along the chain that asked it.
61
+ - **Chats are public channels, wizard↔wizard is private by default.** `send_peer` injects with `channel:""` and posts no bubble; `public:true` (the model decides) posts `emoji .a → .b` into the sender's current channel and the reply follows there. No spawn/relay/job bubbles remain — only `notify`. A `tell_peer` carrying a `job` is always private (`public` is ignored), so a fan-out's whole process stays on the rolepage; the human sees only the initiator's own closing reply, routed back along the chain that asked it. A chat with `chatPolicy.<chat>.topOnly` (top-level-only mode) on goes further (daemon-enforced in `tellPeerAs` / `/notify`): every wizard↔wizard line there is private regardless of `public`, and a wizard whose turn is working for a peer (a private peer task, or a receipt whose `k` is a peer) may not `notify` it (the steward is exempt in its own chat) — the charter tells top-level wizards to explain the result in their own words.
62
62
  - **`tell_peer` delivery policy** (`priority`) only matters when the target is busy: `normal` (default) waits for its turn to end before pasting — two senders pasting into one busy box get read as one turn; `urgent` presses Esc first (the same interrupt as `stop_wizard`, without the teardown that would drop others' queued lines); `now` pastes into the running turn (interject, answer its question; the default for `re`).
63
63
  - `receipts.ts` makes `tell_peer` asynchronous: every task gets a **turn id** (`t` + 6 hex, written into the envelope) and a watcher waits for the target to go idle (registry, not spinner), locates the answer **to that turn** by envelope (never by timestamp — a busy target's next output is the previous task's conclusion) and pastes it back into the sender when the sender is idle, one pane at a time. A receipt carries a `status`: `done`; `need` (closed with `NEED:`) and `error` (CLI error) are interim — the watcher keeps watching; `timeout` (`deadline`, default 1h) / `silent` / `dead` / `canceled` are synthesized failures, delivered rather than dropped because "no answer" is also an answer. `re` continues the same turn (`legs+1`, same job and channel); `legs > 3` only adds a "give RESULT now" line — the hard brake is the job budget. Slots persist (`receipts.json`, key `from\0to`) and resume after reload, since reloads land mid-turn routinely here. **Chained reply-to**: a task sent while answering upstream X gets parent `k` (read from the sender's turn-opening envelope); the sender's first answer is then *deferred* (not delivered, not counted) until its children settle, and the last child's receipt carries `reply-to` so that receipt-turn's closing line goes back to X (or to the chat for a human-opened turn) — siblings still pending → that turn stays on the rolepage. `chain:false` marks a side task that must not hold the upstream answer. `stop_wizard` / `close_job` cancel before killing, or the watcher would report `dead`. Any change to receipts / jobs / peers: run `node scripts/receipt-regress/run.mjs`.
64
64
  - `shared/turn-state.ts` is the one vocabulary for an in-flight task: `Terminal` (the receipt statuses that settle it) plus `working` / `blocked` (parked on an approval) / `needs-input` / `errored` / `deferred`, computed on read from the slot and one pane probe — roster, peek and `list_jobs` print the same words (`在等` / `欠着`).
package/README.md CHANGED
@@ -294,16 +294,16 @@ sequenceDiagram
294
294
 
295
295
  ---
296
296
 
297
- ## 缓存保活
297
+ ## 缓存保活(默认关)
298
298
 
299
- Agent 空闲超过约 5 分钟,模型侧的 prompt 缓存就会过期,下一轮对话要为整份上下文重新付费。wezard 在缓存快过期时自动发一次极小的心跳把它续上:
299
+ prompt 缓存过期后,下一轮对话要为整份上下文重新付费。保活在缓存快过期时自动发一次极小的心跳把它续上。它是为 API 默认的 5 分钟 TTL 设计的;**Claude Code 订阅写的是 1 小时缓存**(本机实测:1491 个主会话里 1490 个只写 1h,相邻两次请求隔 6–60 分钟的 230 次里 229 次整段命中),一小时内的空闲本来就不会冷,所以默认关闭。transcript 里写的是 5 分钟缓存(`cache_creation.ephemeral_5m`)的环境再打开 `wrc.mirror.keepalive.enabled`:
300
300
 
301
- - 每次真实对话后最多续 6 次(约 26 分钟),之后不再续;
301
+ - 心跳节奏跟着会话实际写入的 TTL 走,按缓存真实被碰的时刻(最近一次请求的开始)计时;缓存闲置超过 10 分钟就不再续(1h 缓存因此不会被保温);
302
+ - 每次真实对话后最多续 6 次,之后不再续;
302
303
  - 心跳不出现在聊天里,只记在详情页时间线;
303
- - 上一轮因报错 / 限额中断时,心跳会顺手把活接上;
304
304
  - `/stop` 同时暂停保活,下次真实对话自动恢复。
305
305
 
306
- 配置项在 `wrc.mirror.keepalive`,完整规则见 [技术说明](技术说明.md#prompt-cache-保活省钱心跳)。
306
+ 路由与 `tell_peer` 冷门控判「缓存冷不冷」不依赖保活:TTL 读自 transcript 实际写入的档位,起点取最近一次请求的开始时刻。完整规则见 [技术说明](技术说明.md#prompt-cache-保活省钱心跳)。
307
307
 
308
308
  ---
309
309
 
@@ -126,6 +126,18 @@
126
126
  }
127
127
  },
128
128
 
129
+ // ── 按难度分档的模型 ─────────────────────────────────────────────
130
+ // spawn_wizard / clone_wizard 的 `tier` 落到这里; 显式给的 model / effort / cli 压过档位。
131
+ // router = 各群管家 (群的默认会话) 新起时用的模型。省略整段 = 下面这组默认值。
132
+ "models": {
133
+ "tiers": {
134
+ "light": { "model": "haiku", "effort": "low" },
135
+ "standard": { "model": "sonnet", "effort": "medium" },
136
+ "hard": { "model": "opus", "effort": "high" }
137
+ },
138
+ "router": { "model": "sonnet", "effort": "low" }
139
+ },
140
+
129
141
  // ── claude / codebuddy wrapper sync ───────────────────────────────
130
142
  // `wezard sync` writes hooks/MCP/permissions into every target settings.json
131
143
  // listed below. Multi-CLI setups (claude + codebuddy 同时装) 列多项即可。
@@ -6,9 +6,11 @@ import { redact } from "./redact.js";
6
6
  import { dangerOf, dangerEarlyExit, selfConfigWriteOf } from "./danger.js";
7
7
  import { appendUnique } from "../shared/config-writer.js";
8
8
  import { claudeConfigWrite } from "../shared/claude-config-path.js";
9
- import { recordApproval, recordApprovalDecision, buildDetailUrl, getDetail } from "./detail.js";
9
+ import { recordApproval, recordApprovalDecision, buildDetailUrl, getDetail, openTurnsOf } from "./detail.js";
10
10
  import { json, readBody } from "./http.js";
11
- import { tagBadge, withTagHeader } from "../shared/session-label.js";
11
+ import { tagBadge, tagOfKey, withTagHeader } from "../shared/session-label.js";
12
+ import { chatPolicyOf } from "./chat-name.js";
13
+ import { makeStewardGuard } from "./steward-guard.js";
12
14
  import { sessionNameFor } from "./session-name.js";
13
15
  // ── Routing helpers ────────────────────────────────────────────────────
14
16
  // 卡片跟着本轮的频道走: 人在群 B 里 `.fix` 叫住在 A 的 wizard, 这一轮的审批/提问卡
@@ -494,10 +496,12 @@ const decodeKey = (key) => {
494
496
  return { reqId, decision: d };
495
497
  };
496
498
  // ── AskUserQuestion 投票卡分支 ─────────────────────────────────────────
497
- // PreToolUse 协议端只能输出 allow/deny/ask, 没有「合成 tool_result」通道。
498
- // 取舍: 用户在 WeCom 选了选项 → 走 deny + 把答案塞进 reason, model 把 reason
499
- // 当作上下文继续推理(CLI 不会弹原生 picker, 流程不被打断);
500
- // 选「🖥️ CLI 处理」哨兵选项 → 返回 ask, CLI 弹原生 picker 由用户本地作答。两路互斥。
499
+ // 用户在 WeCom 选了选项 → allow + updatedInput 补上 `answers` (AskUserQuestion 自己的
500
+ // 入参: {题目文本: 所选 label}): Claude Code 对 AskUserQuestion / ExitPlanMode 认 hook
501
+ // 给的 updatedInput 为「已作答」, 跳过本地面板, 工具照常落地出 tool_result。曾经走
502
+ // deny + 把答案塞进 reason, CLI 里显示成红色 hook error。
503
+ // 选「🖥️ CLI 处理」哨兵选项 → 返回 ask, CLI 弹原生 picker 由用户本地作答; 选「聊聊」
504
+ // → deny (本来就是「别答, 先讨论」)。codebuddy 的镜像流仍是 deny + reason (见下)。
501
505
  // vote_interaction 不支持 button_list (SDK 类型注释明写「button_interaction 类型卡片使用」),
502
506
  // 微信侧静默吞掉, 所以 cli 入口只能塞进 checkbox option_list 作为哨兵 id。
503
507
  const ASKQ_PREFIX = "ASKQ|";
@@ -619,6 +623,53 @@ const decodePlanKey = (key) => {
619
623
  return { reqId, action };
620
624
  };
621
625
  const decodePlanNoopKey = (key) => key.startsWith(PLAN_NOOP_PREFIX) ? key.slice(PLAN_NOOP_PREFIX.length) : undefined;
626
+ // ── 确认卡: 守护进程自己要人点一下才做的事 (config_set 的放权项)。它不经 hook, 所以
627
+ // danger.skipAll / ⏱窗口 / 会话缓存都压不过它 —— 这正是它不走审批卡的理由。
628
+ const CONFIRM_PREFIX = "CONFIRM|";
629
+ const CONFIRM_NOOP_PREFIX = "confirm_noop:";
630
+ const CONFIRM_PICKED_PREFIX = "confirm:";
631
+ const encodeConfirmKey = (reqId, action) => `${CONFIRM_PREFIX}${reqId}|${action}`;
632
+ const decodeConfirmKey = (key) => {
633
+ if (!key.startsWith(CONFIRM_PREFIX))
634
+ return undefined;
635
+ const [reqId, action] = key.slice(CONFIRM_PREFIX.length).split("|");
636
+ return reqId && (action === "yes" || action === "no") ? { reqId, action } : undefined;
637
+ };
638
+ // 事件回调里重画已决卡要用原文; 卡决了 (或超时) 就删。
639
+ const confirmCards = new Map();
640
+ const buildConfirmCard = (reqId, c, done) => ({
641
+ card_type: "button_interaction",
642
+ source: buildSource(undefined, c.who),
643
+ main_title: { title: TRUNC(`⚙️ ${c.title}`, 38) },
644
+ quote_area: quoteArea(TRUNC(c.body, SAFE_QUOTE_MAX)),
645
+ task_id: reqId,
646
+ button_list: done
647
+ ? [{ text: done === "yes" ? "✅ 已确认写入" : done === "no" ? "❌ 已拒绝" : "⌛ 已失效", style: 4, key: `${CONFIRM_NOOP_PREFIX}${reqId}` }]
648
+ : [
649
+ { text: "❌ 不改", style: 4, key: encodeConfirmKey(reqId, "no") },
650
+ { text: "✅ 确认写入", style: 4, key: encodeConfirmKey(reqId, "yes") },
651
+ ],
652
+ });
653
+ /** 往 `principal` 的当前频道推一张确认卡; 卡发出后返回, `decided` 是人的决定 (超时 = false)。
654
+ * 包一层对象是因为 promise 会被 await 摊平。发不出去就抛 —— 没人看得见的卡不该被当成「人拒绝了」。 */
655
+ export const requestConfirm = async ({ client, principal, who, title, body, timeoutMs }) => {
656
+ if (!client.isConnected)
657
+ throw new Error("ws_disconnected");
658
+ const { reqId, promise } = createPending({ meta: { kind: "generic", createdAt: Date.now(), chatKey: principal }, timeoutMs });
659
+ const c = { who, title, body };
660
+ confirmCards.set(reqId, c);
661
+ try {
662
+ await client.sendMessage(targetChatId(principal), { msgtype: "template_card", template_card: buildConfirmCard(reqId, c) });
663
+ }
664
+ catch (e) {
665
+ resolvePending(reqId, "deny");
666
+ confirmCards.delete(reqId);
667
+ throw e;
668
+ }
669
+ return {
670
+ decided: promise.then((d) => d === `${CONFIRM_PICKED_PREFIX}yes`, () => false).finally(() => confirmCards.delete(reqId)),
671
+ };
672
+ };
622
673
  const PLAN_PICKED_PREFIX = "plan:";
623
674
  const PLAN_REVISE_REASON = "用户希望继续完善计划,请询问还需要调整哪些地方,不要直接开始执行。";
624
675
  const parsePlanInput = (i) => {
@@ -799,6 +850,9 @@ const handleExitPlanMode = async ({ cfg, log, client, body, getMirrorTarget, flu
799
850
  }
800
851
  return { decision: "ask", reason: "plan_unknown" };
801
852
  };
853
+ // Claude Code 多选答案的编码 (逆向自其 AskUserQuestion 的结果校验): ", " 连接,
854
+ // 含 ", " 或 `"` 的 label 用 JSON 字符串包起来, 否则它拆不回原 label。
855
+ const encodeAskqLabels = (labels) => labels.map((l) => (l.includes(", ") || l.includes('"') ? JSON.stringify(l) : l)).join(", ");
802
856
  // raw 是 click 事件 listener 塞回 pending 的字符串编码 (cli / chat / picked:i,j)。
803
857
  export const interpretAskqRaw = (raw, q) => {
804
858
  if (raw === "cli")
@@ -812,7 +866,7 @@ export const interpretAskqRaw = (raw, q) => {
812
866
  .filter((n) => Number.isInteger(n) && n >= 0 && n < q.options.length);
813
867
  if (idxs.length === 0)
814
868
  return { kind: "empty" };
815
- return { kind: "picked", labels: idxs.map((i) => q.options[i].label).join(", ") };
869
+ return { kind: "picked", labels: encodeAskqLabels(idxs.map((i) => q.options[i].label)) };
816
870
  }
817
871
  return { kind: "empty" };
818
872
  };
@@ -1244,7 +1298,7 @@ export const runMirrorPickerFlow = async ({ log, client, cfg, sessionId, chatKey
1244
1298
  }
1245
1299
  };
1246
1300
  // 多问题: 逐题顺序发卡 → 收答 → 下一题。任一题选「CLI」整体转 CLI,选「聊聊」
1247
- // 整体转讨论; 全部答完合并成单个 deny+reason 注入。卡不会一次性轰炸 N 张。
1301
+ // 整体转讨论; 全部答完合并成一份 answers 随 allow 的 updatedInput 交回。卡不会一次性轰炸 N 张。
1248
1302
  const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget, flushBeforeCard, clientGone }) => {
1249
1303
  // ── codebuddy 镜像流去重 ────────────────────────────────────────────────
1250
1304
  // codebuddy 的 hook 只在本地面板被提交后到达。mirror 早已发过 vote 卡:
@@ -1275,7 +1329,7 @@ const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget,
1275
1329
  const target = targetChatId(approver);
1276
1330
  const longPollMs = cfg.approval.longPollSec * 1000;
1277
1331
  const total = questions.length;
1278
- const answers = [];
1332
+ const answers = {};
1279
1333
  const flowStart = Date.now();
1280
1334
  for (let i = 0; i < total; i++) {
1281
1335
  const q = questions[i];
@@ -1366,12 +1420,13 @@ const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget,
1366
1420
  }
1367
1421
  if (ans.kind === "empty")
1368
1422
  return { decision: "ask", reason: "askq_empty_pick" };
1369
- answers.push(`"${q.header || q.question}": ${ans.labels}`);
1423
+ answers[q.question] = ans.labels; // 键是题目原文, Claude Code 按它对答案
1370
1424
  }
1371
- const reason = total === 1
1372
- ? `User answered ${answers[0]} via WeCom`
1373
- : `User answered ${total} questions via WeCom — ${answers.join("; ")}`;
1374
- return { decision: "deny", reason };
1425
+ return {
1426
+ decision: "allow",
1427
+ reason: "answered via WeCom",
1428
+ updated_input: { ...body.tool_input, answers },
1429
+ };
1375
1430
  };
1376
1431
  const decisionToHook = (d) => (d === "deny" ? "deny" : "allow");
1377
1432
  // 「完全跳过审批」的模式字面量, 各家 CLI 不统一: bypassPermissions 是 Claude Code
@@ -1442,6 +1497,7 @@ const unwrapDeferredTool = (toolName, toolInput) => {
1442
1497
  return { toolName: inner, toolInput: rest };
1443
1498
  };
1444
1499
  export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarget, flushBeforeCard, nativeModal }) => {
1500
+ const stewardGuard = makeStewardGuard();
1445
1501
  setCardQuoteMax(cfg.approval.cardQuoteMaxChars);
1446
1502
  const detailUrlFor = (id, approver) => buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, approver ? targetChatId(approver) : undefined);
1447
1503
  // 手机端卡片 quote 区实测只渲染前 2~3 行 —— 长命令在卡上看不全。默认解法已经
@@ -1641,6 +1697,22 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1641
1697
  sessionId = parent;
1642
1698
  }
1643
1699
  }
1700
+ // 群管家的手闸 (见 steward-guard.ts): 排在所有审批之前 —— 拦的不是危险, 是越位,
1701
+ // 放行窗口 / allowRules / skipAll 都不该替它开门。
1702
+ const owner = getMirrorTarget?.(sessionId);
1703
+ if (owner && !tagOfKey(owner)) {
1704
+ const reason = stewardGuard({
1705
+ sessionId,
1706
+ turn: openTurnsOf(owner, sessionId)[0]?.id ?? "",
1707
+ toolName,
1708
+ budget: chatPolicyOf(cfg, owner).stewardBudget,
1709
+ });
1710
+ if (reason) {
1711
+ log.info({ toolName, sessionId, owner }, "steward guard reject");
1712
+ json(res, 200, { decision: "deny", reason });
1713
+ return;
1714
+ }
1715
+ }
1644
1716
  // wezard 自改配置的写 (见 selfConfigWriteOf): matcher 也放不掉它。
1645
1717
  const selfWrite = selfConfigWriteOf(toolName, toolInput);
1646
1718
  // Matcher: only intercept matching tools — others pass.
@@ -1722,7 +1794,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1722
1794
  });
1723
1795
  return;
1724
1796
  }
1725
- // AskUserQuestion 走单独的投票卡分支(deny+reason 注入答案 / ask 转 CLI)。
1797
+ // AskUserQuestion 走单独的投票卡分支(allow+updatedInput 交答案 / deny 先聊聊 / ask 转 CLI)。
1726
1798
  if (toolName === "AskUserQuestion") {
1727
1799
  // writableEnded 为 false 时的 close = 响应还没写就断线 = hook 客户端先死。
1728
1800
  // 正常完成时 json() 先置 writableEnded, close 到来后 resolve 不再发生。
@@ -2181,6 +2253,22 @@ export const installApprovalEventListener = (client, log, cfg, onApproved) => {
2181
2253
  }
2182
2254
  return;
2183
2255
  }
2256
+ // ── 确认卡 (requestConfirm): 守护进程自己发的「点了才做」。
2257
+ const confirmClick = decodeConfirmKey(key);
2258
+ if (confirmClick) {
2259
+ const c = confirmCards.get(confirmClick.reqId);
2260
+ const ok = resolvePending(confirmClick.reqId, `${CONFIRM_PICKED_PREFIX}${confirmClick.action}`);
2261
+ log.info({ reqId: confirmClick.reqId, action: confirmClick.action, ok }, "confirm event resolved");
2262
+ try {
2263
+ await client.updateTemplateCard(frame, buildConfirmCard(cbTaskId || confirmClick.reqId, c ?? { who: "wezard", title: "确认卡", body: "" }, ok ? confirmClick.action : "stale"));
2264
+ }
2265
+ catch (e) {
2266
+ log.warn({ err: e.message, reqId: confirmClick.reqId }, "confirm updateTemplateCard failed");
2267
+ }
2268
+ return;
2269
+ }
2270
+ if (key.startsWith(CONFIRM_NOOP_PREFIX))
2271
+ return;
2184
2272
  // ── ExitPlanMode 计划审批卡: 在普通 approval 解码前匹配 PLAN| 前缀。
2185
2273
  const planClick = decodePlanKey(key);
2186
2274
  if (planClick) {