wezard 2.4.0 → 2.5.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 (101) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +113 -57
  3. package/cli/wezard.sh +2 -0
  4. package/config.example.jsonc +7 -4
  5. package/dist/cli/wx.js +78 -0
  6. package/dist/cli/wx.js.map +1 -0
  7. package/dist/daemon/aibot-media.js +62 -0
  8. package/dist/daemon/aibot-media.js.map +1 -0
  9. package/dist/daemon/approval.js +30 -20
  10. package/dist/daemon/approval.js.map +1 -1
  11. package/dist/daemon/ask.js +3 -2
  12. package/dist/daemon/ask.js.map +1 -1
  13. package/dist/daemon/cc-session.js +27 -4
  14. package/dist/daemon/cc-session.js.map +1 -1
  15. package/dist/daemon/chat-log.js +2 -2
  16. package/dist/daemon/chat-log.js.map +1 -1
  17. package/dist/daemon/chat-name.js +6 -2
  18. package/dist/daemon/chat-name.js.map +1 -1
  19. package/dist/daemon/config-api.js +25 -10
  20. package/dist/daemon/config-api.js.map +1 -1
  21. package/dist/daemon/direction.js +53 -0
  22. package/dist/daemon/direction.js.map +1 -0
  23. package/dist/daemon/ilink.js +162 -0
  24. package/dist/daemon/ilink.js.map +1 -0
  25. package/dist/daemon/inbound.js +64 -18
  26. package/dist/daemon/inbound.js.map +1 -1
  27. package/dist/daemon/index.js +415 -109
  28. package/dist/daemon/index.js.map +1 -1
  29. package/dist/daemon/jobs.js +4 -3
  30. package/dist/daemon/jobs.js.map +1 -1
  31. package/dist/daemon/last-response.js +6 -3
  32. package/dist/daemon/last-response.js.map +1 -1
  33. package/dist/daemon/media.js +108 -0
  34. package/dist/daemon/media.js.map +1 -0
  35. package/dist/daemon/mirror-bridge.js +111 -93
  36. package/dist/daemon/mirror-bridge.js.map +1 -1
  37. package/dist/daemon/model-select.js +159 -54
  38. package/dist/daemon/model-select.js.map +1 -1
  39. package/dist/daemon/notices.js +59 -12
  40. package/dist/daemon/notices.js.map +1 -1
  41. package/dist/daemon/outbound.js +2 -1
  42. package/dist/daemon/outbound.js.map +1 -1
  43. package/dist/daemon/peers.js +46 -27
  44. package/dist/daemon/peers.js.map +1 -1
  45. package/dist/daemon/pending-items.js +1 -1
  46. package/dist/daemon/pending-items.js.map +1 -1
  47. package/dist/daemon/reach.js +6 -0
  48. package/dist/daemon/reach.js.map +1 -0
  49. package/dist/daemon/receipts.js +149 -7
  50. package/dist/daemon/receipts.js.map +1 -1
  51. package/dist/daemon/route.js +11 -4
  52. package/dist/daemon/route.js.map +1 -1
  53. package/dist/daemon/spawn-tmux.js +157 -60
  54. package/dist/daemon/spawn-tmux.js.map +1 -1
  55. package/dist/daemon/steward-guard.js +48 -7
  56. package/dist/daemon/steward-guard.js.map +1 -1
  57. package/dist/daemon/top-only-nudge.js +29 -0
  58. package/dist/daemon/top-only-nudge.js.map +1 -0
  59. package/dist/daemon/weixin-cmd.js +268 -0
  60. package/dist/daemon/weixin-cmd.js.map +1 -0
  61. package/dist/daemon/weixin-port.js +226 -0
  62. package/dist/daemon/weixin-port.js.map +1 -0
  63. package/dist/daemon/weixin.js +772 -0
  64. package/dist/daemon/weixin.js.map +1 -0
  65. package/dist/daemon/wizard.js +56 -18
  66. package/dist/daemon/wizard.js.map +1 -1
  67. package/dist/mcp/server.js +43 -23
  68. package/dist/mcp/server.js.map +1 -1
  69. package/dist/shared/chat-http.js +61 -20
  70. package/dist/shared/chat-http.js.map +1 -1
  71. package/dist/shared/chat-render.js +9 -1
  72. package/dist/shared/chat-render.js.map +1 -1
  73. package/dist/shared/chat-view.js +16 -2
  74. package/dist/shared/chat-view.js.map +1 -1
  75. package/dist/shared/cli-backends.js +3 -0
  76. package/dist/shared/cli-backends.js.map +1 -1
  77. package/dist/shared/config.js +45 -10
  78. package/dist/shared/config.js.map +1 -1
  79. package/dist/shared/json-map-store.js +1 -0
  80. package/dist/shared/json-map-store.js.map +1 -1
  81. package/dist/shared/reminder.js +1 -0
  82. package/dist/shared/reminder.js.map +1 -1
  83. package/dist/shared/role-render.js +7 -4
  84. package/dist/shared/role-render.js.map +1 -1
  85. package/dist/shared/role-view.js +116 -37
  86. package/dist/shared/role-view.js.map +1 -1
  87. package/dist/shared/seen-log.js +26 -0
  88. package/dist/shared/seen-log.js.map +1 -0
  89. package/dist/shared/session-label.js +84 -31
  90. package/dist/shared/session-label.js.map +1 -1
  91. package/dist/shared/turn-state.js +1 -1
  92. package/dist/shared/turn-state.js.map +1 -1
  93. package/dist/shared/world.js.map +1 -1
  94. package/dist/shared/wx-text.js +101 -0
  95. package/dist/shared/wx-text.js.map +1 -0
  96. package/dist/svr/index.js +5 -2
  97. package/dist/svr/index.js.map +1 -1
  98. package/hooks/pre-tool-use.sh +9 -6
  99. package/package.json +6 -2
  100. package/web/chat.css +32 -5
  101. package/web/chat.js +160 -92
