wezard 1.4.2 → 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 (69) hide show
  1. package/CLAUDE.md +12 -7
  2. package/README.md +121 -67
  3. package/dist/daemon/approval.js +9 -3
  4. package/dist/daemon/approval.js.map +1 -1
  5. package/dist/daemon/cc-bridge.js.map +1 -1
  6. package/dist/daemon/chat-name.js +8 -18
  7. package/dist/daemon/chat-name.js.map +1 -1
  8. package/dist/daemon/detail.js +53 -12
  9. package/dist/daemon/detail.js.map +1 -1
  10. package/dist/daemon/graph.js +5 -7
  11. package/dist/daemon/graph.js.map +1 -1
  12. package/dist/daemon/inbound.js +135 -158
  13. package/dist/daemon/inbound.js.map +1 -1
  14. package/dist/daemon/index.js +381 -192
  15. package/dist/daemon/index.js.map +1 -1
  16. package/dist/daemon/jobs.js +2 -2
  17. package/dist/daemon/mirror-bridge.js +221 -134
  18. package/dist/daemon/mirror-bridge.js.map +1 -1
  19. package/dist/daemon/mirror-store.js.map +1 -1
  20. package/dist/daemon/model-select.js +74 -0
  21. package/dist/daemon/model-select.js.map +1 -0
  22. package/dist/daemon/peers.js +10 -16
  23. package/dist/daemon/peers.js.map +1 -1
  24. package/dist/daemon/spawn-tmux.js +27 -4
  25. package/dist/daemon/spawn-tmux.js.map +1 -1
  26. package/dist/daemon/task-gate.js +97 -0
  27. package/dist/daemon/task-gate.js.map +1 -0
  28. package/dist/daemon/task-registry.js +132 -0
  29. package/dist/daemon/task-registry.js.map +1 -0
  30. package/dist/daemon/tasks.js +126 -54
  31. package/dist/daemon/tasks.js.map +1 -1
  32. package/dist/daemon/wizard-memory.js +57 -0
  33. package/dist/daemon/wizard-memory.js.map +1 -0
  34. package/dist/daemon/wizard.js +84 -38
  35. package/dist/daemon/wizard.js.map +1 -1
  36. package/dist/mcp/server.js +138 -76
  37. package/dist/mcp/server.js.map +1 -1
  38. package/dist/shared/chat-http.js +185 -108
  39. package/dist/shared/chat-http.js.map +1 -1
  40. package/dist/shared/chat-render.js +26 -34
  41. package/dist/shared/chat-render.js.map +1 -1
  42. package/dist/shared/chat-view.js +14 -2
  43. package/dist/shared/chat-view.js.map +1 -1
  44. package/dist/shared/config.js +23 -4
  45. package/dist/shared/config.js.map +1 -1
  46. package/dist/shared/detail-render.js +12 -6
  47. package/dist/shared/detail-render.js.map +1 -1
  48. package/dist/shared/detail-store.js.map +1 -1
  49. package/dist/shared/role-render.js +52 -0
  50. package/dist/shared/role-render.js.map +1 -0
  51. package/dist/shared/role-view.js +210 -0
  52. package/dist/shared/role-view.js.map +1 -0
  53. package/dist/shared/schedule-spec.js +92 -116
  54. package/dist/shared/schedule-spec.js.map +1 -1
  55. package/dist/shared/session-label.js +103 -72
  56. package/dist/shared/session-label.js.map +1 -1
  57. package/dist/shared/std.js +3 -0
  58. package/dist/shared/std.js.map +1 -1
  59. package/dist/shared/task-file.js +120 -0
  60. package/dist/shared/task-file.js.map +1 -0
  61. package/dist/shared/trigger.js +232 -0
  62. package/dist/shared/trigger.js.map +1 -0
  63. package/dist/shared/world.js +7 -5
  64. package/dist/shared/world.js.map +1 -1
  65. package/dist/svr/index.js +67 -2
  66. package/dist/svr/index.js.map +1 -1
  67. package/package.json +1 -1
  68. package/web/chat.css +407 -212
  69. package/web/chat.js +932 -321
@@ -13,21 +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, chatTicketFor, setWorldFactsProvider } from "./detail.js";
16
+ import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider } from "./detail.js";
17
17
  import { EMPTY_FACTS } from "../shared/world.js";
18
18
  import { initAutoWindowPersistence } from "./session-cache.js";
19
19
  import { makeMessageHandler } from "./outbound.js";
20
20
  import { makeCardHandler, makeAskHandler, installAskEventListener } from "./ask.js";
21
- import { drainForReload } from "./pending.js";
21
+ import { drainForReload, listPending } from "./pending.js";
22
22
  import { makeClaimStartHandler, makeClaimStatusHandler, makeClaimResetHandler, } from "./claim.js";
23
23
  import { makeWedocBridge } from "./wedoc.js";
24
24
  import { installResponseTracker } from "./last-response.js";
25
25
  import { scanClaudeSessions } from "./session-scan.js";
