wezard 2.4.1 → 2.5.1

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 (102) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +113 -57
  3. package/cli/wezard.sh +6 -1
  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 +11 -7
  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 +5 -6
  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 +57 -14
  26. package/dist/daemon/inbound.js.map +1 -1
  27. package/dist/daemon/index.js +297 -65
  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 +109 -92
  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 +35 -15
  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 +29 -23
  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 +145 -3
  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/session-scan.js +3 -3
  54. package/dist/daemon/session-scan.js.map +1 -1
  55. package/dist/daemon/spawn-tmux.js +170 -62
  56. package/dist/daemon/spawn-tmux.js.map +1 -1
  57. package/dist/daemon/steward-guard.js +48 -7
  58. package/dist/daemon/steward-guard.js.map +1 -1
  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 +47 -19
  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 +59 -28
  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 +35 -10
  78. package/dist/shared/config.js.map +1 -1
  79. package/dist/shared/exec-path.js +12 -0
  80. package/dist/shared/exec-path.js.map +1 -1
  81. package/dist/shared/json-map-store.js +1 -0
  82. package/dist/shared/json-map-store.js.map +1 -1
  83. package/dist/shared/reminder.js +1 -0
  84. package/dist/shared/reminder.js.map +1 -1
  85. package/dist/shared/role-render.js +4 -3
  86. package/dist/shared/role-render.js.map +1 -1
  87. package/dist/shared/role-view.js +97 -62
  88. package/dist/shared/role-view.js.map +1 -1
  89. package/dist/shared/seen-log.js +26 -0
  90. package/dist/shared/seen-log.js.map +1 -0
  91. package/dist/shared/session-label.js +84 -31
  92. package/dist/shared/session-label.js.map +1 -1
  93. package/dist/shared/turn-state.js +1 -1
  94. package/dist/shared/turn-state.js.map +1 -1
  95. package/dist/shared/world.js.map +1 -1
  96. package/dist/shared/wx-text.js +101 -0
  97. package/dist/shared/wx-text.js.map +1 -0
  98. package/dist/svr/index.js +5 -2
  99. package/dist/svr/index.js.map +1 -1
  100. package/package.json +6 -2
  101. package/web/chat.css +26 -5
  102. package/web/chat.js +128 -63
@@ -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,14 +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";
43
48
  import { createTopOnlyNudge, gcNudge, policyKeyOf, renderNudge } from "./top-only-nudge.js";
44
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";
45
50
  import { bindNoticeBox, createNoticeBox, createSeenRing, gcSeen, chatAudience, hears } from "./notices.js";
46
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";
47
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";
48
54
  import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, lastContextTokens, lastExchange, lastModel, openingTurn, replyClosedBefore, talkTurns, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
49
55
  import { keepalivePingSigs } from "../shared/keepalive.js";
@@ -54,7 +60,7 @@ import { renderInFlight } from "../shared/turn-state.js";
54
60
  import { startIdleNudge } from "./idle-nudge.js";
55
61
  import { renderReminder } from "../shared/reminder.js";
56
62
  import { createHandoffs, handedOff, handingOff } from "./handoff.js";
57
- import { coldGateOf, decide, rankCandidates, renderCandidates, tierFor, wakeNoteOf } from "./route.js";
63
+ import { coldGateOf, decide, rankCandidates, renderCandidates, tierFor, wakeCostOf, wakeNoteOf } from "./route.js";
58
64
  import { parseWhen, renderChatLog, UNKNOWN_HUMAN } from "./chat-log.js";
59
65
  import { audienceOf } from "../shared/role-view.js";
60
66
  import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
@@ -101,11 +107,44 @@ const main = async () => {
101
107
  // 切网 (换 WiFi / VPN 起停) 后原地重建 WS, 等价于自动做了一次 reload 的
102
108
  // 联通性部分 — 但保留全部内存态 (graph 运行、pending 长轮询、镜像绑定),
103
109
  // 也不依赖 launchd/systemd 的 respawn 策略 (nohup fallback 没有 supervisor)。
104
- 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));
105
143
  // Wrap replyStream / replyStreamWithCard before any module sends —
106
144
  // last-response tracker enables inbound's `quote` dedup of bot self-replies,
107
145
  // and its chat-gate drops header-only pushes (empty messages) daemon-wide.
