wezard 2.2.1 → 2.3.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 (95) hide show
  1. package/CLAUDE.md +7 -4
  2. package/README.md +1 -1
  3. package/dist/cli/init.js +2 -1
  4. package/dist/cli/init.js.map +1 -1
  5. package/dist/daemon/approval.js +6 -4
  6. package/dist/daemon/approval.js.map +1 -1
  7. package/dist/daemon/chat-log.js +6 -1
  8. package/dist/daemon/chat-log.js.map +1 -1
  9. package/dist/daemon/danger.js +17 -0
  10. package/dist/daemon/danger.js.map +1 -1
  11. package/dist/daemon/detail.js +41 -0
  12. package/dist/daemon/detail.js.map +1 -1
  13. package/dist/daemon/handoff.js +8 -2
  14. package/dist/daemon/handoff.js.map +1 -1
  15. package/dist/daemon/inbound.js +9 -4
  16. package/dist/daemon/inbound.js.map +1 -1
  17. package/dist/daemon/index.js +744 -152
  18. package/dist/daemon/index.js.map +1 -1
  19. package/dist/daemon/jobs.js +62 -23
  20. package/dist/daemon/jobs.js.map +1 -1
  21. package/dist/daemon/last-response.js +15 -0
  22. package/dist/daemon/last-response.js.map +1 -1
  23. package/dist/daemon/memory-steward.js +124 -68
  24. package/dist/daemon/memory-steward.js.map +1 -1
  25. package/dist/daemon/mirror-bridge.js +461 -91
  26. package/dist/daemon/mirror-bridge.js.map +1 -1
  27. package/dist/daemon/mirror-store.js.map +1 -1
  28. package/dist/daemon/model-select.js +99 -0
  29. package/dist/daemon/model-select.js.map +1 -1
  30. package/dist/daemon/notices.js +7 -5
  31. package/dist/daemon/notices.js.map +1 -1
  32. package/dist/daemon/peers.js +183 -60
  33. package/dist/daemon/peers.js.map +1 -1
  34. package/dist/daemon/receipts.js +391 -77
  35. package/dist/daemon/receipts.js.map +1 -1
  36. package/dist/daemon/route.js +63 -10
  37. package/dist/daemon/route.js.map +1 -1
  38. package/dist/daemon/spawn-tmux.js +24 -13
  39. package/dist/daemon/spawn-tmux.js.map +1 -1
  40. package/dist/daemon/task-registry.js +1 -1
  41. package/dist/daemon/task-registry.js.map +1 -1
  42. package/dist/daemon/tasks.js +12 -4
  43. package/dist/daemon/tasks.js.map +1 -1
  44. package/dist/daemon/usage.js +4 -11
  45. package/dist/daemon/usage.js.map +1 -1
  46. package/dist/daemon/wizard-memory.js +36 -4
  47. package/dist/daemon/wizard-memory.js.map +1 -1
  48. package/dist/daemon/wizard.js +102 -58
  49. package/dist/daemon/wizard.js.map +1 -1
  50. package/dist/mcp/server.js +138 -111
  51. package/dist/mcp/server.js.map +1 -1
  52. package/dist/shared/charter-view.js +35 -0
  53. package/dist/shared/charter-view.js.map +1 -0
  54. package/dist/shared/chat-http.js +133 -31
  55. package/dist/shared/chat-http.js.map +1 -1
  56. package/dist/shared/chat-render.js +6 -3
  57. package/dist/shared/chat-render.js.map +1 -1
  58. package/dist/shared/chat-view.js +17 -6
  59. package/dist/shared/chat-view.js.map +1 -1
  60. package/dist/shared/cli-backends.js +2 -0
  61. package/dist/shared/cli-backends.js.map +1 -1
  62. package/dist/shared/config.js +9 -5
  63. package/dist/shared/config.js.map +1 -1
  64. package/dist/shared/daemon-token.js +18 -0
  65. package/dist/shared/daemon-token.js.map +1 -0
  66. package/dist/shared/detail-render.js +140 -16
  67. package/dist/shared/detail-render.js.map +1 -1
  68. package/dist/shared/detail-store.js +33 -12
  69. package/dist/shared/detail-store.js.map +1 -1
  70. package/dist/shared/effort.js +11 -0
  71. package/dist/shared/effort.js.map +1 -0
  72. package/dist/shared/model-prices.js +258 -0
  73. package/dist/shared/model-prices.js.map +1 -0
  74. package/dist/shared/pricing.js +55 -0
  75. package/dist/shared/pricing.js.map +1 -0
  76. package/dist/shared/reminder.js +18 -4
  77. package/dist/shared/reminder.js.map +1 -1
  78. package/dist/shared/role-render.js +172 -8
  79. package/dist/shared/role-render.js.map +1 -1
  80. package/dist/shared/role-view.js +89 -25
  81. package/dist/shared/role-view.js.map +1 -1
  82. package/dist/shared/session-label.js +4 -0
  83. package/dist/shared/session-label.js.map +1 -1
  84. package/dist/shared/task-file.js +16 -1
  85. package/dist/shared/task-file.js.map +1 -1
  86. package/dist/shared/turn-state.js +24 -0
  87. package/dist/shared/turn-state.js.map +1 -0
  88. package/dist/shared/world.js +37 -3
  89. package/dist/shared/world.js.map +1 -1
  90. package/dist/svr/index.js +2 -1
  91. package/dist/svr/index.js.map +1 -1
  92. package/hooks/pre-tool-use.sh +16 -3
  93. package/package.json +1 -1
  94. package/web/chat.css +173 -41
  95. package/web/chat.js +724 -245
@@ -1,8 +1,15 @@
1
1
  // Daemon entry. Resident process — exits only on signal or fatal WS auth failure.
2
+ import { existsSync, readFileSync, statSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { timingSafeEqual } from "node:crypto";
5
+ import { loadOrCreateSvrToken } from "../shared/svr-token.js";
6
+ import { DAEMON_TOKEN_FILE, DAEMON_TOKEN_HEADER } from "../shared/daemon-token.js";
2
7
  import { homedir } from "node:os";
3
8
  import { loadConfig } from "../shared/config.js";
4
9
  import { makeLogger } from "../shared/log.js";
5
- import { bindCliBackends } from "../shared/cli-backends.js";
10
+ import { clipLine, sleep } from "../shared/std.js";
11
+ import { EFFORTS, parseEffort } from "../shared/effort.js";
12
+ import { bindCliBackends, projectDirsFor } from "../shared/cli-backends.js";
6
13
  import { startWs } from "./ws.js";
7
14
  import { startNetWatch } from "./net-watch.js";
8
15
  import { startHttp, json, readBody } from "./http.js";
@@ -10,37 +17,39 @@ import { configGet, configSet } from "./config-api.js";
10
17
  import { installInboundRouter } from "./inbound.js";
11
18
  import { loadMirrorStore } from "./mirror-store.js";
12
19
  import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
13
- import { setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
20
+ import { runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
14
21
  import { installApprovalEventListener, makeApproveHandler } from "./approval.js";
15
- import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, recordPost } from "./detail.js";
22
+ import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, recordPost, recordCharter, backfillCharter } from "./detail.js";
16
23
  import { EMPTY_FACTS } from "../shared/world.js";
17
- import { initAutoWindowPersistence } from "./session-cache.js";
24
+ import { clearAutoWindow, initAutoWindowPersistence, setAutoWindow } from "./session-cache.js";
18
25
  import { makeMessageHandler } from "./outbound.js";
19
26
  import { makeCardHandler, makeAskHandler, installAskEventListener } from "./ask.js";
20
27
  import { drainForReload, listPending } from "./pending.js";
21
28
  import { makeClaimStartHandler, makeClaimStatusHandler, makeClaimResetHandler, } from "./claim.js";
22
29
  import { makeWedocBridge } from "./wedoc.js";
23
- import { installResponseTracker } from "./last-response.js";
30
+ import { errText, installResponseTracker } from "./last-response.js";
24
31
  import { scanClaudeSessions } from "./session-scan.js";
25
- import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, } from "./tasks.js";
32
+ import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, TASK_POSTPONE_MS, TASK_QUIET_WAIT_MS, } from "./tasks.js";
26
33
  import { openTaskRegistry } from "./task-registry.js";
27
34
  import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
