wezard 1.4.1 → 2.0.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 (71) hide show
  1. package/CLAUDE.md +12 -7
  2. package/README.md +227 -112
  3. package/cli/wezard.sh +6 -3
  4. package/dist/daemon/approval.js +9 -3
  5. package/dist/daemon/approval.js.map +1 -1
  6. package/dist/daemon/cc-bridge.js.map +1 -1
  7. package/dist/daemon/chat-name.js +8 -14
  8. package/dist/daemon/chat-name.js.map +1 -1
  9. package/dist/daemon/detail.js +94 -9
  10. package/dist/daemon/detail.js.map +1 -1
  11. package/dist/daemon/graph.js +5 -7
  12. package/dist/daemon/graph.js.map +1 -1
  13. package/dist/daemon/inbound.js +135 -158
  14. package/dist/daemon/inbound.js.map +1 -1
  15. package/dist/daemon/index.js +452 -156
  16. package/dist/daemon/index.js.map +1 -1
  17. package/dist/daemon/jobs.js +2 -2
  18. package/dist/daemon/mirror-bridge.js +284 -140
  19. package/dist/daemon/mirror-bridge.js.map +1 -1
  20. package/dist/daemon/mirror-store.js.map +1 -1
  21. package/dist/daemon/model-select.js +74 -0
  22. package/dist/daemon/model-select.js.map +1 -0
  23. package/dist/daemon/peers.js +10 -16
  24. package/dist/daemon/peers.js.map +1 -1
  25. package/dist/daemon/spawn-tmux.js +27 -4
  26. package/dist/daemon/spawn-tmux.js.map +1 -1
  27. package/dist/daemon/task-gate.js +97 -0
  28. package/dist/daemon/task-gate.js.map +1 -0
  29. package/dist/daemon/task-registry.js +132 -0
  30. package/dist/daemon/task-registry.js.map +1 -0
  31. package/dist/daemon/tasks.js +126 -54
  32. package/dist/daemon/tasks.js.map +1 -1
  33. package/dist/daemon/wizard-memory.js +57 -0
  34. package/dist/daemon/wizard-memory.js.map +1 -0
  35. package/dist/daemon/wizard.js +84 -36
  36. package/dist/daemon/wizard.js.map +1 -1
  37. package/dist/mcp/server.js +138 -76
  38. package/dist/mcp/server.js.map +1 -1
  39. package/dist/shared/chat-http.js +193 -96
  40. package/dist/shared/chat-http.js.map +1 -1
  41. package/dist/shared/chat-render.js +31 -24
  42. package/dist/shared/chat-render.js.map +1 -1
  43. package/dist/shared/chat-view.js +14 -2
  44. package/dist/shared/chat-view.js.map +1 -1
  45. package/dist/shared/config.js +23 -4
  46. package/dist/shared/config.js.map +1 -1
  47. package/dist/shared/detail-render.js +14 -7
  48. package/dist/shared/detail-render.js.map +1 -1
  49. package/dist/shared/detail-store.js +12 -5
  50. package/dist/shared/detail-store.js.map +1 -1
  51. package/dist/shared/role-render.js +52 -0
  52. package/dist/shared/role-render.js.map +1 -0
  53. package/dist/shared/role-view.js +210 -0
  54. package/dist/shared/role-view.js.map +1 -0
  55. package/dist/shared/schedule-spec.js +92 -116
  56. package/dist/shared/schedule-spec.js.map +1 -1
  57. package/dist/shared/session-label.js +103 -72
  58. package/dist/shared/session-label.js.map +1 -1
  59. package/dist/shared/std.js +20 -0
  60. package/dist/shared/std.js.map +1 -1
  61. package/dist/shared/task-file.js +120 -0
  62. package/dist/shared/task-file.js.map +1 -0
  63. package/dist/shared/trigger.js +232 -0
  64. package/dist/shared/trigger.js.map +1 -0
  65. package/dist/shared/world.js +193 -0
  66. package/dist/shared/world.js.map +1 -0
  67. package/dist/svr/index.js +68 -3
  68. package/dist/svr/index.js.map +1 -1
  69. package/package.json +1 -1
  70. package/web/chat.css +609 -147
  71. package/web/chat.js +1274 -215
@@ -13,20 +13,24 @@ import { makeBridge } from "./cc-bridge.js";
13
13
  import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
14
14
  import { setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
15
15
  import { installApprovalEventListener, makeApproveHandler } from "./approval.js";
16
- import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, buildChatUrl, latestTurnIdFor } from "./detail.js";
16
+ import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider } from "./detail.js";
17
+ import { EMPTY_FACTS } from "../shared/world.js";
17
18
  import { initAutoWindowPersistence } from "./session-cache.js";
18
19
  import { makeMessageHandler } from "./outbound.js";
19
20
  import { makeCardHandler, makeAskHandler, installAskEventListener } from "./ask.js";
20
- import { drainForReload } from "./pending.js";
21
+ import { drainForReload, listPending } from "./pending.js";
21
22
  import { makeClaimStartHandler, makeClaimStatusHandler, makeClaimResetHandler, } from "./claim.js";
22
23
  import { makeWedocBridge } from "./wedoc.js";
23
24
  import { installResponseTracker } from "./last-response.js";
24
25
  import { scanClaudeSessions } from "./session-scan.js";
25
- import { startScheduler, listSchedules, addSchedule, removeScheduleById, renderSchedule, } from "./tasks.js";
26
- import { parseWhen } from "../shared/schedule-spec.js";
27
- import { baseOfKey, keyOf, labelFor, normalizeTag, tagFromCwd, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
26
+ import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, } from "./tasks.js";
27
+ import { openTaskRegistry } from "./task-registry.js";
28
+ import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
29
+ import { slugify, uniqueId } from "../shared/task-file.js";
30
+ import { baseOfKey, bindTagLinker, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
31
+ import { appendMemory, forgetMemory, memoryPath, readMemory } from "./wizard-memory.js";
28
32
  import { applyChatNames, chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
29
- import { bindWizardStore, loadWizardStore, wizardName, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
33
+ import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
30
34
  import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.js";
31
35
  import { loadJobStore, renderJobOpen, renderJobClose, JOB_MEMBER_MAX } from "./jobs.js";
32
36
  import { extractResult } from "./peers.js";
@@ -46,6 +50,9 @@ const main = async () => {
46
50
  name: "wezard-daemon",
47
51
  });
48
52
  log.info({ sourcePath, pid: process.pid }, "daemon start");
53
+ // 定时调度器的 `inject` 真正实现, 赋值在 mirror-mode 的大块里 (要用到那里面的
54
+ // wizards / m / notifyChat), 但 startScheduler 在那块外面接线 —— 见文件尾。
55
+ let scheduledTaskInject;
49
56
  // Bind the CLI backend registry. `primary` (= defaultCli) drives new-session
50
57
  // spawns; `backends` is every installed CLI whose transcript root exists, so
51
58
  // sessions from all of them can be mirrored concurrently — each attachment
@@ -75,6 +82,9 @@ const main = async () => {
75
82
  installResponseTracker(ws.client, log.child({ mod: "chat-gate" }));
76
83
  const sessions = loadSessionStore(cfg.wrc.sessionMapFile);
77
84
  const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
85
+ // 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
86
+ // (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
87
+ const tasks = await openTaskRegistry(log.child({ mod: "tasks" }), (added, removed) => log.info({ added, removed }, "task files changed"));
78
88
  const bridge = cfg.wrc.mode === "mirror"
79
89
  ? startMirror({ cfg, log: log.child({ mod: "mirror" }), client: ws.client, store: mirrorStore })
80
90
  : makeBridge({ cfg, log: log.child({ mod: "bridge" }), client: ws.client, sessions });
@@ -82,6 +92,19 @@ const main = async () => {
82
92
  log.fatal("bridge failed to start");
83
93
  fatalExit("bridge failed to start");
84
94
  }
95
+ // 群里每个 `.name` 都挂它的 rolepage (见 session-label 的 TagLinker)。正文提及
96
+ // 只认真有其人的 —— 随手写的 `.gitignore` 不该变成一个开在空栏上的链接; headless
97
+ // 没有 wizard 名册, 只挂头。
98
+ const known = cfg.wrc.mode === "mirror" ? (t) => bridge.chatTargets(t).includes(t) : () => false;
99
+ // 名册在 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
100
+ bindTagLinker({
101
+ urlOf: (t) => chatUrlFor(cfg.daemon, t, baseOfKey(t).replace(/^(user|chat|group):/, "")),
102
+ resolve: (name) => {
103
+ const t = wizardStore()?.byName(name)?.target;
104
+ return t && known(t) ? t : undefined;
105
+ },
106
+ nameOf: (t) => settleName(wizardStore(), chatNameOf(cfg, t), t),
107
+ });
85
108
  installInboundRouter(ws.client, cfg, log, bridge, sourcePath);
86
109
  // approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
87
110
  // 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
@@ -352,21 +375,28 @@ const main = async () => {
352
375
  return;
353
376
  }
354
377
  const foreign = base !== baseOfKey(self);
355
- const taken = new Set(m.chatTargets(base).map(tagOfKey).filter(Boolean));
356
- const asked = normalizeTag(body.tag);
357
- // 复用一个活着的 tag 等于 respawn —— 也就是杀掉那个 wizard。调用方要的是
358
- // 「再来一个」, 不是「把那个重启」; 让它换个名字。
359
- if (asked && taken.has(asked)) {
360
- 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 });
378
+ const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), tagFromCwd(cwd) || "peer");
379
+ if (!slotR.ok) {
380
+ json(res, slotR.status, slotR.body);
361
381
  return;
362
382
  }