26
- import { startScheduler, listSchedules, addSchedule, removeScheduleById, renderSchedule, } from "./tasks.js";
27
- import { describeWhen, nextFire, parseWhen } from "../shared/schedule-spec.js";
28
- 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";
29
32
  import { applyChatNames, chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
30
- import { bindWizardStore, loadWizardStore, wizardName, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
33
+ import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
31
34
  import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.js";
32
35
  import { loadJobStore, renderJobOpen, renderJobClose, JOB_MEMBER_MAX } from "./jobs.js";
33
36
  import { extractResult } from "./peers.js";
@@ -47,6 +50,9 @@ const main = async () => {
47
50
  name: "wezard-daemon",
48
51
  });
49
52
  log.info({ sourcePath, pid: process.pid }, "daemon start");
53
+ // 定时调度器的 `inject` 真正实现, 赋值在 mirror-mode 的大块里 (要用到那里面的
54
+ // wizards / m / notifyChat), 但 startScheduler 在那块外面接线 —— 见文件尾。
55
+ let scheduledTaskInject;
50
56
  // Bind the CLI backend registry. `primary` (= defaultCli) drives new-session
51
57
  // spawns; `backends` is every installed CLI whose transcript root exists, so
52
58
  // sessions from all of them can be mirrored concurrently — each attachment
@@ -76,6 +82,9 @@ const main = async () => {
76
82
  installResponseTracker(ws.client, log.child({ mod: "chat-gate" }));
77
83
  const sessions = loadSessionStore(cfg.wrc.sessionMapFile);
78
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"));
79
88
  const bridge = cfg.wrc.mode === "mirror"
80
89
  ? startMirror({ cfg, log: log.child({ mod: "mirror" }), client: ws.client, store: mirrorStore })
81
90
  : makeBridge({ cfg, log: log.child({ mod: "bridge" }), client: ws.client, sessions });
@@ -83,6 +92,19 @@ const main = async () => {
83
92
  log.fatal("bridge failed to start");
84
93
  fatalExit("bridge failed to start");
85
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
+ });
86
108
  installInboundRouter(ws.client, cfg, log, bridge, sourcePath);
87
109
  // approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
88
110
  // 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
@@ -353,21 +375,28 @@ const main = async () => {
353
375
  return;
354
376
  }
355
377
  const foreign = base !== baseOfKey(self);
356
- const taken = new Set(m.chatTargets(base).map(tagOfKey).filter(Boolean));
357
- const asked = normalizeTag(body.tag);
358
- // 复用一个活着的 tag 等于 respawn —— 也就是杀掉那个 wizard。调用方要的是
359
- // 「再来一个」, 不是「把那个重启」; 让它换个名字。
360
- if (asked && taken.has(asked)) {
361
- 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);
362
381
  return;
363
382
  }
364
- const tag = asked || uniqueTag(tagFromCwd(cwd) || "peer", taken);
365
- 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);
366
386
  const model = (body.model ?? "").toString().trim();
367
- log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model }, "spawning peer session");
368
- 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
+ : "";
369
398
  if (r.ok)
370
- 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)} 造的`);
371
400
  json(res, r.ok ? 200 : 500, r.ok
372
401
  ? {
373
402
  ok: true,
@@ -375,10 +404,12 @@ const main = async () => {
375
404
  self,
376
405
  target,
377
406
  base,
378
- tag,
407
+ name,
379
408
  cwd: r.cwd,
380
- ...(model ? { model } : {}),
409
+ ...(r.model ? { model: r.model } : {}),
410
+ ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}),
381
411
  foreign,
412
+ keepalive,
382
413
  // 调用方之后拿这个串 send_peer / peek_peer 驱动它。
383
414
  address: peerAddress(cfg, self, target),
384
415
  }
@@ -432,8 +463,15 @@ const main = async () => {
432
463
  json(res, 400, { ok: false, reason: "target required (or pass sessionId of an attached chat)" });
433
464
  return;
434
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
+ }
435
473
  if (!cwd) {
436
- 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)" });
437
475
  return;
438
476
  }
439
477
  const set = m.setPendingCwd(target, cwd);
@@ -484,7 +522,7 @@ const main = async () => {
484
522
  target: t,
485
523
  tag: tagOfKey(t),
486
524
  address: self ? peerAddress(cfg, self, t) : t,
487
- name: wizardName(wizards.get(t), chatNameOf(cfg, t), t),
525
+ name: settleName(wizards, chatNameOf(cfg, t), t),
488
526
  description: wizards.get(t)?.description ?? "",
489
527
  cwd: m.getCwd(t).runningCwd || m.getCwd(t).defaultCwd,
490
528
  })),
@@ -507,7 +545,15 @@ const main = async () => {
507
545
  }
508
546
  const previous = chatNameOf(cfg, self);
509
547
  const r = setChatName(cfg, sourcePath, self, raw);
510
- 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 } : {}) });
511
557
  });
512
558
  const readPeerBody = async (req) => {
513
559
  const body = (await readBody(req));
@@ -525,63 +571,87 @@ const main = async () => {
525
571
  });
526
572
  // Push a plain markdown bubble into a chat. `base` may carry a `#tag` — a
527
573
  // tagged key strips down to the same WeCom chatid as its base.
574
+ // 头不走 withTagHeader 的那些 (relay / notify / 工单) 正文里的 tag 在这里挂链;
575
+ // 已挂过的 linkTags 认得出, 不会再套一层。
528
576
  const chatIdOf = (t) => baseOfKey(t).replace(/^(user|chat|group):/, "");
529
577
  const notifyChat = (base, markdown) => {
530
578
  const chatId = chatIdOf(base);
531
579
  void ws.client
532
- .sendMessage(chatId, { msgtype: "markdown", markdown: { content: markdown } })
580
+ .sendMessage(chatId, { msgtype: "markdown", markdown: { content: linkTags(base, markdown) } })
533
581
  .catch((e) => log.warn({ err: e.message }, "chat notify failed"));
534
582
  };
535
583
  // wizard 在群里的称呼, 只用在**正文**里。气泡的头照旧交给 withTagHeader ——
536
584
  // 那一段是路由信息 (`emoji #tag`), parseTagHeader 靠它反解, 群里引用一条气泡
