wezard 2.2.2 → 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 (112) hide show
  1. package/CLAUDE.md +9 -6
  2. package/README.md +5 -5
  3. package/config.example.jsonc +12 -0
  4. package/dist/cli/init.js +2 -1
  5. package/dist/cli/init.js.map +1 -1
  6. package/dist/daemon/approval.js +90 -6
  7. package/dist/daemon/approval.js.map +1 -1
  8. package/dist/daemon/chat-log.js +6 -1
  9. package/dist/daemon/chat-log.js.map +1 -1
  10. package/dist/daemon/chat-name.js +15 -0
  11. package/dist/daemon/chat-name.js.map +1 -1
  12. package/dist/daemon/config-api.js +265 -70
  13. package/dist/daemon/config-api.js.map +1 -1
  14. package/dist/daemon/danger.js +15 -0
  15. package/dist/daemon/danger.js.map +1 -1
  16. package/dist/daemon/detail.js +49 -0
  17. package/dist/daemon/detail.js.map +1 -1
  18. package/dist/daemon/handoff.js +12 -5
  19. package/dist/daemon/handoff.js.map +1 -1
  20. package/dist/daemon/idle-nudge.js +55 -0
  21. package/dist/daemon/idle-nudge.js.map +1 -0
  22. package/dist/daemon/inbound.js +19 -14
  23. package/dist/daemon/inbound.js.map +1 -1
  24. package/dist/daemon/index.js +1509 -259
  25. package/dist/daemon/index.js.map +1 -1
  26. package/dist/daemon/jobs.js +180 -32
  27. package/dist/daemon/jobs.js.map +1 -1
  28. package/dist/daemon/last-response.js +15 -0
  29. package/dist/daemon/last-response.js.map +1 -1
  30. package/dist/daemon/memory-steward.js +124 -68
  31. package/dist/daemon/memory-steward.js.map +1 -1
  32. package/dist/daemon/mirror-bridge.js +506 -124
  33. package/dist/daemon/mirror-bridge.js.map +1 -1
  34. package/dist/daemon/mirror-store.js.map +1 -1
  35. package/dist/daemon/model-select.js +102 -0
  36. package/dist/daemon/model-select.js.map +1 -1
  37. package/dist/daemon/notices.js +147 -33
  38. package/dist/daemon/notices.js.map +1 -1
  39. package/dist/daemon/peers.js +261 -124
  40. package/dist/daemon/peers.js.map +1 -1
  41. package/dist/daemon/pending-items.js +171 -0
  42. package/dist/daemon/pending-items.js.map +1 -0
  43. package/dist/daemon/receipts.js +428 -80
  44. package/dist/daemon/receipts.js.map +1 -1
  45. package/dist/daemon/route.js +133 -19
  46. package/dist/daemon/route.js.map +1 -1
  47. package/dist/daemon/spawn-tmux.js +24 -13
  48. package/dist/daemon/spawn-tmux.js.map +1 -1
  49. package/dist/daemon/steward-guard.js +32 -0
  50. package/dist/daemon/steward-guard.js.map +1 -0
  51. package/dist/daemon/task-registry.js +1 -1
  52. package/dist/daemon/task-registry.js.map +1 -1
  53. package/dist/daemon/tasks.js +12 -4
  54. package/dist/daemon/tasks.js.map +1 -1
  55. package/dist/daemon/usage.js +4 -11
  56. package/dist/daemon/usage.js.map +1 -1
  57. package/dist/daemon/wizard-memory.js +36 -4
  58. package/dist/daemon/wizard-memory.js.map +1 -1
  59. package/dist/daemon/wizard.js +174 -87
  60. package/dist/daemon/wizard.js.map +1 -1
  61. package/dist/mcp/server.js +209 -132
  62. package/dist/mcp/server.js.map +1 -1
  63. package/dist/shared/charter-view.js +35 -0
  64. package/dist/shared/charter-view.js.map +1 -0
  65. package/dist/shared/chat-http.js +134 -32
  66. package/dist/shared/chat-http.js.map +1 -1
  67. package/dist/shared/chat-render.js +6 -3
  68. package/dist/shared/chat-render.js.map +1 -1
  69. package/dist/shared/chat-view.js +17 -6
  70. package/dist/shared/chat-view.js.map +1 -1
  71. package/dist/shared/cli-backends.js +2 -0
  72. package/dist/shared/cli-backends.js.map +1 -1
  73. package/dist/shared/config-meta.js +91 -0
  74. package/dist/shared/config-meta.js.map +1 -0
  75. package/dist/shared/config-writer.js +5 -6
  76. package/dist/shared/config-writer.js.map +1 -1
  77. package/dist/shared/config.js +273 -294
  78. package/dist/shared/config.js.map +1 -1
  79. package/dist/shared/daemon-token.js +18 -0
  80. package/dist/shared/daemon-token.js.map +1 -0
  81. package/dist/shared/detail-render.js +233 -20
  82. package/dist/shared/detail-render.js.map +1 -1
  83. package/dist/shared/detail-store.js +33 -12
  84. package/dist/shared/detail-store.js.map +1 -1
  85. package/dist/shared/effort.js +11 -0
  86. package/dist/shared/effort.js.map +1 -0
  87. package/dist/shared/keepalive.js +5 -5
  88. package/dist/shared/keepalive.js.map +1 -1
  89. package/dist/shared/model-prices.js +258 -0
  90. package/dist/shared/model-prices.js.map +1 -0
  91. package/dist/shared/pricing.js +55 -0
  92. package/dist/shared/pricing.js.map +1 -0
  93. package/dist/shared/reminder.js +18 -4
  94. package/dist/shared/reminder.js.map +1 -1
  95. package/dist/shared/role-render.js +210 -8
  96. package/dist/shared/role-render.js.map +1 -1
  97. package/dist/shared/role-view.js +104 -26
  98. package/dist/shared/role-view.js.map +1 -1
  99. package/dist/shared/session-label.js +4 -0
  100. package/dist/shared/session-label.js.map +1 -1
  101. package/dist/shared/task-file.js +16 -1
  102. package/dist/shared/task-file.js.map +1 -1
  103. package/dist/shared/turn-state.js +24 -0
  104. package/dist/shared/turn-state.js.map +1 -0
  105. package/dist/shared/world.js +116 -13
  106. package/dist/shared/world.js.map +1 -1
  107. package/dist/svr/index.js +2 -1
  108. package/dist/svr/index.js.map +1 -1
  109. package/hooks/pre-tool-use.sh +15 -3
  110. package/package.json +1 -1
  111. package/web/chat.css +226 -74
  112. package/web/chat.js +1075 -370
@@ -1,227 +1,195 @@
1
1
  // Declarative config: schema (zod) + loader. Pure transforms, file IO at boundary.
2
+ // `.describe()` on a field is the prose config_get shows a model — keep it one
3
+ // sentence of "what"; the "why" stays in source comments. `knob` (config-meta.ts)
4
+ // marks who may change a subtree (`gate`) and whether a change bites without a
5
+ // reload (`apply`); both are inherited downward.
2
6
  import { readFileSync, existsSync } from "node:fs";
3
7
  import { parse as parseJsonc } from "jsonc-parser";
4
8
  import { z } from "zod";
5
9
  import { expandHome } from "./paths.js";
6
10
  import { resolveCliBackend } from "./cli-backends.js";
11
+ import { EFFORTS } from "./effort.js";
12
+ import { knob } from "./config-meta.js";
13
+ // 守护进程每次用时都现读 `cfg.x` 的项 —— 逐个核实过, 没核实的一律留默认 reload。
14
+ const hot = (s) => knob(s, { apply: "hot" });
15
+ const CLI_NAMES = ["claude", "claude-internal", "codebuddy"];
16
+ const LOG_LEVELS = ["trace", "debug", "info", "warn", "error"];
7
17
  // ── Schema ──────────────────────────────────────────────────────────
8
18
  const Bot = z.object({
9
- botId: z.string().min(1),
10
- secret: z.string().min(1),
11
- websocketUrl: z.string().url().default("wss://openws.work.weixin.qq.com"),
19
+ botId: z.string().min(1).describe("企微智能机器人 botId"),
20
+ secret: z.string().min(1).describe("企微智能机器人 secret"),
21
+ websocketUrl: z.string().url().default("wss://openws.work.weixin.qq.com").describe("企微长连接地址"),
12
22
  });
