wezard 1.3.22 → 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 +156 -89
  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 +54 -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
@@ -12,7 +12,7 @@ import { makeBridge } from "./cc-bridge.js";
12
12
  import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
13
13
  import { setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
14
14
  import { installApprovalEventListener, makeApproveHandler } from "./approval.js";
15
- import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward } from "./detail.js";
15
+ import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, buildChatUrl, latestTurnIdFor } from "./detail.js";
16
16
  import { initAutoWindowPersistence } from "./session-cache.js";
17
17
  import { makeMessageHandler } from "./outbound.js";
18
18
  import { makeCardHandler, makeAskHandler, installAskEventListener } from "./ask.js";
@@ -21,10 +21,15 @@ import { makeClaimStartHandler, makeClaimStatusHandler, makeClaimResetHandler, }
21
21
  import { makeWedocBridge } from "./wedoc.js";
22
22
  import { installResponseTracker } from "./last-response.js";
23
23
  import { scanClaudeSessions } from "./session-scan.js";
24
- import { startScheduler, publish as publishTopic, subscribe as subscribeTopic, unsubscribe as unsubscribeTopic, listSubs, listSchedules, addSchedule, removeSchedulesByTopic, } from "./topics.js";
25
- import { baseOfKey, keyOf, normalizeTag, tagFromCwd, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
26
- import { chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, setChatName } from "./chat-name.js";
27
- import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, } from "./graph.js";
24
+ import { startScheduler, listSchedules, addSchedule, removeScheduleById, renderSchedule, } from "./tasks.js";
25
+ import { parseWhen } from "../shared/schedule-spec.js";
26
+ import { baseOfKey, keyOf, labelFor, normalizeTag, tagFromCwd, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
27
+ import { applyChatNames, chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
28
+ import { bindWizardStore, loadWizardStore, wizardName, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
29
+ import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.js";
30
+ import { loadJobStore, renderJobOpen, renderJobClose, JOB_MEMBER_MAX } from "./jobs.js";
31
+ import { extractResult } from "./peers.js";
32
+ import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
28
33
  // pino's file transport is async; without flush, log.fatal before process.exit
29
34
  // vanishes. Mirror anything fatal to stderr too so launchd's stderr.log captures it.
30
35
  const fatalExit = (msg, extra) => {
@@ -129,28 +134,6 @@ const main = async () => {
129
134
  http.register("POST /message", makeMessageHandler(ws.client, log.child({ mod: "outbound" })));
130
135
  http.register("POST /card", makeCardHandler(ws.client, log.child({ mod: "outbound" })));
131
136
  http.register("POST /ask", makeAskHandler(ws.client, log.child({ mod: "ask" })));
132
- // 事件订阅广播: 外部脚本/CI 可 curl :17890/publish 触发一次广播,不用关心订阅者。
133
- http.register("POST /publish", async (req, res) => {
134
- const { readBody } = await import("./http.js");
135
- const body = (await readBody(req));
136
- const topic = (body.topic ?? "").trim();
137
- const content = body.markdown ?? body.text ?? "";
138
- if (!topic || !content) {
139
- json(res, 400, { ok: false, error: "topic and markdown/text required" });
140
- return;
141
- }
142
- if (!ws.client.isConnected) {
143
- json(res, 503, { ok: false, error: "ws_disconnected" });
144
- return;
145
- }
146
- try {
147
- const r = await publishTopic(ws.client, cfg, log.child({ mod: "topics" }), topic, content);
148
- json(res, 200, { ok: true, ...r });
149
- }
150
- catch (e) {
151
- json(res, 502, { ok: false, error: e.message });
152
- }
153
- });
154
137
  http.register("POST /claim/start", makeClaimStartHandler({ log: log.child({ mod: "claim" }) }));
155
138
  http.register("GET /claim/status", makeClaimStatusHandler());
156
139
  http.register("POST /claim/reset", makeClaimResetHandler());
@@ -256,7 +239,7 @@ const main = async () => {
256
239
  return;
257
240
  }
258
241
  const spawnLog = log.child({ mod: "mirror", sub: "spawn-init", target });
259
- const r = await spawnTmuxClaude({ cfg, log: spawnLog, windowName: target });
242
+ const r = await spawnTmuxClaude({ cfg, log: spawnLog, windowName: target, systemPrompt: charterFor(target, {}) });
260
243
  if (!r.ok) {
261
244
  json(res, 500, { ok: false, reason: r.reason });
262
245
  return;
@@ -329,8 +312,9 @@ const main = async () => {
329
312
  : { ok: false, reason: att.reason });
330
313
  });
331
314
  // POST /sessions/new { cwd, tag?, chat?, cli?, target?|sessionId?|tmuxPane? } —
332
- // spawn a peer session in the caller's chat (or, with `chat`, in a NAMED
333
- // one), rooted at `cwd`.
315
+ // 在调用方自己的聊天里 (或者给了 `chat` 就在那个**起过名字**的聊天里) 让一个
316
+ // 全新的 wizard 在 `cwd` 下就位。它不继承任何上下文 —— 要继承的那种是 clone,
317
+ // 走 /wizard/clone。
334
318
  //
335
319
  // The caller is an agent living in some chat, so "new session" means what
336
320
  // it means to the human typing `/new #tag` there: another `#tag` sibling of
@@ -339,15 +323,14 @@ const main = async () => {
339
323
  // defaultChat, where the agent that asked for it can neither see nor reach
340
324
  // it; (2) land untagged, which IS the chat's default session — attaching
341
325
  // there evicts whoever is already mirrored to it, quite possibly the caller.
342
- // Routing through `m.newSession` (not spawn+attach) is what makes the peer
343
- // indistinguishable from a hand-made one: chat-scoped cwd/CLI inheritance,
344
- // the tag as tmux window name, and the "created + 📂 当前项目" bubble that
345
- // gives it a visible record in the group and in chat detail.
326
+ // 走 `m.newSession` (而不是 spawn+attach) 才能让这个 wizard 与人手敲出来的那种
327
+ // 完全一样: 聊天级的 cwd/CLI 继承、tag 当 tmux 窗口名、以及那条
328
+ // "created + 📂 当前项目" 气泡 —— 它在群里和详情页留下可见的出生记录。
346
329
  //
347
330
  // `chat` lifts that from "in my chat" to "in that chat", and it is why chat
348
331
  // naming exists: a NAMED chat is the only kind an agent can point at, so
349
- // the escape hatch for "the peer I need lives over there and doesn't exist
350
- // yet" stops being "go ask a human to type /new in the other group".
332
+ // 「我要的那个 wizard 在别的群、而且还不存在」从此不必再靠"找个人去那边敲
333
+ // /new"来解决。
351
334
  // Unnamed chats stay unaddressable ON PURPOSE — spawning into a raw
352
335
  // `chat:wr…` id nobody can read is how you strand a session in a group the
353
336
  // caller has no business in.
@@ -377,16 +360,19 @@ const main = async () => {
377
360
  const foreign = base !== baseOfKey(self);
378
361
  const taken = new Set(m.chatTargets(base).map(tagOfKey).filter(Boolean));
379
362
  const asked = normalizeTag(body.tag);
380
- // Reusing a live tag would respawn — i.e. kill — that peer. The caller
381
- // asked for another one, not for that one restarted; make it pick a name.
363
+ // 复用一个活着的 tag 等于 respawn —— 也就是杀掉那个 wizard。调用方要的是
364
+ // 「再来一个」, 不是「把那个重启」; 让它换个名字。
382
365
  if (asked && taken.has(asked)) {
383
366
  json(res, 409, { ok: false, reason: `peer '#${asked}' already exists in ${foreign ? `chat '${wantChat}'` : "this chat"} — pick a different tag, or drive that one with send_peer`, tag: asked });
384
367
  return;
385
368
  }
386
369
  const tag = asked || uniqueTag(tagFromCwd(cwd) || "peer", taken);
387
370
  const target = keyOf(base, tag);
388
- log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli }, "spawning peer session");
389
- const r = await m.newSession(target, tag, body.cli, { cwd });
371
+ const model = (body.model ?? "").toString().trim();
372
+ log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model }, "spawning peer session");
373
+ const r = await m.newSession(target, tag, body.cli, { cwd, model });
374
+ if (r.ok)
375
+ postRoster(base, [target, self], `新 wizard **#${tag}** 就位 · 地址 \`${peerAddress(cfg, base, target)}\`${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${model ? ` · 模型 ${model}` : ""} —— 空白起步, 由 ${displayName(self)} 造的`);
390
376
  json(res, r.ok ? 200 : 500, r.ok
391
377
  ? {
392
378
  ok: true,
@@ -396,8 +382,9 @@ const main = async () => {
396
382
  base,
397
383
  tag,
398
384
  cwd: r.cwd,
385
+ ...(model ? { model } : {}),
399
386
  foreign,
400
- // What the caller must pass to send_peer/peek_peer to drive it.
387
+ // 调用方之后拿这个串 send_peer / peek_peer 驱动它。
401
388
  address: peerAddress(cfg, self, target),
402
389
  }
403
390
  : { ok: false, reason: r.reason });
@@ -469,17 +456,14 @@ const main = async () => {
469
456
  ? { ok: true, target, sessionId: r.sessionId, cwd: r.cwd }
470
457
  : { ok: false, reason: r.reason, target, runningCwd: set.runningCwd, pendingCwd: set.pendingCwd });
471
458
  });
472
- // ── Peer collaboration + loop graph ────────────────────────────────
473
- // These routes let the Claude inside one pane act on its SIBLINGS: the
474
- // other `#tag` sessions of the same WeCom chat. An MCP tool can only see
475
- // its own process, so the daemon — which owns every attachment — is the
476
- // only place that can answer "what is #fix doing" or "inject this into
477
- // #review". Callers identify themselves via `resolveSelf` above.
478
- // Peer addressed by a peer address (see mirror-bridge.resolvePeerTag). ""
479
- // = the chat's default (untagged) session, which is a legitimate
480
- // collaboration target ("report back to the main one"); a bare `fix` is
481
- // this chat first then a globally unique `#fix` anywhere; `daily#fix`
482
- // names the chat outright and needs no uniqueness at all.
459
+ // ── wizard ↔ wizard + 流水线 ───────────────────────────────────────
460
+ // 这组路由让一个 pane 里的 wizard 作用到它的**同伴**身上: 同一个聊天里别的
461
+ // `#tag` wizard。MCP 工具只看得见自己那个进程, 所以"#fix 在干嘛""把这句话注入
462
+ // #review"只有守护进程答得了 —— 它才持有全部 attachment。调用方靠上面的
463
+ // `resolveSelf` 说明自己是谁。
464
+ // 地址语义见 mirror-bridge.resolvePeerTag: "" = 本聊天的默认 wizard (它是正当的
465
+ // 协作对象 ——「回去跟主会话汇报」); 裸 `fix` 先找本聊天、再退回全机唯一的那个;
466
+ // `daily#fix` 直接指名聊天, 不要求任何唯一性。
483
467
  const resolvePeer = (self, tag) => {
484
468
  const r = m.resolvePeerTag(self, tag);
485
469
  if (r.ok)
@@ -492,16 +476,24 @@ const main = async () => {
492
476
  // (`new_claude_session({ chat: 'daily' })`) expressible at all.
493
477
  http.register("POST /chats/list", async (req, res) => {
494
478
  const { self } = await readPeerBody(req);
479
+ if (self)
480
+ ensureChatNames(self);
495
481
  const roster = self ? m.chatRoster(self) : listChatNames(cfg).map((c) => ({ ...c, self: false, targets: [] }));
496
482
  json(res, 200, {
497
483
  ok: true,
498
484
  self,
499
485
  chats: roster.map((c) => ({
500
486
  ...c,
487
+ // 每一行带上"它是谁": 只有 target/tag/address 的话, 想知道哪个会话
488
+ // 是干什么的就得再拉一次三百条的名册。名字/职责取注册表, 工作区取
489
+ // store —— 都是内存里的, 不探 tmux。
501
490
  sessions: c.targets.map((t) => ({
502
491
  target: t,
503
492
  tag: tagOfKey(t),
504
493
  address: self ? peerAddress(cfg, self, t) : t,
494
+ name: wizardName(wizards.get(t), chatNameOf(cfg, t), t),
495
+ description: wizards.get(t)?.description ?? "",
496
+ cwd: m.getCwd(t).runningCwd || m.getCwd(t).defaultCwd,
505
497
  })),
506
498
  })),
507
499
  });
@@ -535,34 +527,99 @@ const main = async () => {
535
527
  json(res, 400, { ok: false, reason: "cannot resolve caller session (pass target/sessionId/tmuxPane)" });
536
528
  return;
537
529
  }
530
+ ensureChatNames(self);
538
531
  const [peers, foreignPeers] = await Promise.all([m.peers(self), m.foreignPeers(self)]);
539
532
  json(res, 200, { ok: true, self, base: baseOfKey(self), peers, foreignPeers });
540
533
  });
541
534
  // Push a plain markdown bubble into a chat. `base` may carry a `#tag` — a
542
535
  // tagged key strips down to the same WeCom chatid as its base.
536
+ const chatIdOf = (t) => baseOfKey(t).replace(/^(user|chat|group):/, "");
543
537
  const notifyChat = (base, markdown) => {
544
- const chatId = baseOfKey(base).replace(/^(user|chat|group):/, "");
538
+ const chatId = chatIdOf(base);
545
539
  void ws.client
546
540
  .sendMessage(chatId, { msgtype: "markdown", markdown: { content: markdown } })
547
541
  .catch((e) => log.warn({ err: e.message }, "chat notify failed"));
548
542
  };
549
- // Agent↔agent traffic is invisible to the human otherwise: it happens inside
550
- // two panes nobody is watching. Relay each leg as its own bubble, headed
551
- // `<from> → <to>` so the direction reads at a glance in the chat timeline.
552
- // 跨 chat 时两端都要看得见 —— from 的群显示 "我发出去了",to 的群显示
553
- // "另一个群的 agent 找上门了",否则 to 侧的人以为消息是凭空冒出来的。
543
+ // wizard 在群里的称呼, 只用在**正文**里。气泡的头照旧交给 withTagHeader ——
544
+ // 那一段是路由信息 (`emoji #tag`), parseTagHeader 靠它反解, 群里引用一条气泡
545
+ // 就能直接跟那个 wizard 说话; 把它换成名字会把这条通路弄断。
546
+ const displayName = (t) => {
547
+ const tag = tagOfKey(t);
548
+ const name = wizardName(wizards.get(t), chatNameOf(cfg, t), t);
549
+ return name || (tag ? `#${tag}` : "默认 wizard");
550
+ };
551
+ // wizard 之间的往返本来是看不见的: 它发生在两个没人盯着的 pane 里。关键的那几
552
+ // 次 (派活、结论) 各自成一条气泡, 头写成 `<from> → <to>`, 方向一眼可读。
554
553
  const RELAY_MAX = 1200;
554
+ /** 一个 wizard 在 `dest` 群里的称呼。本群: 照旧 `emoji #tag` —— 那一段是路由
555
+ * 信息, parseTagHeader 靠它反解, 引用气泡就能直接跟它说话。外群: 换成带聊天
556
+ * 名的全称 `emoji chat#tag`, 一个裸 `#tag` 在别人群里既认不出是谁, 又会被当
557
+ * 成本群的 tag 误投到一个不存在的会话上。 */
558
+ const relayLabel = (t, dest) => {
559
+ const name = baseOfKey(t) === dest
560
+ ? withTagHeader(t, "").trim()
561
+ : `${tagOfKey(t) ? labelFor(tagOfKey(t)) : "🧙"} ${peerAddress(cfg, dest, t)}`;
562
+ const url = chatDetailUrl(t);
563
+ return url ? `[${name}](${url})` : name;
564
+ };
565
+ /** 头上的名字挂它自己的 chat 详情页 —— 看见「A → B」的人下一步想问的永远是
566
+ * 「A 那边在干嘛」, 链接就省掉他去翻群找 A 气泡这一步。票据取该 wizard 最近
567
+ * 那条 turn (mirror 的 linkedTagPrefix 同源); 没跑过一轮就不挂, 留裸名字。 */
568
+ const chatDetailUrl = (t) => {
569
+ const id = latestTurnIdFor(t);
570
+ return id ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, chatIdOf(t)) : undefined;
571
+ };
572
+ /** 一条 relay 只落在**收信那一方**的群里, 从不两头都发:
573
+ * - 派活 → to 的群。同群时那就是双方共处的那个群 (行为照旧); 跨群时源头群
574
+ * 不再复述 —— 那句话本来就是它自己说出口的, 贴回自己群里只是噪音, 真正
575
+ * 需要看见的是 to 那边的人: 活是谁派来的。
576
+ * - 回程结论 (`relayPeer(target, self)`) → 问话人的群。答话方自己的群里,
577
+ * 它的回复早就以它自己的气泡出现过了。 */
555
578
  const relayPeer = (from, to, body) => {
556
579
  const text = body.trim();
557
580
  if (!text)
558
581
  return;
559
- const head = `${withTagHeader(from, "→")} ${withTagHeader(to, "")}`.trim();
582
+ const dest = baseOfKey(to);
583
+ const head = `${relayLabel(from, dest)} → ${relayLabel(to, dest)}`;
560
584
  const clipped = text.length > RELAY_MAX ? `${text.slice(0, RELAY_MAX)}…` : text;
561
- const bubble = `${head}\n${clipped}`;
562
- notifyChat(from, bubble);
563
- if (baseOfKey(from) !== baseOfKey(to))
564
- notifyChat(to, bubble);
585
+ // 头独占一行, 正文自成一个块 —— 只隔一个换行的话, markdown 会把正文首行
586
+ // 当成头那一段的续行; 表格因此整张塌成一行带竖线的文字 (表格不能打断段落)。
587
+ // 方向已经写在头里了, 正文不再加任何引用/缩进标记。
588
+ notifyChat(dest, `${head}\n\n${clipped}`);
565
589
  };
590
+ // ── 给人看的消息 ──────────────────────────────────────────────────
591
+ // send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
592
+ // 给**人**看。收件人就是地址 —— 聊天名或裸 principal, 没有「先订阅才收得到」
593
+ // 这一步。头沿用 relay 那一套: 本群退化成寻常的 `emoji #tag`, 外群写成带聊天
594
+ // 名的全称, 于是那边的人一眼看得出是谁、从哪个群说过来的。
595
+ http.register("POST /notify", async (req, res) => {
596
+ const { self, body } = await readPeerBody(req);
597
+ if (!self) {
598
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
599
+ return;
600
+ }
601
+ const b = body;
602
+ const content = (b.markdown ?? "").trim();
603
+ if (!content) {
604
+ json(res, 400, { ok: false, reason: "markdown required" });
605
+ return;
606
+ }
607
+ // 收件人写的就是名字 —— 补名必须发生在解析之前, 否则一个刚被别处命名的聊天
608
+ // 在这里仍然认不出来。
609
+ ensureChatNames(self);
610
+ // 认不出的名字原样报回去, 绝不静默跳过 ——「发了但没人收到」是这类工具最难查
611
+ // 的故障; 有一个收件人错了整条就不发, 部分送达比不送达更难对账。
612
+ const refs = (b.to ?? []).map((r) => (r ?? "").trim()).filter(Boolean);
613
+ const bad = refs.filter((r) => !chatBaseOf(cfg, r));
614
+ if (bad.length) {
615
+ json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (list_chats 看有哪些; 没起名的聊天寻址不到)` });
616
+ return;
617
+ }
618
+ const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [baseOfKey(self)])];
619
+ for (const dest of dests)
620
+ notifyChat(dest, `${relayLabel(self, dest)}\n\n${content}`);
621
+ json(res, 200, { ok: true, sent: dests.map((d) => chatNameOf(cfg, d) || d) });
622
+ });
566
623
  http.register("POST /peers/peek", async (req, res) => {
567
624
  const { self, body } = await readPeerBody(req);
568
625
  if (!self) {
@@ -576,9 +633,8 @@ const main = async () => {
576
633
  }
577
634
  const { target, foreign } = r;
578
635
  const turns = Math.min(Math.max(Number(body.turns ?? 6) || 6, 1), 40);
579
- // Transcript first — it is the conversation. Only when the peer has no
580
- // readable jsonl (never attached, or `/clear`ed a moment ago) do we fall
581
- // back to scraping its terminal.
636
+ // 先读 transcript —— 那才是对话本身。只有当这个 wizard 还没有可读的 jsonl
637
+ // (从没 attach 过, 或者刚 `/clear` 完) 才退回去刮它的终端。
582
638
  const peek = await m.peekTurns(target, turns);
583
639
  const pane = peek.ok ? undefined : await m.peekPane(target, 24);
584
640
  json(res, 200, {
@@ -603,6 +659,14 @@ const main = async () => {
603
659
  json(res, 400, { ok: false, reason: "text required" });
604
660
  return;
605
661
  }
662
+ const jobId = (body.job ?? "").trim();
663
+ if (jobId) {
664
+ const jc = checkJob(jobId);
665
+ if (!jc.ok) {
666
+ json(res, jc.status, { ok: false, reason: jc.reason });
667
+ return;
668
+ }
669
+ }
606
670
  const r = resolvePeer(self, body.tag ?? "");
607
671
  if (!r.ok) {
608
672
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
@@ -615,34 +679,93 @@ const main = async () => {
615
679
  json(res, 400, { ok: false, reason: "refusing to inject into the calling session itself" });
616
680
  return;
617
681
  }
682
+ // 投递时机。默认照旧立刻投 —— 「回答它的提问」「打断它」本来就是冲着一个
683
+ // 正在忙的会话去的。`when:"idle"` 是**派新活**该用的那一种: 两个 wizard 同时
684
+ // 找第三个时, 两段文本会挤进同一个输入框、被当成一轮读掉, 这在协同网络里
685
+ // 不是边角情况而是常态。等它闲下来再投, 一句话就是一轮。
686
+ const when = (body.when ?? "now") === "idle" ? "idle" : "now";
687
+ const waitSec = Math.min(Math.max(Number(body.waitSec ?? 600) || 600, 10), 3600);
688
+ const wasBusy = await m.isBusy(target);
689
+ let waitedMs = 0;
690
+ if (when === "idle" && wasBusy) {
691
+ const t0 = Date.now();
692
+ // rampMs=0: ramp 是给「刚注入、这一轮还没起来」准备的; 这里相反, 一旦它
693
+ // 真闲下来就该立刻投。
694
+ const wr = await waitForIdle(target, m.isBusy, waitSec * 1000, () => false, { rampMs: 0, confirm: 2 });
695
+ waitedMs = Date.now() - t0;
696
+ if (!wr.idle) {
697
+ json(res, 409, { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪, 或者用 when:"now" 插队` });
698
+ return;
699
+ }
700
+ }
618
701
  const inj = await m.injectText(target, text);
619
- if (inj.ok)
702
+ // 归到工单名下的往返不单独出气泡: 五路 fan-out 的每一次派活都发一条, 群里
703
+ // 就只剩交叉的气泡, 读不出结构。它照旧落在对方的 chat 详情页里, 收工那一条
704
+ // 会把成员和各自那段活一起列出来。
705
+ if (inj.ok && jobId)
706
+ jobs.attach(jobId, { target, task: text, spawned: false });
707
+ if (inj.ok && !jobId)
620
708
  relayPeer(self, target, text);
621
- json(res, inj.ok ? 200 : 502, { ...inj, target, foreign });
709
+ // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
710
+ // 它这一轮后面, 而不是马上被读到。
711
+ json(res, inj.ok ? 200 : 502, { ...inj, target, foreign, when, wasBusy, waitedMs, ...(jobId ? { job: jobId } : {}) });
622
712
  });