537
585
  // 就能直接跟那个 wizard 说话; 把它换成名字会把这条通路弄断。
538
586
  const displayName = (t) => {
539
- const tag = tagOfKey(t);
540
- const name = wizardName(wizards.get(t), chatNameOf(cfg, t), t);
541
- return name || (tag ? `#${tag}` : "默认 wizard");
587
+ const name = settleName(wizards, chatNameOf(cfg, t), t);
588
+ return name ? `.${name}` : "默认 wizard";
542
589
  };
543
- // wizard 之间的往返本来是看不见的: 它发生在两个没人盯着的 pane 里。关键的那几
544
- // 次 (派活、结论) 各自成一条气泡, 头写成 `<from> → <to>`, 方向一眼可读。
590
+ // wizard 之间的往返默认是私聊, 不进群。只有发话方判断「该当着人说」的那一次
591
+ // (send_peer public:true) 在公开频道里成一条气泡, 头写成 `.a → .b`, 方向一眼可读。
545
592
  const RELAY_MAX = 1200;
546
- /** 一个 wizard 在 `dest` 群里的称呼。本群: 照旧 `emoji #tag` —— 那一段是路由
547
- * 信息, parseTagHeader 靠它反解, 引用气泡就能直接跟它说话。外群: 换成带聊天
548
- * 名的全称 `emoji chat#tag`, 一个裸 `#tag` 在别人群里既认不出是谁, 又会被当
549
- * 成本群的 tag 误投到一个不存在的会话上。 */
550
- const relayLabel = (t, dest) => {
551
- const name = baseOfKey(t) === dest
552
- ? withTagHeader(t, "").trim()
553
- : `${tagOfKey(t) ? labelFor(tagOfKey(t)) : "🧙"} ${peerAddress(cfg, dest, t)}`;
554
- const url = chatDetailUrl(t);
555
- return url ? `[${name}](${url})` : name;
556
- };
557
- /** 头上的名字挂它自己的 chat 详情页 —— 看见「A → B」的人下一步想问的永远是
558
- * 「A 那边在干嘛」, 链接就省掉他去翻群找 A 气泡这一步。票据优先取该 wizard 自己
559
- * 最近那条 turn (mirror 的 linkedTagPrefix 同源), 取不到就退到它所在聊天的长期
560
- * 票据 —— `?id=` 的授权范围本来就是整个聊天, 开在谁那一栏由 `target=` 明说, 所以
561
- * 换票不多给权限也不会开错栏。少了这层兜底, `A → B` 里的 B 几乎总是不可点:
562
- * detail store 只留 24h, 而被派活的那个 wizard 恰恰常常是刚出生 / 闲了一天的,
563
- * 跨聊天派活时它所在的那个群更可能整个群都没有 turn 记录。 */
564
- const chatDetailUrl = (t) => {
565
- const id = latestTurnIdFor(t) ?? chatTicketFor(baseOfKey(t));
566
- return id ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, chatIdOf(t), t) : undefined;
567
- };
568
- /** 一条 relay 只落在**收信那一方**的群里, 从不两头都发:
569
- * - 派活 → to 的群。同群时那就是双方共处的那个群 (行为照旧); 跨群时源头群
570
- * 不再复述 —— 那句话本来就是它自己说出口的, 贴回自己群里只是噪音, 真正
571
- * 需要看见的是 to 那边的人: 活是谁派来的。
572
- * - 回程结论 (`relayPeer(target, self)`) → 问话人的群。答话方自己的群里,
573
- * 它的回复早就以它自己的气泡出现过了。 */
574
- 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) => {
575
598
  const text = body.trim();
576
- if (!text)
599
+ if (!text || !channel)
577
600
  return;
578
- const dest = baseOfKey(to);
579
- const head = `${relayLabel(from, dest)} → ${relayLabel(to, dest)}`;
601
+ const head = `${relayLabel(from)} → ${relayLabel(to)}`;
580
602
  const clipped = text.length > RELAY_MAX ? `${text.slice(0, RELAY_MAX)}…` : text;
581
603
  // 头独占一行, 正文自成一个块 —— 只隔一个换行的话, markdown 会把正文首行
582
604
  // 当成头那一段的续行; 表格因此整张塌成一行带竖线的文字 (表格不能打断段落)。
583
- // 方向已经写在头里了, 正文不再加任何引用/缩进标记。
584
- 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 };
585
655
  };
586
656
  // ── 给人看的消息 ──────────────────────────────────────────────────
587
657
  // send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
