wezard 2.3.0 → 2.4.0

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 (68) 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 +84 -2
  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 +265 -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 +10 -10
  19. package/dist/daemon/inbound.js.map +1 -1
  20. package/dist/daemon/index.js +872 -214
  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 +95 -83
  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 +145 -33
  29. package/dist/daemon/notices.js.map +1 -1
  30. package/dist/daemon/peers.js +88 -74
  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 +54 -20
  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/wizard.js +104 -61
  43. package/dist/daemon/wizard.js.map +1 -1
  44. package/dist/mcp/server.js +75 -25
  45. package/dist/mcp/server.js.map +1 -1
  46. package/dist/shared/chat-http.js +8 -8
  47. package/dist/shared/chat-http.js.map +1 -1
  48. package/dist/shared/config-meta.js +91 -0
  49. package/dist/shared/config-meta.js.map +1 -0
  50. package/dist/shared/config-writer.js +5 -6
  51. package/dist/shared/config-writer.js.map +1 -1
  52. package/dist/shared/config.js +269 -294
  53. package/dist/shared/config.js.map +1 -1
  54. package/dist/shared/detail-render.js +121 -32
  55. package/dist/shared/detail-render.js.map +1 -1
  56. package/dist/shared/detail-store.js.map +1 -1
  57. package/dist/shared/keepalive.js +5 -5
  58. package/dist/shared/keepalive.js.map +1 -1
  59. package/dist/shared/role-render.js +49 -11
  60. package/dist/shared/role-render.js.map +1 -1
  61. package/dist/shared/role-view.js +18 -4
  62. package/dist/shared/role-view.js.map +1 -1
  63. package/dist/shared/world.js +82 -13
  64. package/dist/shared/world.js.map +1 -1
  65. package/hooks/pre-tool-use.sh +5 -6
  66. package/package.json +1 -1
  67. package/web/chat.css +57 -37
  68. package/web/chat.js +454 -227
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, 这一轮的审批/提问卡
@@ -619,6 +621,53 @@ const decodePlanKey = (key) => {
619
621
  return { reqId, action };
620
622
  };
621
623
  const decodePlanNoopKey = (key) => key.startsWith(PLAN_NOOP_PREFIX) ? key.slice(PLAN_NOOP_PREFIX.length) : undefined;
624
+ // ── 确认卡: 守护进程自己要人点一下才做的事 (config_set 的放权项)。它不经 hook, 所以
625
+ // danger.skipAll / ⏱窗口 / 会话缓存都压不过它 —— 这正是它不走审批卡的理由。
626
+ const CONFIRM_PREFIX = "CONFIRM|";
627
+ const CONFIRM_NOOP_PREFIX = "confirm_noop:";
628
+ const CONFIRM_PICKED_PREFIX = "confirm:";
629
+ const encodeConfirmKey = (reqId, action) => `${CONFIRM_PREFIX}${reqId}|${action}`;
630
+ const decodeConfirmKey = (key) => {
631
+ if (!key.startsWith(CONFIRM_PREFIX))
632
+ return undefined;
633
+ const [reqId, action] = key.slice(CONFIRM_PREFIX.length).split("|");
634
+ return reqId && (action === "yes" || action === "no") ? { reqId, action } : undefined;
635
+ };
636
+ // 事件回调里重画已决卡要用原文; 卡决了 (或超时) 就删。
637
+ const confirmCards = new Map();
638
+ const buildConfirmCard = (reqId, c, done) => ({
639
+ card_type: "button_interaction",
640
+ source: buildSource(undefined, c.who),
641
+ main_title: { title: TRUNC(`⚙️ ${c.title}`, 38) },
642
+ quote_area: quoteArea(TRUNC(c.body, SAFE_QUOTE_MAX)),
643
+ task_id: reqId,
644
+ button_list: done
645
+ ? [{ text: done === "yes" ? "✅ 已确认写入" : done === "no" ? "❌ 已拒绝" : "⌛ 已失效", style: 4, key: `${CONFIRM_NOOP_PREFIX}${reqId}` }]
646
+ : [
647
+ { text: "❌ 不改", style: 4, key: encodeConfirmKey(reqId, "no") },
648
+ { text: "✅ 确认写入", style: 4, key: encodeConfirmKey(reqId, "yes") },
649
+ ],
650
+ });
651
+ /** 往 `principal` 的当前频道推一张确认卡; 卡发出后返回, `decided` 是人的决定 (超时 = false)。
652
+ * 包一层对象是因为 promise 会被 await 摊平。发不出去就抛 —— 没人看得见的卡不该被当成「人拒绝了」。 */
653
+ export const requestConfirm = async ({ client, principal, who, title, body, timeoutMs }) => {
654
+ if (!client.isConnected)
655
+ throw new Error("ws_disconnected");
656
+ const { reqId, promise } = createPending({ meta: { kind: "generic", createdAt: Date.now(), chatKey: principal }, timeoutMs });
657
+ const c = { who, title, body };
658
+ confirmCards.set(reqId, c);
659
+ try {
660
+ await client.sendMessage(targetChatId(principal), { msgtype: "template_card", template_card: buildConfirmCard(reqId, c) });
661
+ }
662
+ catch (e) {
663
+ resolvePending(reqId, "deny");
664
+ confirmCards.delete(reqId);
665
+ throw e;
666
+ }
667
+ return {
668
+ decided: promise.then((d) => d === `${CONFIRM_PICKED_PREFIX}yes`, () => false).finally(() => confirmCards.delete(reqId)),
669
+ };
670
+ };
622
671
  const PLAN_PICKED_PREFIX = "plan:";