713
+ // 等一个 wizard, 或者等一**组**。fan-out 之后 join 必须是并行的: 串行地等五个
714
+ // 分身, 墙钟是五个之和, 而它们本来就在同时干活。`need` 把 all / any / 过半收进
715
+ // 一个数字 (默认全等), 满了就撤掉剩下的等待。
623
716
  http.register("POST /peers/wait", async (req, res) => {
624
717
  const { self, body } = await readPeerBody(req);
625
718
  if (!self) {
626
719
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
627
720
  return;
628
721
  }
629
- const r = resolvePeer(self, body.tag ?? "");
630
- if (!r.ok) {
631
- json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
722
+ const b = body;
723
+ const asked = (Array.isArray(b.tags) && b.tags.length > 0 ? b.tags : [b.tag ?? ""]).map((x) => String(x ?? ""));
724
+ if (asked.length > 16) {
725
+ json(res, 400, { ok: false, reason: "一次最多等 16 个 wizard" });
632
726
  return;
633
727
  }
634
- const { target, foreign } = r;
635
- if (target === self) {
728
+ // 全部先解析: 地址错了就整批拒绝, 而不是等了十分钟才发现有一个打错了。
729
+ const resolved = asked.map((address) => ({ address, r: resolvePeer(self, address) }));
730
+ const bad = resolved.find((x) => !x.r.ok);
731
+ if (bad && !bad.r.ok) {
732
+ json(res, bad.r.status, { ok: false, reason: `地址 '${bad.address}': ${bad.r.reason}`, candidates: bad.r.candidates });
733
+ return;
734
+ }
735
+ const hits = resolved.map((x) => ({ address: x.address, ...x.r }));
736
+ if (hits.some((h) => h.target === self)) {
636
737
  json(res, 400, { ok: false, reason: "refusing to wait on the calling session itself" });
637
738
  return;
638
739
  }
639
- const timeoutMs = Math.min(Math.max(Number(body.timeoutSec ?? 900) || 900, 10), 7200) * 1000;
640
- const wr = await waitForIdle(target, m.isBusy, timeoutMs, () => false);
641
- const lastText = m.lastText(target);
642
- // Only a finished turn is a real answer; a timeout's tail is half-written.
643
- if (wr.idle)
644
- relayPeer(target, self, lastText);
645
- json(res, 200, { ok: true, target, foreign, idle: wr.idle, reason: wr.reason, lastText });
740
+ // 同一个 wizard 写两遍不该顶掉两个名额。
741
+ const uniq = hits.filter((h, i) => hits.findIndex((o) => o.target === h.target) === i);
742
+ const need = Math.min(Math.max(Number(b.need ?? uniq.length) || uniq.length, 1), uniq.length);
743
+ const timeoutMs = Math.min(Math.max(Number(b.timeoutSec ?? 900) || 900, 10), 7200) * 1000;
744
+ const wrs = await waitForQuorum(uniq.map((h) => h.target), m.isBusy, timeoutMs, need);
745
+ const results = uniq.map((h, i) => {
746
+ const wr = wrs[i];
747
+ const lastText = m.lastText(h.target);
748
+ // 回程只在跨聊天时下发。同一个群里, 对方的回复本来就会以它自己的 `emoji #tag`
749
+ // 气泡出现 —— 再 relay 一条 `B → A` 就是同一句话在同一个群里出现两次, 正是
750
+ // 「A 告诉 B 之后不必再展示 B 收到了」要消灭的那种重复。跨聊天则相反: A 的群
751
+ // 里看不到 B 的任何气泡, 这条 relay 是那边唯一能看见结论的地方 —— 所以它落在
752
+ // self (问话人) 的群, 而不是答话方那边。
753
+ if (wr.idle && h.foreign)
754
+ relayPeer(h.target, self, lastText);
755
+ // `result` 是它自己收口的那一行 (见 peers.extractResult); 捞不到就是 "",
756
+ // 调用方照旧读 lastText。
757
+ return { address: h.address, target: h.target, foreign: h.foreign, idle: wr.idle, reason: wr.reason, result: extractResult(lastText), lastText };
758
+ });
759
+ const first = results[0];
760
+ // 单目标的扁平回显照旧 —— 老调用方 (和只等一个的绝大多数调用) 不必去读数组。
761
+ json(res, 200, {
762
+ ok: true,
763
+ need,
764
+ done: results.filter((x) => x.idle).length,
765
+ satisfied: results.filter((x) => x.idle).length >= need,
766
+ results,
767
+ target: first.target, foreign: first.foreign, idle: first.idle, reason: first.reason, result: first.result, lastText: first.lastText,
768
+ });
646
769
  });
647
770
  // POST /handoff — 交接一个 pane 的会话给一个全新会话,原地完成。先让目标
648
771
  // 会话把当前工作压成一份"零上下文也能接手"的交接简报,等它写完并抓取,
@@ -719,92 +842,608 @@ const main = async () => {
719
842
  ? { ok: true, target, brief }
720
843
  : { ok: false, target, brief, reason: `handoff carry inject failed: ${inj2.reason}` });
721
844
  });