13
23
  const Daemon = z.object({
14
- host: z.string().default("127.0.0.1"),
15
- port: z.number().int().min(1).max(65535).default(17890),
16
- stateDir: z.string().default("~/.wezard/state"),
17
- logFile: z.string().default("~/.wezard/daemon.log"),
18
- logLevel: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
19
- // 工具调用 / 授权详情页 URL。空则用 http://<host>:<port> (回环)。
24
+ host: knob(z.string().default("127.0.0.1").describe("守护进程 HTTP 监听地址"), { gate: "card" }),
25
+ port: knob(z.number().int().min(1).max(65535).default(17890).describe("守护进程 HTTP 监听端口"), { gate: "card" }),
26
+ stateDir: z.string().default("~/.wezard/state").describe("守护进程状态目录"),
27
+ logFile: z.string().default("~/.wezard/daemon.log").describe("守护进程日志文件"),
28
+ logLevel: z.enum(LOG_LEVELS).default("info").describe("日志级别"),
20
29
  // 想让手机 WeCom 也能点开, 需要在反向代理后填外网地址。桌面端用回环即可。
21
- detailPublicBase: z.string().default(""),
22
- // 远端 detail svr (wezard svr 起的独立服务, chat + cli 共同可达的网络里)。
23
- // 空则不转发, 详情走本机 :port/detail。非空时:
24
- // • 每次 record*() 后 fire-and-forget POST 到 <base>/d, 存到远端 store。
25
- // • buildDetailUrl 用 <base> 作为链接根 (chat 端浏览器打开 <base>/detail?id=...)。
30
+ detailPublicBase: knob(z.string().default("").describe("工具调用 / 授权详情页链接的根 URL; 空 = http://<host>:<port> 回环"), { gate: "card", apply: "hot" }),
31
+ // 每次 record*() 后 fire-and-forget POST 到 <base>/d 存到远端 store, 链接根也换成 <base>。
26
32
  // 用这条路径解决 daemon (公司内网) 和 chat 用户 (移动网络) 不同网段的场景。
27
- detailRemoteBase: z.string().default(""),
28
- detailRemoteToken: z.string().default(""),
29
- // 镜像消息里给每个 tool_use 行包成 markdown 链接, 点开本地 HTML 详情页。
30
- detailLinksInMirror: z.boolean().default(true),
33
+ detailRemoteBase: knob(z.string().default("").describe("远端 detail svr (wezard svr) 地址; 空 = 不转发, 详情走本机"), { gate: "card" }),
34
+ detailRemoteToken: knob(z.string().default("").describe("远端 detail svr 的口令"), { gate: "hidden" }),
35
+ detailLinksInMirror: hot(z.boolean().default(true).describe("镜像消息里把每个 tool_use 行包成指向详情页的链接")),
31
36
  });
