wezard 2.4.0 → 2.5.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 (101) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +113 -57
  3. package/cli/wezard.sh +2 -0
  4. package/config.example.jsonc +7 -4
  5. package/dist/cli/wx.js +78 -0
  6. package/dist/cli/wx.js.map +1 -0
  7. package/dist/daemon/aibot-media.js +62 -0
  8. package/dist/daemon/aibot-media.js.map +1 -0
  9. package/dist/daemon/approval.js +30 -20
  10. package/dist/daemon/approval.js.map +1 -1
  11. package/dist/daemon/ask.js +3 -2
  12. package/dist/daemon/ask.js.map +1 -1
  13. package/dist/daemon/cc-session.js +27 -4
  14. package/dist/daemon/cc-session.js.map +1 -1
  15. package/dist/daemon/chat-log.js +2 -2
  16. package/dist/daemon/chat-log.js.map +1 -1
  17. package/dist/daemon/chat-name.js +6 -2
  18. package/dist/daemon/chat-name.js.map +1 -1
  19. package/dist/daemon/config-api.js +25 -10
  20. package/dist/daemon/config-api.js.map +1 -1
  21. package/dist/daemon/direction.js +53 -0
  22. package/dist/daemon/direction.js.map +1 -0
  23. package/dist/daemon/ilink.js +162 -0
  24. package/dist/daemon/ilink.js.map +1 -0
  25. package/dist/daemon/inbound.js +64 -18
  26. package/dist/daemon/inbound.js.map +1 -1
  27. package/dist/daemon/index.js +415 -109
  28. package/dist/daemon/index.js.map +1 -1
  29. package/dist/daemon/jobs.js +4 -3
  30. package/dist/daemon/jobs.js.map +1 -1
  31. package/dist/daemon/last-response.js +6 -3
  32. package/dist/daemon/last-response.js.map +1 -1
  33. package/dist/daemon/media.js +108 -0
  34. package/dist/daemon/media.js.map +1 -0
  35. package/dist/daemon/mirror-bridge.js +111 -93
  36. package/dist/daemon/mirror-bridge.js.map +1 -1
  37. package/dist/daemon/model-select.js +159 -54
  38. package/dist/daemon/model-select.js.map +1 -1
  39. package/dist/daemon/notices.js +59 -12
  40. package/dist/daemon/notices.js.map +1 -1
  41. package/dist/daemon/outbound.js +2 -1
  42. package/dist/daemon/outbound.js.map +1 -1
  43. package/dist/daemon/peers.js +46 -27
  44. package/dist/daemon/peers.js.map +1 -1
  45. package/dist/daemon/pending-items.js +1 -1
  46. package/dist/daemon/pending-items.js.map +1 -1
  47. package/dist/daemon/reach.js +6 -0
  48. package/dist/daemon/reach.js.map +1 -0
  49. package/dist/daemon/receipts.js +149 -7
  50. package/dist/daemon/receipts.js.map +1 -1
  51. package/dist/daemon/route.js +11 -4
  52. package/dist/daemon/route.js.map +1 -1
  53. package/dist/daemon/spawn-tmux.js +157 -60
  54. package/dist/daemon/spawn-tmux.js.map +1 -1
  55. package/dist/daemon/steward-guard.js +48 -7
  56. package/dist/daemon/steward-guard.js.map +1 -1
  57. package/dist/daemon/top-only-nudge.js +29 -0
  58. package/dist/daemon/top-only-nudge.js.map +1 -0
  59. package/dist/daemon/weixin-cmd.js +268 -0
  60. package/dist/daemon/weixin-cmd.js.map +1 -0
  61. package/dist/daemon/weixin-port.js +226 -0
  62. package/dist/daemon/weixin-port.js.map +1 -0
  63. package/dist/daemon/weixin.js +772 -0
  64. package/dist/daemon/weixin.js.map +1 -0
  65. package/dist/daemon/wizard.js +56 -18
  66. package/dist/daemon/wizard.js.map +1 -1
  67. package/dist/mcp/server.js +43 -23
  68. package/dist/mcp/server.js.map +1 -1
  69. package/dist/shared/chat-http.js +61 -20
  70. package/dist/shared/chat-http.js.map +1 -1
  71. package/dist/shared/chat-render.js +9 -1
  72. package/dist/shared/chat-render.js.map +1 -1
  73. package/dist/shared/chat-view.js +16 -2
  74. package/dist/shared/chat-view.js.map +1 -1
  75. package/dist/shared/cli-backends.js +3 -0
  76. package/dist/shared/cli-backends.js.map +1 -1
  77. package/dist/shared/config.js +45 -10
  78. package/dist/shared/config.js.map +1 -1
  79. package/dist/shared/json-map-store.js +1 -0
  80. package/dist/shared/json-map-store.js.map +1 -1
  81. package/dist/shared/reminder.js +1 -0
  82. package/dist/shared/reminder.js.map +1 -1
  83. package/dist/shared/role-render.js +7 -4
  84. package/dist/shared/role-render.js.map +1 -1
  85. package/dist/shared/role-view.js +116 -37
  86. package/dist/shared/role-view.js.map +1 -1
  87. package/dist/shared/seen-log.js +26 -0
  88. package/dist/shared/seen-log.js.map +1 -0
  89. package/dist/shared/session-label.js +84 -31
  90. package/dist/shared/session-label.js.map +1 -1
  91. package/dist/shared/turn-state.js +1 -1
  92. package/dist/shared/turn-state.js.map +1 -1
  93. package/dist/shared/world.js.map +1 -1
  94. package/dist/shared/wx-text.js +101 -0
  95. package/dist/shared/wx-text.js.map +1 -0
  96. package/dist/svr/index.js +5 -2
  97. package/dist/svr/index.js.map +1 -1
  98. package/hooks/pre-tool-use.sh +9 -6
  99. package/package.json +6 -2
  100. package/web/chat.css +32 -5
  101. package/web/chat.js +160 -92
@@ -12,6 +12,11 @@ import { patchJsonc } from "../shared/config-writer.js";
12
12
  import { EFFORTS, parseEffort } from "../shared/effort.js";
13
13
  import { bindCliBackends, projectDirsFor } from "../shared/cli-backends.js";
14
14
  import { startWs } from "./ws.js";
15
+ import { startWeixin } from "./weixin.js";
16
+ import { installWeixinPort } from "./weixin-port.js";
17
+ import { bindWxCommands, makeOnBound, makeWxCommands, routeSendMedia, wecomBindSink, weixinSendMedia, wxAdmins, wxRoutes } from "./weixin-cmd.js";
18
+ import { bindChatReach } from "./reach.js";
19
+ import { aibotSendMedia } from "./aibot-media.js";
15
20
  import { startNetWatch } from "./net-watch.js";
16
21
  import { startHttp, json, readBody } from "./http.js";
17
22
  import { parsePath } from "../shared/config-meta.js";
@@ -36,13 +41,15 @@ import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderT
36
41
  import { openTaskRegistry } from "./task-registry.js";
37
42
  import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
38
43
  import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
39
- import { baseOfKey, bindTagLinker, isInternalKey, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
44
+ import { baseOfKey, bindTagLinker, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, speakerHead, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
40
45
  import { appendEpisode, clipForCharter, cwdOfMd, episodePath, inboxPath, mdsOf, proposedCwds, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
41
46
  import { retireStewardTask, startSteward, stewardEnvelope, STEWARD_ID, STEWARD_RUN_MS, STEWARD_TARGET } from "./memory-steward.js";
42
- import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
47
+ import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName, stewardTierOf } from "./chat-name.js";
48
+ import { createTopOnlyNudge, gcNudge, policyKeyOf, renderNudge } from "./top-only-nudge.js";
43
49
  import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, backfillBorn, sweepUnborn, BOOT_ID, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderLead, renderLeadPlaybook, ROSTER_FRESH_MS, renderRoster, handoffAt, memoryNudgeAt, MEMORY_NUDGE_FLOOR, MEMORY_MAX, NOTE_MAX, } from "./wizard.js";
44
50
  import { bindNoticeBox, createNoticeBox, createSeenRing, gcSeen, chatAudience, hears } from "./notices.js";
45
51
  import { createLedger, digest, pendingGc, renderDigest, renderTable, renderAwaiting, renderStalled, renderShelved, ago as agoMs, HEARD_EVERY_MS } from "./pending-items.js";
52
+ import { checkDirection } from "./direction.js";
46
53
  import { loadJobStore, dutyLine, awaitingAccept, stalledRoots, shelvedRoots, nudgeDue, dayClamp, childrenOf as jobChildren, jobEpisode, rejectReason, ACCEPTS, JOB_MEMBER_MAX, JOB_DEPTH_MAX, depthOf, ancestorsOf as jobAncestors, treeOrder, jobStage } from "./jobs.js";
47
54
  import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, lastContextTokens, lastExchange, lastModel, openingTurn, replyClosedBefore, talkTurns, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
48
55
  import { keepalivePingSigs } from "../shared/keepalive.js";
@@ -53,7 +60,7 @@ import { renderInFlight } from "../shared/turn-state.js";
53
60
  import { startIdleNudge } from "./idle-nudge.js";
54
61
  import { renderReminder } from "../shared/reminder.js";
55
62
  import { createHandoffs, handedOff, handingOff } from "./handoff.js";
56
- import { coldGateOf, decide, rankCandidates, renderCandidates, tierFor, wakeNoteOf } from "./route.js";
63
+ import { coldGateOf, decide, rankCandidates, renderCandidates, tierFor, wakeCostOf, wakeNoteOf } from "./route.js";
57
64
  import { parseWhen, renderChatLog, UNKNOWN_HUMAN } from "./chat-log.js";
58
65
  import { audienceOf } from "../shared/role-view.js";
59
66
  import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
@@ -100,11 +107,44 @@ const main = async () => {
100
107
  // 切网 (换 WiFi / VPN 起停) 后原地重建 WS, 等价于自动做了一次 reload 的
101
108
  // 联通性部分 — 但保留全部内存态 (graph 运行、pending 长轮询、镜像绑定),
102
109
  // 也不依赖 launchd/systemd 的 respawn 策略 (nohup fallback 没有 supervisor)。
103
- const netWatch = startNetWatch(log.child({ mod: "net" }), (from, to) => ws.reconnect(`network changed: ${from || "<offline>"} → ${to}`));
110
+ const netWatch = startNetWatch(log.child({ mod: "net" }), (from, to) => {
111
+ ws.reconnect(`network changed: ${from || "<offline>"} → ${to}`);
112
+ wx.kick(`network changed → ${to}`);
113
+ });
114
+ // 微信 ClawBot: 每个扫码绑定的微信用户 = 一个群聊 `chat:wx_…`。分流层装在 tracker 里面 (先装) ——
115
+ // tracker 的空消息 gate / last-response 记录照样覆盖微信; 其余模块照旧只认 ws.client。
116
+ // 这里只建状态, 收发要等下面的入站监听全装好 (wx.start)。onBound 用到的 bridge 在下面
117
+ // 才立起来, 绑定只会在开机很久之后发生, 闭包里取得到。
118
+ const wxLog = log.child({ mod: "weixin" });
119
+ // `/wx` 命令与续上的绑定共用的企微出口 (原生 aibot, 不经微信分流)。
120
+ const wxPush = {
121
+ log: wxLog,
122
+ sendWecom: (p, md) => void ws.client
123
+ .sendMessage(baseOfKey(p).replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content: md } })
124
+ .catch((e) => wxLog.warn({ to: p, err: errText(e) }, "wx cmd push failed")),
125
+ sendWecomMedia: aibotSendMedia(ws.client, log.child({ mod: "media" })),
126
+ };
127
+ const wx = startWeixin({
128
+ cfg,
129
+ log: wxLog,
130
+ sinkFor: (to) => wecomBindSink(wxPush, to),
131
+ onBound: (acct, name) => makeOnBound({
132
+ cfg, sourcePath, log: wxLog,
133
+ hasSession: (t) => bridge.hasMirrorTarget(t),
134
+ newSession: (t, win) => bridge.newSession(t, win, undefined, { silent: true }),
135
+ })(acct, name),
136
+ notifyWecom: (p, md) => void ws.client
137
+ .sendMessage(baseOfKey(p).replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content: md } })
138
+ .catch((e) => wxLog.warn({ to: p, err: errText(e) }, "weixin → wecom notice failed")),
139
+ });
140
+ installWeixinPort(ws.client, wx, wxLog);
141
+ // 审批 / ask / outbound 的「送得到吗」: 微信 chat 看它自己的号, 不看企微长连接。
142
+ bindChatReach((c) => (wx.owns(c) ? wx.live(c) : undefined));
104
143
  // Wrap replyStream / replyStreamWithCard before any module sends —