363
- const tag = asked || uniqueTag(tagFromCwd(cwd) || "peer", taken);
364
- const target = keyOf(base, tag);
383
+ const { target, slot: tag } = slotR;
384
+ const existed = !!wizards.get(target);
385
+ const name = wizards.rename(target, normalizeTag(String(body.name ?? body.tag ?? "")) || tag);
365
386
  const model = (body.model ?? "").toString().trim();
366
- log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model }, "spawning peer session");
367
- const r = await m.newSession(target, tag, body.cli, { cwd, model });
387
+ // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
388
+ const keepalive = typeof body.keepalive === "boolean" ? body.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
389
+ log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model, keepalive }, "spawning peer session");
390
+ const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive });
391
+ if (!r.ok && !existed)
392
+ wizards.drop(target);
393
+ // r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
394
+ // 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
395
+ const modelNote = r.model
396
+ ? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
397
+ : "";
368
398
  if (r.ok)
369
- postRoster(base, [target, self], `新 wizard **#${tag}** 就位 · 地址 \`${peerAddress(cfg, base, target)}\`${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${model ? ` · 模型 ${model}` : ""} —— 空白起步, 由 ${displayName(self)} 造的`);
399
+ postRoster(base, [target, self], `新 wizard **.${name}** 就位${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— 空白起步, 由 ${displayName(self)} 造的`);
370
400
  json(res, r.ok ? 200 : 500, r.ok
371
401
  ? {
372
402
  ok: true,
@@ -374,10 +404,12 @@ const main = async () => {
374
404
  self,
375
405
  target,
376
406
  base,
377
- tag,
407
+ name,
378
408
  cwd: r.cwd,
379
- ...(model ? { model } : {}),
409
+ ...(r.model ? { model: r.model } : {}),
410
+ ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}),
380
411
  foreign,
412
+ keepalive,
381
413
  // 调用方之后拿这个串 send_peer / peek_peer 驱动它。
382
414
  address: peerAddress(cfg, self, target),
383
415
  }
@@ -431,8 +463,15 @@ const main = async () => {
431
463
  json(res, 400, { ok: false, reason: "target required (or pass sessionId of an attached chat)" });
432
464
  return;
433
465
  }
466
+ // keep = 人回答了「不用换, 就用现在这个」。只落一笔确认, 不碰 pane —— 把
467
+ // 「保持现状」也走成 setPendingCwd + newSession 的话, 代价是白杀一个会话。
468
+ if (body.keep === true) {
469
+ const c = m.confirmCwd(target);
470
+ json(res, c.ok ? 200 : 400, c.ok ? { ok: true, target, cwd: c.cwd, confirmed: true } : { ok: false, reason: c.reason, target });
471
+ return;
472
+ }
434
473
  if (!cwd) {
435
- json(res, 400, { ok: false, reason: "cwd required" });
474
+ json(res, 400, { ok: false, reason: "cwd required (or keep:true to confirm the current one)" });
436
475
  return;
437
476
  }
438
477
  const set = m.setPendingCwd(target, cwd);
@@ -483,7 +522,7 @@ const main = async () => {
483
522
  target: t,
484
523
  tag: tagOfKey(t),
485
524
  address: self ? peerAddress(cfg, self, t) : t,
486
- name: wizardName(wizards.get(t), chatNameOf(cfg, t), t),
525
+ name: settleName(wizards, chatNameOf(cfg, t), t),
487
526
  description: wizards.get(t)?.description ?? "",
488
527
  cwd: m.getCwd(t).runningCwd || m.getCwd(t).defaultCwd,
489
528
  })),
@@ -506,7 +545,15 @@ const main = async () => {
506
545
  }
507
546
  const previous = chatNameOf(cfg, self);
508
547
  const r = setChatName(cfg, sourcePath, self, raw);
509
- json(res, r.ok ? 200 : 409, r.ok ? { ok: true, base: r.base, name: r.name, previous } : { ok: false, reason: r.reason });
548
+ if (!r.ok) {
549
+ json(res, 409, { ok: false, reason: r.reason });
550
+ return;
551
+ }
552
+ // 默认 wizard 的名字取自聊天名; 它还叫旧聊天名 (没被单独改过) 就跟着改。
553
+ const home = baseOfKey(self);
554
+ const cur = wizards.get(home)?.name ?? "";
555
+ const wizardRenamed = previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined;
556
+ json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(wizardRenamed ? { wizardRenamed } : {}) });
510
557
  });
511
558
  const readPeerBody = async (req) => {
512
559
  const body = (await readBody(req));
@@ -524,59 +571,87 @@ const main = async () => {
524
571
  });
525
572
  // Push a plain markdown bubble into a chat. `base` may carry a `#tag` — a
526
573
  // tagged key strips down to the same WeCom chatid as its base.
574
+ // 头不走 withTagHeader 的那些 (relay / notify / 工单) 正文里的 tag 在这里挂链;
575
+ // 已挂过的 linkTags 认得出, 不会再套一层。
527
576
  const chatIdOf = (t) => baseOfKey(t).replace(/^(user|chat|group):/, "");
528
577
  const notifyChat = (base, markdown) => {
529
578
  const chatId = chatIdOf(base);
530
579
  void ws.client
531
- .sendMessage(chatId, { msgtype: "markdown", markdown: { content: markdown } })
580
+ .sendMessage(chatId, { msgtype: "markdown", markdown: { content: linkTags(base, markdown) } })
532
581
  .catch((e) => log.warn({ err: e.message }, "chat notify failed"));
533
582
  };
534
583
  // wizard 在群里的称呼, 只用在**正文**里。气泡的头照旧交给 withTagHeader ——
535
584
  // 那一段是路由信息 (`emoji #tag`), parseTagHeader 靠它反解, 群里引用一条气泡
536
585
  // 就能直接跟那个 wizard 说话; 把它换成名字会把这条通路弄断。
537
586
  const displayName = (t) => {
538
- const tag = tagOfKey(t);
539
- const name = wizardName(wizards.get(t), chatNameOf(cfg, t), t);
540
- return name || (tag ? `#${tag}` : "默认 wizard");
587
+ const name = settleName(wizards, chatNameOf(cfg, t), t);
588
+ return name ? `.${name}` : "默认 wizard";
541
589
  };
542
- // wizard 之间的往返本来是看不见的: 它发生在两个没人盯着的 pane 里。关键的那几
543
- // 次 (派活、结论) 各自成一条气泡, 头写成 `<from> → <to>`, 方向一眼可读。
590
+ // wizard 之间的往返默认是私聊, 不进群。只有发话方判断「该当着人说」的那一次
591
+ // (send_peer public:true) 在公开频道里成一条气泡, 头写成 `.a → .b`, 方向一眼可读。
544
592
  const RELAY_MAX = 1200;