@@ -611,9 +681,9 @@ const main = async () => {
611
681
  json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (list_chats 看有哪些; 没起名的聊天寻址不到)` });
612
682
  return;
613
683
  }
614
- 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)])];
615
685
  for (const dest of dests)
616
- notifyChat(dest, `${relayLabel(self, dest)}\n\n${content}`);
686
+ notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
617
687
  json(res, 200, { ok: true, sent: dests.map((d) => chatNameOf(cfg, d) || d) });
618
688
  });
619
689
  http.register("POST /peers/peek", async (req, res) => {
@@ -622,7 +692,7 @@ const main = async () => {
622
692
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
623
693
  return;
624
694
  }
625
- const r = resolvePeer(self, body.tag ?? "");
695
+ const r = resolvePeer(self, addrOf(body));
626
696
  if (!r.ok) {
627
697
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
628
698
  return;
@@ -663,12 +733,16 @@ const main = async () => {
663
733
  return;
664
734
  }
665
735
  }
666
- const r = resolvePeer(self, body.tag ?? "");
736
+ const r = resolvePeer(self, addrOf(body));
667
737
  if (!r.ok) {
668
738
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
669
739
  return;
670
740
  }
671
741
  const { target, foreign } = r;
742
+ // 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
743
+ // 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
744
+ const isPublic = body.public === true;
745
+ const channel = isPublic ? channelOf(self) : "";
672
746
  // Injecting into your own pane would type into the box you're generating
673
747
  // from — Claude Code queues it and the caller deadlocks waiting for itself.
674
748
  if (target === self) {
@@ -694,17 +768,18 @@ const main = async () => {
694
768
  return;
695
769
  }
696
770
  }
697
- const inj = await m.injectText(target, text, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) } });
698
- // 归到工单名下的往返不单独出气泡: 五路 fan-out 的每一次派活都发一条, 群里
699
- // 就只剩交叉的气泡, 读不出结构。它照旧落在对方的 chat 详情页里, 收工那一条
700
- // 会把成员和各自那段活一起列出来。
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
+ // 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
701
776
  if (inj.ok && jobId)
702
777
  jobs.attach(jobId, { target, task: text, spawned: false });
703
- if (inj.ok && !jobId)
704
- relayPeer(self, target, text);
778
+ if (inj.ok && isPublic)
779
+ relayPeer(self, target, text, channel);
705
780
  // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
706
781
  // 它这一轮后面, 而不是马上被读到。
707
- 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 } : {}) });
708
783
  });
709
784
  // 等一个 wizard, 或者等一**组**。fan-out 之后 join 必须是并行的: 串行地等五个
710
785
  // 分身, 墙钟是五个之和, 而它们本来就在同时干活。`need` 把 all / any / 过半收进
@@ -716,7 +791,8 @@ const main = async () => {
716
791
  return;
717
792
  }
718
793
  const b = body;
719
- 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 ?? ""));
720
796
  if (asked.length > 16) {
721
797
  json(res, 400, { ok: false, reason: "一次最多等 16 个 wizard" });
722
798
  return;
@@ -741,13 +817,7 @@ const main = async () => {
741
817
  const results = uniq.map((h, i) => {
742
818
  const wr = wrs[i];
743
819
  const lastText = m.lastText(h.target);
744
- // 回程只在跨聊天时下发。同一个群里, 对方的回复本来就会以它自己的 `emoji #tag`
745
- // 气泡出现 —— 再 relay 一条 `B → A` 就是同一句话在同一个群里出现两次, 正是
746
- // 「A 告诉 B 之后不必再展示 B 收到了」要消灭的那种重复。跨聊天则相反: A 的群
747
- // 里看不到 B 的任何气泡, 这条 relay 是那边唯一能看见结论的地方 —— 所以它落在
748
- // self (问话人) 的群, 而不是答话方那边。
749
- if (wr.idle && h.foreign)
750
- relayPeer(h.target, self, lastText);
820
+ // 回程不再进群: 私聊的结论由调用方自己收口给人; 公开轮的回复 mirror 早已发进那个群。
751
821
  // `result` 是它自己收口的那一行 (见 peers.extractResult); 捞不到就是 "",
752
822
  // 调用方照旧读 lastText。
753
823
  return { address: h.address, target: h.target, foreign: h.foreign, idle: wr.idle, reason: wr.reason, result: extractResult(lastText), lastText };
@@ -784,7 +854,7 @@ const main = async () => {
784
854
  }
785
855
  }
786
856
  else {
787
- const r = resolvePeer(self, body.tag ?? "");
857
+ const r = resolvePeer(self, addrOf(body));
788
858
  if (!r.ok) {
789
859
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
790
860
  return;
@@ -839,8 +909,8 @@ const main = async () => {
839
909
  : { ok: false, target, brief, reason: `handoff carry inject failed: ${inj2.reason}` });
840
910
  });
841
911
  // ── Wizard: 会话的身份层 ────────────────────────────────────────────
842
- // 一个会话 (`chat:xxx#tag`) 从此是一个 wizard: 名字 (= 聊天名, 带 tag 的分身读作
843
- // `chat#tag`)、一句职责、一份跨会话的记忆、一条家谱。身份不靠"在对话里讲一遍"
912
+ // 一个会话 (`chat:xxx#k`) 从此是一个 wizard: 一个全局唯一的名字 (默认会话出生时
913
+ // 取聊天名, 分身取自己的名字)、一句职责、一份跨会话的记忆、一条家谱。身份不靠"在对话里讲一遍"
844
914
  // 维持 —— spawn 时以 `--append-system-prompt` 压进那个进程, `/clear` 抹不掉、
845
915
  // 上下文窗口也挤不掉。
846
916
  // 这里只做三件事: 读身份、改身份、按身份生/收分身。tmux 一概不碰, 动作全在