108
- installResponseTracker(ws.client, log.child({ mod: "chat-gate" }));
146
+ // 微信出站不走企微长连接, 不必等它连上。
147
+ installResponseTracker(ws.client, log.child({ mod: "chat-gate" }), (c) => wx.owns(c));
109
148
  const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
110
149
  // 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
111
150
  // (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
@@ -132,6 +171,7 @@ const main = async () => {
132
171
  return t && known(t) ? t : undefined;
133
172
  },
134
173
  nameOf: (t) => settleName(wizardStore(), chatNameOf(cfg, t), t),
174
+ voiceOf: (t) => wizardStore()?.get(t)?.voiceOf,
135
175
  });
136
176
  // 人话也过交接的闸 (见 handoff.ts): 交接期间说的话进新会话, 不被旧会话带走、也不在
137
177
  // 重开的空档里 resume 回旧 sid。
@@ -190,7 +230,26 @@ const main = async () => {
190
230
  http.register("GET /detail", makeDetailHandler(log.child({ mod: "detail" })));
191
231
  for (const [key, handler] of Object.entries(chatHandlers()))
192
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 }));
193
249
  installAskEventListener(ws.client, log.child({ mod: "ask" }));
250
+ // 入站监听 (inbound / approval / ask) 都装好了才开收: 首轮 getupdates 会立刻带回 reload 期间
251
+ // 人发的话 (含回菜单的数字), 游标随即前移 —— 早了就是永久丢。
252
+ wx.start();
194
253
  // 智能机器人 doc / smartsheet / contact MCP 桥接 — 总是注册路由, 失败让
195
254
  // 错误透传到上游 (Claude / curl)。requesterUserId 解析顺序:
196
255
  // 调用方 body.requesterUserId → defaultChat 里 user:<id> 部分 → 不传
