wezard 1.3.23 → 1.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 (43) hide show
  1. package/CLAUDE.md +4 -2
  2. package/README.md +43 -37
  3. package/dist/daemon/chat-name.js +65 -2
  4. package/dist/daemon/chat-name.js.map +1 -1
  5. package/dist/daemon/detail.js +7 -0
  6. package/dist/daemon/detail.js.map +1 -1
  7. package/dist/daemon/graph.js +25 -3
  8. package/dist/daemon/graph.js.map +1 -1
  9. package/dist/daemon/inbound.js +114 -58
  10. package/dist/daemon/inbound.js.map +1 -1
  11. package/dist/daemon/index.js +773 -125
  12. package/dist/daemon/index.js.map +1 -1
  13. package/dist/daemon/jobs.js +91 -0
  14. package/dist/daemon/jobs.js.map +1 -0
  15. package/dist/daemon/mirror-bridge.js +194 -14
  16. package/dist/daemon/mirror-bridge.js.map +1 -1
  17. package/dist/daemon/mirror-store.js.map +1 -1
  18. package/dist/daemon/notices.js +70 -0
  19. package/dist/daemon/notices.js.map +1 -0
  20. package/dist/daemon/peers.js +93 -61
  21. package/dist/daemon/peers.js.map +1 -1
  22. package/dist/daemon/spawn-tmux.js +46 -4
  23. package/dist/daemon/spawn-tmux.js.map +1 -1
  24. package/dist/daemon/tasks.js +90 -0
  25. package/dist/daemon/tasks.js.map +1 -0
  26. package/dist/daemon/wizard.js +151 -0
  27. package/dist/daemon/wizard.js.map +1 -0
  28. package/dist/mcp/server.js +236 -102
  29. package/dist/mcp/server.js.map +1 -1
  30. package/dist/shared/cli-backends.js +10 -0
  31. package/dist/shared/cli-backends.js.map +1 -1
  32. package/dist/shared/config.js +50 -14
  33. package/dist/shared/config.js.map +1 -1
  34. package/dist/shared/detail-render.js +3 -2
  35. package/dist/shared/detail-render.js.map +1 -1
  36. package/dist/shared/detail-store.js.map +1 -1
  37. package/dist/shared/schedule-spec.js +240 -0
  38. package/dist/shared/schedule-spec.js.map +1 -0
  39. package/dist/shared/session-label.js +8 -4
  40. package/dist/shared/session-label.js.map +1 -1
  41. package/package.json +1 -1
  42. package/dist/daemon/topics.js +0 -106
  43. package/dist/daemon/topics.js.map +0 -1