@@ -885,10 +955,14 @@ const main = async () => {
885
955
  log.info({ base, name }, "chat: auto-named from workspace");
886
956
  // 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
887
957
  // 得知道自己的地址变了 —— 它的 charter 里写的还是「(未命名)」。
888
- postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— 别的聊天从此能以 \`${name}#tag\` 叫到这里, 你的地址也随之可读了。`);
958
+ postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— notify / new_claude_session 可以用这个名字指到这里。`);
889
959
  }
890
960
  return named;
891
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));
892
966
  // 工单账本 (见 jobs.ts)。只在**显式传了 `job`** 时起作用 —— 不用工单的调用方
893
967
  // 行为与从前一模一样。
894
968
  const jobs = loadJobStore(cfg.wrc.mirror.jobsFile);
@@ -925,7 +999,7 @@ const main = async () => {
925
999
  const w = byTarget.get(p.target);
926
1000
  return {
927
1001
  target: p.target,
928
- name: wizardName(w, chatNameOf(cfg, p.target), p.target),
1002
+ name: settleName(wizards, chatNameOf(cfg, p.target), p.target),
929
1003
  description: w?.description ?? "",
930
1004
  chat: chatNameOf(cfg, p.target) || p.chat,
931
1005
  cwd: p.cwd,
@@ -946,7 +1020,7 @@ const main = async () => {
946
1020
  .filter((w) => !seen.has(w.target))
947
1021
  .map((w) => ({
948
1022
  target: w.target,
949
- name: wizardName(w, chatNameOf(cfg, w.target), w.target),
1023
+ name: settleName(wizards, chatNameOf(cfg, w.target), w.target),
950
1024
  description: w.description,
951
1025
  chat: chatNameOf(cfg, w.target),
952
1026
  cwd: "", model: "", cli: "",
@@ -965,16 +1039,20 @@ const main = async () => {
965
1039
  openedAt: j.openedAt, closedAt: j.closedAt, summary: j.summary,
966
1040
  members: j.members.map((mm) => ({ target: mm.target, task: mm.task, spawned: mm.spawned })),
967
1041
  })),
968
- schedules: cfg.schedules.map((x) => ({
969
- id: x.id,
970
- target: x.target,
971
- when: describeWhen(x.when),
972
- nextAt: nextFire(x.when, x.lastFired ?? x.createdAt, nowDate),
973
- lastFired: x.lastFired,
974
- prompt: x.prompt,
975
- note: x.note,
976
- createdBy: x.createdBy,
977
- })),
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
+ }),
978
1056
  chatNames: Object.fromEntries(listChatNames(cfg).map((c) => [c.base, c.name])),
979
1057
  };
980
1058
  };
@@ -989,39 +1067,40 @@ const main = async () => {
989
1067
  * 给交接那一轮本身 —— 提示而已, 决定权在 wizard 自己。 */
990
1068
  const HANDOFF_HINT_TOKENS = 140_000;
991
1069
  const briefOf = (self, target) => ({
992
- name: wizardName(wizards.get(target), chatNameOf(cfg, target), target),
1070
+ name: settleName(wizards, chatNameOf(cfg, target), target),
993
1071
  address: peerAddress(cfg, self, target),
994
1072
  description: wizards.get(target)?.description ?? "",
995
1073
  cwd: m.getCwd(target).runningCwd,
996
1074
  });
997
- /** 自己的地址要写成**全局**形态 (`聊天名#tag`), 不是同群内部的裸 tag —— 宪章
998
- * 和 whoami 里的这个串会被它原样贴给别的聊天的 wizard, 裸 tag 到了那边只有
999
- * 在全机唯一时才碰巧能解析。聊天没起名时只能退回裸 tag。 */
1000
- const selfAddress = (target) => {
1001
- const chat = chatNameOf(cfg, target);
1002
- const tag = tagOfKey(target);
1003
- return chat ? `${chat}#${tag}` : tag;
1004
- };
1075
+ /** 自己的地址 = 全局名字, 与住在哪个群无关。 */
1076
+ const selfAddress = (target) => settleName(wizards, chatNameOf(cfg, target), target);
1005
1077
  /** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
1006
1078
  * 让它一睁眼就知道自己不是一个人在跑。 */
1007
1079
  const charterFor = (target, o) => renderCharter({
1008
- self: { ...briefOf(target, target), address: selfAddress(target) },
1080
+ // o.cwd = 正在启动的那个 pane 的目录; 没给才退回"现在记着的那个"。
1081
+ self: { ...briefOf(target, target), address: selfAddress(target), ...(o.cwd ? { cwd: o.cwd } : {}) },
1009
1082
  chat: chatNameOf(cfg, target),
1010
1083
  principal: baseOfKey(target),
1011
1084
  parent: o.parent ? briefOf(target, o.parent) : undefined,
1012
1085
  inherited: !!o.inherited,
1086
+ cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
1013
1087
  siblings: m.chatTargets(baseOfKey(target)).filter((t) => t !== target).map((t) => briefOf(target, t)),
1014
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
+ })(),
1015
1094
  });
1016
1095
  // 所有 spawn 路径 (群里手打 /new、pane 自愈重生、编排出来的分身) 都从这里
1017
1096
  // 取身份, 于是「是谁」不再取决于是哪段代码把它生出来的。