722
- // ── Topic pub/sub (MCP-driven) ─────────────────────────────────────
723
- // 订阅/退订/列表/定时/取消 全部走 MCP,不再有 IM 文本命令。self 复用 peer
724
- // 路由的解析链(target → sessionId → tmuxPane → defaultChat),把调用方所在
725
- // 的聊天当作订阅者。即时广播是订阅者无关的,复用全局 POST /publish,不在这里
726
- // 另开。持久化与 startScheduler 定时器不变。
727
- const topicOf = (body) => (body.topic ?? "").trim();
728
- http.register("POST /topics/subscribe", async (req, res) => {
845
+ // ── Wizard: 会话的身份层 ────────────────────────────────────────────
846
+ // 一个会话 (`chat:xxx#tag`) 从此是一个 wizard: 名字 (= 聊天名, 带 tag 的分身读作
847
+ // `chat#tag`)、一句职责、一份跨会话的记忆、一条家谱。身份不靠"在对话里讲一遍"
848
+ // 维持 —— spawn 时以 `--append-system-prompt` 压进那个进程, `/clear` 抹不掉、
849
+ // 上下文窗口也挤不掉。
850
+ // 这里只做三件事: 读身份、改身份、按身份生/收分身。tmux 一概不碰, 动作全在
851
+ // mirror-bridge。
852
+ const wizards = bindWizardStore(loadWizardStore(cfg.wrc.mirror.wizardsFile));
853
+ // 名册增量的投递面 (见 notices.ts)。charter 只是出生那一刻的快照; 此后的成员
854
+ // 变动从这里搭下一次注入的车进到每个在场 wizard 的上下文里, 不占一轮。
855
+ // 一条变动只投给**同一个聊天**里在场的其他 wizard: 群成员变动是那个群的事,
856
+ // 当事人自己做的自己知道, 所以排除在外。
857
+ const notices = bindNoticeBox(createNoticeBox());
858
+ const postRoster = (base, except, line) => notices.post(chatAudience(m.chatTargets(base), base, except), line);
859
+ /** 一个聊天的工作区: 它名下各会话跑在哪个目录, 取最多的那个。聊天与工作区是
860
+ * 一对多, 但绝大多数聊天只围着一个项目转。 */
861
+ const chatCwd = (targets) => {
862
+ const counts = targets.reduce((acc, t) => {
863
+ const c = m.getCwd(t).runningCwd;
864
+ return c ? { ...acc, [c]: (acc[c] ?? 0) + 1 } : acc;
865
+ }, {});
866
+ return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] ?? "";
867
+ };
868
+ /** 拦在每一个「把名字交给模型」的入口前: 没名字的聊天按它的工作区补一个。
869
+ *
870
+ * 名字就是地址。一个没名字的聊天在别的聊天眼里只有 `chat:wr4-87DwAA…` 这串
871
+ * key —— 能寻址, 但模型抄错一个字符就找不到, 人更读不出那是哪个群。等人想起来
872
+ * 去 `/name` 是等不到的 (实测 16 个聊天只有 3 个有名字), 而每个聊天本来就带着
873
+ * 一个可读的标识: 它在干哪个项目。所以在这里补, 而不是在某个 spawn 路径上补 ——
874
+ * 聊天是先于 wizard 存在的, 只有"要用到名字"这个时刻才是它必须有名字的时刻。
875
+ * 只填空, 从不覆盖人起过的名字; 推不出名字 (工作区为空) 的原样留着。 */
876
+ const ensureChatNames = (self) => {
877
+ const blank = m.chatRoster(self).filter((c) => !c.name);
878
+ if (blank.length === 0)
879
+ return {};
880
+ // 会话多的先排 —— 同一个目录下的几个聊天要靠序号区分 (`lisct` / `lisct-2`),
881
+ // 而裸名字该归那个真正在干这个项目的群; 按 chatRoster 的字典序分配的话,
882
+ // 70 个会话的主力群会拿到 `lisct-7`, 一个只有一条冷记录的空群反倒占了裸名。
883
+ const plan = planChatNames(cfg, blank
884
+ .slice()
885
+ .sort((x, y) => y.targets.length - x.targets.length)
886
+ .map((c) => ({ base: c.base, cwd: chatCwd(c.targets) })));
887
+ const named = applyChatNames(cfg, sourcePath, plan);
888
+ for (const [base, name] of Object.entries(named)) {
889
+ log.info({ base, name }, "chat: auto-named from workspace");
890
+ // 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
891
+ // 得知道自己的地址变了 —— 它的 charter 里写的还是「(未命名)」。
892
+ postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— 别的聊天从此能以 \`${name}#tag\` 叫到这里, 你的地址也随之可读了。`);
893
+ }
894
+ return named;
895
+ };
896
+ // 工单账本 (见 jobs.ts)。只在**显式传了 `job`** 时起作用 —— 不用工单的调用方
897
+ // 行为与从前一模一样。
898
+ const jobs = loadJobStore(cfg.wrc.mirror.jobsFile);
899
+ /** 派活前先验工单: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。 */
900
+ const checkJob = (id) => {
901
+ const j = jobs.get(id);
902
+ if (!j)
903
+ return { ok: false, status: 404, reason: `没有工单 '${id}' —— open_job 先开一个, 或者 list_jobs 看还开着哪些` };
904
+ if (j.status !== "open")
905
+ return { ok: false, status: 409, reason: `工单 '${id}' 已经收工了` };
906
+ if (j.members.length >= JOB_MEMBER_MAX)
907
+ return { ok: false, status: 409, reason: `工单 '${id}' 的成员已经满了 (${JOB_MEMBER_MAX} 个) —— 分身是有成本的, 拆成两个工单, 或者先收工回收掉一批` };
908
+ return { ok: true };
909
+ };
910
+ /** 上下文超过这个数就该自己交接了。Claude 家族最小的窗口是 200k, 留三成余量
911
+ * 给交接那一轮本身 —— 提示而已, 决定权在 wizard 自己。 */
912
+ const HANDOFF_HINT_TOKENS = 140_000;
913
+ const briefOf = (self, target) => ({
914
+ name: wizardName(wizards.get(target), chatNameOf(cfg, target), target),
915
+ address: peerAddress(cfg, self, target),
916
+ description: wizards.get(target)?.description ?? "",
917
+ cwd: m.getCwd(target).runningCwd,
918
+ });
919
+ /** 自己的地址要写成**全局**形态 (`聊天名#tag`), 不是同群内部的裸 tag —— 宪章
920
+ * 和 whoami 里的这个串会被它原样贴给别的聊天的 wizard, 裸 tag 到了那边只有
921
+ * 在全机唯一时才碰巧能解析。聊天没起名时只能退回裸 tag。 */
922
+ const selfAddress = (target) => {
923
+ const chat = chatNameOf(cfg, target);
924
+ const tag = tagOfKey(target);
925
+ return chat ? `${chat}#${tag}` : tag;
926
+ };
927
+ /** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
928
+ * 让它一睁眼就知道自己不是一个人在跑。 */
929
+ const charterFor = (target, o) => renderCharter({
930
+ self: { ...briefOf(target, target), address: selfAddress(target) },
931
+ chat: chatNameOf(cfg, target),
932
+ principal: baseOfKey(target),
933
+ parent: o.parent ? briefOf(target, o.parent) : undefined,
934
+ inherited: !!o.inherited,
935
+ siblings: m.chatTargets(baseOfKey(target)).filter((t) => t !== target).map((t) => briefOf(target, t)),
936
+ memory: wizards.get(target)?.memory ?? [],
937
+ });
938
+ // 所有 spawn 路径 (群里手打 /new、pane 自愈重生、编排出来的分身) 都从这里
939
+ // 取身份, 于是「是谁」不再取决于是哪段代码把它生出来的。
940
+ m.setCharterProvider((target) => {
941
+ // 这里是最要紧的那道拦截: 宪章把「所在聊天」写进系统提示, 而系统提示是随进程
942
+ // 终身的 —— 一个在聊天还没名字时出生的 wizard, 会一辈子以为自己住在一个
943
+ // 「(未命名)」的地方。所以补名要发生在渲染之前, 不能等它以后自己去查。
944
+ ensureChatNames(target);
945
+ const rec = wizards.get(target);
946
+ return charterFor(target, { parent: rec?.parent, inherited: !!rec?.clonedFrom });
947
+ });
948
+ // 开机也补一次: IM 侧的 `/peers`、`/help` 直接读名字, 不经过任何 MCP 路由。
949
+ // 延后是因为附着表要先从盘上恢复完, 否则这一刻还看不见那些聊天; 补不全也无妨,
950
+ // 上面每一道拦截都会再补。
951
+ setTimeout(() => {
952
+ try {
953
+ ensureChatNames(cfg.defaultChat ?? "");
954
+ }
955
+ catch (e) {
956
+ log.warn({ err: e.message }, "boot auto-name failed");
957
+ }
958
+ }, 15_000).unref();
959
+ const kinOf = (self, all, target) => ({
960
+ parent: wizards.get(target)?.parent ? briefOf(self, wizards.get(target).parent) : undefined,
961
+ ancestors: ancestorsOf(all, target).map((t) => briefOf(self, t)),
962
+ clones: childrenOf(all, target).map((w) => briefOf(self, w.target)),
963
+ });
964
+ const identityOf = (self, target) => {
965
+ const rec = wizards.get(target);
966
+ const info = m.sessionInfo(target);
967
+ const me = briefOf(self, target);
968
+ return {
969
+ target,
970
+ ...me,
971
+ address: self === target ? selfAddress(target) : me.address,
972
+ chat: chatNameOf(cfg, target),
973
+ principal: baseOfKey(target),
974
+ tag: tagOfKey(target),
975
+ named: !!(rec?.name ?? "").trim() || !!chatNameOf(cfg, target),
976
+ bornAt: rec?.bornAt,
977
+ inheritedFrom: rec?.clonedFrom || "",
978
+ memory: rec?.memory ?? [],
979
+ ...kinOf(self, wizards.all(), target),
980
+ sessionId: info?.sessionId ?? "",
981
+ cli: info?.cli,
982
+ model: info?.model ?? "",
983
+ contextTokens: info?.contextTokens ?? 0,
984
+ handoffSuggested: (info?.contextTokens ?? 0) > HANDOFF_HINT_TOKENS,
985
+ };
986
+ };
987
+ /** 全体名册: 活着的 (peers + 跨聊天可寻址的) 在前, 注册表里有记录但此刻不在
988
+ * 可寻址集合里的 (pane 死了 / 聊天没起名) 跟在后面 —— 它们仍是这个世界的一员,
989
+ * 只是暂时叫不动。 */
990
+ const rosterOf = async (self) => {
991
+ const [peers, foreign] = await Promise.all([m.peers(self), m.foreignPeers(self)]);
992
+ const all = wizards.all();
993
+ const live = [...peers, ...foreign].map((p) => {
994
+ const b = briefOf(self, p.target);
995
+ return {
996
+ ...b,
997
+ target: p.target,
998
+ chat: chatNameOf(cfg, p.target) || p.chat,
999
+ label: p.label,
1000
+ cli: p.cli,
1001
+ model: p.model,
1002
+ busy: p.busy,
1003
+ alive: p.paneAlive,
1004
+ self: p.self,
1005
+ lastActivity: p.lastActivity,
1006
+ summary: p.summary,
1007
+ ...kinOf(self, all, p.target),
1008
+ };
1009
+ });
1010
+ const seen = new Set(live.map((r) => r.target));
1011
+ const cold = all
1012
+ .filter((w) => !seen.has(w.target))
1013
+ .map((w) => ({
1014
+ ...briefOf(self, w.target),
1015
+ target: w.target,
1016
+ chat: chatNameOf(cfg, w.target),
1017
+ busy: false,
1018
+ alive: false,
1019
+ self: false,
1020
+ // 没跑过就没有活动时刻。排序拿它当最旧 —— 冷会话排在活着的后面, 正是
1021
+ // 截断时该被留下的顺序。
1022
+ lastActivity: 0,
1023
+ summary: "(未运行 —— 发消息或 send_peer 会把它唤醒)",
1024
+ ...kinOf(self, all, w.target),
1025
+ }));
1026
+ return [...live, ...cold];
1027
+ };
1028
+ http.register("POST /wizard/whoami", async (req, res) => {
1029
+ const { self } = await readPeerBody(req);
1030
+ if (!self) {
1031
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1032
+ return;
1033
+ }
1034
+ // 身份已经写在系统提示里了, whoami 回答的是"此刻"的部分: 上下文用了多少、
1035
+ // 分身还剩几个、工作区有没有被换掉。
1036
+ ensureChatNames(self);
1037
+ json(res, 200, { ok: true, ...identityOf(self, self), peers: (await m.peers(self)).filter((p) => !p.self).length });
1038
+ });
1039
+ // 起名字 / 写职责。默认会话的名字就是聊天的名字 (需求: wizard 的名字 = chat
1040
+ // 的名字), 所以那一路同时写 chats 表 —— 否则别的聊天仍然叫不到它。带 tag 的
1041
+ // 分身只写自己的记录, 它的地址本来就是 `chat#tag`。
1042
+ http.register("POST /wizard/identity", async (req, res) => {
1043
+ const { self, body } = await readPeerBody(req);
1044
+ if (!self) {
1045
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1046
+ return;
1047
+ }
1048
+ const b = body;
1049
+ const name = (b.name ?? "").toString().trim();
1050
+ const description = (b.description ?? "").toString().trim();
1051
+ const wasNamed = !!wizardName(wizards.get(self), chatNameOf(cfg, self), self);
1052
+ let chatNamed;
1053
+ if (name && !tagOfKey(self)) {
1054
+ const r = setChatName(cfg, sourcePath, self, name);
1055
+ if (!r.ok) {
1056
+ json(res, 409, { ok: false, reason: r.reason });
1057
+ return;
1058
+ }
1059
+ chatNamed = r.name;
1060
+ }
1061
+ wizards.upsert(self, {
1062
+ ...(name ? { name: chatNamed ?? name } : {}),
1063
+ ...(description ? { description } : {}),
1064
+ });
1065
+ const me = identityOf(self, self);
1066
+ // 起名是稀有事件, 值得在群里留一条 —— 人得知道群里这个角色叫什么了。
1067
+ if (name && !wasNamed)
1068
+ notifyChat(self, withTagHeader(self, `我是 **${me.name}**${description ? ` · ${description}` : ""}`));
1069
+ // 职责是别人决定"该不该找你"的依据, 改了就得让同群的人知道 —— 否则他们照着
1070
+ // 出生快照里那句旧的 (或者空的) 职责派活。
1071
+ if (name || description)
1072
+ postRoster(baseOfKey(self), [self], `**${me.name || me.address}** 改了身份 · 地址 \`${peerAddress(cfg, baseOfKey(self), self)}\`${description ? ` · 职责: ${description}` : ""}`);
1073
+ json(res, 200, { ok: true, ...me, chatNamed });
1074
+ });
1075
+ // 名册是**索引**, 不是转储: 这台机器上现在有 300+ 个会话, 整表吐出来是三万
1076
+ // token —— 一个 wizard 每次想找人都付这个价, 等于找不到人。所以服务端过滤
1077
+ // (名字/职责/工作区/聊天/死活), 并且默认只给最相关的一页, 同时回 total 让
1078
+ // 调用方知道自己看的是不是全部。
1079
+ const rosterFilters = (b) => {
1080
+ const q = (b.query ?? "").trim().toLowerCase();
1081
+ const chat = (b.chat ?? "").trim().toLowerCase();
1082
+ const cwd = (b.cwd ?? "").trim().toLowerCase();
1083
+ return (w) => (!q || [w.name, w.address, w.description, w.target].some((f) => (f ?? "").toLowerCase().includes(q))) &&
1084
+ (!chat || (w.chat ?? "").toLowerCase() === chat || (w.target ?? "").toLowerCase().includes(chat)) &&
1085
+ (!cwd || (w.cwd ?? "").toLowerCase().includes(cwd)) &&
1086
+ (b.alive !== true || w.alive);
1087
+ };
1088
+ http.register("POST /wizard/roster", async (req, res) => {
729
1089
  const { self, body } = await readPeerBody(req);
730
1090
  if (!self) {
731
1091
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
732
1092
  return;
733
1093
  }
734
- const topic = topicOf(body);
735
- if (!topic) {
736
- json(res, 400, { ok: false, reason: "topic required" });
1094
+ const b = body;
1095
+ ensureChatNames(self);
1096
+ const all = await rosterOf(self);
1097
+ const hit = all.filter(rosterFilters(b));
1098
+ // 自己在最前, 然后活着的, 然后最近动过的 —— 截断时留下的正是最可能有用的。
1099
+ const ranked = hit.slice().sort((x, y) => Number(y.self) - Number(x.self) || Number(y.alive) - Number(x.alive) || (y.lastActivity ?? 0) - (x.lastActivity ?? 0));
1100
+ const limit = Math.min(Math.max(Number(b.limit ?? 40) || 40, 1), 300);
1101
+ const shown = ranked.slice(0, limit);
1102
+ json(res, 200, {
1103
+ ok: true,
1104
+ self: identityOf(self, self),
1105
+ total: all.length,
1106
+ matched: hit.length,
1107
+ wizards: shown,
1108
+ ...(hit.length > shown.length
1109
+ ? { more: `还有 ${hit.length - shown.length} 个没列出来 —— 用 query (名字/职责) / cwd (工作区) / chat 收窄, 或者调大 limit` }
1110
+ : {}),
1111
+ });
1112
+ });
1113
+ http.register("POST /wizard/remember", async (req, res) => {
1114
+ const { self, body } = await readPeerBody(req);
1115
+ if (!self) {
1116
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
737
1117
  return;
738
1118
  }
739
- const r = subscribeTopic(cfg, sourcePath, topic, self);
740
- json(res, 200, { ok: true, ...r, topic, target: self, subs: (cfg.topics.subs[topic] ?? []).length });
1119
+ const b = body;
1120
+ const cur = wizards.get(self)?.memory ?? [];
1121
+ const note = (b.note ?? "").toString().trim();
1122
+ const forget = (b.forget ?? "").toString().trim();
1123
+ // 忘记按子串匹配: 模型记不住自己当初一字不差写了什么, 但记得大意。
1124
+ const next = forget ? cur.filter((x) => !x.includes(forget)) : cur;
1125
+ const memory = note ? [...next.filter((x) => x !== note), note] : next;
1126
+ wizards.upsert(self, { memory });
1127
+ json(res, 200, { ok: true, memory, added: !!note, forgotten: cur.length - next.length });
741
1128
  });
742
- http.register("POST /topics/unsubscribe", async (req, res) => {
1129
+ // 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
1130
+ // 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
1131
+ http.register("POST /wizard/clone", async (req, res) => {
743
1132
  const { self, body } = await readPeerBody(req);
744
1133
  if (!self) {
745
1134
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
746
1135
  return;
747
1136
  }
748
- const topic = topicOf(body);
749
- if (!topic) {
750
- json(res, 400, { ok: false, reason: "topic required" });
1137
+ const b = body;
1138
+ // 工单先验: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。
1139
+ const jobId = (b.job ?? "").trim();
1140
+ if (jobId) {
1141
+ const jc = checkJob(jobId);
1142
+ if (!jc.ok) {
1143
+ json(res, jc.status, { ok: false, reason: jc.reason });
1144
+ return;
1145
+ }
1146
+ }
1147
+ // `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
1148
+ // 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
1149
+ // 逼调用方每次自己说。
1150
+ if (typeof b.inherit !== "boolean") {
1151
+ json(res, 400, { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" });
1152
+ return;
1153
+ }
1154
+ const alivePeers = await m.peers(self);
1155
+ const inherit = b.inherit;
1156
+ // 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
1157
+ // close_job 整批回收一个工单的临时分身。只数**本聊天里**活着的 ——
1158
+ // 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
1159
+ const aliveKids = childrenOf(wizards.all(), self).filter((k) => alivePeers.some((pp) => pp.target === k.target && pp.paneAlive));
1160
+ if (aliveKids.length >= cfg.wrc.mirror.cloneMax) {
1161
+ json(res, 429, {
1162
+ ok: false,
1163
+ reason: `你名下已经有 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) —— 先 stop_wizard 收掉干完活的那些, 或者 close_job 整批回收一个工单`,
1164
+ clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
1165
+ });
751
1166
  return;
752
1167
  }
753
- const r = unsubscribeTopic(cfg, sourcePath, topic, self);
754
- json(res, 200, { ok: true, ...r, topic, target: self });
1168
+ const wantChat = (b.chat ?? "").toString().trim();
1169
+ const base = wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self);
1170
+ if (!base) {
1171
+ json(res, 404, {
1172
+ ok: false,
1173
+ reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
1174
+ candidates: listChatNames(cfg).map((c) => c.name),
1175
+ });
1176
+ return;
1177
+ }
1178
+ const taken = new Set(m.chatTargets(base).map(tagOfKey).filter(Boolean));
1179
+ const asked = normalizeTag(b.tag);
1180
+ if (asked && taken.has(asked)) {
1181
+ json(res, 409, { ok: false, reason: `'#${asked}' 已经是一个活着的 wizard —— 换个名字, 或者直接 send_peer 找它`, tag: asked });
1182
+ return;
1183
+ }
1184
+ const tag = asked || uniqueTag(normalizeTag(b.description?.split(/\s+/)[0]) || "clone", taken);
1185
+ const target = keyOf(base, tag);
1186
+ const parentInfo = m.sessionInfo(self);
1187
+ // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
1188
+ wizards.upsert(target, {
1189
+ description: (b.description ?? "").toString().trim(),
1190
+ parent: self,
1191
+ bornAt: Date.now(),
1192
+ clonedFrom: inherit ? parentInfo?.sessionId ?? "" : "",
1193
+ });
1194
+ const charter = charterFor(target, { parent: self, inherited: inherit });
1195
+ const task = (b.task ?? "").toString().trim();
1196
+ const r = await m.cloneSession({
1197
+ parent: self,
1198
+ target,
1199
+ windowName: tag,
1200
+ cli: b.cli,
1201
+ model: b.model,
1202
+ cwd: b.cwd,
1203
+ systemPrompt: charter,
1204
+ inherit,
1205
+ // 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
1206
+ // 也少一次"就位了但没事干"的空转。
1207
+ bootstrap: task || undefined,
1208
+ });
1209
+ if (!r.ok) {
1210
+ wizards.drop(target);
1211
+ json(res, 500, { ok: false, reason: r.reason });
1212
+ return;
1213
+ }
1214
+ wizards.upsert(target, { clonedFrom: r.inherited ? parentInfo?.sessionId ?? "" : "" });
1215
+ const me = briefOf(self, self);
1216
+ const kid = briefOf(self, target);
1217
+ // 分身出生要在群里留一条 —— 群里多了一个成员, 人有权当场知道。
1218
+ // 工单里的分身是临时工: 出生、派活各发一条气泡, 五路 fan-out 就是十条交叉的
1219
+ // 气泡, 人从里面读不出结构。它们攒到收工那一条里一起交代 (成员 + 各自那段活),
1220
+ // 中间过程照旧在各自的 chat 详情页。同理不惊动同群的其他 wizard。
1221
+ if (!jobId) {
1222
+ notifyChat(base, withTagHeader(target, `已就位 · ${r.inherited ? `${displayName(self)} 的分身 (继承了它的上下文)` : "全新 wizard (空白上下文)"}${kid.description ? ` · ${kid.description}` : ""}`));
1223
+ postRoster(base, [target, self], `新 wizard **${kid.name || tag}** 就位 · 地址 \`${peerAddress(cfg, base, target)}\`${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${b.model?.trim() ? ` · 模型 ${b.model.trim()}` : ""} —— ${displayName(self)} 的分身${r.inherited ? " (继承了它的上下文)" : ""}`);
1224
+ }
1225
+ // 派活在群里留一条 (它是关键节点)。继承路径上活已经随开场白进去了, 空白分身
1226
+ // 才需要在这里补一次注入。
1227
+ let dispatched = r.inherited && !!task;
1228
+ if (task && !r.inherited) {
1229
+ const inj = await m.injectText(target, task);
1230
+ dispatched = inj.ok;
1231
+ }
1232
+ if (jobId)
1233
+ jobs.attach(jobId, { target, task, spawned: true });
1234
+ else if (dispatched)
1235
+ relayPeer(self, target, task);
1236
+ json(res, 200, { ok: true, target, tag, address: peerAddress(cfg, self, target), name: kid.name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, ...(jobId ? { job: jobId } : {}) });
755
1237
  });
756
- // List = 本聊天订阅了哪些 topic + 全部定时广播(scheduler 是进程级的)。
757
- http.register("POST /topics/list", async (req, res) => {
758
- const { self } = await readPeerBody(req);
1238
+ // 收掉一个 wizard。interrupt = 打断它这一轮 (Esc); end = 结束它并回收 pane。
1239
+ // 自己终结自己是合法的 (分身干完活自我了结), 代价是这次工具调用不会返回 ——
1240
+ // 群里的气泡就是回执, 与 set_workspace 同款。
1241
+ http.register("POST /wizard/stop", async (req, res) => {
1242
+ const { self, body } = await readPeerBody(req);
759
1243
  if (!self) {
760
1244
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
761
1245
  return;
762
1246
  }
763
- json(res, 200, { ok: true, target: self, subs: listSubs(cfg, self), schedules: listSchedules(cfg) });
1247
+ const b = body;
1248
+ const r = resolvePeer(self, b.tag ?? "");
1249
+ if (!r.ok) {
1250
+ json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
1251
+ return;
1252
+ }
1253
+ const { target } = r;
1254
+ const victim = briefOf(self, target);
1255
+ const end = (b.mode ?? "end") === "end";
1256
+ if (!end && target === self) {
1257
+ json(res, 400, { ok: false, reason: "打断自己没有意义 —— 你就是正在生成的那一个" });
1258
+ return;
1259
+ }
1260
+ const done = end ? await m.killPane(target) : await m.interruptPane(target, { teardown: true });
1261
+ if (!done.ok) {
1262
+ json(res, 502, { ok: false, target, reason: done.reason });
1263
+ return;
1264
+ }
1265
+ if (end && b.forget)
1266
+ wizards.drop(target);
1267
+ notifyChat(target, withTagHeader(target, `${end ? "已结束" : "已打断"} · 由 ${displayName(self)} 发起`));
1268
+ // 打断只是停了它这一轮, 它还在; 只有结束才是名册变了。
1269
+ if (end)
1270
+ postRoster(baseOfKey(target), [target, self], `**${victim.name || target}** 已收工 · 由 ${displayName(self)} 结束${b.forget ? " (记录一并抹掉)" : ""}`);
1271
+ json(res, 200, { ok: true, target, name: victim.name, mode: end ? "end" : "interrupt", forgotten: !!(end && b.forget) });
1272
+ });
1273
+ // 自我交接: 上下文撑不住了, 自己把工作压成简报, 原地 /clear 重开, 再把简报贴
1274
+ // 回去。和 /handoff 的区别是**简报由调用方自己写在入参里** —— 它没法在自己
1275
+ // 生成的当口再被问一次 (那正是 /handoff 拒绝对自身操作的原因)。所以这里先应答,
1276
+ // 等它这一轮说完、pane 空下来再动手。
1277
+ http.register("POST /wizard/handoff-self", async (req, res) => {
1278
+ const { self, body } = await readPeerBody(req);
1279
+ if (!self) {
1280
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1281
+ return;
1282
+ }
1283
+ const brief = (body.brief ?? "").toString().trim();
1284
+ if (brief.length < 40) {
1285
+ json(res, 400, { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" });
1286
+ return;
1287
+ }
1288
+ const info = m.sessionInfo(self);
1289
+ json(res, 200, { ok: true, target: self, contextTokens: info?.contextTokens ?? 0, scheduled: true });
1290
+ // 应答之后再干活: 调用方此刻还在生成, 必须等它把话说完。
1291
+ void (async () => {
1292
+ const lg = log.child({ mod: "wizard", sub: "handoff-self", target: self });
1293
+ const idle = await waitForIdle(self, m.isBusy, 10 * 60_000, () => false);
1294
+ if (!idle.idle) {
1295
+ lg.warn({ reason: idle.reason }, "self-handoff: 目标一直忙, 放弃");
1296
+ return;
1297
+ }
1298
+ const clr = await m.injectText(self, "/clear");
1299
+ if (!clr.ok) {
1300
+ lg.warn({ reason: clr.reason }, "self-handoff: /clear 注入失败");
1301
+ return;
1302
+ }
1303
+ await new Promise((r2) => setTimeout(r2, 3000));
1304
+ const carry = `以下是你自己上一段会话压缩出来的交接简报, 据此无缝接着干:\n\n${brief}`;
1305
+ const inj = await m.injectText(self, carry);
1306
+ lg.info({ ok: inj.ok, reason: inj.reason }, "self-handoff: 完成");
1307
+ notifyChat(self, withTagHeader(self, `上下文已交接重开 (${info?.contextTokens ?? 0} tok → 0), 工作照旧`));
1308
+ })();
764
1309
  });
765
- http.register("POST /topics/schedule", async (req, res) => {
1310
+ // ── Job: 一次 fan-out 的工单 ─────────────────────────────────────────
1311
+ // 账本, 不是编排器: 控制流留在发起的那个 wizard 手里 (它自己 spawn / wait /
1312
+ // 汇总), 这里只记谁属于这个活、谁是为它临时生的、群里该出哪两条气泡。
1313
+ // 见 jobs.ts 开头那段取舍。
1314
+ http.register("POST /jobs/open", async (req, res) => {
766
1315
  const { self, body } = await readPeerBody(req);
767
1316
  if (!self) {
768
1317
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
769
1318
  return;
770
1319
  }
771
- const topic = topicOf(body);
772
1320
  const b = body;
773
- const hour = Number(b.hour);
774
- const minute = Number(b.minute ?? 0);
775
- const content = (b.content ?? "").toString();
776
- if (!topic) {
777
- json(res, 400, { ok: false, reason: "topic required" });
1321
+ const title = (b.title ?? "").toString().trim();
1322
+ if (!title) {
1323
+ json(res, 400, { ok: false, reason: "title required —— 一句话说清这个工单要干成什么" });
778
1324
  return;
779
1325
  }
780
- if (!content.trim()) {
781
- json(res, 400, { ok: false, reason: "content required" });
1326
+ const base = baseOfKey(self);
1327
+ const job = jobs.open(base, self, title);
1328
+ notifyChat(base, renderJobOpen(job, (b.plan ?? "").toString()));
1329
+ json(res, 200, {
1330
+ ok: true,
1331
+ job: job.id,
1332
+ title,
1333
+ memberMax: JOB_MEMBER_MAX,
1334
+ hint: "把这个 id 传给 spawn_clone / send_peer 的 `job` 参数, 它们就归到这个工单名下 (期间不再逐条出气泡); 活干完调 close_job 收尾并回收临时分身。",
1335
+ });
1336
+ });
1337
+ http.register("POST /jobs/close", async (req, res) => {
1338
+ const { self, body } = await readPeerBody(req);
1339
+ if (!self) {
1340
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
782
1341
  return;
783
1342
  }
784
- if (!Number.isInteger(hour) || hour < 0 || hour > 23) {
785
- json(res, 400, { ok: false, reason: "hour must be 0-23" });
1343
+ const b = body;
1344
+ const id = (b.job ?? "").toString().trim();
1345
+ const job = jobs.get(id);
1346
+ if (!job) {
1347
+ json(res, 404, { ok: false, reason: `没有工单 '${id}'` });
1348
+ return;
1349
+ }
1350
+ if (job.status !== "open") {
1351
+ json(res, 409, { ok: false, reason: `工单 '${id}' 已经收工了`, closedAt: job.closedAt });
786
1352
  return;
787
1353
  }
788
- if (!Number.isInteger(minute) || minute < 0 || minute > 59) {
789
- json(res, 400, { ok: false, reason: "minute must be 0-59" });
1354
+ // 回收只针对**为这个工单生出来的**分身: 被拉来帮忙的长期 wizard 不该因为一次
1355
+ // 活结束就被杀掉。调用方自己也不收 —— 那会在这次工具调用里把自己干掉。
1356
+ const recycle = b.stop !== false;
1357
+ const victims = recycle ? job.members.filter((mm) => mm.spawned && mm.target !== self) : [];
1358
+ const killed = await victims.reduce(async (acc, mm) => (await acc) + ((await m.killPane(mm.target)).ok ? 1 : 0), Promise.resolve(0));
1359
+ const closed = jobs.close(id, (b.summary ?? "").toString());
1360
+ notifyChat(job.base, renderJobClose(closed, (t) => relayLabel(t, job.base), killed));
1361
+ json(res, 200, { ok: true, job: id, members: closed.members.length, recycled: killed, kept: victims.length - killed });
1362
+ });
1363
+ http.register("POST /jobs/list", async (req, res) => {
1364
+ const { self } = await readPeerBody(req);
1365
+ if (!self) {
1366
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
790
1367
  return;
791
1368
  }
792
- addSchedule(cfg, sourcePath, { topic, hour, minute, content, createdBy: self, createdAt: Date.now() });
793
- json(res, 200, { ok: true, topic, hour, minute, subs: (cfg.topics.subs[topic] ?? []).length });
1369
+ json(res, 200, {
1370
+ ok: true,
1371
+ jobs: jobs.openOf(baseOfKey(self)).map((j) => ({
1372
+ job: j.id,
1373
+ title: j.title,
1374
+ owner: displayName(j.owner),
1375
+ mine: j.owner === self,
1376
+ openedAt: j.openedAt,
1377
+ members: j.members.map((mm) => ({ address: peerAddress(cfg, self, mm.target), task: mm.task.split("\n")[0] ?? "", spawned: mm.spawned })),
1378
+ })),
1379
+ });
794
1380
  });
795
- http.register("POST /topics/cancel-schedule", async (req, res) => {
1381
+ // ── 定时任务 ────────────────────────────────────────────────────
1382
+ // 到点把一句 prompt 注入一个 wizard 会话 —— 和人在群里对它说话走的是同一条路
1383
+ // (injectText), 所以 pane 死了会被拉起来, 输出照常落进群和详情页。
1384
+ http.register("POST /tasks/schedule", async (req, res) => {
796
1385
  const { self, body } = await readPeerBody(req);
797
1386
  if (!self) {
798
1387
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
799
1388
  return;
800
1389
  }
801
- const topic = topicOf(body);
802
- if (!topic) {
803
- json(res, 400, { ok: false, reason: "topic required" });
1390
+ const b = body;
1391
+ const prompt = (b.prompt ?? "").toString().trim();
1392
+ const whenText = (b.when ?? "").toString().trim();
1393
+ if (!prompt) {
1394
+ json(res, 400, { ok: false, reason: "prompt required" });
1395
+ return;
1396
+ }
1397
+ const when = parseWhen(whenText, new Date());
1398
+ if (!when) {
1399
+ json(res, 400, { ok: false, reason: `无法理解「${whenText}」。能认的说法: 每天/每个工作日/每周三 + 时刻 (晚上9:30 / 21:30 / 九点半), 每隔 N 分钟|小时, N 分钟后, 明早 9 点。` });
804
1400
  return;
805
1401
  }
806
- const removed = removeSchedulesByTopic(cfg, sourcePath, topic);
807
- json(res, 200, { ok: true, topic, removed });
1402
+ // tag 省略 = 排给调用者自己。这是最常见的用法: wizard 给自己定一个夜里跑的活。
1403
+ // 注入自身在**当下**是死锁 (往正在生成的输入框里打字), 但定时是未来的事,
1404
+ // 那时这一轮早已收工, 所以这里不套 /peers/send 的自我保护。
1405
+ const tag = (body.tag ?? "").toString().trim();
1406
+ let target = self;
1407
+ if (tag) {
1408
+ const r = resolvePeer(self, tag);
1409
+ if (!r.ok) {
1410
+ json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
1411
+ return;
1412
+ }
1413
+ target = r.target;
1414
+ }
1415
+ const rec = addSchedule(cfg, sourcePath, { when, target, prompt, createdBy: self, note: (b.note ?? "").toString() });
1416
+ json(res, 200, { ok: true, ...renderSchedule(rec), address: peerAddress(cfg, self, target) });
1417
+ });
1418
+ http.register("POST /tasks/list", async (req, res) => {
1419
+ const { self, body } = await readPeerBody(req);
1420
+ if (!self) {
1421
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1422
+ return;
1423
+ }
1424
+ const mineOnly = body.mine === true;
1425
+ const now = new Date();
1426
+ const tasks = listSchedules(cfg, mineOnly ? self : undefined)
1427
+ .map((x) => ({ ...renderSchedule(x, now), address: peerAddress(cfg, self, x.target) }));
1428
+ json(res, 200, { ok: true, self, tasks });
1429
+ });
1430
+ http.register("POST /tasks/cancel", async (req, res) => {
1431
+ const { self, body } = await readPeerBody(req);
1432
+ if (!self) {
1433
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1434
+ return;
1435
+ }
1436
+ const id = (body.id ?? "").toString().trim();
1437
+ if (!id) {
1438
+ json(res, 400, { ok: false, reason: "id required (list_tasks 里那个 id)" });
1439
+ return;
1440
+ }
1441
+ const gone = removeScheduleById(cfg, sourcePath, id);
1442
+ if (!gone) {
1443
+ json(res, 404, { ok: false, reason: `no schedule with id ${id}` });
1444
+ return;
1445
+ }
1446
+ json(res, 200, { ok: true, removed: renderSchedule(gone) });
808
1447
  });
809
1448
  // POST /config/set — modify daemon config from MCP
810
1449
  http.register("POST /config/set", async (req, res) => {
@@ -914,8 +1553,17 @@ const main = async () => {
914
1553
  json(res, 200, { ok: true, target, ...m.getCwd(target) });
915
1554
  });
916
1555
  }
917
- // 事件订阅调度器 — 每 20s 检查 topics.schedules,匹配当前分钟即广播。
918
- const scheduler = startScheduler({ client: ws.client, cfg, log: log.child({ mod: "topics" }) });
1556
+ // 定时调度器 — 每 20s 检查 cfg.schedules, 到点把 prompt 注入目标 wizard。
1557
+ // inject 只在 mirror 模式给得出 (headless 模式没有常驻会话可注入)。
1558
+ const scheduler = startScheduler({
1559
+ client: ws.client,
1560
+ cfg,
1561
+ sourcePath,
1562
+ log: log.child({ mod: "tasks" }),
1563
+ inject: cfg.wrc.mode === "mirror"
1564
+ ? (target, text) => bridge.injectText(target, text, undefined, { fromChat: true })
1565
+ : undefined,
1566
+ });
919
1567
  const shutdown = async (signal) => {
920
1568
  log.info({ signal }, "shutdown signal");
921
1569
  scheduler.stop();