105
144
  // last-response tracker enables inbound's `quote` dedup of bot self-replies,
106
145
  // and its chat-gate drops header-only pushes (empty messages) daemon-wide.
107
- installResponseTracker(ws.client, log.child({ mod: "chat-gate" }));
146
+ // 微信出站不走企微长连接, 不必等它连上。
147
+ installResponseTracker(ws.client, log.child({ mod: "chat-gate" }), (c) => wx.owns(c));
108
148
  const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
109
149
  // 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
110
150
  // (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
@@ -131,10 +171,13 @@ const main = async () => {
131
171
  return t && known(t) ? t : undefined;
132
172
  },
133
173
  nameOf: (t) => settleName(wizardStore(), chatNameOf(cfg, t), t),
174
+ voiceOf: (t) => wizardStore()?.get(t)?.voiceOf,
134
175
  });
135
176
  // 人话也过交接的闸 (见 handoff.ts): 交接期间说的话进新会话, 不被旧会话带走、也不在
136
177
  // 重开的空档里 resume 回旧 sid。
137
- installInboundRouter(ws.client, cfg, log, { ...bridge, dispatch: async (a) => (await handedOff(a.principal), bridge.dispatch(a)) }, sourcePath);
178
+ // 宪章守卫 (见「改配置会不会动到宪章」): 要等下面宪章渲染那一块立起来才有; 之前 (启动时那次自动补名) 只写不比对。
179
+ let guardCharter = (write) => ({ r: write(), note: Promise.resolve("") });
180
+ installInboundRouter(ws.client, cfg, log, { ...bridge, dispatch: async (a) => (await handedOff(a.principal), bridge.dispatch(a)) }, sourcePath, (w, c) => guardCharter(w, c));
138
181
  // approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
139
182
  // 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
140
183
  // 工作单独成块, 比让 stream 一直长到下一个 inbound / hardTimer 更清晰。
@@ -187,7 +230,26 @@ const main = async () => {
187
230
  http.register("GET /detail", makeDetailHandler(log.child({ mod: "detail" })));
188
231
  for (const [key, handler] of Object.entries(chatHandlers()))
189
232
  http.register(key, handler);
233
+ // 终端 / 持口令的进程发起绑定: 先推确认卡给审批人 (口令每个 wizard 都读得到), 点了才出二维码。
234
+ const confirmBind = async (name) => {
235
+ const to = wxAdmins(cfg)[0];
236
+ if (!to)
237
+ throw new Error("没有审批人 (approval.approvers / defaultChat / allowFrom 里都没有 user:)");
238
+ const { decided } = await requestConfirm({
239
+ client: ws.client, principal: to, who: "本机 `wezard wx bind`",
240
+ title: "微信绑定待确认",
241
+ body: `本机有进程 (终端或持 daemon 口令的 wizard) 要绑定一个新微信${name ? ` (群名「${name}」)` : ""}: 同意才会出二维码, 扫码的那个微信将能驱动本机。`,
242
+ timeoutMs: 180_000,
243
+ });
244
+ return decided;
245
+ };
246
+ for (const [key, handler] of Object.entries(wxRoutes({ cfg, wx, sourcePath, confirmBind })))
247
+ http.register(key, guarded(handler));
248
+ bindWxCommands(makeWxCommands({ cfg, sourcePath, wx, ...wxPush }));
190
249
  installAskEventListener(ws.client, log.child({ mod: "ask" }));
250
+ // 入站监听 (inbound / approval / ask) 都装好了才开收: 首轮 getupdates 会立刻带回 reload 期间
251
+ // 人发的话 (含回菜单的数字), 游标随即前移 —— 早了就是永久丢。
252
+ wx.start();
191
253
  // 智能机器人 doc / smartsheet / contact MCP 桥接 — 总是注册路由, 失败让
192
254
  // 错误透传到上游 (Claude / curl)。requesterUserId 解析顺序:
193
255
  // 调用方 body.requesterUserId → defaultChat 里 user:<id> 部分 → 不传
@@ -548,24 +610,31 @@ const main = async () => {
548
610
  const raw = (body.name ?? "").toString().trim();
549
611
  // "-" is the same erase gesture `/name -` uses in the chat — one verb,
550
612
  // one meaning, whether a human or an agent performs it.
613
+ // 聊天名进宪章: 过宪章守卫, 受影响的 wizard 被提醒 handoff, 调用方自己受影响的那段话放在 `charter`。
614
+ const byHuman = !!askerOf(self)?.who.startsWith("user:");
615
+ const charterOf = async (note) => ((t) => (t ? { charter: t } : {}))((await note).trim());
551
616
  if (raw === "-") {
552
- const gone = clearChatName(cfg, sourcePath, self);
553
- json(res, 200, { ok: true, base: baseOfKey(self), name: "", previous: gone });
617
+ const { r: gone, note } = guardCharter(() => clearChatName(cfg, sourcePath, self), (g) => g ? { what: `\`name_chat\` 取消了聊天名 \`${g}\``, self, byHuman } : undefined);
618
+ json(res, 200, { ok: true, base: baseOfKey(self), name: "", previous: gone, ...(await charterOf(note)) });
554
619
  return;
555
620
  }
556
621
  const previous = chatNameOf(cfg, self);
557
- const r = setChatName(cfg, sourcePath, self, raw);
622
+ const home = baseOfKey(self);
623
+ const { r, note } = guardCharter(() => {
624
+ const r = setChatName(cfg, sourcePath, self, raw);
625
+ if (!r.ok)
626
+ return r;
627
+ // 默认 wizard 的名字取自聊天名; 它还叫旧聊天名 (没被单独改过) 就跟着改。
628
+ const cur = wizards.get(home)?.name ?? "";
629
+ return { ...r, wizardRenamed: previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined };
630
+ }, (r) => r.ok && r.name !== previous ? { what: `\`name_chat\` 把聊天${previous ? ` \`${previous}\`` : ""}改名为 \`${r.name}\``, self, byHuman } : undefined);
558
631
  if (!r.ok) {
559
632
  json(res, 409, { ok: false, reason: r.reason });
560
633
  return;
561
634
  }
562
- // 默认 wizard 的名字取自聊天名; 它还叫旧聊天名 (没被单独改过) 就跟着改。
563
- const home = baseOfKey(self);
564
- const cur = wizards.get(home)?.name ?? "";
565
- const wizardRenamed = previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined;
566
- if (wizardRenamed)
635
+ if (r.wizardRenamed)
567
636
  void m.retitlePane(home);
568
- json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(wizardRenamed ? { wizardRenamed } : {}) });
637
+ json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(r.wizardRenamed ? { wizardRenamed: r.wizardRenamed } : {}), ...(await charterOf(note)) });
569
638
  });