545
- /** 一个 wizard 在 `dest` 群里的称呼。本群: 照旧 `emoji #tag` —— 那一段是路由
546
- * 信息, parseTagHeader 靠它反解, 引用气泡就能直接跟它说话。外群: 换成带聊天
547
- * 名的全称 `emoji chat#tag`, 一个裸 `#tag` 在别人群里既认不出是谁, 又会被当
548
- * 成本群的 tag 误投到一个不存在的会话上。 */
549
- const relayLabel = (t, dest) => {
550
- const name = baseOfKey(t) === dest
551
- ? withTagHeader(t, "").trim()
552
- : `${tagOfKey(t) ? labelFor(tagOfKey(t)) : "🧙"} ${peerAddress(cfg, dest, t)}`;
553
- const url = chatDetailUrl(t);
554
- return url ? `[${name}](${url})` : name;
555
- };
556
- /** 头上的名字挂它自己的 chat 详情页 —— 看见「A → B」的人下一步想问的永远是
557
- * 「A 那边在干嘛」, 链接就省掉他去翻群找 A 气泡这一步。票据取该 wizard 最近
558
- * 那条 turn (mirror 的 linkedTagPrefix 同源); 没跑过一轮就不挂, 留裸名字。 */
559
- const chatDetailUrl = (t) => {
560
- const id = latestTurnIdFor(t);
561
- return id ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, chatIdOf(t)) : undefined;
562
- };
563
- /** 一条 relay 只落在**收信那一方**的群里, 从不两头都发:
564
- * - 派活 → to 的群。同群时那就是双方共处的那个群 (行为照旧); 跨群时源头群
565
- * 不再复述 —— 那句话本来就是它自己说出口的, 贴回自己群里只是噪音, 真正
566
- * 需要看见的是 to 那边的人: 活是谁派来的。
567
- * - 回程结论 (`relayPeer(target, self)`) → 问话人的群。答话方自己的群里,
568
- * 它的回复早就以它自己的气泡出现过了。 */
569
- const relayPeer = (from, to, body) => {
593
+ /** 一个 wizard 在群里的称呼: `emoji .name`, 挂它的 rolepage。名字全局唯一, 不再
594
+ * 因为落在哪个群而换写法 —— 引用这一段就能跟它说话 (parseTagHeader 认得)。 */
595
+ const relayLabel = (t, _dest) => tagLink(t, tagHead(t));
596
+ /** 公开的 wizard 间对话: 落在 `channel` 这个群。 */
597
+ const relayPeer = (from, to, body, channel) => {
570
598
  const text = body.trim();
571
- if (!text)
599
+ if (!text || !channel)
572
600
  return;
573
- const dest = baseOfKey(to);
574
- const head = `${relayLabel(from, dest)} → ${relayLabel(to, dest)}`;
601
+ const head = `${relayLabel(from)} → ${relayLabel(to)}`;
575
602
  const clipped = text.length > RELAY_MAX ? `${text.slice(0, RELAY_MAX)}…` : text;
576
603
  // 头独占一行, 正文自成一个块 —— 只隔一个换行的话, markdown 会把正文首行
577
604
  // 当成头那一段的续行; 表格因此整张塌成一行带竖线的文字 (表格不能打断段落)。
578
- // 方向已经写在头里了, 正文不再加任何引用/缩进标记。
579
- notifyChat(dest, `${head}\n\n${clipped}`);
605
+ notifyChat(channel, `${head}\n\n${clipped}`);
606
+ };
607
+ /** 调用方这一轮所在的公开频道: 人从哪个群叫的它 / 公开 peer 轮的那个群;
608
+ * 私聊轮或无记录 → 它的 home 群。notify 与 public send_peer 默认发到这里。 */
609
+ const channelOf = (self) => m.currentChannel(self) || baseOfKey(self);
610
+ /** 入参里的地址: 新字段 `name`, 老 MCP 进程 (正在跑的 wizard) 仍在传 `tag`。 */
611
+ const addrOf = (b) => String(b.name ?? b.tag ?? "");
612
+ /** 给一个要出生的 wizard 挑槽位 (target key) 并落定名字。名字全局唯一:
613
+ * - 撞上的是一个还绑着会话的 wizard → 409, 附上它的死活, 调用方据此换名或先收掉它;
614
+ * - 撞上的只是目标群里同一个槽位的冷记录 (会话早没了) → 复用这个槽, 即原名重生,
615
+ * 与从前「同一个 tag 重开」一样, 家谱/记忆都还在;
616
+ * - 撞上别的群里的冷记录 → 409: 名字仍被那条记录占着。
617
+ * 槽位 (key 里的 `#k`) 只是内部 id: 默认取名字本身, 被占了就加序号, 与名字无关。 */
618
+ const claimSlot = async (base, want, fallback) => {
619
+ const asked = normalizeTag(want);
620
+ const clash = asked ? wizards.byName(asked) : undefined;
621
+ if (clash) {
622
+ const bound = m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
623
+ const reusable = !bound && baseOfKey(clash.target) === base && !!tagOfKey(clash.target);
624
+ if (reusable)
625
+ return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
626
+ const info = bound ? (await m.peers(clash.target)).find((p) => p.target === clash.target) : undefined;
627
+ const alive = info?.paneAlive ?? false;
628
+ const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
629
+ const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
630
+ const advice = alive
631
+ ? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个名字重新生, 别把不相关的活塞给一个已经有职责的 wizard"
632
+ : bound
633
+ ? `pane 已经不在了 (${idleDesc}), 真要复用这个名字就先 stop_wizard({name:"${clash.name}", mode:"end"}) 收掉, 再用同一个名字重新生 —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`
634
+ : "这个名字被别的群里一个已经不在跑的 wizard 占着 —— 换个名字";
635
+ return {
636
+ ok: false,
637
+ status: 409,
638
+ body: {
639
+ ok: false,
640
+ reason: `名字 '.${clash.name}' 已经属于一个${alive ? "活着的" : "不再活跃的"} wizard (名字全局唯一) —— ${advice}`,
641
+ name: clash.name,
642
+ address: clash.name,
643
+ alive,
644
+ busy: info?.busy ?? false,
645
+ idleForMs,
646
+ },
647
+ };
648
+ }
649
+ const taken = new Set([
650
+ ...m.chatTargets(base).map(tagOfKey),
651
+ ...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
652
+ ].filter(Boolean));
653
+ const slot = uniqueTag(asked || normalizeTag(fallback) || "wizard", taken);
654
+ return { ok: true, target: keyOf(base, slot), slot };
580
655
  };
581
656
  // ── 给人看的消息 ──────────────────────────────────────────────────
582
657
  // send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
@@ -606,9 +681,9 @@ const main = async () => {
606
681
  json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (list_chats 看有哪些; 没起名的聊天寻址不到)` });
607
682
  return;
608
683
  }
609
- const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [baseOfKey(self)])];
684
+ const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
610
685
  for (const dest of dests)
611
- notifyChat(dest, `${relayLabel(self, dest)}\n\n${content}`);
686
+ notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
612
687
  json(res, 200, { ok: true, sent: dests.map((d) => chatNameOf(cfg, d) || d) });
613
688
  });
614
689
  http.register("POST /peers/peek", async (req, res) => {
@@ -617,7 +692,7 @@ const main = async () => {
617
692
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
618
693
  return;
619
694
  }
620
- const r = resolvePeer(self, body.tag ?? "");
695
+ const r = resolvePeer(self, addrOf(body));
621
696
  if (!r.ok) {
622
697
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
623
698
  return;
@@ -658,12 +733,16 @@ const main = async () => {
658
733
  return;
659
734
  }
660
735
  }
661
- const r = resolvePeer(self, body.tag ?? "");
736
+ const r = resolvePeer(self, addrOf(body));
662
737
  if (!r.ok) {
663
738
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
664
739
  return;
665
740
  }
666
741
  const { target, foreign } = r;
742
+ // 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
743
+ // 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
744
+ const isPublic = body.public === true;
745
+ const channel = isPublic ? channelOf(self) : "";
667
746
  // Injecting into your own pane would type into the box you're generating
668
747
  // from — Claude Code queues it and the caller deadlocks waiting for itself.
669
748
  if (target === self) {
@@ -689,17 +768,18 @@ const main = async () => {
689
768
  return;
690
769
  }
691
770
  }
692
- const inj = await m.injectText(target, text);
693
- // 归到工单名下的往返不单独出气泡: 五路 fan-out 的每一次派活都发一条, 群里
694
- // 就只剩交叉的气泡, 读不出结构。它照旧落在对方的 chat 详情页里, 收工那一条
695
- // 会把成员和各自那段活一起列出来。
771
+ const inj = await m.injectText(target, text, undefined, {
772
+ from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}) },
773
+ channel,
774
+ });
775
+ // 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
696
776
  if (inj.ok && jobId)
697
777
  jobs.attach(jobId, { target, task: text, spawned: false });
698
- if (inj.ok && !jobId)
699
- relayPeer(self, target, text);
778
+ if (inj.ok && isPublic)
779
+ relayPeer(self, target, text, channel);
700
780
  // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
701
781
  // 它这一轮后面, 而不是马上被读到。
702
- json(res, inj.ok ? 200 : 502, { ...inj, target, foreign, when, wasBusy, waitedMs, ...(jobId ? { job: jobId } : {}) });
782
+ json(res, inj.ok ? 200 : 502, { ...inj, target, name: peerAddress(cfg, self, target), foreign, public: isPublic, when, wasBusy, waitedMs, ...(jobId ? { job: jobId } : {}) });
703
783
  });
704
784
  // 等一个 wizard, 或者等一**组**。fan-out 之后 join 必须是并行的: 串行地等五个
705
785
  // 分身, 墙钟是五个之和, 而它们本来就在同时干活。`need` 把 all / any / 过半收进
@@ -711,7 +791,8 @@ const main = async () => {
711
791
  return;
712
792
  }
713
793
  const b = body;
714
- const asked = (Array.isArray(b.tags) && b.tags.length > 0 ? b.tags : [b.tag ?? ""]).map((x) => String(x ?? ""));
794
+ const many = Array.isArray(b.names) && b.names.length > 0 ? b.names : b.tags;
795
+ const asked = (Array.isArray(many) && many.length > 0 ? many : [addrOf(b)]).map((x) => String(x ?? ""));
715
796
  if (asked.length > 16) {
716
797
  json(res, 400, { ok: false, reason: "一次最多等 16 个 wizard" });
717
798
  return;
@@ -736,13 +817,7 @@ const main = async () => {
736
817
  const results = uniq.map((h, i) => {
737
818
  const wr = wrs[i];
738
819
  const lastText = m.lastText(h.target);
739
- // 回程只在跨聊天时下发。同一个群里, 对方的回复本来就会以它自己的 `emoji #tag`
740
- // 气泡出现 —— 再 relay 一条 `B → A` 就是同一句话在同一个群里出现两次, 正是
741
- // 「A 告诉 B 之后不必再展示 B 收到了」要消灭的那种重复。跨聊天则相反: A 的群
742
- // 里看不到 B 的任何气泡, 这条 relay 是那边唯一能看见结论的地方 —— 所以它落在
743
- // self (问话人) 的群, 而不是答话方那边。
744
- if (wr.idle && h.foreign)
745
- relayPeer(h.target, self, lastText);
820
+ // 回程不再进群: 私聊的结论由调用方自己收口给人; 公开轮的回复 mirror 早已发进那个群。
746
821
  // `result` 是它自己收口的那一行 (见 peers.extractResult); 捞不到就是 "",
747
822
  // 调用方照旧读 lastText。
748
823
  return { address: h.address, target: h.target, foreign: h.foreign, idle: wr.idle, reason: wr.reason, result: extractResult(lastText), lastText };
@@ -779,7 +854,7 @@ const main = async () => {
779
854
  }
780
855
  }
781
856
  else {
782
- const r = resolvePeer(self, body.tag ?? "");
857
+ const r = resolvePeer(self, addrOf(body));
783
858
  if (!r.ok) {
784
859
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
785
860
  return;
@@ -834,8 +909,8 @@ const main = async () => {
834
909
  : { ok: false, target, brief, reason: `handoff carry inject failed: ${inj2.reason}` });
835
910
  });
836
911
  // ── Wizard: 会话的身份层 ────────────────────────────────────────────
837
- // 一个会话 (`chat:xxx#tag`) 从此是一个 wizard: 名字 (= 聊天名, 带 tag 的分身读作
838
- // `chat#tag`)、一句职责、一份跨会话的记忆、一条家谱。身份不靠"在对话里讲一遍"
912
+ // 一个会话 (`chat:xxx#k`) 从此是一个 wizard: 一个全局唯一的名字 (默认会话出生时
913
+ // 取聊天名, 分身取自己的名字)、一句职责、一份跨会话的记忆、一条家谱。身份不靠"在对话里讲一遍"
839
914
  // 维持 —— spawn 时以 `--append-system-prompt` 压进那个进程, `/clear` 抹不掉、
840
915
  // 上下文窗口也挤不掉。
841
916
  // 这里只做三件事: 读身份、改身份、按身份生/收分身。tmux 一概不碰, 动作全在
@@ -880,10 +955,14 @@ const main = async () => {
880
955
  log.info({ base, name }, "chat: auto-named from workspace");
881
956
  // 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
882
957
  // 得知道自己的地址变了 —— 它的 charter 里写的还是「(未命名)」。
883
- postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— 别的聊天从此能以 \`${name}#tag\` 叫到这里, 你的地址也随之可读了。`);
958
+ postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— notify / new_claude_session 可以用这个名字指到这里。`);
884
959
  }
885
960
  return named;
886
961
  };
962
+ // 名字全局唯一 (见 wizard.ts): 先给没名字的聊天补名, 再按「默认会话先挑」把
963
+ // 每个已知 wizard 的名字落定 —— 老记录 (名字还跟着聊天走的那一代) 在这里一次迁完。
964
+ ensureChatNames(cfg.defaultChat || "");
965
+ settleAll(wizards, [...m.chatRoster("").flatMap((c) => c.targets), ...wizards.all().map((w) => w.target)], (t) => chatNameOf(cfg, t));
887
966
  // 工单账本 (见 jobs.ts)。只在**显式传了 `job`** 时起作用 —— 不用工单的调用方
888
967
  // 行为与从前一模一样。
889
968
  const jobs = loadJobStore(cfg.wrc.mirror.jobsFile);
@@ -898,43 +977,130 @@ const main = async () => {
898
977
  return { ok: false, status: 409, reason: `工单 '${id}' 的成员已经满了 (${JOB_MEMBER_MAX} 个) —— 分身是有成本的, 拆成两个工单, 或者先收工回收掉一批` };
899
978
  return { ok: true };
900
979
  };
980
+ // ── 关系视图的数据面 ──────────────────────────────────────────────
981
+ // `/api/world` (chat 详情页的「关系」「日程」两栏) 要的是注册表侧的事实:
982
+ // 身份、家谱、工单、日程, 外加此刻的忙闲。turn 记录里没有这些 —— 它只知道
983
+ // "某个 target 跑了一轮"。
984
+ //
985
+ // 活体状态要一个 pane 一次 tmux, 而这条路由会被前端定时轮询, 几十个 wizard
986
+ // 就是几十次 shell-out。所以整份快照带一个短 TTL 缓存: 页面上的呼吸灯晚几秒
987
+ // 亮起无所谓, 把 tmux 打爆则会连累所有正在跑的会话。
988
+ const WORLD_TTL_MS = 5_000;
989
+ let worldCache;
990
+ const collectWorldFacts = async () => {
991
+ // self 只影响 PeerInfo.address 的写法, 关系图不用它 —— 传 defaultChat 让
992
+ // peerInfoOf 有个基准即可。
993
+ const anchorSelf = cfg.defaultChat || m.chatRoster("").at(0)?.base || "";
994
+ const peers = await m.worldPeers(anchorSelf);
995
+ const known = wizards.all();
996
+ const byTarget = new Map(known.map((w) => [w.target, w]));
997
+ const seen = new Set(peers.map((p) => p.target));
998
+ const live = peers.map((p) => {
999
+ const w = byTarget.get(p.target);
1000
+ return {
1001
+ target: p.target,
1002
+ name: settleName(wizards, chatNameOf(cfg, p.target), p.target),
1003
+ description: w?.description ?? "",
1004
+ chat: chatNameOf(cfg, p.target) || p.chat,
1005
+ cwd: p.cwd,
1006
+ model: p.model,
1007
+ cli: p.cli,
1008
+ busy: p.busy,
1009
+ alive: p.paneAlive,
1010
+ parent: w?.parent,
1011
+ clonedFrom: w?.clonedFrom,
1012
+ bornAt: w?.bornAt,
1013
+ lastActivity: p.lastActivity,
1014
+ summary: p.summary,
1015
+ };
1016
+ });
1017
+ // 注册表里有、但一个 pane / 绑定都不剩的 —— 仍是家谱上的一环 (它的分身还在
1018
+ // 跑), 漏掉它会让那些分身看着像是凭空长出来的。
1019
+ const cold = known
1020
+ .filter((w) => !seen.has(w.target))
1021
+ .map((w) => ({
1022
+ target: w.target,
1023
+ name: settleName(wizards, chatNameOf(cfg, w.target), w.target),
1024
+ description: w.description,
1025
+ chat: chatNameOf(cfg, w.target),
1026
+ cwd: "", model: "", cli: "",
1027
+ busy: false, alive: false,
1028
+ parent: w.parent,
1029
+ clonedFrom: w.clonedFrom,
1030
+ bornAt: w.bornAt,
1031
+ lastActivity: 0,
1032
+ summary: "(未运行)",
1033
+ }));
1034
+ const nowDate = new Date();
1035
+ return {
1036
+ wizards: [...live, ...cold],
1037
+ jobs: jobs.all().map((j) => ({
1038
+ id: j.id, base: j.base, owner: j.owner, title: j.title, status: j.status,
1039
+ openedAt: j.openedAt, closedAt: j.closedAt, summary: j.summary,
1040
+ members: j.members.map((mm) => ({ target: mm.target, task: mm.task, spawned: mm.spawned })),
1041
+ })),
1042
+ schedules: tasks.list().filter((x) => x.enabled).map((x) => {
1043
+ const st = tasks.stateOf(x.id);
1044
+ return {
1045
+ id: x.id,
1046
+ target: x.target || (cfg.defaultChat ?? ""),
1047
+ when: describeTrigger(x.trigger),
1048
+ nextAt: nextFire(x.trigger, sinceOf(st), nowDate),
1049
+ lastFired: st.lastFired,
1050
+ prompt: x.prompt,
1051
+ note: x.note,
1052
+ createdBy: x.createdBy,
1053
+ owner: x.owner || (cfg.defaultChat ?? ""),
1054
+ };
1055
+ }),
1056
+ chatNames: Object.fromEntries(listChatNames(cfg).map((c) => [c.base, c.name])),
1057
+ };
1058
+ };
1059
+ setWorldFactsProvider(() => {
1060
+ const now = Date.now();
1061
+ if (!worldCache || now - worldCache.at > WORLD_TTL_MS) {
1062
+ worldCache = { at: now, facts: collectWorldFacts().catch(() => EMPTY_FACTS) };
1063
+ }
1064
+ return worldCache.facts;
1065
+ });
901
1066
  /** 上下文超过这个数就该自己交接了。Claude 家族最小的窗口是 200k, 留三成余量
902
1067
  * 给交接那一轮本身 —— 提示而已, 决定权在 wizard 自己。 */
903
1068
  const HANDOFF_HINT_TOKENS = 140_000;
904
1069
  const briefOf = (self, target) => ({
905
- name: wizardName(wizards.get(target), chatNameOf(cfg, target), target),
1070
+ name: settleName(wizards, chatNameOf(cfg, target), target),
906
1071
  address: peerAddress(cfg, self, target),
907
1072
  description: wizards.get(target)?.description ?? "",
908
1073
  cwd: m.getCwd(target).runningCwd,
909
1074
  });
910
- /** 自己的地址要写成**全局**形态 (`聊天名#tag`), 不是同群内部的裸 tag —— 宪章
911
- * 和 whoami 里的这个串会被它原样贴给别的聊天的 wizard, 裸 tag 到了那边只有
912
- * 在全机唯一时才碰巧能解析。聊天没起名时只能退回裸 tag。 */
913
- const selfAddress = (target) => {
914
- const chat = chatNameOf(cfg, target);
915
- const tag = tagOfKey(target);
916
- return chat ? `${chat}#${tag}` : tag;
917
- };
1075
+ /** 自己的地址 = 全局名字, 与住在哪个群无关。 */
1076
+ const selfAddress = (target) => settleName(wizards, chatNameOf(cfg, target), target);
918
1077
  /** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
919
1078
  * 让它一睁眼就知道自己不是一个人在跑。 */
920
1079
  const charterFor = (target, o) => renderCharter({
921
- self: { ...briefOf(target, target), address: selfAddress(target) },
1080
+ // o.cwd = 正在启动的那个 pane 的目录; 没给才退回"现在记着的那个"。
1081
+ self: { ...briefOf(target, target), address: selfAddress(target), ...(o.cwd ? { cwd: o.cwd } : {}) },
922
1082
  chat: chatNameOf(cfg, target),
923
1083
  principal: baseOfKey(target),
924
1084
  parent: o.parent ? briefOf(target, o.parent) : undefined,
925
1085
  inherited: !!o.inherited,
1086
+ cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
926
1087
  siblings: m.chatTargets(baseOfKey(target)).filter((t) => t !== target).map((t) => briefOf(target, t)),
927
1088
  memory: wizards.get(target)?.memory ?? [],
1089
+ chatMemory: readMemory(memoryPath(cfg.daemon.stateDir, "chat", baseOfKey(target))),
1090
+ workspaceMemory: (() => {
1091
+ const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
1092
+ return cwd ? readMemory(memoryPath(cfg.daemon.stateDir, "workspace", cwd)) : "";
1093
+ })(),
928
1094
  });
929
1095
  // 所有 spawn 路径 (群里手打 /new、pane 自愈重生、编排出来的分身) 都从这里
930
1096
  // 取身份, 于是「是谁」不再取决于是哪段代码把它生出来的。
931
- m.setCharterProvider((target) => {
1097
+ m.setCharterProvider((target, ctx) => {
932
1098
  // 这里是最要紧的那道拦截: 宪章把「所在聊天」写进系统提示, 而系统提示是随进程
933
1099
  // 终身的 —— 一个在聊天还没名字时出生的 wizard, 会一辈子以为自己住在一个
934
1100
  // 「(未命名)」的地方。所以补名要发生在渲染之前, 不能等它以后自己去查。
935
1101
  ensureChatNames(target);
936
1102
  const rec = wizards.get(target);
937
- return charterFor(target, { parent: rec?.parent, inherited: !!rec?.clonedFrom });
1103
+ return charterFor(target, { parent: rec?.parent, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
938
1104
  });
939
1105
  // 开机也补一次: IM 侧的 `/peers`、`/help` 直接读名字, 不经过任何 MCP 路由。
940
1106
  // 延后是因为附着表要先从盘上恢复完, 否则这一刻还看不见那些聊天; 补不全也无妨,
@@ -963,7 +1129,7 @@ const main = async () => {
963
1129
  chat: chatNameOf(cfg, target),
964
1130
  principal: baseOfKey(target),
965
1131
  tag: tagOfKey(target),
966
- named: !!(rec?.name ?? "").trim() || !!chatNameOf(cfg, target),
1132
+ named: !!(rec?.name ?? "").trim(),
967
1133
  bornAt: rec?.bornAt,
968
1134
  inheritedFrom: rec?.clonedFrom || "",
969
1135
  memory: rec?.memory ?? [],
@@ -1027,9 +1193,8 @@ const main = async () => {
1027
1193
  ensureChatNames(self);
1028
1194
  json(res, 200, { ok: true, ...identityOf(self, self), peers: (await m.peers(self)).filter((p) => !p.self).length });
1029
1195
  });
1030
- // 起名字 / 写职责。默认会话的名字就是聊天的名字 (需求: wizard 的名字 = chat
1031
- // 的名字), 所以那一路同时写 chats 表 —— 否则别的聊天仍然叫不到它。带 tag 的
1032
- // 分身只写自己的记录, 它的地址本来就是 `chat#tag`。
1196
+ // 改名字 / 写职责。名字全局唯一、与聊天名脱钩 (默认会话只是出生时取了聊天名):
1197
+ // 撞名自动挂 `-N`, 返回里的 `name` 才是落定的那个。
1033
1198
  http.register("POST /wizard/identity", async (req, res) => {
1034
1199
  const { self, body } = await readPeerBody(req);
1035
1200
  if (!self) {
@@ -1037,31 +1202,26 @@ const main = async () => {
1037
1202
  return;
1038
1203
  }
1039
1204
  const b = body;
1040
- const name = (b.name ?? "").toString().trim();
1205
+ const asked = normalizeTag((b.name ?? "").toString());
1041
1206
  const description = (b.description ?? "").toString().trim();
1042
- const wasNamed = !!wizardName(wizards.get(self), chatNameOf(cfg, self), self);
1043
- let chatNamed;
1044
- if (name && !tagOfKey(self)) {
1045
- const r = setChatName(cfg, sourcePath, self, name);
1046
- if (!r.ok) {
1047
- json(res, 409, { ok: false, reason: r.reason });
1048
- return;
1049
- }
1050
- chatNamed = r.name;
1207
+ if ((b.name ?? "").toString().trim() && !asked) {
1208
+ json(res, 400, { ok: false, reason: "名字只能是 1-32 位字母/数字/`_`/`-`" });
1209
+ return;
1051
1210
  }
1052
- wizards.upsert(self, {
1053
- ...(name ? { name: chatNamed ?? name } : {}),
1054
- ...(description ? { description } : {}),
1055
- });
1211
+ const before = settleName(wizards, chatNameOf(cfg, self), self);
1212
+ const name = asked ? wizards.rename(self, asked) : before;
1213
+ if (description)
1214
+ wizards.upsert(self, { description });
1056
1215
  const me = identityOf(self, self);
1057
- // 起名是稀有事件, 值得在群里留一条 —— 人得知道群里这个角色叫什么了。
1058
- if (name && !wasNamed)
1059
- notifyChat(self, withTagHeader(self, `我是 **${me.name}**${description ? ` · ${description}` : ""}`));
1060
- // 职责是别人决定"该不该找你"的依据, 改了就得让同群的人知道 —— 否则他们照着
1216
+ // 职责是别人决定"该不该找你"的依据, 改了就得让同群的知道 —— 否则他们照着
1061
1217
  // 出生快照里那句旧的 (或者空的) 职责派活。
1062
- if (name || description)
1063
- postRoster(baseOfKey(self), [self], `**${me.name || me.address}** 改了身份 · 地址 \`${peerAddress(cfg, baseOfKey(self), self)}\`${description ? ` · 职责: ${description}` : ""}`);
1064
- json(res, 200, { ok: true, ...me, chatNamed });
1218
+ if (asked || description)
1219
+ postRoster(baseOfKey(self), [self], `${before && before !== name ? `**.${before}** 改名为 **.${name}**` : `**.${name}** 改了身份`}${description ? ` · 职责: ${description}` : ""}`);
1220
+ json(res, 200, {
1221
+ ok: true,
1222
+ ...me,
1223
+ ...(asked && name.toLowerCase() !== asked.toLowerCase() ? { renamed: `'.${asked}' 已被占用, 落定为 '.${name}'` } : {}),
1224
+ });
1065
1225
  });
1066
1226
  // 名册是**索引**, 不是转储: 这台机器上现在有 300+ 个会话, 整表吐出来是三万
1067
1227
  // token —— 一个 wizard 每次想找人都付这个价, 等于找不到人。所以服务端过滤
@@ -1101,6 +1261,8 @@ const main = async () => {
1101
1261
  : {}),
1102
1262
  });
1103
1263
  });
1264
+ // 记忆三种作用域: self 跟着自己 (wizards.json); chat / workspace 是共享的 md,
1265
+ // 每个在那个群 / 那个目录出生的 wizard 都会读到 (见 wizard-memory.ts)。
1104
1266
  http.register("POST /wizard/remember", async (req, res) => {
1105
1267
  const { self, body } = await readPeerBody(req);
1106
1268
  if (!self) {
@@ -1108,14 +1270,27 @@ const main = async () => {
1108
1270
  return;
1109
1271
  }
1110
1272
  const b = body;
1111
- const cur = wizards.get(self)?.memory ?? [];
1112
1273
  const note = (b.note ?? "").toString().trim();
1113
1274
  const forget = (b.forget ?? "").toString().trim();
1275
+ const scope = (b.scope ?? "self").toString();
1276
+ if (scope === "chat" || scope === "workspace") {
1277
+ const key = scope === "chat" ? baseOfKey(self) : (m.getCwd(self).runningCwd || m.getCwd(self).defaultCwd);
1278
+ if (!key) {
1279
+ json(res, 400, { ok: false, reason: "这个 wizard 没有工作区, 记不了 workspace 记忆" });
1280
+ return;
1281
+ }
1282
+ const path = memoryPath(cfg.daemon.stateDir, scope, key);
1283
+ const forgotten = forget ? forgetMemory(path, forget) : 0;
1284
+ const added = note ? appendMemory(path, note) : false;
1285
+ json(res, 200, { ok: true, scope, file: path, memory: readMemory(path), added, forgotten });
1286
+ return;
1287
+ }
1288
+ const cur = wizards.get(self)?.memory ?? [];
1114
1289
  // 忘记按子串匹配: 模型记不住自己当初一字不差写了什么, 但记得大意。
1115
1290
  const next = forget ? cur.filter((x) => !x.includes(forget)) : cur;
1116
1291
  const memory = note ? [...next.filter((x) => x !== note), note] : next;
1117
1292
  wizards.upsert(self, { memory });
1118
- json(res, 200, { ok: true, memory, added: !!note, forgotten: cur.length - next.length });
1293
+ json(res, 200, { ok: true, scope: "self", memory, added: !!note, forgotten: cur.length - next.length });
1119
1294
  });
1120
1295
  // 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
1121
1296
  // 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
@@ -1166,14 +1341,13 @@ const main = async () => {
1166
1341
  });
1167
1342
  return;
1168
1343
  }
1169
- const taken = new Set(m.chatTargets(base).map(tagOfKey).filter(Boolean));
1170
- const asked = normalizeTag(b.tag);
1171
- if (asked && taken.has(asked)) {
1172
- json(res, 409, { ok: false, reason: `'#${asked}' 已经是一个活着的 wizard —— 换个名字, 或者直接 send_peer 找它`, tag: asked });
1344
+ const askedName = String(b.name ?? b.tag ?? "");
1345
+ const slotR = await claimSlot(base, askedName, normalizeTag(b.description?.split(/\s+/)[0]) || "clone");
1346
+ if (!slotR.ok) {
1347
+ json(res, slotR.status, slotR.body);
1173
1348
  return;
1174
1349
  }
1175
- const tag = asked || uniqueTag(normalizeTag(b.description?.split(/\s+/)[0]) || "clone", taken);
1176
- const target = keyOf(base, tag);
1350
+ const { target, slot: tag } = slotR;
1177
1351
  const parentInfo = m.sessionInfo(self);
1178
1352
  // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
1179
1353
  wizards.upsert(target, {
@@ -1182,12 +1356,15 @@ const main = async () => {
1182
1356
  bornAt: Date.now(),
1183
1357
  clonedFrom: inherit ? parentInfo?.sessionId ?? "" : "",
1184
1358
  });
1359
+ const name = wizards.rename(target, normalizeTag(askedName) || tag);
1185
1360
  const charter = charterFor(target, { parent: self, inherited: inherit });
1186
1361
  const task = (b.task ?? "").toString().trim();
1362
+ // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
1363
+ const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
1187
1364
  const r = await m.cloneSession({
1188
1365
  parent: self,
1189
1366
  target,
1190
- windowName: tag,
1367
+ windowName: name,
1191
1368
  cli: b.cli,
1192
1369
  model: b.model,
1193
1370
  cwd: b.cwd,
@@ -1196,6 +1373,7 @@ const main = async () => {
1196
1373
  // 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
1197
1374
  // 也少一次"就位了但没事干"的空转。
1198
1375
  bootstrap: task || undefined,
1376
+ keepalive,
1199
1377
  });
1200
1378
  if (!r.ok) {
1201
1379
  wizards.drop(target);
@@ -1204,26 +1382,25 @@ const main = async () => {
1204
1382
  }
1205
1383
  wizards.upsert(target, { clonedFrom: r.inherited ? parentInfo?.sessionId ?? "" : "" });
1206
1384
  const kid = briefOf(self, target);
1207
- // 分身出生要在群里留一条 —— 群里多了一个成员, 人有权当场知道。
1208
- // 工单里的分身是临时工: 出生、派活各发一条气泡, 五路 fan-out 就是十条交叉的
1209
- // 气泡, 人从里面读不出结构。它们攒到收工那一条里一起交代 (成员 + 各自那段活),
1210
- // 中间过程照旧在各自的 chat 详情页。同理不惊动同群的其他 wizard。
1385
+ // r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
1386
+ // 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
1387
+ const modelNote = r.model
1388
+ ? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
1389
+ : "";
1390
+ // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
1391
+ // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
1211
1392
  if (!jobId) {
1212
- notifyChat(base, withTagHeader(target, `已就位 · ${r.inherited ? `${displayName(self)} 的分身 (继承了它的上下文)` : "全新 wizard (空白上下文)"}${kid.description ? ` · ${kid.description}` : ""}`));
1213
- 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 ? " (继承了它的上下文)" : ""}`);
1393
+ postRoster(base, [target, self], `新 wizard **.${name}** 就位${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— ${displayName(self)} 的分身${r.inherited ? " (继承了它的上下文)" : ""}`);
1214
1394
  }
1215
- // 派活在群里留一条 (它是关键节点)。继承路径上活已经随开场白进去了, 空白分身
1216
- // 才需要在这里补一次注入。
1395
+ // 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
1217
1396
  let dispatched = r.inherited && !!task;
1218
1397
  if (task && !r.inherited) {
1219
- const inj = await m.injectText(target, task);
1398
+ const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) }, channel: "" });
1220
1399
  dispatched = inj.ok;
1221
1400
  }
1222
1401
  if (jobId)
1223
1402
  jobs.attach(jobId, { target, task, spawned: true });
1224
- else if (dispatched)
1225
- relayPeer(self, target, task);
1226
- 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 } : {}) });
1403
+ json(res, 200, { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(jobId ? { job: jobId } : {}) });
1227
1404
  });
1228
1405
  // 收掉一个 wizard。interrupt = 打断它这一轮 (Esc); end = 结束它并回收 pane。
1229
1406
  // 自己终结自己是合法的 (分身干完活自我了结), 代价是这次工具调用不会返回 ——
@@ -1235,7 +1412,7 @@ const main = async () => {
1235
1412
  return;
1236
1413
  }
1237
1414
  const b = body;
1238
- const r = resolvePeer(self, b.tag ?? "");
1415
+ const r = resolvePeer(self, addrOf(b));
1239
1416
  if (!r.ok) {
1240
1417
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
1241
1418
  return;
@@ -1283,6 +1460,9 @@ const main = async () => {
1283
1460
  const idle = await waitForIdle(self, m.isBusy, 10 * 60_000, () => false);
1284
1461
  if (!idle.idle) {
1285
1462
  lg.warn({ reason: idle.reason }, "self-handoff: 目标一直忙, 放弃");
1463
+ // 之前这里只写日志: 调用方早就拿到 `scheduled:true` 挂了, 交接静悄悄
1464
+ // 放弃, 群里没有任何气泡告诉人。补一条 notify, 对齐它成功时的那条。
1465
+ notifyChat(self, withTagHeader(self, `上下文交接放弃: 一直没能闲下来 (${idle.reason}), 上下文和之前一样没动, 有空再试一次`));
1286
1466
  return;
1287
1467
  }
1288
1468
  const clr = await m.injectText(self, "/clear");
@@ -1371,6 +1551,8 @@ const main = async () => {
1371
1551
  // ── 定时任务 ────────────────────────────────────────────────────
1372
1552
  // 到点把一句 prompt 注入一个 wizard 会话 —— 和人在群里对它说话走的是同一条路
1373
1553
  // (injectText), 所以 pane 死了会被拉起来, 输出照常落进群和详情页。
1554
+ /** 日程归谁: 排班那个 wizard (createdBy); 老文件没写就归执行它的那个。 */
1555
+ const ownerOf = (t) => t.owner || (cfg.defaultChat ?? "");
1374
1556
  http.register("POST /tasks/schedule", async (req, res) => {
1375
1557
  const { self, body } = await readPeerBody(req);
1376
1558
  if (!self) {
@@ -1384,15 +1566,15 @@ const main = async () => {
1384
1566
  json(res, 400, { ok: false, reason: "prompt required" });
1385
1567
  return;
1386
1568
  }
1387
- const when = parseWhen(whenText, new Date());
1388
- if (!when) {
1389
- json(res, 400, { ok: false, reason: `无法理解「${whenText}」。能认的说法: 每天/每个工作日/每周三 + 时刻 (晚上9:30 / 21:30 / 九点半), 每隔 N 分钟|小时, N 分钟后, 明早 9 点。` });
1569
+ const trigger = parseTrigger(whenText, new Date());
1570
+ if (!trigger) {
1571
+ json(res, 400, { ok: false, reason: WHEN_HELP(whenText) });
1390
1572
  return;
1391
1573
  }
1392
1574
  // tag 省略 = 排给调用者自己。这是最常见的用法: wizard 给自己定一个夜里跑的活。
1393
1575
  // 注入自身在**当下**是死锁 (往正在生成的输入框里打字), 但定时是未来的事,
1394
1576
  // 那时这一轮早已收工, 所以这里不套 /peers/send 的自我保护。
1395
- const tag = (body.tag ?? "").toString().trim();
1577
+ const tag = addrOf(body).trim();
1396
1578
  let target = self;
1397
1579
  if (tag) {
1398
1580
  const r = resolvePeer(self, tag);
@@ -1402,8 +1584,26 @@ const main = async () => {
1402
1584
  }
1403
1585
  target = r.target;
1404
1586
  }
1405
- const rec = addSchedule(cfg, sourcePath, { when, target, prompt, createdBy: self, note: (b.note ?? "").toString() });
1406
- json(res, 200, { ok: true, ...renderSchedule(rec), address: peerAddress(cfg, self, target) });
1587
+ // 到点是新建还是续用, 在**排班时**就定死。两条都指向新建:
1588
+ // · 没点名 wizard —— 定时的 prompt 本就要求零上下文自洽, 没理由挑一个会话挤进去;
1589
+ // · prompt 自己就在说「建两个 wizard 去干 xxx」—— 这时 tag 只是「在谁的聊天/
1590
+ // 目录下办」, 不是「在它的上下文里续」。
1591
+ // `fresh` 显式传入压过这两条推断 (要强行在 tag 那一轮里续就传 false)。
1592
+ const fresh = typeof b.fresh === "boolean" ? b.fresh : !tag || promptWantsFreshWizard(prompt);
1593
+ const note = (b.note ?? "").toString();
1594
+ const id = uniqueId(slugify((b.id ?? "").toString() || note || prompt.slice(0, 24), "task"), tasks.takenIds());
1595
+ const created = tasks.create({ id, trigger, prompt, target, fresh, note, createdBy: self });
1596
+ if ("error" in created) {
1597
+ json(res, 400, { ok: false, reason: created.error });
1598
+ return;
1599
+ }
1600
+ tasks.patchState(id, { createdAt: Date.now() });
1601
+ json(res, 200, {
1602
+ ok: true,
1603
+ ...renderTask(created, tasks.stateOf(id)),
1604
+ address: peerAddress(cfg, self, target),
1605
+ owner: peerAddress(cfg, self, self),
1606
+ });
1407
1607
  });
1408
1608
  http.register("POST /tasks/list", async (req, res) => {
1409
1609
  const { self, body } = await readPeerBody(req);
@@ -1413,9 +1613,20 @@ const main = async () => {
1413
1613
  }
1414
1614
  const mineOnly = body.mine === true;
1415
1615
  const now = new Date();
1416
- const tasks = listSchedules(cfg, mineOnly ? self : undefined)
1417
- .map((x) => ({ ...renderSchedule(x, now), address: peerAddress(cfg, self, x.target) }));
1418
- json(res, 200, { ok: true, self, tasks });
1616
+ const errs = tasks.errors();
1617
+ const rows = tasks.list()
1618
+ .filter((t) => !mineOnly || ownerOf(t) === self)
1619
+ .map((t) => ({
1620
+ ...renderTask(t, tasks.stateOf(t.id), now, errs[t.id]),
1621
+ address: peerAddress(cfg, self, t.target || (cfg.defaultChat ?? "")),
1622
+ owner: peerAddress(cfg, self, ownerOf(t)),
1623
+ }));
1624
+ // 加载失败的那些没有记录可回显, 但必须出现在列表里 —— 否则 wizard 改错了
1625
+ // 一个字, 那条定时就像凭空消失了。
1626
+ const broken = Object.entries(errs)
1627
+ .filter(([id]) => !tasks.get(id))
1628
+ .map(([id, loadError]) => ({ id, file: `${tasks.dir}/${id}.task.mjs`, loadError }));
1629
+ json(res, 200, { ok: true, self, dir: tasks.dir, tasks: [...rows, ...broken] });
1419
1630
  });
1420
1631
  http.register("POST /tasks/cancel", async (req, res) => {
1421
1632
  const { self, body } = await readPeerBody(req);
@@ -1428,12 +1639,13 @@ const main = async () => {
1428
1639
  json(res, 400, { ok: false, reason: "id required (list_tasks 里那个 id)" });
1429
1640
  return;
1430
1641
  }
1431
- const gone = removeScheduleById(cfg, sourcePath, id);
1642
+ const state = tasks.stateOf(id);
1643
+ const gone = tasks.remove(id);
1432
1644
  if (!gone) {
1433
1645
  json(res, 404, { ok: false, reason: `no schedule with id ${id}` });
1434
1646
  return;
1435
1647
  }
1436
- json(res, 200, { ok: true, removed: renderSchedule(gone) });
1648
+ json(res, 200, { ok: true, removed: renderTask(gone, state) });
1437
1649
  });
1438
1650
  // POST /config/set — modify daemon config from MCP
1439
1651
  http.register("POST /config/set", async (req, res) => {
@@ -1536,22 +1748,106 @@ const main = async () => {
1536
1748
  }
1537
1749
  json(res, 200, { ok: true, target, ...m.getCwd(target) });
1538
1750
  });
1751
+ // 定时任务的执行体。schedule_task 的 prompt 设计上本就要求"零上下文也能执行"
1752
+ // (到点时目标可能早已 /clear 过, 它只看得见这一句) —— 既然如此, 默认就不在任何
1753
+ // 已有会话里跑: 硬塞进去会把两件不相关的事挤进同一个 transcript, 目标正忙时
1754
+ // 还要连触发时间点一起被它那一轮拖走。所以到点起一个白板 wizard 单独执行,
1755
+ // 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
1756
+ // 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
1757
+ scheduledTaskInject = async (target, text, { taskId, fresh }) => {
1758
+ const busy = fresh ? false : await m.isBusy(target);
1759
+ if (!fresh && !busy)
1760
+ return m.injectText(target, text, undefined, { fromChat: true, from: { kind: "task", taskId } });
1761
+ // 执行体挂在日程的主人名下: 名字 `<主人>-task-xxxx`, parent = 主人 —— 它的
1762
+ // rolepage 里看得见这一枪是谁的日程放的。落在执行目标的群/目录里。
1763
+ const base = baseOfKey(target);
1764
+ const rec = tasks.get(taskId);
1765
+ const owner = rec ? ownerOf(rec) : target;
1766
+ const ownerName = settleName(wizards, chatNameOf(cfg, owner), owner) || "task";
1767
+ const slotR = await claimSlot(base, "", `${ownerName}-task-${taskId.slice(0, 4)}`);
1768
+ if (!slotR.ok)
1769
+ return { ok: false, reason: "起白板 wizard 失败: 挑不出槽位" };
1770
+ const runner = slotR.target;
1771
+ const info = m.sessionInfo(target);
1772
+ const why = busy ? ` (起因: ${displayName(target)} 当时正忙)` : "";
1773
+ wizards.upsert(runner, {
1774
+ description: `定时任务 ${taskId} 的一次性执行体${why}`,
1775
+ parent: owner,
1776
+ bornAt: Date.now(),
1777
+ });
1778
+ const runnerName = wizards.rename(runner, `${ownerName}-task-${taskId.slice(0, 4)}`);
1779
+ const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true });
1780
+ if (!spawned.ok) {
1781
+ wizards.drop(runner);
1782
+ return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
1783
+ }
1784
+ notifyChat(base, withTagHeader(target, busy
1785
+ ? `⏰ 定时任务到点时正忙, 已起白板 wizard .${runnerName} 单独执行, 完成后自动收掉`
1786
+ : `⏰ 定时任务已起白板 wizard .${runnerName} 执行, 完成后自动收掉`));
1787
+ const inj = await m.injectText(runner, text, undefined, { fromChat: true, from: { kind: "task", taskId } });
1788
+ if (!inj.ok) {
1789
+ wizards.drop(runner);
1790
+ return inj;
1791
+ }
1792
+ // 没有人会对这个一次性分身喊 stop_wizard, 只能自己等它闲下来再收。30min
1793
+ // 内没闲下来就放弃自动回收 (它大概率还在干一个长活), 留给人手动处理 ——
1794
+ // 比杀掉一个还在跑的 pane 安全。
1795
+ void (async () => {
1796
+ const idle = await waitForIdle(runner, m.isBusy, 30 * 60_000, () => false);
1797
+ if (!idle.idle) {
1798
+ log.warn({ runner, reason: idle.reason }, "task runner: 30min 未闲下来, 放弃自动回收");
1799
+ return;
1800
+ }
1801
+ await m.killPane(runner);
1802
+ wizards.drop(runner);
1803
+ })();
1804
+ return inj;
1805
+ };
1539
1806
  }
1540
- // 定时调度器 — 每 20s 检查 cfg.schedules, 到点把 prompt 注入目标 wizard。
1807
+ // 定时调度器 — 每 20s 检查任务表, 到点把 prompt 注入目标 wizard。
1541
1808
  // inject 只在 mirror 模式给得出 (headless 模式没有常驻会话可注入)。
1809
+ migrateLegacySchedules(cfg, sourcePath, tasks, log.child({ mod: "tasks" }));
1542
1810
  const scheduler = startScheduler({
1543
1811
  client: ws.client,
1544
- cfg,
1545
- sourcePath,
1812
+ registry: tasks,
1546
1813
  log: log.child({ mod: "tasks" }),
1547
- inject: cfg.wrc.mode === "mirror"
1548
- ? (target, text) => bridge.injectText(target, text, undefined, { fromChat: true })
1814
+ inject: cfg.wrc.mode === "mirror" ? scheduledTaskInject : undefined,
1815
+ // gate 里的 `sh` 默认就在目标 wizard 此刻的工作区跑 —— 任务文件里不必写死绝对路径。
1816
+ cwdOf: cfg.wrc.mode === "mirror"
1817
+ ? (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; }
1549
1818
  : undefined,
1819
+ fallbackTarget: () => (cfg.defaultChat ?? "").trim(),
1550
1820
  });
1821
+ // 闲置 pane 收割机 — 默认 48h 静默才动 (wrc.mirror.idleReapHours, 0=关)。
1822
+ // 只收 pane 不收绑定: 下一条消息 `--resume` 复活。豁免: 名下有定时任务的
1823
+ // wizard (调度本身会唤醒/需要它), 挂着审批长轮询的会话 (hook 还停在等点击),
1824
+ // 忙着的 / 人正盯着的 (bridge 内部判)。keepalive 会话靠 ping 刷新 transcript,
1825
+ // 天然到不了阈值。
1826
+ let reapTimer;
1827
+ if (cfg.wrc.mode === "mirror" && cfg.wrc.mirror.idleReapHours > 0) {
1828
+ const ttlMs = cfg.wrc.mirror.idleReapHours * 3.6e6;
1829
+ const sweep = async () => {
1830
+ const taskOwners = new Set(tasks.list()
1831
+ .filter((t) => t.enabled)
1832
+ .map((t) => wizardStore()?.byName(t.owner)?.target)
1833
+ .filter((t) => !!t));
1834
+ const pendingSids = new Set(listPending().map((p) => p.meta.sessionId).filter((s) => !!s));
1835
+ const r = await bridge.reapIdle(ttlMs, (target, sid) => taskOwners.has(target) ? "owns scheduled task"
1836
+ : pendingSids.has(sid) ? "pending approval"
1837
+ : undefined);
1838
+ if (r.reaped.length)
1839
+ log.info({ reaped: r.reaped.length, targets: r.reaped }, "idle reap sweep");
1840
+ };
1841
+ reapTimer = setInterval(() => void sweep().catch((e) => log.warn({ err: e.message }, "idle reap sweep failed")), 30 * 60_000);
1842
+ reapTimer.unref();
1843
+ }
1551
1844
  const shutdown = async (signal) => {
1552
1845
  log.info({ signal }, "shutdown signal");
1553
1846
  scheduler.stop();
1847
+ tasks.stop();
1554
1848
  netWatch.stop();
1849
+ if (reapTimer)
1850
+ clearInterval(reapTimer);
1555
1851
  // 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。
1556
1852
  log.info(drainForReload(), "pending drained for reload");
1557
1853
  // Hard-exit watchdog: http.close() blocks until every in-flight connection