@@ -144,7 +144,7 @@ server.registerTool("wrc", {
144
144
  // the switch.
145
145
  server.registerTool("set_workspace", {
146
146
  title: "Switch workspace directory",
147
- description: "Switch this chat's agent session to a different project directory in ONE shot: kill the current pane and spawn a FRESH session rooted at the given cwd — equivalent to a /new into that directory. The chat receives the new session's 📂 project-info bubble as the receipt; conversation context is NOT carried over (fresh session, same as /new). If the caller is the session being replaced it is terminated mid-call — expected, the bubble is the receipt. Use absolute paths (or paths starting with ~).",
147
+ description: "一步换掉这个 wizard 的**工作区**: 杀掉当前 pane, 在给定目录下重开一个全新的会话 —— 等价于往那个目录 `/new`。群里收到新会话的 📂 项目信息气泡当回执; 对话上下文**不会**带过去 (和 /new 一样是全新会话, 但身份的系统提示还在)。调用方就是被替换的那一个时, 它在调用当口就被终结 —— 这是预期行为, 群里那条气泡就是回执。用绝对路径 (或 `~` 开头)。想换目录又想保住手上的上下文: 先 wizard_handoff_self 把工作压成简报, 或者 spawn_clone({inherit:false, cwd}) 让一个分身去那边干。",
148
148
  inputSchema: {
149
149
  cwd: z.string().describe("Absolute project path, e.g. /Users/foo/projects/bar. ~ is expanded."),
150
150
  target: z
@@ -229,7 +229,7 @@ server.registerTool("wecom_doc_call", {
229
229
  // host-wide /proc + tmux scan (an MCP tool can only see its OWN session).
230
230
  server.registerTool("list_claude_sessions", {
231
231
  title: "List running agent sessions",
232
- description: "List all agent sessions currently running in tmux on this host (claude / claude-internal / codebuddy backends alike), each with a stable animal-emoji label, its working directory, tmux location, and a short summary of what it's recently been doing. Call this whenever the user asks to see / list / switch between sessions (e.g. '列出所有 claude session', '有哪些会话在跑', '我想切换 session'). Present the result to the user as a readable numbered list (emoji + dir + summary), and note which one is the current mirror target (`current: true`).",
232
+ description: "本机 tmux 里**所有**正在跑的 agent 会话 (claude / claude-internal / codebuddy 都算), 每个带一个稳定的动物 emoji、工作目录、tmux 位置和最近在干嘛的一行摘要。注意这是**机器级**的清单: 里面既有绑定了聊天的 wizard, 也有人在终端里自己开的、与企微无关的会话。用户说「列出所有 session」「有哪些会话在跑」「我想切换 session」时调它, 结果按 emoji + 目录 + 摘要 排成可读的编号列表, 并标出当前正被镜像的那个 (`current: true`)。只想看 wizard (名字/职责/家谱/忙闲) 用 wizard_roster。",
233
233
  inputSchema: {},
234
234
  }, async () => {
235
235
  const resp = await fetch(`${DAEMON_BASE}/sessions/list`, { method: "GET" });
@@ -238,7 +238,7 @@ server.registerTool("list_claude_sessions", {
238
238
  });
239
239
  server.registerTool("switch_claude_session", {
240
240
  title: "Switch WeCom mirror to another agent session",
241
- description: "Re-point the WeCom mirror at a different already-running agent session, so the user's IM chat starts mirroring (and injecting into) that session instead. Call this when the user picks a session to switch to — e.g. '切到 wezard 那个', '镜像第2个', '换到 🦊 那个会话'. First call list_claude_sessions to resolve the user's natural-language reference (emoji / directory / topic) to a concrete sessionId, then pass that sessionId here.",
241
+ description: "把企微镜像**改接**到另一个已经在跑的会话上 —— 从此这个聊天镜像的、注入的都是它。换句话说: 让那个会话成为这个聊天的 wizard。用户挑了一个要切过去时调 —— 「切到 wezard 那个」「镜像第 2 个」「换到 🦊 那个会话」。先用 list_claude_sessions 把用户的自然语言指代 (emoji / 目录 / 话题) 解析成具体 sessionId, 再传进来。",
242
242
  inputSchema: {
243
243
  sessionId: z.string().describe("The target session's sessionId (a UUID), as returned by list_claude_sessions."),
244
244
  },
@@ -251,12 +251,11 @@ server.registerTool("switch_claude_session", {
251
251
  const j = (await resp.json().catch(() => ({})));
252
252
  return j.ok ? ok(j) : fail(`switch_claude_session failed: ${j.reason ?? `http ${resp.status}`}`);
253
253
  });
254
- // ── Peer collaboration (agent ↔ agent inside one WeCom chat) ───────────────
255
- // One WeCom chat can host several concurrent agent sessions, each addressed by
256
- // a `#tag` (`#fix`, `#docs`, …) and each free to run a different CLI / model /
257
- // project. They are peers: siblings that can watch and drive each other. This
258
- // process can only see ITSELF, so every question about a sibling goes to the
259
- // daemon, which owns all the attachments.
254
+ // ── wizard ↔ wizard (一个聊天里的同伴) ────────────────────────────────────
255
+ // 一个企微聊天里可以同时住着好几个 wizard, 每个用 `#tag` 寻址 (`#fix`、`#docs`…),
256
+ // 各自可以跑不同的 CLI / 模型 / 项目。它们互为同伴 (peer): 看得见彼此, 也驱动得动
257
+ // 彼此 —— 下面这组工具就是那条通路。本进程只看得见**自己**, 所以任何关于同伴的
258
+ // 问题都要问守护进程, 它才是持有全部 attachment 的那个。
260
259
  //
261
260
  // `selfRef` is how the daemon figures out which session is asking: sessionId
262
261
  // from env (frozen at MCP spawn — goes stale after a `/clear`) plus TMUX_PANE
@@ -277,36 +276,40 @@ const daemonPost = async (path, body) => {
277
276
  return { j: (await resp.json().catch(() => ({}))), status: resp.status };
278
277
  };
279
278
  const unwrap = (name, { j, status }) => j.ok ? ok(j) : fail(`${name} failed: ${j.reason ?? `http ${status}`}`);
280
- // The one address grammar, restated in full wherever a tool takes one: a model
281
- // reading a single tool schema in isolation has no other place to learn it, and
282
- // a guessed address silently resolves to the wrong agent's terminal.
283
- const ADDRESS_DOC = "Peer address. `''` = this chat's own default (untagged) session. A bare tag like `'fix'` means THIS chat's `#fix`, falling back to a GLOBALLY UNIQUE `#fix` in some other chat. `'daily#fix'` names the chat outright — the reliable cross-chat form, and the only one that works when several chats each hold a `#fix`. Never invent one: list_peers and list_chats return the exact string to pass, as `address`.";
284
- // Creating a session is a peer operation, not a global one: it lands in the
285
- // caller's own chat (hence `selfRef` via daemonPost) under its own `#tag`, or —
286
- // with `chat` — in another NAMED chat, which is what chat naming buys.
279
+ // 地址语法只有一套, 每个吃地址的工具都把它原样重述一遍: 模型单看一个 schema
280
+ // 时没有别的地方能学到它, 而猜出来的地址会安静地指向另一个 wizard 的终端。
281
+ const ADDRESS_DOC = "wizard 的地址。`''` = 本聊天的默认 wizard (没有 `#tag` 的那个)。裸 tag 如 `'fix'` = 本聊天的 `#fix`; 本聊天没有就退回到全机唯一的那个 `#fix`。`'daily#fix'` 直接指名聊天 —— 跨聊天可靠的形式, 也是好几个聊天各有一个 `#fix` 时唯一有效的形式。永远别自己拼: wizard_roster / list_peers / list_chats 返回的 `address` 就是要原样传回来的那个串。";
282
+ // 造一个 wizard 是本地操作, 不是全局操作: 它落在调用方自己的聊天里 (所以走
283
+ // `selfRef`), 带自己的 `#tag`; 或者 —— 给了 `chat` —— 落在另一个**起过名字**的
284
+ // 聊天里, 这正是给聊天起名换来的东西。
287
285
  server.registerTool("new_claude_session", {
288
- title: "Spawn a new peer session",
289
- description: "Spawn a brand-new agent session in a fresh tmux pane rooted at the given project path, as a `#tag` PEER — by default of THIS chat, exactly what the user gets by typing `/new #tag` here. The peer posts into that chat (its bubbles are headed `emoji #tag`, and it shows up in chat detail), and you can drive it afterwards with list_peers / peek_peer / send_peer / wait_peer. Call this when the user asks to start a new session somewhere — e.g. '在 /path/to/proj 下新建一个 claude session', '帮我在 xxx 目录起个新会话', '再开一个 agent 干这件事'. Pass `chat` to create it in ANOTHER chat instead ('在 daily 群里开一个 #ingest 跑这个目录') — that chat must have a name (list_chats shows them); this is the way to stand up a cross-chat collaborator that doesn't exist yet, instead of asking a human to go type `/new` over there. The directory is created if it doesn't exist. Never takes over a chat's default session.",
286
+ title: "Spawn a blank new wizard",
287
+ description: "在指定项目目录下长出一个**全新的 wizard** —— 自己的 tmux pane、自己的 `#tag` 地址, 默认活在你这个聊天里, 等价于人在群里敲 `/new #tag`。它**不继承任何上下文**(白纸一张): 要一个开局就带着你读过的材料的分身, 用 spawn_clone({inherit:true})。新 wizard 在群里说话时气泡头是 `emoji #tag`, 之后用 wizard_roster / peek_peer / send_peer / wait_peer 驱动它。用户说「在 /path 下新建一个会话」「帮我在 xxx 目录起个 agent」时调它。给 `chat` 就把它生在**另一个**聊天里(「在 daily 群里开一个 #ingest 跑这个目录」)—— 那个聊天必须起过名字(list_chats 能看到); 这是让一个还不存在的跨群协作者就位的办法, 不必让人跑去那边手敲 `/new`。目录不存在会自动创建。绝不会顶掉一个聊天的默认 wizard。",
290
288
  inputSchema: {
291
289
  cwd: z.string().describe("Absolute project path to start the new session in, e.g. /Users/foo/projects/bar. Created if missing."),
292
290
  tag: z
293
291
  .string()
294
292
  .optional()
295
- .describe("Tag to address the new peer by, WITHOUT '#' (e.g. 'fix', 'docs'). Pick a short name describing its job; use it later with send_peer / peek_peer. Must not collide with an existing peer in the target chat — call list_peers / list_chats first if unsure. Omitted → derived from the directory name."),
293
+ .describe("新 wizard 的 tag, 不带 '#' (如 'fix'、'docs') —— 它既是地址也是名字, 挑一个说明它干什么的短词, 之后用它 send_peer / peek_peer。目标聊天里不能重名 —— 不确定先 list_peers / list_chats。省略则按目录名生成。"),
296
294
  chat: z
297
295
  .string()
298
296
  .optional()
299
- .describe("Name of the chat to create the peer in (as shown by list_chats). Omit for this chat, which is what the user almost always means. Only NAMED chats can be targeted — an unnamed one has no address, so someone must run `/name <name>` in it first."),
297
+ .describe("把它生在哪个聊天里 (list_chats 里显示的名字)。省略 = 你自己的聊天, 用户绝大多数时候指的就是这个。只有**起过名字**的聊天能被指名 —— 没名字就没有地址, 得先有人在那边发一次 `/name <名字>`。"),
300
298
  cli: z
301
299
  .enum(["claude", "claude-internal", "codebuddy"])
302
300
  .optional()
303
- .describe("Which CLI to launch. Omit unless the user names one (e.g. '用 codebuddy 起一个'); the peer then inherits that chat's current backend. Multiple backends can run side by side."),
301
+ .describe("用哪个 CLI 启动。用户没点名就省略, 它会继承那个聊天当前的后端。多个后端可以并存。"),
302
+ model: z
303
+ .string()
304
+ .optional()
305
+ .describe("这个 wizard 跑在哪个模型上 (`--model` 的 slug, 如 'opus' / 'sonnet' / 'haiku')。省略用该 CLI 的默认。同一个聊天里的 wizard 可以各跑各的模型 —— 又长又要判断的活给 opus, 跑腿的 lint/grep 给 haiku。"),
304
306
  },
305
- }, async ({ cwd, tag, chat, cli }) => unwrap("new_claude_session", await daemonPost("/sessions/new", {
307
+ }, async ({ cwd, tag, chat, cli, model }) => unwrap("new_claude_session", await daemonPost("/sessions/new", {
306
308
  cwd,
307
309
  ...(tag ? { tag } : {}),
308
310
  ...(chat ? { chat } : {}),
309
311
  ...(cli ? { cli } : {}),
312
+ ...(model ? { model } : {}),
310
313
  })));
311
314
  // ── Chat naming (the cross-chat address space) ─────────────────────────────
312
315
  // A WeCom chat's identity is an unreadable `chat:wrkS…` id, so before naming,
@@ -315,7 +318,7 @@ server.registerTool("new_claude_session", {
315
318
  // pass, which is what makes `daily#fix` — and spawning into `daily` — possible.
316
319
  server.registerTool("name_chat", {
317
320
  title: "Name this WeCom chat",
318
- description: "Give THIS chat a short name, so agents in other chats can address its sessions as `name#tag` and spawn peers into it. Call this when the user says '给这个群起个名叫 daily' / '把这个聊天命名为 xxx' / '这个群叫什么' (omit `name` to just read the current one) / '取消命名' (pass '-'). Names are unique across the host and case-insensitive; renaming replaces the old name, and any address written against the old one stops resolving. After naming, tell the user the address form their other chats should use (`name#tag`).",
321
+ description: "给**这个聊天**起个短名字, 别的聊天里的 wizard 从此能以 `名字#tag` 叫到这里、也能把新 wizard 生进来。没起过名字的聊天不会一直没名字 —— 第一次有人用到名字时 (名册 / 聊天列表 / 分身出生) 守护进程按它的工作区自动补一个 (`~/develop/foo` → `foo`, 撞名加序号), 所以这个工具的用途是**起一个更好的名字**, 而不是从无到有。聊天的名字同时就是这里默认 wizard 的名字 —— 所以 `wizard_identity({name})` 在默认 wizard 身上会连带写这里。用户说「给这个群起名叫 daily」「这个群叫什么」(不传 `name` 就是读) 「取消命名」(传 '-') 时调它。名字全机唯一、大小写不敏感; 改名即覆盖, 照着旧名字写的地址从此解析不到。起完名告诉用户别的聊天该怎么写地址 (`名字#tag`)。",
319
322
  inputSchema: {
320
323
  name: z
321
324
  .string()
@@ -327,53 +330,86 @@ server.registerTool("name_chat", {
327
330
  : unwrap("name_chat", await daemonPost("/chats/name", { name })));
328
331
  server.registerTool("list_chats", {
329
332
  title: "List every chat and its sessions",
330
- description: "The cross-chat directory: every WeCom chat the daemon knows, its name (empty = unnamed), whether it is the one you live in (`self`), and the sessions running in each with the exact `address` to pass to send_peer / peek_peer / wait_peer. Call this whenever the user points at work outside this chat — '别的群有谁在跑', '把这个交给 daily 群的 agent', '在 sanitizer 群里开个会话' — or when a peer address failed to resolve and you need the real one. Unnamed chats cannot be addressed or spawned into; if the user wants one used, they must run `/name <name>` inside it.",
333
+ description: "跨聊天目录: 守护进程知道的每一个企微聊天、它的名字 (空 = 没起名)、是不是你住的那个 (`self`), 以及每个聊天里住着哪些 wizard 及其 `address`。用户指向这个聊天之外的活时调它 —— 「别的群有谁在跑」「把这个交给 daily 群」「在 sanitizer 群里开个会话」—— 或者一个地址解析失败、你需要真正的那个串时。没起名的聊天既寻址不到也生不进去; 要用它, 得有人在那个群里发一次 `/name <名字>`。",
331
334
  inputSchema: {},
332
335
  }, async () => unwrap("list_chats", await daemonPost("/chats/list", {})));
333
336
  server.registerTool("list_peers", {
334
- title: "List sibling agent sessions in this chat",
335
- description: "List the OTHER agent sessions running in the SAME WeCom chat as this one. A chat hosts one default session plus any number of `#tag` sessions (e.g. `#fix`, `#review`), each with its own tmux pane, CLI, model and working directory. Returns for each peer: its tag, the `address` to pass to the other peer tools, emoji label, cwd, CLI, whether its pane is alive, whether it is mid-turn (`busy`), when it last did anything, and a one-line summary of its recent conversation. `self: true` marks your own session. Also returns `foreignPeers`: reachable sessions in OTHER chats — either their `#tag` is globally unique (plain `send_peer('theirTag')` hits it) or their chat has a name, in which case `address` is the qualified `chatName#tag` form. Always send back the `address` verbatim rather than reassembling one. Call this FIRST whenever the user refers to another agent or tag — '#fix 进展如何', '还有谁在跑', '让 #docs 也看看', '把语料交给 #sanitizer 处理' — then use peek_peer / send_peer / wait_peer to actually collaborate. For chats with no session you can see yet, use list_chats.",
337
+ title: "List the wizards sharing this chat",
338
+ description: "和你住在**同一个聊天**里的其他 wizard。一个聊天里住着一个默认 wizard 加任意多个 `#tag` wizard (`#fix`、`#review`…), 各有各的 pane、CLI、模型和工作区。每一个返回: tag、要传给其他工具的 `address`、emoji、工作区、CLI、pane 是否还活着、此刻是否在生成 (`busy`)、最后动过是什么时候、最近在聊什么的一行摘要; `self: true` 是你自己。另外返回 `foreignPeers`: 别的聊天里你**叫得动**的 wizard —— 要么它的 `#tag` 全机唯一, 要么它的聊天有名字, 那时 `address` 是 `聊天名#tag` 的完整形式。地址一律原样回传, 别自己拼。用户提到另一个 agent 或某个 tag 时先调它 —— 「#fix 进展如何」「还有谁在跑」「让 #docs 也看看」—— 再用 peek_peer / send_peer / wait_peer 真正协作。想连**名字、职责、家谱**一起看 (谁是谁生的、它是干什么的), 用 wizard_roster; 想看还没有 wizard 的聊天, 用 list_chats。",
336
339
  inputSchema: {},
337
340
  }, async () => unwrap("list_peers", await daemonPost("/peers/list", {})));
338
341
  server.registerTool("peek_peer", {
339
- title: "Read a sibling agent's conversation",
340
- description: "Observe another agent WITHOUT interrupting it: returns `dialog` — the last N turns of its actual conversation, read from its session transcript, `▸` for what was asked and `◂` for what it answered — plus whether it is currently mid-turn (`busy`) and its most recent complete reply (`lastText`). This is the readable record of what that agent and whoever drives it have been saying; use it to answer '查看 #fix 的进展', '他们聊到哪了', or to decide whether a peer needs a nudge. A `#tag` written INSIDE a user message means that peer: the daemon appends a system-reminder naming every mentioned tag that resolves to a live session, so `#b` in the prompt is peer `b` — peek it here instead of guessing what it is doing or answering on its behalf. If the peer has no readable transcript yet, `pane` falls back to its raw terminal tail. `foreign: true` in the reply means the tag resolved to a session in another chat. Read-only and safe to poll.",
342
+ title: "Read what another wizard has been saying",
343
+ description: "**不打扰**地观察另一个 wizard: 返回 `dialog` —— 它最近 N 轮真实对话 (从它的 transcript 读的, `▸` 是别人说的, `◂` 是它答的), 外加它此刻是否在生成 (`busy`) 与它最后一条完整回复 (`lastText`)。这是「它和驱动它的人到底说了什么」的可读记录: 回答「#fix 进展如何」「它们聊到哪了」, 或者判断要不要推它一把, 都读这里。用户消息里写的 `#tag` 指的就是那个 wizard —— 守护进程会在消息尾部挂一条 system-reminder 点名每一个解析得出的 tag, 所以 prompt 里的 `#b` 是 wizard `b`: 去 peek 它, 别猜它在干嘛, 更别替它回答。它还没有可读 transcript 时, `pane` 兜底给它终端的原始尾巴。`foreign: true` 表示这个 tag 落在别的聊天里。只读, 随便轮询。",
341
344
  inputSchema: {
342
345
  tag: z.string().describe(ADDRESS_DOC),
343
346
  turns: z.number().optional().describe("How many recent conversation turns to return (1-40, default 6)."),
344
347
  },
345
348
  }, async ({ tag, turns }) => unwrap("peek_peer", await daemonPost("/peers/peek", { tag, ...(turns ? { turns } : {}) })));
346
349
  server.registerTool("send_peer", {
347
- title: "Send a message into a sibling agent's session",
348
- description: "Type a message into another agent's session, exactly as if the user had sent it there — the peer picks it up as a new turn. This is how you DRIVE a peer: unblock it, answer its question, hand it work, or tell it to keep going. Typical loop for '推动 #fix 直到结束': peek_peer → send_peer with the nudge → wait_peer until it goes idle → peek_peer again. Cross-chat handoff (e.g. daily pipeline → sanitizer): the target agent lives in a DIFFERENT WeCom chat, addressed either by a globally-unique tag like `#sanitizer-ingest` or, when that chat has a name, in full as `sanitizer#ingest`; the daemon routes across chats automatically and both chats see the exchange in their timelines. If the peer doesn't exist yet, create it yourself with new_claude_session (pass `chat` for another chat). Refuses to target your own session.",
350
+ title: "Say something to another wizard",
351
+ description: "跟另一个 wizard 说话 —— 文本原样落进它的输入框, 它当成新的一轮接手。这是你**驱动**同伴的唯一方式: 派活、解它的阻塞、回答它的提问、叫它继续。「推动 #fix 干到底」的典型循环: peek_peer 看它在哪 → send_peer 说该说的 → wait_peer 等它停下 → 再 peek。跨聊天同理: 目标 wizard 住在别的群, 用全机唯一的 tag 或 `聊天名#tag` 寻址, 守护进程自己路由。对方还不存在就自己造: 要它继承你的上下文用 spawn_clone, 要一个白纸一张的新 wizard 用 new_claude_session。\n" +
352
+ "**这条消息会以 `你 → 它` 的气泡出现在群里, 人看得见**。所以直说: 要什么、给什么、结论是什么, 不用寒暄、不用引用原文、不用复述它刚说过的话。拒绝对自己发送。",
349
353
  inputSchema: {
350
354
  tag: z.string().describe(ADDRESS_DOC),
351
355
  text: z.string().describe("Message to inject. Plain prompt text; slash commands like '/clear' also work."),
356
+ when: z
357
+ .enum(["now", "idle"])
358
+ .optional()
359
+ .describe("什么时候投。`now` (默认) 立刻投 —— 对方正在生成时这句话会排在它这一轮后面, 回答它的提问、打断它、催它都该用这个。`idle` 先等它闲下来再投: **派一件新活给一个正在忙的同伴时用它**, 否则你和别人的两段文本会挤进同一个输入框被当成一轮读掉。返回里的 `wasBusy` 告诉你投的时候它忙不忙。"),
360
+ waitSec: z.number().optional().describe("`when:'idle'` 最多等多少秒 (10-3600, 默认 600)。等不到就返回失败, 不会强行投。"),
361
+ job: z.string().optional().describe("这次派活归到某个工单名下 (open_job 给的 id) —— 不再单独出气泡, 攒进 close_job 那一条。"),
352
362
  },
353
- }, async ({ tag, text }) => unwrap("send_peer", await daemonPost("/peers/send", { tag, text })));
363
+ }, async ({ tag, text, when, waitSec, job }) => unwrap("send_peer", await daemonPost("/peers/send", { tag, text, ...(when ? { when } : {}), ...(waitSec ? { waitSec } : {}), ...(job ? { job } : {}) })));
364
+ server.registerTool("notify", {
365
+ title: "Post a message into a chat for people to read",
366
+ description: "把一段 markdown 贴进一个企微聊天**给人看**。和 send_peer 分工明确: send_peer 是把话塞进另一个 agent 的输入框 (驱动它干活), notify 是说给人听 —— 不会触发任何一轮对话。\n" +
367
+ "`to` 省略 = 自己所在的聊天 (等于你正常回复, 只是不用等这一轮结束就能先播一条); 要发到别的群就写聊天名 (`list_chats` 里那个), 一次可以写多个。跨群时气泡头自动写成 `源聊天#你` 并挂上你的 chat 详情页链接, 那边的人一眼知道是谁从哪说过来的。\n" +
368
+ "什么时候用: 长活跑完了要通知另一个群的人; 一批分身收工后把汇总播给发起那个群; 定时任务 (schedule_task) 到点跑完把结论送到该看的人那里。别拿它跟同群的人说话 —— 那是你的正常回复。",
369
+ inputSchema: {
370
+ to: z
371
+ .array(z.string())
372
+ .optional()
373
+ .describe("收件聊天。**任何一种地址都收**: 聊天名 (`daily`)、裸 principal (`chat:wr…` / `user:…`), 以及 wizard_roster / list_peers 给的那个 wizard 地址 (`daily#fix`、`chat:wr…#fix`) —— 后者会自动落到它所在的那个聊天, 所以「知道某个 wizard 叫什么」就等于「能往它那个群里说话」, 哪怕那个群没起过名字。省略 = 自己所在的聊天。认不出的整条拒绝并列出来, 不会部分送达。"),
374
+ markdown: z.string().describe("正文, markdown。头 (是谁发的) 由守护进程自动加, 别自己写。"),
375
+ },
376
+ }, async ({ to, markdown }) => unwrap("notify", await daemonPost("/notify", { ...(to ? { to } : {}), markdown })));
354
377
  server.registerTool("wait_peer", {
355
- title: "Wait until a sibling agent finishes its turn",
356
- description: "Block until the named peer stops working (its terminal no longer shows an interrupt hint), then return its latest reply. Use it after send_peer so you act on a finished answer instead of a half-written one. Returns `idle: false` with a reason if the timeout hits first — the peer is simply still working, so you can peek and wait again. Cheap: the daemon polls the pane, it does not consume tokens.",
378
+ title: "Wait until another wizard stops working",
379
+ description: "挂起, 直到点名的 wizard 停下来 (它的终端不再显示中断提示), 然后返回它最新的回复。send_peer 之后就该用它 —— 这样你拿到的是写完的答案, 而不是写了一半的。超时先到则返回 `idle: false` 与原因: 它只是还在干, 你可以 peek 一眼再等。很便宜: 守护进程轮询的是 pane, 不烧 token。同一个聊天里它的回复本来就会以它自己的气泡出现在群里, 所以你拿到结论后**别再复述一遍**, 只说你据此做了什么。\n" +
380
+ "**派了一批活就用 `tags` 一次等一组**, 别一个一个等: 它们本来在同时干活, 串行等的墙钟是所有人之和, 并行等只等最慢的那一个。`results` 按你给的顺序逐个回 `idle` / `lastText`。`need` 决定满几个就返回 (默认全部; `need:1` = 谁先完事就先处理谁, 剩下的还在跑, 再调一次接着等)。",
357
381
  inputSchema: {
358
- tag: z.string().describe(ADDRESS_DOC),
382
+ tag: z.string().optional().describe(`${ADDRESS_DOC} 等一组时改用 \`tags\`。`),
383
+ tags: z
384
+ .array(z.string())
385
+ .optional()
386
+ .describe("一次等多个 wizard 的地址 (最多 16 个), 每个的写法同 `tag`。fan-out 之后的 join 用它 —— 五个分身并行等只花最慢那一个的时间。"),
387
+ need: z
388
+ .number()
389
+ .optional()
390
+ .describe("满几个就返回 (1 到地址个数, 默认全部)。`1` = 任意一个先完事就返回; 中间值 = 法定人数。满足后剩下的等待会被撤掉, 它们照常继续干活, 结果里 `idle:false`。"),
359
391
  timeoutSec: z.number().optional().describe("Max seconds to wait (10-7200, default 900)."),
360
392
  },
361
- }, async ({ tag, timeoutSec }) => unwrap("wait_peer", await daemonPost("/peers/wait", { tag, ...(timeoutSec ? { timeoutSec } : {}) })));
393
+ }, async ({ tag, tags, need, timeoutSec }) => unwrap("wait_peer", await daemonPost("/peers/wait", {
394
+ ...(tags?.length ? { tags } : { tag: tag ?? "" }),
395
+ ...(need ? { need } : {}),
396
+ ...(timeoutSec ? { timeoutSec } : {}),
397
+ })));
362
398
  server.registerTool("run_agent_graph", {
363
399
  title: "Run a loop graph over several tagged agents",
364
- description: "Declare a multi-agent loop inside this chat and let the daemon drive it. `nodes` are the participating `#tag` sessions (each may pick its own cli / model / cwd; missing sessions are spawned, existing ones are reused with their context intact). `steps` is the ordered pipeline — each step sends a prompt to one node, waits for it to finish, captures its reply, and feeds it forward. The step list is walked `rounds` times, which is what makes it a LOOP: `fix → review → fix → review …` until `until` appears in a reply or the rounds run out. Prompt templates may reference earlier output: `{{last}}` = the previous step's reply, `{{<tag>}}` = that node's latest reply, `{{round}}` = round number. Returns a runId immediately and narrates progress into the chat; poll with graph_status, cancel with stop_graph. Use this when the user asks for several agents to work together / review each other / iterate to a conclusion. For a one-off nudge to a single peer, prefer send_peer + wait_peer.",
400
+ 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。要它们开局就共享同一批材料, 先 spawn_clone 出这些节点再跑图。",
365
401
  inputSchema: {
366
402
  nodes: z
367
403
  .array(z.object({
368
- tag: z.string().describe("Session tag without '#', e.g. 'fix'."),
369
- cli: z.enum(["claude", "claude-internal", "codebuddy"]).optional().describe("CLI backend for this node. Omit to inherit the chat's."),
370
- model: z.string().optional().describe("Model slug passed as --model when the node has to be spawned, e.g. 'opus' / 'haiku'. Ignored for an already-running session."),
371
- cwd: z.string().optional().describe("Absolute project path for this node. Omit to inherit the chat's."),
404
+ tag: z.string().describe("wizard 的 tag, 不带 '#', 如 'fix'。"),
405
+ cli: z.enum(["claude", "claude-internal", "codebuddy"]).optional().describe("这个 wizard 用哪个 CLI。省略则继承本聊天的。"),
406
+ model: z.string().optional().describe("要现造这个 wizard 时传给 `--model` 的模型 slug, 如 'opus' / 'haiku'。已经在跑的不受影响。"),
407
+ cwd: z.string().optional().describe("这个 wizard 的工作区绝对路径。省略则继承本聊天的。"),
372
408
  }))
373
- .describe("Participating sessions. Every step's `to` must name one of these tags."),
409
+ .describe("参与的 wizard。每个 step 的 `to` 都必须点到这里面的某个 tag。"),
374
410
  steps: z
375
411
  .array(z.object({
376
- to: z.string().describe("Tag of the node this step drives."),
412
+ to: z.string().describe("这一步驱动哪个 wizard 的 tag。"),
377
413
  prompt: z.string().describe("Prompt template. Supports {{last}}, {{<tag>}}, {{round}}."),
378
414
  }))
379
415
  .describe("Ordered pipeline, replayed once per round."),
@@ -390,7 +426,7 @@ server.registerTool("run_agent_graph", {
390
426
  })));
391
427
  server.registerTool("graph_status", {
392
428
  title: "Inspect running / finished agent graphs",
393
- description: "Report progress of loop graphs started by run_agent_graph: per-step round, target tag, status (running / done / timeout / error) and each node's captured reply. Omit runId to list every graph belonging to this chat. Note graphs live in daemon memory — a daemon reload clears them (the panes survive).",
429
+ description: "看 run_agent_graph 起的流水线跑到哪了: 每一步的轮次、目标 wizard、状态 (running / done / timeout / error) 以及每个 wizard 交出来的回复。不给 runId 就列出本聊天的全部。注意图只活在守护进程内存里 —— reload 会把它清掉 (wizard 本身还活着)。",
394
430
  inputSchema: {
395
431
  runId: z.string().optional().describe("Run id from run_agent_graph. Omit to list all runs for this chat."),
396
432
  },
@@ -402,16 +438,16 @@ server.registerTool("graph_status", {
402
438
  });
403
439
  server.registerTool("stop_graph", {
404
440
  title: "Cancel a running agent graph",
405
- description: "Stop a loop graph after its current step. Does NOT interrupt the agent that is mid-turn — it finishes, then no further steps are dispatched. Use when the user says to abort the loop.",
441
+ description: "在当前这一步之后停掉流水线。**不会**打断正在生成的那个 wizard —— 它把话说完, 之后不再派新的步骤。用户说「别跑了」时用。要立刻打断某个 wizard 用 stop_wizard({mode:'interrupt'})。",
406
442
  inputSchema: { runId: z.string().describe("Run id from run_agent_graph.") },
407
443
  }, async ({ runId }) => unwrap("stop_graph", await daemonPost("/graph/stop", { runId })));
408
444
  server.registerTool("handoff", {
409
- title: "Hand off a session's work to a fresh session",
410
- description: "Hand off the work in a tmux pane / peer session to a BRAND-NEW session, in place: the daemon asks that session to compress everything into a self-contained handoff brief, waits for it, then sends `/clear` into the SAME pane (resets the context window, new sessionId, same cwd) and pastes the brief in as the new session's first message. Use this when a session's context window is bloated / near its limit, or the user says '交接一下' / 'handoff' / '开个新会话接着干' / '压缩上下文重开'. Address the session by tmux `pane` id (e.g. '%5', from list_peers / list_claude_sessions) OR by peer `tag`. Refuses to hand off your OWN session (would deadlock). Returns the brief that was carried across.",
445
+ title: "Hand another wizard's work over to a fresh context",
446
+ description: "给**另一个** wizard 做交接, 原地完成: 守护进程让它把当前工作压成一份自洽的交接简报, 等它写完抓取, 再往**同一个 pane** 注入 `/clear` (上下文清零、新 sessionId、cwd 不变、身份的系统提示还在), 然后把简报作为新会话的第一条消息贴回去。它的上下文撑不住了、或者用户说「让 #fix 交接一下」「叫它压缩上下文重开」时用。按 tmux `pane` id (`%5`, 来自 wizard_roster / list_claude_sessions) 或按 `tag` 寻址。**要交接的是你自己就用 wizard_handoff_self** —— 这里拒绝对自身操作 (会死锁: 你没法在自己生成的当口再被问一次)。返回被带过去的那份简报。",
411
447
  inputSchema: {
412
- pane: z.string().optional().describe("Target tmux pane id, e.g. '%5'. Takes precedence over tag. Get it from list_peers / list_claude_sessions."),
413
- tag: z.string().optional().describe("Peer tag WITHOUT '#'. Empty string = this chat's default session. Non-empty prefers same-chat, falls back to a GLOBALLY UNIQUE match in another chat. Ignored when pane is given."),
414
- focus: z.string().optional().describe("Optional emphasis for the handoff brief, e.g. '重点交代还没跑通的测试'."),
448
+ pane: z.string().optional().describe("目标 tmux pane id, 如 '%5'。优先于 tag。从 wizard_roster / list_claude_sessions 拿。"),
449
+ tag: z.string().optional().describe("wizard 的 tag, 不带 '#'。空串 = 本聊天的默认 wizard。非空时先找本聊天, 找不到再退回全机唯一的那个。给了 pane 就忽略它。"),
450
+ focus: z.string().optional().describe("交接简报里要特别交代的点, 如 '重点交代还没跑通的测试'。可选。"),
415
451
  timeoutSec: z.number().optional().describe("Max seconds to wait for the summary before aborting (30-7200, default 600)."),
416
452
  },
417
453
  }, async ({ pane, tag, focus, timeoutSec }) => unwrap("handoff", await daemonPost("/handoff", {
@@ -420,65 +456,33 @@ server.registerTool("handoff", {
420
456
  ...(focus ? { focus } : {}),
421
457
  ...(timeoutSec ? { timeoutSec } : {}),
422
458
  })));
423
- // ── Topic pub/sub (注册订阅 + 广播) ────────────────────────────────────────
424
- // A lightweight event bus layered on WeCom chats: a session registers its chat
425
- // as a subscriber of a named topic, anyone broadcasts to every subscriber at
426
- // once. Same store the IM commands「订阅」/「广播」use — persisted to config.jsonc
427
- // (`topics.subs`), surviving daemon reloads. subscribe resolves the caller's
428
- // chat via selfRef; broadcast is subscriber-agnostic, so it hits the shared
429
- // /publish route directly.
430
- server.registerTool("subscribe_topic", {
431
- title: "Subscribe this chat to a topic",
432
- description: "Register the CURRENT WeCom chat (the one mirroring this session) as a subscriber of a named topic, so it receives every future broadcast_topic push and scheduled daily broadcast on that topic. Equivalent to the user typing 「订阅 <topic>」 in the chat, but driven by the agent. Topics are free-form event names (e.g. 'ci-fail', 'daily-report'); subscriptions persist across daemon reloads. Use when the user says 「订阅 xxx」/「注册到 xxx 事件」/「以后 xxx 的消息也发这个群」. Returns `added` (false if already subscribed) and the topic's current subscriber count.",
459
+ // 定时任务 —— 到点把一句话说给一个 wizard 听。和人在群里 at 它说同一句话完全等价:
460
+ // pane 死了会被拉起来, 它干完的活照常出现在群里和详情页。这是 claude/codebuddy 自带
461
+ // 定时器给不了的那一半: 它们的循环活在会话里, 会话一死就没了; 这个活在 daemon 里。
462
+ server.registerTool("schedule_task", {
463
+ title: "Schedule a prompt to run in a wizard session, on a recurring or one-off schedule",
464
+ description: "给某个 wizard 排一个**到点自动执行**的活: 到时间了, daemon 把 `prompt` 原样说给它听 —— 等价于那一刻有人在群里对它说了这句话, 所以它会真的去做, 产出照常落在群里。守护进程级, 跨 CLI 重启/会话结束仍在, 目标 pane 死了会被自动拉起来。用户说「每个工作日晚上 9:30 自动跑一下 xxx」「每天早上帮我看看 yyy」「每 2 小时同步一次 zzz」「明早 9 点提醒并整理 www」时调它。\n`when` 用人话原样写, 别自己翻译成 cron: 「每个工作日晚上9:30」「每天 8:00」「每周三下午3点」「每隔两小时」「每 30 分钟」「20 分钟后」「明早 9 点」都认。解析不出会报错并列出能认的说法 —— 这时把原话回给用户让他重说, 别自己猜一个时间存进去。\n存成功后**必须把回显的 `when` 和 `next` 念给用户**确认 (例: 「每个工作日 21:30, 下次 2026-09-21 21:30」)。`prompt` 要写成一句完整的、零上下文也能执行的指令 —— 到点时那个会话可能早已 /clear 过, 它只看得见这句话。",
433
465
  inputSchema: {
434
- topic: z.string().describe("Topic name to subscribe to, e.g. 'ci-fail'. Free-form: letters / digits / CJK / - / _ / . , no whitespace."),
466
+ when: z.string().describe("什么时候跑, 人话原样传: 「每个工作日晚上9:30」「每天早上9点」「每周三下午3点」「每隔2小时」「每30分钟」「20分钟后」「明早9点」。"),
467
+ prompt: z.string().describe("到点要说给那个 wizard 听的话。写成自洽的完整指令 (要做什么、在哪个目录/文件上、做完怎么汇报), 别依赖当前对话的上下文。"),
468
+ tag: z.string().optional().describe(`排给谁干。省略 = 排给你自己 (最常见: 给自己定一个夜里跑的活)。${ADDRESS_DOC}`),
469
+ note: z.string().optional().describe("给人看的一句话备注, 只在 list_tasks 里回显。"),
435
470
  },
436
- }, async ({ topic }) => unwrap("subscribe_topic", await daemonPost("/topics/subscribe", { topic })));
437
- server.registerTool("broadcast_topic", {
438
- title: "Broadcast a message to a topic's subscribers",
439
- description: "Fan a markdown message out to EVERY chat/session subscribed to the given topic. Equivalent to 「广播 <topic> <内容>」. Each subscriber receives it as a normal WeCom bubble in its own channel (tagged sessions get their `#tag` header). Returns `sent` / `failed` / `subs` so you know the reach. Use when the user says 「广播 xxx」/「给订阅 xxx 的都发一下」, or an agent needs to notify a fleet of sessions at once. For a private nudge into ONE peer session, use send_peer instead.",
471
+ }, async ({ when, prompt, tag, note }) => unwrap("schedule_task", await daemonPost("/tasks/schedule", { when, prompt, tag: tag ?? "", note: note ?? "" })));
472
+ server.registerTool("list_tasks", {
473
+ title: "List scheduled tasks on this host",
474
+ description: "列出本机所有定时任务: `id` (取消要用)、`when` (人话回显)、`next` (下次触发时刻)、`lastFired`、目标 wizard 的 `address` 与 `prompt`。用户问「有哪些定时任务」「我设了什么定时」「下次什么时候跑」时调它。只读。`mine:true` 只看排给你自己的。",
440
475
  inputSchema: {
441
- topic: z.string().describe("Topic to publish to. Subscribers are whoever ran subscribe_topic / 「订阅」 on this topic."),
442
- markdown: z.string().describe("Message body in WeCom markdown."),
476
+ mine: z.boolean().optional().describe("true = 只列排给调用方自己的任务。默认列全机。"),
443
477
  },
444
- }, async ({ topic, markdown }) => {
445
- const resp = await fetch(`${DAEMON_BASE}/publish`, {
446
- method: "POST",
447
- headers: { "content-type": "application/json" },
448
- body: JSON.stringify({ topic, markdown }),
449
- });
450
- const j = (await resp.json().catch(() => ({})));
451
- return j.ok ? ok(j) : fail(`broadcast_topic failed: ${j.error ?? `http ${resp.status}`}`);
452
- });
453
- server.registerTool("unsubscribe_topic", {
454
- title: "Unsubscribe this chat from a topic",
455
- description: "Remove the CURRENT WeCom chat from a topic's subscriber list, so it stops receiving that topic's broadcasts and scheduled pushes. The inverse of subscribe_topic. Returns `removed` (false if it wasn't subscribed). Use when the user says 「退订 xxx」/「别再往这个群发 xxx 了」.",
456
- inputSchema: {
457
- topic: z.string().describe("Topic name to unsubscribe from."),
458
- },
459
- }, async ({ topic }) => unwrap("unsubscribe_topic", await daemonPost("/topics/unsubscribe", { topic })));
460
- server.registerTool("list_topics", {
461
- title: "List this chat's subscriptions and all scheduled broadcasts",
462
- description: "Show what THIS chat is subscribed to (`subs`: topic + subscriber count) plus every daily scheduled broadcast on the host (`schedules`: topic, HH:MM, creator). Use when the user asks 「订阅列表」/「有哪些定时广播」/「我订了什么」. Read-only.",
463
- inputSchema: {},
464
- }, async () => unwrap("list_topics", await daemonPost("/topics/list", {})));
465
- server.registerTool("schedule_broadcast", {
466
- title: "Schedule a daily broadcast to a topic",
467
- description: "Register a recurring daily broadcast: every day at hour:minute (host local time) the daemon publishes `content` to all subscribers of `topic`. Equivalent to 「每天 HH:MM 广播 <topic> <内容>」. Persists across daemon reloads. Use when the user says 「每天 8 点广播 xxx」/「定时给订阅者发 xxx」. To fire once immediately instead, use broadcast_topic.",
478
+ }, async ({ mine }) => unwrap("list_tasks", await daemonPost("/tasks/list", { mine: mine === true })));
479
+ server.registerTool("cancel_task", {
480
+ title: "Cancel one scheduled task by id",
481
+ description: "按 id 删掉一条定时任务 (id 从 list_tasks 拿)。用户说「取消那个定时」「别再每天跑了」时调它: 先 list_tasks 把候选念给用户确认是哪一条, 再删。删的是日程本身, 不影响任何正在跑的活。",
468
482
  inputSchema: {
469
- topic: z.string().describe("Topic whose subscribers receive the daily push."),
470
- hour: z.number().int().min(0).max(23).describe("Hour of day, 0-23 (host local time)."),
471
- minute: z.number().int().min(0).max(59).optional().describe("Minute, 0-59. Default 0."),
472
- content: z.string().describe("Message body in WeCom markdown, sent every day at the given time."),
483
+ id: z.string().describe("Schedule id from list_tasks."),
473
484
  },
474
- }, async ({ topic, hour, minute, content }) => unwrap("schedule_broadcast", await daemonPost("/topics/schedule", { topic, hour, minute: minute ?? 0, content })));
475
- server.registerTool("cancel_broadcast", {
476
- title: "Cancel a topic's daily scheduled broadcasts",
477
- description: "Delete ALL daily scheduled broadcasts for a topic (does NOT touch subscriptions or fire anything). Equivalent to 「取消广播 <topic>」. Returns how many schedules were removed. Use when the user says 「取消 xxx 的定时」/「别再每天发 xxx 了」.",
478
- inputSchema: {
479
- topic: z.string().describe("Topic whose scheduled broadcasts should be removed."),
480
- },
481
- }, async ({ topic }) => unwrap("cancel_broadcast", await daemonPost("/topics/cancel-schedule", { topic })));
485
+ }, async ({ id }) => unwrap("cancel_task", await daemonPost("/tasks/cancel", { id })));
482
486
  // ── Config ──────────────────────────────────────────────────────────
483
487
  server.registerTool("config_set", {
484
488
  title: "Wezard config",
@@ -502,6 +506,136 @@ server.registerTool("config_set", {
502
506
  }
503
507
  return unwrap("config_set", await daemonPost("/config/set", { key, value, action: action ?? "set" }));
504
508
  });
509
+ // ── Wizard: 会话的身份 ─────────────────────────────────────────────────────
510
+ // 一个绑定到聊天的会话就是一个 wizard —— 有名字、有工作区、有职责、有记忆、能生
511
+ // 分身。这些工具是它认识自己、认识同伴、以及扩编/收编的全部入口。身份本身在
512
+ // spawn 时已经写进了系统提示, 所以这里回答的是"此刻"的部分: 上下文用到哪了、
513
+ // 分身还剩几个、别人是谁。
514
+ server.registerTool("wizard_whoami", {
515
+ title: "Who am I",
516
+ description: "你自己是谁: 名字、地址 (别人用它找你)、所在聊天、工作区、职责、记忆、家谱 (谁生的你、你生了谁), 以及此刻的 contextTokens 与 handoffSuggested。用户问「你是谁」「你叫什么」「你在哪个目录」「你有几个分身」时先调它; 要做任何编排之前也先调它 —— 你得知道自己的工作区在哪、手里已经有哪些分身。handoffSuggested=true 表示上下文该交接了 (见 wizard_handoff_self)。",
517
+ inputSchema: {},
518
+ }, async () => unwrap("wizard_whoami", await daemonPost("/wizard/whoami", {})));
519
+ server.registerTool("wizard_identity", {
520
+ title: "Name yourself / declare your job",
521
+ description: "给自己起名字、写职责。名字就是别人喊你的那个词: 你若是聊天的默认会话, 起名同时给这个聊天起名 (等价于 /name), 别的聊天从此能以 `名字#tag` 找到这里; 你若是带 tag 的分身, 名字只属于你自己。职责是一句话的「我是干什么的」—— 别的 wizard 在名册里读到它, 据此决定该不该找你。用户说「你以后叫 X」「这个群叫 X」「你负责 X」时调它; 你自己发现 whoami 里名字或职责是空的, 也应当主动补上。只传要改的那个字段。",
522
+ inputSchema: {
523
+ name: z.string().optional().describe("新名字, 1-32 位字母/数字/`_`/`-`, 全机唯一 (默认会话的名字即聊天名)。不改就别传。"),
524
+ description: z.string().optional().describe("一句话职责, 例如 '盯 wezard 主仓的重构与发版'。不改就别传。"),
525
+ },
526
+ }, async ({ name, description }) => unwrap("wizard_identity", await daemonPost("/wizard/identity", {
527
+ ...(name !== undefined ? { name } : {}),
528
+ ...(description !== undefined ? { description } : {}),
529
+ })));
530
+ server.registerTool("wizard_roster", {
531
+ title: "Every wizard and clone",
532
+ description: "这个世界上所有的 wizard 与 clone: 每一个的名字、地址、**所在聊天**、**工作区**、职责、忙闲 (busy)、是否还活着 (alive)、最近在干嘛 (summary), 以及家谱 (parent / clones / ancestors)。跨聊天的也在里面。这是你感知同伴的唯一入口 —— 用户说「还有谁在跑」「谁在弄那个项目」「让懂 X 的那个来看看」时先调它, 拿到目标的 `address` 再 send_peer / peek_peer / wait_peer; 要往它**所在的群里对人说话**则把它的 `address` (或 `chat`) 交给 notify。\n" +
533
+ "**这是一张索引, 不是一份名单**: 整台机器上可能有几百个会话, 所以默认只回最相关的一页 (自己 → 活着的 → 最近动过的), 并告诉你 `total` / `matched` 有多少。找人就带上条件: `query` 匹配名字/职责/地址, `cwd` 匹配工作区路径 (「谁在这个目录里干活」), `chat` 限定某个聊天, `alive:true` 只看还活着的。别不带条件硬拉全表。",
534
+ inputSchema: {
535
+ query: z.string().optional().describe("在名字 / 职责 / 地址 / target 里做子串匹配 (不分大小写)。「让懂 X 的那个来看看」就把 X 写在这里。"),
536
+ chat: z.string().optional().describe("只看某个聊天里的 wizard: 聊天名, 或者聊天 principal 的一段 (无名聊天用它)。"),
537
+ cwd: z.string().optional().describe("只看工作区路径包含这一段的 wizard —— 「谁在 /path 下干活」的反查。"),
538
+ alive: z.boolean().optional().describe("true = 只看 pane 还活着的。默认全给 (冷会话发消息就会被唤醒)。"),
539
+ limit: z.number().optional().describe("最多回多少条 (1-300, 默认 40)。"),
540
+ },
541
+ }, async ({ query, chat, cwd, alive, limit }) => unwrap("wizard_roster", await daemonPost("/wizard/roster", {
542
+ ...(query ? { query } : {}),
543
+ ...(chat ? { chat } : {}),
544
+ ...(cwd ? { cwd } : {}),
545
+ ...(alive ? { alive } : {}),
546
+ ...(limit ? { limit } : {}),
547
+ })));
548
+ server.registerTool("spawn_clone", {
549
+ title: "Spawn a clone of yourself",
550
+ description: "生一个分身 —— 一个新的 wizard, 活在同一个聊天 (或指名的另一个聊天) 里, 有自己的 tmux pane、自己的 `#tag` 地址、自己的职责, 归你管。\n" +
551
+ "`inherit` 必填, 它决定这是哪一种分身:\n" +
552
+ "• inherit=true —— **fork 你此刻的上下文**: 它开局就拥有你已经读过的一切 (规范、目录结构、刚啃完的那份文档), 不必重读。代价是它必须留在你当前的工作区 (换 cwd 会自动退化成 false)。这是编排一组「共享同一批材料」的任务的正确姿势: 你先把公共材料读进自己的上下文, 再 fork 出 N 个分身, 材料只读一遍却进了 N 份上下文。\n" +
553
+ "• inherit=false —— 空白分身: 只继承身份, 不继承上下文。适合干一件与你手头无关的事, 或者要在别的目录/别的聊天里干活。\n" +
554
+ "带上 `task` 可以在它就位的同时把第一件活派下去, 省掉一次 send_peer。之后用 send_peer 继续派活、wait_peer 等它做完、stop_wizard 收掉它。分身自己也能再 spawn_clone, 层级不限。分身是有成本的 (一个 pane + 一份上下文), 任务少于两三件时你自己做完更快。",
555
+ inputSchema: {
556
+ inherit: z
557
+ .boolean()
558
+ .describe("true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一工作区); false = 空白分身, 只继承身份。必填, 没有默认值。"),
559
+ description: z.string().describe("这个分身负责什么, 一句话。它会写进分身的系统提示, 也会出现在名册里让别人看到。"),
560
+ tag: z.string().optional().describe("分身的地址 tag, 不带 '#' (如 'docs'、'fix')。同一聊天内不能重名。省略则按 description 首词生成。"),
561
+ task: z.string().optional().describe("就位后立刻派下去的第一件活。省略则它就位待命。"),
562
+ cwd: z.string().optional().describe("分身的工作区绝对路径。只在 inherit=false 时有意义 —— 换目录与继承上下文互斥。"),
563
+ chat: z.string().optional().describe("把分身生在另一个聊天里 (list_chats 里的名字)。省略 = 你自己的聊天, 这是绝大多数情况。"),
564
+ cli: z.enum(["claude", "claude-internal", "codebuddy"]).optional().describe("分身用哪个 CLI。省略则继承。"),
565
+ model: z.string().optional().describe("分身跑在哪个模型上 (`--model` 的 slug, 如 'opus' / 'sonnet' / 'haiku')。省略用该 CLI 的默认。分身可以和你跑在不同模型上: 要判断力的那一路给 opus, 跑腿的 (grep、跑测试、照着清单改) 给 haiku —— 一批分身不必齐步走。"),
566
+ job: z
567
+ .string()
568
+ .optional()
569
+ .describe("把这个分身归到某个工单名下 (open_job 给的 id)。归了工单的分身出生/派活不再逐条出气泡 —— 五路 fan-out 就是十条交叉气泡, 人读不出结构; 它们攒到 close_job 那一条里一起交代, 过程照旧在各自的详情页。close_job 还会把它们整批回收掉。"),
570
+ },
571
+ }, async ({ inherit, description, tag, task, cwd, chat, cli, model, job }) => unwrap("spawn_clone", await daemonPost("/wizard/clone", {
572
+ inherit,
573
+ description,
574
+ ...(tag ? { tag } : {}),
575
+ ...(task ? { task } : {}),
576
+ ...(job ? { job } : {}),
577
+ ...(cwd ? { cwd } : {}),
578
+ ...(chat ? { chat } : {}),
579
+ ...(cli ? { cli } : {}),
580
+ ...(model ? { model } : {}),
581
+ })));
582
+ // ── Job: 一次 fan-out 的工单 ────────────────────────────────────────────────
583
+ // 工单不是第二个编排器: 控制流始终在你自己的上下文里 (你自己 spawn、自己 wait、
584
+ // 自己汇总)。守护进程只替你记一本账 —— 谁属于这个活、谁是临时生的、群里出哪两条
585
+ // 气泡、收工时该回收谁。
586
+ server.registerTool("open_job", {
587
+ title: "Open a job for a fan-out",
588
+ description: "开一个**工单**: 你接下来要同时派出两个以上的分身干同一件事时, 先开它。返回一个 id, 把这个 id 传给 spawn_clone / send_peer 的 `job` 参数, 它们就归到这个工单名下。\n" +
589
+ "开了工单之后有三件事不一样: ① 群里只出两条气泡 —— 这里的「开工」和 close_job 的「收工」, 中间每个分身的出生和每一次派活不再各刷一条 (五路 fan-out 本来会刷十条交叉气泡, 人从里面读不出结构; 过程照旧在各自的 chat 详情页里, 收工那条会把成员和各自那段活列出来)。② close_job 会把为这个工单生出来的分身**整批回收**, 不必一个个 stop_wizard —— 忘记回收是常态, 每个分身都占着一个 pane 和一份上下文。③ list_jobs 能看到还开着哪些活。\n" +
590
+ "派活时顺手让每个分身**把结论收口成一行** `RESULT: …` (交付物写进文件就回传路径): wait_peer 会把这一行单独摘出来放进 `result`, 你汇总时不必再从八百字里找结论。\n" +
591
+ "只派一个分身、或者只是推某个同伴一把, 不用开工单 —— 那时逐条气泡正是人想看的。",
592
+ inputSchema: {
593
+ title: z.string().describe("一句话说清这个工单要干成什么 —— 它会出现在群里的开工气泡上。"),
594
+ plan: z.string().optional().describe("要在开工气泡里一并说明的计划 (打算分几路、各干什么)。省略则只出标题。"),
595
+ },
596
+ }, async ({ title, plan }) => unwrap("open_job", await daemonPost("/jobs/open", { title, ...(plan ? { plan } : {}) })));
597
+ server.registerTool("close_job", {
598
+ title: "Close a job and recycle its clones",
599
+ description: "收工: 把汇总结论发进群 (连同成员清单和各自那段活, 每个名字挂它自己的 chat 详情页), 并**把为这个工单生出来的分身整批回收**。被拉来帮忙的长期 wizard 不在回收之列, 你自己也不会被收。\n" +
600
+ "拿到所有分身的结果、汇总完就调它 —— 分身留着不收, 下一次编排就会撞到分身上限。`stop:false` 只结账不回收 (那些分身后面还有用)。",
601
+ inputSchema: {
602
+ job: z.string().describe("open_job 返回的工单 id。"),
603
+ summary: z.string().optional().describe("汇总结论, 发进群给人看。这是人在群里看到的唯一一条结果 —— 写清楚做成了什么、有什么没做成。"),
604
+ stop: z.boolean().optional().describe("是否回收为这个工单生出来的分身。默认 true。"),
605
+ },
606
+ }, async ({ job, summary, stop }) => unwrap("close_job", await daemonPost("/jobs/close", { job, ...(summary ? { summary } : {}), ...(stop === false ? { stop: false } : {}) })));
607
+ server.registerTool("list_jobs", {
608
+ title: "Open jobs in this chat",
609
+ description: "这个聊天里还开着的工单: id、标题、谁开的、成员和各自那段活。用来回答「那批分身在干什么」「上次那个活收了没」, 以及在继续派活前拿回工单 id。",
610
+ inputSchema: {},
611
+ }, async () => unwrap("list_jobs", await daemonPost("/jobs/list", {})));
612
+ server.registerTool("stop_wizard", {
613
+ title: "Interrupt or end another wizard",
614
+ description: "收掉一个 wizard/分身。mode='interrupt' 只打断它当前这一轮 (等价于群里的 /stop, 它还活着, 可以继续派活); mode='end' 结束它并回收 tmux pane (等价于 /kill, 之后再找它会重新长出一个空白会话)。活干完了就把临时分身 end 掉 —— 每个分身都占着一个 pane 和一份上下文。加 forget=true 连它的身份记录一起抹掉 (名字、职责、记忆), 只在它彻底不会再回来时用。用户说「让 #x 停下」「把那些分身收了」时调它。终结自己也是合法的 (分身干完活自我了结), 只是这次调用不会返回 —— 群里的通知就是回执。",
615
+ inputSchema: {
616
+ tag: z.string().describe(ADDRESS_DOC),
617
+ mode: z.enum(["end", "interrupt"]).optional().describe("'end' 结束并回收 pane (默认); 'interrupt' 只打断当前这一轮。"),
618
+ forget: z.boolean().optional().describe("仅对 end 有效: 连身份记录 (名字/职责/记忆) 一起删除。默认 false —— 身份留着, 下次它回来还是它。"),
619
+ },
620
+ }, async ({ tag, mode, forget }) => unwrap("stop_wizard", await daemonPost("/wizard/stop", { tag, ...(mode ? { mode } : {}), ...(forget ? { forget } : {}) })));
621
+ server.registerTool("wizard_remember", {
622
+ title: "Write something into your long-term memory",
623
+ description: "写一条只属于你的长期记忆。它不在对话里 —— 它在注册表里, 每次你 (重)开会话时重新压进你的系统提示。所以这是唯一能跨 /clear、跨交接、跨重启活下来的东西: 用户的口味偏好、这个项目的硬约束、踩过的坑、'发版前必须更新 CHANGELOG' 这种规矩。一条一句话, 越具体越有用。传 forget (子串匹配) 删掉过时的那条。别拿它存这次任务的临时状态 —— 那种东西属于交接简报。",
624
+ inputSchema: {
625
+ note: z.string().optional().describe("要记住的一句话。"),
626
+ forget: z.string().optional().describe("要忘掉的记忆里的一个子串, 命中的整条删除。"),
627
+ },
628
+ }, async ({ note, forget }) => unwrap("wizard_remember", await daemonPost("/wizard/remember", {
629
+ ...(note ? { note } : {}),
630
+ ...(forget ? { forget } : {}),
631
+ })));
632
+ server.registerTool("wizard_handoff_self", {
633
+ title: "Hand your own work over to a fresh context",
634
+ description: "给自己做交接: 你把当前工作压成一份自洽的简报写在 `brief` 里, 守护进程等你这一轮说完、会话空下来之后, 在同一个 pane 里 /clear (上下文清零、cwd 不变、身份的系统提示还在), 再把简报作为新会话的第一条消息贴回去。wizard_whoami 的 handoffSuggested=true, 或者你自己感觉上下文塞满了、开始记不住前面的事时, 主动调它 —— 不必等人下令。简报要写到「零上下文的自己仅凭它就能接着干」: 总目标 / 已完成与关键决策 / 当前状态 (改到哪、什么能跑、什么没跑通) / 下一步 (有序) / 关键文件路径与非显然的坑。要交接的是别人 (某个分身上下文爆了), 用 handoff 而不是这个。",
635
+ inputSchema: {
636
+ brief: z.string().describe("交接简报全文。自洽、具体、可执行 —— 接手的是一个什么都不记得的你。"),
637
+ },
638
+ }, async ({ brief }) => unwrap("wizard_handoff_self", await daemonPost("/wizard/handoff-self", { brief })));
505
639
  const transport = new StdioServerTransport();
506
640
  await server.connect(transport);
507
641
  //# sourceMappingURL=server.js.map