570
639
  const readPeerBody = async (req) => {
571
640
  const body = (await readBody(req));
@@ -588,6 +657,10 @@ const main = async () => {
588
657
  // 头不走 withTagHeader 的那些 (relay / notify / 工单) 正文里的 tag 在这里挂链;
589
658
  // 已挂过的 linkTags 认得出, 不会再套一层。
590
659
  const chatIdOf = (t) => baseOfKey(t).replace(/^(user|chat|group):/, "");
660
+ // 出站媒体只经这一个口子 (通道无关, 见 media.ts); 别的通道接进来时在这里按 principal 分流。
661
+ // 绑定过的微信群聊走微信 (上限不同, 校验也跟着各通道走 —— planFor)。
662
+ const media = routeSendMedia(wx, weixinSendMedia(wx), aibotSendMedia(ws.client, log.child({ mod: "media" })));
663
+ const sendMedia = media.send;
591
664
  const notifyChat = (base, markdown) => {
592
665
  const chatId = chatIdOf(base);
593
666
  void ws.client
@@ -595,28 +668,43 @@ const main = async () => {
595
668
  .catch((e) => log.warn({ chatId, err: errText(e) }, "chat notify failed"));
596
669
  };
597
670
  // wizard 在群里的称呼, 只用在**正文**里。气泡的头照旧交给 withTagHeader ——
598
- // 那一段是路由信息 (`emoji #tag`), parseTagHeader 靠它反解, 群里引用一条气泡
671
+ // 那一段是路由信息 (`emoji.name:`), parseTagHeader 靠它反解, 群里引用一条气泡
599
672
  // 就能直接跟那个 wizard 说话; 把它换成名字会把这条通路弄断。
600
673
  const displayName = (t) => {
601
674
  const name = settleName(wizards, chatNameOf(cfg, t), t);
602
675
  return name ? `.${name}` : "默认 wizard";
603
676
  };
604
677
  // wizard 之间的往返默认是私聊, 不进群。只有发话方判断「该当着人说」的那一次
605
- // (send_peer public:true) 在公开频道里成一条气泡, 头写成 `.a → .b`, 方向一眼可读。
678
+ // (send_peer public:true) 在公开频道里成一条气泡, 头写成 `.a: .b`, 方向一眼可读。
606
679
  const RELAY_MAX = 1200;
607
- /** 一个 wizard 在群里的称呼: `emoji .name`, 挂它的 rolepage。名字全局唯一, 不再
680
+ /** 一个 wizard 在群里的称呼: `.name`, 挂它的 rolepage。名字全局唯一, 不再
608
681
  * 因为落在哪个群而换写法 —— 引用这一段就能跟它说话 (parseTagHeader 认得)。 */
609
- const relayLabel = (t, _dest) => tagLink(t, tagHead(t));
682
+ const relayLabel = (t) => ((h) => (h ? tagLink(t, h) : ""))(tagHead(t));
683
+ /** 以 `self` 的名义往 `dest` 群发一条: 头独占一行; 头怎么写 (群默认 wizard 报不报名) 归 ownHead。 */
684
+ const postAs = (self, dest, text) => notifyChat(dest, headed(ownHead(self, dest), text, "\n\n"));
685
+ // 没开顶层模式的群里公开气泡多了 → 往那个群的管家 (群默认会话, target 就是 base principal) 信箱挂一行,
686
+ // 让它问人要不要开顶层模式 (见 top-only-nudge.ts)。路径里写聊天名: 管家照抄就能 config_set。
687
+ const topOnlyNudge = createTopOnlyNudge({
688
+ store: loadJsonMap("~/.wezard/top-only-nudge.json", gcNudge),
689
+ policy: () => ((p) => ({ windowMs: p.windowMin * 60_000, threshold: p.threshold, cooldownMs: p.cooldownH * 3_600_000 }))(cfg.wrc.mirror.topOnlyNudge),
690
+ nudge: (chat, n) => {
691
+ const p = cfg.wrc.mirror.topOnlyNudge;
692
+ notices.post([chat], renderNudge(policyKeyOf(chatNameOf(cfg, chat), chat), n, p.windowMin, p.cooldownH));
693
+ log.info({ chat, n }, "top-only nudge: 已提醒管家问人要不要开顶层模式");
694
+ },
695
+ });
610
696
  /** 公开的 wizard 间对话: 落在 `channel` 这个群。 */
611
697
  const relayPeer = (from, to, body, channel) => {
612
698
  const text = body.trim();
613
699
  if (!text || !channel)
614
700
  return;
615
- const head = `${relayLabel(from)} → ${relayLabel(to)}`;
701
+ const head = speakerHead(relayLabel(from), relayLabel(to));
616
702
  const clipped = text.length > RELAY_MAX ? `${text.slice(0, RELAY_MAX)}…` : text;
617
703
  // 头独占一行, 正文自成一个块 —— 只隔一个换行的话, markdown 会把正文首行
618
704
  // 当成头那一段的续行; 表格因此整张塌成一行带竖线的文字 (表格不能打断段落)。
619
705
  notifyChat(channel, `${head}\n\n${clipped}`);
706
+ if (!chatPolicyOf(cfg, channel).topOnly)
707
+ topOnlyNudge.count(baseOfKey(channel));
620
708
  };
621
709
  /** 调用方这一轮所在的公开频道: 人从哪个群叫的它 / 公开 peer 轮的那个群;
622
710
  * 私聊轮或无记录 → 它的 home 群。notify 与 public send_peer 默认发到这里。 */
@@ -633,7 +721,7 @@ const main = async () => {
633
721
  * read_chat; 认不出 = ""。 */
634
722
  const audienceName = (channel, asker) => {
635
723
  const id = audienceOf(channel, asker);
636
- return id === "human:" ? "" : id.slice("human:".length);
724
+ return id.startsWith("human:") ? id.slice("human:".length) : "";
637
725
  };
638
726
  // 挂起事项表 (见 pending-items.ts): 派活时记一行, 回执落定时由下面的 onOutcome 记账。
639
727
  const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc));
@@ -654,6 +742,12 @@ const main = async () => {
654
742
  nameOf: displayName,
655
743
  log,
656
744
  store: loadJsonMap(cfg.wrc.mirror.receiptsFile),
745
+ holdStore: loadJsonMap(cfg.wrc.mirror.holdsFile),
746
+ // 放行停放着的 after 件: 走 tell_peer 同一条注入路径, 沿用停放时的件号 / 频道 / 父 k。
747
+ release: async (h, seen) => {
748
+ const r = await tellPeerAs(h.from, h.body, afterNote(h.after, seen), { turn: h.turn, channel: h.channel, ...(h.k ? { k: h.k } : {}), ...(h.asker ? { asker: h.asker } : {}) });
749
+ return r.status === 200 ? { ok: true } : { ok: false, reason: String(r.body.reason ?? `HTTP ${r.status}`) };
750
+ },
657
751
  handingOff,
658
752
  handedOff,
659
753
  replyFor: (to, fromName, since, turn) => m.replyToPeer(to, fromName, since, turn),
@@ -873,6 +967,17 @@ const main = async () => {
873
967
  // 给**人**看。收件人就是地址 —— 聊天名或裸 principal, 没有「先订阅才收得到」
874
968
  // 这一步。头沿用 relay 那一套: 本群退化成寻常的 `emoji #tag`, 外群写成带聊天
875
969
  // 名的全称, 于是那边的人一眼看得出是谁、从哪个群说过来的。
970
+ // 顶层模式: 只有顶层 wizard 对人说话。拦的是「这一轮在为同伴干活」的: 同伴私聊派来的活, 或
971
+ // 回执轮但那件活是为上游同伴派的 (k 是 peer)。人说的、定时任务放的、旁支 (chain:false, 无 k)
972
+ // 的回执轮都不拦; 管家只在自己的群里豁免。notify 与 send_file 共用这一道闸。
973
+ const topOnlyMuted = (self, dests) => {
974
+ const env = openingOfSelf(self)?.env;
975
+ const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
976
+ const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
977
+ return muted.length && belowTop
978
+ ? { ok: false, topOnly: true, reason: `${muted.map((d) => chatNameOf(cfg, d) || d).join(", ")} 开着顶层模式: 你这一轮是同伴派来的活, 不对人说话 —— 要说的写进终句 (\`RESULT:\` / \`NEED:\`), 由派你的那个代你向人交代` }
979
+ : undefined;
980
+ };
876
981
  http.register("POST /notify", async (req, res) => {
877
982
  const { self, body } = await readPeerBody(req);
878
983
  if (!self) {
@@ -897,22 +1002,69 @@ const main = async () => {
897
1002
  return;
898
1003
  }
899
1004
  const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
900
- // 顶层模式: 只有顶层 wizard 对人说话。拦的是「这一轮在为同伴干活」的: 同伴私聊派来的活, 或
901
- // 回执轮但那件活是为上游同伴派的 (k 是 peer)。人说的、定时任务放的、旁支 (chain:false, 无 k)
902
- // 的回执轮都不拦; 管家只在自己的群里豁免。
903
- const env = openingOfSelf(self)?.env;
904
- const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
905
- const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
906
- if (muted.length && belowTop) {
907
- json(res, 409, { ok: false, topOnly: true, reason: `${muted.map((d) => chatNameOf(cfg, d) || d).join(", ")} 开着顶层模式: 你这一轮是同伴派来的活, 不对人说话 —— 要说的写进终句 (\`RESULT:\` / \`NEED:\`), 由派你的那个代你向人交代` });
1005
+ const muted = topOnlyMuted(self, dests);
1006
+ if (muted) {
1007
+ json(res, 409, muted);
908
1008
  return;
909
1009
  }
910
1010
  const asker = askerOf(self);
911
1011
  for (const dest of dests) {
912
- notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
1012
+ postAs(self, dest, content);
913
1013
  recordPost({ target: self, channel: dest, body: content, ...(asker ? { asker } : {}) });
914
1014
  }
915
- json(res, 200, { ok: true, sent: dests.map((d) => chatNameOf(cfg, d) || d) });
1015
+ // 微信群聊: 人超过 ctxTtlSec 没说话时微信会静默丢弃, 消息只能压着等他下次开口 —— 如实告诉调用方, 别回「已发送」。
1016
+ const wxId = (d) => baseOfKey(d).replace(/^chat:/, "");
1017
+ const held = dests.filter((d) => wx.owns(wxId(d)) && !wx.fresh(wxId(d)));
1018
+ const name = (d) => chatNameOf(cfg, d) || d;
1019
+ json(res, 200, {
1020
+ ok: true,
1021
+ sent: dests.filter((d) => !held.includes(d)).map(name),
1022
+ ...(held.length ? { held: held.map(name), note: `微信群聊 ${held.map(name).join("、")} 那头超过 ${cfg.weixin.ctxTtlSec}s 没说话: 微信会静默丢弃主动推送, 这条已压着, 对方下次在微信里开口时送达 (定时提醒做不到准点)` } : {}),
1023
+ });
1024
+ });
1025
+ // ── 给人看的文件 / 图片 ────────────────────────────────────────────
1026
+ // notify 的媒体版: 去向、认名字、顶层模式闸都与它同一套; 投递交给 sendMedia (通道无关,
1027
+ // 按 principal 分到 aibot / 微信)。caption 先成一条带头的气泡, 让群里知道是谁发的。
1028
+ http.register("POST /send_file", async (req, res) => {
1029
+ const { self, body } = await readPeerBody(req);
1030
+ if (!self) {
1031
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1032
+ return;
1033
+ }
1034
+ const b = body;
1035
+ const path = expandHome((b.path ?? "").trim());
1036
+ if (!path) {
1037
+ json(res, 400, { ok: false, reason: "path required" });
1038
+ return;
1039
+ }
1040
+ ensureChatNames(self);
1041
+ const ref = (b.to ?? "").trim();
1042
+ const dest = ref ? chatBaseOf(cfg, ref) : channelOf(self);
1043
+ if (!dest) {
1044
+ json(res, 400, { ok: false, reason: `认不出聊天: ${ref} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
1045
+ return;
1046
+ }
1047
+ const muted = topOnlyMuted(self, [dest]);
1048
+ if (muted) {
1049
+ json(res, 409, muted);
1050
+ return;
1051
+ }
1052
+ const plan = media.plan(dest, { path, ...(b.kind ? { kind: b.kind } : {}) });
1053
+ if (!plan.ok) {
1054
+ json(res, 400, plan);
1055
+ return;
1056
+ }
1057
+ const caption = (b.caption ?? "").trim();
1058
+ if (caption)
1059
+ postAs(self, dest, caption);
1060
+ const r = await sendMedia(dest, { path, kind: plan.kind, ...(b.title ? { title: b.title } : {}), ...(b.description ? { description: b.description } : {}) });
1061
+ if (!r.ok) {
1062
+ json(res, 502, r);
1063
+ return;
1064
+ }
1065
+ const asker = askerOf(self);
1066
+ recordPost({ target: self, channel: dest, body: [caption, `📎 ${r.name} (${r.kind}) · ${path}`].filter(Boolean).join("\n\n"), ...(asker ? { asker } : {}) });
1067
+ json(res, 200, { ok: true, sent: chatNameOf(cfg, dest) || dest, kind: r.kind, name: r.name, bytes: r.bytes });
916
1068
  });
917
1069
  http.register("POST /peers/peek", async (req, res) => {
918
1070
  const { self, body } = await readPeerBody(req);
@@ -1043,9 +1195,29 @@ const main = async () => {
1043
1195
  const ttlOf = (p) => (cacheTtlSec(p) || cfg.wrc.mirror.keepalive.ttlSec) * 1000;
1044
1196
  // 被冷门控退回过的 `self\0target` → 到期时刻: 老 MCP 进程原样重发即视为 force。
1045
1197
  const coldRefused = new Map();
1198
+ /** 入参 `after` → 件号数组 (去反引号 / 空白 / 重复)。 */
1199
+ const afterTurnsOf = (body) => {
1200
+ const raw = body.after;
1201
+ return [...new Set((Array.isArray(raw) ? raw : []).map((x) => String(x).replace(/[`\s]/g, "")).filter(Boolean))];
1202
+ };
1203
+ /** 放行时挂在信封后面的前置结论: 谁交的、件号、RESULT 与 ARTIFACT 原样 (只进对方输入框)。 */
1204
+ const afterNote = (turns, seen) => renderReminder({ wezard: "after" }, [
1205
+ "这件活排在下面这些前置之后, 它们都已落定; 结论原样附上 (谁交的 · 件号):",
1206
+ ...turns.flatMap((t) => {
1207
+ const x = seen[t];
1208
+ if (!x)
1209
+ return [];
1210
+ const c = parseClosing(x.body, 1500);
1211
+ const clean = (v) => v.replace(/<\/?system-reminder[^>]*>/g, "");
1212
+ return [
1213
+ `- ${displayName(x.to)} · ${t} · ${x.status}: ${c.kind === "result" ? `RESULT: ${clean(c.text)}` : clipLine(clean(x.body), 600) || "(没有正文)"}`,
1214
+ ...c.artifacts.map((a) => ` ARTIFACT: ${a.path}${a.note ? ` — ${a.note}` : ""}`),
1215
+ ];
1216
+ }),
1217
+ ]);
1046
1218
  /** tell_peer 的本体: 以 `self` 的名义说一句 —— HTTP 路由与 dispatch 共用。`extra` = 挂在信封后面的
1047
1219
  * 一段 (只进对方输入框, 不进 `text`: 公开时 text 会成群气泡)。 */
1048
- const tellPeerAs = async (self, body, extra = "") => {
1220
+ const tellPeerAs = async (self, body, extra = "", held) => {
1049
1221
  const text = (body.text ?? "").toString();
1050
1222
  if (!text.trim()) {
1051
1223
  return { status: 400, body: { ok: false, reason: "text required" } };
@@ -1061,7 +1233,8 @@ const main = async () => {
1061
1233
  const { target, foreign } = r;
1062
1234
  // 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
1063
1235
  const re = (body.re ?? "").toString().trim();
1064
- const turn = receipts.prepare(self, target, re || undefined);
1236
+ // 放行一件停放着的 (after): 件号 / 频道 / 父 k / 链头都沿用停放那一刻定的 —— 依赖边不改 k。
1237
+ const turn = held ? { turn: held.turn, legs: 1, channel: held.channel } : receipts.prepare(self, target, re || undefined);
1065
1238
  // 续问只在那个工单还开着时沿用它: 收了工的工单不再收成员, 续问照样能发。
1066
1239
  // 没带工单的一句顶掉一份还没落定的工单活, 也算在那张工单上 (receipts.register 同理
1067
1240
  // 继承): 不然换个不带 job 的 tell_peer 就绕开了预算。
@@ -1075,11 +1248,40 @@ const main = async () => {
1075
1248
  const leadJob = jobs.all().find((j) => j.kind === "req" && j.status === "open" && j.owner === self && j.members.some((x) => x.target === target && x.role === "lead"))?.id ?? "";
1076
1249
  const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || (open?.deferred ? "" : isOpen(open?.job)) || (re && turn.reUnknown ? leadJob : "");
1077
1250
  if (jobId) {
1078
- const jc = checkJob(jobId, target, !!open?.need && open.turn === turn.turn);
1251
+ const jc = checkJob(jobId, target, (!!open?.need && open.turn === turn.turn) || !!held);
1079
1252
  if (!jc.ok) {
1080
1253
  return { status: jc.status, body: { ok: false, reason: jc.reason } };
1081
1254
  }
1082
1255
  }
1256
+ // 派活方向闸 (见 direction.ts): 只拒不改投递路径, 不动回执 k。
1257
+ const dr = held ? undefined : checkDirection({ self, target, kind, re: turn.legs > 1, jobId, jobs: jobs.all(), chain: receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName });
1258
+ if (dr) {
1259
+ return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
1260
+ }
1261
+ // 依赖 (after): 前置必须是自己派出去的件。查不到 → 400; 有失败的 → 409; 都 done → 照常投并附结论; 还有没落定的 → 停放 (见下)。
1262
+ const afters = held ? [] : afterTurnsOf(body);
1263
+ const prereqs = afters.length ? receipts.prereqs(self, afters) : [];
1264
+ if (afters.length) {
1265
+ if (kind !== "task" || re) {
1266
+ return { status: 400, body: { ok: false, reason: "`after` 只用于派新活 (kind:\"task\"、不带 re)" } };
1267
+ }
1268
+ const gone = afters.filter((_, i) => prereqs[i].state === "unknown");
1269
+ if (gone.length) {
1270
+ return { status: 400, body: { ok: false, reason: `after 里的 ${gone.join(" ")} 在你的回执登记里查不到 —— 只能依赖你自己派出去的件号 (过期 / 被同一对的新一句顶掉的也查不到)` } };
1271
+ }
1272
+ const failed = afters.flatMap((t, i) => { const p = prereqs[i]; return p.state === "failed" ? [`${t} (${displayName(p.seen.to)}) 以 ${p.seen.status} 收场`] : []; });
1273
+ if (failed.length) {
1274
+ return { status: 409, body: { ok: false, reason: `前置 ${failed.join("; ")} —— 这件不投; 前置失败了就先处理它, 再重发` } };
1275
+ }
1276
+ const cur = receipts.pending(self, target);
1277
+ if (cur && !afters.includes(cur.turn)) {
1278
+ return { status: 409, body: { ok: false, reason: `你已有一件活 (${cur.turn}) 在 ${displayName(target)} 手上没落定, 同一对只有一个回执槽 —— 等它交差, 或把它也列进 after` } };
1279
+ }
1280
+ }
1281
+ const parkIt = prereqs.some((p) => p.state === "pending");
1282
+ // 前置都已落定: 不停放, 结论附在信封后面照常投。
1283
+ if (afters.length && !parkIt)
1284
+ extra += afterNote(afters, Object.fromEntries(afters.map((t, i) => [t, prereqs[i].seen])));
1083
1285
  // 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
1084
1286
  // 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
1085
1287
  // 只有两端都是登记在册的 wizard 才能当着人说: 裸 target 冒充的发话方、forget
@@ -1131,6 +1333,25 @@ const main = async () => {
1131
1333
  return { status: 409, body: { ok: false, gated: "cold", reason: gate + (force === undefined ? " (你的 MCP 是旧版、没有 force 参数: 10 分钟内原样再发一次即视为 force)" : "") } };
1132
1334
  }
1133
1335
  }
1336
+ // 前置还没落定: 不注入, 登记一份停放 (见 receipts.Hold) —— 件号、频道、父 k、链头定在这一刻,
1337
+ // 放行时原样沿用; 工单成员与预算也现在就记, 「齐了吗」照常数。
1338
+ if (parkIt) {
1339
+ const bp = body;
1340
+ const pk = bp.chain === false ? undefined : parentKOf(self);
1341
+ const pasker = askerOf(self);
1342
+ receipts.hold({
1343
+ from: self, to: target, turn: turn.turn, at: Date.now(), after: afters, channel, job: jobId,
1344
+ ...(pk ? { k: pk } : {}), ...(pasker ? { asker: pasker } : {}),
1345
+ // force: 冷门控上面已过一遍, 放行时缓存又凉了也不再退回。
1346
+ body: { name: peerAddress(cfg, self, target), text, kind, force: true, ...(jobId ? { job: jobId } : {}), ...(bp.role ? { role: bp.role } : {}), ...(bp.priority ? { priority: bp.priority } : {}), ...(bp.waitSec ? { waitSec: bp.waitSec } : {}), ...(bp.deadline !== undefined ? { deadline: bp.deadline } : {}), ...(bp.receipt === false ? { receipt: false } : {}) },
1347
+ });
1348
+ if (jobId) {
1349
+ const role = roleParam(bp.role);
1350
+ jobs.attach(jobId, { target, task: text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
1351
+ jobs.spend(jobId);
1352
+ }
1353
+ return { status: 200, body: { ok: true, parked: true, after: afters, turn: turn.turn, name: peerAddress(cfg, self, target), ...(jobId ? { job: jobId } : {}), receipt: `排在 ${afters.join(" ")} 之后: 前置都 done 才投 (带上它们的结论), 任一失败这件不投、按 canceled 回给你; 不要挂在 wait_peer 上等, 接着干你自己的事` } };
1354
+ }
1134
1355
  // 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
1135
1356
  const wasBusy = !(await m.idleNow(target));
1136
1357
  // urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
@@ -1169,7 +1390,7 @@ const main = async () => {
1169
1390
  // 是"放出去就不管了"的那种派活; fyi 一定不要。对方的信封据此改口, 不再让它收口。
1170
1391
  const wantReceipt = kind !== "fyi" && body.receipt !== false;
1171
1392
  const deadlineAt = deadlineOf(at, deadlineSec === undefined ? undefined : Number(deadlineSec));
1172
- const asker = askerOf(self);
1393
+ const asker = held ? held.asker : askerOf(self);
1173
1394
  const inj = await m.injectText(target, text, undefined, {
1174
1395
  from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
1175
1396
  channel,
@@ -1187,7 +1408,7 @@ const main = async () => {
1187
1408
  // 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
1188
1409
  // 父 k: 默认这件活算发话方此刻在答的那件的子活 (链式续回); `chain:false` = 旁支,
1189
1410
  // 回执照常回来, 但不挂住它给上游的交代 —— 只有发话方知道这件活是不是为上游派的。
1190
- const k = body.chain === false ? undefined : parentKOf(self);
1411
+ const k = held ? held.k : body.chain === false ? undefined : parentKOf(self);
1191
1412
  if (inj.ok && wantReceipt)
1192
1413
  receipts.register({ from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
1193
1414
  if (inj.ok && wantReceipt) {
@@ -1206,7 +1427,8 @@ const main = async () => {
1206
1427
  jobs.mark(jobId, { gate: { g2: Date.now() } });
1207
1428
  jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
1208
1429
  }
1209
- const spent = inj.ok && jobId ? jobs.spend(jobId) : undefined;
1430
+ // 停放时已记过预算, 放行不再算一次。
1431
+ const spent = inj.ok && jobId && !held ? jobs.spend(jobId) : undefined;
1210
1432
  if (inj.ok && isPublic)
1211
1433
  relayPeer(self, target, text, channel);
1212
1434
  // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
@@ -1321,10 +1543,12 @@ const main = async () => {
1321
1543
  });
1322
1544
  // 交接的「重开」一步: 走 /new (杀旧 pane、起新进程) 而不是往原 pane 注入 /clear。
1323
1545
  // /clear 只换会话不换进程 —— MCP 子进程、charter 都还是旧的, 新工具拿不到; 镜像
1324
- // 还得靠 sid 轮换探测才跟得上。模型 / CLI / cwd / keepalive 照旧沿用。
1546
+ // 还得靠 sid 轮换探测才跟得上。CLI / cwd / keepalive 照旧沿用; 模型也沿用, 管家除外 ——
1547
+ // 它换上此刻的管家档 (newSession 里定), 改了 models.steward 后交接一次就是让它生效的那一下。
1325
1548
  const restartFresh = (target) => {
1326
1549
  const info = m.sessionInfo(target);
1327
- return m.newSession(target, displayName(target) || tagOfKey(target) || target, info?.cli, { model: info?.model || undefined, silent: true, warm: true });
1550
+ const model = stewardTierOf(cfg, target) ? undefined : info?.model || undefined;
1551
+ return m.newSession(target, displayName(target) || tagOfKey(target) || target, info?.cli, { model, silent: true, warm: true });
1328
1552
  };
1329
1553
  // POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
1330
1554
  // 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
@@ -1474,6 +1698,19 @@ const main = async () => {
1474
1698
  const notices = bindNoticeBox(createNoticeBox({
1475
1699
  probe: (t) => [...handoffNudge(t), ...memoryNudge(t)],
1476
1700
  digest: pendingDigest,
1701
+ // 群况快照只给长住的 (没有 parent): 分身的协调人是生它的那位, 宪章里的出生快照够用。
1702
+ // 表里是这个群的常驻面: 管家、长住有职责的、管家生的 (群里的执行者)、收件人自己生的;
1703
+ // 再往下一层的 (分身的分身, 回归测试那批 rr-*) 不进表 —— 同名册变动 hears 的取舍。
1704
+ snapshot: (t) => (wizards.get(t)?.parent ? undefined : () => m.chatTargets(t)
1705
+ .filter((x) => x !== t && wizards.get(x)?.name)
1706
+ .filter((x) => ((p) => !tagOfKey(x) || (!p ? !!wizards.get(x)?.description : p === t || p === baseOfKey(t)))(wizards.get(x)?.parent))
1707
+ .map((x) => {
1708
+ const i = m.sessionInfo(x);
1709
+ const state = ((v) => (v === undefined ? "-" : v ? "idle" : m.parkedNow(x) ? "parked" : "busy"))(m.idleVerdict(x));
1710
+ // 冷热与 route_candidates / tell_peer 冷门控同一口径 (touchOf / ttlOf)。
1711
+ const warmth = i?.jsonlPath ? { ctx: i.contextTokens, cold: wakeCostOf(i.contextTokens, touchOf(i.jsonlPath), Date.now(), ttlOf(i.jsonlPath)).cold } : {};
1712
+ return { name: displayName(x), state, ...warmth, job: wizards.get(x)?.description || (tagOfKey(x) ? "" : "steward") };
1713
+ })),
1477
1714
  seen: createSeenRing(loadJsonMap(cfg.wrc.mirror.noticeSeenFile, gcSeen), (t) => m.sessionInfo(t)?.sessionId ?? ""),
1478
1715
  cwdOf: (t) => ((c) => c.runningCwd || c.defaultCwd)(m.getCwd(t)),
1479
1716
  }));
@@ -1501,7 +1738,7 @@ const main = async () => {
1501
1738
  const one = xs.length === 1;
1502
1739
  const mark = (i) => (one ? "" : "①②③④⑤⑥⑦⑧⑨⑩"[i] ?? `${i + 1}.`);
1503
1740
  const text = `有 ${xs.length} 件做完了等你看: ${xs.map(({ job, at }, i) => `${mark(i)}「${job.title}」(${agoZh(now - at)}交付)`).join(" ")} —— 回一句「好」就收, 或者说哪里不对。`;
1504
- notifyChat(chat, `${relayLabel(owner)}\n\n${text}`);
1741
+ postAs(owner, chat, text);
1505
1742
  recordPost({ target: owner, channel: chat, body: text });
1506
1743
  xs.forEach(({ job }) => jobs.mark(job.id, { nudge: { n: (job.nudge?.n ?? 0) + 1, lastAt: now } }));
1507
1744
  notices.post([owner], `nudged the human (daemon, in your name) about finished requests; their next line may be the verdict on one: ${xs.map(({ job }) => `${job.id} "${job.title}"`).join(" · ")}`);
@@ -1548,7 +1785,9 @@ const main = async () => {
1548
1785
  .slice()
1549
1786
  .sort((x, y) => y.targets.length - x.targets.length)
1550
1787
  .map((c) => ({ base: c.base, cwd: chatCwd(c.targets) })));
1551
- const named = applyChatNames(cfg, sourcePath, plan);
1788
+ if (!Object.keys(plan).length)
1789
+ return {};
1790
+ const { r: named } = guardCharter(() => applyChatNames(cfg, sourcePath, plan), (n) => Object.keys(n).length ? { what: `自动补的聊天名 (${Object.values(n).map((x) => `\`${x}\``).join(" ")})` } : undefined);
1552
1791
  for (const [base, name] of Object.entries(named)) {
1553
1792
  log.info({ base, name }, "chat: auto-named from workspace");
1554
1793
  // 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
@@ -1702,6 +1941,12 @@ const main = async () => {
1702
1941
  };
1703
1942
  }),
1704
1943
  chatNames: Object.fromEntries(listChatNames(cfg).map((c) => [c.base, c.name])),
1944
+ // 微信用户的发话人 id 就是它那个群的 chatId: 名字跟着群名走。
1945
+ humanNames: Object.fromEntries(wx.list().map((a) => [`human:${a.chatId}`, chatNameOf(cfg, `chat:${a.chatId}`)]).filter(([, n]) => n)),
1946
+ weixin: wx.list().map((a) => ({
1947
+ base: `chat:${a.chatId}`, state: a.state, lastInAt: a.lastInAt ?? 0, held: a.outbox.length,
1948
+ budget: a.ctx ? Math.max(0, cfg.weixin.sendCap - a.ctx.used) : 0, pausedUntil: a.pausedUntil ?? 0,
1949
+ })),
1705
1950
  };
1706
1951
  };
1707
1952
  // 一次采集失败 (某个 pane 的 tmux 抖了一下) 不等于注册表没了: 退回上一份好的,
@@ -1721,6 +1966,7 @@ const main = async () => {
1721
1966
  name: settleName(wizards, chatNameOf(cfg, target), target),
1722
1967
  address: peerAddress(cfg, self, target),
1723
1968
  description: wizards.get(target)?.description ?? "",
1969
+ ...(wizards.get(target)?.expertise ? { expertise: wizards.get(target).expertise } : {}),
1724
1970
  cwd: m.getCwd(target).runningCwd,
1725
1971
  });
1726
1972
  /** 自己的地址 = 全局名字, 与住在哪个群无关。 */
@@ -1939,6 +2185,7 @@ const main = async () => {
1939
2185
  const b = body;
1940
2186
  const asked = normalizeTag((b.name ?? "").toString());
1941
2187
  const description = (b.description ?? "").toString().trim();
2188
+ const expertise = (b.expertise ?? "").toString().trim();
1942
2189
  if ((b.name ?? "").toString().trim() && !asked) {
1943
2190
  json(res, 400, { ok: false, reason: "名字只能是 1-32 位字母/数字/`_`/`-`" });
1944
2191
  return;
@@ -1947,6 +2194,9 @@ const main = async () => {
1947
2194
  const name = asked ? wizards.rename(self, asked) : before;
1948
2195
  if (description)
1949
2196
  wizards.upsert(self, { description });
2197
+ // 专长: 传 "-" 清掉; 它不进名册变动通知 (只在名册 / 候选行里读)。
2198
+ if (expertise)
2199
+ wizards.upsert(self, { expertise: expertise === "-" ? undefined : expertise });
1950
2200
  const me = identityOf(self, self);
1951
2201
  // 职责是别人决定"该不该找你"的依据, 改了就得让同群的知道 —— 否则他们照着
1952
2202
  // 出生快照里那句旧的 (或者空的) 职责派活。
@@ -1968,7 +2218,7 @@ const main = async () => {
1968
2218
  const q = (b.query ?? "").trim().toLowerCase();
1969
2219
  const chat = (b.chat ?? "").trim().toLowerCase();
1970
2220
  const cwd = (b.cwd ?? "").trim().toLowerCase();
1971
- return (w) => (!q || [w.name, w.address, w.description, w.target].some((f) => (f ?? "").toLowerCase().includes(q))) &&
2221
+ return (w) => (!q || [w.name, w.address, w.description, w.expertise, w.target].some((f) => (f ?? "").toLowerCase().includes(q))) &&
1972
2222
  (!chat || (w.chat ?? "").toLowerCase() === chat || (w.target ?? "").toLowerCase().includes(chat)) &&
1973
2223
  (!cwd || (w.cwd ?? "").toLowerCase().includes(cwd)) &&
1974
2224
  (b.alive !== true || w.alive);
@@ -1994,7 +2244,7 @@ const main = async () => {
1994
2244
  const shown = ranked.slice(0, limit).map((r) => ({
1995
2245
  ...r,
1996
2246
  turns: turnsLine(inflight, r.target),
1997
- duty: dutyLine(jobs.all(), r.target),
2247
+ duty: dutyLine(jobs.all(), r.target, (t) => `.${displayName(t)}`),
1998
2248
  model: r.model || (r.jsonlPath ? lastModel(r.jsonlPath) : ""),
1999
2249
  summary: r.jsonlPath ? lastExchange(r.jsonlPath, 80, warm) : "",
2000
2250
  contextTokens: r.jsonlPath ? lastContextTokens(r.jsonlPath) : 0,
@@ -2048,7 +2298,7 @@ const main = async () => {
2048
2298
  return;
2049
2299
  }
2050
2300
  const limit = Math.min(Math.max(Number(b.limit ?? 5) || 5, 1), 20);
2051
- const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, await routeRowsOf(self)).slice(0, limit);
2301
+ const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, await routeRowsOf(self), { now: Date.now(), ttlMs: cfg.wrc.mirror.keepalive.ttlSec * 1000 }).slice(0, limit);
2052
2302
  json(res, 200, { ok: true, text: renderCandidates(task, cands, Date.now(), cfg.wrc.mirror.keepalive.ttlSec * 1000, homedir()) });
2053
2303
  });
2054
2304
  // 派活收成一步 (见 route.decide): 查候选 → 过成本门控 → 转给已有的或按档白板 spawn →
@@ -2075,7 +2325,7 @@ const main = async () => {
2075
2325
  // 不是别人开着的工单里的队员 —— 点名 (`to`) 不受此限。
2076
2326
  const inOthersJob = (t) => jobs.openOf(baseOfKey(t)).some((j) => j.owner !== self && j.members.some((mm) => mm.target === t));
2077
2327
  const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target) && !inOthersJob(r.target));
2078
- const cands = rankCandidates(task, m.getCwd(self).runningCwd, pool).slice(0, 5);
2328
+ const cands = rankCandidates(task, m.getCwd(self).runningCwd, pool, { now, ttlMs }).slice(0, 5);
2079
2329
  let d = to ? { kind: "existing", why: `你点名了 ${to}` }
2080
2330
  : b.spawn ? { kind: "spawn", why: "你要了新的" }
2081
2331
  : lead ? { kind: "spawn", why: "复杂活: 白板起一个 hard 档的 lead, 由它组队" }
@@ -2106,7 +2356,7 @@ const main = async () => {
2106
2356
  const settleRoot = (ok) => { if (rootId && !ok)
2107
2357
  jobs.close(rootId, "派活没投成"); };
2108
2358
  const withRoot = (body) => rootId
2109
- ? { ...body, job: rootId, hint: "需求根单已开, 对人**别提单号**。lead 交付后根单仍开着, 等人验收: 人认可 → close_job(job) 归档并回收 lead; 人说不对 → tell_peer({name, re, job}) 给 lead 返工 (同一张单, 不开新单); 人说不要了 → close_job({job, as:\"cancel\"}); 先放着 → close_job({job, as:\"shelve\"}) (停提醒, 不关单)。" }
2359
+ ? { ...body, job: rootId, hint: "需求根单已开, 对人**别提单号**。lead 交付后根单仍开着, 等人验收: 人认可 → close_job(job) 归档; 人说不对 → tell_peer({name, re, job}) 给 lead 返工 (同一张单, 不开新单); 人说不要了 → close_job({job, as:\"cancel\"}); 先放着 → close_job({job, as:\"shelve\"}) (停提醒, 不关单)。" }
2110
2360
  : body;
2111
2361
  if (d.kind === "existing") {
2112
2362
  const name = to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "");
@@ -2123,13 +2373,13 @@ const main = async () => {
2123
2373
  wizards.upsert(r.target, { lead: true });
2124
2374
  if (rootId && t.status === 200)
2125
2375
  jobs.attach(rootId, { target: r.target, task: "", spawned: false, role: "lead" });
2126
- // 自动选中的被冷门控退回 (decide 用名册的活动时刻, 门控用 transcript 的, 口径差一点): 改走 spawn。
2127
- if (!(t.body.gated === "cold" && !to)) {
2376
+ // 自动选中的被冷门控 (decide 用名册的活动时刻, 门控用 transcript 的, 口径差一点) 或方向闸 (它归别人管 / 是借来的专家) 退回: 改走 spawn; 点名的不改。
2377
+ if (!(!to && (t.body.gated === "cold" || t.body.gated === "direction"))) {
2128
2378
  settleRoot(t.status === 200);
2129
2379
  json(res, t.status, withRoot({ ...t.body, decision: "existing", reason: d.why, ...(shown.length ? { candidates: shown } : {}) }));
2130
2380
  return;
2131
2381
  }
2132
- d = { kind: "spawn", why: `${d.why}; 但它被冷门控退回 (${String(t.body.reason)}) —— 改为白板 spawn` };
2382
+ d = { kind: "spawn", why: `${d.why}; 但它被${t.body.gated === "direction" ? "方向闸" : "冷门控"}退回 (${String(t.body.reason)}) —— 改为白板 spawn` };
2133
2383
  }
2134
2384
  const name = (b.name ?? "").toString().trim();
2135
2385
  const description = (b.description ?? "").toString().trim();
@@ -2148,7 +2398,7 @@ const main = async () => {
2148
2398
  // 宪章里只有层级规矩, 打法随信封带 (宪章每轮都在上下文里, 打法只这一件活用得上)。
2149
2399
  const t = await tell(String(born.body.name), task, lead ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "");
2150
2400
  settleRoot(t.status === 200);
2151
- // 为这张根单生出来的: 归档 close_job 时要回收它 (tell 记的是 spawned:false, 再 attach 一次补上; lead 角色也在这里记)。
2401
+ // 为这张根单生出来的 (tell 记的是 spawned:false, 再 attach 一次补上; lead 角色也在这里记)。
2152
2402
  if (rootId && t.status === 200)
2153
2403
  jobs.attach(rootId, { target: String(born.body.target), task: "", spawned: true, role: "lead" });
2154
2404
  json(res, t.status, withRoot({
@@ -2243,6 +2493,11 @@ const main = async () => {
2243
2493
  return { status: jc.status, body: { ok: false, reason: jc.reason } };
2244
2494
  }
2245
2495
  }
2496
+ // 带 task 派下去 = 派活: 同样过方向闸, 不论带不带 job (新生的目标还不在任何单里, 实际只有 1 / 链深两条会拦)。
2497
+ const dr = (b.task ?? "").toString().trim() ? checkDirection({ self, target: "", kind: "task", re: false, jobId, jobs: jobs.all(), chain: receipts.ancestorsOf(self, parentKOf(self)), nameOf: displayName }) : undefined;
2498
+ if (dr) {
2499
+ return { status: 409, body: { ok: false, gated: "direction", reason: dr } };
2500
+ }
2246
2501
  // `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
2247
2502
  // 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
2248
2503
  // 逼调用方每次自己说。
@@ -2275,16 +2530,28 @@ const main = async () => {
2275
2530
  return { status: 409, body: { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` } };
2276
2531
  }
2277
2532
  const alivePeers = detached ? [] : await m.peers(self);
2278
- // 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
2279
- // close_job 整批回收一个工单的临时分身。只数**本聊天里**活着的 ——
2533
+ // 预算。撞到上限由守护进程腾位, 不叫调用方去收: 最久没动的闲分身只关 pane、绑定留着
2534
+ // (同 maxPanes 的 reap), 再找它 `--resume` 带全上下文复活。忙的、停在审批上的、手上
2535
+ // 还有没落定的活的不动; 全都动不了才拒。只数**本聊天里**活着的 ——
2280
2536
  // 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
2281
- const aliveKids = childrenOf(wizards.all(), self).filter((k) => alivePeers.some((pp) => pp.target === k.target && pp.paneAlive));
2282
- if (!detached && aliveKids.length >= cfg.wrc.mirror.cloneMax) {
2283
- return { status: 429, body: {
2284
- ok: false,
2285
- reason: `你名下已经有 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) —— 先 stop_wizard 收掉干完活的那些, 或者 close_job 整批回收一个工单`,
2286
- clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
2287
- } };
2537
+ const aliveKids = alivePeers.filter((pp) => pp.paneAlive && childrenOf(wizards.all(), self).some((k) => k.target === pp.target));
2538
+ const over = detached ? 0 : aliveKids.length - cfg.wrc.mirror.cloneMax + 1;
2539
+ if (over > 0) {
2540
+ const parked = new Set(listPending().map((p) => p.meta.sessionId).filter(Boolean));
2541
+ const owing = new Set(receipts.states().map((x) => x.to));
2542
+ const idle = aliveKids
2543
+ .filter((pp) => !pp.busy && !owing.has(pp.target) && !parked.has(m.sessionInfo(pp.target)?.sessionId ?? ""))
2544
+ .sort((x, y) => x.lastActivity - y.lastActivity)
2545
+ .slice(0, over);
2546
+ const slept = (await Promise.all(idle.map((pp) => m.sleepPane(pp.target)))).filter((r) => r.ok).length;
2547
+ if (slept < over) {
2548
+ return { status: 429, body: {
2549
+ ok: false,
2550
+ reason: `你名下 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) 都在干活, 腾不出位置 —— 等其中一个交回结论再分`,
2551
+ clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
2552
+ } };
2553
+ }
2554
+ log.info({ self, slept: idle.map((pp) => pp.target) }, "clone cap: idle clones put to sleep");
2288
2555
  }
2289
2556
  const wantChat = (b.chat ?? "").toString().trim();
2290
2557
  const base = wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self);
@@ -2598,7 +2865,7 @@ const main = async () => {
2598
2865
  title,
2599
2866
  ...(parentJob ? { parent: parentJob.id } : {}),
2600
2867
  memberMax: JOB_MEMBER_MAX,
2601
- hint: "把这个 id 传给 spawn_wizard / clone_wizard / tell_peer 的 `job` 参数, 它们就归到这个工单名下 (一律私聊, 不进群); 活干完调 close_job 留档并回收临时分身, 给人的结论写在你自己这一轮的最终回复里。",
2868
+ hint: "把这个 id 传给 spawn_wizard / clone_wizard / tell_peer 的 `job` 参数, 它们就归到这个工单名下 (一律私聊, 不进群); 活干完调 close_job 留档, 给人的结论写在你自己这一轮的最终回复里。",
2602
2869
  });
2603
2870
  });
2604
2871
  http.register("POST /jobs/close", async (req, res) => {
@@ -2639,22 +2906,18 @@ const main = async () => {
2639
2906
  json(res, 200, { ok: true, job: id, [how === "shelve" ? "shelved" : "resumed"]: true });
2640
2907
  return;
2641
2908
  }
2642
- // 关一张单连它还开着的后代单一起关 (深的先关): 否则子 lead 的分身留在原地、账本上挂着没人收的单。
2643
- // 回收只针对**为这个工单生出来的**分身: 被拉来帮忙的长期 wizard 不该因为一次活结束就被杀掉。
2644
- // 调用方自己也不收 —— 那会在这次工具调用里把自己干掉。
2645
- const recycle = b.stop !== false;
2909
+ // 关一张单连它还开着的后代单一起关 (深的先关): 否则账本上挂着没人收的子单。
2910
+ // 收工只结账、留档, 不碰成员的 pane: 分身的去留与工单无关, pane 多了由 maxPanes / cloneMax 自己收
2911
+ // (只关 pane、绑定留着)。老 MCP 进程还会带 `stop`, 收下不用。
2646
2912
  const openKids = (j) => jobChildren(jobs.all(), j.id).filter((c) => c.status === "open").flatMap((c) => [...openKids(c), c]);
2647
2913
  const cascade = openKids(job);
2648
2914
  const closeOne = async (j, summary, mark) => {
2649
- const victims = recycle ? j.members.filter((mm) => mm.spawned && mm.target !== self) : [];
2650
- // 收掉之前把它们手上没落定的活记成 canceled: 不然发起者收工之后还会收到一串 dead 回执。
2651
- victims.forEach((mm) => receipts.cancel(mm.target, j.owner));
2652
- // 留档要的 sessionId 得在回收之前取: killPane 连绑定一起删。
2653
2915
  const sids = new Map([j.owner, ...j.members.map((mm) => mm.target)].map((t) => [t, m.sessionInfo(t)?.sessionId ?? ""]));
2654
- const killed = await victims.reduce(async (acc, mm) => (await acc) + ((await m.killPane(mm.target)).ok ? 1 : 0), Promise.resolve(0));
2655
- // 取消: 还没落定、又不是为这张单生的成员 (长住 wizard) 不杀, 但它们名下这张单派的活记成 canceled, 发起者不会再收到一串回执。
2916
+ // 取消: 还没落定的成员手上这张单派的活记成 canceled, 发起者不会再收到一串回执。
2656
2917
  if (how === "cancel")
2657
- j.members.filter((mm) => !mm.outcome && !victims.includes(mm)).forEach((mm) => receipts.cancel(mm.target, j.owner, (x) => x.from === j.owner));
2918
+ j.members.filter((mm) => !mm.outcome).forEach((mm) => receipts.cancel(mm.target, j.owner, (x) => x.from === j.owner));
2919
+ // 这张单名下还排着没投的 (after 停放) 也不投了。
2920
+ receipts.cancelHeld(j.id, j.owner);
2658
2921
  const closed = jobs.close(j.id, summary, mark);
2659
2922
  ledger.closeJob(j.id);
2660
2923
  // 收工结论留档到开单者名下 (与交接简报同一份 jsonl); 空工单 (没人、没结论) 不记。
@@ -2663,13 +2926,12 @@ const main = async () => {
2663
2926
  if (!appendEpisode(episodePath(cfg.daemon.stateDir, ep.name), ep))
2664
2927
  log.warn({ job: j.id, name: ep.name }, "job episode not archived");
2665
2928
  }
2666
- return { members: closed.members.length, killed, kept: victims.length - killed };
2929
+ return { members: closed.members.length };
2667
2930
  };
2668
2931
  const rs = await [...cascade.map((c) => [c, `随上级单 ${id} 收工`]), [job, how === "cancel" ? `取消: ${(b.summary ?? "").toString()}` : (b.summary ?? "").toString()]]
2669
2932
  // 根单收工: accept = G3 验收过闸 (人认可), cancel = 取消; 非根单没有闸。
2670
2933
  .reduce(async (acc, [j, sum]) => [...(await acc), await closeOne(j, sum, j !== job ? undefined : how === "cancel" ? { end: "cancel" } : job.kind === "req" ? { gate: { g3: Date.now() }, end: "accept" } : undefined)], Promise.resolve([]));
2671
- const sum = (f) => rs.reduce((n, r) => n + f(r), 0);
2672
- json(res, 200, { ok: true, job: id, members: rs[rs.length - 1].members, recycled: sum((r) => r.killed), kept: sum((r) => r.kept), ...(cascade.length ? { cascaded: cascade.map((c) => c.id) } : {}) });
2934
+ json(res, 200, { ok: true, job: id, members: rs[rs.length - 1].members, ...(cascade.length ? { cascaded: cascade.map((c) => c.id) } : {}) });
2673
2935
  });
2674
2936
  // 挂起事项全表 (见 pending-items.ts): 你派出去、还没了结的事。`drop` 显式消项;
2675
2937
  // `name` 看别人的 (只读)。读过全表就算刚给过一次: 自动的全表提醒从此刻重新计。
@@ -2861,7 +3123,27 @@ const main = async () => {
2861
3123
  usageMemo = { at: Date.now(), u: { today: fold(r.today), week: fold(r.week) } };
2862
3124
  return usageMemo.u;
2863
3125
  };
2864
- const configEvidence = (p) => tierEvidence(cfg.models, p, tierUsage, (tier) => wizards.all().filter((w) => w.tier === tier).length, { tokens: fmtTokens, cost: fmtCost });
3126
+ /** chatPolicy 的依据: 各聊天名 ↔ principal 与它此刻的顶层模式 —— 键落盘是 principal, 人和模型认的是名字。 */
3127
+ const chatPolicyEvidence = (p) => {
3128
+ if (p[0] !== "chatPolicy")
3129
+ return [];
3130
+ const named = listChatNames(cfg);
3131
+ const line = (b) => `${named.filter((c) => c.base === b).map((c) => c.name).join(" / ") || "(未命名)"} = ${b} · 顶层模式 ${chatPolicyOf(cfg, b).topOnly ? "开" : "关"}`;
3132
+ // 查某一个聊天: 只报它; 查整张表: 报写过策略的, 其余命名聊天只报个数。
3133
+ if (p[1])
3134
+ return [line(p[1])];
3135
+ const set = Object.keys(cfg.chatPolicy);
3136
+ const rest = named.filter((c) => !set.includes(c.base)).length;
3137
+ return [
3138
+ "键写聊天名或 principal 都行, 落盘换成 principal:",
3139
+ ...set.map(line),
3140
+ ...(rest ? [`其余 ${rest} 个已命名聊天没写, 按默认 (顶层模式关) —— 查某个: config_get({path:"chatPolicy.<聊天名>"})`] : []),
3141
+ ];
3142
+ };
3143
+ const configEvidence = (p) => [
3144
+ ...tierEvidence(cfg.models, p, tierUsage, (tier) => wizards.all().filter((w) => w.tier === tier).length, { tokens: fmtTokens, cost: fmtCost }),
3145
+ ...chatPolicyEvidence(p),
3146
+ ];
2865
3147
  // ── 改配置会不会动到宪章 ─────────────────────────────────────────
2866
3148
  // 宪章是 --append-system-prompt 压进去的, 随进程终身: 改了进宪章的配置, 已在跑的 wizard
2867
3149
  // 看不见, 要 handoff 重开 (重新渲染宪章) 才换上。不按字段清单判 —— 宪章读哪些配置随代码变,
@@ -2871,17 +3153,19 @@ const main = async () => {
2871
3153
  const x = charterSections(a), y = charterSections(b);
2872
3154
  return [...new Set([...x.keys(), ...y.keys()])].filter((k) => x.get(k) !== y.get(k));
2873
3155
  };
2874
- /** pane 活着的 wizard —— 宪章在效的只有它们; 冷的醒来时宪章重渲染, 自然是新的。一次 list-panes 答完。 */
2875
- const liveWizards = async () => {
3156
+ /** 宪章挂在 pane 上的 wizard —— 候选。同步取: 守卫要在写之前拍快照, 中间不能 await。 */
3157
+ const attachedWizards = () => wizards.all().map((w) => w.target).filter((t) => !!m.sessionInfo(t)?.tmuxPane);
3158
+ /** 其中 pane 活着的 —— 宪章在效的只有它们; 冷的醒来时宪章重渲染, 自然是新的。一次 list-panes 答完。 */
3159
+ const liveOf = async (targets) => {
2876
3160
  const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
2877
3161
  const live = new Set(snap.ok ? snap.stdout.split("\n").map((l) => l.trim()).filter(Boolean) : []);
2878
- return wizards.all().map((w) => w.target).filter((t) => live.has(m.sessionInfo(t)?.tmuxPane ?? ""));
3162
+ return targets.filter((t) => live.has(m.sessionInfo(t)?.tmuxPane ?? ""));
2879
3163
  };
2880
3164
  /** 这些 wizard 此刻的宪章正文。 */
2881
3165
  const charterSnapshot = (targets) => new Map(targets.map((t) => [t, charterText(t, charterOptsOf(t, m.getCwd(t).runningCwd || undefined), true)]));
2882
3166
  /** 把 `next` 那一节换进活的 cfg 渲染一遍再换回 —— 同步完成, 中间没有 await, 别的模块看不见这一瞬。
2883
3167
  * reload 项落盘后活值不变, 只有这样才算得出它 reload 之后的宪章。 */
2884
- const charterUnder = (pl, targets) => {
3168
+ const charterUnder = (pl) => (targets) => {
2885
3169
  const live = cfg;
2886
3170
  const key = pl.path[0];
2887
3171
  const saved = live[key];
@@ -2893,26 +3177,48 @@ const main = async () => {
2893
3177
  live[key] = saved;
2894
3178
  }
2895
3179
  };
2896
- const charterImpact = (before, pl) => {
2897
- const after = charterUnder(pl, [...before.keys()]);
2898
- return [...before].flatMap(([t, a]) => { const secs = changedSections(a, after.get(t) ?? a); return secs.length ? [{ target: t, sections: secs }] : []; });
2899
- };
2900
- /** 受影响的 wizard 各挂一条提醒 (随它下一条消息到); 给调用方的那段话。调用方自己受影响就直说要它 handoff。 */
2901
- const announceCharterImpact = (pl, hits, self) => {
3180
+ const charterImpact = (before, after) => [...before].flatMap(([t, a]) => { const secs = changedSections(a, after.get(t) ?? a); return secs.length ? [{ target: t, sections: secs }] : []; });
3181
+ /** 受影响的 wizard 各挂一条提醒 (随它下一条消息到); 给调用方的那段话。调用方自己受影响就直说要它 handoff。
3182
+ * 人发起的改动多一句「交接之前还是旧规矩」: 管家要知道它此刻向人汇报的方式还没变, 别让人以为已经生效。 */
3183
+ const announceCharterImpact = (c, hits) => {
2902
3184
  if (!hits.length)
2903
3185
  return "";
2904
- const when = pl.apply === "reload" ? "等守护进程 reload 之后再" : "";
2905
- const ask = `${when}调 \`handoff({brief})\` 原地重开 (手上这件告一段落就交; 宪章在 handoff 时重新生成)`;
2906
- const others = hits.filter((h) => h.target !== self);
2907
- others.forEach((h) => notices.post([h.target], `\`config_set ${pl.path.join(".")}\` 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}`));
2908
- const mine = hits.find((h) => h.target === self);
3186
+ const ask = `${c.reload ? "等守护进程 reload 之后再" : ""}调 \`handoff({brief})\` 原地重开 (手上这件告一段落就交; 宪章在 handoff 时重新生成)`;
3187
+ const lag = (t) => !c.byHuman ? "" : tagOfKey(t)
3188
+ ? " —— 这是人发起的改动: 交接之前你仍按旧规矩做事, handoff 之后才按新的"
3189
+ : " —— 这是人发起的改动: 交接之前你仍按旧规矩向人汇报, handoff 之后才会按新规矩汇报; 回人时说一句「交接后生效」";
3190
+ const others = hits.filter((h) => h.target !== c.self);
3191
+ others.forEach((h) => notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}${lag(h.target)}`));
3192
+ const mine = hits.find((h) => h.target === c.self);
2909
3193
  return [
2910
3194
  "",
2911
3195
  `⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${hits.length} 个: ${hits.map((h) => displayName(h.target)).join(" ")}`,
2912
3196
  ...(others.length ? [`已给其余 ${others.length} 个各挂一条提醒 (随它们下一条消息到), 让它们${ask}`] : []),
2913
- ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}`] : []),
3197
+ ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}${lag(mine.target)}`] : []),
2914
3198
  ].join("\n");
2915
3199
  };
3200
+ // 所有会动宪章的写入 (config_set、人 `/name`、name_chat、自动补名) 共用这一个守卫: 写前写后各渲染一遍
3201
+ // 挂在 pane 上的 wizard 的宪章, 变了的、pane 还活着的挂提醒。快照到写完之间不能有 await —— 别人的写入
3202
+ // 插进来, 前后就对不上了。已经写了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
3203
+ guardCharter = (write, change) => {
3204
+ const targets = attachedWizards();
3205
+ const before = charterSnapshot(targets);
3206
+ const r = write();
3207
+ const c = change(r);
3208
+ if (!c)
3209
+ return { r, note: Promise.resolve("") };
3210
+ const fail = (e) => {
3211
+ log.warn({ err: errText(e), what: c.what }, "宪章影响比对失败");
3212
+ return `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的内容, 受影响的 wizard 要 handoff 才换上)`;
3213
+ };
3214
+ try {
3215
+ const hits = charterImpact(before, (c.after ?? charterSnapshot)(targets));
3216
+ return { r, note: liveOf(hits.map((h) => h.target)).then((live) => announceCharterImpact(c, hits.filter((h) => live.includes(h.target)))).catch(fail) };
3217
+ }
3218
+ catch (e) {
3219
+ return { r, note: Promise.resolve(fail(e)) };
3220
+ }
3221
+ };
2916
3222
  // POST /config/get —— 新 MCP 带 `path` (可空) 拿渲染好的文本; 老 MCP 带 `key` 拿 {key, value}。
2917
3223
  http.register("POST /config/get", async (req, res) => {
2918
3224
  const body = (await readBody(req));
@@ -2921,7 +3227,10 @@ const main = async () => {
2921
3227
  json(res, r.ok ? 200 : 400, r);
2922
3228
  return;
2923
3229
  }
2924
- const r = configGet(cfg, sourcePath, body.path, configEvidence);
3230
+ // chatPolicy.<聊天名>… 换成它指向的 principal 再查 (同 config_set 的 pinChatKeys); 认不出的原样查。
3231
+ const p = parsePath(body.path);
3232
+ const base = p[0] === "chatPolicy" && p[1] ? chatBaseOf(cfg, p[1]) : "";
3233
+ const r = configGet(cfg, sourcePath, base ? ["chatPolicy", base, ...p.slice(2)].join(".") : body.path, configEvidence);
2925
3234
  json(res, r.ok ? 200 : 400, r);
2926
3235
  });
2927
3236
  // chatPolicy 的键一律落成 base principal: 聊天名会改, 拿它当键一改名策略就悄悄失效。写名字照收,
@@ -2974,26 +3283,21 @@ const main = async () => {
2974
3283
  return;
2975
3284
  }
2976
3285
  // 落盘时重算一遍: 等卡期间文件可能被别人改过, 按那一刻的文件打补丁、再校验。
2977
- // 宪章快照取在落盘之前: hot 项一 applyHot, 活的 cfg 就已经是新的了。
3286
+ // 宪章快照取在落盘之前 (守卫里): hot 项一 applyHot, 活的 cfg 就已经是新的了。
2978
3287
  // 不用 resolveSelf: 它最后回落到 defaultChat, 会把认不出的调用方当成那个群的管家。
2979
3288
  const caller = (body.sessionId?.trim() && m.targetForSession(body.sessionId.trim())) || (body.tmuxPane?.trim() && m.targetForPane(body.tmuxPane.trim())) || undefined;
3289
+ // 人发起 = 调用方这一轮的链头是人 (人在群里 / 单聊里叫它改的)。
3290
+ const byHuman = !!caller && !!askerOf(caller)?.who.startsWith("user:");
2980
3291
  const commit = async () => {
2981
- const targets = await liveWizards();
2982
- // 从这里到 applyHot 之间不能有 await: 快照与落盘之间别人的 config_set 插进来, 前后就对不上了。
2983
- const now = planSet(cfg, sourcePath, ask);
2984
- if (!now.ok)
3292
+ const { r: now, note } = guardCharter(() => {
3293
+ const now = planSet(cfg, sourcePath, ask);
3294
+ if (!now.ok)
3295
+ return now;
3296
+ patchJsonc(sourcePath, [{ path: now.jsonPath, value: now.value }]);
3297
+ applyHot(cfg, now);
2985
3298
  return now;
2986
- const before = charterSnapshot(targets);
2987
- patchJsonc(sourcePath, [{ path: now.jsonPath, value: now.value }]);
2988
- applyHot(cfg, now);
2989
- // 已经落盘了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
2990
- try {
2991
- return { ...now, charter: announceCharterImpact(now, charterImpact(before, now), caller) };
2992
- }
2993
- catch (e) {
2994
- log.warn({ err: errText(e), path: now.path.join(".") }, "config_set: 宪章影响比对失败");
2995
- return { ...now, charter: `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的配置, 受影响的 wizard 要 handoff 才换上)` };
2996
- }
3299
+ }, (now) => now.ok ? { what: `\`config_set ${now.path.join(".")}\``, self: caller, byHuman, reload: now.apply === "reload", after: charterUnder(now) } : undefined);
3300
+ return now.ok ? { ...now, charter: await note } : now;
2997
3301
  };
2998
3302
  const done = (now) => `已写入${now.apply === "hot" ? ", 已生效" : "; 需 reload (`./cli/wezard.sh reload`) 才生效"}:\n${renderPlan(now)}${now.charter}`;
2999
3303
  if (pl.gate !== "card") {
@@ -3194,7 +3498,7 @@ const main = async () => {
3194
3498
  const reply = (m.replyToPeer(t, "", at, turn)?.text ?? "").trim();
3195
3499
  if (reply && (await m.idleNow(t))) {
3196
3500
  if (!isQuiet(reply))
3197
- notifyChat(base, withTagHeader(t, reply));
3501
+ notifyChat(base, withTagHeader(t, reply, undefined, base));
3198
3502
  log.info({ target: t, quiet: isQuiet(reply), len: reply.length }, "quiet task: 一轮结束");
3199
3503
  quietRelays.drop(turn);
3200
3504
  return;
@@ -3224,6 +3528,7 @@ const main = async () => {
3224
3528
  wizards.upsert(runner, {
3225
3529
  description: `定时任务 ${taskId} 的一次性执行体${why}`,
3226
3530
  parent: owner,
3531
+ voiceOf: owner,
3227
3532
  bornAt: Date.now(),
3228
3533
  });
3229
3534
  const runnerName = wizards.rename(runner, `${ownerName}-task-${taskId.slice(0, 4)}`);
@@ -3366,6 +3671,7 @@ const main = async () => {
3366
3671
  steward?.stop();
3367
3672
  tasks.stop();
3368
3673
  netWatch.stop();
3674
+ await wx.stop().catch(() => undefined);
3369
3675
  // 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。
3370
3676
  log.info(drainForReload(), "pending drained for reload");
3371
3677
  // Hard-exit watchdog: http.close() blocks until every in-flight connection