@@ -598,6 +657,10 @@ const main = async () => {
598
657
  // 头不走 withTagHeader 的那些 (relay / notify / 工单) 正文里的 tag 在这里挂链;
599
658
  // 已挂过的 linkTags 认得出, 不会再套一层。
600
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;
601
664
  const notifyChat = (base, markdown) => {
602
665
  const chatId = chatIdOf(base);
603
666
  void ws.client
@@ -605,18 +668,20 @@ const main = async () => {
605
668
  .catch((e) => log.warn({ chatId, err: errText(e) }, "chat notify failed"));
606
669
  };
607
670
  // wizard 在群里的称呼, 只用在**正文**里。气泡的头照旧交给 withTagHeader ——
608
- // 那一段是路由信息 (`emoji #tag`), parseTagHeader 靠它反解, 群里引用一条气泡
671
+ // 那一段是路由信息 (`emoji.name:`), parseTagHeader 靠它反解, 群里引用一条气泡
609
672
  // 就能直接跟那个 wizard 说话; 把它换成名字会把这条通路弄断。
610
673
  const displayName = (t) => {
611
674
  const name = settleName(wizards, chatNameOf(cfg, t), t);
612
675
  return name ? `.${name}` : "默认 wizard";
613
676
  };
614
677
  // wizard 之间的往返默认是私聊, 不进群。只有发话方判断「该当着人说」的那一次
615
- // (send_peer public:true) 在公开频道里成一条气泡, 头写成 `.a → .b`, 方向一眼可读。
678
+ // (send_peer public:true) 在公开频道里成一条气泡, 头写成 `.a: .b`, 方向一眼可读。
616
679
  const RELAY_MAX = 1200;
617
- /** 一个 wizard 在群里的称呼: `emoji .name`, 挂它的 rolepage。名字全局唯一, 不再
680
+ /** 一个 wizard 在群里的称呼: `.name`, 挂它的 rolepage。名字全局唯一, 不再
618
681
  * 因为落在哪个群而换写法 —— 引用这一段就能跟它说话 (parseTagHeader 认得)。 */
619
- 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"));
620
685
  // 没开顶层模式的群里公开气泡多了 → 往那个群的管家 (群默认会话, target 就是 base principal) 信箱挂一行,
621
686
  // 让它问人要不要开顶层模式 (见 top-only-nudge.ts)。路径里写聊天名: 管家照抄就能 config_set。
622
687
  const topOnlyNudge = createTopOnlyNudge({
@@ -633,7 +698,7 @@ const main = async () => {
633
698
  const text = body.trim();
634
699
  if (!text || !channel)
635
700
  return;
636
- const head = `${relayLabel(from)} → ${relayLabel(to)}`;
701
+ const head = speakerHead(relayLabel(from), relayLabel(to));
637
702
  const clipped = text.length > RELAY_MAX ? `${text.slice(0, RELAY_MAX)}…` : text;
638
703
  // 头独占一行, 正文自成一个块 —— 只隔一个换行的话, markdown 会把正文首行
639
704
  // 当成头那一段的续行; 表格因此整张塌成一行带竖线的文字 (表格不能打断段落)。
@@ -656,7 +721,7 @@ const main = async () => {
656
721
  * read_chat; 认不出 = ""。 */
657
722
  const audienceName = (channel, asker) => {
658
723
  const id = audienceOf(channel, asker);
659
- return id === "human:" ? "" : id.slice("human:".length);
724
+ return id.startsWith("human:") ? id.slice("human:".length) : "";
660
725
  };
661
726
  // 挂起事项表 (见 pending-items.ts): 派活时记一行, 回执落定时由下面的 onOutcome 记账。
662
727
  const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc));
@@ -677,6 +742,12 @@ const main = async () => {
677
742
  nameOf: displayName,
678
743
  log,
679
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
+ },
680
751
  handingOff,
681
752
  handedOff,
682
753
  replyFor: (to, fromName, since, turn) => m.replyToPeer(to, fromName, since, turn),
@@ -896,6 +967,17 @@ const main = async () => {
896
967
  // 给**人**看。收件人就是地址 —— 聊天名或裸 principal, 没有「先订阅才收得到」
897
968
  // 这一步。头沿用 relay 那一套: 本群退化成寻常的 `emoji #tag`, 外群写成带聊天
898
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
+ };
899
981
  http.register("POST /notify", async (req, res) => {
900
982
  const { self, body } = await readPeerBody(req);
901
983
  if (!self) {
@@ -920,22 +1002,69 @@ const main = async () => {
920
1002
  return;
921
1003
  }
922
1004
  const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
923
- // 顶层模式: 只有顶层 wizard 对人说话。拦的是「这一轮在为同伴干活」的: 同伴私聊派来的活, 或
924
- // 回执轮但那件活是为上游同伴派的 (k 是 peer)。人说的、定时任务放的、旁支 (chain:false, 无 k)
925
- // 的回执轮都不拦; 管家只在自己的群里豁免。
926
- const env = openingOfSelf(self)?.env;
927
- const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
928
- const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
929
- if (muted.length && belowTop) {
930
- 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);
931
1008
  return;
932
1009
  }
933
1010
  const asker = askerOf(self);
934
1011
  for (const dest of dests) {
935
- notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
1012
+ postAs(self, dest, content);
936
1013
  recordPost({ target: self, channel: dest, body: content, ...(asker ? { asker } : {}) });
937
1014
  }
938
- 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 });
939
1068
  });
940
1069
  http.register("POST /peers/peek", async (req, res) => {
941
1070
  const { self, body } = await readPeerBody(req);
@@ -1066,9 +1195,29 @@ const main = async () => {
1066
1195
  const ttlOf = (p) => (cacheTtlSec(p) || cfg.wrc.mirror.keepalive.ttlSec) * 1000;
1067
1196
  // 被冷门控退回过的 `self\0target` → 到期时刻: 老 MCP 进程原样重发即视为 force。
1068
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
+ ]);
1069
1218
  /** tell_peer 的本体: 以 `self` 的名义说一句 —— HTTP 路由与 dispatch 共用。`extra` = 挂在信封后面的
1070
1219
  * 一段 (只进对方输入框, 不进 `text`: 公开时 text 会成群气泡)。 */
1071
- const tellPeerAs = async (self, body, extra = "") => {
1220
+ const tellPeerAs = async (self, body, extra = "", held) => {
1072
1221
  const text = (body.text ?? "").toString();
1073
1222
  if (!text.trim()) {
1074
1223
  return { status: 400, body: { ok: false, reason: "text required" } };
@@ -1084,7 +1233,8 @@ const main = async () => {
1084
1233
  const { target, foreign } = r;
1085
1234
  // 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
1086
1235
  const re = (body.re ?? "").toString().trim();
1087
- 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);
1088
1238
  // 续问只在那个工单还开着时沿用它: 收了工的工单不再收成员, 续问照样能发。
1089
1239
  // 没带工单的一句顶掉一份还没落定的工单活, 也算在那张工单上 (receipts.register 同理
1090
1240
  // 继承): 不然换个不带 job 的 tell_peer 就绕开了预算。
@@ -1098,11 +1248,40 @@ const main = async () => {
1098
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 ?? "";
1099
1249
  const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || (open?.deferred ? "" : isOpen(open?.job)) || (re && turn.reUnknown ? leadJob : "");
1100
1250
  if (jobId) {
1101
- const jc = checkJob(jobId, target, !!open?.need && open.turn === turn.turn);
1251
+ const jc = checkJob(jobId, target, (!!open?.need && open.turn === turn.turn) || !!held);
1102
1252
  if (!jc.ok) {
1103
1253
  return { status: jc.status, body: { ok: false, reason: jc.reason } };
1104
1254
  }
1105
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])));
1106
1285
  // 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
1107
1286
  // 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
1108
1287
  // 只有两端都是登记在册的 wizard 才能当着人说: 裸 target 冒充的发话方、forget
@@ -1154,6 +1333,25 @@ const main = async () => {
1154
1333
  return { status: 409, body: { ok: false, gated: "cold", reason: gate + (force === undefined ? " (你的 MCP 是旧版、没有 force 参数: 10 分钟内原样再发一次即视为 force)" : "") } };
1155
1334
  }
1156
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
+ }
1157
1355
  // 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
1158
1356
  const wasBusy = !(await m.idleNow(target));
1159
1357
  // urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
@@ -1192,7 +1390,7 @@ const main = async () => {
1192
1390
  // 是"放出去就不管了"的那种派活; fyi 一定不要。对方的信封据此改口, 不再让它收口。
1193
1391
  const wantReceipt = kind !== "fyi" && body.receipt !== false;
1194
1392
  const deadlineAt = deadlineOf(at, deadlineSec === undefined ? undefined : Number(deadlineSec));
1195
- const asker = askerOf(self);
1393
+ const asker = held ? held.asker : askerOf(self);
1196
1394
  const inj = await m.injectText(target, text, undefined, {
1197
1395
  from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
1198
1396
  channel,
@@ -1210,7 +1408,7 @@ const main = async () => {
1210
1408
  // 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
1211
1409
  // 父 k: 默认这件活算发话方此刻在答的那件的子活 (链式续回); `chain:false` = 旁支,
1212
1410
  // 回执照常回来, 但不挂住它给上游的交代 —— 只有发话方知道这件活是不是为上游派的。
1213
- const k = body.chain === false ? undefined : parentKOf(self);
1411
+ const k = held ? held.k : body.chain === false ? undefined : parentKOf(self);
1214
1412
  if (inj.ok && wantReceipt)
1215
1413
  receipts.register({ from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
1216
1414
  if (inj.ok && wantReceipt) {
@@ -1229,7 +1427,8 @@ const main = async () => {
1229
1427
  jobs.mark(jobId, { gate: { g2: Date.now() } });
1230
1428
  jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
1231
1429
  }
1232
- const spent = inj.ok && jobId ? jobs.spend(jobId) : undefined;
1430
+ // 停放时已记过预算, 放行不再算一次。
1431
+ const spent = inj.ok && jobId && !held ? jobs.spend(jobId) : undefined;
1233
1432
  if (inj.ok && isPublic)
1234
1433
  relayPeer(self, target, text, channel);
1235
1434
  // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
@@ -1344,10 +1543,12 @@ const main = async () => {
1344
1543
  });
1345
1544
  // 交接的「重开」一步: 走 /new (杀旧 pane、起新进程) 而不是往原 pane 注入 /clear。
1346
1545
  // /clear 只换会话不换进程 —— MCP 子进程、charter 都还是旧的, 新工具拿不到; 镜像
1347
- // 还得靠 sid 轮换探测才跟得上。模型 / CLI / cwd / keepalive 照旧沿用。
1546
+ // 还得靠 sid 轮换探测才跟得上。CLI / cwd / keepalive 照旧沿用; 模型也沿用, 管家除外 ——
1547
+ // 它换上此刻的管家档 (newSession 里定), 改了 models.steward 后交接一次就是让它生效的那一下。
1348
1548
  const restartFresh = (target) => {
1349
1549
  const info = m.sessionInfo(target);
1350
- 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 });
1351
1552
  };
1352
1553
  // POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
1353
1554
  // 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
@@ -1503,7 +1704,13 @@ const main = async () => {
1503
1704
  snapshot: (t) => (wizards.get(t)?.parent ? undefined : () => m.chatTargets(t)
1504
1705
  .filter((x) => x !== t && wizards.get(x)?.name)
1505
1706
  .filter((x) => ((p) => !tagOfKey(x) || (!p ? !!wizards.get(x)?.description : p === t || p === baseOfKey(t)))(wizards.get(x)?.parent))
1506
- .map((x) => ({ name: displayName(x), state: ((v) => (v === undefined ? "-" : v ? "idle" : "busy"))(m.idleVerdict(x)), job: wizards.get(x)?.description || (tagOfKey(x) ? "" : "steward") }))),
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
+ })),
1507
1714
  seen: createSeenRing(loadJsonMap(cfg.wrc.mirror.noticeSeenFile, gcSeen), (t) => m.sessionInfo(t)?.sessionId ?? ""),
1508
1715
  cwdOf: (t) => ((c) => c.runningCwd || c.defaultCwd)(m.getCwd(t)),
1509
1716
  }));
@@ -1531,7 +1738,7 @@ const main = async () => {
1531
1738
  const one = xs.length === 1;
1532
1739
  const mark = (i) => (one ? "" : "①②③④⑤⑥⑦⑧⑨⑩"[i] ?? `${i + 1}.`);
1533
1740
  const text = `有 ${xs.length} 件做完了等你看: ${xs.map(({ job, at }, i) => `${mark(i)}「${job.title}」(${agoZh(now - at)}交付)`).join(" ")} —— 回一句「好」就收, 或者说哪里不对。`;
1534
- notifyChat(chat, `${relayLabel(owner)}\n\n${text}`);
1741
+ postAs(owner, chat, text);
1535
1742
  recordPost({ target: owner, channel: chat, body: text });
1536
1743
  xs.forEach(({ job }) => jobs.mark(job.id, { nudge: { n: (job.nudge?.n ?? 0) + 1, lastAt: now } }));
1537
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(" · ")}`);
@@ -1734,6 +1941,12 @@ const main = async () => {
1734
1941
  };
1735
1942
  }),
1736
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
+ })),
1737
1950
  };
1738
1951
  };
1739
1952
  // 一次采集失败 (某个 pane 的 tmux 抖了一下) 不等于注册表没了: 退回上一份好的,
@@ -1753,6 +1966,7 @@ const main = async () => {
1753
1966
  name: settleName(wizards, chatNameOf(cfg, target), target),
1754
1967
  address: peerAddress(cfg, self, target),
1755
1968
  description: wizards.get(target)?.description ?? "",
1969
+ ...(wizards.get(target)?.expertise ? { expertise: wizards.get(target).expertise } : {}),
1756
1970
  cwd: m.getCwd(target).runningCwd,
1757
1971
  });
1758
1972
  /** 自己的地址 = 全局名字, 与住在哪个群无关。 */
@@ -1971,6 +2185,7 @@ const main = async () => {
1971
2185
  const b = body;
1972
2186
  const asked = normalizeTag((b.name ?? "").toString());
1973
2187
  const description = (b.description ?? "").toString().trim();
2188
+ const expertise = (b.expertise ?? "").toString().trim();
1974
2189
  if ((b.name ?? "").toString().trim() && !asked) {
1975
2190
  json(res, 400, { ok: false, reason: "名字只能是 1-32 位字母/数字/`_`/`-`" });
1976
2191
  return;
@@ -1979,6 +2194,9 @@ const main = async () => {
1979
2194
  const name = asked ? wizards.rename(self, asked) : before;
1980
2195
  if (description)
1981
2196
  wizards.upsert(self, { description });
2197
+ // 专长: 传 "-" 清掉; 它不进名册变动通知 (只在名册 / 候选行里读)。
2198
+ if (expertise)
2199
+ wizards.upsert(self, { expertise: expertise === "-" ? undefined : expertise });
1982
2200
  const me = identityOf(self, self);
1983
2201
  // 职责是别人决定"该不该找你"的依据, 改了就得让同群的知道 —— 否则他们照着
1984
2202
  // 出生快照里那句旧的 (或者空的) 职责派活。
@@ -2000,7 +2218,7 @@ const main = async () => {
2000
2218
  const q = (b.query ?? "").trim().toLowerCase();
2001
2219
  const chat = (b.chat ?? "").trim().toLowerCase();
2002
2220
  const cwd = (b.cwd ?? "").trim().toLowerCase();
2003
- 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))) &&
2004
2222
  (!chat || (w.chat ?? "").toLowerCase() === chat || (w.target ?? "").toLowerCase().includes(chat)) &&
2005
2223
  (!cwd || (w.cwd ?? "").toLowerCase().includes(cwd)) &&
2006
2224
  (b.alive !== true || w.alive);
@@ -2026,7 +2244,7 @@ const main = async () => {
2026
2244
  const shown = ranked.slice(0, limit).map((r) => ({
2027
2245
  ...r,
2028
2246
  turns: turnsLine(inflight, r.target),
2029
- duty: dutyLine(jobs.all(), r.target),
2247
+ duty: dutyLine(jobs.all(), r.target, (t) => `.${displayName(t)}`),
2030
2248
  model: r.model || (r.jsonlPath ? lastModel(r.jsonlPath) : ""),
2031
2249
  summary: r.jsonlPath ? lastExchange(r.jsonlPath, 80, warm) : "",
2032
2250
  contextTokens: r.jsonlPath ? lastContextTokens(r.jsonlPath) : 0,
@@ -2080,7 +2298,7 @@ const main = async () => {
2080
2298
  return;
2081
2299
  }
2082
2300
  const limit = Math.min(Math.max(Number(b.limit ?? 5) || 5, 1), 20);
2083
- 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);
2084
2302
  json(res, 200, { ok: true, text: renderCandidates(task, cands, Date.now(), cfg.wrc.mirror.keepalive.ttlSec * 1000, homedir()) });
2085
2303
  });
2086
2304
  // 派活收成一步 (见 route.decide): 查候选 → 过成本门控 → 转给已有的或按档白板 spawn →
@@ -2107,7 +2325,7 @@ const main = async () => {
2107
2325
  // 不是别人开着的工单里的队员 —— 点名 (`to`) 不受此限。
2108
2326
  const inOthersJob = (t) => jobs.openOf(baseOfKey(t)).some((j) => j.owner !== self && j.members.some((mm) => mm.target === t));
2109
2327
  const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target) && !inOthersJob(r.target));
2110
- 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);
2111
2329
  let d = to ? { kind: "existing", why: `你点名了 ${to}` }
2112
2330
  : b.spawn ? { kind: "spawn", why: "你要了新的" }
2113
2331
  : lead ? { kind: "spawn", why: "复杂活: 白板起一个 hard 档的 lead, 由它组队" }
@@ -2138,7 +2356,7 @@ const main = async () => {
2138
2356
  const settleRoot = (ok) => { if (rootId && !ok)
2139
2357
  jobs.close(rootId, "派活没投成"); };
2140
2358
  const withRoot = (body) => rootId
2141
- ? { ...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\"}) (停提醒, 不关单)。" }
2142
2360
  : body;
2143
2361
  if (d.kind === "existing") {
2144
2362
  const name = to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "");
@@ -2155,13 +2373,13 @@ const main = async () => {
2155
2373
  wizards.upsert(r.target, { lead: true });
2156
2374
  if (rootId && t.status === 200)
2157
2375
  jobs.attach(rootId, { target: r.target, task: "", spawned: false, role: "lead" });
2158
- // 自动选中的被冷门控退回 (decide 用名册的活动时刻, 门控用 transcript 的, 口径差一点): 改走 spawn。
2159
- if (!(t.body.gated === "cold" && !to)) {
2376
+ // 自动选中的被冷门控 (decide 用名册的活动时刻, 门控用 transcript 的, 口径差一点) 或方向闸 (它归别人管 / 是借来的专家) 退回: 改走 spawn; 点名的不改。
2377
+ if (!(!to && (t.body.gated === "cold" || t.body.gated === "direction"))) {
2160
2378
  settleRoot(t.status === 200);
2161
2379
  json(res, t.status, withRoot({ ...t.body, decision: "existing", reason: d.why, ...(shown.length ? { candidates: shown } : {}) }));
2162
2380
  return;
2163
2381
  }
2164
- 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` };
2165
2383
  }
