wezard 2.2.1 → 2.3.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 (95) hide show
  1. package/CLAUDE.md +7 -4
  2. package/README.md +1 -1
  3. package/dist/cli/init.js +2 -1
  4. package/dist/cli/init.js.map +1 -1
  5. package/dist/daemon/approval.js +6 -4
  6. package/dist/daemon/approval.js.map +1 -1
  7. package/dist/daemon/chat-log.js +6 -1
  8. package/dist/daemon/chat-log.js.map +1 -1
  9. package/dist/daemon/danger.js +17 -0
  10. package/dist/daemon/danger.js.map +1 -1
  11. package/dist/daemon/detail.js +41 -0
  12. package/dist/daemon/detail.js.map +1 -1
  13. package/dist/daemon/handoff.js +8 -2
  14. package/dist/daemon/handoff.js.map +1 -1
  15. package/dist/daemon/inbound.js +9 -4
  16. package/dist/daemon/inbound.js.map +1 -1
  17. package/dist/daemon/index.js +744 -152
  18. package/dist/daemon/index.js.map +1 -1
  19. package/dist/daemon/jobs.js +62 -23
  20. package/dist/daemon/jobs.js.map +1 -1
  21. package/dist/daemon/last-response.js +15 -0
  22. package/dist/daemon/last-response.js.map +1 -1
  23. package/dist/daemon/memory-steward.js +124 -68
  24. package/dist/daemon/memory-steward.js.map +1 -1
  25. package/dist/daemon/mirror-bridge.js +461 -91
  26. package/dist/daemon/mirror-bridge.js.map +1 -1
  27. package/dist/daemon/mirror-store.js.map +1 -1
  28. package/dist/daemon/model-select.js +99 -0
  29. package/dist/daemon/model-select.js.map +1 -1
  30. package/dist/daemon/notices.js +7 -5
  31. package/dist/daemon/notices.js.map +1 -1
  32. package/dist/daemon/peers.js +183 -60
  33. package/dist/daemon/peers.js.map +1 -1
  34. package/dist/daemon/receipts.js +391 -77
  35. package/dist/daemon/receipts.js.map +1 -1
  36. package/dist/daemon/route.js +63 -10
  37. package/dist/daemon/route.js.map +1 -1
  38. package/dist/daemon/spawn-tmux.js +24 -13
  39. package/dist/daemon/spawn-tmux.js.map +1 -1
  40. package/dist/daemon/task-registry.js +1 -1
  41. package/dist/daemon/task-registry.js.map +1 -1
  42. package/dist/daemon/tasks.js +12 -4
  43. package/dist/daemon/tasks.js.map +1 -1
  44. package/dist/daemon/usage.js +4 -11
  45. package/dist/daemon/usage.js.map +1 -1
  46. package/dist/daemon/wizard-memory.js +36 -4
  47. package/dist/daemon/wizard-memory.js.map +1 -1
  48. package/dist/daemon/wizard.js +102 -58
  49. package/dist/daemon/wizard.js.map +1 -1
  50. package/dist/mcp/server.js +138 -111
  51. package/dist/mcp/server.js.map +1 -1
  52. package/dist/shared/charter-view.js +35 -0
  53. package/dist/shared/charter-view.js.map +1 -0
  54. package/dist/shared/chat-http.js +133 -31
  55. package/dist/shared/chat-http.js.map +1 -1
  56. package/dist/shared/chat-render.js +6 -3
  57. package/dist/shared/chat-render.js.map +1 -1
  58. package/dist/shared/chat-view.js +17 -6
  59. package/dist/shared/chat-view.js.map +1 -1
  60. package/dist/shared/cli-backends.js +2 -0
  61. package/dist/shared/cli-backends.js.map +1 -1
  62. package/dist/shared/config.js +9 -5
  63. package/dist/shared/config.js.map +1 -1
  64. package/dist/shared/daemon-token.js +18 -0
  65. package/dist/shared/daemon-token.js.map +1 -0
  66. package/dist/shared/detail-render.js +140 -16
  67. package/dist/shared/detail-render.js.map +1 -1
  68. package/dist/shared/detail-store.js +33 -12
  69. package/dist/shared/detail-store.js.map +1 -1
  70. package/dist/shared/effort.js +11 -0
  71. package/dist/shared/effort.js.map +1 -0
  72. package/dist/shared/model-prices.js +258 -0
  73. package/dist/shared/model-prices.js.map +1 -0
  74. package/dist/shared/pricing.js +55 -0
  75. package/dist/shared/pricing.js.map +1 -0
  76. package/dist/shared/reminder.js +18 -4
  77. package/dist/shared/reminder.js.map +1 -1
  78. package/dist/shared/role-render.js +172 -8
  79. package/dist/shared/role-render.js.map +1 -1
  80. package/dist/shared/role-view.js +89 -25
  81. package/dist/shared/role-view.js.map +1 -1
  82. package/dist/shared/session-label.js +4 -0
  83. package/dist/shared/session-label.js.map +1 -1
  84. package/dist/shared/task-file.js +16 -1
  85. package/dist/shared/task-file.js.map +1 -1
  86. package/dist/shared/turn-state.js +24 -0
  87. package/dist/shared/turn-state.js.map +1 -0
  88. package/dist/shared/world.js +37 -3
  89. package/dist/shared/world.js.map +1 -1
  90. package/dist/svr/index.js +2 -1
  91. package/dist/svr/index.js.map +1 -1
  92. package/hooks/pre-tool-use.sh +16 -3
  93. package/package.json +1 -1
  94. package/web/chat.css +173 -41
  95. package/web/chat.js +724 -245
@@ -4,6 +4,8 @@
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
6
  import { z } from "zod";
7
+ import { DAEMON_TOKEN_HEADER, readDaemonToken } from "../shared/daemon-token.js";
8
+ import { EFFORTS } from "../shared/effort.js";
7
9
  const DAEMON_BASE = process.env.WEZARD_DAEMON_BASE ?? "http://127.0.0.1:17890";