1018
- m.setCharterProvider((target) => {
1097
+ m.setCharterProvider((target, ctx) => {
1019
1098
  // 这里是最要紧的那道拦截: 宪章把「所在聊天」写进系统提示, 而系统提示是随进程
1020
1099
  // 终身的 —— 一个在聊天还没名字时出生的 wizard, 会一辈子以为自己住在一个
1021
1100
  // 「(未命名)」的地方。所以补名要发生在渲染之前, 不能等它以后自己去查。
1022
1101
  ensureChatNames(target);
1023
1102
  const rec = wizards.get(target);
1024
- return charterFor(target, { parent: rec?.parent, inherited: !!rec?.clonedFrom });
1103
+ return charterFor(target, { parent: rec?.parent, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
1025
1104
  });
1026
1105
  // 开机也补一次: IM 侧的 `/peers`、`/help` 直接读名字, 不经过任何 MCP 路由。
1027
1106
  // 延后是因为附着表要先从盘上恢复完, 否则这一刻还看不见那些聊天; 补不全也无妨,
@@ -1050,7 +1129,7 @@ const main = async () => {
1050
1129
  chat: chatNameOf(cfg, target),
1051
1130
  principal: baseOfKey(target),
1052
1131
  tag: tagOfKey(target),
1053
- named: !!(rec?.name ?? "").trim() || !!chatNameOf(cfg, target),
1132
+ named: !!(rec?.name ?? "").trim(),
1054
1133
  bornAt: rec?.bornAt,
1055
1134
  inheritedFrom: rec?.clonedFrom || "",
1056
1135
  memory: rec?.memory ?? [],
@@ -1114,9 +1193,8 @@ const main = async () => {
1114
1193
  ensureChatNames(self);
1115
1194
  json(res, 200, { ok: true, ...identityOf(self, self), peers: (await m.peers(self)).filter((p) => !p.self).length });
1116
1195
  });
1117
- // 起名字 / 写职责。默认会话的名字就是聊天的名字 (需求: wizard 的名字 = chat
1118
- // 的名字), 所以那一路同时写 chats 表 —— 否则别的聊天仍然叫不到它。带 tag 的
1119
- // 分身只写自己的记录, 它的地址本来就是 `chat#tag`。
1196
+ // 改名字 / 写职责。名字全局唯一、与聊天名脱钩 (默认会话只是出生时取了聊天名):
1197
+ // 撞名自动挂 `-N`, 返回里的 `name` 才是落定的那个。
1120
1198
  http.register("POST /wizard/identity", async (req, res) => {
1121
1199
  const { self, body } = await readPeerBody(req);
1122
1200
  if (!self) {
@@ -1124,31 +1202,26 @@ const main = async () => {
1124
1202
  return;
1125
1203
  }
1126
1204
  const b = body;
1127
- const name = (b.name ?? "").toString().trim();
1205
+ const asked = normalizeTag((b.name ?? "").toString());
1128
1206
  const description = (b.description ?? "").toString().trim();
1129
- const wasNamed = !!wizardName(wizards.get(self), chatNameOf(cfg, self), self);
1130
- let chatNamed;
1131
- if (name && !tagOfKey(self)) {
1132
- const r = setChatName(cfg, sourcePath, self, name);
1133
- if (!r.ok) {
1134
- json(res, 409, { ok: false, reason: r.reason });
1135
- return;
1136
- }
1137
- chatNamed = r.name;
1207
+ if ((b.name ?? "").toString().trim() && !asked) {
1208
+ json(res, 400, { ok: false, reason: "名字只能是 1-32 位字母/数字/`_`/`-`" });
1209
+ return;
1138
1210
  }
1139
- wizards.upsert(self, {
1140
- ...(name ? { name: chatNamed ?? name } : {}),
1141
- ...(description ? { description } : {}),
1142
- });
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 });
1143
1215
  const me = identityOf(self, self);
1144
- // 起名是稀有事件, 值得在群里留一条 —— 人得知道群里这个角色叫什么了。
1145
- if (name && !wasNamed)
1146
- notifyChat(self, withTagHeader(self, `我是 **${me.name}**${description ? ` · ${description}` : ""}`));
1147
- // 职责是别人决定"该不该找你"的依据, 改了就得让同群的人知道 —— 否则他们照着
1216
+ // 职责是别人决定"该不该找你"的依据, 改了就得让同群的知道 —— 否则他们照着
1148
1217
  // 出生快照里那句旧的 (或者空的) 职责派活。
1149
- if (name || description)
1150
- postRoster(baseOfKey(self), [self], `**${me.name || me.address}** 改了身份 · 地址 \`${peerAddress(cfg, baseOfKey(self), self)}\`${description ? ` · 职责: ${description}` : ""}`);
1151
- 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
+ });
1152
1225
  });
1153
1226
  // 名册是**索引**, 不是转储: 这台机器上现在有 300+ 个会话, 整表吐出来是三万
1154
1227
  // token —— 一个 wizard 每次想找人都付这个价, 等于找不到人。所以服务端过滤
@@ -1188,6 +1261,8 @@ const main = async () => {
1188
1261
  : {}),
1189
1262
  });
1190
1263
  });
1264
+ // 记忆三种作用域: self 跟着自己 (wizards.json); chat / workspace 是共享的 md,
1265
+ // 每个在那个群 / 那个目录出生的 wizard 都会读到 (见 wizard-memory.ts)。
1191
1266
  http.register("POST /wizard/remember", async (req, res) => {
1192
1267
  const { self, body } = await readPeerBody(req);
1193
1268
  if (!self) {
@@ -1195,14 +1270,27 @@ const main = async () => {
1195
1270
  return;
1196
1271
  }
1197
1272
  const b = body;
1198
- const cur = wizards.get(self)?.memory ?? [];
1199
1273
  const note = (b.note ?? "").toString().trim();
1200
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 ?? [];
1201
1289
  // 忘记按子串匹配: 模型记不住自己当初一字不差写了什么, 但记得大意。
1202
1290
  const next = forget ? cur.filter((x) => !x.includes(forget)) : cur;
1203
1291
  const memory = note ? [...next.filter((x) => x !== note), note] : next;
1204
1292
  wizards.upsert(self, { memory });
1205
- 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 });
1206
1294
  });
1207
1295
  // 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
1208
1296
  // 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
@@ -1253,34 +1341,13 @@ const main = async () => {
1253
1341
  });
1254
1342
  return;
1255
1343
  }