623
672
  const PLAN_REVISE_REASON = "用户希望继续完善计划,请询问还需要调整哪些地方,不要直接开始执行。";
624
673
  const parsePlanInput = (i) => {
@@ -1442,6 +1491,7 @@ const unwrapDeferredTool = (toolName, toolInput) => {
1442
1491
  return { toolName: inner, toolInput: rest };
1443
1492
  };
1444
1493
  export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarget, flushBeforeCard, nativeModal }) => {
1494
+ const stewardGuard = makeStewardGuard();
1445
1495
  setCardQuoteMax(cfg.approval.cardQuoteMaxChars);
1446
1496
  const detailUrlFor = (id, approver) => buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, approver ? targetChatId(approver) : undefined);
1447
1497
  // 手机端卡片 quote 区实测只渲染前 2~3 行 —— 长命令在卡上看不全。默认解法已经
@@ -1641,6 +1691,22 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1641
1691
  sessionId = parent;
1642
1692
  }
1643
1693
  }
1694
+ // 群管家的手闸 (见 steward-guard.ts): 排在所有审批之前 —— 拦的不是危险, 是越位,
1695
+ // 放行窗口 / allowRules / skipAll 都不该替它开门。
1696
+ const owner = getMirrorTarget?.(sessionId);
1697
+ if (owner && !tagOfKey(owner)) {
1698
+ const reason = stewardGuard({
1699
+ sessionId,
1700
+ turn: openTurnsOf(owner, sessionId)[0]?.id ?? "",
1701
+ toolName,
1702
+ budget: chatPolicyOf(cfg, owner).stewardBudget,
1703
+ });
1704
+ if (reason) {
1705
+ log.info({ toolName, sessionId, owner }, "steward guard reject");
1706
+ json(res, 200, { decision: "deny", reason });
1707
+ return;
1708
+ }
1709
+ }
1644
1710
  // wezard 自改配置的写 (见 selfConfigWriteOf): matcher 也放不掉它。
1645
1711
  const selfWrite = selfConfigWriteOf(toolName, toolInput);
1646
1712
  // Matcher: only intercept matching tools — others pass.
@@ -2181,6 +2247,22 @@ export const installApprovalEventListener = (client, log, cfg, onApproved) => {
2181
2247
  }
2182
2248
  return;
2183
2249
  }
2250
+ // ── 确认卡 (requestConfirm): 守护进程自己发的「点了才做」。
2251
+ const confirmClick = decodeConfirmKey(key);
2252
+ if (confirmClick) {
2253
+ const c = confirmCards.get(confirmClick.reqId);
2254
+ const ok = resolvePending(confirmClick.reqId, `${CONFIRM_PICKED_PREFIX}${confirmClick.action}`);
2255
+ log.info({ reqId: confirmClick.reqId, action: confirmClick.action, ok }, "confirm event resolved");
2256
+ try {
2257
+ await client.updateTemplateCard(frame, buildConfirmCard(cbTaskId || confirmClick.reqId, c ?? { who: "wezard", title: "确认卡", body: "" }, ok ? confirmClick.action : "stale"));
2258
+ }
2259
+ catch (e) {
2260
+ log.warn({ err: e.message, reqId: confirmClick.reqId }, "confirm updateTemplateCard failed");
2261
+ }
2262
+ return;
2263
+ }
2264
+ if (key.startsWith(CONFIRM_NOOP_PREFIX))
2265
+ return;
2184
2266
  // ── ExitPlanMode 计划审批卡: 在普通 approval 解码前匹配 PLAN| 前缀。
2185
2267
  const planClick = decodePlanKey(key);
2186
2268
  if (planClick) {