8
10
  const ok = (data) => ({
9
11
  content: [{ type: "text", text: JSON.stringify(data) }],
@@ -40,6 +42,10 @@ server.registerTool = ((toolName, config, cb) => {
40
42
  const fold = foldLegacyAddress(!!shape.name && !shape.name.isOptional());
41
43
  return registerTool(toolName, { ...config, inputSchema: withLegacyAddress(shape) }, ((args, extra) => cb(fold(args), extra)));
42
44
  });
45
+ // 人侧 / 罕用的工具 (30 天零调用: graph 三件套、本机会话清单与改接、聊天目录) 不进默认
46
+ // 工具面 —— 每个注册的工具都占模型的上下文, 而这些要么人在 IM 里有对应命令 (`/sessions`
47
+ // `/chats`), 要么由 tell_peer + 回执覆盖。路由都还在; `WEZARD_MCP_EXTRA=1` 把它们挂回来。
48
+ const registerRare = process.env.WEZARD_MCP_EXTRA === "1" ? server.registerTool.bind(server) : (() => undefined);
43
49
  // Accept user-friendly prefixes from the LLM: vid:<id> → user:<id>, chatid:<id> → chat:<id>.
44
50
  // Pass anything else (already user:/chat:/group:, or empty) through unchanged.
45
51
  const normalizeTarget = (raw) => {
@@ -61,7 +67,7 @@ const normalizeTarget = (raw) => {
61
67
  // the switch.
62
68
  server.registerTool("set_workspace", {
63
69
  title: "Switch workspace directory",
64
- description: "一步换掉这个 wizard 的**工作区**: 杀掉当前 pane, 在给定目录下重开一个全新的会话 —— 等价于往那个目录 `/new`。群里收到新会话的 📂 项目信息气泡当回执; 对话上下文**不会**带过去 (和 /new 一样是全新会话, 但身份的系统提示还在)。调用方就是被替换的那一个时, 它在调用当口就被终结 —— 这是预期行为, 群里那条气泡就是回执。用绝对路径 (或 `~` 开头)。想换目录又想保住手上的上下文: 先 wizard_handoff_self 把工作压成简报, 或者 spawn_wizard({cwd}) 生一个子 wizard 去那边干。\n"
70
+ description: "一步换掉这个 wizard 的**工作区**: 杀掉当前 pane, 在给定目录下重开一个全新的会话 —— 等价于往那个目录 `/new`。群里收到新会话的 📂 项目信息气泡当回执; 对话上下文**不会**带过去 (和 /new 一样是全新会话, 但身份的系统提示还在)。调用方就是被替换的那一个时, 它在调用当口就被终结 —— 这是预期行为, 群里那条气泡就是回执。用绝对路径 (或 `~` 开头)。想换目录又想保住手上的上下文: 先 handoff 把自己的工作压成简报, 或者 spawn_wizard({cwd}) 生一个子 wizard 去那边干。\n"
65
71
  + "**`keep:true` 是另一支**: 人回答「不用换, 就用现在这个目录」时调它 —— 不重开会话, 只把「这个工作区是人认过的」记下来, 此后新会话的开局不再问这件事。一个新聊天的第一个 wizard 落在默认兜底目录里, 开局会被要求先问一句要去哪个项目; 人给路径就走 `cwd`, 人说不用换就走 `keep`。",
66
72
  inputSchema: {
67
73
  cwd: z.string().optional().describe("Absolute project path, e.g. /Users/foo/projects/bar. ~ is expanded. 与 `keep` 二选一。"),
@@ -138,16 +144,16 @@ server.registerTool("wecom_doc_call", {
138
144
  // These let the WeCom-side Claude answer "列出所有 claude session" / "切到 xxx
139
145
  // 那个" / "在 /path 下新建一个 session" in natural language. The daemon does the
140
146
  // host-wide /proc + tmux scan (an MCP tool can only see its OWN session).
141
- server.registerTool("list_claude_sessions", {
147
+ registerRare("list_claude_sessions", {
142
148
  title: "List running agent sessions",
143
- description: "本机 tmux 里**所有**正在跑的 agent 会话 (claude / claude-internal / codebuddy 都算), 每个带一个稳定的动物 emoji、工作目录、tmux 位置和最近在干嘛的一行摘要。注意这是**机器级**的清单: 里面既有绑定了聊天的 wizard, 也有人在终端里自己开的、与企微无关的会话。用户说「列出所有 session」「有哪些会话在跑」「我想切换 session」时调它, 结果按 emoji + 目录 + 摘要 排成可读的编号列表, 并标出当前正被镜像的那个 (`current: true`)。只想看 wizard (名字/职责/家谱/忙闲) 用 wizard_roster。",
149
+ description: "(人侧/罕用 · 同 IM `/sessions`) 本机 tmux 里所有 agent 会话, 含与企微无关的终端会话; 只在用户要「切换 session」时用。找 wizard 用 wizard_roster。",
144
150
  inputSchema: {},
145
151
  }, async () => {
146
152
  return unwrap("list_claude_sessions", await daemonGet("/sessions/list"));
147
153
  });
148
- server.registerTool("switch_claude_session", {
154
+ registerRare("switch_claude_session", {
149
155
  title: "Switch WeCom mirror to another agent session",
150
- description: "把企微镜像**改接**到另一个已经在跑的会话上 —— 从此这个聊天镜像的、注入的都是它。换句话说: 让那个会话成为这个聊天的 wizard。用户挑了一个要切过去时调 —— 「切到 wezard 那个」「镜像第 2 个」「换到 🦊 那个会话」。先用 list_claude_sessions 把用户的自然语言指代 (emoji / 目录 / 话题) 解析成具体 sessionId, 再传进来。",
156
+ description: "(人侧/罕用 · 同 IM `/sessions <emoji|id>`) 让本聊天改接到一个已在跑的会话 (sessionId 从 list_claude_sessions 拿)。只在用户点名要切时用。",
151
157
  inputSchema: {
152
158
  sessionId: z.string().describe("The target session's sessionId (a UUID), as returned by list_claude_sessions."),
153
159
  },
@@ -173,7 +179,8 @@ const selfRef = () => {
173
179
  const daemonPost = async (path, body) => {
174
180
  const resp = await fetch(`${DAEMON_BASE}${path}`, {
175
181
  method: "POST",
176
- headers: { "content-type": "application/json" },
182
+ // 口令每次现读: daemon 可能在本进程起来之后才生成它。
183
+ headers: { "content-type": "application/json", [DAEMON_TOKEN_HEADER]: readDaemonToken() },
177
184
  body: JSON.stringify({ ...selfRef(), ...body }),
178
185
  });
179
186
  return { j: (await resp.json().catch(() => ({}))), status: resp.status };
@@ -191,33 +198,21 @@ const unwrapText = (name, r) => r.j.ok && typeof r.j.text === "string" ? { conte
191
198
  // 地址语法只有一套, 每个吃地址的工具都把它原样重述一遍: 模型单看一个 schema
192
199
  // 时没有别的地方能学到它, 而猜出来的地址会安静地指向另一个 wizard 的终端。
193
200
  const ADDRESS_DOC = "wizard 的名字 —— 全机唯一, 它就是地址: `'fix'` 或 `'.fix'` 都行, 不分它住在哪个群。`''` = 你这个聊天的默认 wizard。永远别自己拼 key: wizard_roster / spawn_wizard / clone_wizard 返回的名字 (或 409 里的 `address`) 原样传回来。老的 `聊天名#tag` 写法仍然认, 但别再写。";
201
+ // 生 wizard 的三个工具共用: 模型名是口语, 落地的是 `/model` 列表里最接近的那一项。
202
+ const MODEL_DOC = "跑在哪个模型上, 口语写 ('opus' / 'haiku' / 'sonnet 5'); 落地的是它 `/model` 列表里最接近的一项, 见返回的 `model`, 对不上时带 `modelWarning` (仍在跑, 停在默认模型)。写确切的 id ('claude-opus-5-5') 或列表标签 ('Opus 5.5') 且本机跑过它时, 直接带 `--model` 启动, 省掉选择器那一来回。省略 = 该 CLI 默认。之后换用 set_model。";
203
+ const EFFORT_DOC = "推理档位, 启动时就带上 (`--effort`), 之后它每次重启都沿用。判断密集的活给 high 以上, 跑腿的给 low。省略 = 该 CLI 默认 (克隆则跟被克隆者同档)。不支持的 CLI (codebuddy) 忽略它。之后换用 set_model 的 `effort`。";
194
204
  // 造一个 wizard: 它的 home 默认是调用方自己的聊天 (所以走 `selfRef`); 给了 `chat`
195
205
  // 就落在另一个**起过名字**的聊天里。名字全局唯一, 与 home 无关。
196
206
  server.registerTool("new_claude_session", {
197
207
  title: "Spawn a blank new wizard",
198
- description: "在指定项目目录下长出一个**全新的 wizard** —— 自己的 tmux pane、自己的全局名字 (`.name`), home 默认是你这个聊天, 等价于人在群里敲 `/new .name`。它**不继承任何上下文**(白纸一张): 要一个开局就带着你读过的材料的分身, 用 clone_wizard; 要一个归你管、干完可回收的白板子 wizard, 用 spawn_wizard。这个工具生的 wizard 独立长住, 不挂在谁名下。新 wizard 在群里说话时气泡头是 `emoji .name`, 之后用 wizard_roster / peek_peer / send_peer / wait_peer 驱动它。用户说「在 /path 下新建一个会话」「帮我在 xxx 目录起个 agent」时调它。给 `chat` 就把它的 home 设在**另一个**聊天里 —— 那个聊天必须起过名字(list_chats 能看到)。目录不存在会自动创建。绝不会顶掉一个聊天的默认 wizard。名字全局唯一: 撞上的 wizard 静默超过一天就直接顶掉它、名字归新的; 撞上一个还在的 wizard 返回 409, 返回里的 `name` 是最终落定的名字。",
208
+ description: "同 `spawn_wizard({detached:true, cwd})` —— 在某个目录生一个独立长住的白板 wizard (旧名, 只为不打断正在跑的旧会话而保留)。新调用一律用 spawn_wizard。",
199
209
  inputSchema: {
200
- cwd: z.string().describe("Absolute project path to start the new session in, e.g. /Users/foo/projects/bar. Created if missing."),
201
- name: z
202
- .string()
203
- .optional()
204
- .describe("新 wizard 的名字 (如 'fix'、'docs', 带不带 '.' 都行) —— 全机唯一, 它就是地址, 挑一个说明它干什么的短词, 之后用它 send_peer / peek_peer。省略则按目录名生成。"),
205
- chat: z
206
- .string()
207
- .optional()
208
- .describe("把它生在哪个聊天里 (list_chats 里显示的名字)。省略 = 你自己的聊天, 用户绝大多数时候指的就是这个。只有**起过名字**的聊天能被指名 —— 没名字就没有地址, 得先有人在那边发一次 `/name <名字>`。"),
209
- cli: z
210
- .enum(["claude", "claude-internal", "codebuddy"])
211
- .optional()
212
- .describe("用哪个 CLI 启动。用户没点名就省略, 它会继承那个聊天当前的后端。多个后端可以并存。"),
213
- model: z
214
- .string()
215
- .optional()
216
- .describe("这个 wizard 跑在哪个模型上, 口语化随便写 ('opus' / 'sonnet 5' / '最新的 opus' / 'claude-haiku-4-5' 都行) —— 不是塞给启动参数, 而是等 pane 起来后打开它自己的 `/model` 列表, 在里面挑最接近的一项选中, 所以返回里的 `model` 是列表里真正落地的那一项 (如 'Opus 5.5'), 可能跟你传的字符串不一样; 列表里对不上会带 `modelWarning`, 那时 wizard 还在跑但停在了原来的模型上, 不是失败。省略用该 CLI 的默认。同一个聊天里的 wizard 可以各跑各的模型 —— 又长又要判断的活给 opus, 跑腿的 lint/grep 给 haiku。之后要换用 set_model。"),
217
- keepalive: z
218
- .boolean()
219
- .optional()
220
- .describe("这个 wizard 要不要被 keepalive 心跳保温 (空闲时定期 ping 一下防 prompt cache 过期)。false = 永远不保温, 省下那份 ping 的钱 —— 适合跑腿一次就收工的临时会话; true = 明确要保温。省略则按 daemon 配置的默认值。"),
210
+ cwd: z.string().describe("工作区绝对路径, 不存在会创建。"),
211
+ name: z.string().optional().describe("它的名字, 全机唯一。省略按目录名生成。"),
212
+ chat: z.string().optional().describe("home 聊天名。省略 = 你的聊天。"),
213
+ cli: z.enum(["claude", "claude-internal", "codebuddy"]).optional().describe("用哪个 CLI。省略则继承。"),
214
+ model: z.string().optional().describe(MODEL_DOC),
215
+ keepalive: z.boolean().optional().describe("要不要保温。省略按配置。"),
221
216
  },
222
217
  }, async ({ cwd, name, chat, cli, model, keepalive }) => unwrap("new_claude_session", await daemonPost("/sessions/new", {
223
218
  cwd,
@@ -233,7 +228,7 @@ server.registerTool("new_claude_session", {
233
228
  // point at, and what a chat's default wizard is born named after.
234
229
  server.registerTool("name_chat", {
235
230
  title: "Name this WeCom chat",
236
- description: "给**这个聊天**起个短名字: notify 的收件人、new_claude_session / spawn_wizard / clone_wizard 的 `chat` 从此能指到这里。没起过名字的聊天不会一直没名字 —— 第一次有人用到时守护进程按它的工作区自动补一个 (`~/develop/foo` → `foo`, 撞名加序号), 所以这个工具的用途是**起一个更好的名字**。这里的默认 wizard 出生时取聊天名; 它若还叫旧聊天名, 会跟着改 (wizard 的名字全局唯一, 撞名加后缀, 返回里的 `wizardRenamed` 就是它的新名字)。wizard 自己的名字用 wizard_identity 改, 与这里无关。用户说「给这个群起名叫 daily」「这个群叫什么」(不传 `name` 就是读) 「取消命名」(传 '-') 时调它。名字全机唯一、大小写不敏感。",
231
+ description: "给**这个聊天**起个短名字: notify 的收件人、spawn_wizard / clone_wizard 的 `chat` 从此能指到这里。没起过名字的聊天不会一直没名字 —— 第一次有人用到时守护进程按它的工作区自动补一个 (`~/develop/foo` → `foo`, 撞名加序号), 所以这个工具的用途是**起一个更好的名字**。这里的默认 wizard 出生时取聊天名; 它若还叫旧聊天名, 会跟着改 (wizard 的名字全局唯一, 撞名加后缀, 返回里的 `wizardRenamed` 就是它的新名字)。wizard 自己的名字用 wizard_identity 改, 与这里无关。用户说「给这个群起名叫 daily」「这个群叫什么」(不传 `name` 就是读) 「取消命名」(传 '-') 时调它。名字全机唯一、大小写不敏感。",
237
232
  inputSchema: {
238
233
  name: z
239
234
  .string()
@@ -243,14 +238,14 @@ server.registerTool("name_chat", {
243
238
  }, async ({ name }) => name === undefined
244
239
  ? unwrap("name_chat", await daemonPost("/chats/list", {})) // read-only path: roster carries this chat's name
245
240
  : unwrap("name_chat", await daemonPost("/chats/name", { name })));
246
- server.registerTool("list_chats", {
241
+ registerRare("list_chats", {
247
242
  title: "List every chat and its sessions",
248
- description: "跨聊天目录: 守护进程知道的每一个企微聊天、它的名字 (空 = 没起名)、是不是你住的那个 (`self`), 以及以每个聊天为 home 的 wizard 及其名字。用户指向这个聊天之外的活时调它 —— 「别的群有谁在跑」「把这个结论发到 daily 群」「在 sanitizer 群里开个会话」。wizard 本身不必经过聊天就能叫到 (名字全局唯一); 聊天名用于 notify 与在那边生新 wizard。",
243
+ description: "(罕用 · 同 IM `/chats`) 每个聊天的名字与住在那里的 wizard。只在要给 notify / spawn 的 `chat` 找聊天名时用; 找 wizard 用 wizard_roster。",
249
244
  inputSchema: {},
250
245
  }, async () => unwrap("list_chats", await daemonPost("/chats/list", {})));
251
246
  server.registerTool("peek_peer", {
252
247
  title: "Read what another wizard has been saying",
253
- description: "**不打扰**地读另一个 wizard 的会话: 首行是它此刻的状态 (正在生成 / 空闲 / 停在哪个工具调用上等人点), 下面是它最近 N 个来回的正文 —— `▸` 是别人对它说的, `◂` 是它对这句最新说的一句 (跑完的是终句, 还在跑的是途中最近那句), 最后一条给得最全; 截掉的地方都标了 `…(+N)` / `…(略 N 字)…`。全部从它的 transcript 读: 只有说出来的话, 没有工具调用的过程, 保温的 ping/pong 已经剔掉。回答「.fix 进展如何」「它卡在哪」, 或者判断要不要推它一把, 都读这里。用户消息里写的 `.name` 指的就是那个 wizard —— 去 peek 它, 别猜它在干嘛, 更别替它回答。这是**一个会话**的视角 (它从各处听到的都在里面); 要看**一个群**里谁对谁说了什么、或你和它的私聊往来, 用 read_chat。只读, 随便轮询。",
248
+ description: "**不打扰**地读另一个 wizard 的会话: 首行是它此刻的状态 (正在生成 / 空闲 / 停在哪个工具调用上等人点), 下面是它最近 N 个来回的正文 —— `▸` 是别人对它说的, `◂` 是它对这句最新说的一句 (跑完的是终句, 还在跑的是途中最近那句), 最后一条给得最全; 截掉的地方都标了 `…(+N)` / `…(略 N 字)…`。全部从它的 transcript 读: 只有说出来的话, 没有工具调用的过程, 保温的 ping/pong 已经剔掉。回答「.fix 进展如何」「它卡在哪」, 或者判断要不要推它一把, 都读这里。这是**一个会话**的视角 (它从各处听到的都在里面); 要看**一个群**里谁对谁说了什么、或你和它的私聊往来, 用 read_chat。只读, 随便轮询。",
254
249
  inputSchema: {
255
250
  name: z.string().describe(ADDRESS_DOC),
256
251
  turns: z.number().optional().describe("回最近多少个来回 (1-40, 默认 6); 一个来回 = 一句问话 + 它的回答。"),
@@ -258,12 +253,12 @@ server.registerTool("peek_peer", {
258
253
  }, async ({ name, turns }) => unwrapText("peek_peer", await daemonPost("/peers/peek", { name, ...(turns ? { turns } : {}) })));
259
254
  server.registerTool("read_chat", {
260
255
  title: "Read chat history like a person would",
261
- description: "像人翻聊天记录那样读往来, 一行一句: `[时刻] 谁 → 谁: 说了什么`。三级收窄, 每一级都可以不给: `role` (谁的视角 —— 只留它说的或听的) → `chat` (哪个群 —— 只留那个群里公开说的) → `target` (和谁 —— 只留与它的往来)。什么都不给 = 你这一轮所在的群: 人说的、各个 wizard 答的、wizard 之间公开说的都在里面。只给 `target` = 你和它的全部往来 (含 send_peer 默认走的私聊, 群里看不见的那部分); `role` + `target` = 那两方之间; 只给 `role` = 它在所有群与私聊里的往来; 再加 `chat` 就限定在那个群里。没给 `chat` 时每行会标出这句是在哪个聊天 / 私聊里说的。一条消息一行起头, 正文的续行缩进; 还没等到回答的问话行尾标 `(还没有回复)`。\n" +
256
+ description: "像人翻聊天记录那样读往来, 一行一句: `[时刻] 谁 → 谁: 说了什么`。三级收窄, 每一级都可以不给: `role` (谁的视角 —— 只留它说的或听的) → `chat` (哪个群 —— 只留那个群里公开说的) → `target` (和谁 —— 只留与它的往来)。什么都不给 = 你这一轮所在的群: 人说的、各个 wizard 答的、wizard 之间公开说的都在里面。只给 `target` = 你和它的全部往来 (含 tell_peer 默认走的私聊, 群里看不见的那部分); `role` + `target` = 那两方之间; 只给 `role` = 它在所有群与私聊里的往来; 再加 `chat` 就限定在那个群里。没给 `chat` 时每行会标出这句是在哪个聊天 / 私聊里说的。一条消息一行起头, 正文的续行缩进; 还没等到回答的问话行尾标 `(还没有回复)`。\n" +
262
257
  "按时间和条数读: 默认回最新的 `limit` 条; `until` = 只看那之前的 (往回翻页), `since` = 从那时起 (只给 `since` 就从它往后数 `limit` 条)。回执末尾给出翻页用的时刻, 原样传回即可; 往回已经没有了会明说到头。\n" +
263
258
  "只有正文: 每个来回取问话和终句, 工具调用的过程不在里面, 保温的 ping/pong 已经剔掉。记录是从各个 wizard 当前会话的 transcript 现拼的: 一个 wizard `/clear` 或交接之前说的不在里面, 除点名的 role / target 外只并最近一周动过的会话。用户说「群里刚才聊了什么」「.fix 之前怎么答的」「我上次让 .docs 干了什么」, 或者你被叫进一个已经聊了一阵的群、需要来龙去脉时调它。要读**某一个 wizard** 会话里的原始对话用 peek_peer。",
264
259
  inputSchema: {
265
260
  role: z.string().optional().describe("谁的视角: wizard 的名字 ('fix' / '.fix') 或人的 userid。省略 = 不限 (给了 `target` 时默认是你自己)。"),
266
- chat: z.string().optional().describe("哪个群: 聊天名 (list_chats 里那个) 或裸 principal。省略 = 不限群 (公开与私聊都算); 三个都省略 = 你这一轮所在的群。"),
261
+ chat: z.string().optional().describe("哪个群: 聊天名 (wizard_roster 里的 home 聊天名) 或裸 principal。省略 = 不限群 (公开与私聊都算); 三个都省略 = 你这一轮所在的群。"),
267
262
  target: z.string().optional().describe("和谁的往来: wizard 的名字或人的 userid。"),
268
263
  since: z.string().optional().describe("从什么时候起: `2h` / `30m` / `3d` (多久以前)、`14:30` (今天)、`09-30 14:30`、或 ISO 时间。"),
269
264
  until: z.string().optional().describe("到什么时候为止 (不含), 写法同 `since`。往回翻页就把回执里的「更早的」时刻传进来。"),
@@ -288,46 +283,59 @@ server.registerTool("read_chat", {
288
283
  const TELL_SCHEMA = {
289
284
  name: z.string().describe(ADDRESS_DOC),
290
285
  text: z.string().describe("Message to inject. Plain prompt text; slash commands like '/clear' also work."),
291
- when: z
292
- .enum(["now", "idle"])
286
+ priority: z
287
+ .enum(["normal", "urgent", "now"])
293
288
  .optional()
294
- .describe("什么时候投。`now` (默认) 立刻投 —— 对方正在生成时这句话会排在它这一轮后面, 回答它的提问、打断它、催它都该用这个。`idle` 先等它闲下来再投: **派一件新活给一个正在忙的同伴时用它**, 否则你和别人的两段文本会挤进同一个输入框被当成一轮读掉。返回里的 `wasBusy` 告诉你投的时候它忙不忙。"),
295
- waitSec: z.number().optional().describe("`when:'idle'` 最多等多少秒 (10-3600, 默认 600)。等不到就返回失败, 不会强行投。"),
289
+ .describe("对方**正忙**时怎么投 (它闲着三者一样, 立刻投)。`normal` (默认) 等它这一轮结束再投 —— 派新活用它, 否则你和别人的两段话会挤进同一个输入框被当成一轮读掉; 等的是你这次调用 (最多 `waitSec`)。`urgent` 先打断它这一轮 (同 stop_wizard 的 interrupt) 再投 —— 只给真紧急的改道, 它手上那一轮作废; 那一轮若是别的 wizard 派的活, 不打断, 降成 normal (回包 `urgentDowngraded`)。`now` 不等不打断, 落进它的输入框并进当前这一轮 —— 插话、补一句、答它的问。带 `re` 续问时默认 `now`。返回里 `wasBusy` / `interrupted` / `waitedMs` 说明实际怎么投的。"),
290
+ waitSec: z.number().optional().describe("`normal` 等它闲下来最多等多少秒 (10-3600, 默认 600)。等不到就返回失败, 不会强行投。"),
296
291
  public: z
297
292
  .boolean()
298
293
  .optional()
299
- .describe("true = 在公开频道 (你这一轮所在的群) 里说: 群里出 `.你 → .它` 气泡, 它的回复也进群 (同时照样回执给你)。默认 false = 私聊, 只记在 rolepage。需要人知道 / 该当着人讨论才用 true。"),
300
- job: z.string().optional().describe("这次派活归到某个工单名下 (open_job 给的 id)。**fan-out 一定要带上它**: 回执里会带 `还差几份 / 全部到齐`, 「齐了吗」由守护进程数给你, 不用你自己在上下文里记。"),
294
+ .describe("true = 在公开频道 (你这一轮所在的群) 里说: 群里出 `.你 → .它` 气泡, 它的回复也进群 (同时照样回执给你)。默认 false = 私聊, 只记在 rolepage。需要人知道 / 该当着人讨论才用 true。带 `job` 时不起作用 —— 工单里的往来一律私聊。"),
295
+ job: z.string().optional().describe("这次派活归到某个工单名下 (open_job 给的 id)。**fan-out 一定要带上它**: 回执里会带 `还差几份 / 全部到齐`, 「齐了吗」由守护进程数给你, 不用你自己在上下文里记。带了它就一律私聊, 往来与回执都不进群。"),
301
296
  receipt: z
302
297
  .boolean()
303
298
  .optional()
304
299
  .describe("false = 放出去就不管了, 不要回执 (纯通知、或者你根本不关心它说什么)。默认 true。"),
300
+ kind: z
301
+ .enum(["task", "ask", "fyi"])
302
+ .optional()
303
+ .describe("对方该怎么接这句话 (与 `priority` 的何时投、`receipt` 的要不要回执正交): `task` (默认) 一件活, 干完收口 `RESULT:`; `ask` 只答一个问题, 别为它开工; `fyi` 知会, 不用回 —— 自动不要回执、不进工单、不进群。"),
304
+ re: z.string().optional().describe("续问: 回执或回包里的件号 (`t…`)。接着那件活说, 沿用它的工单与频道; 对不上就按新活发出 (`reUnknown`)。"),
305
+ deadline: z.number().optional().describe("最多等它多少秒 (60-43200, 默认 3600); 到点没答完你收到一份 timeout 回执。"),
306
+ chain: z
307
+ .boolean()
308
+ .optional()
309
+ .describe("你此刻在答别的 wizard 派的活时, 这次派出的活默认算那件活的子活: 它回来之前你这一轮的终句不会先交给上游, 它回来那一轮的终句再续回上游。false = 这件活与上游无关 (顺手的测试、旁支), 回执照常回你, 但不挂住你给上游的交代。"),
305
310
  };
306
- const TELL_DESC = "跟另一个 wizard 说话 —— 文本原样落进它的输入框, 它当成新的一轮接手。这是你**驱动**同伴的唯一方式: 派活、解它的阻塞、回答它的提问、叫它继续。名字全局唯一, 目标住在哪个群都一样叫。对方还不存在就自己造: 要它继承你的上下文用 clone_wizard, 要一个白纸一张的子 wizard 用 spawn_wizard。\n" +
307
- "**说完就返回, 你不会被挂住**: 对方干完那一轮, 守护进程把它的结论作为**新的一轮**自动送到你这里 (带信封说明是谁的回执、在哪场对话里、工单还差几份)。所以: 问完就接着干你自己的事, **不要再调 wait_peer 守着**; 也不必催 —— 它答了你自然会收到。你忙着的时候回执会排队等你这一轮说完再进来, 不会和你的输出挤在一起。\n" +
308
- "**fan-out 带上 `job`**: 五路派活就是五次 tell_peer (或 clone_wizard({task})), 回执陆续回来, 每一份都写着第几份 / 还差几份, 最后一份明确告诉你「全部到齐」—— 那时才汇总、才 close_job。没齐就别向人汇报进度。\n" +
309
- "**默认是私聊**: 不出任何群气泡, 只记在你们双方的 rolepage 里。**`public:true` 则在公开频道说** —— 你这一轮所在的群里出一条 `.你 → .它` 的气泡, 它那一轮的回复也发进这个群。公开与否由你判断: 需要人知道的、或本该当着人讨论的 (关键决策、给人的结论、要人拍板的分歧) 用 public; 过程性的派活、催进度、对齐细节用私聊。\n" +
310
- "无论哪种都直说: 要什么、给什么、结论是什么, 不用寒暄、不用引用原文。`text` 只写活本身 —— 守护进程会在后面挂一段信封, 告诉对方这是谁发的、私聊还是公开、它的最后一条消息就是回执 (收口成 `RESULT: …`), 这些别自己再写。拒绝对自己发送。";
311
+ const TELL_DESC = "跟另一个 wizard 说话 —— 文本原样落进它的输入框, 它当成新的一轮接手 (派活、答它的问题、叫它继续都走这里)。目标不存在就先 spawn_wizard / clone_wizard。\n" +
312
+ "**说完就返回**: 对方干完那一轮, 它的最后一条消息作为**新的一轮**自动送到你这里 (回执, 带信封说明是谁、哪场对话、工单还差几份); 你正忙时回执排队等你说完。\n" +
313
+ "默认私聊, 只记在双方 rolepage; 守护进程给 `text` 挂信封 (发话人、私聊/公开、`RESULT: …` 收口)。拒绝对自己发送。";
311
314
  const tellBody = (a) => ({
312
315
  name: a.name,
313
316
  text: a.text,
314
- ...(a.when ? { when: a.when } : {}),
317
+ ...(a.kind ? { kind: a.kind } : {}),
318
+ ...(a.priority ? { priority: a.priority } : {}),
315
319
  ...(a.waitSec ? { waitSec: a.waitSec } : {}),
316
320
  ...(a.job ? { job: a.job } : {}),
317
321
  ...(a.public ? { public: true } : {}),
318
322
  ...(a.receipt === false ? { receipt: false } : {}),
323
+ ...(a.re ? { re: a.re } : {}),
324
+ ...(a.deadline ? { deadline: a.deadline } : {}),
325
+ ...(a.chain === false ? { chain: false } : {}),
319
326
  });
320
327
  server.registerTool("tell_peer", { title: "Say something to another wizard (reply comes back by itself)", description: TELL_DESC, inputSchema: TELL_SCHEMA }, async (a) => unwrap("tell_peer", await daemonPost("/peers/tell", tellBody(a))));
321
328
  server.registerTool("send_peer", {
322
329
  title: "(deprecated) alias of tell_peer",
323
- description: "**已更名为 `tell_peer`** —— 行为完全一样 (说一句、不阻塞、对方的结论自动回执给你), 保留这个名字只为不打断正在跑的会话。新的调用请用 `tell_peer`。\n" + TELL_DESC,
324
- inputSchema: TELL_SCHEMA,
330
+ description: "同 tell_peer (旧名, 只为不打断正在跑的旧会话而保留)。新调用一律用 tell_peer。",
331
+ // 参数与 tell_peer 一样收, 但不再背一份参数说明: 别名只为老调用不断, 不该再占一份上下文。
332
+ inputSchema: Object.fromEntries(Object.entries(TELL_SCHEMA).map(([k, v]) => [k, v.describe("")])),
325
333
  }, async (a) => unwrap("send_peer", await daemonPost("/peers/tell", tellBody(a))));
326
334
  server.registerTool("notify", {
327
335
  title: "Post a message into a chat for people to read",
328
336
  description: "把一段 markdown 贴进一个企微聊天**给人看**。和 tell_peer 分工明确: tell_peer 是把话塞进另一个 agent 的输入框 (驱动它干活), notify 是说给人听 —— 不会触发任何一轮对话。\n" +
329
- "`to` 省略 = 你这一轮所在的群 (人从哪个群叫的你就是哪个; 私聊轮则是你的 home 群); 要发到别的群就写聊天名 (`list_chats` 里那个), 一次可以写多个。气泡头自动写成 `emoji .你的名字` 并挂上你的 rolepage 链接, 那边的人一眼知道是谁。\n" +
330
- "什么时候用: 长活跑完了要通知另一个群的人; 一批分身收工后把汇总播给发起那个群; 定时任务 (schedule_task) 到点跑完把结论送到该看的人那里。别拿它跟同群的人说话 —— 那是你的正常回复。",
337
+ "`to` 省略 = 你这一轮所在的群 (人从哪个群叫的你就是哪个; 私聊轮则是你的 home 群); 要发到别的群就写聊天名 (wizard_roster 里的 home 聊天名), 一次可以写多个。气泡头自动写成 `emoji .你的名字` 并挂上你的 rolepage 链接, 那边的人一眼知道是谁。\n" +
338
+ "什么时候用: 长活跑完了要通知另一个群的人; 一批分身收工后把汇总播给发起那个群; 定时任务 (schedule_task) 到点跑完把结论送到该看的人那里。",
331
339
  inputSchema: {
332
340
  to: z
333
341
  .array(z.string())
@@ -340,7 +348,7 @@ server.registerTool("wait_peer", {
340
348
  title: "(deprecated) block until another wizard stops working",
341
349
  description: "**一般不需要它了**: `tell_peer` 之后对方的结论会自动作为新的一轮送到你这里 (回执), 你不必守着。保留它是因为有一件事只有它做得到 —— **在同一轮里拿到答案**: 下一步硬依赖对方的结果、把工作拆成两轮会丢上下文时, 用它阻塞等; 以及 `need:1` 那种「谁先完事就先处理谁」。代价是你这个 pane 在等的过程中什么也干不了。\n" +
342
350
  "被它取走的那一份**不会再作为回执注入**一遍 (返回里 `delivered: true` = 回执已经抢先进过你的会话, 别重复处理)。\n" +
343
- "挂起, 直到点名的 wizard 停下来 (它的终端不再显示中断提示), 然后返回它最新的回复。超时先到则返回 `idle: false` 与原因: 它只是还在干, 你可以 peek 一眼再等。很便宜: 守护进程轮询的是 pane, 不烧 token。回的只算**你上次 send_peer 之后**它说的: 它收口了 `RESULT: …` 就只回 `result` (`omitted` = 正文还有多少字没给, 要读用 peek_peer), 没收口才回 `lastText` (超长掐中间); `stale: true` = 它停下了却没有新回复 (卡在弹窗上, 或那句话没被接住) —— peek_peer 看一眼, 别把它当成答完了。私聊轮的回复不进群 —— 人需要知道结论时, 用你自己的话收口给人, 别把原话再念一遍; 公开轮的回复已经在群里了, 更不必复述。\n" +
351
+ "挂起, 直到点名的 wizard 停下来 (它的终端不再显示中断提示), 然后返回它最新的回复。超时先到则返回 `idle: false` 与原因: 它只是还在干, 你可以 peek 一眼再等。很便宜: 守护进程轮询的是 pane, 不烧 token。回的只算**你上次 tell_peer 之后**它说的: 它收口了 `RESULT: …` 就只回 `result` (`omitted` = 正文还有多少字没给, 要读用 peek_peer), 没收口才回 `lastText` (超长掐中间); `stale: true` = 它停下了却没有新回复 (卡在弹窗上, 或那句话没被接住) —— peek_peer 看一眼, 别把它当成答完了。\n" +
344
352
  "**真要等一批就用 `names` 一次等一组**, 别一个一个等: 它们本来在同时干活, 串行等的墙钟是所有人之和, 并行等只等最慢的那一个 (不过 fan-out 的常规做法是给 `tell_peer` 带 `job`, 让回执自己回来并替你数「齐了吗」)。等一组时 `results` 按你给的顺序逐个回, 只等一个则直接摊平在顶层。`need` 决定满几个就返回 (默认全部; `need:1` = 谁先完事就先处理谁, 剩下的还在跑, 再调一次接着等)。",
345
353
  inputSchema: {
346
354
  name: z.string().optional().describe(`${ADDRESS_DOC} 等一组时改用 \`names\`。`),
@@ -359,9 +367,9 @@ server.registerTool("wait_peer", {
359
367
  ...(need ? { need } : {}),
360
368
  ...(timeoutSec ? { timeoutSec } : {}),
361
369
  })));
362
- server.registerTool("run_agent_graph", {
370
+ registerRare("run_agent_graph", {
363
371
  title: "Run a loop graph over several tagged agents",
364
- description: "把这个聊天里的几个 wizard 串成一条**会循环的流水线**, 交给守护进程去驱动。`nodes` 是参与的 `#tag` wizard (每个可以自选 cli / 模型 / 工作区; 不存在的当场造出来, 已经在跑的原样复用、上下文不动)。`steps` 是有序管线 —— 每一步向一个 wizard 发一段提示、等它干完、抓住它的回复、喂给下一步。整张 step 表会被走 `rounds` 遍, 这才叫**循环**: `fix → review → fix → review …` 直到某个回复里出现 `until` 或轮次用完。提示模板可以引用前面的产出: `{{last}}` = 上一步的回复, `{{<tag>}}` = 那个 wizard 最新的回复, `{{round}}` = 第几轮。立刻返回 runId 并把进度播报进群; 用 graph_status 查、stop_graph 停。用户要「几个 agent 互相评审/迭代到收敛」时用它。只是推一个 wizard 一把, 用 send_peer + wait_peer。要它们开局就共享同一批材料, 先 clone_wizard 出这些节点再跑图。",
372
+ description: "(罕用) 守护进程驱动的循环流水线: `steps` 依次给 `nodes` 里的 wizard 发提示、等它答完、把回复喂给下一步 (`{{last}}` / `{{<tag>}}` / `{{round}}`), 整表走 `rounds` 遍或回复含 `until` 即停。只活在内存, reload 即丢。仅当用户明说「互相迭代到收敛」时用; 平时 tell_peer + 回执就够。",
365
373
  inputSchema: {
366
374
  nodes: z
367
375
  .array(z.object({
@@ -388,9 +396,9 @@ server.registerTool("run_agent_graph", {
388
396
  ...(until ? { until } : {}),
389
397
  ...(idleTimeoutSec ? { idleTimeoutSec } : {}),
390
398
  })));
391
- server.registerTool("graph_status", {
399
+ registerRare("graph_status", {
392
400
  title: "Inspect running / finished agent graphs",
393
- description: "看 run_agent_graph 起的流水线跑到哪了: 每一步的轮次、目标 wizard、状态 (running / done / timeout / error) 以及每个 wizard 交出来的回复。不给 runId 就列出本聊天的全部。注意图只活在守护进程内存里 —— reload 会把它清掉 (wizard 本身还活着)。",
401
+ description: "(罕用) run_agent_graph 的进度: 每步的轮次、目标、状态与回复。省略 runId = 本聊天全部。",
394
402
  inputSchema: {
395
403
  runId: z.string().optional().describe("Run id from run_agent_graph. Omit to list all runs for this chat."),
396
404
  },
@@ -398,50 +406,60 @@ server.registerTool("graph_status", {
398
406
  const qs = runId ? `?runId=${encodeURIComponent(runId)}` : "";
399
407
  return unwrap("graph_status", await daemonGet(`/graph/status${qs}`));
400
408
  });
401
- server.registerTool("stop_graph", {
409
+ registerRare("stop_graph", {
402
410
  title: "Cancel a running agent graph",
403
- description: "在当前这一步之后停掉流水线。**不会**打断正在生成的那个 wizard —— 它把话说完, 之后不再派新的步骤。用户说「别跑了」时用。要立刻打断某个 wizard 用 stop_wizard({mode:'interrupt'})。",
411
+ description: "(罕用) 当前步之后停掉 run_agent_graph, 不打断正在生成的 wizard (要打断用 stop_wizard)。",
404
412
  inputSchema: { runId: z.string().describe("Run id from run_agent_graph.") },
405
413
  }, async ({ runId }) => unwrap("stop_graph", await daemonPost("/graph/stop", { runId })));
414
+ // 交接只有一个动词: 不点名 = 交接你自己 (你写简报), 点名 = 替别人交接 (守护进程让它写)。
415
+ // 两条路由不变 —— 已在跑的 wizard 的 MCP 进程还在调老名字 wizard_handoff_self。
416
+ const handoffSelf = (tool, brief) => daemonPost("/wizard/handoff-self", { brief }).then((r) => unwrap(tool, r));
406
417
  server.registerTool("handoff", {
407
- title: "Hand another wizard's work over to a fresh context",
408
- description: "给**另一个** wizard 做交接, 原地完成: 守护进程让它把当前工作压成一份自洽的交接简报, 等它写完抓取, 再给它 `/new` 一个全新的会话 (新进程、上下文清零, 名字 / cwd / 模型 / 身份的系统提示照旧), 然后把简报作为新会话的第一条消息贴回去。它的上下文撑不住了、或者用户说「让 .fix 交接一下」「叫它压缩上下文重开」时用。按 tmux `pane` id (`%5`, 来自 wizard_roster / list_claude_sessions) 或按 `name` 寻址。**要交接的是你自己就用 wizard_handoff_self** —— 这里拒绝对自身操作 (会死锁: 你没法在自己生成的当口再被问一次)。返回被带过去的那份简报。",
418
+ title: "Hand work over to a fresh context (yourself or another wizard)",
419
+ description: "把一段工作压成简报、原地换一个全新的会话接着干 (新进程、上下文清零; 名字 / cwd / 模型 / 身份的系统提示照旧), 简报作为新会话的第一条消息贴回去。\n" +
420
+ "**不传 `name` = 交接你自己**: 简报由你写在 `brief` 里, 守护进程等你这一轮说完再重开 (新会话里 MCP 工具也换成最新的)。简报要写到零上下文的自己仅凭它就能接着干: 总目标 / 已完成与关键决策 / 当前状态 / 有序的下一步 / 关键文件与非显然的坑。\n" +
421
+ "**传 `name` = 替那个 wizard 交接**: 守护进程让它自己写简报 (`focus` 指定要特别交代的点), 抓到后给它重开并贴回; 返回被带过去的那份简报。",
409
422
  inputSchema: {
410
- pane: z.string().optional().describe("目标 tmux pane id, 如 '%5'。优先于 name。从 wizard_roster / list_claude_sessions 拿。"),
411
- name: z.string().optional().describe(`${ADDRESS_DOC} 给了 pane 就忽略它。`),
412
- focus: z.string().optional().describe("交接简报里要特别交代的点, 如 '重点交代还没跑通的测试'。可选。"),
413
- timeoutSec: z.number().optional().describe("Max seconds to wait for the summary before aborting (30-7200, default 600)."),
423
+ name: z.string().optional().describe("替谁交接 —— wizard 的名字 ('fix' / '.fix')。省略 = 你自己。"),
424
+ brief: z.string().optional().describe("交接你自己时必填: 简报全文, 自洽、具体、可执行。"),
425
+ focus: z.string().optional().describe("替别人交接时可选: 简报里要特别交代的点。"),
426
+ timeoutSec: z.number().optional().describe("替别人交接时等它写简报的上限秒数 (30-7200, 默认 600)。"),
427
+ pane: z.string().optional().describe("(旧参数) 按 tmux pane id 指定对方, 同 `name`。"),
414
428
  },
415
- }, async ({ pane, name, focus, timeoutSec }) => unwrap("handoff", await daemonPost("/handoff", {
416
- ...(pane ? { pane } : {}),
417
- ...(name !== undefined ? { name } : {}),
418
- ...(focus ? { focus } : {}),
419
- ...(timeoutSec ? { timeoutSec } : {}),
420
- })));
429
+ }, async ({ name, brief, focus, timeoutSec, pane }) => !name && !pane
430
+ ? brief?.trim()
431
+ ? handoffSelf("handoff", brief)
432
+ : fail("handoff: 交接你自己要写 `brief` (零上下文也能接手的简报); 替别人交接就传 `name`")
433
+ : unwrap("handoff", await daemonPost("/handoff", {
434
+ ...(pane ? { pane } : {}),
435
+ ...(name ? { name } : {}),
436
+ ...(brief ? { brief } : {}),
437
+ ...(focus ? { focus } : {}),
438
+ ...(timeoutSec ? { timeoutSec } : {}),
439
+ })));
421
440
  // 定时任务 —— 到点把一句话说给一个 wizard 听。和人在群里 at 它说同一句话完全等价:
422
441
  // pane 死了会被拉起来, 它干完的活照常出现在群里和详情页。这是 claude/codebuddy 自带
423
442
  // 定时器给不了的那一半: 它们的循环活在会话里, 会话一死就没了; 这个活在 daemon 里。
424
443
  server.registerTool("schedule_task", {
425
444
  title: "Schedule a prompt to run in a wizard session, on a recurring or one-off schedule",
426
- description: "排一个**到点自动执行**的活, **归在你名下** (你的 rolepage「日程」里看得到): 到时间了, daemon 起一个**全新的白板 wizard** (名字 `<你>-task-xxxx`, 挂在你的家谱下), 把 `prompt` 原样说给它听, 它干完活自动收掉 —— 产出照常落在群里。守护进程级, 跨 CLI 重启/会话结束仍在。用户说「每个工作日晚上 9:30 自动跑一下 xxx」「每天早上帮我看看 yyy」「每 2 小时同步一次 zzz」「明早 9 点提醒并整理 www」时调它。\n" +
427
- "**每条任务落成一份你能直接改的代码文件** `~/.wezard/tasks/<id>.task.mjs` (返回值里的 `file`)。这个工具只管最常见的那条路 (什么时候 + 说什么); 要更细的东西就**用 Read/Edit 改那个文件**, 存盘即生效, 不用 reload:\n" +
428
- "· 触发条件可组合 —— `when: ({every, daily, at, between, onDays, and, not}) => and(every(\"1h\"), between(\"08:00\",\"20:00\"), onDays(\"工作日\"))`。`every/daily/at` 产生时刻, `between/onDays/not` 只做筛选。\n" +
429
- "· **放枪前先探一眼**用 `gate` —— 一段在 daemon 侧跑的异步函数, 返回 `false` 这一轮就不放枪 (不起 wizard、群里不出声), 返回 `{vars}` 则填进 prompt 里的 `{{名字}}`, 返回 `{state}` 下一轮还拿得到。`gate: async ({sh, state}) => { const out = await sh(\"git fetch -q && git log --oneline HEAD..@{u}\"); return out.trim() ? {vars:{commits: out}} : false; }`。「有新东西才处理」这类需求必须用它, 别写成「到点起个 wizard 让它自己看一眼没有就退出」—— 那是每次空转一个 pane 加一份上下文。\n" +
430
- "· `enabled: false` 暂停而不删。\n" +
431
- "**默认新建, 不在任何已有会话里续**: 定时的活是一件独立的事, 塞进一个常驻 wizard 会把两件不相关的事挤进同一个 transcript, 那个 wizard 正忙时还会连触发时刻一起被拖走。只有用户明确说了「在 .foo 里继续 / 让 .foo 每天…」才传 `name` 点名它 —— 这时到点直接投进它那一轮 (它正忙则仍然另起白板执行)。`prompt` 本身就在说「建两个 wizard 去干 xxx」时, 就算给了 `name` 也照新建办 —— 守护进程自己认这句话, 那个名字只决定在谁的聊天/目录下办。要强行在它那一轮里续, 显式传 `fresh:false`。\n" +
432
- "`when` 用人话原样写, 别自己翻译成 cron: 「每个工作日晚上9:30」「每天 8:00」「每周三下午3点」「每隔两个小时」「每小时」「每 30 分钟」「20 分钟后」「明早 9 点」都认, 还能叠时间窗口 —— 「白天每隔一个小时」「工作时间每半小时」「8点到20点每小时」。解析不出会报错并列出能认的说法 —— 这时把原话回给用户让他重说, 别自己猜一个时间存进去。\n" +
433
- "存成功后**必须把回显的 `when` 和 `next` 念给用户**确认 (例: 「每个工作日 21:30, 下次 2026-09-21 21:30」)。`prompt` 要写成一句完整的、零上下文也能执行的指令 —— 到点接活的多半是个刚出生的白板 wizard, 它只看得见这句话。",
445
+ description: "排一个**到点自动执行**的活, 归在你名下 (rolepage「日程」可见)。用户说「每个工作日晚上 9:30 跑一下 xxx」「每天早上看看 yyy」「每 2 小时同步 zzz」「明早 9 点整理 www」时调它。守护进程级, 跨 CLI 重启仍在。\n" +
446
+ "`when` 用人话原样写, 别翻译成 cron (「每个工作日晚上9:30」「每隔两个小时」「白天每小时」「20 分钟后」「明早 9 点」都认); 解析不出会报错 —— 把原话回给用户让他重说, 别自己猜。\n" +
447
+ "**默认到点新起一个白板 wizard** (`<你>-task-xxxx`) 执行, 干完自动收掉; 只有用户明说「在 .foo 里继续」才传 `name` 投进那个已有会话 (它到点正忙就等它这一轮结束再投, 等满 30 分钟仍忙才另起白板执行; prompt 本身要求「建 wizard 去干」时也照新建办, 除非显式传 `fresh:false`)。所以 `prompt` 要写成零上下文也能执行的完整指令。\n" +
448
+ "「有新东西才处理」写成任务文件里的 gate (没活这一轮就不放枪), 别让到点起的 wizard 自己看一眼没有就退出 —— 那是每轮空转一个 pane。\n" +
449
+ "存成功后**必须把回显的 `when` 和 `next` 念给用户确认**。任务落成可直接改的 `~/.wezard/tasks/<id>.task.mjs` (返回的 `file`): 组合触发条件、「有活才放枪」的 gate、暂停, 都 Read 那个文件 —— 写法在它的头注释里, 存盘即生效。",
434
450
  inputSchema: {
435
451
  when: z.string().describe("什么时候跑, 人话原样传: 「每个工作日晚上9:30」「每天早上9点」「白天每隔一个小时」「工作时间每半小时」「每30分钟」「20分钟后」「明早9点」。时间窗口 (白天 / 工作时间 / 8点到20点) 会被解析成筛子, 窗外的那些枪直接吞掉。"),
436
452
  prompt: z.string().describe("到点要说给那个 wizard 听的话。写成自洽的完整指令 (要做什么、在哪个目录/文件上、做完怎么汇报), 别依赖当前对话的上下文。里面可以留 `{{名字}}` 占位, 由任务文件里的 gate 填。"),
437
453
  name: z.string().optional().describe(`点名在**哪个已有 wizard**里跑 —— 只有用户要求「在它那儿继续」时才传。省略 = 到点新建一个白板 wizard 干完就收 (默认, 也是「定时新建 wizard 干 xxx」要的那个)。传了它还想要新建, 再加 \`fresh:true\`: 那时它只当模板, 新 wizard 继承它的聊天/目录/模型。${ADDRESS_DOC}`),
454
+ quiet: z.boolean().optional().describe("true = 有事才说: 到点不在群里预告, 那一轮私下跑, 它看完没事就只回一行 `QUIET`, 什么也不发; 有事守护进程才把它的终句转进群。巡检 / 盯梢类 (「每小时看一眼 CI, 挂了才告诉我」) 用它。没活连枪都不该放的, 写 gate。"),
438
455
  fresh: z.boolean().optional().describe("覆盖默认: true = 每次到点新建白板 wizard 执行 (给了 name 时用来表达「在它的目录下新开一个干」), false = 注入 name 指向的已有会话。默认由 name 推断 (给了 name = false, 没给 = true)。"),
439
456
  id: z.string().optional().describe("任务 id, 同时也是文件名 (`~/.wezard/tasks/<id>.task.mjs`)。省略则从 note/prompt 生成。取个好认的短名 —— 之后你要改这条任务, 改的就是那个文件。"),
440
457
  note: z.string().optional().describe("给人看的一句话备注, 在 list_tasks 里回显。"),
441
458
  },
442
- }, async ({ when, prompt, name, note, fresh, id }) => unwrap("schedule_task", await daemonPost("/tasks/schedule", {
459
+ }, async ({ when, prompt, name, note, fresh, quiet, id }) => unwrap("schedule_task", await daemonPost("/tasks/schedule", {
443
460
  when, prompt, name: name ?? "", note: note ?? "", id: id ?? "",
444
461
  ...(fresh === undefined ? {} : { fresh }),
462
+ ...(quiet ? { quiet: true } : {}),
445
463
  })));
446
464
  server.registerTool("list_tasks", {
447
465
  title: "List scheduled tasks on this host",
@@ -488,12 +506,12 @@ server.registerTool("config_set", {
488
506
  // 分身还剩几个、别人是谁。
489
507
  server.registerTool("wizard_whoami", {
490
508
  title: "Who am I",
491
- description: "你自己是谁: 全局唯一的名字 (即地址, 别人用它找你)、home 聊天、工作区、职责、记忆、家谱 (谁生的你、你生了谁), 以及此刻的 contextTokens 与 handoffSuggested。用户问「你是谁」「你叫什么」「你在哪个目录」「你有几个分身」时先调它; 要做任何编排之前也先调它 —— 你得知道自己的工作区在哪、手里已经有哪些分身。handoffSuggested=true 表示上下文该交接了 (见 wizard_handoff_self)。",
509
+ description: "你自己是谁: 全局唯一的名字 (即地址, 别人用它找你)、home 聊天、工作区、职责、记忆、家谱 (谁生的你、你生了谁), 以及此刻的 contextTokens 与 handoffSuggested。用户问「你是谁」「你叫什么」「你在哪个目录」「你有几个分身」时先调它; 要做任何编排之前也先调它 —— 你得知道自己的工作区在哪、手里已经有哪些分身。handoffSuggested=true 表示上下文该交接了 (见 handoff)。",
492
510
  inputSchema: {},
493
511
  }, async () => unwrap("wizard_whoami", await daemonPost("/wizard/whoami", {})));
494
512
  server.registerTool("wizard_identity", {
495
513
  title: "Name yourself / declare your job",
496
- description: "改名字、写职责。名字就是别人喊你的那个词 (`.name`), **全机唯一**, 与聊天名无关 (给聊天起名用 name_chat): 撞名时自动加 `-N` 后缀, 返回里的 `name` 是落定的那个, `renamed` 会告诉你发生了改写。职责是一句话的「我是干什么的」—— 别的 wizard 在名册里读到它, 据此决定该不该找你。用户说「你以后叫 X」「你负责 X」时调它; 你自己发现 whoami 里职责是空的, 也应当主动补上。只传要改的那个字段。",
514
+ description: "改名字、写职责。名字就是别人喊你的那个词 (`.name`), **全机唯一**, 与聊天名无关 (给聊天起名用 name_chat): 撞名时自动加 `-N` 后缀, 返回里的 `name` 是落定的那个, `renamed` 会告诉你发生了改写。职责是一句话的「我是干什么的」—— 别的 wizard 在名册里读到它, 据此决定该不该找你。用户说「你以后叫 X」「你负责 X」时调它;只传要改的那个字段。",
497
515
  inputSchema: {
498
516
  name: z.string().optional().describe("新名字, 1-32 位字母/数字/`_`/`-`, 全机唯一。不改就别传。"),
499
517
  description: z.string().optional().describe("一句话职责, 例如 '盯 wezard 主仓的重构与发版'。不改就别传。"),
@@ -504,7 +522,7 @@ server.registerTool("wizard_identity", {
504
522
  })));
505
523
  server.registerTool("wizard_roster", {
506
524
  title: "Every wizard and clone",
507
- description: "这个世界上所有的 wizard 与 clone, 一个一行: 名字 (全局唯一, 即地址)、忙闲 (忙 = 正在生成 / 闲 = 活着没在跑 / 冷 = 没有 pane, 发消息会唤醒)、**home 聊天** (群 / 单聊)、**工作区**、模型 (还没应答过的没有这一项)、多久没动、家谱 (父 / 分身); 写了职责的多一行职责, 活着的多一行「最近」—— 它最近一个来回 (`▸` 问 `◂` 答, 各截到 80 字, 截了带 `…`)。跨聊天的也在里面。这是你感知同伴的唯一入口 —— 用户说「还有谁在跑」「这个群里有谁」「谁在弄那个项目」「让懂 X 的那个来看看」时先调它, 拿到目标的名字再 send_peer / peek_peer / wait_peer; 要往某个群里对人说话则把那个群名交给 notify。\n" +
525
+ description: "这个世界上所有的 wizard 与 clone, 一个一行: 名字 (全局唯一, 即地址)、忙闲 (忙 = 正在生成 / 闲 = 活着没在跑 / 冷 = 没有 pane, 发消息会唤醒)、**home 聊天** (群 / 单聊)、**工作区**、模型 (还没应答过的没有这一项)、多久没动、家谱 (父 / 分身); 写了职责的多一行职责, 活着的多一行「最近」—— 它最近一个来回 (`▸` 问 `◂` 答, 各截到 80 字, 截了带 `…`)。跨聊天的也在里面。这是你感知同伴的唯一入口 —— 用户说「还有谁在跑」「这个群里有谁」「谁在弄那个项目」「让懂 X 的那个来看看」时先调它, 拿到目标的名字再 tell_peer / peek_peer; 要往某个群里对人说话则把那个群名交给 notify。\n" +
508
526
  "**这是一张索引, 不是一份名单**: 整台机器上可能有几百个会话, 所以默认只回最相关的一页 (自己 → 活着的 → 最近动过的), 并告诉你 `total` / `matched` 有多少。找人就带上条件: `query` 匹配名字/职责/地址, `cwd` 匹配工作区路径 (「谁在这个目录里干活」), `chat` 限定某个聊天, `alive:true` 只看还活着的。「同群有谁」就是 `chat` 写你自己的群名。别不带条件硬拉全表。",
509
527
  inputSchema: {
510
528
  query: z.string().optional().describe("在名字 / 职责 / target 里做子串匹配 (不分大小写)。「让懂 X 的那个来看看」就把 X 写在这里。"),
@@ -522,7 +540,7 @@ server.registerTool("wizard_roster", {
522
540
  })));
523
541
  server.registerTool("route_candidates", {
524
542
  title: "Who could take this task",
525
- description: "一件活要不要转给**已有**的 wizard, 而上下文里还看不出该给谁时调它 (刚派过同一摊活、职责正对口的, 直接判断就行)。守护进程替你把能算的都算好: 每个已有 wizard 的职责 / 最近的话与这件活重叠了哪些词, 它当前上下文里 (上次压缩之后) 读过哪些文件、其中哪些被这件活点到, 它在不在同一个工作区、忙闲、上下文多大 (`ctx`)。没有任何交集的不列, 列出来的按证据强弱排 (文件命中 > 职责 > 最近的话), 末尾永远附「新 spawn」。不出分数、不替你拍板 —— 交集是不是同一件事、接着用它那段上下文值不值 (省下的重读 vs 往后每一轮都背着它的 ctx), 由你读表判断: 选中一个就 tell_peer 给它, 都不合适就 spawn_wizard。",
543
+ description: "一件活要不要转给**已有**的 wizard, 而上下文里还看不出该给谁时调它 (刚派过同一摊活、职责正对口的, 直接判断就行)。守护进程替你把能算的都算好: 每个已有 wizard 的职责 / 最近的话与这件活重叠了哪些词, 它当前上下文里 (上次压缩之后) 读过哪些文件、其中哪些被这件活点到, 它在不在同一个工作区、忙闲、上下文多大 (`ctx`), 缓存冷热与唤醒要重写多少缓存 (对比白板 spawn 的倍数)。没有任何交集的不列, 列出来的按证据强弱排 (文件命中 > 职责 > 最近的话), 末尾永远附「新 spawn」; 证据弱又贵的标 ⚠「不划算」。不出分数、不替你拍板。",
526
544
  inputSchema: {
527
545
  task: z.string().describe("这件活, 原话或一句概括都行 —— 带上提到的文件名 / 模块名 / 函数名, 文件命中是最强的证据。"),
528
546
  cwd: z.string().optional().describe("这件活落在哪个工作区。省略 = 你自己的工作区。"),
@@ -532,17 +550,18 @@ server.registerTool("route_candidates", {
532
550
  // 换模型和 spawn 时挑模型是同一条路: 守护进程在目标 pane 里打开 `/model` 列表,
533
551
  // 读出这台 CLI 此刻真有的每一项, 挑最接近的, 方向键移过去选中。
534
552
  server.registerTool("set_model", {
535
- title: "Switch a wizard's model",
536
- description: "给一个**已经在跑**的 wizard 换模型 —— 默认换你自己, `name` 点名就换那个 wizard。口语化写要什么 ('opus' / 'haiku' / 'sonnet 5' / '最新的 opus' / '默认'), 守护进程在它的 pane 里打开 `/model` 列表, 读出这台 CLI 此刻真有的每一项, 挑最接近的一项选中; 返回里的 `model` 是真正落地的那一项, `catalog` 是列表里的全部 —— 对不上时 (`ok:false`) 照着 `catalog` 重说一遍。默认只对这一个会话生效 (`scope:'session'`), 不改新开会话的默认模型; `scope:'default'` 则同时把它设成这台 CLI 此后**每个新会话**的默认模型 —— 那是全机的设置, 只在人明说「以后默认都用 X」时才用。返回里的 `scope` 是实际落在哪一档。它之后被重启也会回到这个模型上。正在干活的也能换 (从下一次请求起生效), 但换模型会让它的对话缓存整份重读一遍 —— 别来回切。用户说「换成 opus」「这个用 haiku 跑就行」「把 .fix 切到 sonnet」时调它; 你自己判断手上的活配不上/撑不起当前模型时也可以主动换。",
553
+ title: "Switch a wizard's model / effort",
554
+ description: "给一个**已经在跑**的 wizard 换模型 (和/或推理档位 `effort`) —— 默认换你自己, `name` 点名就换那个 wizard。口语化写要什么 ('opus' / 'haiku' / 'sonnet 5' / '最新的 opus' / '默认'), 守护进程在它的 pane 里打开 `/model` 列表, 读出这台 CLI 此刻真有的每一项, 挑最接近的一项选中; 返回里的 `model` 是真正落地的那一项, `catalog` 是列表里的全部 —— 对不上时 (`ok:false`) 照着 `catalog` 重说一遍。默认只对这一个会话生效 (`scope:'session'`), 不改新开会话的默认模型; `scope:'default'` 则同时把它设成这台 CLI 此后**每个新会话**的默认模型 —— 那是全机的设置, 只在人明说「以后默认都用 X」时才用。返回里的 `scope` 是实际落在哪一档。它之后被重启也会回到这个模型上。正在干活的也能换 (从下一次请求起生效), 但换模型会让它的对话缓存整份重读一遍 —— 别来回切。用户说「换成 opus」「这个用 haiku 跑就行」「把 .fix 切到 sonnet」时调它; 你自己判断手上的活配不上/撑不起当前模型时也可以主动换。",
537
555
  inputSchema: {
538
- model: z.string().describe("要换到哪个模型, 口语化写: 'opus' / 'haiku' / 'sonnet 5' / 'opus 4.7' / '最新的 fable' / '默认'。只写家族名 = 那个家族最新的一个。"),
556
+ model: z.string().optional().describe("要换到哪个模型, 口语化写: 'opus' / 'haiku' / 'sonnet 5' / 'opus 4.7' / '最新的 fable' / '默认'。只写家族名 = 那个家族最新的一个。只换 effort 时省略。"),
557
+ effort: z.enum(EFFORTS).optional().describe("顺带 (或只) 换推理档位 —— 只对这个会话生效, 不改全机默认; 之后它重启也沿用。返回里的 `effort` 是落地的那一档。"),
539
558
  name: z.string().optional().describe("换谁的 —— wizard 的名字 ('fix' / '.fix')。省略 = 换你自己。"),
540
559
  scope: z
541
560
  .enum(["session", "default"])
542
561
  .optional()
543
562
  .describe("'session' (默认) = 只换这一个会话; 'default' = 换这个会话, 并设为此后所有新会话的默认模型 (改的是 CLI 的全局设置, 不指定模型的新 wizard 都会跟着变)。"),
544
563
  },
545
- }, async ({ model, name, scope }) => unwrap("set_model", await daemonPost("/wizard/model", { model, ...(name ? { name } : {}), ...(scope ? { scope } : {}) })));
564
+ }, async ({ model, effort, name, scope }) => unwrap("set_model", await daemonPost("/wizard/model", { ...(model ? { model } : {}), ...(effort ? { effort } : {}), ...(name ? { name } : {}), ...(scope ? { scope } : {}) })));
546
565
  // 生孩子与分身是两个操作, 不是一个开关的两档: 一个白纸起步、可以去别的目录;
547
566
  // 一个 fork 调用方此刻的上下文、必须留在原地。拆成两个工具, 模型选的是动词,
548
567
  // 而不是在一个布尔上猜。两者落到同一条路由 (`inherit` 由工具定死)。
@@ -550,13 +569,15 @@ const offspringShape = {
550
569
  description: z.string().describe("它负责什么, 一句话。它会写进它的系统提示, 也会出现在名册里让别人看到。"),
551
570
  name: z.string().optional().describe("它的名字 (如 'docs'、'fix', 带不带 '.' 都行)。全机唯一。省略则按 description 首词生成。"),
552
571
  task: z.string().optional().describe("就位后立刻派下去的第一件活 (私聊)。省略则它就位待命。"),
553
- chat: z.string().optional().describe("把它生在另一个聊天里 (list_chats 里的名字)。省略 = 你自己的聊天, 这是绝大多数情况。"),
572
+ chat: z.string().optional().describe("把它生在另一个聊天里 (wizard_roster 里的 home 聊天名)。省略 = 你自己的聊天, 这是绝大多数情况。"),
554
573
  cli: z.enum(["claude", "claude-internal", "codebuddy"]).optional().describe("用哪个 CLI。省略则继承。"),
555
- model: z.string().optional().describe("跑在哪个模型上, 口语化随便写 ('opus' / 'sonnet 5' / '最新的 opus' / 'claude-haiku-4-5' 都行) —— 不是塞给启动参数, 而是等 pane 起来后打开它自己的 `/model` 列表, 在里面挑最接近的一项选中, 所以返回里的 `model` 是列表里真正落地的那一项 (如 'Opus 5.5'), 可能跟你传的字符串不一样; 列表里对不上会带 `modelWarning`, 那时它还在跑但停在了原来的模型上, 不是失败。省略用该 CLI 的默认。要判断力的给 opus, 跑腿的 (grep、跑测试、照着清单改) 给 haiku —— 一批不必齐步走。之后要换用 set_model。"),
574
+ model: z.string().optional().describe(MODEL_DOC),
575
+ effort: z.enum(EFFORTS).optional().describe(EFFORT_DOC),
556
576
  job: z
557
577
  .string()
558
578
  .optional()
559
- .describe("归到某个工单名下 (open_job 给的 id)。它们攒到 close_job 那一条里一起交代, 过程在各自的 rolepage; close_job 还会把它们整批回收掉。"),
579
+ .describe("归到某个工单名下 (open_job 给的 id)。过程在各自的 rolepage 与工单页, 不进群; close_job 会把它们整批回收掉。"),
580
+ chain: z.boolean().optional().describe("同 tell_peer 的 `chain`: 带了 task 而这件活与你此刻在答的上游那件无关 (旁支、测试) 时给 false, 它的回执就不挂住你给上游的交代。"),
560
581
  keepalive: z
561
582
  .boolean()
562
583
  .optional()
@@ -568,28 +589,32 @@ const bear = (tool, inherit) => async (a) => unwrap(tool, await daemonPost("/wiz
568
589
  ...(a.name ? { name: a.name } : {}),
569
590
  ...(a.task ? { task: a.task } : {}),
570
591
  ...(a.from ? { from: a.from } : {}),
592
+ ...(a.detached ? { detached: true } : {}),
571
593
  ...(a.job ? { job: a.job } : {}),
572
594
  ...(a.cwd ? { cwd: a.cwd } : {}),
573
595
  ...(a.chat ? { chat: a.chat } : {}),
574
596
  ...(a.cli ? { cli: a.cli } : {}),
575
597
  ...(a.model ? { model: a.model } : {}),
598
+ ...(a.effort ? { effort: a.effort } : {}),
576
599
  ...(a.keepalive !== undefined ? { keepalive: a.keepalive } : {}),
600
+ ...(a.chain === false ? { chain: false } : {}),
577
601
  }));
578
- const OFFSPRING_TAIL = "名字全局唯一: 撞上的 wizard 静默超过一天就直接顶掉它、名字归新的; 撞上一个还在的 wizard 返回 409 (附它的死活), 返回里的 `name` 是落定的名字。之后用 send_peer 继续派活、wait_peer 等它做完、stop_wizard 收掉它。它自己也能再 spawn_wizard / clone_wizard, 层级不限。每一个都有成本 (一个 pane + 一份上下文), 名下同时活着的有上限; 任务少于两三件时你自己做完更快。";
602
+ const OFFSPRING_TAIL = "名字全局唯一: 撞上的 wizard 静默超过一天就直接顶掉它、名字归新的; 撞上一个还在的 wizard 返回 409, 附 `alive` / `busy` / `idleForMs` —— 据此分辨它是真在干活 (换个名字) 还是冷绑定 (先 stop_wizard 再同名重生); 返回里的 `name` 是落定的名字。 它自己也能再生分身, 层级不限; 名下同时活着的有上限。";
579
603
  server.registerTool("spawn_wizard", {
580
604
  title: "Spawn a blank child wizard",
581
605
  description: "**从白板生**一个子 wizard —— 新的 tmux pane、自己的全局名字 (`.name`)、自己的职责, 归你管 (家谱里挂在你名下, 工单收工时可整批回收)。它**不继承你的任何上下文**, 只拿到身份。适合干一件与你手头无关的事, 或者要去别的目录 (`cwd`) / 别的聊天里干活。要一个开局就带着你读过的材料的, 用 clone_wizard。\n" +
582
- "带上 `task` 可以在它就位的同时把第一件活私聊派下去, 省掉一次 send_peer; 要它的回复进群, 就别带 task, 就位后用 `send_peer({public:true})` 转给它。" +
606
+ "带上 `task` 可以在它就位的同时把第一件活私聊派下去, 省掉一次 tell_peer; 要它的回复进群, 就别带 task, 就位后用 `tell_peer({public:true})` 转给它。" +
583
607
  OFFSPRING_TAIL,
584
608
  inputSchema: {
585
609
  ...offspringShape,
586
610
  cwd: z.string().optional().describe("它的工作区绝对路径。省略 = 跟你同一个目录。"),
611
+ detached: z.boolean().optional().describe("true = 独立长住: 不挂在你名下 (不占分身名额、不随工单回收、不带 job), 等价于人在群里 `/new .name` —— 要一个往后一直在的新 wizard 时用; 干完一件活就收的别用。"),
587
612
  },
588
613
  }, bear("spawn_wizard", false));
589
614
  server.registerTool("clone_wizard", {
590
615
  title: "Clone a wizard, context and all",
591
616
  description: "**克隆**一个 wizard 出分身 —— fork 它此刻的上下文: 分身开局就拥有它已经读过的一切 (规范、目录结构、刚啃完的那份文档), 不必重读; 被克隆的那个毫发无损。默认克隆你自己; `from` 点名就克隆别的 wizard (比如一个已经把某个模块啃透的同伴) —— 分身仍归你管 (占你的名额、随你的工单回收), 只是上下文来自它。分身留在被克隆者的工作区。\n" +
592
- "这是编排一组「共享同一批材料」的任务的正确姿势: 先让一个 wizard (你自己或某个同伴) 把公共材料读进上下文, 再从它克隆出 N 个分身, 材料只读一遍却进了 N 份上下文。要白纸一张、或要去别的目录, 用 spawn_wizard。被克隆者正在干活时, 分身拿到的是它此刻为止的上下文 —— 想要它读完再分, 先 wait_peer 等它停下。\n" +
617
+ "要白纸一张、或要去别的目录, 用 spawn_wizard。被克隆者正在干活时, 分身拿到的是它此刻为止的上下文 —— 想要它读完再分: 是你派的活就等它的回执, 否则先 `wait_peer` 等它停下 (或看 wizard_roster 的忙闲)。\n" +
593
618
  "带上 `task` 可以在它就位的同时把第一件活私聊派下去 (分叉本来就由第一句话触发, 省一次往返)。" +
594
619
  OFFSPRING_TAIL,
595
620
  inputSchema: {
@@ -599,26 +624,28 @@ server.registerTool("clone_wizard", {
599
624
  }, bear("clone_wizard", true));
600
625
  // ── Job: 一次 fan-out 的工单 ────────────────────────────────────────────────
601
626
  // 工单不是第二个编排器: 控制流始终在你自己的上下文里 (你自己 spawn、自己 wait、
602
- // 自己汇总)。守护进程只替你记一本账 —— 谁属于这个活、谁是临时生的、群里出哪两条
603
- // 气泡、收工时该回收谁。
627
+ // 自己汇总)。守护进程只替你记一本账 —— 谁属于这个活、谁是临时生的、各自落成了什么、
628
+ // 收工时该回收谁。工单整个不进群。
604
629
  server.registerTool("open_job", {
605
630
  title: "Open a job for a fan-out",
606
- description: "开一个**工单**: 你接下来要同时派出两个以上的分身干同一件事时, 先开它。返回一个 id, 把这个 id 传给 spawn_wizard / clone_wizard / send_peer 的 `job` 参数, 它们就归到这个工单名下。\n" +
607
- "开了工单之后有三件事不一样: ① 群里出「开工」与 close_job 的「收工」两条气泡, 人据此读得出这批活的结构 (过程在各自的 rolepage 里, 收工那条会把成员和各自那段活列出来)。② close_job 会把为这个工单生出来的分身**整批回收**, 不必一个个 stop_wizard —— 忘记回收是常态, 每个分身都占着一个 pane 和一份上下文。③ list_jobs 能看到还开着哪些活。\n" +
608
- "派活的文本只写活本身: 每个分身收到的那一轮都带着信封, 已经要求它把结论收口成 `RESULT: …` (交付物写进文件就回传路径); wait_peer 摘到就只回这一行, 你汇总时不必从八百字里找结论。\n" +
631
+ description: "开一个**工单**: 你接下来要同时派出两个以上的分身干同一件事时, 先开它。返回一个 id, 把这个 id 传给 spawn_wizard / clone_wizard / tell_peer 的 `job` 参数, 它们就归到这个工单名下。\n" +
632
+ "开了工单之后有三件事不一样: ① 带这个 id 的派活一律私聊, 开工、派活、回执、收工都**不进群** —— 结构在 rolepage 的工单页里, 人在群里只看到你自己那一轮的最终回复。② close_job 会把为这个工单生出来的分身**整批回收**, 不必一个个 stop_wizard —— 忘记回收是常态, 每个分身都占着一个 pane 和一份上下文。③ list_jobs 能看到还开着哪些活。④ 默认验收: 成员交上来的答复没有非空的 `RESULT:` (按 `accept`), 守护进程以你的名义同件号打回一次, 那一份不投给你、不计数, 也不占 `maxTurns`; 再交上来的照收。\n" +
609
633
  "只派一个分身、或者只是推某个同伴一把, 不用开工单。",
610
634
  inputSchema: {
611
- title: z.string().describe("一句话说清这个工单要干成什么 —— 它会出现在群里的开工气泡上。"),
612
- plan: z.string().optional().describe("要在开工气泡里一并说明的计划 (打算分几路、各干什么)。省略则只出标题。"),
635
+ title: z.string().describe("一句话说清这个工单要干成什么 —— 工单页与收工留档的标题。"),
636
+ plan: z.string().optional().describe("计划 (打算分几路、各干什么), 记进工单账本给工单页读。"),
637
+ expect: z.number().optional().describe("这批一共要几份回执。分身是陆续派的, 给了它「全部到齐」就不会在派齐之前提前报。"),
638
+ maxTurns: z.number().optional().describe("派活次数的预算: 带这张工单的每次 tell_peer (含 re 续问、答 NEED) 与带 task 的 spawn/clone 各记一次, 用完再派会被拒 —— 防反复追问兜圈。守护进程按 `accept` 代你打回的那一次不算。省略 = 不限。"),
639
+ accept: z.enum(["result", "artifact", "none"]).optional().describe("成员交差的验收: `result` (默认) 要有非空 RESULT; `artifact` 还要列出 ARTIFACT 交付物; `none` 不验。不合格的自动打回一次。"),
613
640
  },
614
- }, async ({ title, plan }) => unwrap("open_job", await daemonPost("/jobs/open", { title, ...(plan ? { plan } : {}) })));
641
+ }, async ({ title, plan, expect, maxTurns, accept }) => unwrap("open_job", await daemonPost("/jobs/open", { title, ...(plan ? { plan } : {}), ...(expect ? { expect } : {}), ...(maxTurns ? { maxTurns } : {}), ...(accept ? { accept } : {}) })));
615
642
  server.registerTool("close_job", {
616
643
  title: "Close a job and recycle its clones",
617
- description: "收工: 把汇总结论发进群 (连同成员清单和各自那段活, 每个名字挂它自己的 rolepage), 并**把为这个工单生出来的分身整批回收**。被拉来帮忙的长期 wizard 不在回收之列, 你自己也不会被收。\n" +
618
- "拿到所有分身的结果、汇总完就调它 —— 分身留着不收, 下一次编排就会撞到分身上限。`stop:false` 只结账不回收 (那些分身后面还有用)。",
644
+ description: "收工: 把汇总结论连同成员与各自那段活**留档** (情景记忆, 不发群), 并**把为这个工单生出来的分身整批回收**。被拉来帮忙的长期 wizard 不在回收之列, 你自己也不会被收。\n" +
645
+ "汇总完就调它。给人的结论写在你自己这一轮的最终回复里 —— 人在群里问的, 那条回复就进群。`stop:false` 只结账不回收 (那些分身后面还有用)。",
619
646
  inputSchema: {
620
647
  job: z.string().describe("open_job 返回的工单 id。"),
621
- summary: z.string().optional().describe("汇总结论, 发进群给人看。这是人在群里看到的唯一一条结果 —— 写清楚做成了什么、有什么没做成。"),
648
+ summary: z.string().optional().describe("汇总结论, 留档 (不发群): 做成了什么、有什么没做成。"),
622
649
  stop: z.boolean().optional().describe("是否回收为这个工单生出来的分身。默认 true。"),
623
650
  },
624
651
  }, async ({ job, summary, stop }) => unwrap("close_job", await daemonPost("/jobs/close", { job, ...(summary ? { summary } : {}), ...(stop === false ? { stop: false } : {}) })));
@@ -629,13 +656,15 @@ server.registerTool("list_jobs", {
629
656
  }, async () => unwrap("list_jobs", await daemonPost("/jobs/list", {})));
630
657
  server.registerTool("stop_wizard", {
631
658
  title: "Interrupt or end another wizard",
632
- description: "收掉一个 wizard/分身。mode='interrupt' 只打断它当前这一轮 (等价于群里的 /stop, 它还活着, 可以继续派活); mode='end' 结束它并回收 tmux pane (等价于 /kill, 之后再找它会重新长出一个空白会话)。活干完了就把临时分身 end 掉 —— 每个分身都占着一个 pane 和一份上下文。加 forget=true 连它的身份记录一起抹掉 (名字、职责、记忆), 只在它彻底不会再回来时用。用户说「让 .x 停下」「把那些分身收了」时调它。终结自己也是合法的 (分身干完活自我了结), 只是这次调用不会返回 (这类生命周期事件不进群, 记录在 rolepage)。",
659
+ description: "收掉一个 wizard/分身。mode='interrupt' 只打断它当前这一轮 (等价于群里的 /stop, 它还活着, 可以继续派活); mode='end' 结束它并回收 tmux pane (等价于 /kill, 之后再找它会重新长出一个空白会话)。加 forget=true 连它的身份记录一起抹掉 (名字、职责、记忆), 只在它彻底不会再回来时用。用户说「让 .x 停下」「把那些分身收了」时调它。终结自己也是合法的 (分身干完活自我了结), 只是这次调用不会返回 (这类生命周期事件不进群, 记录在 rolepage)。",
633
660
  inputSchema: {
634
661
  name: z.string().describe(ADDRESS_DOC),
635
662
  mode: z.enum(["end", "interrupt"]).optional().describe("'end' 结束并回收 pane (默认); 'interrupt' 只打断当前这一轮。"),
636
663
  forget: z.boolean().optional().describe("仅对 end 有效: 连身份记录 (名字/职责/记忆) 一起删除。默认 false —— 身份留着, 下次它回来还是它。"),
664
+ turn: z.string().optional().describe("仅对 interrupt: 只撤回**你自己派的**这一件 (回执 / tell_peer 回包里的件号)。它正在做的就是这件才按 Esc; 还排着或它在做别人的活, 就只把这件记成取消, 不碰它手上那一轮。"),
665
+ force: z.boolean().optional().describe("仅对 interrupt: 它此刻在做别的 wizard 派的活时, 不带 turn 的打断默认被拒 (会误伤那件); 确要打断加 true。"),
637
666
  },
638
- }, async ({ name, mode, forget }) => unwrap("stop_wizard", await daemonPost("/wizard/stop", { name, ...(mode ? { mode } : {}), ...(forget ? { forget } : {}) })));
667
+ }, async ({ name, mode, forget, turn, force }) => unwrap("stop_wizard", await daemonPost("/wizard/stop", { name, ...(mode ? { mode } : {}), ...(forget ? { forget } : {}), ...(turn ? { turn } : {}), ...(force ? { force } : {}) })));
639
668
  server.registerTool("wizard_remember", {
640
669
  title: "Write something into your long-term memory",
641
670
  description: "写一条长期记忆。它不在对话里 —— 每次 wizard (重)开会话时重新压进系统提示, 所以能跨 /clear、跨交接、跨重启活下来。三种作用域: `self` (默认) 只属于你; `chat` 是**本群共享**的记忆 (这个群里人的习惯、约定), 在这个群出生的每个 wizard 都会读到; `workspace` 是**本工作区共享**的记忆 (这个仓库的硬约束、踩过的坑、'发版前必须更新 CHANGELOG' 这种规矩), 在这个目录下干活的每个 wizard 都会读到。后两者存成 markdown (`~/.wezard/memory/…`), 人也会直接改; 你写的只是一条**提议**, 由定时的记忆整理者去重、改写、合并 (半小时内), 所以不必先读全文查重, 也别为改一个字反复提交。一条一句话, 越具体越有用; 选对作用域 —— 属于群/仓库的别只记在自己身上。传 forget (子串匹配) 删掉 / 提议删掉过时的那条。别拿它存这次任务的临时状态 —— 那种东西属于交接简报。",
@@ -650,12 +679,10 @@ server.registerTool("wizard_remember", {
650
679
  ...(forget ? { forget } : {}),
651
680
  })));
652
681
  server.registerTool("wizard_handoff_self", {
653
- title: "Hand your own work over to a fresh context",
654
- description: "给自己做交接: 你把当前工作压成一份自洽的简报写在 `brief` 里, 守护进程等你这一轮说完、会话空下来之后, 给你 `/new` 一个全新的会话 (新进程 —— MCP 工具也换成最新的; 上下文清零, 名字 / cwd / 模型 / 身份的系统提示照旧), 再把简报作为新会话的第一条消息贴回去。wizard_whoami 的 handoffSuggested=true, 或者你自己感觉上下文塞满了、开始记不住前面的事时, 主动调它 —— 不必等人下令。简报要写到「零上下文的自己仅凭它就能接着干」: 总目标 / 已完成与关键决策 / 当前状态 (改到哪、什么能跑、什么没跑通) / 下一步 (有序) / 关键文件路径与非显然的坑。要交接的是别人 (某个分身上下文爆了), 用 handoff 而不是这个。",
655
- inputSchema: {
656
- brief: z.string().describe("交接简报全文。自洽、具体、可执行 —— 接手的是一个什么都不记得的你。"),
657
- },
658
- }, async ({ brief }) => unwrap("wizard_handoff_self", await daemonPost("/wizard/handoff-self", { brief })));
682
+ title: "(deprecated) alias of handoff for yourself",
683
+ description: "同 `handoff({brief})` 不传 name —— 交接你自己 (旧名, 只为不打断正在跑的旧会话而保留)。新调用一律用 handoff。",
684
+ inputSchema: { brief: z.string().describe("交接简报全文。") },
685
+ }, async ({ brief }) => handoffSelf("wizard_handoff_self", brief));
659
686
  const transport = new StdioServerTransport();
660
687
  await server.connect(transport);
661
688
  //# sourceMappingURL=server.js.map