32
37
  const Mirror = z.object({
33
- // Transcript root for the DEFAULT backend only — an escape hatch for a
34
- // nonstandard location. Empty → auto-derived from `defaultCli` (`claude` →
35
- // `~/.claude/projects`, `claude-internal` → `~/.claude-internal/projects`,
36
- // `codebuddy` → `~/.codebuddy/projects`). Other installed backends always use
37
- // their own built-in roots; the mirror probes all of them, so setting this
38
+ // An escape hatch for a nonstandard location. Other installed backends always
39
+ // use their own built-in roots; the mirror probes all of them, so setting this
38
40
  // does not narrow which CLIs can be mirrored.
39
- projectsDir: z.string().default(""),
40
- // Pin a specific Claude session to mirror. Empty → auto-pick latest .jsonl
41
- // under `<projectsDir>/<encoded(wrc.cwd)>/` by mtime.
42
- sessionId: z.string().default(""),
43
- // Where to push live assistant output. Empty → fall back to defaultChat.
44
- pushChat: z.string().default(""),
45
- // Cap a single push payload in BYTES (WeCom markdown caps `content` at 4096
46
- // UTF-8 bytes). Long replies are split into that many bytes per page, minus
47
- // the tag header. Shared/md-chunk measures in bytes too — CJK is 3 bytes/char.
48
- chunkBytes: z.number().int().positive().default(3800),
49
- // Mirror the user's CLI prompts (type:"user" with string content). Off by
50
- // default: WeCom-sourced inbounds get dedup'd anyway, and local CLI typing
41
+ projectsDir: z.string().default("").describe("默认 CLI 的 transcript 根目录; 空 = 按 defaultCli 自动推导"),
42
+ sessionId: z.string().default("").describe("钉死要镜像的会话 id; 空 = 取工作区下最新的 .jsonl"),
43
+ pushChat: hot(z.string().default("").describe("实时输出推往哪个聊天; 空 = defaultChat")),
44
+ // WeCom markdown caps `content` at 4096 UTF-8 bytes; shared/md-chunk measures
45
+ // in bytes too — CJK is 3 bytes/char.
46
+ chunkBytes: hot(z.number().int().positive().default(3800).describe("单条推送的字节上限, 长回复按此分页")),
47
+ // Off by default: WeCom-sourced inbounds get dedup'd anyway, and local CLI typing
51
48
  // is rare in the bot-driven flow — keeping it on mostly produced echo noise.
52
- includeUser: z.boolean().default(false),
53
- // Mirror assistant tool_use blocks (Bash/Edit/Read/...).
54
- includeTools: z.boolean().default(true),
55
- // Mirror tool_result blocks. Off by default — usually noisy.
56
- includeToolResults: z.boolean().default(false),
57
- // Truncate each tool_result body to this many chars.
58
- toolResultMaxChars: z.number().int().positive().default(400),
59
- // 工具调用气泡里 `compact` 一行的最大字符数 (`🔧 Name <compact>` / `[• compact](url)`).
49
+ includeUser: z.boolean().default(false).describe("镜像人在 CLI 里敲的 prompt"),
50
+ includeTools: z.boolean().default(true).describe("镜像 tool_use (Bash/Edit/Read…)"),
51
+ includeToolResults: z.boolean().default(false).describe("镜像 tool_result (通常很吵)"),
52
+ toolResultMaxChars: z.number().int().positive().default(400).describe("每条 tool_result 截断到多少字符"),
60
53
  // 旧值 40 太窄, 长 bash / 长 file_path 直接被截掉; 抬到 120 兼顾可读与单行。
61
- toolUseInlineMaxChars: z.number().int().positive().default(120),
62
- // Where inbound images/files from WeCom get saved before being pasted into
63
- // the live TTY. Files persist — claude reads them by absolute path.
64
- inboxDir: z.string().default("~/.wezard/inbox"),
65
- // Persisted mirror attachments (principal → sessionId/jsonl/tmux). Restored
66
- // on daemon boot + lazily on first inbound after reload — so reloading the
67
- // daemon doesn't re-spawn a fresh claude for an already-bound chat.
68
- attachmentsFile: z.string().default("~/.wezard/mirror-attachments.json"),
69
- // Wizard registry: 每个「绑定聊天的会话」的名字 / 职责 / 记忆 / 家谱。运行时
70
- // 状态 (会话生生死死), 所以落在 state 目录而不是 config.jsonc —— 与 chats 的
54
+ toolUseInlineMaxChars: z.number().int().positive().default(120).describe("工具调用气泡里 compact 一行的最大字符数"),
55
+ // Files persist — claude reads them by absolute path.
56
+ inboxDir: z.string().default("~/.wezard/inbox").describe("企微发来的图片 / 文件落盘目录"),
57
+ // Restored on daemon boot + lazily on first inbound after reload — so reloading
58
+ // the daemon doesn't re-spawn a fresh claude for an already-bound chat.
59
+ attachmentsFile: z.string().default("~/.wezard/mirror-attachments.json").describe("镜像绑定 (聊天 → 会话/jsonl/tmux) 的持久化文件"),
60
+ // 运行时状态 (会话生生死死), 所以落在 state 目录而不是 config.jsonc —— 与 chats 的
71
61
  // 名字表相反: 那是人手写的长期配置。
72
- wizardsFile: z.string().default("~/.wezard/wizards.json"),
73
- // 工单账本: 一次 fan-out 的成员与状态。与 wizards 同理是运行时状态; 分开存是
74
- // 因为 wizard 是长期身份, job 是一次性的活 —— 混在一张表里, 收工清理会连身份
75
- // 一起抹掉。
76
- jobsFile: z.string().default("~/.wezard/jobs.json"),
77
- // 在飞的 tell_peer 回执登记。落盘是为了扛住 reload —— 被派活的 wizard 自己常以
78
- // reload 收尾, 纯内存的话它那一份回执就跟着旧进程没了。
79
- receiptsFile: z.string().default("~/.wezard/receipts.json"),
80
- // 在飞的交接 (handoff.ts)。同理扛 reload: 落在「杀了旧 pane、还没贴回简报」之间,
81
- // 纯内存的话新会话就空着醒来, 简报跟着旧进程没了。
82
- handoffsFile: z.string().default("~/.wezard/handoffs.json"),
83
- // 一个 wizard 名下同时活着的分身上限。不是能力上限, 是"忘了收"的刹车: 分身能
84
- // 递归生分身, 而每个都是一个 tmux pane + 一份上下文, 一次跑飞的编排足以把 fd
85
- // 吃光 (见 launchd plist 的 NumberOfFiles)。
86
- cloneMax: z.number().int().positive().default(8),
87
- // Standalone fallback 路径(liveStream 已 closed/dead/capped) 上的防抖聚合窗口
88
- // (ms)。窗口内同一 attachment 的多个 item 合并为一条 markdown, 抑制连续工具
89
- // 调用刷屏。liveStream 仍活时不受影响——直接走 typewriter。0 = 关闭。
90
- standaloneDebounceMs: z.number().int().nonnegative().default(30000),
91
- // dispatch 后延迟开 stream 的窗口 (ms)。窗口内 item 累积:
62
+ wizardsFile: z.string().default("~/.wezard/wizards.json").describe("wizard 名册 (名字/职责/记忆/家谱) 文件"),
63
+ // 与 wizards 分开存: wizard 是长期身份, job 是一次性的活 —— 混在一张表里, 收工
64
+ // 清理会连身份一起抹掉。
65
+ jobsFile: z.string().default("~/.wezard/jobs.json").describe("工单账本文件"),
66
+ // 落盘是为了扛住 reload —— 被派活的 wizard 自己常以 reload 收尾, 纯内存的话它那
67
+ // 一份回执就跟着旧进程没了。
68
+ receiptsFile: z.string().default("~/.wezard/receipts.json").describe("在飞 tell_peer 回执的登记文件"),
69
+ pendingFile: z.string().default("~/.wezard/pending.json").describe("挂起事项表: 各 wizard 派出去、还没了结的事"),
70
+ // 同理扛 reload: 落在「杀了旧 pane、还没贴回简报」之间, 纯内存的话新会话就空着
71
+ // 醒来, 简报跟着旧进程没了。
72
+ handoffsFile: z.string().default("~/.wezard/handoffs.json").describe("在飞交接 (handoff) 的登记文件"),
73
+ // 同理扛 reload: 纯内存的话 reload 之后每个 pane 头一条注入又把整段说明挂一遍。
74
+ noticeSeenFile: z.string().default("~/.wezard/notice-seen.json").describe("每个 wizard 近几次注入尾巴上挂过哪些提示 (去重用)"),
75
+ // 不是能力上限, 是"忘了收"的刹车: 分身能递归生分身, 而每个都是一个 tmux pane +
76
+ // 一份上下文, 一次跑飞的编排足以把 fd 吃光 (见 launchd plist 的 NumberOfFiles)。
77
+ cloneMax: hot(z.number().int().positive().default(8).describe("一个 wizard 名下同时活着的分身上限")),
78
+ // 派出去的活, 对方持续闲着又没交回结论 → 守护进程代发话方同件号追问一句进展 (见 daemon/idle-nudge.ts)。
79
+ // 每次巡检现读, 改完即用。
80
+ idleNudge: hot(z
81
+ .object({
82
+ afterMin: z.number().nonnegative().default(5).describe("对方持续闲置多少分钟、仍无回执才追问; 0 = 关闭"),
83
+ max: z.number().int().nonnegative().default(3).describe("同一件活最多追问几次"),
84
+ })
85
+ .default({})
86
+ .describe("派活后对方闲置无回执时的自动追问")),
87
+ // liveStream 仍活时不受影响——直接走 typewriter。
88
+ standaloneDebounceMs: hot(z.number().int().nonnegative().default(30000).describe("非流式推送的防抖聚合窗口 (ms), 窗口内多条合并; 0 = 关闭")),
89
+ // 窗口内 item 累积:
92
90
  // • 出现 needs-approval tool_use → 立刻把 buffer 聚合成单条 standalone 推出 (赶在
93
91
  // 授权卡之前), 切到 AWAITING_APPR 等点击; 点击后再开 stream 续 tool_result+回复。
94
92
  // • 窗口内 turn_end (纯文本快回复) → buffer 整体作为一条 standalone, 不开 stream。
95
93
  // • 窗口超时 → 正常开 stream, 重放 buffer。
96
- // 0 = 关闭, 退回到 dispatch 立即 ack "…" 的旧行为。
97
- outboundDeferMs: z.number().int().nonnegative().default(3000),
98
- // 发卡前等待目标 tool_use 在 jsonl 落盘的最长 poll 时间 (ms)。Claude Code 的
99
- // assistant 行写盘相对 PreToolUse hook fire 有 tens-to-hundreds ms 异步抖动,
100
- // 没等到的话 drain 抓空, 卡先到、思考过程后到。轮询步进 50ms。0 = 关闭等待,
101
- // 仅做一次同步 drain (旧行为)。
102
- flushBeforeCardWaitMs: z.number().int().nonnegative().default(800),
94
+ outboundDeferMs: hot(z.number().int().nonnegative().default(3000).describe("派发后延迟开流的窗口 (ms); 0 = 立即 ack")),
95
+ // Claude Code 的 assistant 行写盘相对 PreToolUse hook fire 有 tens-to-hundreds ms 异步
96
+ // 抖动, 没等到的话 drain 抓空, 卡先到、思考过程后到。轮询步进 50ms。
97
+ flushBeforeCardWaitMs: hot(z.number().int().nonnegative().default(800).describe("发卡前等目标 tool_use 落盘的最长时间 (ms); 0 = 不等")),
103
98
  // codebuddy 对 AskUserQuestion 不在提问时触发 PreToolUse hook (先弹本地面板),
104
99
  // mirror 从 jsonl 提前探测到 function_call 后直接下发 vote 卡。该卡与本地面板
105
100
  // 竞争 — 面板本就无限期阻塞 turn, 卡对齐 longPollSec 的 12h; 本地先答会作废它。
106
- askqVoteTimeoutSec: z.number().int().positive().default(43200),
107
- // 发卡前从活着的 tmux pane 抠出「为什么发这张卡」的 assistant 前言并先推一条。
101
+ askqVoteTimeoutSec: hot(z.number().int().positive().default(43200).describe("codebuddy 提问卡的等待上限 (s)")),
108
102
  // 必要性: Claude Code 把以 tool_use 收尾的整个 turn 攒着, 等工具 resolve 才 flush
109
103
  // 到 jsonl —— 而工具正卡在这张授权卡上, 于是前言在发卡时点既不在 jsonl 也不在
110
104
  // hook 的 transcript_tail 里, 唯一存在处是 pane。抠不到时静默退回「只发卡」(无回归)。
111
- panePreamble: z.boolean().default(true),
112
- // Brief 模式: 只对 mirror 模式生效。一个 turn 里群里只发两条 —
113
- // 1. turn 起始时:「🧵 [本轮详情](url)」链接 (指向聚合详情页,含所有 tool 输入/输出+中间文本+折叠展示)
114
- // 2. turn 结束时: Claude 的 finish text (最终 assistant 回复)
115
- // 中间 tool_use / tool_result / thinking / 非 final text 全部只写进详情页,不发气泡。
116
- // 授权卡照常发群 (交互无法替代)。false = 现状 (逐条气泡)。
117
- brief: z.boolean().default(true),
118
- // 出处门: 群里只留"需要在群里发生的事"。人在 CLI 手敲的那一轮 (以及它触发的
119
- // 斜杠命令回执) 不再下发气泡 —— 镜像改为发生在他眼前的终端 + chat 详情页
120
- // (turn store 全量记录, SSE 实时刷新)。WeCom 发起的轮次、审批/提问卡、
121
- // `[mirror]` 系统提示都不受影响。每个 attachment 首次静默时发一条带链接的提示,
122
- // 免得一个从没收过气泡的会话在群里无处可点。
105
+ panePreamble: hot(z.boolean().default(true).describe("发授权卡前先从 pane 抠出 assistant 前言推一条")),
106
+ // 中间 tool_use / tool_result / thinking / 非 final text 全部只写进详情页, 不发气泡。
107
+ // 授权卡照常发群 (交互无法替代)。
108
+ brief: hot(z.boolean().default(true).describe("Brief 模式: 一轮在群里只发「本轮详情」链接 + 最终回复")),
109
+ // 人在 CLI 手敲的那一轮 (以及它触发的斜杠命令回执) 不再下发气泡 —— 镜像改为发生在
110
+ // 他眼前的终端 + chat 详情页。每个 attachment 首次静默时发一条带链接的提示。
123
111
  // 只在 brief=true 下生效: 抑制的前提是详情页兜住内容, 而 turn store 只有 brief 在写。
124
- chatOriginOnly: z.boolean().default(true),
125
- // 斜杠命令回执精简: /clear、/new 等会话边界命令的回执只发第一行 ack
126
- // ("cleared" / "created"), 不再附 💡 tip。cwd 信息一律不随回执下发, 随时
127
- // `/pwd` 可查。默认 false = ack + tip 两行气泡。
128
- slashAckFirstLine: z.boolean().default(false),
129
- // 软收口静默期 (ms)。codebuddy 后端只能说"这条消息写完了", 等这么久没有新 item
130
- // 才认定一轮结束。值越大越不容易误收 (model 思考时间长), 但用户等最终结论的延迟也
131
- // 越大。不设则按后端自动选择: codebuddy 1s (派发子 agent 期间由 openAgents guard
132
- // 拦截, 无需长静默期), claude 4s。
133
- softTurnEndMs: z.number().int().positive().optional(),
134
- // 会话 pane 上限: 每新建一个 wizard 数一次, 活着的 tmux pane 超过这个数就从 transcript 最久没动的
135
- // 收起, 直到回到上限 —— 只收 pane, 保留 store 绑定 (sessionId/jsonl), 下一条消息
136
- // 走 dead-pane `--resume` 自愈复活, 对话不丢。与 /kill 的分界线就是那行
137
- // store.drop: reap 是"睡着", kill 是"死了"。0 = 关闭。
112
+ chatOriginOnly: hot(z.boolean().default(true).describe("出处门: 只把群里发起的轮次下发到群, CLI 手敲的只进详情页")),
113
+ slashAckFirstLine: hot(z.boolean().default(false).describe("/clear、/new 等斜杠命令回执只发第一行 ack, 不附 tip")),
114
+ // 值越大越不容易误收 (model 思考时间长), 但用户等最终结论的延迟也越大。不设则按
115
+ // 后端自动: codebuddy 1s (派发子 agent 期间由 openAgents guard 拦截), claude 4s。
116
+ softTurnEndMs: hot(z.number().int().positive().optional().describe("软收口静默期 (ms): 多久没有新输出算一轮结束; 不设 = 按后端自动")),
117
+ // 只收 pane, 保留 store 绑定 (sessionId/jsonl), 下一条消息走 dead-pane `--resume` 自愈
118
+ // 复活, 对话不丢。与 /kill 的分界线就是那行 store.drop: reap 是"睡着", kill 是"死了"。
138
119
  // 名下有定时任务 / 挂着审批 / 正忙 / 人正盯着的不收, 所以实际数量可能暂时高于上限。
139
- maxPanes: z.number().int().nonnegative().default(20),
120
+ maxPanes: z.number().int().nonnegative().default(20).describe("活着的会话 pane 上限, 超出从最久没动的收起; 0 = 关闭"),
140
121
  // ── Prompt-cache keepalive ────────────────────────────────────────────
141
- // Anthropic prompt caching: cache-write costs 1.25x, cache-read 0.1x, and the
142
- // cache lives only ~5min. A pane that goes idle (agent parked waiting on a
143
- // peer, or a long background task) lets that cache expire — so the next real
144
- // turn pays a full 1.25x re-write of the entire context. keepalive injects a
145
- // tiny ping just before expiry: it re-reads the cached prefix (0.1x) and
146
- // slides the TTL forward, so the eventual real turn only writes the delta.
147
- keepalive: z
122
+ // Anthropic prompt caching: cache-write costs 1.25x (5min TTL) / 2x (1h TTL),
123
+ // cache-read 0.1x. A pane that goes idle (agent parked waiting on a peer, or a
124
+ // long background task) lets that cache expire — so the next real turn pays a
125
+ // full re-write of the entire context. keepalive injects a tiny ping just
126
+ // before expiry: it re-reads the cached prefix and slides the TTL forward, so
127
+ // the eventual real turn only writes the delta.
128
+ // Off by default: it was built for the API's 5min default, but Claude Code on a
129
+ // subscription writes 1h entries (measured here: 1490/1491 main sessions, and
130
+ // 229/230 requests 6–60min apart still hit in full), so idle gaps within the
131
+ // hour need no warmer — and every ping is a real model turn in a live pane.
132
+ // Turn it on where transcripts show 5min writes (`ephemeral_5m`, e.g. a bare API key).
133
+ // 保温 tick 每 15s 现读整节; 只有 ping 另被启动时建成的识别表用着。
134
+ // A cache idle past 10min is never warmed (1h entries need no warmer) — so in
135
+ // practice only 5min entries are.
136
+ keepalive: hot(z
148
137
  .object({
149
- // Master switch. Off → no pings, no timer overhead.
150
- enabled: z.boolean().default(true),
151
- // Prompt-cache TTL (s). Anthropic default is 300 (5min).
152
- ttlSec: z.number().int().positive().default(300),
153
- // Fire this many seconds BEFORE ttl expiry — the ping needs slack to land
154
- // and settle. Effective idle trigger = ttlSec - marginSec.
155
- marginSec: z.number().int().nonnegative().default(45),
156
- // How many pings to fire after the last REAL (non-ping) turn before
157
- // letting the cache go cold. Pings fire at the cadence (ttlSec -
158
- // marginSec = 255s) — i.e. always just before the cache would expire.
159
- // Real activity resets the count, so 6 pings = ~26min of bridging
160
- // restarts from each genuine turn. Each ping is a near-free cache-read;
161
- // a single cold-rewrite of a large context costs ~1.25x of its full
162
- // size, so a handful of pings beats letting it expire while the user
163
- // is still around.
164
- rounds: z.number().int().positive().default(6),
165
- // The ping text. Tiny (small cache-write delta) and self-describing so a
166
- // human glancing at the pane sees why it's there. The reply is swallowed —
167
- // never mirrored to chat, the detail store, or usage accounting.
168
- ping: z.string().default('keepalive — reply with just "pong", take no other action'),
169
- // Stall recovery, gated PURELY by rule — never by the model's own judgment,
170
- // and read from the transcript's structure only (never the screen): the
171
- // turn is still unfinished — it died on the CLI's synthetic API-error reply,
172
- // or a tool result was never answered — and has been quiet for the whole
173
- // idle window. Only then does the ping carry `resumePing` (a plain
174
- // "continue") INSTEAD of `ping`. Any non-"pong" reply to a ping is treated as a real turn
175
- // (un-swallowed to chat, clocks re-anchored) — so a resumed session shows.
176
- resumeOnStall: z.boolean().default(true),
177
- resumePing: z.string().default('continue'),
178
- // Fallback for the per-spawn `keepalive` override (spawn_wizard / clone_wizard /
179
- // new_claude_session's `keepalive` param). A spawn that passes the param
180
- // explicitly always wins; this only applies when it's omitted. false →
181
- // newly created wizards/clones opt OUT of pings by default — useful when
182
- // most spawns are short-lived task runners and only a few should be kept
183
- // warm, at which point those few pass `keepalive:true` explicitly.
184
- spawnDefault: z.boolean().default(true),
138
+ enabled: z.boolean().default(false).describe("prompt-cache 保温总开关; 订阅下缓存本是 1h, 默认关"),
139
+ // The warm-ping cadence follows the TTL each session's cache was actually
140
+ // written with (`cache_creation.ephemeral_1h/5m` in its transcript — 1h on a
141
+ // Claude Code subscription); this only applies when no such split is on record.
142
+ ttlSec: z.number().int().positive().default(300).describe("缓存 TTL 兜底值 (s)"),
143
+ // The ping needs slack to land and settle. Effective idle trigger = ttlSec - marginSec.
144
+ marginSec: z.number().int().nonnegative().default(45).describe("提前 TTL 到期多少秒打 ping"),
145
+ // Real activity resets the count, so bridging restarts from each genuine
146
+ // turn: 6 pings = ~26min on a 5min cache. Each ping is a
147
+ // near-free cache-read (0.1x); a single cold-rewrite of a large context
148
+ // costs 1.25x (5min) / 2x (1h) of its full size, so a handful of pings beats
149
+ // letting it expire while the user is still around.
150
+ rounds: z.number().int().positive().default(6).describe("最后一次真实轮次之后最多保温几次"),
151
+ // Tiny (small cache-write delta) and self-describing so a human glancing at
152
+ // the pane sees why it's there. The reply is swallowed — never mirrored to
153
+ // chat, the detail store, or usage accounting.
154
+ ping: knob(z.string().default('keepalive — reply with just "pong", take no other action').describe("保温 ping 的文本"), { apply: "reload" }),
155
+ // A spawn that passes the param explicitly always wins. false → useful when
156
+ // most spawns are short-lived task runners and only a few should be kept warm,
157
+ // at which point those few pass `keepalive:true` explicitly.
158
+ spawnDefault: z.boolean().default(true).describe("spawn/clone 没传 keepalive 时, 新 wizard 是否保温"),
185
159
  })
186
- .default({}),
160
+ .default({})
161
+ .describe("prompt-cache 保温: 空闲 pane 在缓存过期前打一个小 ping")),
187
162
  });