@@ -12,6 +12,7 @@ import { tagBadge, tagOfKey, withTagHeader } from "../shared/session-label.js";
12
12
  import { chatPolicyOf } from "./chat-name.js";
13
13
  import { makeStewardGuard } from "./steward-guard.js";
14
14
  import { sessionNameFor } from "./session-name.js";
15
+ import { reachable } from "./reach.js";
15
16
  // ── Routing helpers ────────────────────────────────────────────────────
16
17
  // 卡片跟着本轮的频道走: 人在群 B 里 `.fix` 叫住在 A 的 wizard, 这一轮的审批/提问卡
17
18
  // 要出现在 B —— 等着点的人在那儿。`principal` (chatKey) 仍是 wizard 自己的 key,
@@ -29,6 +30,8 @@ const targetChatId = (key) => {
29
30
  const h = rest.indexOf("#");
30
31
  return h >= 0 ? rest.slice(0, h) : rest;
31
32
  };
33
+ /** 卡片真正要去的那个聊天 (按频道换算之后) 此刻送得到吗 —— 微信 chat 不看企微长连接。 */
34
+ const canReach = (client, key) => reachable(client, targetChatId(key));
32
35
  const pickApprover = (cfg) => {
33
36
  if (cfg.approval.approvers.length > 0)
34
37
  return cfg.approval.approvers[0];
@@ -496,10 +499,12 @@ const decodeKey = (key) => {
496
499
  return { reqId, decision: d };
497
500
  };
498
501
  // ── AskUserQuestion 投票卡分支 ─────────────────────────────────────────
499
- // PreToolUse 协议端只能输出 allow/deny/ask, 没有「合成 tool_result」通道。
500
- // 取舍: 用户在 WeCom 选了选项 → 走 deny + 把答案塞进 reason, model 把 reason
501
- // 当作上下文继续推理(CLI 不会弹原生 picker, 流程不被打断);
502
- // 选「🖥️ CLI 处理」哨兵选项 → 返回 ask, CLI 弹原生 picker 由用户本地作答。两路互斥。
502
+ // 用户在 WeCom 选了选项 → allow + updatedInput 补上 `answers` (AskUserQuestion 自己的
503
+ // 入参: {题目文本: 所选 label}): Claude Code 对 AskUserQuestion / ExitPlanMode 认 hook
504
+ // 给的 updatedInput 为「已作答」, 跳过本地面板, 工具照常落地出 tool_result。曾经走
505
+ // deny + 把答案塞进 reason, CLI 里显示成红色 hook error。
506
+ // 选「🖥️ CLI 处理」哨兵选项 → 返回 ask, CLI 弹原生 picker 由用户本地作答; 选「聊聊」
507
+ // → deny (本来就是「别答, 先讨论」)。codebuddy 的镜像流仍是 deny + reason (见下)。
503
508
  // vote_interaction 不支持 button_list (SDK 类型注释明写「button_interaction 类型卡片使用」),
504
509
  // 微信侧静默吞掉, 所以 cli 入口只能塞进 checkbox option_list 作为哨兵 id。
505
510
  const ASKQ_PREFIX = "ASKQ|";
@@ -651,7 +656,7 @@ const buildConfirmCard = (reqId, c, done) => ({
651
656
  /** 往 `principal` 的当前频道推一张确认卡; 卡发出后返回, `decided` 是人的决定 (超时 = false)。
652
657
  * 包一层对象是因为 promise 会被 await 摊平。发不出去就抛 —— 没人看得见的卡不该被当成「人拒绝了」。 */
653
658
  export const requestConfirm = async ({ client, principal, who, title, body, timeoutMs }) => {
654
- if (!client.isConnected)
659
+ if (!canReach(client, principal))
655
660
  throw new Error("ws_disconnected");
656
661
  const { reqId, promise } = createPending({ meta: { kind: "generic", createdAt: Date.now(), chatKey: principal }, timeoutMs });
657
662
  const c = { who, title, body };
@@ -775,7 +780,7 @@ const handleExitPlanMode = async ({ cfg, log, client, body, getMirrorTarget, flu
775
780
  const approver = resolveApprover(cfg, body.session_id, getMirrorTarget);
776
781
  if (!approver)
777
782
  return { decision: "ask", reason: "no_approver" };
778
- if (!client.isConnected)
783
+ if (!canReach(client, approver))
779
784
  return { decision: "ask", reason: "ws_disconnected" };
780
785
  const longPollMs = cfg.approval.longPollSec * 1000;
781
786
  const { reqId, promise } = createPending({
@@ -848,6 +853,9 @@ const handleExitPlanMode = async ({ cfg, log, client, body, getMirrorTarget, flu
848
853
  }
849
854
  return { decision: "ask", reason: "plan_unknown" };
850
855
  };
856
+ // Claude Code 多选答案的编码 (逆向自其 AskUserQuestion 的结果校验): ", " 连接,
857
+ // 含 ", " 或 `"` 的 label 用 JSON 字符串包起来, 否则它拆不回原 label。
858
+ const encodeAskqLabels = (labels) => labels.map((l) => (l.includes(", ") || l.includes('"') ? JSON.stringify(l) : l)).join(", ");
851
859
  // raw 是 click 事件 listener 塞回 pending 的字符串编码 (cli / chat / picked:i,j)。
852
860
  export const interpretAskqRaw = (raw, q) => {
853
861
  if (raw === "cli")
@@ -861,7 +869,7 @@ export const interpretAskqRaw = (raw, q) => {
861
869
  .filter((n) => Number.isInteger(n) && n >= 0 && n < q.options.length);
862
870
  if (idxs.length === 0)
863
871
  return { kind: "empty" };
864
- return { kind: "picked", labels: idxs.map((i) => q.options[i].label).join(", ") };
872
+ return { kind: "picked", labels: encodeAskqLabels(idxs.map((i) => q.options[i].label)) };
865
873
  }
866
874
  return { kind: "empty" };
867
875
  };
@@ -920,7 +928,7 @@ export const runMirrorAskqFlow = async ({ cfg, log, client, sessionId, chatKey,
920
928
  return;
921
929
  if (questions.some((q) => q.options.length === 0))
922
930
  return;
923
- if (!client.isConnected)
931
+ if (!canReach(client, chatKey))
924
932
  return;
925
933
  const target = targetChatId(chatKey);
926
934
  const total = questions.length;
@@ -1069,7 +1077,7 @@ export const mootMirrorPlan = (sessionId) => {
1069
1077
  mirrorPlanSlots.delete(sessionId);
1070
1078
  };
1071
1079
  export const runMirrorPlanFlow = async ({ cfg, log, client, sessionId, chatKey, cwd, jsonlPath, voteTimeoutMs, sendKey }) => {
1072
- if (!client.isConnected)
1080
+ if (!canReach(client, chatKey))
1073
1081
  return;
1074
1082
  const target = targetChatId(chatKey);
1075
1083
  const note = async (content) => {
@@ -1186,7 +1194,7 @@ const consumePickerAllow = (sessionId, toolName) => {
1186
1194
  };
1187
1195
  /** 返回「卡是否真的发出去了」— false (WS 断 / 发送失败) 时调用方应允许对同屏重试。 */
1188
1196
  export const runMirrorPickerFlow = async ({ log, client, cfg, sessionId, chatKey, cwd, jsonlPath, toolName, toolInput, agent, danger, voteTimeoutMs, press }) => {
1189
- if (!client.isConnected)
1197
+ if (!canReach(client, chatKey))
1190
1198
  return false;
1191
1199
  const target = targetChatId(chatKey);
1192
1200
  const note = async (content) => {
@@ -1293,7 +1301,7 @@ export const runMirrorPickerFlow = async ({ log, client, cfg, sessionId, chatKey
1293
1301
  }
1294
1302
  };
1295
1303
  // 多问题: 逐题顺序发卡 → 收答 → 下一题。任一题选「CLI」整体转 CLI,选「聊聊」
1296
- // 整体转讨论; 全部答完合并成单个 deny+reason 注入。卡不会一次性轰炸 N 张。
1304
+ // 整体转讨论; 全部答完合并成一份 answers 随 allow 的 updatedInput 交回。卡不会一次性轰炸 N 张。
1297
1305
  const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget, flushBeforeCard, clientGone }) => {
1298
1306
  // ── codebuddy 镜像流去重 ────────────────────────────────────────────────
1299
1307
  // codebuddy 的 hook 只在本地面板被提交后到达。mirror 早已发过 vote 卡:
@@ -1319,12 +1327,12 @@ const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget,
1319
1327
  const approver = resolveApprover(cfg, body.session_id, getMirrorTarget);
1320
1328
  if (!approver)
1321
1329
  return { decision: "ask", reason: "no_approver" };
1322
- if (!client.isConnected)
1330
+ if (!canReach(client, approver))
1323
1331
  return { decision: "ask", reason: "ws_disconnected" };
1324
1332
  const target = targetChatId(approver);
1325
1333
  const longPollMs = cfg.approval.longPollSec * 1000;
1326
1334
  const total = questions.length;
1327
- const answers = [];
1335
+ const answers = {};
1328
1336
  const flowStart = Date.now();
1329
1337
  for (let i = 0; i < total; i++) {
1330
1338
  const q = questions[i];
@@ -1415,12 +1423,13 @@ const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget,
1415
1423
  }
1416
1424
  if (ans.kind === "empty")
1417
1425
  return { decision: "ask", reason: "askq_empty_pick" };
1418
- answers.push(`"${q.header || q.question}": ${ans.labels}`);
1426
+ answers[q.question] = ans.labels; // 键是题目原文, Claude Code 按它对答案
1419
1427
  }
1420
- const reason = total === 1
1421
- ? `User answered ${answers[0]} via WeCom`
1422
- : `User answered ${total} questions via WeCom — ${answers.join("; ")}`;
1423
- return { decision: "deny", reason };
1428
+ return {
1429
+ decision: "allow",
1430
+ reason: "answered via WeCom",
1431
+ updated_input: { ...body.tool_input, answers },
1432
+ };
1424
1433
  };
1425
1434
  const decisionToHook = (d) => (d === "deny" ? "deny" : "allow");
1426
1435
  // 「完全跳过审批」的模式字面量, 各家 CLI 不统一: bypassPermissions 是 Claude Code
@@ -1699,6 +1708,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1699
1708
  sessionId,
1700
1709
  turn: openTurnsOf(owner, sessionId)[0]?.id ?? "",
1701
1710
  toolName,
1711
+ toolInput,
1702
1712
  budget: chatPolicyOf(cfg, owner).stewardBudget,
1703
1713
  });
1704
1714
  if (reason) {
@@ -1788,7 +1798,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1788
1798
  });
1789
1799
  return;
1790
1800
  }
1791
- // AskUserQuestion 走单独的投票卡分支(deny+reason 注入答案 / ask 转 CLI)。
1801
+ // AskUserQuestion 走单独的投票卡分支(allow+updatedInput 交答案 / deny 先聊聊 / ask 转 CLI)。
1792
1802
  if (toolName === "AskUserQuestion") {
1793
1803
  // writableEnded 为 false 时的 close = 响应还没写就断线 = hook 客户端先死。
1794
1804
  // 正常完成时 json() 先置 writableEnded, close 到来后 resolve 不再发生。
@@ -1918,7 +1928,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1918
1928
  json(res, 200, fallback(cfg, "no_approver", mustCard || guardActive));
1919
1929
  return;
1920
1930
  }
1921
- if (!client.isConnected) {
1931
+ if (!canReach(client, approver)) {
1922
1932
  log.warn("ws not connected");
1923
1933
  json(res, 200, fallback(cfg, "ws_disconnected", mustCard || guardActive));
1924
1934
  return;