1256
- const taken = new Set(m.chatTargets(base).map(tagOfKey).filter(Boolean));
1257
- const asked = normalizeTag(b.tag);
1258
- if (asked && taken.has(asked)) {
1259
- // "活着" 不能只看 taken 里有没有这个 tag —— taken 是 chatTargets(), 连早就
1260
- // 断线的冷绑定也算数。真探一次活, 把结论 (alive/busy/多久没动过) 和能直接
1261
- // 回传给 send_peer/stop_wizard 的完整地址一起吐回去, 调用方才判断得出这是
1262
- // "该换个名字" 还是"值得 stop_wizard 收掉腾地方再复用"。
1263
- const existing = keyOf(base, asked);
1264
- const info = (await m.peers(existing)).find((p) => p.target === existing);
1265
- const alive = info?.paneAlive ?? false;
1266
- const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
1267
- const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
1268
- const advice = alive
1269
- ? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个 tag 重新 spawn_clone, 别把不相关的活塞给一个已经有职责的 wizard"
1270
- : `pane 已经不在了 (${idleDesc}), 真要复用这个 tag 就先 stop_wizard({tag:"${asked}", mode:"end"}) 收掉腾出名字, 再用同一个 tag 重新 spawn_clone —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`;
1271
- json(res, 409, {
1272
- ok: false,
1273
- reason: `'#${asked}' 已经是一个${alive ? "活着的" : "不再活跃的"} wizard —— ${advice}`,
1274
- tag: asked,
1275
- address: peerAddress(cfg, self, existing),
1276
- alive,
1277
- busy: info?.busy ?? false,
1278
- idleForMs,
1279
- });
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);
1280
1348
  return;
1281
1349
  }
1282
- const tag = asked || uniqueTag(normalizeTag(b.description?.split(/\s+/)[0]) || "clone", taken);
1283
- const target = keyOf(base, tag);
1350
+ const { target, slot: tag } = slotR;
1284
1351
  const parentInfo = m.sessionInfo(self);
1285
1352
  // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