28
- import { slugify, uniqueId } from "../shared/task-file.js";
29
- import { baseOfKey, bindTagLinker, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
30
- import { clipForCharter, inboxPath, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
31
- import { ensureStewardTask } from "./memory-steward.js";
35
+ import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
36
+ import { baseOfKey, bindTagLinker, isInternalKey, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
37
+ import { appendEpisode, clipForCharter, cwdOfMd, episodePath, inboxPath, mdsOf, proposedCwds, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
38
+ import { retireStewardTask, startSteward, stewardEnvelope, STEWARD_ID, STEWARD_RUN_MS, STEWARD_TARGET } from "./memory-steward.js";
32
39
  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, CONTEXT_FULL_TOKENS, } from "./wizard.js";
40
+ import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, backfillBorn, sweepUnborn, BOOT_ID, evictStaleName, childrenOf, ancestorsOf, renderCharter, ROSTER_FRESH_MS, renderRoster, handoffAt, memoryNudgeAt, MEMORY_NUDGE_FLOOR, MEMORY_MAX, NOTE_MAX, } from "./wizard.js";
34
41
  import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.js";
35
- import { loadJobStore, renderJobOpen, renderJobClose, JOB_MEMBER_MAX } from "./jobs.js";
36
- import { clipMiddle, contextFiles, extractResult, lastContextTokens, lastExchange, lastModel, replyClosedBefore, talkTurns, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
42
+ import { loadJobStore, jobEpisode, rejectReason, ACCEPTS, JOB_MEMBER_MAX } from "./jobs.js";
43
+ import { cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, lastContextTokens, lastExchange, lastModel, openingOf, replyClosedBefore, talkTurns, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
37
44
  import { keepalivePingSigs } from "../shared/keepalive.js";
38
45
  import { expandHome } from "../shared/paths.js";
39
46
  import { loadJsonMap } from "../shared/json-map-store.js";
40
- import { createReceipts } from "./receipts.js";
47
+ import { createReceipts, deadlineOf, kAttr, newTurn } from "./receipts.js";
48
+ import { renderInFlight } from "../shared/turn-state.js";
41
49
  import { createHandoffs, handedOff, handingOff } from "./handoff.js";
42
- import { rankCandidates, renderCandidates } from "./route.js";
43
- import { parseWhen, renderChatLog } from "./chat-log.js";
50
+ import { rankCandidates, renderCandidates, wakeNoteOf } from "./route.js";
51
+ import { parseWhen, renderChatLog, UNKNOWN_HUMAN } from "./chat-log.js";
52
+ import { audienceOf } from "../shared/role-view.js";
44
53
  import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
45
54
  // pino's file transport is async; without flush, log.fatal before process.exit
46
55
  // vanishes. Mirror anything fatal to stderr too so launchd's stderr.log captures it.
@@ -59,6 +68,9 @@ const main = async () => {
59
68
  log.info({ sourcePath, pid: process.pid }, "daemon start");
60
69
  // 定时调度器的 `inject` 真正实现, 赋值在 mirror-mode 的大块里 (要用到那里面的
61
70
  // wizards / m / notifyChat), 但 startScheduler 在那块外面接线 —— 见文件尾。
71
+ // 记忆整理者的执行体, 同样只有 mirror 模式给得出 —— 赋值见 scheduledTaskInject 旁边。
72
+ let stewardRun;
73
+ let stewardWiring = {};
62
74
  let scheduledTaskInject;
63
75
  // Bind the CLI backend registry. `primary` (= defaultCli) drives new-session
64
76
  // spawns; `backends` is every installed CLI whose transcript root exists, so
@@ -90,12 +102,15 @@ const main = async () => {
90
102
  const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
91
103
  // 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
92
104
  // (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
93
- // 共享记忆的整理者是一条定时任务 (memory-steward.ts), 缺了就补一份默认的。
105
+ // 共享记忆的整理者改成了 daemon 内建定时器 (memory-steward.ts); 老版本写下的那条
106
+ // 定时任务要在任务表加载前停用, 否则两路并跑。
94
107
  try {
95
- ensureStewardTask(memoryRoot(cfg.daemon.stateDir));
108
+ const retired = retireStewardTask();
109
+ if (retired)
110
+ log.info({ retired }, "memory steward task file retired");
96
111
  }
97
112
  catch (e) {
98
- log.warn({ err: e.message }, "memory steward task not written");
113
+ log.warn({ err: e.message }, "memory steward task not retired");
99
114
  }
100
115
  const tasks = await openTaskRegistry(log.child({ mod: "tasks" }), (added, removed) => log.info({ added, removed }, "task files changed"));
101
116
  const bridge = startMirror({ cfg, log: log.child({ mod: "mirror" }), client: ws.client, store: mirrorStore });
@@ -145,9 +160,23 @@ const main = async () => {
145
160
  http.register("POST /message", makeMessageHandler(ws.client, log.child({ mod: "outbound" })));
146
161
  http.register("POST /card", makeCardHandler(ws.client, log.child({ mod: "outbound" })));
147
162
  http.register("POST /ask", makeAskHandler(ws.client, log.child({ mod: "ask" })));
148
- http.register("POST /claim/start", makeClaimStartHandler({ log: log.child({ mod: "claim" }) }));
163
+ // 写敏感状态的路由只认出示口令的 —— wezard 的 MCP server 与 CLI 读得到 0600 的口令文件
164
+ // (见 shared/daemon-token.ts), 本机别的进程 curl 不进来: 写配置、改 allowFrom 的认领
165
+ // 口令、落一份 daemon 会执行其 gate 的任务文件, 都不该是谁连上 127.0.0.1 就能做的。
166
+ const daemonToken = loadOrCreateSvrToken(DAEMON_TOKEN_FILE);
167
+ const tokenOk = (req) => {
168
+ const got = Buffer.from(String(req.headers[DAEMON_TOKEN_HEADER] ?? ""));
169
+ const want = Buffer.from(daemonToken);
170
+ return got.length === want.length && timingSafeEqual(got, want);
171
+ };
172
+ const guarded = (h) => async (req, res, url, ctx) => {
173
+ if (tokenOk(req))
174
+ return h(req, res, url, ctx);
175
+ json(res, 403, { ok: false, reason: `这条路由要出示 ${DAEMON_TOKEN_FILE} 里的口令 (header ${DAEMON_TOKEN_HEADER}); 正在跑的 wizard 若是旧版 MCP 进程, 重开会话即可` });
176
+ };
177
+ http.register("POST /claim/start", guarded(makeClaimStartHandler({ log: log.child({ mod: "claim" }) })));
149
178
  http.register("GET /claim/status", makeClaimStatusHandler());
150
- http.register("POST /claim/reset", makeClaimResetHandler());
179
+ http.register("POST /claim/reset", guarded(makeClaimResetHandler()));
151
180
  http.register("GET /detail", makeDetailHandler(log.child({ mod: "detail" })));
152
181
  for (const [key, handler] of Object.entries(chatHandlers()))
153
182
  http.register(key, handler);
@@ -532,6 +561,8 @@ const main = async () => {
532
561
  const home = baseOfKey(self);
533
562
  const cur = wizards.get(home)?.name ?? "";
534
563
  const wizardRenamed = previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined;
564
+ if (wizardRenamed)
565
+ void m.retitlePane(home);
535
566
  json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(wizardRenamed ? { wizardRenamed } : {}) });
536
567
  });
537
568
  const readPeerBody = async (req) => {
@@ -559,7 +590,7 @@ const main = async () => {
559
590
  const chatId = chatIdOf(base);
560
591
  void ws.client
561
592
  .sendMessage(chatId, { msgtype: "markdown", markdown: { content: linkTags(base, markdown) } })
562
- .catch((e) => log.warn({ err: e.message }, "chat notify failed"));
593
+ .catch((e) => log.warn({ chatId, err: errText(e) }, "chat notify failed"));
563
594
  };
564
595
  // wizard 在群里的称呼, 只用在**正文**里。气泡的头照旧交给 withTagHeader ——
565
596
  // 那一段是路由信息 (`emoji #tag`), parseTagHeader 靠它反解, 群里引用一条气泡
@@ -589,13 +620,30 @@ const main = async () => {
589
620
  * 私聊轮或无记录 → 它的 home 群。notify 与 public send_peer 默认发到这里。 */
590
621
  const channelOf = (self) => m.currentChannel(self) || baseOfKey(self);
591
622
  /** 同伴注入的信封: 公开轮写上那个群的名字, 私聊不写 (见 peers.renderPeerEnvelope)。 */
592
- const envelopeFor = (from, channel) => renderPeerEnvelope(displayName(from), channel ? chatNameOf(cfg, channel) : undefined);
623
+ const envelopeFor = (from, channel, t) => renderPeerEnvelope(displayName(from), channel ? chatNameOf(cfg, channel) : undefined, t);
593
624
  // 回执 (见 receipts.ts): 一次 tell_peer 之后对方干完那一轮, 它的结论自动 paste
594
625
  // 回发话方的输入框, 发话方全程不阻塞。这里只提供四样能力给那个模块 —— 忙闲、
595
626
  // 死活、「它答我那一句的是哪段话」、以及怎么把一段话送进去 —— 投递逻辑在那边。
627
+ // 工单账本 (见 jobs.ts)。只在**显式传了 `job`** 时起作用 —— 不用工单的调用方
628
+ // 行为与从前一模一样。回执要数它的成员, 所以先于回执载入。
629
+ const jobs = loadJobStore(cfg.wrc.mirror.jobsFile);
630
+ /** 回执那一轮在群里的终句答给谁 (userid) —— 与 rolepage 同一个判定, 写进信封给模型与
631
+ * read_chat; 认不出 = ""。 */
632
+ const audienceName = (channel, asker) => {
633
+ const id = audienceOf(channel, asker);
634
+ return id === "human:" ? "" : id.slice("human:".length);
635
+ };
596
636
  const receipts = createReceipts({
597
- isBusy: m.isBusy,
598
637
  idleNow: m.idleNow,
638
+ jobTally: (id) => jobs.tally(id),
639
+ parkedNow: m.parkedNow,
640
+ settleJob: (id, target, outcome, artifacts) => { jobs.settle(id, target, outcome, artifacts); },
641
+ accept: (id, body) => { const j = jobs.get(id); return j?.status === "open" ? rejectReason(j.accept ?? "result", parseClosing(body)) : undefined; },
642
+ rebound: async (x, why) => (await m.injectText(x.to, `(守护进程代 ${displayName(x.from)} 打回一次: 这件活是工单里的一份, 你的回复${why}。请按约定收口 —— 末尾一行 \`RESULT: <结论>\`, 交付了文件每个加一行 \`ARTIFACT: <路径> — 一句话\`; 真缺信息就 \`NEED: <问题>\`。)`, undefined, {
643
+ from: { kind: "peer", from: x.from, turn: x.turn, ...(x.asker ? { asker: x.asker } : {}) },
644
+ channel: x.channel,
645
+ envelope: envelopeFor(x.from, x.channel, { turn: x.turn, legs: x.legs, re: true }),
646
+ })).ok,
599
647
  untilIdle: m.untilIdle,
600
648
  paneLive: m.paneLive,
601
649
  nameOf: displayName,
@@ -603,22 +651,29 @@ const main = async () => {
603
651
  store: loadJsonMap(cfg.wrc.mirror.receiptsFile),
604
652
  handingOff,
605
653
  handedOff,
606
- // 信封锚取不到 (对方是个还不挂信封的老 wizard) 时退回按时刻取, 由 receipts
607
- // 区分这两种"没有"。
608
- replyFor: (to, fromName, since) => m.replyToPeer(to, fromName, since),
654
+ replyFor: (to, fromName, since, turn) => m.replyToPeer(to, fromName, since, turn),
655
+ lastWords: m.lastReply,
609
656
  deliver: (to, bodyText, meta) => m.injectText(to, clipMiddle(bodyText), undefined, {
610
657
  // receipt:true 是「这一轮不该再生回执」的记录 —— 回环在结构上就不成立:
611
658
  // 只有 tell_peer 那条路登记 watcher, 这里是直接注入。
612
- from: { kind: "peer", from: meta.from, receipt: true, ...(meta.job ? { job: meta.job } : {}) },
659
+ from: { kind: "peer", from: meta.from, receipt: true, status: meta.status, ...(meta.turn ? { turn: meta.turn } : {}), ...(meta.job ? { job: meta.job, done: meta.done, total: meta.total } : {}), ...(meta.asker ? { asker: meta.asker } : {}) },
613
660
  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),
661
+ envelope: renderReceiptEnvelope(displayName(meta.from), meta.channel ? chatNameOf(cfg, meta.channel) : undefined, meta.job ? { job: meta.job, done: meta.done, total: meta.total, closed: jobs.get(meta.job)?.status !== "open" } : undefined, meta.status, meta.turn || undefined, {
662
+ ...(meta.replyTo ? { replyTo: meta.replyTo.kind === "chat" ? chatNameOf(cfg, meta.replyTo.channel) : displayName(meta.replyTo.from) } : {}),
663
+ ...(meta.k ? { k: kAttr(meta.k) } : {}),
664
+ ...(meta.pending ? { pending: meta.pending } : {}),
665
+ ...(audienceName(meta.channel, meta.asker) ? { audience: audienceName(meta.channel, meta.asker) } : {}),
666
+ }),
615
667
  }),
616
668
  });
669
+ const turnsLine = (xs, target) => renderInFlight(xs, target, displayName);
617
670
  // reload 前在飞的回执: 等 mirror 把绑定都恢复了再续守 —— 早一步读 target 会拿不到
618
671
  // transcript, 退回按时刻取就可能把上一件事的结论当回执。
619
672
  // 交接 (见 handoff.ts): 换会话不换身份。这里给它重开、贴话、读旧 transcript 的能力,
620
673
  // 闸、回执清点与断点续做在那边。
621
674
  const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
675
+ /** 情景记忆 (memory/episodes/<name>.jsonl) 按名字归档; 默认会话没名字时退回 slot / "default"。 */
676
+ const episodeName = (t) => wizards.get(t)?.name || tagOfKey(t) || "default";
622
677
  const handoffs = createHandoffs({
623
678
  isBusy: m.isBusy,
624
679
  sessionId: (t) => m.sessionInfo(t)?.sessionId ?? "",
@@ -627,15 +682,15 @@ const main = async () => {
627
682
  // 频道也接回原来那一句所在的地方 (私聊不进群)。
628
683
  inject: (t, text, owe) => owe.length
629
684
  ? m.injectText(t, text, undefined, {
630
- from: { kind: "peer", from: owe[0].from },
685
+ from: { kind: "peer", from: owe[0].from, ...(owe[0].turn ? { turn: owe[0].turn } : {}), ...(owe[0].asker ? { asker: owe[0].asker } : {}) },
631
686
  channel: owe[0].channel,
632
- envelope: owe.map((o) => envelopeFor(o.from, o.channel)).join(""),
687
+ envelope: owe.map((o) => envelopeFor(o.from, o.channel, o.turn ? { turn: o.turn } : undefined)).join(""),
633
688
  })
634
689
  : m.injectText(t, text),
635
690
  lastText: m.lastText,
636
- answeredBefore: (to, from, since, until) => {
691
+ answeredBefore: (to, from, since, until, turn) => {
637
692
  const p = m.sessionInfo(to)?.jsonlPath;
638
- return p ? replyClosedBefore(expandHome(p), displayName(from), since, until, pingSigs) : undefined;
693
+ return p ? replyClosedBefore(expandHome(p), displayName(from), since, until, pingSigs, turn) : undefined;
639
694
  },
640
695
  receipts,
641
696
  // 交接是生命周期事件, 不进群; 失败只捎给它自己 (它调完 handoff_self 拿到的是
@@ -643,7 +698,66 @@ const main = async () => {
643
698
  notify: (t, text) => notices.post([t], text),
644
699
  log,
645
700
  store: loadJsonMap(cfg.wrc.mirror.handoffsFile),
701
+ archive: (e) => {
702
+ if (!e.brief.trim())
703
+ return;
704
+ const name = episodeName(e.target);
705
+ const ok = appendEpisode(episodePath(cfg.daemon.stateDir, name), { at: e.at, kind: "handoff", name, sid: e.sid, nextSid: e.nextSid, text: e.brief });
706
+ if (!ok)
707
+ log.warn({ target: e.target, name }, "handoff episode not archived");
708
+ },
646
709
  });
710
+ /** `self` 此刻这一轮派出去的活, 结论该交给谁 (见 receipts.parentOf)。只读它自己
711
+ * transcript 里开这一轮的那句 user 行的信封: attachment 上的 channel 在注入时就被
712
+ * 排队的下一句改写了, transcript 才记着「这一轮是谁发起的」, reload 后也读得到。 */
713
+ /** `target` 手上这一轮是哪件 peer 活 —— 认开头那句的信封: 有件号按件号, 老信封按发话方。 */
714
+ /** `target` 手上这一轮若是某个 wizard 派来的活: 发话方的称呼 (`.x`) 与件号。人 / 定时 / 回执轮 = undefined。 */
715
+ const openingAsk = (target) => {
716
+ const p = m.sessionInfo(target)?.jsonlPath;
717
+ const env = p ? openingOf(talkTurns(expandHome(p), 80, pingSigs, false, true))?.env : undefined;
718
+ return env?.kind === "peer" && !env.receipt ? { from: env.from, ...(env.turn ? { turn: env.turn } : {}) } : undefined;
719
+ };
720
+ const currentAsk = (target) => {
721
+ const env = openingAsk(target);
722
+ if (!env)
723
+ return () => false;
724
+ return (x) => (env.turn ? x.turn === env.turn : displayName(x.from) === env.from);
725
+ };
726
+ /** 打断 `target` 会不会误伤别人的活: 它正忙, 且这一轮不是调用方派的 —— 别的 wizard 派的、
727
+ * 人说的、定时任务放的 → 那一方的称呼。回执轮是它自己那件活的延续, 不算别人的。 */
728
+ const othersTurn = async (target, self) => {
729
+ if (await m.idleNow(target))
730
+ return undefined;
731
+ const p = m.sessionInfo(target)?.jsonlPath;
732
+ const env = p ? openingOf(talkTurns(expandHome(p), 80, pingSigs, false, true))?.env : undefined;
733
+ if (env?.receipt)
734
+ return undefined;
735
+ if (env?.kind === "peer")
736
+ return env.from === displayName(self) ? undefined : { from: env.from, ...(env.turn ? { turn: env.turn } : {}) };
737
+ return { from: env?.kind === "task" ? env.from : env?.from ? `人 (${env.from})` : "人" };
738
+ };
739
+ const openingOfSelf = (self) => {
740
+ const p = m.sessionInfo(self)?.jsonlPath;
741
+ return p ? openingOf(talkTurns(expandHome(p), 80, pingSigs, false, true)) : undefined;
742
+ };
743
+ const parentKOf = (self) => {
744
+ const opening = openingOfSelf(self);
745
+ return receipts.parentOf(self, opening?.env, channelOf(self), String(opening?.ms ?? 0));
746
+ };
747
+ /** `self` 此刻这一轮的链头 (见 Asker), 同样认开头那句的信封: 人在群里说的 → 信封上的
748
+ * userid 与群; 与人的单聊里没挂信封 → 那个人; 同伴派的活 / 回执 → 登记那件活时记下的;
749
+ * 定时任务、CLI 敲字、认不出 → undefined (如实不知道, 不猜)。 */
750
+ const askerOf = (self) => {
751
+ const env = openingOfSelf(self)?.env;
752
+ const home = baseOfKey(self);
753
+ if (!env)
754
+ return home.startsWith("user:") ? { who: home, chat: home } : undefined;
755
+ if (env.kind === "human") {
756
+ const chat = env.chat ? chatBaseOf(cfg, env.chat) : home;
757
+ return chat ? { who: `user:${env.from}`, chat } : undefined;
758
+ }
759
+ return env.kind === "peer" ? receipts.askerOf(self, env) : undefined;
760
+ };
647
761
  // 交接先续做: 它的闸要在回执续守之前立起来, 否则续守的 watcher 会读到换了一半的会话。
648
762
  void m.restored.then(() => { handoffs.resume(); receipts.resume(); });
649
763
  /** 入参里的地址: 新字段 `name`, 老 MCP 进程 (正在跑的 wizard) 仍在传 `tag`。 */
@@ -728,13 +842,14 @@ const main = async () => {
728
842
  const refs = (b.to ?? []).map((r) => (r ?? "").trim()).filter(Boolean);
729
843
  const bad = refs.filter((r) => !chatBaseOf(cfg, r));
730
844
  if (bad.length) {
731
- json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (list_chats 看有哪些; 没起名的聊天寻址不到)` });
845
+ json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
732
846
  return;
733
847
  }
734
848
  const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
849
+ const asker = askerOf(self);
735
850
  for (const dest of dests) {
736
851
  notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
737
- recordPost({ target: self, channel: dest, body: content });
852
+ recordPost({ target: self, channel: dest, body: content, ...(asker ? { asker } : {}) });
738
853
  }
739
854
  json(res, 200, { ok: true, sent: dests.map((d) => chatNameOf(cfg, d) || d) });
740
855
  });
@@ -755,6 +870,7 @@ const main = async () => {
755
870
  // 截断的视口加一层 TUI 装饰, 而「它卡在哪」transcript 答得更准 —— 不在转圈
756
871
  // 却悬着工具调用, 就是停在审批卡 / 本地弹窗上等人点。
757
872
  const peek = await m.peekTurns(target, turns);
873
+ const inflight = turnsLine(receipts.states(), target);
758
874
  const waiting = peek.waiting ?? [];
759
875
  const state = peek.busy
760
876
  ? "正在生成"
@@ -766,7 +882,7 @@ const main = async () => {
766
882
  name: peerAddress(cfg, self, target),
767
883
  busy: peek.busy ?? false,
768
884
  ...(waiting.length ? { waiting } : {}),
769
- text: [`${displayName(target)} · ${state}${foreign ? ` · 住在群 ${chatNameOf(cfg, target) || "(未命名)"}` : ""}`, peek.dialog ?? `(${peek.reason})`].join("\n"),
885
+ text: [`${displayName(target)} · ${state}${foreign ? ` · 住在群 ${chatNameOf(cfg, target) || "(未命名)"}` : ""}`, ...(inflight ? [inflight] : []), peek.dialog ?? `(${peek.reason})`].join("\n"),
770
886
  });
771
887
  });
772
888
  // ── 聊天记录 ────────────────────────────────────────────────────────
@@ -785,7 +901,7 @@ const main = async () => {
785
901
  name: displayName(t.target),
786
902
  jsonlPath: t.jsonlPath,
787
903
  homeChat: chatNameOf(cfg, home),
788
- homeHuman: home.startsWith("user:") ? home.slice(5) : "人",
904
+ homeHuman: home.startsWith("user:") ? home.slice(5) : UNKNOWN_HUMAN,
789
905
  since: rec?.clonedFrom ? rec.bornAt : 0,
790
906
  };
791
907
  };
@@ -809,7 +925,7 @@ const main = async () => {
809
925
  // 什么都没给 = 调用方这一轮所在的群; 只给 target = 调用方与它的往来。
810
926
  const base = chatRef ? chatBaseOf(cfg, chatRef) : roleRef || targetRef ? "" : channelOf(self);
811
927
  if (chatRef && !base) {
812
- json(res, 404, { ok: false, reason: `认不出聊天 '${chatRef}' (list_chats 看有哪些)` });
928
+ json(res, 404, { ok: false, reason: `认不出聊天 '${chatRef}' (wizard_roster 每行有 home 聊天名, 人侧 /chats)` });
813
929
  return;
814
930
  }
815
931
  const role = roleRef ? logRole(self, roleRef) : targetRef ? { name: displayName(self), target: self } : undefined;
@@ -866,26 +982,41 @@ const main = async () => {
866
982
  json(res, 400, { ok: false, reason: "text required" });
867
983
  return;
868
984
  }
869
- const jobId = (body.job ?? "").trim();
870
- if (jobId) {
871
- const jc = checkJob(jobId);
872
- if (!jc.ok) {
873
- json(res, jc.status, { ok: false, reason: jc.reason });
874
- return;
875
- }
876
- }
985
+ // 这句话要对方怎么接 (priority 管何时投, receipt 管要不要回执, 这个管它怎么接):
986
+ // task 一件活 (默认) / ask 只答一问 / fyi 知会 —— fyi 不要回执、不进工单、不进群。
987
+ const rawKind = body.kind;
988
+ const kind = rawKind === "ask" || rawKind === "fyi" ? rawKind : "task";
877
989
  const r = resolvePeer(self, addrOf(body));
878
990
  if (!r.ok) {
879
991
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
880
992
  return;
881
993
  }
882
994
  const { target, foreign } = r;
995
+ // 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
996
+ const re = (body.re ?? "").toString().trim();
997
+ const turn = receipts.prepare(self, target, re || undefined);
998
+ // 续问只在那个工单还开着时沿用它: 收了工的工单不再收成员, 续问照样能发。
999
+ // 没带工单的一句顶掉一份还没落定的工单活, 也算在那张工单上 (receipts.register 同理
1000
+ // 继承): 不然换个不带 job 的 tell_peer 就绕开了预算。
1001
+ const open = receipts.pending(self, target);
1002
+ const isOpen = (id) => (id && jobs.get(id)?.status === "open" ? id : "");
1003
+ const askedJob = (body.job ?? "").trim();
1004
+ const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || isOpen(open?.job);
1005
+ if (jobId) {
1006
+ const jc = checkJob(jobId, target, !!open?.need && open.turn === turn.turn);
1007
+ if (!jc.ok) {
1008
+ json(res, jc.status, { ok: false, reason: jc.reason });
1009
+ return;
1010
+ }
1011
+ }
883
1012
  // 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
884
1013
  // 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
885
1014
  // 只有两端都是登记在册的 wizard 才能当着人说: 裸 target 冒充的发话方、forget
886
1015
  // 掉的分身不是谁, 它们的往来进群只会留下一段人找不到主的对话 —— 降为私聊。
887
- const isPublic = body.public === true && !!wizards.get(self) && !!wizards.get(target);
888
- const channel = isPublic ? channelOf(self) : "";
1016
+ // 工单里的派活一律私聊: 人看不懂 fan-out 的过程, 群里只留发起者自己的收口。
1017
+ // 知会不进群: 公开信封要对方「照对人说话的方式答」, 一句「收到」会被发给一群人。
1018
+ const isPublic = !jobId && kind !== "fyi" && (turn.channel !== undefined ? !!turn.channel : body.public === true) && !!wizards.get(self) && !!wizards.get(target);
1019
+ const channel = isPublic ? turn.channel || channelOf(self) : "";
889
1020
  // Injecting into your own pane would type into the box you're generating
890
1021
  // from — Claude Code queues it and the caller deadlocks waiting for itself.
891
1022
  if (target === self) {
@@ -895,40 +1026,90 @@ const main = async () => {
895
1026
  // 它正在交接: 这句话等它换完会话再进 —— 投进旧会话会被交接一并带走, 投进重开中的
896
1027
  // 空 pane 会 resume 回旧 sid (见 handoff.ts)。发话时刻取在这之后。
897
1028
  await handedOff(target);
898
- // 投递时机。默认照旧立刻投 —— 「回答它的提问」「打断它」本来就是冲着一个
899
- // 正在忙的会话去的。`when:"idle"` 是**派新活**该用的那一种: 两个 wizard 同时
900
- // 找第三个时, 两段文本会挤进同一个输入框、被当成一轮读掉, 这在协同网络里
901
- // 不是边角情况而是常态。等它闲下来再投, 一句话就是一轮。
902
- const when = (body.when ?? "now") === "idle" ? "idle" : "now";
1029
+ // 投递策略。对方闲着三者一样 —— 立刻投; 只在它正忙时分岔:
1030
+ // normal (默认) 等它这一轮结束再投 —— 派新活的常态: 两个 wizard 同时找第三个时,
1031
+ // 两段文本会挤进同一个输入框、被当成一轮读掉, 这在协同网络里不是边角情况而是常态;
1032
+ // urgent 先打断它这一轮 (与 stop_wizard 的 interrupt 同一下 Esc) 再投 —— 紧急改道;
1033
+ // now 不等不打断, 落进输入框由 CLI 并进它这一轮 —— 插话、补一句、答它的问。
1034
+ // 老 MCP 进程只认 `when` (默认 now): idle ≡ normal, now ≡ now。都没给时, `re` 续问默认 now ——
1035
+ // 续问是同一场来回里的应答, 对方可能正挂在 wait_peer 上等你, 等它闲下来就互相干等到超时。
1036
+ const bp = body;
1037
+ const asked = bp.priority === "urgent" ? "urgent"
1038
+ : bp.priority === "normal" ? "normal"
1039
+ : bp.priority === "now" || bp.when === "now" ? "now"
1040
+ : bp.when === "idle" ? "normal"
1041
+ : re ? "now" : "normal";
903
1042
  const waitSec = Math.min(Math.max(Number(body.waitSec ?? 600) || 600, 10), 3600);
904
1043
  // 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
905
1044
  const wasBusy = !(await m.idleNow(target));
1045
+ // urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
1046
+ // 打断就误伤了那件 —— 降成 normal, 等它这一轮做完再投。
1047
+ const others = asked === "urgent" && wasBusy ? await othersTurn(target, self) : undefined;
1048
+ const policy = others ? "normal" : asked;
906
1049
  let waitedMs = 0;
907
- if (when === "idle" && wasBusy) {
1050
+ let interrupted;
1051
+ if (policy === "normal" && wasBusy) {
908
1052
  const t0 = Date.now();
909
1053
  // 它那一轮一结束就投: 注册表 status 翻 idle 是事件, 不抽样也不等稳定期。
910
1054
  const wr = await m.untilIdle(target, waitSec * 1000);
911
1055
  waitedMs = Date.now() - t0;
912
1056
  if (!wr.idle) {
913
- json(res, 409, { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪, 或者用 when:"now" 插队` });
1057
+ json(res, 409, { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪; 真急就 priority:"urgent" 打断它` });
914
1058
  return;
915
1059
  }
916
1060
  }
1061
+ if (policy === "urgent" && wasBusy) {
1062
+ // 只按 Esc, 不走 /stop 的 teardown: 那会清掉它的注入队列 (别人排着的话一起丢)
1063
+ // 并暂停 keepalive —— 这里打断它是为了马上接着干, 不是让它安静。
1064
+ const t0 = Date.now();
1065
+ const esc = await m.interruptPane(target);
1066
+ // 等 Esc 生效、这一轮真收住再投; 收不住也照投 —— 落进输入框排在后面, 退化成 now。
1067
+ interrupted = esc.ok && (await m.untilIdle(target, 15_000)).idle;
1068
+ waitedMs = Date.now() - t0;
1069
+ }
917
1070
  // 时刻取在注入**之前**: 晚于那一句落盘的话, 回执定位的下界就偏了。
918
1071
  const at = Date.now();
1072
+ // 唤醒代价同样要在注入之前量: 投进去 transcript 一动, 就看不出它冷了多久。
1073
+ const info = m.sessionInfo(target);
1074
+ const wake = info?.jsonlPath
1075
+ ? wakeNoteOf(info.contextTokens, (() => { try {
1076
+ return statSync(info.jsonlPath).mtimeMs;
1077
+ }
1078
+ catch {
1079
+ return 0;
1080
+ } })(), at, (cacheTtlSec(info.jsonlPath) || cfg.wrc.mirror.keepalive.ttlSec) * 1000)
1081
+ : "";
1082
+ const deadlineSec = body.deadline;
1083
+ // 回执: 对方干完那一轮, 守护进程把它的结论自动送回来 (默认开)。`receipt:false`
1084
+ // 是"放出去就不管了"的那种派活; fyi 一定不要。对方的信封据此改口, 不再让它收口。
1085
+ const wantReceipt = kind !== "fyi" && body.receipt !== false;
1086
+ const deadlineAt = deadlineOf(at, deadlineSec === undefined ? undefined : Number(deadlineSec));
1087
+ const asker = askerOf(self);
919
1088
  const inj = await m.injectText(target, text, undefined, {
920
- from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}) },
1089
+ from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
921
1090
  channel,
922
- envelope: envelopeFor(self, channel),
1091
+ envelope: envelopeFor(self, channel, {
1092
+ turn: turn.turn,
1093
+ legs: turn.legs,
1094
+ ...(turn.legs > 1 ? { re: true } : {}),
1095
+ // 期限只在发话方明说时写给对方看: 默认的一小时不值得每轮多一句。
1096
+ ...(deadlineSec !== undefined ? { deadline: deadlineAt } : {}),
1097
+ ...(kind !== "task" ? { act: kind } : {}),
1098
+ ...(wantReceipt ? {} : { quiet: true }),
1099
+ }),
923
1100
  });
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);
1101
+ // 注入失败就不守 —— 没有问话, 也不会有回答。不要回执 (receipt:false / fyi) 就不登记:
1102
+ // 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
1103
+ // 父 k: 默认这件活算发话方此刻在答的那件的子活 (链式续回); `chain:false` = 旁支,
1104
+ // 回执照常回来, 但不挂住它给上游的交代 —— 只有发话方知道这件活是不是为上游派的。
1105
+ const k = body.chain === false ? undefined : parentKOf(self);
1106
+ if (inj.ok && wantReceipt)
1107
+ receipts.register({ from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
929
1108
  // 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
1109
+ // 续问 (re) 不是一段新活: 工单页与留档里该列的仍是当初派的那段。
930
1110
  if (inj.ok && jobId)
931
- jobs.attach(jobId, { target, task: text, spawned: false });
1111
+ jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false });
1112
+ const spent = inj.ok && jobId ? jobs.spend(jobId) : undefined;
932
1113
  if (inj.ok && isPublic)
933
1114
  relayPeer(self, target, text, channel);
934
1115
  // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
@@ -937,13 +1118,25 @@ const main = async () => {
937
1118
  ...inj,
938
1119
  name: peerAddress(cfg, self, target),
939
1120
  public: isPublic,
1121
+ priority: policy,
1122
+ ...(kind !== "task" ? { kind } : {}),
1123
+ ...(kind === "fyi" && askedJob ? { jobIgnored: true } : {}),
1124
+ ...(others ? { urgentDowngraded: `它此刻在做 ${others.from} 派的活, 没打断它 —— 按 normal 等它这一轮做完再投` } : {}),
940
1125
  wasBusy,
1126
+ ...(interrupted !== undefined ? { interrupted } : {}),
941
1127
  ...(waitedMs ? { waitedMs } : {}),
942
1128
  ...(jobId ? { job: jobId } : {}),
1129
+ ...(inj.ok ? { turn: turn.turn } : {}),
1130
+ ...(spent?.maxTurns ? { budget: `${spent.turns}/${spent.maxTurns}${spent.turns >= spent.maxTurns ? " —— 用完了, 这是最后一次派活, 下一步收口" : ""}` } : {}),
1131
+ ...(inj.ok && wake ? { wakeCost: wake } : {}),
1132
+ ...(turn.reUnknown ? { reUnknown: true, reNote: `re "${re}" 不是你正在等 ${displayName(target)} 答的那件, 按新活发出` } : {}),
943
1133
  // 回执怎么回来 —— 写在回包里, 调用方 (模型) 不必从工具描述里回忆。
944
1134
  ...(inj.ok && wantReceipt
945
1135
  ? { receipt: "它干完那一轮, 结论会作为新的一轮自动进到你这里 —— 不要挂在 wait_peer 上等, 接着干你自己的事" }
946
1136
  : {}),
1137
+ ...(inj.ok && wantReceipt && k?.kind === "peer"
1138
+ ? { chained: `这件活算在你答 ${displayName(k.from)} 的那件 (${k.turn}) 名下: 它回来之前, 你这一轮的终句不会先交给 ${displayName(k.from)}; 与那件活无关就带 chain:false 发` }
1139
+ : {}),
947
1140
  });
948
1141
  };
949
1142
  http.register("POST /peers/tell", tellPeer);
@@ -994,13 +1187,16 @@ const main = async () => {
994
1187
  // 取走就占位: 同一段话不会再作为回执 paste 进来一遍 (见 receipts.claim)。
995
1188
  const full = wr.idle ? m.lastReply(h.target, receipts.sentAt(self, h.target)) : "";
996
1189
  const already = full ? receipts.claim(self, h.target) : false;
997
- const result = extractResult(full);
1190
+ const closing = parseClosing(full);
1191
+ const result = closing.kind === "result" ? closing.text : "";
998
1192
  return {
999
1193
  name: h.address,
1000
1194
  idle: wr.idle,
1001
1195
  // 回执早一步到了: 这段话已经作为新的一轮进过你的会话, 别再处理第二遍。
1002
1196
  ...(already ? { delivered: true } : {}),
1003
1197
  ...(wr.reason ? { reason: wr.reason } : {}),
1198
+ ...(closing.kind === "need" ? { need: closing.text } : {}),
1199
+ ...(closing.artifacts.length ? { artifacts: closing.artifacts } : {}),
1004
1200
  ...(result
1005
1201
  ? { result, ...(full.length > result.length + 200 ? { omitted: full.length - result.length } : {}) }
1006
1202
  : full
@@ -1027,6 +1223,17 @@ const main = async () => {
1027
1223
  // 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
1028
1224
  // 全新的进程(同名、同 cwd),把简报作为新会话的首条消息贴进去。拒绝对调用方
1029
1225
  // 自身操作(会 deadlock,同 /peers/send)。
1226
+ /** 交接自己: 简报由调用方给, 排在它这一轮说完之后 (见 handoff.ts)。/handoff 点名点到
1227
+ * 自己与老路由 /wizard/handoff-self 共用。 */
1228
+ const handoffSelf = (self, brief) => {
1229
+ if (brief.length < 40)
1230
+ return { status: 400, body: { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" } };
1231
+ const info = m.sessionInfo(self);
1232
+ const r = handoffs.self(self, brief);
1233
+ return r.ok
1234
+ ? { status: 200, body: { ok: true, target: self, contextTokens: info?.contextTokens ?? 0, scheduled: true } }
1235
+ : { status: 409, body: { ok: false, reason: r.reason } };
1236
+ };
1030
1237
  http.register("POST /handoff", async (req, res) => {
1031
1238
  const { self, body } = await readPeerBody(req);
1032
1239
  if (!self) {
@@ -1043,6 +1250,12 @@ const main = async () => {
1043
1250
  }
1044
1251
  }
1045
1252
  else {
1253
+ // 不点名不能落成「本聊天的默认 wizard」(resolvePeer 对 "" 的语义): 交接是把人家的
1254
+ // 上下文清掉重开, 一个漏传的参数不该把群管家交接掉。不点名的交接只能是交接自己。
1255
+ if (!addrOf(body).trim()) {
1256
+ json(res, 400, { ok: false, reason: "要点名 (name) —— 交接你自己走 /wizard/handoff-self (MCP: handoff 不传 name, 带 brief)" });
1257
+ return;
1258
+ }
1046
1259
  const r = resolvePeer(self, addrOf(body));
1047
1260
  if (!r.ok) {
1048
1261
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
@@ -1050,10 +1263,16 @@ const main = async () => {
1050
1263
  }
1051
1264
  target = r.target;
1052
1265
  }
1053
- // 注入到自己的 pane = 往正在生成的输入框里打字,Claude Code 会把它排队,
1054
- // 调用方等自己等成死锁(同 /peers/send)。
1266
+ // 点名点到自己: 不能走「让它写简报」那条 (往正在生成的输入框里打字, 等自己等成
1267
+ // 死锁), 改走交接自己 —— 简报得由调用方自己给。
1055
1268
  if (target === self) {
1056
- json(res, 400, { ok: false, reason: "refusing to hand off the calling session itself (would deadlock)" });
1269
+ const brief = (body.brief ?? "").toString().trim();
1270
+ if (!brief) {
1271
+ json(res, 400, { ok: false, reason: "这是交接你自己: 把简报写在 brief 里 (不传 name 即可; 旧会话的 handoff 没有 brief 参数, 用 wizard_handoff_self)" });
1272
+ return;
1273
+ }
1274
+ const r = handoffSelf(self, brief);
1275
+ json(res, r.status, r.body);
1057
1276
  return;
1058
1277
  }
1059
1278
  const focus = (body.focus ?? "").toString().trim();
@@ -1068,12 +1287,26 @@ const main = async () => {
1068
1287
  // 上下文窗口也挤不掉。
1069
1288
  // 这里只做三件事: 读身份、改身份、按身份生/收分身。tmux 一概不碰, 动作全在
1070
1289
  // mirror-bridge。
1071
- const wizards = bindWizardStore(loadWizardStore(cfg.wrc.mirror.wizardsFile));
1290
+ const bornOf = (t) => firstStamp(m.sessionInfo(t)?.jsonlPath ?? "");
1291
+ const wizards = bindWizardStore(loadWizardStore(cfg.wrc.mirror.wizardsFile, bornOf));
1072
1292
  // 名册增量的投递面 (见 notices.ts)。charter 只是出生那一刻的快照; 此后的成员
1073
1293
  // 变动从这里搭下一次注入的车进到每个在场 wizard 的上下文里, 不占一轮。
1074
1294
  // 一条变动只投给**同一个聊天**里在场的其他 wizard: 群成员变动是那个群的事,
1075
1295
  // 当事人自己做的自己知道, 所以排除在外。
1076
- const notices = bindNoticeBox(createNoticeBox());
1296
+ // 上下文逼近自动压缩时提醒一句「先记」: 压缩 / 交接都会丢细节。每段会话 (sid) 只提一次。
1297
+ const nudged = new Set();
1298
+ // 线按模型的窗口算 (memoryNudgeAt); 模型取 transcript 里实际跑的那个 id, pane 记的常是空串或 `opus` 这种别名。
1299
+ const memoryNudge = (t) => {
1300
+ const i = m.sessionInfo(t);
1301
+ // 最低的那条线先挡: 不到它就不必再读一次 transcript 取模型。
1302
+ if (!i?.sessionId || nudged.has(i.sessionId) || i.contextTokens < MEMORY_NUDGE_FLOOR)
1303
+ return [];
1304
+ if (i.contextTokens < memoryNudgeAt(lastModel(i.jsonlPath) || i.model))
1305
+ return [];
1306
+ nudged.add(i.sessionId);
1307
+ return [`你的上下文已到 ${Math.round(i.contextTokens / 1000)}k, 快到自动压缩了: 这段里学到的、值得跨会话活下来的东西, 先 \`wizard_remember\` 记下 (自己的选 self, 属于群 / 仓库的选 chat / workspace), 再考虑 \`handoff\``];
1308
+ };
1309
+ const notices = bindNoticeBox(createNoticeBox(12, memoryNudge));
1077
1310
  const postRoster = (base, except, line) => notices.post(chatAudience(m.chatTargets(base), base, except), line);
1078
1311
  /** 一个聊天的工作区: 它名下各会话跑在哪个目录, 取最多的那个。聊天与工作区是
1079
1312
  * 一对多, 但绝大多数聊天只围着一个项目转。 */
@@ -1108,26 +1341,43 @@ const main = async () => {
1108
1341
  log.info({ base, name }, "chat: auto-named from workspace");
1109
1342
  // 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
1110
1343
  // 得知道自己的地址变了 —— 它的 charter 里写的还是「(未命名)」。
1111
- postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— notify / new_claude_session 可以用这个名字指到这里。`);
1344
+ postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— notify / spawn_wizard 的 \`chat\` 可以用这个名字指到这里。`);
1112
1345
  }
1113
1346
  return named;
1114
1347
  };
1115
1348
  // 名字全局唯一 (见 wizard.ts): 先给没名字的聊天补名, 再按「默认会话先挑」把
1116
1349
  // 每个已知 wizard 的名字落定 —— 老记录 (名字还跟着聊天走的那一代) 在这里一次迁完。
1117
1350
  ensureChatNames(cfg.defaultChat || "");
1351
+ const reborn = backfillBorn(wizards, bornOf);
1352
+ if (reborn.length)
1353
+ log.child({ mod: "wizard" }).info({ n: reborn.length }, "bornAt backfilled from transcript heads");
1118
1354
  settleAll(wizards, [...m.chatRoster("").flatMap((c) => c.targets), ...wizards.all().map((w) => w.target)], (t) => chatNameOf(cfg, t));
1119
- // 工单账本 (见 jobs.ts)。只在**显式传了 `job`** 时起作用 —— 不用工单的调用方
1120
- // 行为与从前一模一样。
1121
- const jobs = loadJobStore(cfg.wrc.mirror.jobsFile);
1355
+ // 上一个进程生到一半就没了的身份 (spawn 途中 reload): 没会话的删掉, 腾出名字。
1356
+ // 它们的 tmux window 也一并收掉 (没人叫得到它, 只会占着 pane): 等绑定恢复完再看,
1357
+ // 免得把还没恢复的活 pane 当成没绑定; 期间名字已被新 spawn 认领的不碰。
1358
+ const unborn = sweepUnborn(wizards, (t) => m.chatTargets(baseOfKey(t)).includes(t));
1359
+ unborn.forEach((w) => log.child({ mod: "wizard" }).warn({ target: w.target, name: w.name }, "unborn identity swept"));
1360
+ if (unborn.length) {
1361
+ void m.restored
1362
+ .then(() => m.reapUnborn(unborn.map((w) => w.name).filter((n) => n && !wizards.byName(n))))
1363
+ .then((killed) => { if (killed.length)
1364
+ log.child({ mod: "wizard" }).info({ killed }, "unborn windows killed"); })
1365
+ .catch((e) => log.warn({ err: e.message }, "unborn window reap failed"));
1366
+ }
1122
1367
  /** 派活前先验工单: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。 */
1123
- const checkJob = (id) => {
1368
+ /** `free` = 这次不花预算 (不带 task 的 spawn; 答一份停在 NEED 上的反问 —— 拒了它就只能干等到超时)。 */
1369
+ const checkJob = (id, target, free = false) => {
1124
1370
  const j = jobs.get(id);
1125
1371
  if (!j)
1126
1372
  return { ok: false, status: 404, reason: `没有工单 '${id}' —— open_job 先开一个, 或者 list_jobs 看还开着哪些` };
1127
1373
  if (j.status !== "open")
1128
1374
  return { ok: false, status: 409, reason: `工单 '${id}' 已经收工了` };
1129
- if (j.members.length >= JOB_MEMBER_MAX)
1375
+ // 已在册的成员 (续问 / 再派一句) 不占新名额。
1376
+ if (j.members.length >= JOB_MEMBER_MAX && !j.members.some((x) => x.target === target))
1130
1377
  return { ok: false, status: 409, reason: `工单 '${id}' 的成员已经满了 (${JOB_MEMBER_MAX} 个) —— 分身是有成本的, 拆成两个工单, 或者先收工回收掉一批` };
1378
+ // 预算是硬的: 与单件活的 legs 不同, 这里拒了也无路可绕 —— 换个人派照样记在这张工单上。
1379
+ if (!free && j.maxTurns && (j.turns ?? 0) >= j.maxTurns)
1380
+ return { ok: false, status: 409, reason: `工单 '${id}' 的派活预算用完了 (${j.turns}/${j.maxTurns} 次) —— 拿手上已有的结论 close_job 收口, 缺的如实写; 真要接着干就 open_job 开一张新的` };
1131
1381
  return { ok: true };
1132
1382
  };
1133
1383
  // ── 关系视图的数据面 ──────────────────────────────────────────────
@@ -1148,8 +1398,18 @@ const main = async () => {
1148
1398
  const known = wizards.all();
1149
1399
  const byTarget = new Map(known.map((w) => [w.target, w]));
1150
1400
  const seen = new Set(peers.map((p) => p.target));
1401
+ // 「卡在审批」以 daemon 手里的 pending 为准 —— 审批长轮询期间 pane 照样在转圈,
1402
+ // 拿 busy 去推会把它当成「执行中」。归属走 approval 认卡的同一口径 (getMirrorTarget):
1403
+ // hook 的 sid 在 /clear 之后先变, 镜像绑定的 sid 要等新 jsonl 出现才跟上。
1404
+ // ask_user 的 pending 不带 sessionId —— 它是 MCP 工具在等, 不归哪张卡, 不亮。
1405
+ const waitingOf = listPending().reduce((m, { meta }) => {
1406
+ const t = meta.sessionId ? getMirrorTarget(meta.sessionId) : undefined;
1407
+ const tool = meta.toolName || meta.kind;
1408
+ return t ? m.set(t, [...new Set([...(m.get(t) ?? []), tool])]) : m;
1409
+ }, new Map());
1151
1410
  const live = peers.map((p) => {
1152
1411
  const w = byTarget.get(p.target);
1412
+ const waiting = waitingOf.get(p.target);
1153
1413
  return {
1154
1414
  target: p.target,
1155
1415
  name: settleName(wizards, chatNameOf(cfg, p.target), p.target),
@@ -1157,9 +1417,11 @@ const main = async () => {
1157
1417
  chat: chatNameOf(cfg, p.target) || p.chat,
1158
1418
  cwd: p.cwd,
1159
1419
  model: p.model,
1420
+ ...((e) => (e ? { effort: e } : {}))(mirrorStore.get(p.target)?.effort),
1160
1421
  cli: p.cli,
1161
1422
  busy: p.busy,
1162
1423
  alive: p.paneAlive,
1424
+ ...(waiting ? { waiting } : {}),
1163
1425
  parent: w?.parent,
1164
1426
  clonedFrom: w?.clonedFrom,
1165
1427
  forkOf: w?.forkOf,
@@ -1191,9 +1453,11 @@ const main = async () => {
1191
1453
  wizards: [...live, ...cold],
1192
1454
  jobs: jobs.all().map((j) => ({
1193
1455
  id: j.id, base: j.base, owner: j.owner, title: j.title, status: j.status,
1194
- openedAt: j.openedAt, closedAt: j.closedAt, summary: j.summary,
1195
- members: j.members.map((mm) => ({ target: mm.target, task: mm.task, spawned: mm.spawned })),
1456
+ openedAt: j.openedAt, closedAt: j.closedAt, summary: j.summary, ...(j.expect ? { expect: j.expect } : {}), ...(j.plan ? { plan: j.plan } : {}),
1457
+ members: j.members.map((mm) => ({ target: mm.target, task: mm.task, spawned: mm.spawned, ...(mm.outcome ? { outcome: mm.outcome } : {}), ...(mm.artifacts?.length ? { artifacts: mm.artifacts } : {}) })),
1196
1458
  })),
1459
+ // rolepage 的待办托盘: 与名册 / peek 的「在等 / 欠着」同一份 (receipts.states)。
1460
+ inflight: receipts.states().map(({ ageMs, ...x }) => ({ ...x, at: Date.now() - ageMs })),
1197
1461
  schedules: tasks.list().filter((x) => x.enabled).map((x) => {
1198
1462
  const st = tasks.stateOf(x.id);
1199
1463
  const loadError = tasks.errors()[x.id];
@@ -1244,25 +1508,68 @@ const main = async () => {
1244
1508
  const path = memoryPath(cfg.daemon.stateDir, scope, key);
1245
1509
  return clipForCharter(readMemory(path), path);
1246
1510
  };
1511
+ /** transcript 最后写入的时刻; 没有会话 / 读不到 = 0。 */
1512
+ const lastTouched = (t) => {
1513
+ const p = m.sessionInfo(t)?.jsonlPath;
1514
+ try {
1515
+ return p ? statSync(p).mtimeMs : 0;
1516
+ }
1517
+ catch {
1518
+ return 0;
1519
+ }
1520
+ };
1521
+ // 每个会话的宪章里写进了哪些 self 记忆。`/clear` 抹掉对话却不重写系统提示, 此后
1522
+ // 新记的那些就只活在被抹掉的对话里 —— onContextCut 据这份快照补发差集。纯内存: reload
1523
+ // 之后没有快照, 只指路不补发。
1524
+ const chartered = new Map();
1247
1525
  /** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
1248
1526
  * 让它一睁眼就知道自己不是一个人在跑。 */
1249
- const charterFor = (target, o) => renderCharter({
1250
- // o.cwd = 正在启动的那个 pane 的目录; 没给才退回"现在记着的那个"。
1251
- self: { ...briefOf(target, target), address: selfAddress(target), ...(o.cwd ? { cwd: o.cwd } : {}) },
1252
- chat: chatNameOf(cfg, target),
1253
- principal: baseOfKey(target),
1254
- parent: o.parent ? briefOf(target, o.parent) : undefined,
1255
- inherited: !!o.inherited,
1256
- forkOf: o.forkOf ? briefOf(target, o.forkOf) : undefined,
1257
- cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
1258
- siblings: m.chatTargets(baseOfKey(target)).filter((t) => t !== target).map((t) => briefOf(target, t)),
1259
- memory: wizards.get(target)?.memory ?? [],
1260
- chatMemory: sharedMemory("chat", baseOfKey(target)),
1261
- workspaceMemory: (() => {
1262
- const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
1263
- return cwd ? sharedMemory("workspace", cwd) : "";
1264
- })(),
1265
- steward: !tagOfKey(target),
1527
+ const charterFor = (target, o) => {
1528
+ chartered.set(target, wizards.get(target)?.memory ?? []);
1529
+ const text = renderCharter({
1530
+ // o.cwd = 正在启动的那个 pane 的目录; 没给才退回"现在记着的那个"。
1531
+ self: { ...briefOf(target, target), address: selfAddress(target), ...(o.cwd ? { cwd: o.cwd } : {}) },
1532
+ chat: chatNameOf(cfg, target),
1533
+ principal: baseOfKey(target),
1534
+ parent: o.parent ? briefOf(target, o.parent) : undefined,
1535
+ inherited: !!o.inherited,
1536
+ forkOf: o.forkOf ? briefOf(target, o.forkOf) : undefined,
1537
+ cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
1538
+ // 最近动过的排前面: 名册只点名前几个, 截掉的该是早就停了的那些。
1539
+ // 没有 transcript 的冷记录按 bornAt 算, 刚生下还没开口的照样在。
1540
+ siblings: m.chatTargets(baseOfKey(target))
1541
+ .filter((t) => t !== target)
1542
+ .map((t) => [t, Math.max(lastTouched(t), wizards.get(t)?.bornAt ?? 0)])
1543
+ .filter(([, at]) => Date.now() - at <= ROSTER_FRESH_MS)
1544
+ .sort((x, y) => y[1] - x[1])
1545
+ .map(([t]) => briefOf(target, t)),
1546
+ memory: wizards.get(target)?.memory ?? [],
1547
+ chatMemory: sharedMemory("chat", baseOfKey(target)),
1548
+ workspaceMemory: (() => {
1549
+ const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
1550
+ return cwd ? sharedMemory("workspace", cwd) : "";
1551
+ })(),
1552
+ steward: !tagOfKey(target),
1553
+ });
1554
+ recordCharter(target, text);
1555
+ return text;
1556
+ };
1557
+ m.onContextCut((t, cut) => {
1558
+ const now = wizards.get(t)?.memory ?? [];
1559
+ const seen = chartered.get(t);
1560
+ // 没有快照 (reload 之后) 就不猜差集, 只指路 —— 整份塞进一行能有几万字。
1561
+ if (!seen) {
1562
+ if (now.length)
1563
+ notices.post([t], `你有 ${now.length} 条 self 记忆, 宪章里那份可能是旧的: \`wizard_whoami\` 看全的`);
1564
+ return;
1565
+ }
1566
+ const fresh = now.filter((x) => !seen.includes(x));
1567
+ const gone = seen.filter((x) => !now.includes(x)).length;
1568
+ const shown = fresh.slice(-10).map((x) => `「${clipLine(x, 200)}」`).join(" ");
1569
+ if (fresh.length)
1570
+ notices.post([t], `你出生之后记下的 self 记忆 (${cut === "clear" ? "/clear 抹掉了对话" : "上下文压缩把对话换成了摘要"}, 宪章里没有它们)${fresh.length > 10 ? `, 最新 10 条` : ""}: ${shown}`);
1571
+ if (gone)
1572
+ notices.post([t], `宪章「我的记忆」里有 ${gone} 条已被你 forget, 别再照它们办`);
1266
1573
  });
1267
1574
  // 所有 spawn 路径 (群里手打 /new、pane 自愈重生、编排出来的分身) 都从这里
1268
1575
  // 取身份, 于是「是谁」不再取决于是哪段代码把它生出来的。
@@ -1271,9 +1578,25 @@ const main = async () => {
1271
1578
  // 终身的 —— 一个在聊天还没名字时出生的 wizard, 会一辈子以为自己住在一个
1272
1579
  // 「(未命名)」的地方。所以补名要发生在渲染之前, 不能等它以后自己去查。
1273
1580
  ensureChatNames(target);
1581
+ // 信箱里装的是相对上一份宪章的变化; 新宪章一渲染就都过期了 (名册、记忆都已在里面)。
1582
+ notices.drain(target);
1274
1583
  const rec = wizards.get(target);
1275
1584
  return charterFor(target, { parent: rec?.parent, forkOf: rec?.forkOf, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
1276
1585
  });
1586
+ // 宪章记录是后来才有的: 已经在跑的 wizard 拿 spawn 落盘的那份 (state/charters/<sid>.md) 补上,
1587
+ // 否则要等它下次重生, rolepage 的「宪章」入口才出现。与自动命名同一个延迟: 等附着表恢复完。
1588
+ setTimeout(() => {
1589
+ const dir = join(expandHome(cfg.daemon.stateDir), "charters");
1590
+ wizards.all().forEach((w) => {
1591
+ const sid = m.sessionInfo(w.target)?.sessionId;
1592
+ const file = sid ? join(dir, `${sid}.md`) : "";
1593
+ try {
1594
+ if (file && existsSync(file))
1595
+ backfillCharter(w.target, readFileSync(file, "utf8"), statSync(file).mtimeMs);
1596
+ }
1597
+ catch { /* 补不上就等它重生 */ }
1598
+ });
1599
+ }, 15_000).unref();
1277
1600
  // 开机也补一次: IM 侧的 `/peers`、`/help` 直接读名字, 不经过任何 MCP 路由。
1278
1601
  // 延后是因为附着表要先从盘上恢复完, 否则这一刻还看不见那些聊天; 补不全也无妨,
1279
1602
  // 上面每一道拦截都会再补。
@@ -1310,8 +1633,9 @@ const main = async () => {
1310
1633
  sessionId: info?.sessionId ?? "",
1311
1634
  cli: info?.cli,
1312
1635
  model: info?.model ?? "",
1636
+ ...(info?.effort ? { effort: info.effort } : {}),
1313
1637
  contextTokens: info?.contextTokens ?? 0,
1314
- handoffSuggested: (info?.contextTokens ?? 0) > CONTEXT_FULL_TOKENS,
1638
+ handoffSuggested: !!info && info.contextTokens > handoffAt(lastModel(info.jsonlPath) || info.model),
1315
1639
  };
1316
1640
  };
1317
1641
  /** 全体名册: 活着的 (peers + 跨聊天可寻址的) 在前, 注册表里有记录但此刻不在
@@ -1429,8 +1753,10 @@ const main = async () => {
1429
1753
  // 的箭头 (读名册的是另一个 wizard), 模型取它真正跑的那个 —— 绑定里的 model 只在
1430
1754
  // 有人显式选过时才有。
1431
1755
  const warm = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1756
+ const inflight = receipts.states();
1432
1757
  const shown = ranked.slice(0, limit).map((r) => ({
1433
1758
  ...r,
1759
+ turns: turnsLine(inflight, r.target),
1434
1760
  model: r.model || (r.jsonlPath ? lastModel(r.jsonlPath) : ""),
1435
1761
  summary: r.jsonlPath ? lastExchange(r.jsonlPath, 80, warm) : "",
1436
1762
  contextTokens: r.jsonlPath ? lastContextTokens(r.jsonlPath) : 0,
@@ -1469,9 +1795,11 @@ const main = async () => {
1469
1795
  .filter((r) => !r.self)
1470
1796
  .map((r) => {
1471
1797
  const live = r.jsonlPath ? r.jsonlPath : "";
1798
+ const ttl = live ? cacheTtlSec(live) : 0;
1472
1799
  return {
1473
1800
  ...r,
1474
1801
  contextTokens: live ? lastContextTokens(live) : 0,
1802
+ ...(ttl ? { cacheTtlMs: ttl * 1000 } : {}),
1475
1803
  files: live ? contextFiles(live) : [],
1476
1804
  asks: live ? talkTurns(live, 12, warm).filter((t) => t.role === "user").map((t) => t.text) : [],
1477
1805
  summary: live ? lastExchange(live, 80, warm) : "",
@@ -1479,7 +1807,7 @@ const main = async () => {
1479
1807
  });
1480
1808
  const limit = Math.min(Math.max(Number(b.limit ?? 5) || 5, 1), 20);
1481
1809
  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()) });
1810
+ json(res, 200, { ok: true, text: renderCandidates(task, cands, Date.now(), cfg.wrc.mirror.keepalive.ttlSec * 1000, homedir()) });
1483
1811
  });
1484
1812
  // 记忆三种作用域: self 跟着自己 (wizards.json), 直写; chat / workspace 是共享的 md,
1485
1813
  // 每个在那个群 / 那个目录出生的 wizard 都会读到 —— 它们只收提议, 由整理者合并
@@ -1508,6 +1836,7 @@ const main = async () => {
1508
1836
  const queued = proposeMemory(inboxPath(cfg.daemon.stateDir, scope, key), {
1509
1837
  at: Date.now(),
1510
1838
  by: selfAddress(self),
1839
+ ...(scope === "workspace" ? { cwd: key } : {}),
1511
1840
  ...(note ? { note } : {}),
1512
1841
  ...(forget ? { forget } : {}),
1513
1842
  });
@@ -1515,18 +1844,30 @@ const main = async () => {
1515
1844
  json(res, 500, { ok: false, reason: "提议没写进收件箱" });
1516
1845
  return;
1517
1846
  }
1847
+ // 不回全文: 每提一条就把整份 md 吐回上下文, 连交几条就是几倍的 token。
1848
+ // forget 是子串, 回它此刻命中几行 —— 0 行就是写错了, 当场能改。
1849
+ const lines = readMemory(path).split("\n").filter((l) => l.trim());
1518
1850
  json(res, 200, {
1519
- ok: true, scope, queued, file: path, memory: readMemory(path),
1520
- hint: "已提交给记忆整理者, 下一轮整理 (半小时内) 合并进上面这份记忆; 此后出生的 wizard 才读得到",
1851
+ ok: true, scope, queued, file: path, lines: lines.length,
1852
+ ...(forget ? { forgetHits: lines.filter((l) => l.includes(forget)).length } : {}),
1853
+ hint: "已提交给记忆整理者, 下一轮整理 (半小时内) 合并进 file; 此后出生的 wizard 才读得到",
1521
1854
  });
1522
1855
  return;
1523
1856
  }
1524
1857
  const cur = wizards.get(self)?.memory ?? [];
1525
1858
  // 忘记按子串匹配: 模型记不住自己当初一字不差写了什么, 但记得大意。
1526
1859
  const next = forget ? cur.filter((x) => !x.includes(forget)) : cur;
1527
- const memory = note ? [...next.filter((x) => x !== note), note] : next;
1528
- wizards.upsert(self, { memory });
1529
- json(res, 200, { ok: true, scope: "self", memory, added: !!note, forgotten: cur.length - next.length });
1860
+ // 先截再比: 注册表存的是截到 NOTE_MAX 的那版, 拿原文比就永远去不了重。
1861
+ const n = note.slice(0, NOTE_MAX);
1862
+ const want = n ? [...next.filter((x) => x !== n), n] : next;
1863
+ // 满额时注册表从最旧的挤掉 —— 挤掉的、截短的都要明说, 不让它以为全记住了。
1864
+ const dropped = want.slice(0, Math.max(0, want.length - MEMORY_MAX));
1865
+ const { memory } = wizards.upsert(self, { memory: want });
1866
+ json(res, 200, {
1867
+ ok: true, scope: "self", count: memory.length, added: !!note, forgotten: cur.length - next.length,
1868
+ ...(dropped.length ? { dropped, hint: `self 记忆满 ${MEMORY_MAX} 条, 最旧的 ${dropped.length} 条被挤掉了 (见 dropped); 还要的话 forget 掉不重要的再记回来, 或合并成一条` } : {}),
1869
+ ...(note.length > NOTE_MAX ? { truncated: `这条超过 ${NOTE_MAX} 字, 只记下了前 ${NOTE_MAX} 字` } : {}),
1870
+ });
1530
1871
  });
1531
1872
  // 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
1532
1873
  // 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
@@ -1537,10 +1878,15 @@ const main = async () => {
1537
1878
  return;
1538
1879
  }
1539
1880
  const b = body;
1881
+ const effort = parseEffort(b.effort);
1882
+ if (b.effort !== undefined && !effort) {
1883
+ json(res, 400, { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` });
1884
+ return;
1885
+ }
1540
1886
  // 工单先验: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。
1541
1887
  const jobId = (b.job ?? "").trim();
1542
1888
  if (jobId) {
1543
- const jc = checkJob(jobId);
1889
+ const jc = checkJob(jobId, undefined, !(b.task ?? "").toString().trim());
1544
1890
  if (!jc.ok) {
1545
1891
  json(res, jc.status, { ok: false, reason: jc.reason });
1546
1892
  return;
@@ -1554,6 +1900,13 @@ const main = async () => {
1554
1900
  return;
1555
1901
  }
1556
1902
  const inherit = b.inherit;
1903
+ // detached = 独立长住 (原 new_claude_session / 人在群里 `/new .x`): 不挂家谱、不占
1904
+ // 调用方的分身名额、不随工单回收。它和「fork 我的上下文」「归某张工单」都互斥。
1905
+ const detached = b.detached === true;
1906
+ if (detached && (inherit || (b.job ?? "").trim())) {
1907
+ json(res, 400, { ok: false, reason: "detached 只用于白板 (inherit:false) 且不带 job —— 独立长住的 wizard 不归你管, 也不随工单回收" });
1908
+ return;
1909
+ }
1557
1910
  // 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
1558
1911
  // 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
1559
1912
  // 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
@@ -1575,12 +1928,12 @@ const main = async () => {
1575
1928
  json(res, 409, { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` });
1576
1929
  return;
1577
1930
  }
1578
- const alivePeers = await m.peers(self);
1931
+ const alivePeers = detached ? [] : await m.peers(self);
1579
1932
  // 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
1580
1933
  // close_job 整批回收一个工单的临时分身。只数**本聊天里**活着的 ——
1581
1934
  // 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
1582
1935
  const aliveKids = childrenOf(wizards.all(), self).filter((k) => alivePeers.some((pp) => pp.target === k.target && pp.paneAlive));
1583
- if (aliveKids.length >= cfg.wrc.mirror.cloneMax) {
1936
+ if (!detached && aliveKids.length >= cfg.wrc.mirror.cloneMax) {
1584
1937
  json(res, 429, {
1585
1938
  ok: false,
1586
1939
  reason: `你名下已经有 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) —— 先 stop_wizard 收掉干完活的那些, 或者 close_job 整批回收一个工单`,
@@ -1599,77 +1952,104 @@ const main = async () => {
1599
1952
  return;
1600
1953
  }
1601
1954
  const askedName = String(b.name ?? b.tag ?? "");
1602
- const slotR = await claimSlot(base, askedName, normalizeTag(b.description?.split(/\s+/)[0]) || "clone", [self, source]);
1955
+ // 没起名: 独立长住的按目录名 (同 /new 与 new_claude_session), 分身按职责的首词。
1956
+ const fallback = (detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone";
1957
+ const slotR = await claimSlot(base, askedName, fallback, [self, source]);
1603
1958
  if (!slotR.ok) {
1604
1959
  json(res, slotR.status, slotR.body);
1605
1960
  return;
1606
1961
  }
1607
1962
  const { target, slot: tag } = slotR;
1963
+ // claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
1964
+ // 只回滚这一次的新记录, 不能把那个老 wizard 的记忆和家谱一起删掉。
1965
+ const existed = !!wizards.get(target);
1608
1966
  const forkOf = inherit && source !== self ? source : undefined;
1609
1967
  // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
1610
1968
  wizards.upsert(target, {
1611
1969
  description: (b.description ?? "").toString().trim(),
1612
- parent: self,
1970
+ // 显式写 undefined: upsert 是合并写, 复用的冷记录里旧的 parent 会留下来。
1971
+ parent: detached ? undefined : self,
1613
1972
  bornAt: Date.now(),
1614
1973
  clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
1615
1974
  ...(forkOf ? { forkOf } : {}),
1975
+ // fork 了上下文就一并继承 self 记忆: 系统提示不随 fork 走 (分身拿的是自己的宪章),
1976
+ // 不拷的话它开局就没有, /clear 或交接重开后更是彻底没了。标上来源; 已经是继承来的不再套一层。
1977
+ ...(inherit ? { memory: (wizards.get(source)?.memory ?? []).map((x) => x.startsWith("(继承自 ") ? x : `(继承自 ${displayName(source)}) ${x}`) } : {}),
1978
+ // 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
1979
+ spawning: BOOT_ID,
1616
1980
  });
1617
1981
  const name = wizards.rename(target, normalizeTag(askedName) || tag);
1618
- const charter = charterFor(target, { parent: self, forkOf, inherited: inherit });
1982
+ const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit });
1619
1983
  const task = (b.task ?? "").toString().trim();
1620
1984
  // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
1621
1985
  const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
1622
1986
  // 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
1623
1987
  // wait_peer 才不会把它们当成分身对这件活的答复。
1624
1988
  const taskAt = Date.now();
1989
+ const taskTurn = newTurn();
1990
+ const asker = task ? askerOf(self) : undefined;
1625
1991
  const r = await m.cloneSession({
1626
1992
  parent: source,
1627
1993
  target,
1628
1994
  windowName: name,
1629
1995
  cli: b.cli,
1630
1996
  model: b.model,
1997
+ effort,
1631
1998
  cwd: b.cwd,
1632
1999
  systemPrompt: charter,
1633
2000
  inherit,
1634
2001
  // 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
1635
2002
  // 也少一次"就位了但没事干"的空转。
1636
- bootstrap: task ? task + envelopeFor(self, "") : undefined,
2003
+ bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn }) : undefined,
2004
+ ...(task ? { bootstrapFrom: { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, query: task } } : {}),
1637
2005
  keepalive,
1638
- });
2006
+ }).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
2007
+ // 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
1639
2008
  if (!r.ok) {
1640
- wizards.drop(target);
2009
+ if (existed)
2010
+ wizards.upsert(target, { spawning: undefined });
2011
+ else
2012
+ wizards.drop(target);
1641
2013
  json(res, 500, { ok: false, reason: r.reason });
1642
2014
  return;
1643
2015
  }
1644
- wizards.upsert(target, { clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
2016
+ wizards.upsert(target, { spawning: undefined, clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
1645
2017
  const kid = briefOf(self, target);
1646
2018
  // r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
1647
2019
  // 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
1648
- const modelNote = r.model
2020
+ const modelNote = (r.model
1649
2021
  ? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
1650
- : "";
2022
+ : "") + (r.effort ? ` · effort ${r.effort}` : "");
1651
2023
  // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
1652
2024
  // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
1653
2025
  if (!jobId) {
1654
- postRoster(base, [target, self], `新 wizard **.${name}** 就位${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— ${displayName(self)} 的分身${r.inherited ? ` (继承了${forkOf ? ` ${displayName(forkOf)} ` : "它"}的上下文)` : ""}`);
2026
+ postRoster(base, [target, self], `新 wizard **.${name}** 就位${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— ${detached ? `空白起步, 由 ${displayName(self)} 造的` : `${displayName(self)} 的分身`}${r.inherited ? ` (继承了${forkOf ? ` ${displayName(forkOf)} ` : "它"}的上下文)` : ""}`);
1655
2027
  }
1656
2028
  // 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
1657
2029
  let dispatched = r.inherited && !!task;
1658
2030
  if (task && !r.inherited) {
1659
- const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) }, channel: "", envelope: envelopeFor(self, "") });
2031
+ const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, channel: "", envelope: envelopeFor(self, "", { turn: taskTurn }) });
1660
2032
  dispatched = inj.ok;
1661
2033
  }
1662
2034
  if (jobId)
1663
2035
  jobs.attach(jobId, { target, task, spawned: true });
2036
+ if (jobId && dispatched)
2037
+ jobs.spend(jobId);
1664
2038
  // 分身的第一件活也守回执: fan-out 最常见的形状就是 clone_wizard({task}) × N,
1665
2039
  // 让它们干完自己把结论送回来, 发起方不必挂在 wait_peer 上。
2040
+ const k = body.chain === false ? undefined : parentKOf(self);
1666
2041
  if (dispatched)
1667
- receipts.register({ from: self, to: target, channel: "", job: jobId, at: taskAt });
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 } : {}) });
2042
+ receipts.register({ from: self, to: target, channel: "", job: jobId, at: taskAt, turn: taskTurn, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
2043
+ 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 } : {}), ...(r.effort ? { effort: r.effort } : {}), ...(jobId ? { job: jobId } : {}), ...(detached ? { detached: true } : {}) });
1669
2044
  });
1670
2045
  // 收掉一个 wizard。interrupt = 打断它这一轮 (Esc); end = 结束它并回收 pane。
1671
2046
  // 自己终结自己是合法的 (分身干完活自我了结), 代价是这次工具调用不会返回 ——
1672
2047
  // 回执只在 rolepage (生命周期事件不进群); 同群的 wizard 从名册增量里得知。
2048
+ /** 抹掉一个 wizard 的身份; 它在开着的工单里没落定的那一份记成 canceled —— 名字都没了, 不会再落定。 */
2049
+ const forgetWizard = (target) => {
2050
+ wizards.drop(target);
2051
+ return jobs.detach(target);
2052
+ };
1673
2053
  http.register("POST /wizard/stop", async (req, res) => {
1674
2054
  const { self, body } = await readPeerBody(req);
1675
2055
  if (!self) {
@@ -1678,6 +2058,15 @@ const main = async () => {
1678
2058
  }
1679
2059
  const b = body;
1680
2060
  const r = resolvePeer(self, addrOf(b));
2061
+ // 有身份没会话 (冷记录 / 生到一半的僵尸): resolvePeer 够不着它, 但收它不需要
2062
+ // 会话 —— end 视为早已结束, forget 删掉记录、腾出名字。interrupt 无从谈起。
2063
+ const found = r.ok || r.candidates?.length ? undefined : wizards.byName(addrOf(b));
2064
+ const cold = found && !m.chatTargets(baseOfKey(found.target)).includes(found.target) ? found : undefined;
2065
+ if (cold && (b.mode ?? "end") === "end") {
2066
+ const detached = b.forget ? forgetWizard(cold.target) : [];
2067
+ json(res, 200, { ok: true, target: cold.target, name: cold.name, mode: "end", forgotten: !!b.forget, note: "它本来就没有会话在跑", ...(detached.length ? { canceledIn: detached } : {}) });
2068
+ return;
2069
+ }
1681
2070
  if (!r.ok) {
1682
2071
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
1683
2072
  return;
@@ -1689,20 +2078,56 @@ const main = async () => {
1689
2078
  json(res, 400, { ok: false, reason: "打断自己没有意义 —— 你就是正在生成的那一个" });
1690
2079
  return;
1691
2080
  }
2081
+ // 按件号撤回自己派的那一件: 它此刻正在做的就是这件才按 Esc; 否则 (排着 / 在做别人的)
2082
+ // 只把这件记成 canceled —— 它之后答出来也不再回执, 手上别人的那一轮不受影响。
2083
+ const turn = (b.turn ?? "").replace(/[`\s]/g, "");
2084
+ if (!end && turn) {
2085
+ const mine = receipts.pending(self, target);
2086
+ if (mine?.turn !== turn) {
2087
+ json(res, 404, { ok: false, reason: `你没有在等 ${victim.name ? `.${victim.name}` : target} 答 '${turn}' 这件 (已落定、件号写错, 或不是你派的)` });
2088
+ return;
2089
+ }
2090
+ // 先落 canceled 再按 Esc: 被打断那一轮的半截话不该被 watcher 当成答案投回来。
2091
+ const busy = !(await m.idleNow(target));
2092
+ const canceled = receipts.cancel(target, self, (x) => x.from === self && x.turn === turn);
2093
+ // 按 Esc 之前再认一次手上这一轮: 上面那次 await 期间它可能已经做完这件、接了别人的下一件。
2094
+ const current = busy && openingAsk(target)?.turn === turn;
2095
+ // 还没轮到 / 排在后面: 捎一句给它, 轮到那句话时别再做了 (notices 挂在下一条注入的尾巴上)。
2096
+ if (!current)
2097
+ notices.post([target], `${displayName(self)} 撤回了它派给你的 ${turn} —— 那件活不用做了; 读到它时直接略过, 不必回复。`);
2098
+ // 只按 Esc, 不 teardown: teardown 会清掉它的注入队列, 别人排着的话一起丢。
2099
+ const esc = current ? await m.interruptPane(target) : undefined;
2100
+ // Esc 没按成也不回滚撤回: 这件已经不等了, 只是它手上那一轮还在跑 —— 如实说。
2101
+ json(res, 200, {
2102
+ ok: true, target, name: victim.name, mode: "interrupt", turn, canceled, interrupted: !!esc?.ok,
2103
+ ...(esc && !esc.ok ? { note: `这件已撤回 (不会再回执给你), 但 Esc 没按成 (${esc.reason ?? "unknown"}), 它这一轮可能还在跑` } : {}),
2104
+ ...(current ? {} : { note: "它此刻没在做这件 (还排着, 或在做别人的活), 没按 Esc —— 这件已撤回, 它之后答出来也不会再回执给你" }),
2105
+ });
2106
+ return;
2107
+ }
2108
+ // 不带件号的打断会停掉它手上整轮: 那一轮若是别的 wizard 派的, 先拒 —— 误伤的是别人的活。
2109
+ const others = !end && !b.force ? await othersTurn(target, self) : undefined;
2110
+ if (others) {
2111
+ json(res, 409, { ok: false, target, reason: `它此刻在做 ${others.from} 派的活${others.turn ? ` (${others.turn})` : ""}, 打断会连那件一起停掉 —— 只想撤回你自己派的那件, 传 turn (件号); 确要打断它这一轮, 加 force:true` });
2112
+ return;
2113
+ }
2114
+ // 先落 canceled 再动 pane: pane 一死, 守着的 watcher 会把同一份报成 dead。
2115
+ // 打断只停它手上这一轮: 只掐这一轮开头那句派来的活 (人 / 定时 / 回执轮不掐任何一份)。
2116
+ const canceled = receipts.cancel(target, self, end ? undefined : currentAsk(target));
1692
2117
  const done = end ? await m.killPane(target) : await m.interruptPane(target, { teardown: true });
1693
2118
  if (!done.ok) {
1694
2119
  json(res, 502, { ok: false, target, reason: done.reason });
1695
2120
  return;
1696
2121
  }
1697
- if (end && b.forget)
1698
- wizards.drop(target);
2122
+ const detached = end && b.forget ? forgetWizard(target) : [];
1699
2123
  // 打断只是停了它这一轮, 它还在; 只有结束才是名册变了。
1700
2124
  if (end)
1701
2125
  postRoster(baseOfKey(target), [target, self], `**${victim.name || target}** 已收工 · 由 ${displayName(self)} 结束${b.forget ? " (记录一并抹掉)" : ""}`);
1702
- json(res, 200, { ok: true, target, name: victim.name, mode: end ? "end" : "interrupt", forgotten: !!(end && b.forget) });
2126
+ json(res, 200, { ok: true, target, name: victim.name, mode: end ? "end" : "interrupt", forgotten: !!(end && b.forget), ...(canceled ? { canceled } : {}), ...(detached.length ? { canceledIn: detached } : {}) });
1703
2127
  });
1704
- // 换模型: 省略名字 = 换自己 (选择器盖在正在跑的这一轮上面照样能开), 点名 = 换
1705
- // 那个 wizard。与 spawn 时的 `model` 是同一条路 (model-select.ts)。
2128
+ // 换模型 / effort: 省略名字 = 换自己 (选择器盖在正在跑的这一轮上面照样能开), 点名 =
2129
+ // 换那个 wizard。与 spawn 时的 `model` 是同一条路 (model-select.ts); effort 走
2130
+ // `/effort` 档位条的「只本会话」键, 不碰全机默认。两样都给就先模型后档位。
1706
2131
  http.register("POST /wizard/model", async (req, res) => {
1707
2132
  const { self, body } = await readPeerBody(req);
1708
2133
  if (!self) {
@@ -1711,8 +2136,13 @@ const main = async () => {
1711
2136
  }
1712
2137
  const b = body;
1713
2138
  const model = (b.model ?? "").toString().trim();
1714
- if (!model) {
1715
- json(res, 400, { ok: false, reason: "model 必填 —— 口语化写就行 ('opus' / 'haiku' / 'sonnet 5')" });
2139
+ const effort = parseEffort(b.effort);
2140
+ if (b.effort !== undefined && !effort) {
2141
+ json(res, 400, { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` });
2142
+ return;
2143
+ }
2144
+ if (!model && !effort) {
2145
+ json(res, 400, { ok: false, reason: "model / effort 至少给一个 —— model 口语化写就行 ('opus' / 'haiku' / 'sonnet 5')" });
1716
2146
  return;
1717
2147
  }
1718
2148
  const scope = (b.scope ?? "session").toString().trim();
@@ -1727,8 +2157,18 @@ const main = async () => {
1727
2157
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
1728
2158
  return;
1729
2159
  }
1730
- const done = await m.setModel(r.target, model, scope);
1731
- json(res, done.ok ? 200 : 502, { ...done, target: r.target, name: briefOf(self, r.target).name, ...(done.ok ? { model: done.applied } : {}) });
2160
+ const done = model ? await m.setModel(r.target, model, scope) : undefined;
2161
+ const eff = effort && (done?.ok ?? true) ? await m.setEffort(r.target, effort) : undefined;
2162
+ const ok = (done?.ok ?? true) && (eff?.ok ?? true);
2163
+ json(res, ok ? 200 : 502, {
2164
+ ...done,
2165
+ ok,
2166
+ target: r.target,
2167
+ name: briefOf(self, r.target).name,
2168
+ ...(done?.ok ? { model: done.applied } : {}),
2169
+ ...(eff?.ok ? { effort: eff.applied } : {}),
2170
+ ...(eff && !eff.ok ? { reason: [done?.reason, `effort: ${eff.reason}`].filter(Boolean).join("; ") } : {}),
2171
+ });
1732
2172
  });
1733
2173
  // 自我交接: 上下文撑不住了, 自己把工作压成简报, /new 换一个全新进程, 再把简报贴
1734
2174
  // 回去。和 /handoff 的区别是**简报由调用方自己写在入参里** —— 它没法在自己
@@ -1740,15 +2180,8 @@ const main = async () => {
1740
2180
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1741
2181
  return;
1742
2182
  }
1743
- const brief = (body.brief ?? "").toString().trim();
1744
- if (brief.length < 40) {
1745
- json(res, 400, { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" });
1746
- return;
1747
- }
1748
- const info = m.sessionInfo(self);
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 });
2183
+ const r = handoffSelf(self, (body.brief ?? "").toString().trim());
2184
+ json(res, r.status, r.body);
1752
2185
  });
1753
2186
  // ── Job: 一次 fan-out 的工单 ─────────────────────────────────────────
1754
2187
  // 账本, 不是编排器: 控制流留在发起的那个 wizard 手里 (它自己 spawn / wait /
@@ -1762,19 +2195,23 @@ const main = async () => {
1762
2195
  }
1763
2196
  const b = body;
1764
2197
  const title = (b.title ?? "").toString().trim();
2198
+ const expect = Math.min(JOB_MEMBER_MAX, Math.max(0, Math.floor(Number(b.expect) || 0)));
1765
2199
  if (!title) {
1766
2200
  json(res, 400, { ok: false, reason: "title required —— 一句话说清这个工单要干成什么" });
1767
2201
  return;
1768
2202
  }
1769
2203
  const base = baseOfKey(self);
1770
- const job = jobs.open(base, self, title);
1771
- notifyChat(base, renderJobOpen(job, (b.plan ?? "").toString()));
2204
+ const maxTurns = Math.min(200, Math.max(0, Math.floor(Number(b.maxTurns) || 0)));
2205
+ const plan = (b.plan ?? "").toString().trim();
2206
+ // 不出群气泡: 工单是私下的过程, 人看到的只有发起者那一轮的最终回复。
2207
+ const accept = ACCEPTS.find((x) => x === b.accept);
2208
+ const job = jobs.open(base, self, title, { ...(plan ? { plan } : {}), ...(accept ? { accept } : {}), ...(expect ? { expect } : {}), ...(maxTurns ? { maxTurns } : {}) });
1772
2209
  json(res, 200, {
1773
2210
  ok: true,
1774
2211
  job: job.id,
1775
2212
  title,
1776
2213
  memberMax: JOB_MEMBER_MAX,
1777
- hint: "把这个 id 传给 spawn_wizard / clone_wizard / send_peer 的 `job` 参数, 它们就归到这个工单名下 (期间不再逐条出气泡); 活干完调 close_job 收尾并回收临时分身。",
2214
+ hint: "把这个 id 传给 spawn_wizard / clone_wizard / tell_peer 的 `job` 参数, 它们就归到这个工单名下 (一律私聊, 不进群); 活干完调 close_job 留档并回收临时分身, 给人的结论写在你自己这一轮的最终回复里。",
1778
2215
  });
1779
2216
  });
1780
2217
  http.register("POST /jobs/close", async (req, res) => {
@@ -1798,9 +2235,18 @@ const main = async () => {
1798
2235
  // 活结束就被杀掉。调用方自己也不收 —— 那会在这次工具调用里把自己干掉。
1799
2236
  const recycle = b.stop !== false;
1800
2237
  const victims = recycle ? job.members.filter((mm) => mm.spawned && mm.target !== self) : [];
2238
+ // 收掉之前把它们手上没落定的活记成 canceled: 不然发起者收工之后还会收到一串 dead 回执。
2239
+ victims.forEach((mm) => receipts.cancel(mm.target, self));
2240
+ // 留档要的 sessionId 得在回收之前取: killPane 连绑定一起删。
2241
+ const sids = new Map([job.owner, ...job.members.map((mm) => mm.target)].map((t) => [t, m.sessionInfo(t)?.sessionId ?? ""]));
1801
2242
  const killed = await victims.reduce(async (acc, mm) => (await acc) + ((await m.killPane(mm.target)).ok ? 1 : 0), Promise.resolve(0));
1802
2243
  const closed = jobs.close(id, (b.summary ?? "").toString());
1803
- notifyChat(job.base, renderJobClose(closed, (t) => relayLabel(t, job.base), killed));
2244
+ // 收工结论留档到开单者名下 (与交接简报同一份 jsonl); 空工单 (没人、没结论) 不记。
2245
+ if (closed.members.length || closed.summary?.trim()) {
2246
+ const ep = jobEpisode(closed, { nameOf: episodeName, sidOf: (t) => sids.get(t) ?? "", chat: chatNameOf(cfg, job.base) || job.base });
2247
+ if (!appendEpisode(episodePath(cfg.daemon.stateDir, ep.name), ep))
2248
+ log.warn({ job: id, name: ep.name }, "job episode not archived");
2249
+ }
1804
2250
  json(res, 200, { ok: true, job: id, members: closed.members.length, recycled: killed, kept: victims.length - killed });
1805
2251
  });
1806
2252
  http.register("POST /jobs/list", async (req, res) => {
@@ -1809,6 +2255,7 @@ const main = async () => {
1809
2255
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1810
2256
  return;
1811
2257
  }
2258
+ const inflight = receipts.states();
1812
2259
  json(res, 200, {
1813
2260
  ok: true,
1814
2261
  jobs: jobs.openOf(baseOfKey(self)).map((j) => ({
@@ -1817,7 +2264,15 @@ const main = async () => {
1817
2264
  owner: displayName(j.owner),
1818
2265
  mine: j.owner === self,
1819
2266
  openedAt: j.openedAt,
1820
- members: j.members.map((mm) => ({ address: peerAddress(cfg, self, mm.target), task: mm.task.split("\n")[0] ?? "", spawned: mm.spawned })),
2267
+ ...jobs.tally(j.id),
2268
+ ...(j.maxTurns ? { budget: `${j.turns ?? 0}/${j.maxTurns}` } : {}),
2269
+ members: j.members.map((mm) => ({
2270
+ address: peerAddress(cfg, self, mm.target),
2271
+ task: mm.task.split("\n")[0] ?? "",
2272
+ spawned: mm.spawned,
2273
+ state: mm.outcome ?? inflight.find((x) => x.to === mm.target && x.job === j.id)?.state ?? "unknown",
2274
+ ...(mm.artifacts?.length ? { artifacts: mm.artifacts } : {}),
2275
+ })),
1821
2276
  })),
1822
2277
  });
1823
2278
  });
@@ -1826,7 +2281,7 @@ const main = async () => {
1826
2281
  // (injectText), 所以 pane 死了会被拉起来, 输出照常落进群和详情页。
1827
2282
  /** 日程归谁: 排班那个 wizard (createdBy); 老文件没写就归执行它的那个。 */
1828
2283
  const ownerOf = (t) => t.owner || (cfg.defaultChat ?? "");
1829
- http.register("POST /tasks/schedule", async (req, res) => {
2284
+ http.register("POST /tasks/schedule", guarded(async (req, res) => {
1830
2285
  const { self, body } = await readPeerBody(req);
1831
2286
  if (!self) {
1832
2287
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
@@ -1865,7 +2320,7 @@ const main = async () => {
1865
2320
  const fresh = typeof b.fresh === "boolean" ? b.fresh : !tag || promptWantsFreshWizard(prompt);
1866
2321
  const note = (b.note ?? "").toString();
1867
2322
  const id = uniqueId(slugify((b.id ?? "").toString() || note || prompt.slice(0, 24), "task"), tasks.takenIds());
1868
- const created = tasks.create({ id, trigger, prompt, target, fresh, note, createdBy: self });
2323
+ const created = tasks.create({ id, trigger, prompt, target, fresh, quiet: b.quiet === true, note, createdBy: self });
1869
2324
  if ("error" in created) {
1870
2325
  json(res, 400, { ok: false, reason: created.error });
1871
2326
  return;
@@ -1877,7 +2332,7 @@ const main = async () => {
1877
2332
  address: peerAddress(cfg, self, target),
1878
2333
  owner: peerAddress(cfg, self, self),
1879
2334
  });
1880
- });
2335
+ }));
1881
2336
  http.register("POST /tasks/list", async (req, res) => {
1882
2337
  const { self, body } = await readPeerBody(req);
1883
2338
  if (!self) {
@@ -1920,12 +2375,13 @@ const main = async () => {
1920
2375
  }
1921
2376
  json(res, 200, { ok: true, removed: renderTask(gone, state) });
1922
2377
  });
1923
- // POST /config/set — modify daemon config from MCP
1924
- http.register("POST /config/set", async (req, res) => {
2378
+ // POST /config/set — modify daemon config from MCP. 口令门见 `guarded`; 模型还得先过
2379
+ // config_set 的审批卡 (danger.ts) 才走得到这里。
2380
+ http.register("POST /config/set", guarded(async (req, res) => {
1925
2381
  const body = (await readBody(req));
1926
2382
  const r = configSet(cfg, sourcePath, body.key, body.value, body.action);
1927
2383
  json(res, r.ok ? 200 : 400, r);
1928
- });
2384
+ }));
1929
2385
  http.register("POST /config/get", async (req, res) => {
1930
2386
  const body = (await readBody(req));
1931
2387
  const r = configGet(cfg, body.key);
@@ -2027,12 +2483,74 @@ const main = async () => {
2027
2483
  // 还要连触发时间点一起被它那一轮拖走。所以到点起一个白板 wizard 单独执行,
2028
2484
  // 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
2029
2485
  // 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
2030
- scheduledTaskInject = async (target, text, { taskId, fresh }) => {
2486
+ scheduledTaskInject = async (target, text, { taskId, fresh, quiet }) => {
2487
+ // 安静任务私下跑 (channel ""): 镜像不推它的任何一句, 终句由 relayUnlessQuiet 判断要不要进群。
2488
+ const inNamed = async () => {
2489
+ const at = Date.now();
2490
+ const turn = newTurn();
2491
+ const inj = await m.injectText(target, text, undefined, quiet
2492
+ ? { from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
2493
+ : { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
2494
+ if (inj.ok && quiet)
2495
+ void relayUnlessQuiet(target, at, turn, baseOfKey(target));
2496
+ return inj;
2497
+ };
2031
2498
  if (!fresh)
2032
2499
  await handedOff(target); // 交接中途 pane 是空的, 别当成闲着投进去
2033
2500
  const busy = fresh ? false : await m.isBusy(target);
2034
2501
  if (!fresh && !busy)
2035
- return m.injectText(target, text, undefined, { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
2502
+ return inNamed();
2503
+ // 点名的目标正忙: 顺延到它这一轮结束再投 (与 tell_peer 默认的 normal 同一口径) ——
2504
+ // 「在它那儿继续」是排班时要求的, 不该因为撞上它在干活就换成白板。后台等, 别拖住
2505
+ // 调度器的其它任务; 等满 30 分钟仍忙才退回起白板。
2506
+ const inRunner = (wasBusy) => runTaskInRunner(target, text, taskId, quiet, wasBusy);
2507
+ if (!fresh && busy) {
2508
+ // 同一条任务已有一枪在等这个目标: 这一枪并进去 —— 等它闲下来时几枪一起投, 会挤进同一个输入框。
2509
+ if (postponed.has(taskId))
2510
+ return { ok: true, reason: "上一枪还在等它闲下来, 这一枪并入" };
2511
+ postponed.add(taskId);
2512
+ void (async () => {
2513
+ const wr = await m.untilIdle(target, TASK_POSTPONE_MS);
2514
+ postponed.delete(taskId);
2515
+ const r = wr.idle ? await inNamed() : await inRunner(true);
2516
+ log.info({ taskId, target, postponed: true, idle: wr.idle, ok: r.ok, reason: r.reason }, "scheduled task: 目标正忙, 顺延");
2517
+ if (!r.ok)
2518
+ notifyChat(baseOfKey(target), withTagHeader(target, `⏰ 定时任务 \`${taskId}\` 顺延后注入失败: ${r.reason ?? "unknown"}`));
2519
+ })();
2520
+ return { ok: true, reason: "目标正忙, 顺延到它闲下来" };
2521
+ }
2522
+ return inRunner(busy);
2523
+ };
2524
+ /** 顺延中的任务 (内存即可: reload 丢了只是少放一枪, 下一次到点照常)。 */
2525
+ const postponed = new Set();
2526
+ /** 安静任务那一轮的终句: 它私下跑, 镜像不推; 等它这一轮跑完, 终句不是 `QUIET` 就替它
2527
+ * 发进群 (头写它的名字, 人知道是谁说的)。按信封件号定位答句 (同回执): 按时刻取会把紧随
2528
+ * 其后的私聊 / 人话那一轮的答案当成它的, 把私下内容发进群。 */
2529
+ const relayUnlessQuiet = async (t, at, turn, base) => {
2530
+ quietRelays.set(turn, { target: t, at, base });
2531
+ const end = at + TASK_QUIET_WAIT_MS;
2532
+ // 先等它动起来: 刚注入时注册表还报 idle。
2533
+ await sleep(5000);
2534
+ while (Date.now() < end) {
2535
+ await m.untilIdle(t, end - Date.now());
2536
+ const reply = (m.replyToPeer(t, "", at, turn)?.text ?? "").trim();
2537
+ if (reply && (await m.idleNow(t))) {
2538
+ if (!isQuiet(reply))
2539
+ notifyChat(base, withTagHeader(t, reply));
2540
+ log.info({ target: t, quiet: isQuiet(reply), len: reply.length }, "quiet task: 一轮结束");
2541
+ quietRelays.drop(turn);
2542
+ return;
2543
+ }
2544
+ await sleep(5000);
2545
+ }
2546
+ log.warn({ target: t }, "quiet task: 等满仍没收口, 不转发");
2547
+ quietRelays.drop(turn);
2548
+ };
2549
+ // 落盘: 这个仓库里 reload 是常态, 守在内存里的那一轮一 reload 就既不转发也不说 QUIET 了。
2550
+ const quietRelays = loadJsonMap(join(cfg.daemon.stateDir, "quiet-relays.json"), (rows) => Object.fromEntries(Object.entries(rows).filter(([, r]) => Date.now() - r.at < TASK_QUIET_WAIT_MS)));
2551
+ void m.restored.then(() => Object.entries(quietRelays.all()).forEach(([turn, r]) => void relayUnlessQuiet(r.target, r.at, turn, r.base)));
2552
+ /** 到点起一个白板 wizard 执行这一枪, 跑完自动收掉。 */
2553
+ const runTaskInRunner = async (target, text, taskId, quiet, busy) => {
2036
2554
  // 执行体挂在日程的主人名下: 名字 `<主人>-task-xxxx`, parent = 主人 —— 它的
2037
2555
  // rolepage 里看得见这一枪是谁的日程放的。落在执行目标的群/目录里。
2038
2556
  const base = baseOfKey(target);
@@ -2056,10 +2574,16 @@ const main = async () => {
2056
2574
  wizards.drop(runner);
2057
2575
  return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
2058
2576
  }
2059
- notifyChat(base, withTagHeader(target, busy
2060
- ? `⏰ 定时任务到点时正忙, 已起白板 wizard .${runnerName} 单独执行, 完成后自动收掉`
2061
- : `⏰ 定时任务已起白板 wizard .${runnerName} 执行, 完成后自动收掉`));
2062
- const inj = await m.injectText(runner, text, undefined, { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
2577
+ if (!quiet) {
2578
+ notifyChat(base, withTagHeader(target, busy
2579
+ ? `⏰ 定时任务到点时正忙, 已起白板 wizard .${runnerName} 单独执行, 完成后自动收掉`
2580
+ : `⏰ 定时任务已起白板 wizard .${runnerName} 执行, 完成后自动收掉`));
2581
+ }
2582
+ const at = Date.now();
2583
+ const turn = newTurn();
2584
+ const inj = await m.injectText(runner, text, undefined, quiet
2585
+ ? { from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
2586
+ : { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
2063
2587
  if (!inj.ok) {
2064
2588
  wizards.drop(runner);
2065
2589
  return inj;
@@ -2068,6 +2592,9 @@ const main = async () => {
2068
2592
  // 内没闲下来就放弃自动回收 (它大概率还在干一个长活), 留给人手动处理 ——
2069
2593
  // 比杀掉一个还在跑的 pane 安全。
2070
2594
  void (async () => {
2595
+ // 安静任务先把终句取走 (收掉 pane 之后就读不到了)。
2596
+ if (quiet)
2597
+ await relayUnlessQuiet(runner, at, turn, base);
2071
2598
  const idle = await waitForIdle(runner, m.isBusy, 30 * 60_000, () => false);
2072
2599
  if (!idle.idle) {
2073
2600
  log.warn({ runner, reason: idle.reason }, "task runner: 30min 未闲下来, 放弃自动回收");
@@ -2078,6 +2605,66 @@ const main = async () => {
2078
2605
  })();
2079
2606
  return inj;
2080
2607
  };
2608
+ // 记忆整理者的一轮: 在内部 key 上起白板 wizard, 私聊注入, 闲下来就收 —— 全程不碰
2609
+ // 任何群 (silent spawn、channel ""、无 notifyChat)。它没有人能点审批卡, 所以这一轮
2610
+ // 给它的 key 开一个 ⏱ 窗口; 危险名单照旧逐条要卡, 而它的活 (读收件箱、改 md) 不在其列。
2611
+ stewardRun = async (prompt) => {
2612
+ const target = STEWARD_TARGET;
2613
+ wizards.upsert(target, { description: "共享记忆整理的一次性执行体 (守护进程内建, 不属于任何群)", bornAt: Date.now() });
2614
+ const name = wizards.rename(target, "memsteward-run");
2615
+ setAutoWindow(target, STEWARD_RUN_MS + 5 * 60_000);
2616
+ const done = async (r) => {
2617
+ await m.killPane(target).catch(() => undefined);
2618
+ wizards.drop(target);
2619
+ clearAutoWindow(target);
2620
+ return r;
2621
+ };
2622
+ const spawned = await m.newSession(target, name, undefined, { cwd: memoryRoot(cfg.daemon.stateDir), silent: true, keepalive: false });
2623
+ if (!spawned.ok)
2624
+ return done({ ok: false, reason: `spawn: ${spawned.reason ?? "unknown"}` });
2625
+ const inj = await m.injectText(target, prompt, undefined, { from: { kind: "task", taskId: STEWARD_ID }, channel: "", envelope: stewardEnvelope() });
2626
+ if (!inj.ok)
2627
+ return done(inj);
2628
+ // 超时也收: 没人会对它喊 stop_wizard; 认领留着, 孤儿回收会再派一轮。
2629
+ const idle = await waitForIdle(target, m.isBusy, STEWARD_RUN_MS, () => false);
2630
+ return done(idle.idle ? { ok: true } : { ok: false, reason: idle.reason });
2631
+ };
2632
+ // 整理者与在场 wizard 之间的两根线 (memory.md ①②): 合并前给它看 CLAUDE.md /
2633
+ // auto-memory 好去重, 合并后告诉读这份 md 的 wizard —— 它们的宪章是出生时的快照。
2634
+ // md ↔ wizard 一律正向算 (mdsOf / cwdOfMd), 不从文件名反推。
2635
+ const stateDir = cfg.daemon.stateDir;
2636
+ // 全量绑定 (含冷的), 与 settleAll 同一份 —— 不依赖身份记录在不在。
2637
+ const boundTargets = () => m.chatRoster("").flatMap((c) => c.targets).filter((t) => !isInternalKey(t));
2638
+ const cwdOfTarget = (t) => { const c = m.getCwd(t); return c.runningCwd || c.defaultCwd; };
2639
+ // 人和各家 CLI 自己读的那几份项目说明 + 各 backend 的 auto-memory 索引。
2640
+ const PROJECT_DOCS = ["CLAUDE.md", "CODEBUDDY.md", "AGENTS.md", join(".claude", "CLAUDE.md")];
2641
+ stewardWiring = {
2642
+ refsOf: (md) => {
2643
+ // cwd 候选: 提议里带的 (提议者可能早收工了) + 在场绑定的; 再正向算出 md 比对。
2644
+ const cwds = [...new Set([...proposedCwds(stateDir), ...boundTargets().map(cwdOfTarget)].filter(Boolean))];
2645
+ const cwd = cwdOfMd(stateDir, md, cwds);
2646
+ if (!cwd)
2647
+ return [];
2648
+ return [...PROJECT_DOCS.map((f) => join(cwd, f)), ...projectDirsFor(cwd).map((d) => join(d.dir, "memory", "MEMORY.md"))].filter((f) => existsSync(f));
2649
+ },
2650
+ // 只投给 pane 活着的: 冷的那些醒来时宪章重渲染, 新记忆已在里面, 再提一句就重复了。
2651
+ onMerged: (mds) => void (async () => {
2652
+ const changed = new Set(mds);
2653
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
2654
+ if (!snap.ok)
2655
+ return;
2656
+ const live = new Set(snap.stdout.split("\n").map((l) => l.trim()).filter(Boolean));
2657
+ for (const t of boundTargets()) {
2658
+ const pane = m.sessionInfo(t)?.tmuxPane;
2659
+ if (!pane || !live.has(pane))
2660
+ continue;
2661
+ const where = (scope) => (scope === "workspace" ? "本工作区" : baseOfKey(t).startsWith("user:") ? "本单聊" : "本群");
2662
+ for (const h of mdsOf(stateDir, baseOfKey(t), cwdOfTarget(t)).filter((x) => changed.has(x.md))) {
2663
+ notices.post([t], `${where(h.scope)}记忆已更新, 全文见 \`${h.md}\``);
2664
+ }
2665
+ }
2666
+ })().catch((e) => log.warn({ err: e.message }, "memory steward: notice fan-out failed")),
2667
+ };
2081
2668
  }
2082
2669
  // 定时调度器 — 每 20s 检查任务表, 到点把 prompt 注入目标 wizard。
2083
2670
  migrateLegacySchedules(cfg, sourcePath, tasks, log.child({ mod: "tasks" }));
@@ -2090,6 +2677,10 @@ const main = async () => {
2090
2677
  cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
2091
2678
  fallbackTarget: () => (cfg.defaultChat ?? "").trim(),
2092
2679
  });
2680
+ // 共享记忆整理 — daemon 内建, 不进任务表 / 日程, 不出任何气泡。
2681
+ const steward = stewardRun
2682
+ ? startSteward({ root: memoryRoot(cfg.daemon.stateDir), log: log.child({ mod: "steward" }), run: stewardRun, ...stewardWiring })
2683
+ : undefined;
2093
2684
  // pane 上限 — 每新建一个 wizard 数一次: 活着的会话 pane 超过 wrc.mirror.maxPanes
2094
2685
  // (默认 20, 0=关) 就从最久没动的收起。数量只在出生时增长, 所以不设定时器。只收
2095
2686
  // pane 不收绑定: 下一条消息 `--resume` 复活。豁免: 名下有定时任务的 wizard (调度
@@ -2114,6 +2705,7 @@ const main = async () => {
2114
2705
  const shutdown = async (signal) => {
2115
2706
  log.info({ signal }, "shutdown signal");
2116
2707
  scheduler.stop();
2708
+ steward?.stop();
2117
2709
  tasks.stop();
2118
2710
  netWatch.stop();
2119
2711
  // 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。