2166
2384
  const name = (b.name ?? "").toString().trim();
2167
2385
  const description = (b.description ?? "").toString().trim();
@@ -2180,7 +2398,7 @@ const main = async () => {
2180
2398
  // 宪章里只有层级规矩, 打法随信封带 (宪章每轮都在上下文里, 打法只这一件活用得上)。
2181
2399
  const t = await tell(String(born.body.name), task, lead ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "");
2182
2400
  settleRoot(t.status === 200);
2183
- // 为这张根单生出来的: 归档 close_job 时要回收它 (tell 记的是 spawned:false, 再 attach 一次补上; lead 角色也在这里记)。
2401
+ // 为这张根单生出来的 (tell 记的是 spawned:false, 再 attach 一次补上; lead 角色也在这里记)。
2184
2402
  if (rootId && t.status === 200)
2185
2403
  jobs.attach(rootId, { target: String(born.body.target), task: "", spawned: true, role: "lead" });
2186
2404
  json(res, t.status, withRoot({
@@ -2275,6 +2493,11 @@ const main = async () => {
2275
2493
  return { status: jc.status, body: { ok: false, reason: jc.reason } };
2276
2494
  }
2277
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
+ }
2278
2501
  // `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
2279
2502
  // 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
2280
2503
  // 逼调用方每次自己说。
@@ -2307,16 +2530,28 @@ const main = async () => {
2307
2530
  return { status: 409, body: { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` } };
2308
2531
  }
2309
2532
  const alivePeers = detached ? [] : await m.peers(self);
2310
- // 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
2311
- // close_job 整批回收一个工单的临时分身。只数**本聊天里**活着的 ——
2533
+ // 预算。撞到上限由守护进程腾位, 不叫调用方去收: 最久没动的闲分身只关 pane、绑定留着
2534
+ // (同 maxPanes 的 reap), 再找它 `--resume` 带全上下文复活。忙的、停在审批上的、手上
2535
+ // 还有没落定的活的不动; 全都动不了才拒。只数**本聊天里**活着的 ——
2312
2536
  // 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
2313
- const aliveKids = childrenOf(wizards.all(), self).filter((k) => alivePeers.some((pp) => pp.target === k.target && pp.paneAlive));
2314
- if (!detached && aliveKids.length >= cfg.wrc.mirror.cloneMax) {
2315
- return { status: 429, body: {
2316
- ok: false,
2317
- reason: `你名下已经有 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) —— 先 stop_wizard 收掉干完活的那些, 或者 close_job 整批回收一个工单`,
2318
- clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
2319
- } };
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");
2320
2555
  }
2321
2556
  const wantChat = (b.chat ?? "").toString().trim();
2322
2557
  const base = wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self);
@@ -2630,7 +2865,7 @@ const main = async () => {
2630
2865
  title,
2631
2866
  ...(parentJob ? { parent: parentJob.id } : {}),
2632
2867
  memberMax: JOB_MEMBER_MAX,
2633
- hint: "把这个 id 传给 spawn_wizard / clone_wizard / tell_peer 的 `job` 参数, 它们就归到这个工单名下 (一律私聊, 不进群); 活干完调 close_job 留档并回收临时分身, 给人的结论写在你自己这一轮的最终回复里。",
2868
+ hint: "把这个 id 传给 spawn_wizard / clone_wizard / tell_peer 的 `job` 参数, 它们就归到这个工单名下 (一律私聊, 不进群); 活干完调 close_job 留档, 给人的结论写在你自己这一轮的最终回复里。",
2634
2869
  });
2635
2870
  });
2636
2871
  http.register("POST /jobs/close", async (req, res) => {
@@ -2671,22 +2906,18 @@ const main = async () => {
2671
2906
  json(res, 200, { ok: true, job: id, [how === "shelve" ? "shelved" : "resumed"]: true });
2672
2907
  return;
2673
2908
  }
2674
- // 关一张单连它还开着的后代单一起关 (深的先关): 否则子 lead 的分身留在原地、账本上挂着没人收的单。
2675
- // 回收只针对**为这个工单生出来的**分身: 被拉来帮忙的长期 wizard 不该因为一次活结束就被杀掉。
2676
- // 调用方自己也不收 —— 那会在这次工具调用里把自己干掉。
2677
- const recycle = b.stop !== false;
2909
+ // 关一张单连它还开着的后代单一起关 (深的先关): 否则账本上挂着没人收的子单。
2910
+ // 收工只结账、留档, 不碰成员的 pane: 分身的去留与工单无关, pane 多了由 maxPanes / cloneMax 自己收
2911
+ // (只关 pane、绑定留着)。老 MCP 进程还会带 `stop`, 收下不用。
2678
2912
  const openKids = (j) => jobChildren(jobs.all(), j.id).filter((c) => c.status === "open").flatMap((c) => [...openKids(c), c]);
2679
2913
  const cascade = openKids(job);
2680
2914
  const closeOne = async (j, summary, mark) => {
2681
- const victims = recycle ? j.members.filter((mm) => mm.spawned && mm.target !== self) : [];
2682
- // 收掉之前把它们手上没落定的活记成 canceled: 不然发起者收工之后还会收到一串 dead 回执。
2683
- victims.forEach((mm) => receipts.cancel(mm.target, j.owner));
2684
- // 留档要的 sessionId 得在回收之前取: killPane 连绑定一起删。
2685
2915
  const sids = new Map([j.owner, ...j.members.map((mm) => mm.target)].map((t) => [t, m.sessionInfo(t)?.sessionId ?? ""]));
2686
- const killed = await victims.reduce(async (acc, mm) => (await acc) + ((await m.killPane(mm.target)).ok ? 1 : 0), Promise.resolve(0));
2687
- // 取消: 还没落定、又不是为这张单生的成员 (长住 wizard) 不杀, 但它们名下这张单派的活记成 canceled, 发起者不会再收到一串回执。
2916
+ // 取消: 还没落定的成员手上这张单派的活记成 canceled, 发起者不会再收到一串回执。
2688
2917
  if (how === "cancel")
2689
- 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);
2690
2921
  const closed = jobs.close(j.id, summary, mark);
2691
2922
  ledger.closeJob(j.id);
2692
2923
  // 收工结论留档到开单者名下 (与交接简报同一份 jsonl); 空工单 (没人、没结论) 不记。
@@ -2695,13 +2926,12 @@ const main = async () => {
2695
2926
  if (!appendEpisode(episodePath(cfg.daemon.stateDir, ep.name), ep))
2696
2927
  log.warn({ job: j.id, name: ep.name }, "job episode not archived");
2697
2928
  }
2698
- return { members: closed.members.length, killed, kept: victims.length - killed };
2929
+ return { members: closed.members.length };
2699
2930
  };
2700
2931
  const rs = await [...cascade.map((c) => [c, `随上级单 ${id} 收工`]), [job, how === "cancel" ? `取消: ${(b.summary ?? "").toString()}` : (b.summary ?? "").toString()]]
2701
2932
  // 根单收工: accept = G3 验收过闸 (人认可), cancel = 取消; 非根单没有闸。
2702
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([]));
2703
- const sum = (f) => rs.reduce((n, r) => n + f(r), 0);
2704
- 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) } : {}) });
2705
2935
  });
2706
2936
  // 挂起事项全表 (见 pending-items.ts): 你派出去、还没了结的事。`drop` 显式消项;
2707
2937
  // `name` 看别人的 (只读)。读过全表就算刚给过一次: 自动的全表提醒从此刻重新计。
@@ -3268,7 +3498,7 @@ const main = async () => {
3268
3498
  const reply = (m.replyToPeer(t, "", at, turn)?.text ?? "").trim();
3269
3499
  if (reply && (await m.idleNow(t))) {
3270
3500
  if (!isQuiet(reply))
3271
- notifyChat(base, withTagHeader(t, reply));
3501
+ notifyChat(base, withTagHeader(t, reply, undefined, base));
3272
3502
  log.info({ target: t, quiet: isQuiet(reply), len: reply.length }, "quiet task: 一轮结束");
3273
3503
  quietRelays.drop(turn);
3274
3504
  return;
@@ -3298,6 +3528,7 @@ const main = async () => {
3298
3528
  wizards.upsert(runner, {
3299
3529
  description: `定时任务 ${taskId} 的一次性执行体${why}`,
3300
3530
  parent: owner,
3531
+ voiceOf: owner,
3301
3532
  bornAt: Date.now(),
3302
3533
  });
3303
3534
  const runnerName = wizards.rename(runner, `${ownerName}-task-${taskId.slice(0, 4)}`);
@@ -3440,6 +3671,7 @@ const main = async () => {
3440
3671
  steward?.stop();
3441
3672
  tasks.stop();
3442
3673
  netWatch.stop();
3674
+ await wx.stop().catch(() => undefined);
3443
3675
  // 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。
3444
3676
  log.info(drainForReload(), "pending drained for reload");
3445
3677
  // Hard-exit watchdog: http.close() blocks until every in-flight connection