188
163
  const Wrc = z.object({
189
- allowFrom: z.array(z.string()).default([]),
190
- // Legacy pre-v0.3: single binary path. Still honored as a back-compat alias
191
- // — resolveCliBackend picks it up when cliBackends.<name>.bin is unset. Kept
192
- // on the schema so existing configs keep parsing without migration.
193
- claudeBin: z.string().default("claude"),
194
- // DEFAULT CLI backend — the one used when there is no transcript to derive a
195
- // backend from (new-session spawns). It is NOT exclusive: the
196
- // daemon mirrors sessions from every installed CLI concurrently, resolving
197
- // each attachment's binary + jsonl dialect from its own transcript path
198
- // (shared/cli-backends.ts bindCliBackends / backendForPath). Optional —
199
- // when unset, resolveCliBackend infers from claudeBin basename (so legacy
200
- // `claudeBin: "claude-internal"` configs still resolve correctly without
201
- // needing to also set defaultCli). The Wrc transform below pins the inferred
202
- // name back into v.defaultCli so downstream readers see a concrete value.
203
- defaultCli: z.enum(["claude", "claude-internal", "codebuddy"]).optional(),
204
- // Per-backend bin overrides. Missing entry → built-in default (`claude`,
205
- // `claude-internal`, `codebuddy`). Example:
206
- // cliBackends: { codebuddy: { bin: "/usr/local/bin/codebuddy" } }
207
- cliBackends: z
164
+ allowFrom: knob(z.array(z.string()).default([]).describe("允许驱动本机的聊天 / 用户 (principal)"), { gate: "card", apply: "hot" }),
165
+ // Still honored as a back-compat alias — resolveCliBackend picks it up when
166
+ // cliBackends.<name>.bin is unset. Kept so existing configs keep parsing.
167
+ claudeBin: knob(z.string().default("claude").describe("旧版单一 CLI 二进制路径 (兼容用, 优先用 cliBackends)"), { gate: "card" }),
168
+ // NOT exclusive: the daemon mirrors sessions from every installed CLI
169
+ // concurrently, resolving each attachment's binary + jsonl dialect from its own
170
+ // transcript path (shared/cli-backends.ts bindCliBackends / backendForPath).
171
+ // When unset, resolveCliBackend infers from claudeBin basename (so legacy
172
+ // `claudeBin: "claude-internal"` configs still resolve correctly). The Wrc
173
+ // transform below pins the inferred name back into v.defaultCli.
174
+ defaultCli: z.enum(CLI_NAMES).optional().describe("新开会话默认用哪个 CLI; 不设 = 按 claudeBin 推断"),
175
+ // Example: cliBackends: { codebuddy: { bin: "/usr/local/bin/codebuddy" } }
176
+ cliBackends: knob(z
208
177
  .object({
209
- claude: z.object({ bin: z.string().optional() }).optional(),
210
- "claude-internal": z.object({ bin: z.string().optional() }).optional(),
211
- codebuddy: z.object({ bin: z.string().optional() }).optional(),
178
+ claude: z.object({ bin: z.string().optional().describe("二进制路径") }).optional(),
179
+ "claude-internal": z.object({ bin: z.string().optional().describe("二进制路径") }).optional(),
180
+ codebuddy: z.object({ bin: z.string().optional().describe("二进制路径") }).optional(),
212
181
  })
213
- .default({}),
214
- cwd: z.string().default("~/.wezard/workspace"),
215
- extraArgs: z.array(z.string()).default([]),
216
- mirror: Mirror.default({}),
217
- // tmux session name prefix for auto-spawn. Final name is
218
- // `${prefix}-<short>`. Auto-spawn fires when an authorized inbound finds no
219
- // mirror attached for that chat — allowFrom IS the authorization.
220
- tmuxPrefix: z.string().default("wezard"),
221
- // 机器人在 WeCom 里的显示名 —— 由建机器人的人自己取, 我们无法从消息里读到。
222
- // 群里 @ 它 / 引用它的气泡时, WeCom 会把 `@<显示名>` 塞进正文; 这里配上真名
223
- // (可多个别名) 才能把它从 prompt 里剥干净。内置 wezard / weclaude 恒生效。
224
- botNames: z.array(z.string()).default([]),
182
+ .default({})
183
+ .describe("各 CLI 后端的二进制路径覆盖; 缺省 = 内置默认"), { gate: "card" }),
184
+ cwd: z.string().default("~/.wezard/workspace").describe("新会话的默认工作区"),
185
+ extraArgs: knob(z.array(z.string()).default([]).describe("启动 CLI 时追加的参数"), { gate: "card", apply: "hot" }),
186
+ mirror: Mirror.default({}).describe("会话镜像: 推送、分页、详情页、pane 上限、保温"),
187
+ // Auto-spawn fires when an authorized inbound finds no mirror attached for that
188
+ // chat — allowFrom IS the authorization.
189
+ tmuxPrefix: z.string().default("wezard").describe("自动起的 tmux 会话名前缀 (`<prefix>-<short>`)"),
190
+ // 机器人显示名由建机器人的人自己取, 我们无法从消息里读到。群里 @ 它 / 引用它的
191
+ // 气泡时, WeCom 会把 `@<显示名>` 塞进正文; 配上真名才能把它从 prompt 里剥干净。
192
+ botNames: z.array(z.string()).default([]).describe("机器人在企微里的显示名 (可多个别名), 用于剥掉 @; 内置 wezard / weclaude 恒生效"),
225
193
  }).transform((v) => {
226
194
  // Resolve the active backend once — honors defaultCli if set, otherwise
227
195
  // infers from claudeBin basename (legacy back-compat). Pin the inferred
@@ -250,107 +218,106 @@ const Wrc = z.object({
250
218
  // 不吃 session cache、不参与批量合流、卡上没有「全过」按钮、超时不静默放行。
251
219
  // patterns 都是 JS 正则源码串, 大小写不敏感; allowPatterns 优先级最高。
252
220
  const Danger = z.object({
253
- enabled: z.boolean().default(true),
254
- // true = 命中危险名单也直接放行 (显式的「跳过 danger」开关)。相当于对危险操作
255
- // 完全免卡 —— 与 enabled=false 的区别: 名单仍会计算 (日志/redact 用得到),
256
- // 只是不再拦。默认 false。
257
- skip: z.boolean().default(false),
258
- // true = 跳过所有审批: 命中 matcher 的调用一律静默放行, 压过危险名单 / askRules /
259
- // ⏱窗口 / 会话缓存 —— 比 skip 更彻底 (skip 只豁免命中名单的调用, 普通调用照审)。
260
- // denyRules 与 EnterPlanMode 拦截仍生效 (拒绝不是审批); AskUserQuestion /
261
- // ExitPlanMode 交互卡不受影响。默认 false。
262
- skipAll: z.boolean().default(false),
263
- // false = 丢掉内置名单, 只用下面三组自定义规则。
264
- builtin: z.boolean().default(true),
265
- commandPatterns: z.array(z.string()).default([]),
266
- toolPatterns: z.array(z.string()).default([]),
267
- pathPatterns: z.array(z.string()).default([]),
268
- allowPatterns: z.array(z.string()).default([]),
221
+ enabled: z.boolean().default(true).describe("危险名单总开关"),
222
+ // 与 enabled=false 的区别: 名单仍会计算 (日志/redact 用得到), 只是不再拦。
223
+ skip: z.boolean().default(false).describe("命中危险名单也直接放行 (只豁免命中名单的调用)"),
224
+ // 压过危险名单 / askRules / ⏱窗口 / 会话缓存 —— 比 skip 更彻底 (skip 只豁免命中名单
225
+ // 的调用, 普通调用照审)。denyRules 与 EnterPlanMode 拦截仍生效 (拒绝不是审批);
226
+ // AskUserQuestion / ExitPlanMode 交互卡不受影响。
227
+ skipAll: z.boolean().default(false).describe("跳过所有审批: 命中 matcher 的调用一律静默放行"),
228
+ builtin: z.boolean().default(true).describe("用内置危险名单; false = 只用下面的自定义规则"),
229
+ commandPatterns: z.array(z.string()).default([]).describe("危险 Bash 命令正则 (大小写不敏感)"),
230
+ toolPatterns: z.array(z.string()).default([]).describe("危险工具名正则"),
231
+ pathPatterns: z.array(z.string()).default([]).describe("危险路径正则"),
232
+ allowPatterns: z.array(z.string()).default([]).describe("豁免正则, 优先级最高"),
269
233
  });
270
234
  const Approval = z.object({
271
- enabled: z.boolean().default(true),
272
- // 审批粒度:
273
- // "all" — 每个命中 matcher 的工具调用都发卡 (默认, 现状)。
274
- // "danger" — 只有命中危险名单 (approval.danger) 的调用才发卡, 其余静默 allow。
275
- // 名单被关掉 (danger.enabled=false) 时该模式自动退回 "all" —— 否则
276
- // 就成了「全放行」, 与 danger.skipAll 语义重合且更隐蔽。
277
- // (跳过所有审批不在 mode 里 —— 用 danger.skipAll, 与 danger.skip 成对。)
278
- mode: z.enum(["all", "danger"]).default("all"),
279
- matcher: z.string().default(".*"),
280
- approvers: z.array(z.string()).default([]),
281
- hookTimeoutSec: z.number().int().positive().default(43210),
282
- longPollSec: z.number().int().positive().default(43200),
283
- sessionCacheMinutes: z.number().int().nonnegative().default(30),
284
- windowMinutes: z.number().int().nonnegative().default(600),
285
- sensitiveArgRedact: z.boolean().default(true),
286
- fallbackOnError: z.enum(["ask", "allow", "deny"]).default("ask"),
287
- // 同 session 同 tool 的并发 PreToolUse 合流窗口: 第一次到达后等待这么久,
288
- // 期间到的同类请求合成一张批量卡 (减少 N 张并发卡的轰炸)。0 = 关闭聚合,
289
- // 每次立刻发卡 (旧行为)。单次到达走单卡路径, 仅多了一次性的延迟。
290
- batchCoalesceMs: z.number().int().nonnegative().default(250),
291
- // 拦截 model 主动调用的 EnterPlanMode (deny + reason),让 Claude 不要自动进
292
- // plan mode、直接干活。用户仍可在本地 Shift+Tab 手动进 plan mode(那条路径
293
- // 不过 hook)。默认 true。设 false 恢复原行为(允许模型自动进 plan mode)。
294
- blockAutoPlanMode: z.boolean().default(true),
295
- danger: Danger.default({}),
296
- // Claude-Code 风格的放行规则 (语法子集见 shared/allow-rules.ts): 命中的调用
297
- // 跳过发卡直接 allow。例:
298
- // "Bash(git log *)" "Bash(python3 .claude/skills/*)" "mcp__server__tool"
299
- // matcher 决定哪些工具进入审批, allowRules 在其中再挖细粒度豁免 (对 Bash 可
300
- // 按命令前缀区分, matcher 只认工具名做不到)。默认空 = 不豁免。
301
- allowRules: z.array(z.string()).default([]),
302
- // 同语法的拒绝规则: 命中直接 deny (不发卡, reason 回传给 model)。
235
+ enabled: z.boolean().default(true).describe("审批总开关"),
236
+ // 名单被关掉 (danger.enabled=false) 时 "danger" 自动退回 "all" —— 否则就成了「全
237
+ // 放行」, 与 danger.skipAll 语义重合且更隐蔽。跳过所有审批不在 mode 里 —— 用
238
+ // danger.skipAll, 与 danger.skip 成对。
239
+ mode: z.enum(["all", "danger"]).default("all").describe("审批粒度: all = 每个命中 matcher 的调用都发卡; danger = 只有命中危险名单的才发卡"),
240
+ matcher: z.string().default(".*").describe("哪些工具名进入审批 (正则)"),
241
+ approvers: z.array(z.string()).default([]).describe("可以点审批卡的人; 空 = 不限"),
242
+ // 必须严格大于 longPollSec, 否则 daemon 还在等点击时 hook 已经返回 ask。
243
+ hookTimeoutSec: knob(z.number().int().positive().default(43210).describe("hook curl 的超时 (s), 须大于 longPollSec; 由 `wezard sync` 写进 hook 环境"), { apply: "reload" }),
244
+ longPollSec: z.number().int().positive().default(43200).describe("审批卡等待点击的上限 (s)"),
245
+ sessionCacheMinutes: z.number().int().nonnegative().default(30).describe("同一会话重复调用沿用上次决定的分钟数"),
246
+ windowMinutes: z.number().int().nonnegative().default(600).describe("点「⏱ 窗口」后该会话自动放行的分钟数"),
247
+ sensitiveArgRedact: z.boolean().default(true).describe("发卡前脱敏参数里的敏感值"),
248
+ fallbackOnError: z.enum(["ask", "allow", "deny"]).default("ask").describe("审批链路出错时 hook 的回答"),
249
+ // 单次到达走单卡路径, 仅多了一次性的延迟。
250
+ batchCoalesceMs: z.number().int().nonnegative().default(250).describe("同会话同工具并发调用合成一张批量卡的等待窗口 (ms); 0 = 关闭"),
251
+ // 用户仍可在本地 Shift+Tab 手动进 plan mode (那条路径不过 hook)。
252
+ blockAutoPlanMode: z.boolean().default(true).describe("拦截模型主动调用的 EnterPlanMode"),
253
+ danger: Danger.default({}).describe("危险操作名单: 命中者强制单次审批"),
254
+ // 语法子集见 shared/allow-rules.ts。matcher 决定哪些工具进入审批, allowRules 在其中
255
+ // 再挖细粒度豁免 (对 Bash 可按命令前缀区分, matcher 只认工具名做不到)。
256
+ allowRules: z.array(z.string()).default([]).describe("Claude Code 风格的放行规则, 如 \"Bash(git log *)\""),
303
257
  // Bash 复合命令任一段命中即拒。优先级最高: deny > ask > allow。
304
- denyRules: z.array(z.string()).default([]),
305
- // 同语法的强制审批规则: 命中必发卡, 压过 allowRules / 自动放行窗口 / 会话缓存。
258
+ denyRules: z.array(z.string()).default([]).describe("同语法的拒绝规则: 命中直接 deny"),
306
259
  // 用于给危险前缀 (如 "Bash(rm *)", "Bash(git push *)") 兜底 — 即使 ⏱ 窗口开着
307
260
  // 也逐条确认, 语义对齐 Claude Code 的 permissions.ask。
308
- askRules: z.array(z.string()).default([]),
309
- // `.claude/**` 写守卫 (见 shared/claude-config-path.ts): 这类改动会触发 Claude Code
310
- // 自己的原生确认框, 而那个框**不经过 PreToolUse hook** —— 规则一放行就是"不发卡 +
311
- // pane 无限期阻塞"的静默死锁。开启后: 命中的调用必发卡 (压过 allowRules / ⏱窗口 /
312
- // 会话缓存), 用户点「允许」后 daemon 去 pane 上把那个框按掉 (只按一次性 Yes);
313
- // 按不掉则 Esc 取消并把原因注入会话, 让模型改走实体路径。
261
+ askRules: z.array(z.string()).default([]).describe("同语法的强制审批规则: 命中必发卡"),
262
+ // 这类改动会触发 Claude Code 自己的原生确认框, 而那个框**不经过 PreToolUse hook** ——
263
+ // 规则一放行就是"不发卡 + pane 无限期阻塞"的静默死锁。开启后命中的调用必发卡, 用户
264
+ // 点「允许」后 daemon 去 pane 上把那个框按掉; 按不掉则 Esc 取消并把原因注入会话。
314
265
  // 仅对有活 tmux pane 的镜像会话生效 —— 本地会话用户自己按掉即可。
315
- claudeConfigGuard: z.boolean().default(true),
316
- // 批准后等原生确认框出现的最长时间 (ms)。CC 在 hook 返回后才渲染它, 轮询步进 200ms。
317
- // 太短会误判成 no_modal (框随后才出现, 于是没人按, 退回死锁), 4s 覆盖实测抖动。
318
- claudeConfigModalWaitMs: z.number().int().nonnegative().default(4000),
319
- // 审批卡 quote_area 里命令/参数体的最大字符数。WeCom 未公开该字段上限, 发送
320
- // 失败会自动缩到 600 重试一次 (见 approval.ts), 所以可以放心调大。
321
- // 注意手机端客户端只渲染 quote 区前 2~3 行 (实测), 看全命令靠「展开完整命令」。
322
- cardQuoteMaxChars: z.number().int().positive().default(1200),
323
- // Bash 命令超过 ~200 字 (卡片手机端可见极限) 时, 发卡前**无条件**先推一条含完整
324
- // 命令的 markdown 消息。默认 0 = 关闭 —— 长命令由卡片自己承载: 引用区可点进详情
325
- // 页, 右上角「⋯」菜单有「📄 展开完整命令」按需在群里发全文。设成正数可恢复旧
326
- // 行为 (客户端不渲染 action_menu、或就是要全文无条件落在群里时用)。
327
- // 此值为前置消息的总字数上限 (超出截断并注明; 按 1800 字/条分块发送)。
328
- fullCommandPreludeChars: z.number().int().nonnegative().default(0),
266
+ claudeConfigGuard: z.boolean().default(true).describe("`.claude/**` 写守卫: 必发卡, 批准后替人按掉 CC 原生确认框"),
267
+ // CC 在 hook 返回后才渲染它, 轮询步进 200ms。太短会误判成 no_modal (框随后才
268
+ // 出现, 于是没人按, 退回死锁), 4s 覆盖实测抖动。
269
+ claudeConfigModalWaitMs: z.number().int().nonnegative().default(4000).describe("批准后等 CC 原生确认框出现的最长时间 (ms)"),
270
+ // WeCom 未公开该字段上限, 发送失败会自动缩到 600 重试一次 (见 approval.ts), 所以
271
+ // 可以放心调大。手机端只渲染 quote 区前 2~3 行, 看全命令靠「展开完整命令」。
272
+ cardQuoteMaxChars: knob(z.number().int().positive().default(1200).describe("审批卡引用区命令/参数体的最大字符数"), { apply: "reload" }),
273
+ // 默认关闭 —— 长命令由卡片自己承载: 引用区可点进详情页, 「⋯」菜单有「📄 展开完整
274
+ // 命令」按需发全文。客户端不渲染 action_menu、或就是要全文落在群里时设成正数。
275
+ fullCommandPreludeChars: z.number().int().nonnegative().default(0).describe("长 Bash 命令发卡前先推全文的字数上限; 0 = 关闭"),
329
276
  });
330
277
  // sync.targets[].kind 的合法值。claude-internal / custom 等旧值自动 collapse
331
278
  // 为 "claude" (preprocess) — settingsPath 已经表达了具体 fork, kind 只表达家族。
332
279
  const SYNC_KIND_LEGACY_TO_CLAUDE = new Set(["claude-internal", "custom"]);
333
280
  const normalizeSyncKind = (v) => typeof v === "string" && SYNC_KIND_LEGACY_TO_CLAUDE.has(v) ? "claude" : v;
334
281
  const SyncTarget = z.object({
335
- // CLI 家族标签: "claude" (含 claude-internal 等 fork) 或 "codebuddy"。
336
282
  // 仅用于 sync 日志; 真正决定写入位置的是 settingsPath。
337
- kind: z.preprocess(normalizeSyncKind, z.enum(["claude", "codebuddy"])).default("claude"),
338
- settingsPath: z.string(),
339
- scope: z.enum(["user", "project", "local"]).default("user"),
283
+ kind: z.preprocess(normalizeSyncKind, z.enum(["claude", "codebuddy"])).default("claude").describe("CLI 家族标签"),
284
+ settingsPath: z.string().describe("要写入 MCP / hook 注册的 settings.json 路径"),
285
+ scope: z.enum(["user", "project", "local"]).default("user").describe("settings 的作用域"),
340
286
  });
341
287
  const Sync = z.object({
342
- targets: z.array(SyncTarget).default([]),
288
+ targets: z.array(SyncTarget).default([]).describe("`wezard sync` 要写入的 settings.json 列表"),
343
289
  });
344
- // 独立 detail 中转服务 (wezard svr) 的默认参数。CLI 参数 (--host/--port/…) 仍可覆盖,
345
- // 空则退回硬编码默认 (0.0.0.0:17891)。放在 top-level 而不是塞进 daemon: svr 是独立进程,
346
- // 与 daemon 生命周期解耦。
290
+ // 放在 top-level 而不是塞进 daemon: svr 是独立进程, 与 daemon 生命周期解耦。CLI 参数
291
+ // (--host/--port/…) 仍可覆盖, 空则退回硬编码默认 (0.0.0.0:17891)。
347
292
  const Svr = z.object({
348
- host: z.string().default("0.0.0.0"),
349
- port: z.number().int().min(1).max(65535).default(17891),
350
- stateDir: z.string().default("~/.wezard/svr"),
351
- tokenFile: z.string().default("~/.wezard/svr-token"),
352
- token: z.string().default(""),
353
- logLevel: z.enum(["trace", "debug", "info", "warn", "error"]).default("info"),
293
+ host: knob(z.string().default("0.0.0.0").describe("svr 监听地址"), { gate: "card" }),
294
+ port: knob(z.number().int().min(1).max(65535).default(17891).describe("svr 监听端口"), { gate: "card" }),
295
+ stateDir: z.string().default("~/.wezard/svr").describe("svr 状态目录"),
296
+ tokenFile: knob(z.string().default("~/.wezard/svr-token").describe("svr 口令文件"), { gate: "hidden" }),
297
+ token: knob(z.string().default("").describe("svr 口令"), { gate: "hidden" }),
298
+ logLevel: z.enum(LOG_LEVELS).default("info").describe("svr 日志级别"),
299
+ });
300
+ // 按难度分档: spawn_wizard / clone_wizard 的 `tier` 落到这里的 {cli, model, effort};
301
+ // 显式给的 model / effort / cli 压过档位。档名固定、按由轻到重排 —— MCP 参数是 enum, 宪章逐档列。
302
+ const Tier = z.object({
303
+ cli: z.enum(CLI_NAMES).optional().describe("用哪个 CLI; 省略 = 继承调用方 (clone 一律继承父会话)"),
304
+ model: z.string().default("").describe("口语化模型名 ('haiku' / 'sonnet 5' / 'opus'), 只写家族 = 该家族最新; 空 = CLI 默认"),
305
+ effort: z.enum(EFFORTS).optional().describe("推理档位; 省略 = CLI 默认"),
306
+ });
307
+ export const TIERS = ["mini", "light", "standard", "hard", "ultra"];
308
+ const Models = z.object({
309
+ tiers: z.object({
310
+ mini: Tier.default({ model: "haiku", effort: "low" }).describe("机械一步: 照单执行、不用判断"),
311
+ light: Tier.default({ model: "haiku", effort: "low" }).describe("跑腿: 查找、搬运、跑命令"),
312
+ standard: Tier.default({ model: "sonnet", effort: "medium" }).describe("常规实现"),
313
+ hard: Tier.default({ model: "opus", effort: "high" }).describe("要判断: 设计、排障、审查"),
314
+ ultra: Tier.default({ model: "opus", effort: "max" }).describe("最难: 架构取舍、疑难排障、关键评审"),
315
+ }).default({}).describe("spawn_wizard / clone_wizard / dispatch 的 tier 档位, 由轻到重"),
316
+ // 只在新起一个群的默认会话时落地; 已在跑的管家不自动切 (换模型会让缓存整份重读)。
317
+ router: z.object({
318
+ model: z.string().default("sonnet").describe("口语化模型名; 空 = CLI 默认"),
319
+ effort: z.enum(EFFORTS).optional().describe("推理档位; 省略 = CLI 默认"),
320
+ }).default({ model: "sonnet", effort: "low" }).describe("各群管家 (群的默认会话) 新起时用的模型"),
354
321
  });
355
322
  // 定时任务的**旧**存法 (1.5 之前)。现在一条任务是 ~/.wezard/tasks/<id>.task.mjs
356
323
  // 一份可注入代码的配置 (shared/task-file.ts) —— 触发条件可组合、放枪前可以先探一眼,
@@ -388,11 +355,16 @@ const Schedule = z.object({
388
355
  // topic 订阅废弃后它们没有收件人了 —— 认不出的记录整条丢掉而不是抛: 一条过期
389
356
  // 定时不该把 daemon 挡在启动之外, 那会让 launchd 陷进崩溃循环。
390
357
  const Schedules = z.preprocess((v) => (Array.isArray(v) ? v.filter((x) => Schedule.safeParse(x).success) : []), z.array(Schedule));
391
- // 聊天命名表: `name → base principal` (`chat:wrxxx` / `user:xxx`)。方向选 name→
392
- // principal 而不是反过来: 名字是寻址用的 key, 这个方向天然保证唯一, 手写也更顺。
393
- // 有了名字, 跨 chat 的 peer 才能写成 `daily#fix` 这种稳定地址 —— 否则只能靠
394
- // "全局唯一 tag" 碰运气。见 daemon/chat-name.ts。
395
- const Chats = z.record(z.string(), z.string());
358
+ // 方向选 name→principal 而不是反过来: 名字是寻址用的 key, 这个方向天然保证唯一, 手写
359
+ // 也更顺。有了名字, 跨 chat 的 peer 才能写成 `daily#fix` 这种稳定地址。见 daemon/chat-name.ts。
360
+ const Chats = z.record(z.string(), z.string().describe("base principal (`chat:wr…` / `user:…`)"));
361
+ // 群聊级策略, 键是 base principal (确定的聊天 id) —— 聊天名会改, 拿它当键一改名策略就悄悄失效;
362
+ // config_set 收名字, 落盘前换成它此刻指向的 principal。会进宪章 (系统提示): 已在跑的 wizard 要 handoff
363
+ // 才换上新规矩 —— config_set 落盘时按宪章前后对比点名受影响的那些。
364
+ export const ChatPolicy = z.object({
365
+ topOnly: z.boolean().default(false).describe("顶层模式: 人只和顶层 wizard (群管家 / 人 `.name` 点名的) 对话; wizard 之间一律私聊 (tell_peer / dispatch 的 public 失效), 被派活的 wizard 不能 notify 进群, 过程只在 rolepage"),
366
+ stewardBudget: z.number().int().min(-1).default(4).describe("群管家的手闸: 每轮自己查 (读 / 搜 / 跑命令) 的次数上限, 用完拒并叫它 dispatch; 改文件、开子代理一律拒。-1 = 不设闸"),
367
+ });
396
368
  // 1.4.x 之前定时表住在 `topics.schedules` (同一张表里还混着 topic 广播)。订阅与
397
369
  // 广播删掉后它升到顶层, 老 config 就地抬一手 —— 否则已排好的任务会静默消失。
398
370
  const liftLegacySchedules = (v) => {
@@ -402,16 +374,22 @@ const liftLegacySchedules = (v) => {
402
374
  const legacy = o.topics?.schedules;
403
375
  return legacy && !o.schedules ? { ...o, schedules: legacy } : v;
404
376
  };
377
+ // gate 的分界线是「放权」: 谁能驱动本机 (allowFrom)、审批怎么判 (approval.*)、跑哪个
378
+ // 二进制带什么参数 (claudeBin / cliBackends / extraArgs —— 一个 --dangerously-skip-permissions
379
+ // 就绕过了整个审批)、往哪写别的 settings (sync)、把什么暴露到哪 (监听地址 / 详情外送 /
380
+ // 出站默认聊天) —— 这些要人点卡; 口令一律 hidden; 其余 wizard 直接改。
405
381
  export const ConfigSchema = z.preprocess(liftLegacySchedules, z.object({
406
- bot: Bot,
407
- defaultChat: z.string().default(""),
408
- chats: Chats.default({}),
409
- daemon: Daemon.default({}),
410
- wrc: Wrc.default({}),
411
- approval: Approval.default({}),
412
- sync: Sync.default({ targets: [] }),
413
- svr: Svr.default({}),
414
- schedules: Schedules,
382
+ bot: knob(Bot.describe("企微机器人凭据"), { gate: "hidden" }),
383
+ defaultChat: knob(z.string().default("").describe("出站默认聊天 (没有更具体去向时推往这里)"), { gate: "card", apply: "hot" }),
384
+ chats: hot(Chats.default({}).describe("聊天命名表: 名字 → base principal")),
385
+ chatPolicy: hot(z.record(z.string(), ChatPolicy).default({}).describe("群聊级策略: base principal (`chat:wr…` / `user:…`) → 开关; config_set 写聊天名会换成它的 principal 落盘; 进宪章, 已在跑的 wizard 要 handoff 才换上 (config_set 落盘时会点名受影响的)")),
386
+ daemon: Daemon.default({}).describe("守护进程: 监听、日志、详情页"),
387
+ wrc: Wrc.default({}).describe("远程驱动: 授权名单、CLI 后端、工作区、镜像"),
388
+ approval: knob(Approval.default({}).describe("工具调用审批: 粒度、规则、危险名单、窗口"), { gate: "card", apply: "hot" }),
389
+ models: hot(Models.default({}).describe("按任务难度分档的模型 / effort, 以及各群管家用的模型")),
390
+ sync: knob(Sync.default({ targets: [] }).describe("把 MCP / hook 注册写进各 CLI 的 settings.json; 改完跑 `wezard sync` 才生效"), { gate: "card" }),
391
+ svr: Svr.default({}).describe("独立详情中转服务 (wezard svr); svr 自己启动时读, 改完重启 svr 才生效"),
392
+ schedules: knob(Schedules, { gate: "hidden" }),
415
393
  }));
416
394
  // ── Loader ──────────────────────────────────────────────────────────
417
395
  const DEFAULT_CONFIG_PATHS = [
@@ -457,15 +435,16 @@ export const loadConfig = (explicitPath) => {
457
435
  throw new Error(`wezard config not found. Create ~/.wezard/config.jsonc (see config.example.jsonc) or set $WEZARD_CONFIG.`);
458
436
  }
459
437
  const base = (readJsoncIfExists(sourcePath) ?? {});
460
- const secrets = (readJsoncIfExists(SECRETS_PATH) ?? {});
461
- const merged = deepMerge(base, secrets);
462
- const parsed = ConfigSchema.safeParse(merged);
463
- if (!parsed.success) {
464
- const issues = parsed.error.issues
465
- .map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`)
466
- .join("\n");
467
- throw new Error(`wezard config invalid (${sourcePath}):\n${issues}`);
468
- }
438
+ const parsed = parseWithSecrets(base);
439
+ if (!parsed.success)
440
+ throw new Error(`wezard config invalid (${sourcePath}):\n${configIssues(parsed.error)}`);
469
441
  return { config: parsed.data, sourcePath: expandHome(sourcePath) };
470
442
  };
443
+ /** secrets.json 的原样内容 —— config_get / config_set 据此把来自它的路径一律藏起来。 */
444
+ export const readSecrets = () => (readJsoncIfExists(SECRETS_PATH) ?? {});
445
+ const parseWithSecrets = (base) => ConfigSchema.safeParse(deepMerge(base, readSecrets()));
446
+ /** 一份 config.jsonc 全文按 loadConfig 的同一口径 (叠 secrets、整份校验) 解析 —— 写之前先过它,
447
+ * 任何一次写入都不会产出一份让守护进程起不来的配置。 */
448
+ export const parseConfigText = (text) => parseWithSecrets((parseJsonc(text) ?? {}));
449
+ export const configIssues = (e) => e.issues.map((i) => ` - ${i.path.join(".") || "<root>"}: ${i.message}`).join("\n");
471
450
  //# sourceMappingURL=config.js.map