wezard 2.1.4 → 2.2.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 (54) hide show
  1. package/README.md +69 -24
  2. package/dist/daemon/cc-session.js +14 -1
  3. package/dist/daemon/cc-session.js.map +1 -1
  4. package/dist/daemon/danger.js +48 -7
  5. package/dist/daemon/danger.js.map +1 -1
  6. package/dist/daemon/detail.js +28 -7
  7. package/dist/daemon/detail.js.map +1 -1
  8. package/dist/daemon/handoff.js +132 -0
  9. package/dist/daemon/handoff.js.map +1 -0
  10. package/dist/daemon/inbound.js +2 -1
  11. package/dist/daemon/inbound.js.map +1 -1
  12. package/dist/daemon/index.js +191 -99
  13. package/dist/daemon/index.js.map +1 -1
  14. package/dist/daemon/mirror-bridge.js +136 -12
  15. package/dist/daemon/mirror-bridge.js.map +1 -1
  16. package/dist/daemon/peers.js +114 -34
  17. package/dist/daemon/peers.js.map +1 -1
  18. package/dist/daemon/receipts.js +190 -0
  19. package/dist/daemon/receipts.js.map +1 -0
  20. package/dist/daemon/route.js +92 -0
  21. package/dist/daemon/route.js.map +1 -0
  22. package/dist/daemon/wizard.js +38 -19
  23. package/dist/daemon/wizard.js.map +1 -1
  24. package/dist/mcp/server.js +60 -25
  25. package/dist/mcp/server.js.map +1 -1
  26. package/dist/shared/chat-http.js +101 -21
  27. package/dist/shared/chat-http.js.map +1 -1
  28. package/dist/shared/chat-render.js +0 -1
  29. package/dist/shared/chat-render.js.map +1 -1
  30. package/dist/shared/chat-view.js +2 -9
  31. package/dist/shared/chat-view.js.map +1 -1
  32. package/dist/shared/config.js +6 -0
  33. package/dist/shared/config.js.map +1 -1
  34. package/dist/shared/detail-render.js +3 -2
  35. package/dist/shared/detail-render.js.map +1 -1
  36. package/dist/shared/detail-store.js +1 -1
  37. package/dist/shared/detail-store.js.map +1 -1
  38. package/dist/shared/keepalive.js +46 -0
  39. package/dist/shared/keepalive.js.map +1 -0
  40. package/dist/shared/reminder.js +13 -0
  41. package/dist/shared/reminder.js.map +1 -1
  42. package/dist/shared/role-render.js +2 -1
  43. package/dist/shared/role-render.js.map +1 -1
  44. package/dist/shared/role-search.js +100 -0
  45. package/dist/shared/role-search.js.map +1 -0
  46. package/dist/shared/role-view.js +85 -26
  47. package/dist/shared/role-view.js.map +1 -1
  48. package/dist/shared/world.js +38 -8
  49. package/dist/shared/world.js.map +1 -1
  50. package/dist/svr/index.js +1 -1
  51. package/dist/svr/index.js.map +1 -1
  52. package/package.json +1 -1
  53. package/web/chat.css +415 -333
  54. package/web/chat.js +899 -741
@@ -12,7 +12,7 @@ import { loadMirrorStore } from "./mirror-store.js";
12
12
  import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
13
13
  import { setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
14
14
  import { installApprovalEventListener, makeApproveHandler } from "./approval.js";
15
- import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider } from "./detail.js";
15
+ import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, recordPost } from "./detail.js";
16
16
  import { EMPTY_FACTS } from "../shared/world.js";
17
17
  import { initAutoWindowPersistence } from "./session-cache.js";
18
18
  import { makeMessageHandler } from "./outbound.js";
@@ -30,10 +30,16 @@ import { baseOfKey, bindTagLinker, keyOf, linkTags, normalizeTag, tagFromCwd, ta
30
30
  import { clipForCharter, inboxPath, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
31
31
  import { ensureStewardTask } from "./memory-steward.js";
32
32
  import { applyChatNames, chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
33
- import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderRoster, } from "./wizard.js";
33
+ import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderRoster, CONTEXT_FULL_TOKENS, } from "./wizard.js";
34
34
  import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.js";
35
35
  import { loadJobStore, renderJobOpen, renderJobClose, JOB_MEMBER_MAX } from "./jobs.js";
36
- import { clipMiddle, extractResult, keepalivePingSigs, lastExchange, lastModel, renderPeerEnvelope, renderTaskEnvelope } from "./peers.js";
36
+ import { clipMiddle, contextFiles, extractResult, lastContextTokens, lastExchange, lastModel, replyClosedBefore, talkTurns, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
37
+ import { keepalivePingSigs } from "../shared/keepalive.js";
38
+ import { expandHome } from "../shared/paths.js";
39
+ import { loadJsonMap } from "../shared/json-map-store.js";
40
+ import { createReceipts } from "./receipts.js";
41
+ import { createHandoffs, handedOff, handingOff } from "./handoff.js";
42
+ import { rankCandidates, renderCandidates } from "./route.js";
37
43
  import { parseWhen, renderChatLog } from "./chat-log.js";
38
44
  import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
39
45
  // pino's file transport is async; without flush, log.fatal before process.exit
@@ -105,7 +111,9 @@ const main = async () => {
105
111
  },
106
112
  nameOf: (t) => settleName(wizardStore(), chatNameOf(cfg, t), t),
107
113
  });