1286
1353
  wizards.upsert(target, {
@@ -1289,12 +1356,15 @@ const main = async () => {
1289
1356
  bornAt: Date.now(),
1290
1357
  clonedFrom: inherit ? parentInfo?.sessionId ?? "" : "",
1291
1358
  });
1359
+ const name = wizards.rename(target, normalizeTag(askedName) || tag);
1292
1360
  const charter = charterFor(target, { parent: self, inherited: inherit });
1293
1361
  const task = (b.task ?? "").toString().trim();
1362
+ // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
1363
+ const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
1294
1364
  const r = await m.cloneSession({
1295
1365
  parent: self,
1296
1366
  target,
1297
- windowName: tag,
1367
+ windowName: name,
1298
1368
  cli: b.cli,
1299
1369
  model: b.model,
1300
1370
  cwd: b.cwd,
@@ -1303,6 +1373,7 @@ const main = async () => {
1303
1373
  // 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
1304
1374
  // 也少一次"就位了但没事干"的空转。
1305
1375
  bootstrap: task || undefined,
1376
+ keepalive,
1306
1377
  });
1307
1378
  if (!r.ok) {
1308
1379
  wizards.drop(target);
@@ -1311,26 +1382,25 @@ const main = async () => {
1311
1382
  }
1312
1383
  wizards.upsert(target, { clonedFrom: r.inherited ? parentInfo?.sessionId ?? "" : "" });
1313
1384
  const kid = briefOf(self, target);
1314
- // 分身出生要在群里留一条 —— 群里多了一个成员, 人有权当场知道。
1315
- // 工单里的分身是临时工: 出生、派活各发一条气泡, 五路 fan-out 就是十条交叉的
1316
- // 气泡, 人从里面读不出结构。它们攒到收工那一条里一起交代 (成员 + 各自那段活),
1317
- // 中间过程照旧在各自的 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 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
1318
1392
  if (!jobId) {
1319
- notifyChat(base, withTagHeader(target, `已就位 · ${r.inherited ? `${displayName(self)} 的分身 (继承了它的上下文)` : "全新 wizard (空白上下文)"}${kid.description ? ` · ${kid.description}` : ""}`));
1320
- 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 ? " (继承了它的上下文)" : ""}`);
1321
1394
  }
1322
- // 派活在群里留一条 (它是关键节点)。继承路径上活已经随开场白进去了, 空白分身
1323
- // 才需要在这里补一次注入。
1395
+ // 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
1324
1396
  let dispatched = r.inherited && !!task;
1325
1397
  if (task && !r.inherited) {
1326
- const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) } });
1398
+ const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) }, channel: "" });
1327
1399
  dispatched = inj.ok;
1328
1400
  }
1329
1401
  if (jobId)
1330
1402
  jobs.attach(jobId, { target, task, spawned: true });
1331
- else if (dispatched)
1332
- relayPeer(self, target, task);
1333
- 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 } : {}) });
1334
1404
  });
1335
1405
  // 收掉一个 wizard。interrupt = 打断它这一轮 (Esc); end = 结束它并回收 pane。
1336
1406
  // 自己终结自己是合法的 (分身干完活自我了结), 代价是这次工具调用不会返回 ——
@@ -1342,7 +1412,7 @@ const main = async () => {
1342
1412
  return;
1343
1413
  }
1344
1414
  const b = body;
1345
- const r = resolvePeer(self, b.tag ?? "");
1415
+ const r = resolvePeer(self, addrOf(b));
1346
1416
  if (!r.ok) {
1347
1417
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
1348
1418
  return;
@@ -1390,6 +1460,9 @@ const main = async () => {
1390
1460
  const idle = await waitForIdle(self, m.isBusy, 10 * 60_000, () => false);
1391
1461
  if (!idle.idle) {
1392
1462
  lg.warn({ reason: idle.reason }, "self-handoff: 目标一直忙, 放弃");
1463
+ // 之前这里只写日志: 调用方早就拿到 `scheduled:true` 挂了, 交接静悄悄
1464
+ // 放弃, 群里没有任何气泡告诉人。补一条 notify, 对齐它成功时的那条。
1465
+ notifyChat(self, withTagHeader(self, `上下文交接放弃: 一直没能闲下来 (${idle.reason}), 上下文和之前一样没动, 有空再试一次`));
1393
1466
  return;
1394
1467
  }
1395
1468
  const clr = await m.injectText(self, "/clear");
@@ -1478,6 +1551,8 @@ const main = async () => {
1478
1551
  // ── 定时任务 ────────────────────────────────────────────────────
1479
1552
  // 到点把一句 prompt 注入一个 wizard 会话 —— 和人在群里对它说话走的是同一条路
1480
1553
  // (injectText), 所以 pane 死了会被拉起来, 输出照常落进群和详情页。
1554
+ /** 日程归谁: 排班那个 wizard (createdBy); 老文件没写就归执行它的那个。 */
1555
+ const ownerOf = (t) => t.owner || (cfg.defaultChat ?? "");
1481
1556
  http.register("POST /tasks/schedule", async (req, res) => {
1482
1557
  const { self, body } = await readPeerBody(req);
1483
1558
  if (!self) {
@@ -1491,15 +1566,15 @@ const main = async () => {
1491
1566
  json(res, 400, { ok: false, reason: "prompt required" });
1492
1567
  return;
1493
1568
  }
1494
- const when = parseWhen(whenText, new Date());
1495
- if (!when) {
1496
- 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) });
1497
1572
  return;
1498
1573
  }
1499
1574
  // tag 省略 = 排给调用者自己。这是最常见的用法: wizard 给自己定一个夜里跑的活。
1500
1575
  // 注入自身在**当下**是死锁 (往正在生成的输入框里打字), 但定时是未来的事,
1501
1576
  // 那时这一轮早已收工, 所以这里不套 /peers/send 的自我保护。
1502
- const tag = (body.tag ?? "").toString().trim();
1577
+ const tag = addrOf(body).trim();
1503
1578
  let target = self;
1504
1579
  if (tag) {
1505
1580
  const r = resolvePeer(self, tag);
@@ -1509,8 +1584,26 @@ const main = async () => {
1509
1584
  }
1510
1585
  target = r.target;
1511
1586
  }
1512
- const rec = addSchedule(cfg, sourcePath, { when, target, prompt, createdBy: self, note: (b.note ?? "").toString() });
1513
- 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
+ });
1514
1607
  });
1515
1608
  http.register("POST /tasks/list", async (req, res) => {
1516
1609
  const { self, body } = await readPeerBody(req);
@@ -1520,9 +1613,20 @@ const main = async () => {
1520
1613
  }
1521
1614
  const mineOnly = body.mine === true;
1522
1615
  const now = new Date();
1523
- const tasks = listSchedules(cfg, mineOnly ? self : undefined)
1524
- .map((x) => ({ ...renderSchedule(x, now), address: peerAddress(cfg, self, x.target) }));
1525
- 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] });
1526
1630
  });
1527
1631
  http.register("POST /tasks/cancel", async (req, res) => {
1528
1632
  const { self, body } = await readPeerBody(req);
@@ -1535,12 +1639,13 @@ const main = async () => {
1535
1639
  json(res, 400, { ok: false, reason: "id required (list_tasks 里那个 id)" });
1536
1640
  return;
1537
1641
  }
1538
- const gone = removeScheduleById(cfg, sourcePath, id);
1642
+ const state = tasks.stateOf(id);
1643
+ const gone = tasks.remove(id);
1539
1644
  if (!gone) {
1540
1645
  json(res, 404, { ok: false, reason: `no schedule with id ${id}` });
1541
1646
  return;
1542
1647
  }
1543
- json(res, 200, { ok: true, removed: renderSchedule(gone) });
1648
+ json(res, 200, { ok: true, removed: renderTask(gone, state) });
1544
1649
  });
1545
1650
  // POST /config/set — modify daemon config from MCP
1546
1651
  http.register("POST /config/set", async (req, res) => {
@@ -1643,22 +1748,106 @@ const main = async () => {
1643
1748
  }
1644
1749
  json(res, 200, { ok: true, target, ...m.getCwd(target) });
1645
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
+ };
1646
1806
  }
1647
- // 定时调度器 — 每 20s 检查 cfg.schedules, 到点把 prompt 注入目标 wizard。
1807
+ // 定时调度器 — 每 20s 检查任务表, 到点把 prompt 注入目标 wizard。
1648
1808
  // inject 只在 mirror 模式给得出 (headless 模式没有常驻会话可注入)。
1809
+ migrateLegacySchedules(cfg, sourcePath, tasks, log.child({ mod: "tasks" }));
1649
1810
  const scheduler = startScheduler({
1650
1811
  client: ws.client,
1651
- cfg,
1652
- sourcePath,
1812
+ registry: tasks,
1653
1813
  log: log.child({ mod: "tasks" }),
1654
- inject: cfg.wrc.mode === "mirror"
1655
- ? (target, text, taskId) => bridge.injectText(target, text, undefined, { fromChat: true, from: { kind: "task", taskId } })
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; }
1656
1818
  : undefined,
1819
+ fallbackTarget: () => (cfg.defaultChat ?? "").trim(),
1657
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
+ }
1658
1844
  const shutdown = async (signal) => {
1659
1845
  log.info({ signal }, "shutdown signal");
1660
1846
  scheduler.stop();
1847
+ tasks.stop();
1661
1848
  netWatch.stop();
1849
+ if (reapTimer)
1850
+ clearInterval(reapTimer);
1662
1851
  // 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。
1663
1852
  log.info(drainForReload(), "pending drained for reload");
1664
1853
  // Hard-exit watchdog: http.close() blocks until every in-flight connection