108
- installInboundRouter(ws.client, cfg, log, bridge, sourcePath);
114
+ // 人话也过交接的闸 (见 handoff.ts): 交接期间说的话进新会话, 不被旧会话带走、也不在
115
+ // 重开的空档里 resume 回旧 sid。
116
+ installInboundRouter(ws.client, cfg, log, { ...bridge, dispatch: async (a) => (await handedOff(a.principal), bridge.dispatch(a)) }, sourcePath);
109
117
  // approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
110
118
  // 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
111
119
  // 工作单独成块, 比让 stream 一直长到下一个 inbound / hardTimer 更清晰。
@@ -357,7 +365,8 @@ const main = async () => {
357
365
  // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
358
366
  const keepalive = typeof body.keepalive === "boolean" ? body.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
359
367
  log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model, keepalive }, "spawning peer session");
360
- const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive });
368
+ // silent: 新 wizard 就位不进群 (生命周期事件), 同群的 wizard 从名册增量里得知。
369
+ const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive, silent: true });
361
370
  if (!r.ok && !existed)
362
371
  wizards.drop(target);
363
372
  // r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
@@ -581,11 +590,62 @@ const main = async () => {
581
590
  const channelOf = (self) => m.currentChannel(self) || baseOfKey(self);
582
591
  /** 同伴注入的信封: 公开轮写上那个群的名字, 私聊不写 (见 peers.renderPeerEnvelope)。 */
583
592
  const envelopeFor = (from, channel) => renderPeerEnvelope(displayName(from), channel ? chatNameOf(cfg, channel) : undefined);
584
- // 发话时刻 —— wait_peer 拿它把「回复」和「这一次发话」对上 (peers.lastReply)。
585
- // 纯内存: reload 之后退化成不带关联的旧行为, 不值得为它落盘。
586
- const sentAt = new Map();
587
- const pairOf = (from, to) => `${from}\u0000${to}`;
588
- const stampSend = (from, to) => void sentAt.set(pairOf(from, to), Date.now());
593
+ // 回执 (见 receipts.ts): 一次 tell_peer 之后对方干完那一轮, 它的结论自动 paste
594
+ // 回发话方的输入框, 发话方全程不阻塞。这里只提供四样能力给那个模块 —— 忙闲、
595
+ // 死活、「它答我那一句的是哪段话」、以及怎么把一段话送进去 —— 投递逻辑在那边。
596
+ const receipts = createReceipts({
597
+ isBusy: m.isBusy,
598
+ idleNow: m.idleNow,
599
+ untilIdle: m.untilIdle,
600
+ paneLive: m.paneLive,
601
+ nameOf: displayName,
602
+ log,
603
+ store: loadJsonMap(cfg.wrc.mirror.receiptsFile),
604
+ handingOff,
605
+ handedOff,
606
+ // 信封锚取不到 (对方是个还不挂信封的老 wizard) 时退回按时刻取, 由 receipts
607
+ // 区分这两种"没有"。
608
+ replyFor: (to, fromName, since) => m.replyToPeer(to, fromName, since),
609
+ deliver: (to, bodyText, meta) => m.injectText(to, clipMiddle(bodyText), undefined, {
610
+ // receipt:true 是「这一轮不该再生回执」的记录 —— 回环在结构上就不成立:
611
+ // 只有 tell_peer 那条路登记 watcher, 这里是直接注入。
612
+ from: { kind: "peer", from: meta.from, receipt: true, ...(meta.job ? { job: meta.job } : {}) },
613
+ channel: meta.channel,
614
+ envelope: renderReceiptEnvelope(displayName(meta.from), meta.channel ? chatNameOf(cfg, meta.channel) : undefined, meta.job ? { job: meta.job, done: meta.done, total: meta.total } : undefined),
615
+ }),
616
+ });
617
+ // reload 前在飞的回执: 等 mirror 把绑定都恢复了再续守 —— 早一步读 target 会拿不到
618
+ // transcript, 退回按时刻取就可能把上一件事的结论当回执。
619
+ // 交接 (见 handoff.ts): 换会话不换身份。这里给它重开、贴话、读旧 transcript 的能力,
620
+ // 闸、回执清点与断点续做在那边。
621
+ const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
622
+ const handoffs = createHandoffs({
623
+ isBusy: m.isBusy,
624
+ sessionId: (t) => m.sessionInfo(t)?.sessionId ?? "",
625
+ restart: (t) => restartFresh(t),
626
+ // 欠着回执的发话方挂上它们的信封: 新会话那一轮就是在答它们, 回执照常按信封定位,
627
+ // 频道也接回原来那一句所在的地方 (私聊不进群)。
628
+ inject: (t, text, owe) => owe.length
629
+ ? m.injectText(t, text, undefined, {
630
+ from: { kind: "peer", from: owe[0].from },
631
+ channel: owe[0].channel,
632
+ envelope: owe.map((o) => envelopeFor(o.from, o.channel)).join(""),
633
+ })
634
+ : m.injectText(t, text),
635
+ lastText: m.lastText,
636
+ answeredBefore: (to, from, since, until) => {
637
+ const p = m.sessionInfo(to)?.jsonlPath;
638
+ return p ? replyClosedBefore(expandHome(p), displayName(from), since, until, pingSigs) : undefined;
639
+ },
640
+ receipts,
641
+ // 交接是生命周期事件, 不进群; 失败只捎给它自己 (它调完 handoff_self 拿到的是
642
+ // scheduled:true, 不说的话它不知道上下文其实没换)。
643
+ notify: (t, text) => notices.post([t], text),
644
+ log,
645
+ store: loadJsonMap(cfg.wrc.mirror.handoffsFile),
646
+ });
647
+ // 交接先续做: 它的闸要在回执续守之前立起来, 否则续守的 watcher 会读到换了一半的会话。
648
+ void m.restored.then(() => { handoffs.resume(); receipts.resume(); });
589
649
  /** 入参里的地址: 新字段 `name`, 老 MCP 进程 (正在跑的 wizard) 仍在传 `tag`。 */
590
650
  const addrOf = (b) => String(b.name ?? b.tag ?? "");
591
651
  /** 撞上的名字属于一个静默超过一天的 wizard → 顶掉它 (见 evictStaleName), 同群的
@@ -672,8 +732,10 @@ const main = async () => {
672
732
  return;
673
733
  }
674
734
  const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
675
- for (const dest of dests)
735
+ for (const dest of dests) {
676
736
  notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
737
+ recordPost({ target: self, channel: dest, body: content });
738
+ }
677
739
  json(res, 200, { ok: true, sent: dests.map((d) => chatNameOf(cfg, d) || d) });
678
740
  });
679
741
  http.register("POST /peers/peek", async (req, res) => {
@@ -789,7 +851,11 @@ const main = async () => {
789
851
  ].join("\n"),
790
852
  });
791
853
  });
792
- http.register("POST /peers/send", async (req, res) => {
854
+ // 跟另一个 wizard 说一句话。`tell_peer` 是它现在的名字 —— 「说」而不是「发送并
855
+ // 等待」: 调用方注入完就返回, 对方干完那一轮的结论由守护进程自动送回来 (receipts)。
856
+ // `/peers/send` 保留为同一个处理函数: 正在跑的 wizard 的 MCP 进程是旧代码, 换名字
857
+ // 不能把它们的通路掐断。
858
+ const tellPeer = async (req, res) => {
793
859
  const { self, body } = await readPeerBody(req);
794
860
  if (!self) {
795
861
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
@@ -816,7 +882,9 @@ const main = async () => {
816
882
  const { target, foreign } = r;
817
883
  // 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
818
884
  // 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
819
- const isPublic = body.public === true;
885
+ // 只有两端都是登记在册的 wizard 才能当着人说: 裸 target 冒充的发话方、forget
886
+ // 掉的分身不是谁, 它们的往来进群只会留下一段人找不到主的对话 —— 降为私聊。
887
+ const isPublic = body.public === true && !!wizards.get(self) && !!wizards.get(target);
820
888
  const channel = isPublic ? channelOf(self) : "";
821
889
  // Injecting into your own pane would type into the box you're generating
822
890
  // from — Claude Code queues it and the caller deadlocks waiting for itself.
@@ -824,31 +892,40 @@ const main = async () => {
824
892
  json(res, 400, { ok: false, reason: "refusing to inject into the calling session itself" });
825
893
  return;
826
894
  }
895
+ // 它正在交接: 这句话等它换完会话再进 —— 投进旧会话会被交接一并带走, 投进重开中的
896
+ // 空 pane 会 resume 回旧 sid (见 handoff.ts)。发话时刻取在这之后。
897
+ await handedOff(target);
827
898
  // 投递时机。默认照旧立刻投 —— 「回答它的提问」「打断它」本来就是冲着一个
828
899
  // 正在忙的会话去的。`when:"idle"` 是**派新活**该用的那一种: 两个 wizard 同时
829
900
  // 找第三个时, 两段文本会挤进同一个输入框、被当成一轮读掉, 这在协同网络里
830
901
  // 不是边角情况而是常态。等它闲下来再投, 一句话就是一轮。
831
902
  const when = (body.when ?? "now") === "idle" ? "idle" : "now";
832
903
  const waitSec = Math.min(Math.max(Number(body.waitSec ?? 600) || 600, 10), 3600);
833
- const wasBusy = await m.isBusy(target);
904
+ // 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
905
+ const wasBusy = !(await m.idleNow(target));
834
906
  let waitedMs = 0;
835
907
  if (when === "idle" && wasBusy) {
836
908
  const t0 = Date.now();
837
- // rampMs=0: ramp 是给「刚注入、这一轮还没起来」准备的; 这里相反, 一旦它
838
- // 真闲下来就该立刻投。
839
- const wr = await waitForIdle(target, m.isBusy, waitSec * 1000, () => false, { rampMs: 0, confirm: 2 });
909
+ // 它那一轮一结束就投: 注册表 status 翻 idle 是事件, 不抽样也不等稳定期。
910
+ const wr = await m.untilIdle(target, waitSec * 1000);
840
911
  waitedMs = Date.now() - t0;
841
912
  if (!wr.idle) {
842
913
  json(res, 409, { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪, 或者用 when:"now" 插队` });
843
914
  return;
844
915
  }
845
916
  }
846
- stampSend(self, target);
917
+ // 时刻取在注入**之前**: 晚于那一句落盘的话, 回执定位的下界就偏了。
918
+ const at = Date.now();
847
919
  const inj = await m.injectText(target, text, undefined, {
848
920
  from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}) },
849
921
  channel,
850
922
  envelope: envelopeFor(self, channel),
851
923
  });
924
+ // 回执: 对方干完那一轮, 守护进程把它的结论自动送回来 (默认开)。`receipt:false`
925
+ // 是"放出去就不管了"的那种派活。注入失败就不守 —— 没有问话, 也不会有回答。
926
+ const wantReceipt = body.receipt !== false;
927
+ if (inj.ok)
928
+ receipts.register({ from: self, to: target, channel, job: jobId, at }, wantReceipt);
852
929
  // 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
853
930
  if (inj.ok && jobId)
854
931
  jobs.attach(jobId, { target, task: text, spawned: false });
@@ -856,8 +933,21 @@ const main = async () => {
856
933
  relayPeer(self, target, text, channel);
857
934
  // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
858
935
  // 它这一轮后面, 而不是马上被读到。
859
- json(res, inj.ok ? 200 : 502, { ...inj, name: peerAddress(cfg, self, target), public: isPublic, wasBusy, ...(waitedMs ? { waitedMs } : {}), ...(jobId ? { job: jobId } : {}) });
860
- });
936
+ json(res, inj.ok ? 200 : 502, {
937
+ ...inj,
938
+ name: peerAddress(cfg, self, target),
939
+ public: isPublic,
940
+ wasBusy,
941
+ ...(waitedMs ? { waitedMs } : {}),
942
+ ...(jobId ? { job: jobId } : {}),
943
+ // 回执怎么回来 —— 写在回包里, 调用方 (模型) 不必从工具描述里回忆。
944
+ ...(inj.ok && wantReceipt
945
+ ? { receipt: "它干完那一轮, 结论会作为新的一轮自动进到你这里 —— 不要挂在 wait_peer 上等, 接着干你自己的事" }
946
+ : {}),
947
+ });
948
+ };
949
+ http.register("POST /peers/tell", tellPeer);
950
+ http.register("POST /peers/send", tellPeer); // 老 MCP 进程还在调这个名字
861
951
  // 等一个 wizard, 或者等一**组**。fan-out 之后 join 必须是并行的: 串行地等五个
862
952
  // 分身, 墙钟是五个之和, 而它们本来就在同时干活。`need` 把 all / any / 过半收进
863
953
  // 一个数字 (默认全等), 满了就撤掉剩下的等待。
@@ -901,11 +991,15 @@ const main = async () => {
901
991
  // - 内部 key (`target`) 与 `foreign` 不回: 模型用不上, 还会被诱导去拼 key。
902
992
  const results = uniq.map((h, i) => {
903
993
  const wr = wrs[i];
904
- const full = wr.idle ? m.lastReply(h.target, sentAt.get(pairOf(self, h.target)) ?? 0) : "";
994
+ // 取走就占位: 同一段话不会再作为回执 paste 进来一遍 (见 receipts.claim)。
995
+ const full = wr.idle ? m.lastReply(h.target, receipts.sentAt(self, h.target)) : "";
996
+ const already = full ? receipts.claim(self, h.target) : false;
905
997
  const result = extractResult(full);
906
998
  return {
907
999
  name: h.address,
908
1000
  idle: wr.idle,
1001
+ // 回执早一步到了: 这段话已经作为新的一轮进过你的会话, 别再处理第二遍。
1002
+ ...(already ? { delivered: true } : {}),
909
1003
  ...(wr.reason ? { reason: wr.reason } : {}),
910
1004
  ...(result
911
1005
  ? { result, ...(full.length > result.length + 200 ? { omitted: full.length - result.length } : {}) }
@@ -922,11 +1016,17 @@ const main = async () => {
922
1016
  ? { ok: true, ...results[0] }
923
1017
  : { ok: true, need, done, satisfied: done >= need, results });
924
1018
  });
925
- // POST /handoff — 交接一个 pane 的会话给一个全新会话,原地完成。先让目标
926
- // 会话把当前工作压成一份"零上下文也能接手"的交接简报,等它写完并抓取,
927
- // 再向同一 pane 注入 `/clear`(原地重开 session、重置上下文窗口、cwd 不变),
928
- // settle 后把简报作为新会话的首条消息贴进去。全程只操控 tmux。拒绝对调用方
929
- // 自身 pane 操作(会 deadlock,同 /peers/send)。
1019
+ // 交接的「重开」一步: 走 /new (杀旧 pane、起新进程) 而不是往原 pane 注入 /clear。
1020
+ // /clear 只换会话不换进程 —— MCP 子进程、charter 都还是旧的, 新工具拿不到; 镜像
1021
+ // 还得靠 sid 轮换探测才跟得上。模型 / CLI / cwd / keepalive 照旧沿用。
1022
+ const restartFresh = (target) => {
1023
+ const info = m.sessionInfo(target);
1024
+ return m.newSession(target, displayName(target) || tagOfKey(target) || target, info?.cli, { model: info?.model || undefined, silent: true, warm: true });
1025
+ };
1026
+ // POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
1027
+ // 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
1028
+ // 全新的进程(同名、同 cwd),把简报作为新会话的首条消息贴进去。拒绝对调用方
1029
+ // 自身操作(会 deadlock,同 /peers/send)。
930
1030
  http.register("POST /handoff", async (req, res) => {
931
1031
  const { self, body } = await readPeerBody(req);
932
1032
  if (!self) {
@@ -958,44 +1058,8 @@ const main = async () => {
958
1058
  }
959
1059
  const focus = (body.focus ?? "").toString().trim();
960
1060
  const timeoutMs = Math.min(Math.max(Number(body.timeoutSec ?? 600) || 600, 30), 7200) * 1000;
961
- const briefPrompt = [
962
- "把你当前会话的全部工作压缩成一份**交接简报**,目标是让一个零上下文的全新会话仅凭这份简报就能无缝接手。必须自洽、具体、可执行,涵盖:",
963
- "1. 总目标 / 用户到底想要什么",
964
- "2. 已完成的事、关键决策与其理由",
965
- "3. 当前状态:改到哪了、什么能跑、什么还没跑通",
966
- "4. 下一步该做什么(有序)",
967
- "5. 涉及的关键文件/路径/符号,以及非显而易见的坑",
968
- focus ? `特别强调:${focus}` : "",
969
- "只输出这份简报本身,不要寒暄、不要反问。",
970
- ].filter(Boolean).join("\n");
971
- const inj1 = await m.injectText(target, briefPrompt);
972
- if (!inj1.ok) {
973
- json(res, 502, { ok: false, target, reason: `summary inject failed: ${inj1.reason}` });
974
- return;
975
- }
976
- const idle = await waitForIdle(target, m.isBusy, timeoutMs, () => false);
977
- const brief = m.lastText(target);
978
- // 没等到 idle 就不能 /clear —— 那会丢掉这次还在生成的交接总结。
979
- if (!idle.idle) {
980
- json(res, 504, { ok: false, target, brief, reason: `target still working: ${idle.reason}; not clearing to avoid losing the turn` });
981
- return;
982
- }
983
- if (!brief.trim()) {
984
- json(res, 502, { ok: false, target, reason: "target produced no summary text" });
985
- return;
986
- }
987
- const clr = await m.injectText(target, "/clear");
988
- if (!clr.ok) {
989
- json(res, 502, { ok: false, target, brief, reason: `/clear inject failed: ${clr.reason}` });
990
- return;
991
- }
992
- // /clear 后 TUI 重绘出全新空会话 + 首条注入需要 warmup,给足 settle。
993
- await new Promise((r) => setTimeout(r, 3000));
994
- const carry = `以下是上一个会话交接过来的工作简报,请据此无缝接手并继续:\n\n${brief}`;
995
- const inj2 = await m.injectText(target, carry);
996
- json(res, inj2.ok ? 200 : 502, inj2.ok
997
- ? { ok: true, target, brief }
998
- : { ok: false, target, brief, reason: `handoff carry inject failed: ${inj2.reason}` });
1061
+ const r = await handoffs.other(target, focus, timeoutMs);
1062
+ json(res, r.ok ? 200 : r.status, r.ok ? { ok: true, target, brief: r.brief } : { ok: false, target, reason: r.reason, ...(r.brief ? { brief: r.brief } : {}) });
999
1063
  });
1000
1064
  // ── Wizard: 会话的身份层 ────────────────────────────────────────────
1001
1065
  // 一个会话 (`chat:xxx#k`) 从此是一个 wizard: 一个全局唯一的名字 (默认会话出生时
@@ -1132,6 +1196,7 @@ const main = async () => {
1132
1196
  })),
1133
1197
  schedules: tasks.list().filter((x) => x.enabled).map((x) => {
1134
1198
  const st = tasks.stateOf(x.id);
1199
+ const loadError = tasks.errors()[x.id];
1135
1200
  return {
1136
1201
  id: x.id,
1137
1202
  target: x.target || (cfg.defaultChat ?? ""),
@@ -1142,21 +1207,31 @@ const main = async () => {
1142
1207
  note: x.note,
1143
1208
  createdBy: x.createdBy,
1144
1209
  owner: x.owner || (cfg.defaultChat ?? ""),
1210
+ fresh: x.fresh,
1211
+ hasGate: x.hasGate,
1212
+ lastGate: st.lastGate,
1213
+ lastGateAt: st.lastGateAt,
1214
+ lastError: st.lastError,
1215
+ ...(loadError ? { loadError } : {}),
1216
+ file: x.file,
1145
1217
  };
1146
1218
  }),
1147
1219
  chatNames: Object.fromEntries(listChatNames(cfg).map((c) => [c.base, c.name])),
1148
1220
  };
1149
1221
  };
1222
+ // 一次采集失败 (某个 pane 的 tmux 抖了一下) 不等于注册表没了: 退回上一份好的,
1223
+ // 否则这份空占位会被推给 svr, 把整张名册抹成「缺席」直到下一次推送。
1224
+ let lastGood = EMPTY_FACTS;
1150
1225
  setWorldFactsProvider(() => {
1151
1226
  const now = Date.now();
1152
1227
  if (!worldCache || now - worldCache.at > WORLD_TTL_MS) {
1153
- worldCache = { at: now, facts: collectWorldFacts().catch(() => EMPTY_FACTS) };
1228
+ worldCache = {
1229
+ at: now,
1230
+ facts: collectWorldFacts().then((f) => (lastGood = f), () => lastGood),
1231
+ };
1154
1232
  }
1155
1233
  return worldCache.facts;
1156
1234
  });
1157
- /** 上下文超过这个数就该自己交接了。Claude 家族最小的窗口是 200k, 留三成余量
1158
- * 给交接那一轮本身 —— 提示而已, 决定权在 wizard 自己。 */
1159
- const HANDOFF_HINT_TOKENS = 140_000;
1160
1235
  const briefOf = (self, target) => ({
1161
1236
  name: settleName(wizards, chatNameOf(cfg, target), target),
1162
1237
  address: peerAddress(cfg, self, target),
@@ -1236,7 +1311,7 @@ const main = async () => {
1236
1311
  cli: info?.cli,
1237
1312
  model: info?.model ?? "",
1238
1313
  contextTokens: info?.contextTokens ?? 0,
1239
- handoffSuggested: (info?.contextTokens ?? 0) > HANDOFF_HINT_TOKENS,
1314
+ handoffSuggested: (info?.contextTokens ?? 0) > CONTEXT_FULL_TOKENS,
1240
1315
  };
1241
1316
  };
1242
1317
  /** 全体名册: 活着的 (peers + 跨聊天可寻址的) 在前, 注册表里有记录但此刻不在
@@ -1358,6 +1433,7 @@ const main = async () => {
1358
1433
  ...r,
1359
1434
  model: r.model || (r.jsonlPath ? lastModel(r.jsonlPath) : ""),
1360
1435
  summary: r.jsonlPath ? lastExchange(r.jsonlPath, 80, warm) : "",
1436
+ contextTokens: r.jsonlPath ? lastContextTokens(r.jsonlPath) : 0,
1361
1437
  }));
1362
1438
  json(res, 200, {
1363
1439
  ok: true,
@@ -1365,7 +1441,7 @@ const main = async () => {
1365
1441
  matched: hit.length,
1366
1442
  shown: shown.length,
1367
1443
  text: [
1368
- `名册 · 列出 ${shown.length}/${hit.length} 个 (全机 ${all.length} 个) · 忙 = 正在生成, 闲 = 活着没在跑, 冷 = 没有 pane (发消息会唤醒)`,
1444
+ `名册 · 列出 ${shown.length}/${hit.length} 个 (全机 ${all.length} 个) · 忙 = 正在生成, 闲 = 活着没在跑, 冷 = 没有 pane (发消息会唤醒) · ctx = 当前上下文用量`,
1369
1445
  renderRoster(shown, Date.now(), homedir()),
1370
1446
  ...(hit.length > shown.length
1371
1447
  ? [`还有 ${hit.length - shown.length} 个没列出来 —— 用 query (名字/职责) / cwd (工作区) / chat 收窄, 或者调大 limit`]
@@ -1373,6 +1449,38 @@ const main = async () => {
1373
1449
  ].join("\n"),
1374
1450
  });
1375
1451
  });
1452
+ // 管家分派前的候选表: 事实在这里算 (交集、读过的文件、ctx), 权衡留给管家。
1453
+ // 只为活着的读 transcript —— 冷的没有 jsonl 可读, 靠职责命中进表。
1454
+ http.register("POST /wizard/route", async (req, res) => {
1455
+ const { self, body } = await readPeerBody(req);
1456
+ if (!self) {
1457
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1458
+ return;
1459
+ }
1460
+ const b = body;
1461
+ const task = (b.task ?? "").trim();
1462
+ if (!task) {
1463
+ json(res, 400, { ok: false, reason: "task is required" });
1464
+ return;
1465
+ }
1466
+ ensureChatNames(self);
1467
+ const warm = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1468
+ const rows = (await rosterOf(self))
1469
+ .filter((r) => !r.self)
1470
+ .map((r) => {
1471
+ const live = r.jsonlPath ? r.jsonlPath : "";
1472
+ return {
1473
+ ...r,
1474
+ contextTokens: live ? lastContextTokens(live) : 0,
1475
+ files: live ? contextFiles(live) : [],
1476
+ asks: live ? talkTurns(live, 12, warm).filter((t) => t.role === "user").map((t) => t.text) : [],
1477
+ summary: live ? lastExchange(live, 80, warm) : "",
1478
+ };
1479
+ });
1480
+ const limit = Math.min(Math.max(Number(b.limit ?? 5) || 5, 1), 20);
1481
+ const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, rows).slice(0, limit);
1482
+ json(res, 200, { ok: true, text: renderCandidates(cands, Date.now(), homedir()) });
1483
+ });
1376
1484
  // 记忆三种作用域: self 跟着自己 (wizards.json), 直写; chat / workspace 是共享的 md,
1377
1485
  // 每个在那个群 / 那个目录出生的 wizard 都会读到 —— 它们只收提议, 由整理者合并
1378
1486
  // (见 wizard-memory.ts / memory-steward.ts)。
@@ -1513,8 +1621,7 @@ const main = async () => {
1513
1621
  const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
1514
1622
  // 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
1515
1623
  // wait_peer 才不会把它们当成分身对这件活的答复。
1516
- if (task)
1517
- stampSend(self, target);
1624
+ const taskAt = Date.now();
1518
1625
  const r = await m.cloneSession({
1519
1626
  parent: source,
1520
1627
  target,
@@ -1554,11 +1661,15 @@ const main = async () => {
1554
1661
  }
1555
1662
  if (jobId)
1556
1663
  jobs.attach(jobId, { target, task, spawned: true });
1664
+ // 分身的第一件活也守回执: fan-out 最常见的形状就是 clone_wizard({task}) × N,
1665
+ // 让它们干完自己把结论送回来, 发起方不必挂在 wait_peer 上。
1666
+ if (dispatched)
1667
+ receipts.register({ from: self, to: target, channel: "", job: jobId, at: taskAt });
1557
1668
  json(res, 200, { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(jobId ? { job: jobId } : {}) });
1558
1669
  });
1559
1670
  // 收掉一个 wizard。interrupt = 打断它这一轮 (Esc); end = 结束它并回收 pane。
1560
1671
  // 自己终结自己是合法的 (分身干完活自我了结), 代价是这次工具调用不会返回 ——
1561
- // 群里的气泡就是回执, 与 set_workspace 同款。
1672
+ // 回执只在 rolepage (生命周期事件不进群); 同群的 wizard 从名册增量里得知。
1562
1673
  http.register("POST /wizard/stop", async (req, res) => {
1563
1674
  const { self, body } = await readPeerBody(req);
1564
1675
  if (!self) {
@@ -1585,7 +1696,6 @@ const main = async () => {
1585
1696
  }
1586
1697
  if (end && b.forget)
1587
1698
  wizards.drop(target);
1588
- notifyChat(target, withTagHeader(target, `${end ? "已结束" : "已打断"} · 由 ${displayName(self)} 发起`));
1589
1699
  // 打断只是停了它这一轮, 它还在; 只有结束才是名册变了。
1590
1700
  if (end)
1591
1701
  postRoster(baseOfKey(target), [target, self], `**${victim.name || target}** 已收工 · 由 ${displayName(self)} 结束${b.forget ? " (记录一并抹掉)" : ""}`);
@@ -1620,7 +1730,7 @@ const main = async () => {
1620
1730
  const done = await m.setModel(r.target, model, scope);
1621
1731
  json(res, done.ok ? 200 : 502, { ...done, target: r.target, name: briefOf(self, r.target).name, ...(done.ok ? { model: done.applied } : {}) });
1622
1732
  });
1623
- // 自我交接: 上下文撑不住了, 自己把工作压成简报, 原地 /clear 重开, 再把简报贴
1733
+ // 自我交接: 上下文撑不住了, 自己把工作压成简报, /new 换一个全新进程, 再把简报贴
1624
1734
  // 回去。和 /handoff 的区别是**简报由调用方自己写在入参里** —— 它没法在自己
1625
1735
  // 生成的当口再被问一次 (那正是 /handoff 拒绝对自身操作的原因)。所以这里先应答,
1626
1736
  // 等它这一轮说完、pane 空下来再动手。
@@ -1636,29 +1746,9 @@ const main = async () => {
1636
1746
  return;
1637
1747
  }
1638
1748
  const info = m.sessionInfo(self);
1639
- json(res, 200, { ok: true, target: self, contextTokens: info?.contextTokens ?? 0, scheduled: true });
1640
- // 应答之后再干活: 调用方此刻还在生成, 必须等它把话说完。
1641
- void (async () => {
1642
- const lg = log.child({ mod: "wizard", sub: "handoff-self", target: self });
1643
- const idle = await waitForIdle(self, m.isBusy, 10 * 60_000, () => false);
1644
- if (!idle.idle) {
1645
- lg.warn({ reason: idle.reason }, "self-handoff: 目标一直忙, 放弃");
1646
- // 之前这里只写日志: 调用方早就拿到 `scheduled:true` 挂了, 交接静悄悄
1647
- // 放弃, 群里没有任何气泡告诉人。补一条 notify, 对齐它成功时的那条。
1648
- notifyChat(self, withTagHeader(self, `上下文交接放弃: 一直没能闲下来 (${idle.reason}), 上下文和之前一样没动, 有空再试一次`));
1649
- return;
1650
- }
1651
- const clr = await m.injectText(self, "/clear");
1652
- if (!clr.ok) {
1653
- lg.warn({ reason: clr.reason }, "self-handoff: /clear 注入失败");
1654
- return;
1655
- }
1656
- await new Promise((r2) => setTimeout(r2, 3000));
1657
- const carry = `以下是你自己上一段会话压缩出来的交接简报, 据此无缝接着干:\n\n${brief}`;
1658
- const inj = await m.injectText(self, carry);
1659
- lg.info({ ok: inj.ok, reason: inj.reason }, "self-handoff: 完成");
1660
- notifyChat(self, withTagHeader(self, `上下文已交接重开 (${info?.contextTokens ?? 0} tok → 0), 工作照旧`));
1661
- })();
1749
+ // 应答之后再干活: 调用方此刻还在生成, 交接要等它把话说完 (见 handoff.ts)。
1750
+ const r = handoffs.self(self, brief);
1751
+ json(res, r.ok ? 200 : 409, r.ok ? { ok: true, target: self, contextTokens: info?.contextTokens ?? 0, scheduled: true } : { ok: false, reason: r.reason });
1662
1752
  });
1663
1753
  // ── Job: 一次 fan-out 的工单 ─────────────────────────────────────────
1664
1754
  // 账本, 不是编排器: 控制流留在发起的那个 wizard 手里 (它自己 spawn / wait /
@@ -1879,7 +1969,7 @@ const main = async () => {
1879
1969
  const r = await m.newSession(target, node.tag, node.cli, { model: node.model, cwd: node.cwd, silent: true });
1880
1970
  return { ok: r.ok, reason: r.reason };
1881
1971
  },
1882
- send: (target, text, origin) => m.injectText(target, text, origin),
1972
+ send: async (target, text, origin) => (await handedOff(target), m.injectText(target, text, origin)),
1883
1973
  isBusy: m.isBusy,
1884
1974
  lastText: async (target) => m.lastText(target),
1885
1975
  notify: notifyChat,
@@ -1938,6 +2028,8 @@ const main = async () => {
1938
2028
  // 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
1939
2029
  // 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
1940
2030
  scheduledTaskInject = async (target, text, { taskId, fresh }) => {
2031
+ if (!fresh)
2032
+ await handedOff(target); // 交接中途 pane 是空的, 别当成闲着投进去
1941
2033
  const busy = fresh ? false : await m.isBusy(target);
1942
2034
  if (!fresh && !busy)
1943
2035
  return m.injectText(target, text, undefined, { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });