wezard 2.2.2 → 2.4.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 (112) hide show
  1. package/CLAUDE.md +9 -6
  2. package/README.md +5 -5
  3. package/config.example.jsonc +12 -0
  4. package/dist/cli/init.js +2 -1
  5. package/dist/cli/init.js.map +1 -1
  6. package/dist/daemon/approval.js +90 -6
  7. package/dist/daemon/approval.js.map +1 -1
  8. package/dist/daemon/chat-log.js +6 -1
  9. package/dist/daemon/chat-log.js.map +1 -1
  10. package/dist/daemon/chat-name.js +15 -0
  11. package/dist/daemon/chat-name.js.map +1 -1
  12. package/dist/daemon/config-api.js +265 -70
  13. package/dist/daemon/config-api.js.map +1 -1
  14. package/dist/daemon/danger.js +15 -0
  15. package/dist/daemon/danger.js.map +1 -1
  16. package/dist/daemon/detail.js +49 -0
  17. package/dist/daemon/detail.js.map +1 -1
  18. package/dist/daemon/handoff.js +12 -5
  19. package/dist/daemon/handoff.js.map +1 -1
  20. package/dist/daemon/idle-nudge.js +55 -0
  21. package/dist/daemon/idle-nudge.js.map +1 -0
  22. package/dist/daemon/inbound.js +19 -14
  23. package/dist/daemon/inbound.js.map +1 -1
  24. package/dist/daemon/index.js +1509 -259
  25. package/dist/daemon/index.js.map +1 -1
  26. package/dist/daemon/jobs.js +180 -32
  27. package/dist/daemon/jobs.js.map +1 -1
  28. package/dist/daemon/last-response.js +15 -0
  29. package/dist/daemon/last-response.js.map +1 -1
  30. package/dist/daemon/memory-steward.js +124 -68
  31. package/dist/daemon/memory-steward.js.map +1 -1
  32. package/dist/daemon/mirror-bridge.js +506 -124
  33. package/dist/daemon/mirror-bridge.js.map +1 -1
  34. package/dist/daemon/mirror-store.js.map +1 -1
  35. package/dist/daemon/model-select.js +102 -0
  36. package/dist/daemon/model-select.js.map +1 -1
  37. package/dist/daemon/notices.js +147 -33
  38. package/dist/daemon/notices.js.map +1 -1
  39. package/dist/daemon/peers.js +261 -124
  40. package/dist/daemon/peers.js.map +1 -1
  41. package/dist/daemon/pending-items.js +171 -0
  42. package/dist/daemon/pending-items.js.map +1 -0
  43. package/dist/daemon/receipts.js +428 -80
  44. package/dist/daemon/receipts.js.map +1 -1
  45. package/dist/daemon/route.js +133 -19
  46. package/dist/daemon/route.js.map +1 -1
  47. package/dist/daemon/spawn-tmux.js +24 -13
  48. package/dist/daemon/spawn-tmux.js.map +1 -1
  49. package/dist/daemon/steward-guard.js +32 -0
  50. package/dist/daemon/steward-guard.js.map +1 -0
  51. package/dist/daemon/task-registry.js +1 -1
  52. package/dist/daemon/task-registry.js.map +1 -1
  53. package/dist/daemon/tasks.js +12 -4
  54. package/dist/daemon/tasks.js.map +1 -1
  55. package/dist/daemon/usage.js +4 -11
  56. package/dist/daemon/usage.js.map +1 -1
  57. package/dist/daemon/wizard-memory.js +36 -4
  58. package/dist/daemon/wizard-memory.js.map +1 -1
  59. package/dist/daemon/wizard.js +174 -87
  60. package/dist/daemon/wizard.js.map +1 -1
  61. package/dist/mcp/server.js +209 -132
  62. package/dist/mcp/server.js.map +1 -1
  63. package/dist/shared/charter-view.js +35 -0
  64. package/dist/shared/charter-view.js.map +1 -0
  65. package/dist/shared/chat-http.js +134 -32
  66. package/dist/shared/chat-http.js.map +1 -1
  67. package/dist/shared/chat-render.js +6 -3
  68. package/dist/shared/chat-render.js.map +1 -1
  69. package/dist/shared/chat-view.js +17 -6
  70. package/dist/shared/chat-view.js.map +1 -1
  71. package/dist/shared/cli-backends.js +2 -0
  72. package/dist/shared/cli-backends.js.map +1 -1
  73. package/dist/shared/config-meta.js +91 -0
  74. package/dist/shared/config-meta.js.map +1 -0
  75. package/dist/shared/config-writer.js +5 -6
  76. package/dist/shared/config-writer.js.map +1 -1
  77. package/dist/shared/config.js +273 -294
  78. package/dist/shared/config.js.map +1 -1
  79. package/dist/shared/daemon-token.js +18 -0
  80. package/dist/shared/daemon-token.js.map +1 -0
  81. package/dist/shared/detail-render.js +233 -20
  82. package/dist/shared/detail-render.js.map +1 -1
  83. package/dist/shared/detail-store.js +33 -12
  84. package/dist/shared/detail-store.js.map +1 -1
  85. package/dist/shared/effort.js +11 -0
  86. package/dist/shared/effort.js.map +1 -0
  87. package/dist/shared/keepalive.js +5 -5
  88. package/dist/shared/keepalive.js.map +1 -1
  89. package/dist/shared/model-prices.js +258 -0
  90. package/dist/shared/model-prices.js.map +1 -0
  91. package/dist/shared/pricing.js +55 -0
  92. package/dist/shared/pricing.js.map +1 -0
  93. package/dist/shared/reminder.js +18 -4
  94. package/dist/shared/reminder.js.map +1 -1
  95. package/dist/shared/role-render.js +210 -8
  96. package/dist/shared/role-render.js.map +1 -1
  97. package/dist/shared/role-view.js +104 -26
  98. package/dist/shared/role-view.js.map +1 -1
  99. package/dist/shared/session-label.js +4 -0
  100. package/dist/shared/session-label.js.map +1 -1
  101. package/dist/shared/task-file.js +16 -1
  102. package/dist/shared/task-file.js.map +1 -1
  103. package/dist/shared/turn-state.js +24 -0
  104. package/dist/shared/turn-state.js.map +1 -0
  105. package/dist/shared/world.js +116 -13
  106. package/dist/shared/world.js.map +1 -1
  107. package/dist/svr/index.js +2 -1
  108. package/dist/svr/index.js.map +1 -1
  109. package/hooks/pre-tool-use.sh +15 -3
  110. package/package.json +1 -1
  111. package/web/chat.css +226 -74
  112. package/web/chat.js +1075 -370
@@ -1,46 +1,61 @@
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
- import { loadConfig } from "../shared/config.js";
8
+ import { TIERS, 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, truncate } from "../shared/std.js";
11
+ import { patchJsonc } from "../shared/config-writer.js";
12
+ import { EFFORTS, parseEffort } from "../shared/effort.js";
13
+ import { bindCliBackends, projectDirsFor } from "../shared/cli-backends.js";
6
14
  import { startWs } from "./ws.js";
7
15
  import { startNetWatch } from "./net-watch.js";
8
16
  import { startHttp, json, readBody } from "./http.js";
9
- import { configGet, configSet } from "./config-api.js";
17
+ import { parsePath } from "../shared/config-meta.js";
18
+ import { applyHot, patchedTop, configGet, fromLegacy, legacyGet, pickTier, planSet, renderPlan, tierEvidence } from "./config-api.js";
19
+ import { computeUsage, costOf, fmtCost, fmtTokens, sumTotal } from "./usage.js";
10
20
  import { installInboundRouter } from "./inbound.js";
11
21
  import { loadMirrorStore } from "./mirror-store.js";
12
22
  import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
13
- import { setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
14
- import { installApprovalEventListener, makeApproveHandler } from "./approval.js";
15
- import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, recordPost } from "./detail.js";
23
+ import { runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
24
+ import { installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
25
+ import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, currentHumanQueryOf, recordPost, recordCharter, backfillCharter } from "./detail.js";
16
26
  import { EMPTY_FACTS } from "../shared/world.js";
17
- import { initAutoWindowPersistence } from "./session-cache.js";
27
+ import { clearAutoWindow, initAutoWindowPersistence, setAutoWindow } from "./session-cache.js";
18
28
  import { makeMessageHandler } from "./outbound.js";
19
29
  import { makeCardHandler, makeAskHandler, installAskEventListener } from "./ask.js";
20
30
  import { drainForReload, listPending } from "./pending.js";
21
31
  import { makeClaimStartHandler, makeClaimStatusHandler, makeClaimResetHandler, } from "./claim.js";
22
32
  import { makeWedocBridge } from "./wedoc.js";
23
- import { installResponseTracker } from "./last-response.js";
33
+ import { errText, installResponseTracker } from "./last-response.js";
24
34
  import { scanClaudeSessions } from "./session-scan.js";
25
- import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, } from "./tasks.js";
35
+ import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, TASK_POSTPONE_MS, TASK_QUIET_WAIT_MS, } from "./tasks.js";
26
36
  import { openTaskRegistry } from "./task-registry.js";
27
37
  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";
32
- import { applyChatNames, chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
33
- import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderRoster, CONTEXT_FULL_TOKENS, } from "./wizard.js";
34
- 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";
38
+ import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
39
+ import { baseOfKey, bindTagLinker, isInternalKey, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
40
+ import { appendEpisode, clipForCharter, cwdOfMd, episodePath, inboxPath, mdsOf, proposedCwds, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
41
+ import { retireStewardTask, startSteward, stewardEnvelope, STEWARD_ID, STEWARD_RUN_MS, STEWARD_TARGET } from "./memory-steward.js";
42
+ import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
43
+ import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, backfillBorn, sweepUnborn, BOOT_ID, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderLead, renderLeadPlaybook, ROSTER_FRESH_MS, renderRoster, handoffAt, memoryNudgeAt, MEMORY_NUDGE_FLOOR, MEMORY_MAX, NOTE_MAX, } from "./wizard.js";
44
+ import { bindNoticeBox, createNoticeBox, createSeenRing, gcSeen, chatAudience, hears } from "./notices.js";
45
+ import { createLedger, digest, pendingGc, renderDigest, renderTable, renderAwaiting, renderStalled, renderShelved, ago as agoMs, HEARD_EVERY_MS } from "./pending-items.js";
46
+ import { loadJobStore, dutyLine, awaitingAccept, stalledRoots, shelvedRoots, nudgeDue, dayClamp, childrenOf as jobChildren, jobEpisode, rejectReason, ACCEPTS, JOB_MEMBER_MAX, JOB_DEPTH_MAX, depthOf, ancestorsOf as jobAncestors, treeOrder, jobStage } from "./jobs.js";
47
+ import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, lastContextTokens, lastExchange, lastModel, openingTurn, replyClosedBefore, talkTurns, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
37
48
  import { keepalivePingSigs } from "../shared/keepalive.js";
38
49
  import { expandHome } from "../shared/paths.js";
39
50
  import { loadJsonMap } from "../shared/json-map-store.js";
40
- import { createReceipts } from "./receipts.js";
51
+ import { createReceipts, deadlineOf, kAttr, kOfAttr, newTurn } from "./receipts.js";
52
+ import { renderInFlight } from "../shared/turn-state.js";
53
+ import { startIdleNudge } from "./idle-nudge.js";
54
+ import { renderReminder } from "../shared/reminder.js";
41
55
  import { createHandoffs, handedOff, handingOff } from "./handoff.js";
42
- import { rankCandidates, renderCandidates } from "./route.js";
43
- import { parseWhen, renderChatLog } from "./chat-log.js";
56
+ import { coldGateOf, decide, rankCandidates, renderCandidates, tierFor, wakeNoteOf } from "./route.js";
57
+ import { parseWhen, renderChatLog, UNKNOWN_HUMAN } from "./chat-log.js";
58
+ import { audienceOf } from "../shared/role-view.js";
44
59
  import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
45
60
  // pino's file transport is async; without flush, log.fatal before process.exit
46
61
  // vanishes. Mirror anything fatal to stderr too so launchd's stderr.log captures it.
@@ -59,6 +74,9 @@ const main = async () => {
59
74
  log.info({ sourcePath, pid: process.pid }, "daemon start");
60
75
  // 定时调度器的 `inject` 真正实现, 赋值在 mirror-mode 的大块里 (要用到那里面的
61
76
  // wizards / m / notifyChat), 但 startScheduler 在那块外面接线 —— 见文件尾。
77
+ // 记忆整理者的执行体, 同样只有 mirror 模式给得出 —— 赋值见 scheduledTaskInject 旁边。
78
+ let stewardRun;
79
+ let stewardWiring = {};
62
80
  let scheduledTaskInject;
63
81
  // Bind the CLI backend registry. `primary` (= defaultCli) drives new-session
64
82
  // spawns; `backends` is every installed CLI whose transcript root exists, so
@@ -90,12 +108,15 @@ const main = async () => {
90
108
  const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
91
109
  // 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
92
110
  // (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
93
- // 共享记忆的整理者是一条定时任务 (memory-steward.ts), 缺了就补一份默认的。
111
+ // 共享记忆的整理者改成了 daemon 内建定时器 (memory-steward.ts); 老版本写下的那条
112
+ // 定时任务要在任务表加载前停用, 否则两路并跑。
94
113
  try {
95
- ensureStewardTask(memoryRoot(cfg.daemon.stateDir));
114
+ const retired = retireStewardTask();
115
+ if (retired)
116
+ log.info({ retired }, "memory steward task file retired");
96
117
  }
97
118
  catch (e) {
98
- log.warn({ err: e.message }, "memory steward task not written");
119
+ log.warn({ err: e.message }, "memory steward task not retired");
99
120
  }
100
121
  const tasks = await openTaskRegistry(log.child({ mod: "tasks" }), (added, removed) => log.info({ added, removed }, "task files changed"));
101
122
  const bridge = startMirror({ cfg, log: log.child({ mod: "mirror" }), client: ws.client, store: mirrorStore });
@@ -135,9 +156,10 @@ const main = async () => {
135
156
  const t = bridge.targetForSession(sid);
136
157
  return t ? await bridge.interruptPane(t) : { ok: false, reason: "no mirror target for session" };
137
158
  },
159
+ // 告知那一句接着被取消的那一轮说: 频道照它的走 —— 不带频道就等于在 home 群里开口。
138
160
  tell: async (sid, text) => {
139
161
  const t = bridge.targetForSession(sid);
140
- return t ? await bridge.injectText(t, text) : { ok: false, reason: "no mirror target for session" };
162
+ return t ? await bridge.injectText(t, text, undefined, { channel: bridge.currentChannel(t) }) : { ok: false, reason: "no mirror target for session" };
141
163
  },
142
164
  };
143
165
  const http = startHttp({ cfg, ws, log, sourcePath });
@@ -145,9 +167,23 @@ const main = async () => {
145
167
  http.register("POST /message", makeMessageHandler(ws.client, log.child({ mod: "outbound" })));
146
168
  http.register("POST /card", makeCardHandler(ws.client, log.child({ mod: "outbound" })));
147
169
  http.register("POST /ask", makeAskHandler(ws.client, log.child({ mod: "ask" })));
148
- http.register("POST /claim/start", makeClaimStartHandler({ log: log.child({ mod: "claim" }) }));
170
+ // 写敏感状态的路由只认出示口令的 —— wezard 的 MCP server 与 CLI 读得到 0600 的口令文件
171
+ // (见 shared/daemon-token.ts), 本机别的进程 curl 不进来: 写配置、改 allowFrom 的认领
172
+ // 口令、落一份 daemon 会执行其 gate 的任务文件, 都不该是谁连上 127.0.0.1 就能做的。
173
+ const daemonToken = loadOrCreateSvrToken(DAEMON_TOKEN_FILE);
174
+ const tokenOk = (req) => {
175
+ const got = Buffer.from(String(req.headers[DAEMON_TOKEN_HEADER] ?? ""));
176
+ const want = Buffer.from(daemonToken);
177
+ return got.length === want.length && timingSafeEqual(got, want);
178
+ };
179
+ const guarded = (h) => async (req, res, url, ctx) => {
180
+ if (tokenOk(req))
181
+ return h(req, res, url, ctx);
182
+ json(res, 403, { ok: false, reason: `这条路由要出示 ${DAEMON_TOKEN_FILE} 里的口令 (header ${DAEMON_TOKEN_HEADER}); 正在跑的 wizard 若是旧版 MCP 进程, 重开会话即可` });
183
+ };
184
+ http.register("POST /claim/start", guarded(makeClaimStartHandler({ log: log.child({ mod: "claim" }) })));
149
185
  http.register("GET /claim/status", makeClaimStatusHandler());
150
- http.register("POST /claim/reset", makeClaimResetHandler());
186
+ http.register("POST /claim/reset", guarded(makeClaimResetHandler()));
151
187
  http.register("GET /detail", makeDetailHandler(log.child({ mod: "detail" })));
152
188
  for (const [key, handler] of Object.entries(chatHandlers()))
153
189
  http.register(key, handler);
@@ -369,13 +405,8 @@ const main = async () => {
369
405
  const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive, silent: true });
370
406
  if (!r.ok && !existed)
371
407
  wizards.drop(target);
372
- // r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
373
- // 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
374
- const modelNote = r.model
375
- ? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
376
- : "";
377
408
  if (r.ok)
378
- postRoster(base, [target, self], `新 wizard **.${name}** 就位${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— 空白起步, 由 ${displayName(self)} 造的`);
409
+ postRoster(base, [target, self], { op: "born", name: `.${name}`, by: displayName(self), cwd: r.cwd }, subjectOf(wizards.get(target)));
379
410
  json(res, r.ok ? 200 : 500, r.ok
380
411
  ? {
381
412
  ok: true,
@@ -532,6 +563,8 @@ const main = async () => {
532
563
  const home = baseOfKey(self);
533
564
  const cur = wizards.get(home)?.name ?? "";
534
565
  const wizardRenamed = previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined;
566
+ if (wizardRenamed)
567
+ void m.retitlePane(home);
535
568
  json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(wizardRenamed ? { wizardRenamed } : {}) });
536
569
  });
537
570
  const readPeerBody = async (req) => {
@@ -559,7 +592,7 @@ const main = async () => {
559
592
  const chatId = chatIdOf(base);
560
593
  void ws.client
561
594
  .sendMessage(chatId, { msgtype: "markdown", markdown: { content: linkTags(base, markdown) } })
562
- .catch((e) => log.warn({ err: e.message }, "chat notify failed"));
595
+ .catch((e) => log.warn({ chatId, err: errText(e) }, "chat notify failed"));
563
596
  };
564
597
  // wizard 在群里的称呼, 只用在**正文**里。气泡的头照旧交给 withTagHeader ——
565
598
  // 那一段是路由信息 (`emoji #tag`), parseTagHeader 靠它反解, 群里引用一条气泡
@@ -589,13 +622,33 @@ const main = async () => {
589
622
  * 私聊轮或无记录 → 它的 home 群。notify 与 public send_peer 默认发到这里。 */
590
623
  const channelOf = (self) => m.currentChannel(self) || baseOfKey(self);
591
624
  /** 同伴注入的信封: 公开轮写上那个群的名字, 私聊不写 (见 peers.renderPeerEnvelope)。 */
592
- const envelopeFor = (from, channel) => renderPeerEnvelope(displayName(from), channel ? chatNameOf(cfg, channel) : undefined);
625
+ const envelopeFor = (from, channel, t) => renderPeerEnvelope(displayName(from), channel ? chatNameOf(cfg, channel) : undefined, t);
593
626
  // 回执 (见 receipts.ts): 一次 tell_peer 之后对方干完那一轮, 它的结论自动 paste
594
627
  // 回发话方的输入框, 发话方全程不阻塞。这里只提供四样能力给那个模块 —— 忙闲、
595
628
  // 死活、「它答我那一句的是哪段话」、以及怎么把一段话送进去 —— 投递逻辑在那边。
629
+ // 工单账本 (见 jobs.ts)。只在**显式传了 `job`** 时起作用 —— 不用工单的调用方
630
+ // 行为与从前一模一样。回执要数它的成员, 所以先于回执载入。
631
+ const jobs = loadJobStore(cfg.wrc.mirror.jobsFile);
632
+ /** 回执那一轮在群里的终句答给谁 (userid) —— 与 rolepage 同一个判定, 写进信封给模型与
633
+ * read_chat; 认不出 = ""。 */
634
+ const audienceName = (channel, asker) => {
635
+ const id = audienceOf(channel, asker);
636
+ return id === "human:" ? "" : id.slice("human:".length);
637
+ };
638
+ // 挂起事项表 (见 pending-items.ts): 派活时记一行, 回执落定时由下面的 onOutcome 记账。
639
+ const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc));
596
640
  const receipts = createReceipts({
597
- isBusy: m.isBusy,
598
641
  idleNow: m.idleNow,
642
+ onOutcome: (s, status, bodyText, bySender) => ledger.outcome(s.from, s.turn ?? "", status, parseClosing(bodyText).text || bodyText, bySender),
643
+ jobTally: (id) => jobs.tally(id),
644
+ parkedNow: m.parkedNow,
645
+ settleJob: (id, target, outcome, artifacts) => { jobs.settle(id, target, outcome, artifacts); },
646
+ accept: (id, body) => { const j = jobs.get(id); return j?.status === "open" ? rejectReason(j.accept ?? "result", parseClosing(body)) : undefined; },
647
+ rebound: async (x, why) => (await m.injectText(x.to, `(守护进程代 ${displayName(x.from)} 打回一次: 这件活是工单里的一份, 你的回复${why}。请按约定收口 —— 末尾一行 \`RESULT: <结论>\`, 交付了文件每个加一行 \`ARTIFACT: <路径> — 一句话\`; 真缺信息就 \`NEED: <问题>\`。)`, undefined, {
648
+ from: { kind: "peer", from: x.from, turn: x.turn, ...(x.asker ? { asker: x.asker } : {}) },
649
+ channel: x.channel,
650
+ envelope: envelopeFor(x.from, x.channel, { turn: x.turn, legs: x.legs, re: true }),
651
+ })).ok,
599
652
  untilIdle: m.untilIdle,
600
653
  paneLive: m.paneLive,
601
654
  nameOf: displayName,
@@ -603,39 +656,50 @@ const main = async () => {
603
656
  store: loadJsonMap(cfg.wrc.mirror.receiptsFile),
604
657
  handingOff,
605
658
  handedOff,
606
- // 信封锚取不到 (对方是个还不挂信封的老 wizard) 时退回按时刻取, 由 receipts
607
- // 区分这两种"没有"。
608
- replyFor: (to, fromName, since) => m.replyToPeer(to, fromName, since),
659
+ replyFor: (to, fromName, since, turn) => m.replyToPeer(to, fromName, since, turn),
660
+ lastWords: m.lastReply,
609
661
  deliver: (to, bodyText, meta) => m.injectText(to, clipMiddle(bodyText), undefined, {
610
662
  // receipt:true 是「这一轮不该再生回执」的记录 —— 回环在结构上就不成立:
611
663
  // 只有 tell_peer 那条路登记 watcher, 这里是直接注入。
612
- from: { kind: "peer", from: meta.from, receipt: true, ...(meta.job ? { job: meta.job } : {}) },
664
+ 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
665
  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),
666
+ 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", ...(jobs.get(meta.job)?.kind === "req" ? { req: true } : {}) } : undefined, meta.status, meta.turn || undefined, {
667
+ ...(meta.replyTo ? { replyTo: meta.replyTo.kind === "chat" ? chatNameOf(cfg, meta.replyTo.channel) : displayName(meta.replyTo.from) } : {}),
668
+ ...(meta.k ? { k: kAttr(meta.k) } : {}),
669
+ ...(meta.pending ? { pending: meta.pending } : {}),
670
+ ...(audienceName(meta.channel, meta.asker) ? { audience: audienceName(meta.channel, meta.asker) } : {}),
671
+ }),
615
672
  }),
616
673
  });
674
+ const turnsLine = (xs, target) => renderInFlight(xs, target, displayName);
617
675
  // reload 前在飞的回执: 等 mirror 把绑定都恢复了再续守 —— 早一步读 target 会拿不到
618
676
  // transcript, 退回按时刻取就可能把上一件事的结论当回执。
619
677
  // 交接 (见 handoff.ts): 换会话不换身份。这里给它重开、贴话、读旧 transcript 的能力,
620
678
  // 闸、回执清点与断点续做在那边。
621
679
  const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
680
+ /** 情景记忆 (memory/episodes/<name>.jsonl) 按名字归档; 默认会话没名字时退回 slot / "default"。 */
681
+ const episodeName = (t) => wizards.get(t)?.name || tagOfKey(t) || "default";
622
682
  const handoffs = createHandoffs({
623
683
  isBusy: m.isBusy,
624
684
  sessionId: (t) => m.sessionInfo(t)?.sessionId ?? "",
625
685
  restart: (t) => restartFresh(t),
626
- // 欠着回执的发话方挂上它们的信封: 新会话那一轮就是在答它们, 回执照常按信封定位,
627
- // 频道也接回原来那一句所在的地方 (私聊不进群)。
628
- inject: (t, text, owe) => owe.length
629
- ? m.injectText(t, text, undefined, {
630
- from: { kind: "peer", from: owe[0].from },
631
- channel: owe[0].channel,
632
- envelope: owe.map((o) => envelopeFor(o.from, o.channel)).join(""),
633
- })
634
- : m.injectText(t, text),
686
+ // 欠着回执的发话方挂上它的信封: 新会话那一轮就是在答它, 回执照常按信封定位, 频道也接回
687
+ // 原来那一句所在的地方 (私聊不进群)。欠好几份时每份各开一轮 (见 carryRest): 并成一句,
688
+ // 那一轮就只有一个去向, 别的上游拿到的是答给第一份的话。
689
+ inject: async (t, text, owe, channel) => {
690
+ const [first, ...rest] = owe;
691
+ if (!first)
692
+ return m.injectText(t, text, undefined, { channel });
693
+ const r = await m.injectText(t, text, undefined, carryOpts(first));
694
+ if (r.ok && rest.length)
695
+ void carryRest(t, rest);
696
+ return r;
697
+ },
698
+ channelOf: m.currentChannel,
635
699
  lastText: m.lastText,
636
- answeredBefore: (to, from, since, until) => {
700
+ answeredBefore: (to, from, since, until, turn) => {
637
701
  const p = m.sessionInfo(to)?.jsonlPath;
638
- return p ? replyClosedBefore(expandHome(p), displayName(from), since, until, pingSigs) : undefined;
702
+ return p ? replyClosedBefore(expandHome(p), displayName(from), since, until, pingSigs, turn) : undefined;
639
703
  },
640
704
  receipts,
641
705
  // 交接是生命周期事件, 不进群; 失败只捎给它自己 (它调完 handoff_self 拿到的是
@@ -643,9 +707,110 @@ const main = async () => {
643
707
  notify: (t, text) => notices.post([t], text),
644
708
  log,
645
709
  store: loadJsonMap(cfg.wrc.mirror.handoffsFile),
710
+ archive: (e) => {
711
+ if (!e.brief.trim())
712
+ return;
713
+ const name = episodeName(e.target);
714
+ const ok = appendEpisode(episodePath(cfg.daemon.stateDir, name), { at: e.at, kind: "handoff", name, sid: e.sid, nextSid: e.nextSid, text: e.brief });
715
+ if (!ok)
716
+ log.warn({ target: e.target, name }, "handoff episode not archived");
717
+ },
646
718
  });
719
+ /** 交接续答一份欠账的那一句: 挂发话方的信封, 出处与频道都是它当初那一句的。 */
720
+ function carryOpts(o) {
721
+ return {
722
+ from: { kind: "peer", from: o.from, ...(o.turn ? { turn: o.turn } : {}), ...(o.channel ? { public: true } : {}), ...(o.asker ? { asker: o.asker } : {}) },
723
+ channel: o.channel,
724
+ envelope: envelopeFor(o.from, o.channel, o.turn ? { turn: o.turn } : undefined),
725
+ };
726
+ }
727
+ /** 简报那一轮之后, 余下的欠账一份一轮: 等它接住上一句 (至多 20s 动起来) 并答完, 再贴下一份。 */
728
+ async function carryRest(t, owe) {
729
+ for (const o of owe) {
730
+ for (const end = Date.now() + 20_000; Date.now() < end && (await m.idleNow(t));)
731
+ await sleep(1000);
732
+ await m.untilIdle(t, 3600_000);
733
+ const r = await m.injectText(t, `(交接续答)接着上面的简报: 你交接前还欠 ${displayName(o.from)} 一个交代${o.turn ? ` (件 \`${o.turn}\`)` : ""} —— 现在只就这件收口。`, undefined, carryOpts(o));
734
+ log.info({ mod: "handoff", target: t, from: o.from, turn: o.turn, ok: r.ok, reason: r.reason }, "handoff: 欠账续答");
735
+ }
736
+ }
737
+ /** `self` 此刻这一轮派出去的活, 结论该交给谁 (见 receipts.parentOf)。只读它自己
738
+ * transcript 里开这一轮的那句 user 行的信封: attachment 上的 channel 在注入时就被
739
+ * 排队的下一句改写了, transcript 才记着「这一轮是谁发起的」, reload 后也读得到。 */
740
+ /** `target` 手上这一轮是哪件 peer 活 —— 认开头那句的信封: 有件号按件号, 老信封按发话方。 */
741
+ /** `target` 手上这一轮若是某个 wizard 派来的活: 发话方的称呼 (`.x`) 与件号。人 / 定时 / 回执轮 = undefined。 */
742
+ const openingAsk = (target) => {
743
+ const p = m.sessionInfo(target)?.jsonlPath;
744
+ const env = p ? openingTurn(expandHome(p), pingSigs)?.env : undefined;
745
+ return env?.kind === "peer" && !env.receipt ? { from: env.from, ...(env.turn ? { turn: env.turn } : {}) } : undefined;
746
+ };
747
+ const currentAsk = (target) => {
748
+ const env = openingAsk(target);
749
+ if (!env)
750
+ return () => false;
751
+ return (x) => (env.turn ? x.turn === env.turn : displayName(x.from) === env.from);
752
+ };
753
+ /** 打断 `target` 会不会误伤别人的活: 它正忙, 且这一轮不是调用方派的 —— 别的 wizard 派的、
754
+ * 人说的、定时任务放的 → 那一方的称呼。回执轮是它自己那件活的延续, 不算别人的。 */
755
+ const othersTurn = async (target, self) => {
756
+ if (await m.idleNow(target))
757
+ return undefined;
758
+ const p = m.sessionInfo(target)?.jsonlPath;
759
+ const env = p ? openingTurn(expandHome(p), pingSigs)?.env : undefined;
760
+ if (env?.receipt)
761
+ return undefined;
762
+ if (env?.kind === "peer")
763
+ return env.from === displayName(self) ? undefined : { from: env.from, ...(env.turn ? { turn: env.turn } : {}) };
764
+ return { from: env?.kind === "task" ? env.from : env?.from ? `人 (${env.from})` : "人" };
765
+ };
766
+ const openingOfSelf = (self) => {
767
+ const p = m.sessionInfo(self)?.jsonlPath;
768
+ return p ? openingTurn(expandHome(p), pingSigs) : undefined;
769
+ };
770
+ const parentKOf = (self) => {
771
+ const opening = openingOfSelf(self);
772
+ // 不用 channelOf: 它把私聊轮 ("") 回落成 home, 私聊轮派出的活就会续回 home 群。
773
+ return receipts.parentOf(self, opening?.env, m.currentChannel(self), String(opening?.ms ?? 0));
774
+ };
775
+ /** `self` 此刻这一轮的链头 (见 Asker), 同样认开头那句的信封: 人在群里说的 → 信封上的
776
+ * userid 与群; 与人的单聊里没挂信封 → 那个人; 同伴派的活 / 回执 → 登记那件活时记下的;
777
+ * 定时任务、CLI 敲字、认不出 → undefined (如实不知道, 不猜)。 */
778
+ const askerOf = (self) => {
779
+ const env = openingOfSelf(self)?.env;
780
+ const home = baseOfKey(self);
781
+ if (!env)
782
+ return home.startsWith("user:") ? { who: home, chat: home } : undefined;
783
+ if (env.kind === "human") {
784
+ const chat = env.chat ? chatBaseOf(cfg, env.chat) : home;
785
+ return chat ? { who: `user:${env.from}`, chat } : undefined;
786
+ }
787
+ return env.kind === "peer" ? receipts.askerOf(self, env) : undefined;
788
+ };
647
789
  // 交接先续做: 它的闸要在回执续守之前立起来, 否则续守的 watcher 会读到换了一半的会话。
648
790
  void m.restored.then(() => { handoffs.resume(); receipts.resume(); });
791
+ // 闲置追问 (见 idle-nudge.ts): 对方持续闲着又没回执 → 代发话方同件号问一句进展, 答复照常回执。
792
+ // 注入照抄工单打回 (rebound) 那条: 私聊信封挂发话方的名义与件号, 父 k 等由 reask 沿用原件。
793
+ void m.restored.then(() => startIdleNudge({
794
+ inflight: () => receipts.states(),
795
+ idleNow: m.idleNow,
796
+ handingOff,
797
+ jobOpen: (id) => jobs.get(id)?.status === "open",
798
+ policy: () => ({ afterMs: cfg.wrc.mirror.idleNudge.afterMin * 60_000, max: cfg.wrc.mirror.idleNudge.max }),
799
+ ask: async ({ from, to, turn, text }) => {
800
+ const x = receipts.reask(from, to, turn);
801
+ if (!x)
802
+ return false;
803
+ ledger.touch(from, turn);
804
+ return (await m.injectText(to, text, undefined, {
805
+ from: { kind: "peer", from, turn, ...(x.asker ? { asker: x.asker } : {}) },
806
+ channel: x.channel,
807
+ envelope: envelopeFor(from, x.channel, { turn, legs: x.legs, re: true }),
808
+ })).ok;
809
+ },
810
+ nameOf: displayName,
811
+ store: loadJsonMap(join(cfg.daemon.stateDir, "idle-nudges.json")),
812
+ log: log.child({ mod: "idle-nudge" }),
813
+ }));
649
814
  /** 入参里的地址: 新字段 `name`, 老 MCP 进程 (正在跑的 wizard) 仍在传 `tag`。 */
650
815
  const addrOf = (b) => String(b.name ?? b.tag ?? "");
651
816
  /** 撞上的名字属于一个静默超过一天的 wizard → 顶掉它 (见 evictStaleName), 同群的
@@ -656,7 +821,7 @@ const main = async () => {
656
821
  return false;
657
822
  const slot = !!tagOfKey(gone.target);
658
823
  log.child({ mod: "wizard" }).info({ target: gone.target, name: gone.name, slot }, "stale name evicted");
659
- postRoster(baseOfKey(gone.target), [], `**.${gone.name}** 静默超过一天, ${slot ? "已被同名的新 wizard 顶掉并收工" : "名字让给了同名的新 wizard"}`);
824
+ postRoster(baseOfKey(gone.target), [], { op: "ended", name: `.${gone.name}`, why: slot ? "evicted (idle >1d, name reused)" : "name yielded (idle >1d)" }, subjectOf(gone));
660
825
  return true;
661
826
  };
662
827
  /** 给一个要出生的 wizard 挑槽位 (target key) 并落定名字。名字全局唯一:
@@ -728,13 +893,24 @@ const main = async () => {
728
893
  const refs = (b.to ?? []).map((r) => (r ?? "").trim()).filter(Boolean);
729
894
  const bad = refs.filter((r) => !chatBaseOf(cfg, r));
730
895
  if (bad.length) {
731
- json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (list_chats 看有哪些; 没起名的聊天寻址不到)` });
896
+ json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
732
897
  return;
733
898
  }
734
899
  const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
900
+ // 顶层模式: 只有顶层 wizard 对人说话。拦的是「这一轮在为同伴干活」的: 同伴私聊派来的活, 或
901
+ // 回执轮但那件活是为上游同伴派的 (k 是 peer)。人说的、定时任务放的、旁支 (chain:false, 无 k)
902
+ // 的回执轮都不拦; 管家只在自己的群里豁免。
903
+ const env = openingOfSelf(self)?.env;
904
+ const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
905
+ const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
906
+ if (muted.length && belowTop) {
907
+ json(res, 409, { ok: false, topOnly: true, reason: `${muted.map((d) => chatNameOf(cfg, d) || d).join(", ")} 开着顶层模式: 你这一轮是同伴派来的活, 不对人说话 —— 要说的写进终句 (\`RESULT:\` / \`NEED:\`), 由派你的那个代你向人交代` });
908
+ return;
909
+ }
910
+ const asker = askerOf(self);
735
911
  for (const dest of dests) {
736
912
  notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
737
- recordPost({ target: self, channel: dest, body: content });
913
+ recordPost({ target: self, channel: dest, body: content, ...(asker ? { asker } : {}) });
738
914
  }
739
915
  json(res, 200, { ok: true, sent: dests.map((d) => chatNameOf(cfg, d) || d) });
740
916
  });
@@ -755,6 +931,7 @@ const main = async () => {
755
931
  // 截断的视口加一层 TUI 装饰, 而「它卡在哪」transcript 答得更准 —— 不在转圈
756
932
  // 却悬着工具调用, 就是停在审批卡 / 本地弹窗上等人点。
757
933
  const peek = await m.peekTurns(target, turns);
934
+ const inflight = turnsLine(receipts.states(), target);
758
935
  const waiting = peek.waiting ?? [];
759
936
  const state = peek.busy
760
937
  ? "正在生成"
@@ -766,7 +943,7 @@ const main = async () => {
766
943
  name: peerAddress(cfg, self, target),
767
944
  busy: peek.busy ?? false,
768
945
  ...(waiting.length ? { waiting } : {}),
769
- text: [`${displayName(target)} · ${state}${foreign ? ` · 住在群 ${chatNameOf(cfg, target) || "(未命名)"}` : ""}`, peek.dialog ?? `(${peek.reason})`].join("\n"),
946
+ text: [`${displayName(target)} · ${state}${foreign ? ` · 住在群 ${chatNameOf(cfg, target) || "(未命名)"}` : ""}`, ...(inflight ? [inflight] : []), peek.dialog ?? `(${peek.reason})`].join("\n"),
770
947
  });
771
948
  });
772
949
  // ── 聊天记录 ────────────────────────────────────────────────────────
@@ -785,7 +962,7 @@ const main = async () => {
785
962
  name: displayName(t.target),
786
963
  jsonlPath: t.jsonlPath,
787
964
  homeChat: chatNameOf(cfg, home),
788
- homeHuman: home.startsWith("user:") ? home.slice(5) : "人",
965
+ homeHuman: home.startsWith("user:") ? home.slice(5) : UNKNOWN_HUMAN,
789
966
  since: rec?.clonedFrom ? rec.bornAt : 0,
790
967
  };
791
968
  };
@@ -809,7 +986,7 @@ const main = async () => {
809
986
  // 什么都没给 = 调用方这一轮所在的群; 只给 target = 调用方与它的往来。
810
987
  const base = chatRef ? chatBaseOf(cfg, chatRef) : roleRef || targetRef ? "" : channelOf(self);
811
988
  if (chatRef && !base) {
812
- json(res, 404, { ok: false, reason: `认不出聊天 '${chatRef}' (list_chats 看有哪些)` });
989
+ json(res, 404, { ok: false, reason: `认不出聊天 '${chatRef}' (wizard_roster 每行有 home 聊天名, 人侧 /chats)` });
813
990
  return;
814
991
  }
815
992
  const role = roleRef ? logRole(self, roleRef) : targetRef ? { name: displayName(self), target: self } : undefined;
@@ -855,96 +1032,219 @@ const main = async () => {
855
1032
  // 等待」: 调用方注入完就返回, 对方干完那一轮的结论由守护进程自动送回来 (receipts)。
856
1033
  // `/peers/send` 保留为同一个处理函数: 正在跑的 wizard 的 MCP 进程是旧代码, 换名字
857
1034
  // 不能把它们的通路掐断。
858
- const tellPeer = async (req, res) => {
859
- const { self, body } = await readPeerBody(req);
860
- if (!self) {
861
- json(res, 400, { ok: false, reason: "cannot resolve caller session" });
862
- return;
863
- }
1035
+ // 冷与否 = 缓存最近一次被碰 (最近一次真实请求的开始; 读不到退回 mtime) 距今多久 vs 它实际按多长 TTL 写的
1036
+ // (与 route_candidates 同一口径)。
1037
+ const touchOf = (p) => { try {
1038
+ return cacheTouchMs(p) || statSync(p).mtimeMs;
1039
+ }
1040
+ catch {
1041
+ return 0;
1042
+ } };
1043
+ const ttlOf = (p) => (cacheTtlSec(p) || cfg.wrc.mirror.keepalive.ttlSec) * 1000;
1044
+ // 被冷门控退回过的 `self\0target` → 到期时刻: 老 MCP 进程原样重发即视为 force。
1045
+ const coldRefused = new Map();
1046
+ /** tell_peer 的本体: 以 `self` 的名义说一句 —— HTTP 路由与 dispatch 共用。`extra` = 挂在信封后面的
1047
+ * 一段 (只进对方输入框, 不进 `text`: 公开时 text 会成群气泡)。 */
1048
+ const tellPeerAs = async (self, body, extra = "") => {
864
1049
  const text = (body.text ?? "").toString();
865
1050
  if (!text.trim()) {
866
- json(res, 400, { ok: false, reason: "text required" });
867
- return;
868
- }
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
- }
1051
+ return { status: 400, body: { ok: false, reason: "text required" } };
876
1052
  }
1053
+ // 这句话要对方怎么接 (priority 管何时投, receipt 管要不要回执, 这个管它怎么接):
1054
+ // task 一件活 (默认) / ask 只答一问 / fyi 知会 —— fyi 不要回执、不进工单、不进群。
1055
+ const rawKind = body.kind;
1056
+ const kind = rawKind === "ask" || rawKind === "fyi" ? rawKind : "task";
877
1057
  const r = resolvePeer(self, addrOf(body));
878
1058
  if (!r.ok) {
879
- json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
880
- return;
1059
+ return { status: r.status, body: { ok: false, reason: r.reason, candidates: r.candidates } };
881
1060
  }
882
1061
  const { target, foreign } = r;
1062
+ // 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
1063
+ const re = (body.re ?? "").toString().trim();
1064
+ const turn = receipts.prepare(self, target, re || undefined);
1065
+ // 续问只在那个工单还开着时沿用它: 收了工的工单不再收成员, 续问照样能发。
1066
+ // 没带工单的一句顶掉一份还没落定的工单活, 也算在那张工单上 (receipts.register 同理
1067
+ // 继承): 不然换个不带 job 的 tell_peer 就绕开了预算。
1068
+ const open = receipts.pending(self, target);
1069
+ const isOpen = (id) => (id && jobs.get(id)?.status === "open" ? id : "");
1070
+ const askedJob = (body.job ?? "").trim();
1071
+ // 挂起等子活的那份不算被顶掉: 它会按件号停放、自己计数, 新的一件不继承它的工单。
1072
+ // 返工兜底: 回执槽 24h 过期 / 被顶掉后, 带了 re 却落空成新件, 不带 job 就和根单断了 ——
1073
+ // 只在这种情形下, 发话方是某张开着的需求根单的 owner、目标恰是它的 lead 时才补上;
1074
+ // 没带 re 的普通 task / ask / dispatch 一律不并进根单。
1075
+ const leadJob = jobs.all().find((j) => j.kind === "req" && j.status === "open" && j.owner === self && j.members.some((x) => x.target === target && x.role === "lead"))?.id ?? "";
1076
+ const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || (open?.deferred ? "" : isOpen(open?.job)) || (re && turn.reUnknown ? leadJob : "");
1077
+ if (jobId) {
1078
+ const jc = checkJob(jobId, target, !!open?.need && open.turn === turn.turn);
1079
+ if (!jc.ok) {
1080
+ return { status: jc.status, body: { ok: false, reason: jc.reason } };
1081
+ }
1082
+ }
883
1083
  // 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
884
1084
  // 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
885
1085
  // 只有两端都是登记在册的 wizard 才能当着人说: 裸 target 冒充的发话方、forget
886
1086
  // 掉的分身不是谁, 它们的往来进群只会留下一段人找不到主的对话 —— 降为私聊。
887
- const isPublic = body.public === true && !!wizards.get(self) && !!wizards.get(target);
888
- const channel = isPublic ? channelOf(self) : "";
1087
+ // 工单里的派活一律私聊: 人看不懂 fan-out 的过程, 群里只留发起者自己的收口。
1088
+ // 知会不进群: 公开信封要对方「照对人说话的方式答」, 一句「收到」会被发给一群人。
1089
+ // 顶层模式 (chatPolicy.topOnly): 那个群里 wizard 之间不当着人说话 —— 公开与否不归模型判断,
1090
+ // 续问沿用的公开频道也一样降为私聊; 人只从顶层 wizard 的收口里听到结论。
1091
+ const wantPublic = !jobId && kind !== "fyi" && (turn.channel !== undefined ? !!turn.channel : body.public === true) && !!wizards.get(self) && !!wizards.get(target);
1092
+ const topOnly = wantPublic && chatPolicyOf(cfg, turn.channel || channelOf(self)).topOnly;
1093
+ const isPublic = wantPublic && !topOnly;
1094
+ const channel = isPublic ? turn.channel || channelOf(self) : "";
889
1095
  // Injecting into your own pane would type into the box you're generating
890
1096
  // from — Claude Code queues it and the caller deadlocks waiting for itself.
891
1097
  if (target === self) {
892
- json(res, 400, { ok: false, reason: "refusing to inject into the calling session itself" });
893
- return;
1098
+ return { status: 400, body: { ok: false, reason: "refusing to inject into the calling session itself" } };
894
1099
  }
895
1100
  // 它正在交接: 这句话等它换完会话再进 —— 投进旧会话会被交接一并带走, 投进重开中的
896
1101
  // 空 pane 会 resume 回旧 sid (见 handoff.ts)。发话时刻取在这之后。
897
1102
  await handedOff(target);
898
- // 投递时机。默认照旧立刻投 —— 「回答它的提问」「打断它」本来就是冲着一个
899
- // 正在忙的会话去的。`when:"idle"` 是**派新活**该用的那一种: 两个 wizard 同时
900
- // 找第三个时, 两段文本会挤进同一个输入框、被当成一轮读掉, 这在协同网络里
901
- // 不是边角情况而是常态。等它闲下来再投, 一句话就是一轮。
902
- const when = (body.when ?? "now") === "idle" ? "idle" : "now";
1103
+ // 投递策略。对方闲着三者一样 —— 立刻投; 只在它正忙时分岔:
1104
+ // normal (默认) 等它这一轮结束再投 —— 派新活的常态: 两个 wizard 同时找第三个时,
1105
+ // 两段文本会挤进同一个输入框、被当成一轮读掉, 这在协同网络里不是边角情况而是常态;
1106
+ // urgent 先打断它这一轮 (与 stop_wizard 的 interrupt 同一下 Esc) 再投 —— 紧急改道;
1107
+ // now 不等不打断, 落进输入框由 CLI 并进它这一轮 —— 插话、补一句、答它的问。
1108
+ // 老 MCP 进程只认 `when` (默认 now): idle ≡ normal, now ≡ now。都没给时, `re` 续问默认 now ——
1109
+ // 续问是同一场来回里的应答, 对方可能正挂在 wait_peer 上等你, 等它闲下来就互相干等到超时。
1110
+ const bp = body;
1111
+ const asked = bp.priority === "urgent" ? "urgent"
1112
+ : bp.priority === "normal" ? "normal"
1113
+ : bp.priority === "now" || bp.when === "now" ? "now"
1114
+ : bp.when === "idle" ? "normal"
1115
+ : re ? "now" : "normal";
903
1116
  const waitSec = Math.min(Math.max(Number(body.waitSec ?? 600) || 600, 10), 3600);
1117
+ // 冷门控: 派新活 (task、不是续问、不是插话) 给一个冷且大的 wizard, 先退回一行让派活的
1118
+ // 算账 —— 唤醒它要整段重写缓存, 点名或职责对口只说明相关, 不说明划算。`force:true`
1119
+ // 是「真依赖它那段上下文」的明说。老 MCP 进程的 schema 里没有 force (请求里压根没这个键,
1120
+ // 新的一律显式带上): 同一对 10 分钟内原样再发一次就算 force, 不然它们被拦下就没有出路。
1121
+ const force = body.force;
1122
+ if (kind === "task" && !re && asked !== "now" && force !== true) {
1123
+ const gk = `${self}\0${target}`;
1124
+ const retried = force === undefined && (coldRefused.get(gk) ?? 0) > Date.now();
1125
+ coldRefused.delete(gk);
1126
+ const gi = retried ? undefined : m.sessionInfo(target);
1127
+ const gate = gi ? coldGateOf(`.${displayName(target).replace(/^\./, "")}`, gi.contextTokens, touchOf(gi.jsonlPath), Date.now(), ttlOf(gi.jsonlPath)) : "";
1128
+ if (gate) {
1129
+ if (force === undefined)
1130
+ coldRefused.set(gk, Date.now() + 10 * 60_000);
1131
+ return { status: 409, body: { ok: false, gated: "cold", reason: gate + (force === undefined ? " (你的 MCP 是旧版、没有 force 参数: 10 分钟内原样再发一次即视为 force)" : "") } };
1132
+ }
1133
+ }
904
1134
  // 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
905
1135
  const wasBusy = !(await m.idleNow(target));
1136
+ // urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
1137
+ // 打断就误伤了那件 —— 降成 normal, 等它这一轮做完再投。
1138
+ const others = asked === "urgent" && wasBusy ? await othersTurn(target, self) : undefined;
1139
+ const policy = others ? "normal" : asked;
906
1140
  let waitedMs = 0;
907
- if (when === "idle" && wasBusy) {
1141
+ let interrupted;
1142
+ if (policy === "normal" && wasBusy) {
908
1143
  const t0 = Date.now();
909
1144
  // 它那一轮一结束就投: 注册表 status 翻 idle 是事件, 不抽样也不等稳定期。
910
1145
  const wr = await m.untilIdle(target, waitSec * 1000);
911
1146
  waitedMs = Date.now() - t0;
912
1147
  if (!wr.idle) {
913
- json(res, 409, { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪, 或者用 when:"now" 插队` });
914
- return;
1148
+ return { status: 409, body: { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪; 真急就 priority:"urgent" 打断它` } };
915
1149
  }
916
1150
  }
1151
+ if (policy === "urgent" && wasBusy) {
1152
+ // 只按 Esc, 不走 /stop 的 teardown: 那会清掉它的注入队列 (别人排着的话一起丢)
1153
+ // 并暂停 keepalive —— 这里打断它是为了马上接着干, 不是让它安静。
1154
+ const t0 = Date.now();
1155
+ const esc = await m.interruptPane(target);
1156
+ // 等 Esc 生效、这一轮真收住再投; 收不住也照投 —— 落进输入框排在后面, 退化成 now。
1157
+ interrupted = esc.ok && (await m.untilIdle(target, 15_000)).idle;
1158
+ waitedMs = Date.now() - t0;
1159
+ }
917
1160
  // 时刻取在注入**之前**: 晚于那一句落盘的话, 回执定位的下界就偏了。
918
1161
  const at = Date.now();
1162
+ // 唤醒代价同样要在注入之前量: 投进去 transcript 一动, 就看不出它冷了多久。
1163
+ const info = m.sessionInfo(target);
1164
+ const wake = info?.jsonlPath
1165
+ ? wakeNoteOf(info.contextTokens, touchOf(info.jsonlPath), at, ttlOf(info.jsonlPath))
1166
+ : "";
1167
+ const deadlineSec = body.deadline;
1168
+ // 回执: 对方干完那一轮, 守护进程把它的结论自动送回来 (默认开)。`receipt:false`
1169
+ // 是"放出去就不管了"的那种派活; fyi 一定不要。对方的信封据此改口, 不再让它收口。
1170
+ const wantReceipt = kind !== "fyi" && body.receipt !== false;
1171
+ const deadlineAt = deadlineOf(at, deadlineSec === undefined ? undefined : Number(deadlineSec));
1172
+ const asker = askerOf(self);
919
1173
  const inj = await m.injectText(target, text, undefined, {
920
- from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}) },
1174
+ from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
921
1175
  channel,
922
- envelope: envelopeFor(self, channel),
1176
+ envelope: envelopeFor(self, channel, {
1177
+ turn: turn.turn,
1178
+ legs: turn.legs,
1179
+ ...(turn.legs > 1 ? { re: true } : {}),
1180
+ // 期限只在发话方明说时写给对方看: 默认的一小时不值得每轮多一句。
1181
+ ...(deadlineSec !== undefined ? { deadline: deadlineAt } : {}),
1182
+ ...(kind !== "task" ? { act: kind } : {}),
1183
+ ...(wantReceipt ? {} : { quiet: true }),
1184
+ }) + extra,
923
1185
  });
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);
1186
+ // 注入失败就不守 —— 没有问话, 也不会有回答。不要回执 (receipt:false / fyi) 就不登记:
1187
+ // 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
1188
+ // 父 k: 默认这件活算发话方此刻在答的那件的子活 (链式续回); `chain:false` = 旁支,
1189
+ // 回执照常回来, 但不挂住它给上游的交代 —— 只有发话方知道这件活是不是为上游派的。
1190
+ const k = body.chain === false ? undefined : parentKOf(self);
1191
+ if (inj.ok && wantReceipt)
1192
+ receipts.register({ from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
1193
+ if (inj.ok && wantReceipt) {
1194
+ if (turn.legs > 1)
1195
+ ledger.touch(self, turn.turn);
1196
+ else
1197
+ ledger.open({ turn: turn.turn, owner: self, to: target, text, at, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
1198
+ }
929
1199
  // 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
930
- if (inj.ok && jobId)
931
- jobs.attach(jobId, { target, task: text, spawned: false });
1200
+ // 续问 (re) 不是一段新活: 工单页与留档里该列的仍是当初派的那段。
1201
+ if (inj.ok && jobId) {
1202
+ const role = roleParam(body.role);
1203
+ // G2: 需求根单的 owner 答了 lead 的 NEED —— 方案闸过了 (只记第一次)。
1204
+ const rj = jobs.get(jobId);
1205
+ if (rj?.kind === "req" && self === rj.owner && !rj.gate?.g2 && !!open?.need && open.turn === turn.turn && rj.members.some((x) => x.target === target && x.role === "lead"))
1206
+ jobs.mark(jobId, { gate: { g2: Date.now() } });
1207
+ jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
1208
+ }
1209
+ const spent = inj.ok && jobId ? jobs.spend(jobId) : undefined;
932
1210
  if (inj.ok && isPublic)
933
1211
  relayPeer(self, target, text, channel);
934
1212
  // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
935
1213
  // 它这一轮后面, 而不是马上被读到。
936
- json(res, inj.ok ? 200 : 502, {
937
- ...inj,
938
- name: peerAddress(cfg, self, target),
939
- public: isPublic,
940
- wasBusy,
941
- ...(waitedMs ? { waitedMs } : {}),
942
- ...(jobId ? { job: jobId } : {}),
943
- // 回执怎么回来 —— 写在回包里, 调用方 (模型) 不必从工具描述里回忆。
944
- ...(inj.ok && wantReceipt
945
- ? { receipt: "它干完那一轮, 结论会作为新的一轮自动进到你这里 —— 不要挂在 wait_peer 上等, 接着干你自己的事" }
946
- : {}),
947
- });
1214
+ return { status: inj.ok ? 200 : 502, body: {
1215
+ ...inj,
1216
+ name: peerAddress(cfg, self, target),
1217
+ public: isPublic,
1218
+ ...(topOnly ? { topOnly: "这个群开着顶层模式: wizard 之间一律私聊, 已按私聊投出; 结论回到你这儿, 由你 (或你的上游) 对人收口" } : {}),
1219
+ priority: policy,
1220
+ ...(kind !== "task" ? { kind } : {}),
1221
+ ...(kind === "fyi" && askedJob ? { jobIgnored: true } : {}),
1222
+ ...(others ? { urgentDowngraded: `它此刻在做 ${others.from} 派的活, 没打断它 —— 按 normal 等它这一轮做完再投` } : {}),
1223
+ wasBusy,
1224
+ ...(interrupted !== undefined ? { interrupted } : {}),
1225
+ ...(waitedMs ? { waitedMs } : {}),
1226
+ ...(jobId ? { job: jobId } : {}),
1227
+ ...(inj.ok ? { turn: turn.turn } : {}),
1228
+ ...(spent?.maxTurns ? { budget: `${spent.turns}/${spent.maxTurns}${spent.turns >= spent.maxTurns ? " —— 用完了, 这是最后一次派活, 下一步收口" : ""}` } : {}),
1229
+ ...(inj.ok && wake ? { wakeCost: wake } : {}),
1230
+ ...(turn.reUnknown ? { reUnknown: true, reNote: `re "${re}" 不是你正在等 ${displayName(target)} 答的那件, 按新活发出` } : {}),
1231
+ // 回执怎么回来 —— 写在回包里, 调用方 (模型) 不必从工具描述里回忆。
1232
+ ...(inj.ok && wantReceipt
1233
+ ? { receipt: "它干完那一轮, 结论会作为新的一轮自动进到你这里 —— 不要挂在 wait_peer 上等, 接着干你自己的事" }
1234
+ : {}),
1235
+ ...(inj.ok && wantReceipt && k?.kind === "peer"
1236
+ ? { chained: `这件活算在你答 ${displayName(k.from)} 的那件 (${k.turn}) 名下: 它回来之前, 你这一轮的终句不会先交给 ${displayName(k.from)}; 与那件活无关就带 chain:false 发` }
1237
+ : {}),
1238
+ } };
1239
+ };
1240
+ const tellPeer = async (req, res) => {
1241
+ const { self, body } = await readPeerBody(req);
1242
+ if (!self) {
1243
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1244
+ return;
1245
+ }
1246
+ const r = await tellPeerAs(self, body);
1247
+ json(res, r.status, r.body);
948
1248
  };
949
1249
  http.register("POST /peers/tell", tellPeer);
950
1250
  http.register("POST /peers/send", tellPeer); // 老 MCP 进程还在调这个名字
@@ -994,13 +1294,16 @@ const main = async () => {
994
1294
  // 取走就占位: 同一段话不会再作为回执 paste 进来一遍 (见 receipts.claim)。
995
1295
  const full = wr.idle ? m.lastReply(h.target, receipts.sentAt(self, h.target)) : "";
996
1296
  const already = full ? receipts.claim(self, h.target) : false;
997
- const result = extractResult(full);
1297
+ const closing = parseClosing(full);
1298
+ const result = closing.kind === "result" ? closing.text : "";
998
1299
  return {
999
1300
  name: h.address,
1000
1301
  idle: wr.idle,
1001
1302
  // 回执早一步到了: 这段话已经作为新的一轮进过你的会话, 别再处理第二遍。
1002
1303
  ...(already ? { delivered: true } : {}),
1003
1304
  ...(wr.reason ? { reason: wr.reason } : {}),
1305
+ ...(closing.kind === "need" ? { need: closing.text } : {}),
1306
+ ...(closing.artifacts.length ? { artifacts: closing.artifacts } : {}),
1004
1307
  ...(result
1005
1308
  ? { result, ...(full.length > result.length + 200 ? { omitted: full.length - result.length } : {}) }
1006
1309
  : full
@@ -1027,6 +1330,17 @@ const main = async () => {
1027
1330
  // 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
1028
1331
  // 全新的进程(同名、同 cwd),把简报作为新会话的首条消息贴进去。拒绝对调用方
1029
1332
  // 自身操作(会 deadlock,同 /peers/send)。
1333
+ /** 交接自己: 简报由调用方给, 排在它这一轮说完之后 (见 handoff.ts)。/handoff 点名点到
1334
+ * 自己与老路由 /wizard/handoff-self 共用。 */
1335
+ const handoffSelf = (self, brief) => {
1336
+ if (brief.length < 40)
1337
+ return { status: 400, body: { ok: false, reason: "brief 太短 —— 要写到零上下文也能接手: 目标 / 已完成 / 当前状态 / 下一步 / 关键文件与坑" } };
1338
+ const info = m.sessionInfo(self);
1339
+ const r = handoffs.self(self, brief);
1340
+ return r.ok
1341
+ ? { status: 200, body: { ok: true, target: self, contextTokens: info?.contextTokens ?? 0, scheduled: true } }
1342
+ : { status: 409, body: { ok: false, reason: r.reason } };
1343
+ };
1030
1344
  http.register("POST /handoff", async (req, res) => {
1031
1345
  const { self, body } = await readPeerBody(req);
1032
1346
  if (!self) {
@@ -1043,6 +1357,12 @@ const main = async () => {
1043
1357
  }
1044
1358
  }
1045
1359
  else {
1360
+ // 不点名不能落成「本聊天的默认 wizard」(resolvePeer 对 "" 的语义): 交接是把人家的
1361
+ // 上下文清掉重开, 一个漏传的参数不该把群管家交接掉。不点名的交接只能是交接自己。
1362
+ if (!addrOf(body).trim()) {
1363
+ json(res, 400, { ok: false, reason: "要点名 (name) —— 交接你自己走 /wizard/handoff-self (MCP: handoff 不传 name, 带 brief)" });
1364
+ return;
1365
+ }
1046
1366
  const r = resolvePeer(self, addrOf(body));
1047
1367
  if (!r.ok) {
1048
1368
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
@@ -1050,10 +1370,16 @@ const main = async () => {
1050
1370
  }
1051
1371
  target = r.target;
1052
1372
  }
1053
- // 注入到自己的 pane = 往正在生成的输入框里打字,Claude Code 会把它排队,
1054
- // 调用方等自己等成死锁(同 /peers/send)。
1373
+ // 点名点到自己: 不能走「让它写简报」那条 (往正在生成的输入框里打字, 等自己等成
1374
+ // 死锁), 改走交接自己 —— 简报得由调用方自己给。
1055
1375
  if (target === self) {
1056
- json(res, 400, { ok: false, reason: "refusing to hand off the calling session itself (would deadlock)" });
1376
+ const brief = (body.brief ?? "").toString().trim();
1377
+ if (!brief) {
1378
+ json(res, 400, { ok: false, reason: "这是交接你自己: 把简报写在 brief 里 (不传 name 即可; 旧会话的 handoff 没有 brief 参数, 用 wizard_handoff_self)" });
1379
+ return;
1380
+ }
1381
+ const r = handoffSelf(self, brief);
1382
+ json(res, r.status, r.body);
1057
1383
  return;
1058
1384
  }
1059
1385
  const focus = (body.focus ?? "").toString().trim();
@@ -1068,13 +1394,132 @@ const main = async () => {
1068
1394
  // 上下文窗口也挤不掉。
1069
1395
  // 这里只做三件事: 读身份、改身份、按身份生/收分身。tmux 一概不碰, 动作全在
1070
1396
  // mirror-bridge。
1071
- const wizards = bindWizardStore(loadWizardStore(cfg.wrc.mirror.wizardsFile));
1397
+ const bornOf = (t) => firstStamp(m.sessionInfo(t)?.jsonlPath ?? "");
1398
+ const wizards = bindWizardStore(loadWizardStore(cfg.wrc.mirror.wizardsFile, bornOf));
1072
1399
  // 名册增量的投递面 (见 notices.ts)。charter 只是出生那一刻的快照; 此后的成员
1073
1400
  // 变动从这里搭下一次注入的车进到每个在场 wizard 的上下文里, 不占一轮。
1074
1401
  // 一条变动只投给**同一个聊天**里在场的其他 wizard: 群成员变动是那个群的事,
1075
1402
  // 当事人自己做的自己知道, 所以排除在外。
1076
- const notices = bindNoticeBox(createNoticeBox());
1077
- const postRoster = (base, except, line) => notices.post(chatAudience(m.chatTargets(base), base, except), line);
1403
+ // 上下文逼近自动压缩时提醒一句「先记」: 压缩 / 交接都会丢细节。每段会话 (sid) 只提一次。
1404
+ const nudged = new Set();
1405
+ // 线按模型的窗口算 (memoryNudgeAt); 模型取 transcript 里实际跑的那个 id, pane 记的常是空串或 `opus` 这种别名。
1406
+ const memoryNudge = (t) => {
1407
+ const i = m.sessionInfo(t);
1408
+ // 最低的那条线先挡: 不到它就不必再读一次 transcript 取模型。
1409
+ if (!i?.sessionId || nudged.has(i.sessionId) || i.contextTokens < MEMORY_NUDGE_FLOOR)
1410
+ return [];
1411
+ if (i.contextTokens < memoryNudgeAt(lastModel(i.jsonlPath) || i.model))
1412
+ return [];
1413
+ nudged.add(i.sessionId);
1414
+ return [`你的上下文已到 ${Math.round(i.contextTokens / 1000)}k, 快到自动压缩了: 这段里学到的、值得跨会话活下来的东西, 先 \`wizard_remember\` 记下 (自己的选 self, 属于群 / 仓库的选 chat / workspace), 再考虑 \`handoff\``];
1415
+ };
1416
+ // 越过交接判断线 (handoffAt) 也只提一次: 交接不是压缩, 不该等快满。同样每段会话一次 ——
1417
+ // 交接后换了 sid, 新会话再越线会再提。
1418
+ const handoffNudged = new Set();
1419
+ const handoffNudge = (t) => {
1420
+ const i = m.sessionInfo(t);
1421
+ if (!i?.sessionId || handoffNudged.has(i.sessionId) || i.contextTokens < handoffAt(lastModel(i.jsonlPath) || i.model))
1422
+ return [];
1423
+ handoffNudged.add(i.sessionId);
1424
+ return [`你的上下文已到 ${Math.round(i.contextTokens / 1000)}k: 该判断要不要 \`handoff\` 了 —— 手上这摊活告一段落、或往后的活不再依赖前面的大段材料, 就交接 (值得跨会话留下的先 \`wizard_remember\`); 还在一件离不开它们的活中间, 做完这一段再交`];
1425
+ };
1426
+ // 挂起事项的提醒 (见 pending-items.digest): 只给管家 —— 各群的默认会话, 同时挂着多件活、
1427
+ // 又被人随时打断的就是它。上次全表时的样子纯内存: reload 后头一次见到只立基线。
1428
+ const pendingMarks = new Map();
1429
+ /** 上次注入之后被 /clear 或压缩截断过的 (onContextCut 记, 下一次注入取走)。 */
1430
+ const pendingCuts = new Map();
1431
+ const liveOfTurns = () => {
1432
+ let byTurn;
1433
+ return (turn) => (byTurn ??= new Map(receipts.states().map((x) => [x.turn, x]))).get(turn);
1434
+ };
1435
+ /** 管家等人验收的根单行 (需求已交付、单还开着)。 */
1436
+ const stalledRows = (owner) => stalledRoots(jobs.all(), owner, jobLive()).map(({ job, status }) => renderStalled(job.title, job.id, status));
1437
+ const awaitingRows = (owner, now) => awaitingAccept(jobs.all(), owner, jobLive()).map(({ job, at }) => renderAwaiting(job.title, job.id, at, now));
1438
+ const shelvedRows = (owner, now) => shelvedRoots(jobs.all(), owner).map((j) => renderShelved(j.title, j.id, j.hold ?? j.openedAt, now));
1439
+ const pendingNames = { nameOf: displayName, chatOf: (b) => chatNameOf(cfg, b) || b };
1440
+ /** 人在某个群最近一次开口 (内存; reload 丢了就当没说过): 重试冒泡避开人在场的时候。 */
1441
+ const humanAt = new Map();
1442
+ /** 「人这句可能在验收某张」同一张至多隔 HEARD_EVERY_MS 提一次。 */
1443
+ const acceptHeard = new Map();
1444
+ const pendingDigest = (t, via) => (seen) => {
1445
+ const cut = pendingCuts.get(t);
1446
+ pendingCuts.delete(t);
1447
+ const now = Date.now();
1448
+ if (via.human)
1449
+ humanAt.set(via.human.channel, now);
1450
+ if (tagOfKey(t))
1451
+ return { text: "", keys: [] };
1452
+ // 人在等验收那件需求的群里开了口: 这句可能就是验收 (是不是由管家判断)。与 digest 的 heard 同一个节流。
1453
+ const acceptLines = (via.human ? awaitingAccept(jobs.all(), t, jobLive()) : [])
1454
+ .filter(({ job }) => job.origin?.chat === via.human?.channel && now - (acceptHeard.get(job.id) ?? 0) >= HEARD_EVERY_MS)
1455
+ .map(({ job, at }) => {
1456
+ acceptHeard.set(job.id, now);
1457
+ return `heard? accept ${job.id} "${job.title}" delivered ${agoMs(now - at)} ago, awaiting the human's verdict: if this line is the verdict → close_job (ok) / tell_peer re to lead (not ok); else ignore`;
1458
+ });
1459
+ const rule = !seen("rule:pending");
1460
+ const items = ledger.openOf(t);
1461
+ // 没挂着的事: 不读会话, 也不留基线 —— 有了第一件再从那一刻立。
1462
+ if (!items.length) {
1463
+ pendingMarks.delete(t);
1464
+ const text = acceptLines.length ? renderDigest({ full: "", lines: acceptLines, touched: [], mark: { sid: "", ctx: 0, ctxAtFull: 0, drains: 0 } }, "", rule) : "";
1465
+ return { text, keys: text && rule ? ["rule:pending"] : [] };
1466
+ }
1467
+ const i = m.sessionInfo(t);
1468
+ const o = digest({ items, mark: pendingMarks.get(t), ...(cut ? { cut } : {}), session: i ? { sid: i.sessionId, ctx: i.contextTokens } : undefined, ...(via.human ? { human: via.human } : {}), now, nameOf: displayName });
1469
+ pendingMarks.set(t, o.mark);
1470
+ o.touched.forEach(ledger.put);
1471
+ const text = renderDigest({ ...o, lines: [...o.lines, ...acceptLines] }, o.full ? renderTable(items, liveOfTurns(), pendingNames, now, awaitingRows(t, now), stalledRows(t), shelvedRows(t, now)) : "", rule);
1472
+ return { text, keys: text && rule ? ["rule:pending"] : [] };
1473
+ };
1474
+ const notices = bindNoticeBox(createNoticeBox({
1475
+ probe: (t) => [...handoffNudge(t), ...memoryNudge(t)],
1476
+ digest: pendingDigest,
1477
+ seen: createSeenRing(loadJsonMap(cfg.wrc.mirror.noticeSeenFile, gcSeen), (t) => m.sessionInfo(t)?.sessionId ?? ""),
1478
+ cwdOf: (t) => ((c) => c.runningCwd || c.defaultCwd)(m.getCwd(t)),
1479
+ }));
1480
+ // 重试冒泡 (设计文档 §3.3.1): 做完了没人验收的需求, 守护进程以管家的名义往原群提醒一句, 不占管家一轮、
1481
+ // 不带单号 (人只认标题); 同时往管家的信箱挂一行带单号的, 人下一句进来时它知道可能在答哪张。
1482
+ // 节奏由根单上的 nudge {n, lastAt} 现算 (见 jobs.nextNudgeAt), 只在白天, 人刚说过话的群顺延 —— reload 不丢、不重发。
1483
+ const NUDGE_TICK_MS = 5 * 60_000;
1484
+ const HUMAN_QUIET_MS = 30 * 60_000;
1485
+ const agoZh = (ms) => ms < 3_600_000 ? `${Math.max(1, Math.round(ms / 60_000))} 分钟前` : ms < 86_400_000 ? `${Math.round(ms / 3_600_000)} 小时前` : `${Math.round(ms / 86_400_000)} 天前`;
1486
+ const nudgeTick = (now = Date.now()) => {
1487
+ if (dayClamp(now) !== now)
1488
+ return 0;
1489
+ const owners = [...new Set(jobs.all().filter((j) => j.kind === "req" && j.status === "open").map((j) => j.owner))];
1490
+ // 只冒群默认管家 (无 tag) 自己的根单: lead 自己 dispatch 出来的子需求不该推给人; 与 pendingDigest 只服务默认会话一致。
1491
+ // owner 须是已登记的 wizard: 伪造的 owner/群 (调试、回归) 不得往 turn store 落 post, 否则 rolepage 凭空长出一个群和一个「人」。
1492
+ const due = owners
1493
+ .filter((o) => !tagOfKey(o) && !!wizards.get(o))
1494
+ .flatMap((o) => awaitingAccept(jobs.all(), o, jobLive()))
1495
+ .filter(({ job, at }) => !!job.origin?.chat && baseOfKey(job.owner) === job.origin.chat && nudgeDue(at, job.nudge, now) && now - (humanAt.get(job.origin.chat) ?? 0) >= HUMAN_QUIET_MS);
1496
+ // 按 (群, owner) 分组: 署名与信箱都跟着 owner。
1497
+ const byChat = due.reduce((acc, x) => { const k = `${x.job.origin.chat}\0${x.job.owner}`; return acc.set(k, [...(acc.get(k) ?? []), x]); }, new Map());
1498
+ byChat.forEach((xs) => {
1499
+ const owner = xs[0].job.owner;
1500
+ const chat = xs[0].job.origin.chat;
1501
+ const one = xs.length === 1;
1502
+ const mark = (i) => (one ? "" : "①②③④⑤⑥⑦⑧⑨⑩"[i] ?? `${i + 1}.`);
1503
+ const text = `有 ${xs.length} 件做完了等你看: ${xs.map(({ job, at }, i) => `${mark(i)}「${job.title}」(${agoZh(now - at)}交付)`).join(" ")} —— 回一句「好」就收, 或者说哪里不对。`;
1504
+ notifyChat(chat, `${relayLabel(owner)}\n\n${text}`);
1505
+ recordPost({ target: owner, channel: chat, body: text });
1506
+ xs.forEach(({ job }) => jobs.mark(job.id, { nudge: { n: (job.nudge?.n ?? 0) + 1, lastAt: now } }));
1507
+ notices.post([owner], `nudged the human (daemon, in your name) about finished requests; their next line may be the verdict on one: ${xs.map(({ job }) => `${job.id} "${job.title}"`).join(" · ")}`);
1508
+ });
1509
+ return due.length;
1510
+ };
1511
+ setInterval(() => { try {
1512
+ nudgeTick();
1513
+ }
1514
+ catch (e) {
1515
+ log.warn({ err: e.message }, "accept nudge failed");
1516
+ } }, NUDGE_TICK_MS).unref();
1517
+ /** `who` = 这次变动的当事人 (见 hears): 不给 = 聊天自己的事 (改名), 全群都听。 */
1518
+ const postRoster = (base, except, n, who) => notices.post(chatAudience(m.chatTargets(base), base, except).filter(who ? hears(who) : () => true), n);
1519
+ /** 注册表里的当事人。要在 forget 抹掉记录之前取; 没有记录 = 谁也不告诉。 */
1520
+ const subjectOf = (w) => (w
1521
+ ? { parent: w.parent, description: w.description, owners: jobs.all().filter((j) => j.status === "open" && j.members.some((x) => x.target === w.target)).map((j) => j.owner) }
1522
+ : {});
1078
1523
  /** 一个聊天的工作区: 它名下各会话跑在哪个目录, 取最多的那个。聊天与工作区是
1079
1524
  * 一对多, 但绝大多数聊天只围着一个项目转。 */
1080
1525
  const chatCwd = (targets) => {
@@ -1108,26 +1553,50 @@ const main = async () => {
1108
1553
  log.info({ base, name }, "chat: auto-named from workspace");
1109
1554
  // 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
1110
1555
  // 得知道自己的地址变了 —— 它的 charter 里写的还是「(未命名)」。
1111
- postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— notify / new_claude_session 可以用这个名字指到这里。`);
1556
+ postRoster(base, [], { op: "chat", name });
1112
1557
  }
1113
1558
  return named;
1114
1559
  };
1115
1560
  // 名字全局唯一 (见 wizard.ts): 先给没名字的聊天补名, 再按「默认会话先挑」把
1116
1561
  // 每个已知 wizard 的名字落定 —— 老记录 (名字还跟着聊天走的那一代) 在这里一次迁完。
1117
1562
  ensureChatNames(cfg.defaultChat || "");
1563
+ const reborn = backfillBorn(wizards, bornOf);
1564
+ if (reborn.length)
1565
+ log.child({ mod: "wizard" }).info({ n: reborn.length }, "bornAt backfilled from transcript heads");
1118
1566
  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);
1567
+ // 上一个进程生到一半就没了的身份 (spawn 途中 reload): 没会话的删掉, 腾出名字。
1568
+ // 它们的 tmux window 也一并收掉 (没人叫得到它, 只会占着 pane): 等绑定恢复完再看,
1569
+ // 免得把还没恢复的活 pane 当成没绑定; 期间名字已被新 spawn 认领的不碰。
1570
+ const unborn = sweepUnborn(wizards, (t) => m.chatTargets(baseOfKey(t)).includes(t));
1571
+ unborn.forEach((w) => log.child({ mod: "wizard" }).warn({ target: w.target, name: w.name }, "unborn identity swept"));
1572
+ if (unborn.length) {
1573
+ void m.restored
1574
+ .then(() => m.reapUnborn(unborn.map((w) => w.name).filter((n) => n && !wizards.byName(n))))
1575
+ .then((killed) => { if (killed.length)
1576
+ log.child({ mod: "wizard" }).info({ killed }, "unborn windows killed"); })
1577
+ .catch((e) => log.warn({ err: e.message }, "unborn window reap failed"));
1578
+ }
1579
+ /** 成员角色参数: 只认 exec / reviewer / expert (lead 只由 dispatch 在派成之后记, 免得谁都能自封 lead); 不认识的 = 不给。 */
1580
+ const roleParam = (v) => ["exec", "reviewer", "expert"].find((x) => x === v);
1581
+ /** 成员此刻的态: 定论 > 在飞的实时态。每次取一份回执快照。 */
1582
+ const jobLive = () => {
1583
+ const inflight = receipts.states();
1584
+ return (j, mm) => mm.outcome ?? inflight.find((x) => x.to === mm.target && x.job === j.id)?.state;
1585
+ };
1122
1586
  /** 派活前先验工单: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。 */
1123
- const checkJob = (id) => {
1587
+ /** `free` = 这次不花预算 (不带 task 的 spawn; 答一份停在 NEED 上的反问 —— 拒了它就只能干等到超时)。 */
1588
+ const checkJob = (id, target, free = false) => {
1124
1589
  const j = jobs.get(id);
1125
1590
  if (!j)
1126
1591
  return { ok: false, status: 404, reason: `没有工单 '${id}' —— open_job 先开一个, 或者 list_jobs 看还开着哪些` };
1127
1592
  if (j.status !== "open")
1128
1593
  return { ok: false, status: 409, reason: `工单 '${id}' 已经收工了` };
1129
- if (j.members.length >= JOB_MEMBER_MAX)
1130
- return { ok: false, status: 409, reason: `工单 '${id}' 的成员已经满了 (${JOB_MEMBER_MAX} 个) —— 分身是有成本的, 拆成两个工单, 或者先收工回收掉一批` };
1594
+ // 已在册的成员 (续问 / 再派一句) 不占新名额。
1595
+ if (j.members.length >= JOB_MEMBER_MAX && !j.members.some((x) => x.target === target))
1596
+ return { ok: false, status: 409, reason: `工单 '${id}' 的成员已经满了 (${JOB_MEMBER_MAX} 个直接成员) —— 管辖幅度有限: 把一块子活交给现有成员 (它自己开子单、长一层子 lead), 或者先收工回收掉一批` };
1597
+ // 预算是硬的: 与单件活的 legs 不同, 这里拒了也无路可绕 —— 换个人派照样记在这张工单上。
1598
+ if (!free && j.maxTurns && (j.turns ?? 0) >= j.maxTurns)
1599
+ return { ok: false, status: 409, reason: `工单 '${id}' 的派活预算用完了 (${j.turns}/${j.maxTurns} 次) —— 拿手上已有的结论 close_job 收口, 缺的如实写; 真要接着干就 open_job 开一张新的` };
1131
1600
  return { ok: true };
1132
1601
  };
1133
1602
  // ── 关系视图的数据面 ──────────────────────────────────────────────
@@ -1148,8 +1617,18 @@ const main = async () => {
1148
1617
  const known = wizards.all();
1149
1618
  const byTarget = new Map(known.map((w) => [w.target, w]));
1150
1619
  const seen = new Set(peers.map((p) => p.target));
1620
+ // 「卡在审批」以 daemon 手里的 pending 为准 —— 审批长轮询期间 pane 照样在转圈,
1621
+ // 拿 busy 去推会把它当成「执行中」。归属走 approval 认卡的同一口径 (getMirrorTarget):
1622
+ // hook 的 sid 在 /clear 之后先变, 镜像绑定的 sid 要等新 jsonl 出现才跟上。
1623
+ // ask_user 的 pending 不带 sessionId —— 它是 MCP 工具在等, 不归哪张卡, 不亮。
1624
+ const waitingOf = listPending().reduce((m, { meta }) => {
1625
+ const t = meta.sessionId ? getMirrorTarget(meta.sessionId) : undefined;
1626
+ const tool = meta.toolName || meta.kind;
1627
+ return t ? m.set(t, [...new Set([...(m.get(t) ?? []), tool])]) : m;
1628
+ }, new Map());
1151
1629
  const live = peers.map((p) => {
1152
1630
  const w = byTarget.get(p.target);
1631
+ const waiting = waitingOf.get(p.target);
1153
1632
  return {
1154
1633
  target: p.target,
1155
1634
  name: settleName(wizards, chatNameOf(cfg, p.target), p.target),
@@ -1157,9 +1636,11 @@ const main = async () => {
1157
1636
  chat: chatNameOf(cfg, p.target) || p.chat,
1158
1637
  cwd: p.cwd,
1159
1638
  model: p.model,
1639
+ ...((e) => (e ? { effort: e } : {}))(mirrorStore.get(p.target)?.effort),
1160
1640
  cli: p.cli,
1161
1641
  busy: p.busy,
1162
1642
  alive: p.paneAlive,
1643
+ ...(waiting ? { waiting } : {}),
1163
1644
  parent: w?.parent,
1164
1645
  clonedFrom: w?.clonedFrom,
1165
1646
  forkOf: w?.forkOf,
@@ -1189,11 +1670,15 @@ const main = async () => {
1189
1670
  const nowDate = new Date();
1190
1671
  return {
1191
1672
  wizards: [...live, ...cold],
1192
- jobs: jobs.all().map((j) => ({
1673
+ jobs: ((all, live) => all.map((j) => ({
1193
1674
  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 })),
1196
- })),
1675
+ openedAt: j.openedAt, closedAt: j.closedAt, summary: j.summary, ...(j.expect ? { expect: j.expect } : {}), ...(j.plan ? { plan: j.plan } : {}),
1676
+ ...(j.parent ? { parent: j.parent } : {}), ...(j.kind ? { kind: j.kind } : {}), ...(j.criteria ? { criteria: j.criteria } : {}), ...(j.origin ? { origin: j.origin } : {}), ...(j.end ? { end: j.end } : {}),
1677
+ stage: jobStage(j, all, live),
1678
+ 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 } : {}), ...(mm.role ? { role: mm.role } : {}), ...(mm.forkOf ? { forkOf: mm.forkOf } : {}) })),
1679
+ })))(jobs.all(), jobLive()),
1680
+ // rolepage 的待办托盘: 与名册 / peek 的「在等 / 欠着」同一份 (receipts.states)。
1681
+ inflight: receipts.states().map(({ ageMs, ...x }) => ({ ...x, at: Date.now() - ageMs })),
1197
1682
  schedules: tasks.list().filter((x) => x.enabled).map((x) => {
1198
1683
  const st = tasks.stateOf(x.id);
1199
1684
  const loadError = tasks.errors()[x.id];
@@ -1244,9 +1729,27 @@ const main = async () => {
1244
1729
  const path = memoryPath(cfg.daemon.stateDir, scope, key);
1245
1730
  return clipForCharter(readMemory(path), path);
1246
1731
  };
1247
- /** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
1248
- * 让它一睁眼就知道自己不是一个人在跑。 */
1249
- const charterFor = (target, o) => renderCharter({
1732
+ /** transcript 最后写入的时刻; 没有会话 / 读不到 = 0。 */
1733
+ const lastTouched = (t) => {
1734
+ const p = m.sessionInfo(t)?.jsonlPath;
1735
+ try {
1736
+ return p ? statSync(p).mtimeMs : 0;
1737
+ }
1738
+ catch {
1739
+ return 0;
1740
+ }
1741
+ };
1742
+ // 每个会话的宪章里写进了哪些 self 记忆。`/clear` 抹掉对话却不重写系统提示, 此后
1743
+ // 新记的那些就只活在被抹掉的对话里 —— onContextCut 据这份快照补发差集。纯内存: reload
1744
+ // 之后没有快照, 只指路不补发。
1745
+ const chartered = new Map();
1746
+ const charterOptsOf = (target, cwd) => {
1747
+ const rec = wizards.get(target);
1748
+ return { parent: rec?.parent, forkOf: rec?.forkOf, inherited: !!rec?.clonedFrom, cwd };
1749
+ };
1750
+ /** 宪章正文, 只读 —— 只由 cfg 与此刻的注册表 / 附着表算出, config_set 据此比对前后。`forDiff` 去掉
1751
+ * 宪章里自认是出生快照的配置 (档位那一行, 真相在 config_get): 改档位不该叫全员 handoff。 */
1752
+ const charterText = (target, o, forDiff = false) => renderCharter({
1250
1753
  // o.cwd = 正在启动的那个 pane 的目录; 没给才退回"现在记着的那个"。
1251
1754
  self: { ...briefOf(target, target), address: selfAddress(target), ...(o.cwd ? { cwd: o.cwd } : {}) },
1252
1755
  chat: chatNameOf(cfg, target),
@@ -1255,14 +1758,53 @@ const main = async () => {
1255
1758
  inherited: !!o.inherited,
1256
1759
  forkOf: o.forkOf ? briefOf(target, o.forkOf) : undefined,
1257
1760
  cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
1258
- siblings: m.chatTargets(baseOfKey(target)).filter((t) => t !== target).map((t) => briefOf(target, t)),
1761
+ // 最近动过的排前面: 名册只点名前几个, 截掉的该是早就停了的那些。
1762
+ // 没有 transcript 的冷记录按 bornAt 算, 刚生下还没开口的照样在。
1763
+ siblings: m.chatTargets(baseOfKey(target))
1764
+ .filter((t) => t !== target)
1765
+ .map((t) => [t, Math.max(lastTouched(t), wizards.get(t)?.bornAt ?? 0)])
1766
+ .filter(([, at]) => Date.now() - at <= ROSTER_FRESH_MS)
1767
+ .sort((x, y) => y[1] - x[1])
1768
+ .map(([t]) => briefOf(target, t)),
1259
1769
  memory: wizards.get(target)?.memory ?? [],
1770
+ tiers: forDiff ? [] : TIERS.map((n) => { const s = cfg.models.tiers[n]; return `${n}=${s.model || "默认"}${s.effort ? `·${s.effort}` : ""}`; }),
1260
1771
  chatMemory: sharedMemory("chat", baseOfKey(target)),
1261
1772
  workspaceMemory: (() => {
1262
1773
  const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
1263
1774
  return cwd ? sharedMemory("workspace", cwd) : "";
1264
1775
  })(),
1265
1776
  steward: !tagOfKey(target),
1777
+ stewardBudget: chatPolicyOf(cfg, target).stewardBudget,
1778
+ lead: !!wizards.get(target)?.lead,
1779
+ topOnly: chatPolicyOf(cfg, target).topOnly,
1780
+ });
1781
+ /** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
1782
+ * 让它一睁眼就知道自己不是一个人在跑。 */
1783
+ const charterFor = (target, o) => {
1784
+ chartered.set(target, wizards.get(target)?.memory ?? []);
1785
+ const text = charterText(target, o);
1786
+ recordCharter(target, text, o.parent ? { parent: o.parent, kind: o.inherited ? "clone" : "spawn", ...(o.forkOf ? { forkOf: o.forkOf } : {}) } : undefined);
1787
+ return text;
1788
+ };
1789
+ m.onContextCut((t, cut) => {
1790
+ pendingCuts.set(t, cut);
1791
+ // 之前挂过的提示随对话一起没了: 去重窗口清零, 下一次该给的规矩照给。
1792
+ notices.forget(t);
1793
+ const now = wizards.get(t)?.memory ?? [];
1794
+ const seen = chartered.get(t);
1795
+ // 没有快照 (reload 之后) 就不猜差集, 只指路 —— 整份塞进一行能有几万字。
1796
+ if (!seen) {
1797
+ if (now.length)
1798
+ notices.post([t], `你有 ${now.length} 条 self 记忆, 宪章里那份可能是旧的: \`wizard_whoami\` 看全的`);
1799
+ return;
1800
+ }
1801
+ const fresh = now.filter((x) => !seen.includes(x));
1802
+ const gone = seen.filter((x) => !now.includes(x)).length;
1803
+ const shown = fresh.slice(-10).map((x) => `「${clipLine(x, 200)}」`).join(" ");
1804
+ if (fresh.length)
1805
+ notices.post([t], `你出生之后记下的 self 记忆 (${cut === "clear" ? "/clear 抹掉了对话" : "上下文压缩把对话换成了摘要"}, 宪章里没有它们)${fresh.length > 10 ? `, 最新 10 条` : ""}: ${shown}`);
1806
+ if (gone)
1807
+ notices.post([t], `宪章「我的记忆」里有 ${gone} 条已被你 forget, 别再照它们办`);
1266
1808
  });
1267
1809
  // 所有 spawn 路径 (群里手打 /new、pane 自愈重生、编排出来的分身) 都从这里
1268
1810
  // 取身份, 于是「是谁」不再取决于是哪段代码把它生出来的。
@@ -1271,9 +1813,25 @@ const main = async () => {
1271
1813
  // 终身的 —— 一个在聊天还没名字时出生的 wizard, 会一辈子以为自己住在一个
1272
1814
  // 「(未命名)」的地方。所以补名要发生在渲染之前, 不能等它以后自己去查。
1273
1815
  ensureChatNames(target);
1274
- const rec = wizards.get(target);
1275
- return charterFor(target, { parent: rec?.parent, forkOf: rec?.forkOf, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
1816
+ // 信箱里装的是相对上一份宪章的变化; 新宪章一渲染就都过期了 (名册、记忆都已在里面)。
1817
+ notices.drain(target);
1818
+ notices.forget(target);
1819
+ return charterFor(target, charterOptsOf(target, ctx?.cwd));
1276
1820
  });
1821
+ // 宪章记录是后来才有的: 已经在跑的 wizard 拿 spawn 落盘的那份 (state/charters/<sid>.md) 补上,
1822
+ // 否则要等它下次重生, rolepage 的「宪章」入口才出现。与自动命名同一个延迟: 等附着表恢复完。
1823
+ setTimeout(() => {
1824
+ const dir = join(expandHome(cfg.daemon.stateDir), "charters");
1825
+ wizards.all().forEach((w) => {
1826
+ const sid = m.sessionInfo(w.target)?.sessionId;
1827
+ const file = sid ? join(dir, `${sid}.md`) : "";
1828
+ try {
1829
+ if (file && existsSync(file))
1830
+ backfillCharter(w.target, readFileSync(file, "utf8"), statSync(file).mtimeMs);
1831
+ }
1832
+ catch { /* 补不上就等它重生 */ }
1833
+ });
1834
+ }, 15_000).unref();
1277
1835
  // 开机也补一次: IM 侧的 `/peers`、`/help` 直接读名字, 不经过任何 MCP 路由。
1278
1836
  // 延后是因为附着表要先从盘上恢复完, 否则这一刻还看不见那些聊天; 补不全也无妨,
1279
1837
  // 上面每一道拦截都会再补。
@@ -1310,8 +1868,9 @@ const main = async () => {
1310
1868
  sessionId: info?.sessionId ?? "",
1311
1869
  cli: info?.cli,
1312
1870
  model: info?.model ?? "",
1871
+ ...(info?.effort ? { effort: info.effort } : {}),
1313
1872
  contextTokens: info?.contextTokens ?? 0,
1314
- handoffSuggested: (info?.contextTokens ?? 0) > CONTEXT_FULL_TOKENS,
1873
+ handoffSuggested: !!info && info.contextTokens > handoffAt(lastModel(info.jsonlPath) || info.model),
1315
1874
  };
1316
1875
  };
1317
1876
  /** 全体名册: 活着的 (peers + 跨聊天可寻址的) 在前, 注册表里有记录但此刻不在
@@ -1392,7 +1951,9 @@ const main = async () => {
1392
1951
  // 职责是别人决定"该不该找你"的依据, 改了就得让同群的知道 —— 否则他们照着
1393
1952
  // 出生快照里那句旧的 (或者空的) 职责派活。
1394
1953
  if (asked || description)
1395
- postRoster(baseOfKey(self), [self], `${before && before !== name ? `**.${before}** 改名为 **.${name}**` : `**.${name}** 改了身份`}${description ? ` · 职责: ${description}` : ""}`);
1954
+ postRoster(baseOfKey(self), [self], before && before !== name
1955
+ ? { op: "renamed", name: `.${name}`, was: `.${before}`, ...(description ? { job: description } : {}) }
1956
+ : { op: "job", name: `.${name}`, job: description }, subjectOf(wizards.get(self)));
1396
1957
  json(res, 200, {
1397
1958
  ok: true,
1398
1959
  ...me,
@@ -1429,8 +1990,11 @@ const main = async () => {
1429
1990
  // 的箭头 (读名册的是另一个 wizard), 模型取它真正跑的那个 —— 绑定里的 model 只在
1430
1991
  // 有人显式选过时才有。
1431
1992
  const warm = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1993
+ const inflight = receipts.states();
1432
1994
  const shown = ranked.slice(0, limit).map((r) => ({
1433
1995
  ...r,
1996
+ turns: turnsLine(inflight, r.target),
1997
+ duty: dutyLine(jobs.all(), r.target),
1434
1998
  model: r.model || (r.jsonlPath ? lastModel(r.jsonlPath) : ""),
1435
1999
  summary: r.jsonlPath ? lastExchange(r.jsonlPath, 80, warm) : "",
1436
2000
  contextTokens: r.jsonlPath ? lastContextTokens(r.jsonlPath) : 0,
@@ -1451,35 +2015,151 @@ const main = async () => {
1451
2015
  });
1452
2016
  // 管家分派前的候选表: 事实在这里算 (交集、读过的文件、ctx), 权衡留给管家。
1453
2017
  // 只为活着的读 transcript —— 冷的没有 jsonl 可读, 靠职责命中进表。
1454
- http.register("POST /wizard/route", async (req, res) => {
1455
- const { self, body } = await readPeerBody(req);
1456
- if (!self) {
1457
- json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1458
- return;
1459
- }
1460
- const b = body;
1461
- const task = (b.task ?? "").trim();
1462
- if (!task) {
1463
- json(res, 400, { ok: false, reason: "task is required" });
1464
- return;
1465
- }
2018
+ /** 候选的原料: 除自己之外每个 wizard 的上下文事实 (文件集 / 最近的话 / ctx / 缓存 TTL)。 */
2019
+ const routeRowsOf = async (self) => {
1466
2020
  ensureChatNames(self);
1467
2021
  const warm = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1468
- const rows = (await rosterOf(self))
2022
+ return (await rosterOf(self))
1469
2023
  .filter((r) => !r.self)
1470
2024
  .map((r) => {
1471
2025
  const live = r.jsonlPath ? r.jsonlPath : "";
2026
+ const ttl = live ? cacheTtlSec(live) : 0;
1472
2027
  return {
1473
2028
  ...r,
1474
2029
  contextTokens: live ? lastContextTokens(live) : 0,
2030
+ ...(ttl ? { cacheTtlMs: ttl * 1000 } : {}),
2031
+ ...(live ? { cacheAt: cacheTouchMs(live) } : {}),
1475
2032
  files: live ? contextFiles(live) : [],
1476
2033
  asks: live ? talkTurns(live, 12, warm).filter((t) => t.role === "user").map((t) => t.text) : [],
1477
2034
  summary: live ? lastExchange(live, 80, warm) : "",
1478
2035
  };
1479
2036
  });
2037
+ };
2038
+ http.register("POST /wizard/route", async (req, res) => {
2039
+ const { self, body } = await readPeerBody(req);
2040
+ if (!self) {
2041
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2042
+ return;
2043
+ }
2044
+ const b = body;
2045
+ const task = (b.task ?? "").trim();
2046
+ if (!task) {
2047
+ json(res, 400, { ok: false, reason: "task is required" });
2048
+ return;
2049
+ }
1480
2050
  const limit = Math.min(Math.max(Number(b.limit ?? 5) || 5, 1), 20);
1481
- const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, rows).slice(0, limit);
1482
- json(res, 200, { ok: true, text: renderCandidates(cands, Date.now(), homedir()) });
2051
+ const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, await routeRowsOf(self)).slice(0, limit);
2052
+ json(res, 200, { ok: true, text: renderCandidates(task, cands, Date.now(), cfg.wrc.mirror.keepalive.ttlSec * 1000, homedir()) });
2053
+ });
2054
+ // 派活收成一步 (见 route.decide): 查候选 → 过成本门控 → 转给已有的或按档白板 spawn →
2055
+ // tell_peer 投出去, 回决定与理由。默认决定由守护进程算; 管家不同意就 `to` 点名、
2056
+ // `force` 越过冷门控、`spawn:true` 硬要新的 —— 推翻要显式说。
2057
+ http.register("POST /dispatch", async (req, res) => {
2058
+ const { self, body } = await readPeerBody(req);
2059
+ if (!self) {
2060
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2061
+ return;
2062
+ }
2063
+ const b = body;
2064
+ const task = (b.task ?? "").toString().trim();
2065
+ if (!task) {
2066
+ json(res, 400, { ok: false, reason: "task is required" });
2067
+ return;
2068
+ }
2069
+ const lead = b.lead === true;
2070
+ const force = b.force === true;
2071
+ const to = (b.to ?? "").toString().trim();
2072
+ const now = Date.now();
2073
+ const ttlMs = cfg.wrc.mirror.keepalive.ttlSec * 1000;
2074
+ // 自动选只在这些里选: 在线的 (没 pane 的 ctx 量不出, 成本门会盲过)、不是哪个群的管家、
2075
+ // 不是别人开着的工单里的队员 —— 点名 (`to`) 不受此限。
2076
+ const inOthersJob = (t) => jobs.openOf(baseOfKey(t)).some((j) => j.owner !== self && j.members.some((mm) => mm.target === t));
2077
+ const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target) && !inOthersJob(r.target));
2078
+ const cands = rankCandidates(task, m.getCwd(self).runningCwd, pool).slice(0, 5);
2079
+ let d = to ? { kind: "existing", why: `你点名了 ${to}` }
2080
+ : b.spawn ? { kind: "spawn", why: "你要了新的" }
2081
+ : lead ? { kind: "spawn", why: "复杂活: 白板起一个 hard 档的 lead, 由它组队" }
2082
+ : decide(task, cands, now, ttlMs, force);
2083
+ // 复杂活 (lead:true) 是一件「需求」: 先开需求根单 (账本, 不占 pane), 这一句带它派下去, lead 记成根单成员。
2084
+ // 小活不开单。dryRun 不落账, 所以放在它后面开 (见下)。
2085
+ const criteria = (b.criteria ?? "").toString().trim();
2086
+ let rootId = "";
2087
+ const tell = (name, text, extra = "") => tellPeerAs(self, {
2088
+ name, text, public: b.public !== false, force,
2089
+ // lead 的活远超默认的 1h: 不给 deadline 就按 12h (回执的上限)。
2090
+ ...(b.deadline !== undefined ? { deadline: b.deadline } : lead ? { deadline: 43200 } : {}),
2091
+ ...(rootId ? { job: rootId } : {}),
2092
+ }, extra);
2093
+ const shown = cands.slice(0, 3).map((e) => `.${e.row.address || e.row.name}${e.row.busy ? " 忙" : ""}`);
2094
+ if (b.dryRun) {
2095
+ json(res, 200, { ok: true, dryRun: true, decision: d.kind, reason: d.why, ...(d.kind === "existing" ? { name: to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "") } : { tier: (b.tier ?? "").toString().trim() || tierFor(task, lead) }), ...(shown.length ? { candidates: shown } : {}) });
2096
+ return;
2097
+ }
2098
+ if (lead) {
2099
+ const head = (b.description ?? "").toString().trim() || task.split("\n")[0].trim();
2100
+ rootId = jobs.open(baseOfKey(self), self, truncate(head, 60), {
2101
+ kind: "req", ...(criteria ? { criteria, gate: { g1: now } } : {}),
2102
+ origin: { text: (currentHumanQueryOf(self) || task).trim(), chat: channelOf(self) },
2103
+ }).id;
2104
+ }
2105
+ // 没投成的根单当场收掉: 留着只是一张没人干的空单。
2106
+ const settleRoot = (ok) => { if (rootId && !ok)
2107
+ jobs.close(rootId, "派活没投成"); };
2108
+ const withRoot = (body) => rootId
2109
+ ? { ...body, job: rootId, hint: "需求根单已开, 对人**别提单号**。lead 交付后根单仍开着, 等人验收: 人认可 → close_job(job) 归档并回收 lead; 人说不对 → tell_peer({name, re, job}) 给 lead 返工 (同一张单, 不开新单); 人说不要了 → close_job({job, as:\"cancel\"}); 先放着 → close_job({job, as:\"shelve\"}) (停提醒, 不关单)。" }
2110
+ : body;
2111
+ if (d.kind === "existing") {
2112
+ const name = to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "");
2113
+ const r = resolvePeer(self, name);
2114
+ if (!r.ok) {
2115
+ settleRoot(false);
2116
+ json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
2117
+ return;
2118
+ }
2119
+ // 已有 wizard 当 lead: 宪章是出生时定的 (里面没有 lead 一节), 规矩加打法挂在信封后面带过去;
2120
+ // 已是 lead 的宪章里已有层级规矩, 只带打法。投成了才记 lead, 它下次重生宪章里就有。
2121
+ const t = await tell(name, task, lead ? renderReminder({ wezard: "lead" }, wizards.get(r.target)?.lead ? renderLeadPlaybook() : renderLead()) : "");
2122
+ if (lead && t.status === 200)
2123
+ wizards.upsert(r.target, { lead: true });
2124
+ if (rootId && t.status === 200)
2125
+ jobs.attach(rootId, { target: r.target, task: "", spawned: false, role: "lead" });
2126
+ // 自动选中的被冷门控退回 (decide 用名册的活动时刻, 门控用 transcript 的, 口径差一点): 改走 spawn。
2127
+ if (!(t.body.gated === "cold" && !to)) {
2128
+ settleRoot(t.status === 200);
2129
+ json(res, t.status, withRoot({ ...t.body, decision: "existing", reason: d.why, ...(shown.length ? { candidates: shown } : {}) }));
2130
+ return;
2131
+ }
2132
+ d = { kind: "spawn", why: `${d.why}; 但它被冷门控退回 (${String(t.body.reason)}) —— 改为白板 spawn` };
2133
+ }
2134
+ const name = (b.name ?? "").toString().trim();
2135
+ const description = (b.description ?? "").toString().trim();
2136
+ if (!name || !description) {
2137
+ settleRoot(false);
2138
+ json(res, 400, { ok: false, decision: "spawn", reason: `${d.why} —— 要新生一个, 带上 name (这件事的短名) 与 description (它的职责) 重发`, ...(shown.length ? { candidates: shown } : {}) });
2139
+ return;
2140
+ }
2141
+ const tier = (b.tier ?? "").toString().trim() || tierFor(task, lead);
2142
+ const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) });
2143
+ if (born.status !== 200) {
2144
+ settleRoot(false);
2145
+ json(res, born.status, { ...born.body, decision: "spawn", reason: d.why });
2146
+ return;
2147
+ }
2148
+ // 宪章里只有层级规矩, 打法随信封带 (宪章每轮都在上下文里, 打法只这一件活用得上)。
2149
+ const t = await tell(String(born.body.name), task, lead ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "");
2150
+ settleRoot(t.status === 200);
2151
+ // 为这张根单生出来的: 归档 close_job 时要回收它 (tell 记的是 spawned:false, 再 attach 一次补上; lead 角色也在这里记)。
2152
+ if (rootId && t.status === 200)
2153
+ jobs.attach(rootId, { target: String(born.body.target), task: "", spawned: true, role: "lead" });
2154
+ json(res, t.status, withRoot({
2155
+ ...t.body,
2156
+ decision: "spawn",
2157
+ reason: d.why,
2158
+ tier,
2159
+ ...(born.body.model ? { model: born.body.model } : {}),
2160
+ ...(born.body.effort ? { effort: born.body.effort } : {}),
2161
+ ...(shown.length ? { candidates: shown } : {}),
2162
+ }));
1483
2163
  });
1484
2164
  // 记忆三种作用域: self 跟着自己 (wizards.json), 直写; chat / workspace 是共享的 md,
1485
2165
  // 每个在那个群 / 那个目录出生的 wizard 都会读到 —— 它们只收提议, 由整理者合并
@@ -1508,6 +2188,7 @@ const main = async () => {
1508
2188
  const queued = proposeMemory(inboxPath(cfg.daemon.stateDir, scope, key), {
1509
2189
  at: Date.now(),
1510
2190
  by: selfAddress(self),
2191
+ ...(scope === "workspace" ? { cwd: key } : {}),
1511
2192
  ...(note ? { note } : {}),
1512
2193
  ...(forget ? { forget } : {}),
1513
2194
  });
@@ -1515,161 +2196,220 @@ const main = async () => {
1515
2196
  json(res, 500, { ok: false, reason: "提议没写进收件箱" });
1516
2197
  return;
1517
2198
  }
2199
+ // 不回全文: 每提一条就把整份 md 吐回上下文, 连交几条就是几倍的 token。
2200
+ // forget 是子串, 回它此刻命中几行 —— 0 行就是写错了, 当场能改。
2201
+ const lines = readMemory(path).split("\n").filter((l) => l.trim());
1518
2202
  json(res, 200, {
1519
- ok: true, scope, queued, file: path, memory: readMemory(path),
1520
- hint: "已提交给记忆整理者, 下一轮整理 (半小时内) 合并进上面这份记忆; 此后出生的 wizard 才读得到",
2203
+ ok: true, scope, queued, file: path, lines: lines.length,
2204
+ ...(forget ? { forgetHits: lines.filter((l) => l.includes(forget)).length } : {}),
2205
+ hint: "已提交给记忆整理者, 下一轮整理 (半小时内) 合并进 file; 此后出生的 wizard 才读得到",
1521
2206
  });
1522
2207
  return;
1523
2208
  }
1524
2209
  const cur = wizards.get(self)?.memory ?? [];
1525
2210
  // 忘记按子串匹配: 模型记不住自己当初一字不差写了什么, 但记得大意。
1526
2211
  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 });
2212
+ // 先截再比: 注册表存的是截到 NOTE_MAX 的那版, 拿原文比就永远去不了重。
2213
+ const n = note.slice(0, NOTE_MAX);
2214
+ const want = n ? [...next.filter((x) => x !== n), n] : next;
2215
+ // 满额时注册表从最旧的挤掉 —— 挤掉的、截短的都要明说, 不让它以为全记住了。
2216
+ const dropped = want.slice(0, Math.max(0, want.length - MEMORY_MAX));
2217
+ const { memory } = wizards.upsert(self, { memory: want });
2218
+ json(res, 200, {
2219
+ ok: true, scope: "self", count: memory.length, added: !!note, forgotten: cur.length - next.length,
2220
+ ...(dropped.length ? { dropped, hint: `self 记忆满 ${MEMORY_MAX} 条, 最旧的 ${dropped.length} 条被挤掉了 (见 dropped); 还要的话 forget 掉不重要的再记回来, 或合并成一条` } : {}),
2221
+ ...(note.length > NOTE_MAX ? { truncated: `这条超过 ${NOTE_MAX} 字, 只记下了前 ${NOTE_MAX} 字` } : {}),
2222
+ });
1530
2223
  });
1531
2224
  // 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
1532
2225
  // 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
1533
- http.register("POST /wizard/clone", async (req, res) => {
1534
- const { self, body } = await readPeerBody(req);
1535
- if (!self) {
1536
- json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1537
- return;
1538
- }
2226
+ /** spawn_wizard / clone_wizard 的本体 —— HTTP 路由与 dispatch 共用。 */
2227
+ const bearAs = async (self, body) => {
1539
2228
  const b = body;
2229
+ const askedEffort = parseEffort(b.effort);
2230
+ if (b.effort !== undefined && !askedEffort) {
2231
+ return { status: 400, body: { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` } };
2232
+ }
2233
+ const pick = pickTier(cfg.models, b.tier, { cli: b.cli, model: b.model, effort: askedEffort }, b.inherit === true);
2234
+ if (!pick.ok) {
2235
+ return { status: 400, body: pick };
2236
+ }
2237
+ const effort = pick.effort;
1540
2238
  // 工单先验: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。
1541
2239
  const jobId = (b.job ?? "").trim();
1542
2240
  if (jobId) {
1543
- const jc = checkJob(jobId);
2241
+ const jc = checkJob(jobId, undefined, !(b.task ?? "").toString().trim());
1544
2242
  if (!jc.ok) {
1545
- json(res, jc.status, { ok: false, reason: jc.reason });
1546
- return;
2243
+ return { status: jc.status, body: { ok: false, reason: jc.reason } };
1547
2244
  }
1548
2245
  }
1549
2246
  // `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
1550
2247
  // 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
1551
2248
  // 逼调用方每次自己说。
1552
2249
  if (typeof b.inherit !== "boolean") {
1553
- json(res, 400, { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" });
1554
- return;
2250
+ return { status: 400, body: { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" } };
1555
2251
  }
1556
2252
  const inherit = b.inherit;
2253
+ // detached = 独立长住 (原 new_claude_session / 人在群里 `/new .x`): 不挂家谱、不占
2254
+ // 调用方的分身名额、不随工单回收。它和「fork 我的上下文」「归某张工单」都互斥。
2255
+ const detached = b.detached === true;
2256
+ if (detached && (inherit || (b.job ?? "").trim())) {
2257
+ return { status: 400, body: { ok: false, reason: "detached 只用于白板 (inherit:false) 且不带 job —— 独立长住的 wizard 不归你管, 也不随工单回收" } };
2258
+ }
1557
2259
  // 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
1558
2260
  // 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
1559
2261
  // 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
1560
2262
  const fromAddr = (b.from ?? "").toString().trim();
1561
2263
  if (fromAddr && !inherit) {
1562
- json(res, 400, { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" });
1563
- return;
2264
+ return { status: 400, body: { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" } };
1564
2265
  }
1565
2266
  const fromR = fromAddr ? resolvePeer(self, fromAddr) : { ok: true, target: self };
1566
2267
  if (!fromR.ok) {
1567
- json(res, fromR.status, { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) });
1568
- return;
2268
+ return { status: fromR.status, body: { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) } };
1569
2269
  }
1570
2270
  const source = fromR.target;
1571
2271
  const sourceInfo = m.sessionInfo(source);
1572
2272
  // 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
1573
2273
  // 要的恰恰是它的上下文, 给一张白纸等于答非所问。
1574
2274
  if (source !== self && !sourceInfo?.sessionId) {
1575
- json(res, 409, { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` });
1576
- return;
2275
+ return { status: 409, body: { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` } };
1577
2276
  }
1578
- const alivePeers = await m.peers(self);
2277
+ const alivePeers = detached ? [] : await m.peers(self);
1579
2278
  // 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
1580
2279
  // close_job 整批回收一个工单的临时分身。只数**本聊天里**活着的 ——
1581
2280
  // 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
1582
2281
  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) {
1584
- json(res, 429, {
1585
- ok: false,
1586
- reason: `你名下已经有 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) —— 先 stop_wizard 收掉干完活的那些, 或者 close_job 整批回收一个工单`,
1587
- clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
1588
- });
1589
- return;
2282
+ if (!detached && aliveKids.length >= cfg.wrc.mirror.cloneMax) {
2283
+ return { status: 429, body: {
2284
+ ok: false,
2285
+ reason: `你名下已经有 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) —— 先 stop_wizard 收掉干完活的那些, 或者 close_job 整批回收一个工单`,
2286
+ clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
2287
+ } };
1590
2288
  }
1591
2289
  const wantChat = (b.chat ?? "").toString().trim();
1592
2290
  const base = wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self);
1593
2291
  if (!base) {
1594
- json(res, 404, {
1595
- ok: false,
1596
- reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
1597
- candidates: listChatNames(cfg).map((c) => c.name),
1598
- });
1599
- return;
2292
+ return { status: 404, body: {
2293
+ ok: false,
2294
+ reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
2295
+ candidates: listChatNames(cfg).map((c) => c.name),
2296
+ } };
1600
2297
  }
1601
2298
  const askedName = String(b.name ?? b.tag ?? "");
1602
- const slotR = await claimSlot(base, askedName, normalizeTag(b.description?.split(/\s+/)[0]) || "clone", [self, source]);
2299
+ // 没起名: 独立长住的按目录名 (同 /new 与 new_claude_session), 分身按职责的首词。
2300
+ const fallback = (detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone";
2301
+ const slotR = await claimSlot(base, askedName, fallback, [self, source]);
1603
2302
  if (!slotR.ok) {
1604
- json(res, slotR.status, slotR.body);
1605
- return;
2303
+ return { status: slotR.status, body: slotR.body };
1606
2304
  }
1607
2305
  const { target, slot: tag } = slotR;
2306
+ // claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
2307
+ // 只回滚这一次的新记录, 不能把那个老 wizard 的记忆和家谱一起删掉。
2308
+ const existed = !!wizards.get(target);
1608
2309
  const forkOf = inherit && source !== self ? source : undefined;
1609
2310
  // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
1610
2311
  wizards.upsert(target, {
1611
2312
  description: (b.description ?? "").toString().trim(),
1612
- parent: self,
2313
+ // 显式写 undefined: upsert 是合并写, 复用的冷记录里旧的 parent 会留下来。
2314
+ parent: detached ? undefined : self,
1613
2315
  bornAt: Date.now(),
1614
2316
  clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
1615
2317
  ...(forkOf ? { forkOf } : {}),
2318
+ // fork 了上下文就一并继承 self 记忆: 系统提示不随 fork 走 (分身拿的是自己的宪章),
2319
+ // 不拷的话它开局就没有, /clear 或交接重开后更是彻底没了。标上来源; 已经是继承来的不再套一层。
2320
+ ...(inherit ? { memory: (wizards.get(source)?.memory ?? []).map((x) => x.startsWith("(继承自 ") ? x : `(继承自 ${displayName(source)}) ${x}`) } : {}),
2321
+ tier: pick.tier,
2322
+ // 显式写 undefined: 复用的冷记录可能当过 lead。
2323
+ lead: b.lead === true ? true : undefined,
2324
+ // 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
2325
+ spawning: BOOT_ID,
1616
2326
  });
1617
2327
  const name = wizards.rename(target, normalizeTag(askedName) || tag);
1618
- const charter = charterFor(target, { parent: self, forkOf, inherited: inherit });
2328
+ const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit });
1619
2329
  const task = (b.task ?? "").toString().trim();
1620
2330
  // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
1621
2331
  const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
1622
2332
  // 先盖章再生: 分叉出来的 transcript 里躺着被克隆者的旧回复, 时刻早于这一枚章,
1623
2333
  // wait_peer 才不会把它们当成分身对这件活的答复。
1624
2334
  const taskAt = Date.now();
2335
+ const taskTurn = newTurn();
2336
+ const asker = task ? askerOf(self) : undefined;
1625
2337
  const r = await m.cloneSession({
1626
2338
  parent: source,
1627
2339
  target,
1628
2340
  windowName: name,
1629
- cli: b.cli,
1630
- model: b.model,
2341
+ cli: pick.cli,
2342
+ model: pick.model,
2343
+ effort,
1631
2344
  cwd: b.cwd,
1632
2345
  systemPrompt: charter,
1633
2346
  inherit,
1634
2347
  // 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
1635
2348
  // 也少一次"就位了但没事干"的空转。
1636
- bootstrap: task ? task + envelopeFor(self, "") : undefined,
2349
+ // 子 lead 的宪章只有层级规矩, 打法跟第一句的信封走 (同 dispatch 对已有 wizard 的做法)。
2350
+ bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "") : undefined,
2351
+ ...(task ? { bootstrapFrom: { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, query: task } } : {}),
1637
2352
  keepalive,
1638
- });
2353
+ }).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
2354
+ // 生不出来就回滚身份 —— 否则名字被一个永远没有会话的记录占住。
1639
2355
  if (!r.ok) {
1640
- wizards.drop(target);
1641
- json(res, 500, { ok: false, reason: r.reason });
1642
- return;
2356
+ if (existed)
2357
+ wizards.upsert(target, { spawning: undefined });
2358
+ else
2359
+ wizards.drop(target);
2360
+ return { status: 500, body: { ok: false, reason: r.reason } };
1643
2361
  }
1644
- wizards.upsert(target, { clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
2362
+ wizards.upsert(target, { spawning: undefined, clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
1645
2363
  const kid = briefOf(self, target);
1646
- // r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
1647
- // 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
1648
- const modelNote = r.model
1649
- ? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
1650
- : "";
1651
2364
  // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
1652
2365
  // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
1653
2366
  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)} ` : "它"}的上下文)` : ""}`);
2367
+ // 模型 / keepalive 不报: 那是生它的那位的事, 旁人要时 wizard_roster 有。
2368
+ postRoster(base, [target, self], {
2369
+ op: "born", name: `.${name}`, by: displayName(self), clone: !detached,
2370
+ ...(r.inherited ? { ctx: forkOf ? displayName(forkOf) : displayName(self) } : {}),
2371
+ ...(kid.description ? { job: kid.description } : {}), cwd: r.cwd,
2372
+ }, subjectOf(wizards.get(target)));
1655
2373
  }
1656
2374
  // 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
1657
2375
  let dispatched = r.inherited && !!task;
1658
2376
  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, "") });
2377
+ 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
2378
  dispatched = inj.ok;
1661
2379
  }
1662
- if (jobId)
1663
- jobs.attach(jobId, { target, task, spawned: true });
2380
+ if (jobId) {
2381
+ const role = roleParam(body.role);
2382
+ const from = inherit ? source : undefined;
2383
+ jobs.attach(jobId, { target, task, spawned: true, ...(role ? { role } : {}), ...(from ? { forkOf: from } : {}) });
2384
+ }
2385
+ if (jobId && dispatched)
2386
+ jobs.spend(jobId);
1664
2387
  // 分身的第一件活也守回执: fan-out 最常见的形状就是 clone_wizard({task}) × N,
1665
2388
  // 让它们干完自己把结论送回来, 发起方不必挂在 wait_peer 上。
2389
+ const k = body.chain === false ? undefined : parentKOf(self);
2390
+ if (dispatched)
2391
+ receipts.register({ from: self, to: target, channel: "", job: jobId, at: taskAt, turn: taskTurn, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
1666
2392
  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 } : {}) });
2393
+ ledger.open({ turn: taskTurn, owner: self, to: target, text: task, at: taskAt, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
2394
+ return { status: 200, body: { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, ...(dispatched ? { turn: taskTurn } : {}), keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(r.effort ? { effort: r.effort } : {}), ...(pick.tier ? { tier: pick.tier } : {}), via: pick.via, ...(pick.note ? { tierNote: pick.note } : {}), ...(jobId ? { job: jobId } : {}), ...(detached ? { detached: true } : {}) } };
2395
+ };
2396
+ http.register("POST /wizard/clone", async (req, res) => {
2397
+ const { self, body } = await readPeerBody(req);
2398
+ if (!self) {
2399
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2400
+ return;
2401
+ }
2402
+ const r = await bearAs(self, body);
2403
+ json(res, r.status, r.body);
1669
2404
  });
1670
2405
  // 收掉一个 wizard。interrupt = 打断它这一轮 (Esc); end = 结束它并回收 pane。
1671
2406
  // 自己终结自己是合法的 (分身干完活自我了结), 代价是这次工具调用不会返回 ——
1672
2407
  // 回执只在 rolepage (生命周期事件不进群); 同群的 wizard 从名册增量里得知。
2408
+ /** 抹掉一个 wizard 的身份; 它在开着的工单里没落定的那一份记成 canceled —— 名字都没了, 不会再落定。 */
2409
+ const forgetWizard = (target) => {
2410
+ wizards.drop(target);
2411
+ return jobs.detach(target);
2412
+ };
1673
2413
  http.register("POST /wizard/stop", async (req, res) => {
1674
2414
  const { self, body } = await readPeerBody(req);
1675
2415
  if (!self) {
@@ -1678,31 +2418,78 @@ const main = async () => {
1678
2418
  }
1679
2419
  const b = body;
1680
2420
  const r = resolvePeer(self, addrOf(b));
2421
+ // 有身份没会话 (冷记录 / 生到一半的僵尸): resolvePeer 够不着它, 但收它不需要
2422
+ // 会话 —— end 视为早已结束, forget 删掉记录、腾出名字。interrupt 无从谈起。
2423
+ const found = r.ok || r.candidates?.length ? undefined : wizards.byName(addrOf(b));
2424
+ const cold = found && !m.chatTargets(baseOfKey(found.target)).includes(found.target) ? found : undefined;
2425
+ if (cold && (b.mode ?? "end") === "end") {
2426
+ const detached = b.forget ? forgetWizard(cold.target) : [];
2427
+ json(res, 200, { ok: true, target: cold.target, name: cold.name, mode: "end", forgotten: !!b.forget, note: "它本来就没有会话在跑", ...(detached.length ? { canceledIn: detached } : {}) });
2428
+ return;
2429
+ }
1681
2430
  if (!r.ok) {
1682
2431
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
1683
2432
  return;
1684
2433
  }
1685
2434
  const { target } = r;
1686
2435
  const victim = briefOf(self, target);
2436
+ // 当事人要在 forget 抹掉记录之前取 (见 subjectOf)。
2437
+ const victimRec = wizards.get(target);
1687
2438
  const end = (b.mode ?? "end") === "end";
1688
2439
  if (!end && target === self) {
1689
2440
  json(res, 400, { ok: false, reason: "打断自己没有意义 —— 你就是正在生成的那一个" });
1690
2441
  return;
1691
2442
  }
2443
+ // 按件号撤回自己派的那一件: 它此刻正在做的就是这件才按 Esc; 否则 (排着 / 在做别人的)
2444
+ // 只把这件记成 canceled —— 它之后答出来也不再回执, 手上别人的那一轮不受影响。
2445
+ const turn = (b.turn ?? "").replace(/[`\s]/g, "");
2446
+ if (!end && turn) {
2447
+ const mine = receipts.pending(self, target);
2448
+ if (mine?.turn !== turn) {
2449
+ json(res, 404, { ok: false, reason: `你没有在等 ${victim.name ? `.${victim.name}` : target} 答 '${turn}' 这件 (已落定、件号写错, 或不是你派的)` });
2450
+ return;
2451
+ }
2452
+ // 先落 canceled 再按 Esc: 被打断那一轮的半截话不该被 watcher 当成答案投回来。
2453
+ const busy = !(await m.idleNow(target));
2454
+ const canceled = receipts.cancel(target, self, (x) => x.from === self && x.turn === turn);
2455
+ // 按 Esc 之前再认一次手上这一轮: 上面那次 await 期间它可能已经做完这件、接了别人的下一件。
2456
+ const current = busy && openingAsk(target)?.turn === turn;
2457
+ // 还没轮到 / 排在后面: 捎一句给它, 轮到那句话时别再做了 (notices 挂在下一条注入的尾巴上)。
2458
+ if (!current)
2459
+ notices.post([target], `${displayName(self)} 撤回了它派给你的 ${turn} —— 那件活不用做了; 读到它时直接略过, 不必回复。`);
2460
+ // 只按 Esc, 不 teardown: teardown 会清掉它的注入队列, 别人排着的话一起丢。
2461
+ const esc = current ? await m.interruptPane(target) : undefined;
2462
+ // Esc 没按成也不回滚撤回: 这件已经不等了, 只是它手上那一轮还在跑 —— 如实说。
2463
+ json(res, 200, {
2464
+ ok: true, target, name: victim.name, mode: "interrupt", turn, canceled, interrupted: !!esc?.ok,
2465
+ ...(esc && !esc.ok ? { note: `这件已撤回 (不会再回执给你), 但 Esc 没按成 (${esc.reason ?? "unknown"}), 它这一轮可能还在跑` } : {}),
2466
+ ...(current ? {} : { note: "它此刻没在做这件 (还排着, 或在做别人的活), 没按 Esc —— 这件已撤回, 它之后答出来也不会再回执给你" }),
2467
+ });
2468
+ return;
2469
+ }
2470
+ // 不带件号的打断会停掉它手上整轮: 那一轮若是别的 wizard 派的, 先拒 —— 误伤的是别人的活。
2471
+ const others = !end && !b.force ? await othersTurn(target, self) : undefined;
2472
+ if (others) {
2473
+ json(res, 409, { ok: false, target, reason: `它此刻在做 ${others.from} 派的活${others.turn ? ` (${others.turn})` : ""}, 打断会连那件一起停掉 —— 只想撤回你自己派的那件, 传 turn (件号); 确要打断它这一轮, 加 force:true` });
2474
+ return;
2475
+ }
2476
+ // 先落 canceled 再动 pane: pane 一死, 守着的 watcher 会把同一份报成 dead。
2477
+ // 打断只停它手上这一轮: 只掐这一轮开头那句派来的活 (人 / 定时 / 回执轮不掐任何一份)。
2478
+ const canceled = receipts.cancel(target, self, end ? undefined : currentAsk(target));
1692
2479
  const done = end ? await m.killPane(target) : await m.interruptPane(target, { teardown: true });
1693
2480
  if (!done.ok) {
1694
2481
  json(res, 502, { ok: false, target, reason: done.reason });
1695
2482
  return;
1696
2483
  }
1697
- if (end && b.forget)
1698
- wizards.drop(target);
2484
+ const detached = end && b.forget ? forgetWizard(target) : [];
1699
2485
  // 打断只是停了它这一轮, 它还在; 只有结束才是名册变了。
1700
2486
  if (end)
1701
- 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) });
2487
+ postRoster(baseOfKey(target), [target, self], { op: "ended", name: victim.name ? `.${victim.name}` : target, by: displayName(self), ...(b.forget ? { why: "forgotten" } : {}) }, subjectOf(victimRec));
2488
+ json(res, 200, { ok: true, target, name: victim.name, mode: end ? "end" : "interrupt", forgotten: !!(end && b.forget), ...(canceled ? { canceled } : {}), ...(detached.length ? { canceledIn: detached } : {}) });
1703
2489
  });
1704
- // 换模型: 省略名字 = 换自己 (选择器盖在正在跑的这一轮上面照样能开), 点名 = 换
1705
- // 那个 wizard。与 spawn 时的 `model` 是同一条路 (model-select.ts)。
2490
+ // 换模型 / effort: 省略名字 = 换自己 (选择器盖在正在跑的这一轮上面照样能开), 点名 =
2491
+ // 换那个 wizard。与 spawn 时的 `model` 是同一条路 (model-select.ts); effort 走
2492
+ // `/effort` 档位条的「只本会话」键, 不碰全机默认。两样都给就先模型后档位。
1706
2493
  http.register("POST /wizard/model", async (req, res) => {
1707
2494
  const { self, body } = await readPeerBody(req);
1708
2495
  if (!self) {
@@ -1711,8 +2498,13 @@ const main = async () => {
1711
2498
  }
1712
2499
  const b = body;
1713
2500
  const model = (b.model ?? "").toString().trim();
1714
- if (!model) {
1715
- json(res, 400, { ok: false, reason: "model 必填 —— 口语化写就行 ('opus' / 'haiku' / 'sonnet 5')" });
2501
+ const effort = parseEffort(b.effort);
2502
+ if (b.effort !== undefined && !effort) {
2503
+ json(res, 400, { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` });
2504
+ return;
2505
+ }
2506
+ if (!model && !effort) {
2507
+ json(res, 400, { ok: false, reason: "model / effort 至少给一个 —— model 口语化写就行 ('opus' / 'haiku' / 'sonnet 5')" });
1716
2508
  return;
1717
2509
  }
1718
2510
  const scope = (b.scope ?? "session").toString().trim();
@@ -1727,8 +2519,18 @@ const main = async () => {
1727
2519
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
1728
2520
  return;
1729
2521
  }
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 } : {}) });
2522
+ const done = model ? await m.setModel(r.target, model, scope) : undefined;
2523
+ const eff = effort && (done?.ok ?? true) ? await m.setEffort(r.target, effort) : undefined;
2524
+ const ok = (done?.ok ?? true) && (eff?.ok ?? true);
2525
+ json(res, ok ? 200 : 502, {
2526
+ ...done,
2527
+ ok,
2528
+ target: r.target,
2529
+ name: briefOf(self, r.target).name,
2530
+ ...(done?.ok ? { model: done.applied } : {}),
2531
+ ...(eff?.ok ? { effort: eff.applied } : {}),
2532
+ ...(eff && !eff.ok ? { reason: [done?.reason, `effort: ${eff.reason}`].filter(Boolean).join("; ") } : {}),
2533
+ });
1732
2534
  });
1733
2535
  // 自我交接: 上下文撑不住了, 自己把工作压成简报, /new 换一个全新进程, 再把简报贴
1734
2536
  // 回去。和 /handoff 的区别是**简报由调用方自己写在入参里** —— 它没法在自己
@@ -1740,15 +2542,8 @@ const main = async () => {
1740
2542
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1741
2543
  return;
1742
2544
  }
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 });
2545
+ const r = handoffSelf(self, (body.brief ?? "").toString().trim());
2546
+ json(res, r.status, r.body);
1752
2547
  });
1753
2548
  // ── Job: 一次 fan-out 的工单 ─────────────────────────────────────────
1754
2549
  // 账本, 不是编排器: 控制流留在发起的那个 wizard 手里 (它自己 spawn / wait /
@@ -1762,19 +2557,48 @@ const main = async () => {
1762
2557
  }
1763
2558
  const b = body;
1764
2559
  const title = (b.title ?? "").toString().trim();
2560
+ const expect = Math.min(JOB_MEMBER_MAX, Math.max(0, Math.floor(Number(b.expect) || 0)));
1765
2561
  if (!title) {
1766
2562
  json(res, 400, { ok: false, reason: "title required —— 一句话说清这个工单要干成什么" });
1767
2563
  return;
1768
2564
  }
1769
2565
  const base = baseOfKey(self);
1770
- const job = jobs.open(base, self, title);
1771
- notifyChat(base, renderJobOpen(job, (b.plan ?? "").toString()));
2566
+ const maxTurns = Math.min(200, Math.max(0, Math.floor(Number(b.maxTurns) || 0)));
2567
+ const plan = (b.plan ?? "").toString().trim();
2568
+ const criteria = (b.criteria ?? "").toString().trim();
2569
+ // 上级工单: 显式传的要求自己是它的成员或开单者; 不传 = 自己作为未落定成员所在的、最近开的那张
2570
+ // (lead 在需求根单里领了活, 开的第一张子单自然挂在根单下, 不用自己记号)。
2571
+ const all = jobs.all();
2572
+ const mineIn = (j) => j.owner === self || j.members.some((x) => x.target === self);
2573
+ const askedParent = (b.parent ?? "").toString().trim();
2574
+ // 推断: 同一个群里、我作为未落定成员的开着的单, 取我**最近一次领活** (mm.at) 的那张; 它已在最深一层就不挂了 (退成根单, 不 409)。
2575
+ const inferred = all
2576
+ .filter((j) => j.base === base && j.status === "open")
2577
+ .flatMap((j) => j.members.filter((x) => x.target === self && !x.outcome).map((x) => ({ j, at: x.at })))
2578
+ .sort((x, y) => y.at - x.at)[0]?.j;
2579
+ const parentJob = askedParent ? jobs.get(askedParent) : inferred && depthOf(jobs.get, inferred) < JOB_DEPTH_MAX ? inferred : undefined;
2580
+ if (askedParent && !parentJob) {
2581
+ json(res, 404, { ok: false, reason: `没有上级工单 '${askedParent}'` });
2582
+ return;
2583
+ }
2584
+ if (askedParent && parentJob && (parentJob.status !== "open" || !mineIn(parentJob))) {
2585
+ json(res, 409, { ok: false, reason: `上级工单 '${askedParent}' 已收工, 或你既不是它的成员也不是它的开单者` });
2586
+ return;
2587
+ }
2588
+ if (parentJob && depthOf(jobs.get, parentJob) + 1 > JOB_DEPTH_MAX) {
2589
+ json(res, 409, { ok: false, reason: `工单树最深 ${JOB_DEPTH_MAX} 层, 你这张 (挂在 ${parentJob.id} 下) 会是第 ${depthOf(jobs.get, parentJob) + 1} 层 —— 子活交给现有成员, 别再往下长` });
2590
+ return;
2591
+ }
2592
+ // 不出群气泡: 工单是私下的过程, 人看到的只有发起者那一轮的最终回复。
2593
+ const accept = ACCEPTS.find((x) => x === b.accept);
2594
+ const job = jobs.open(base, self, title, { ...(plan ? { plan } : {}), ...(accept ? { accept } : {}), ...(expect ? { expect } : {}), ...(maxTurns ? { maxTurns } : {}), ...(parentJob ? { parent: parentJob.id } : {}), ...(criteria ? { criteria } : {}) });
1772
2595
  json(res, 200, {
1773
2596
  ok: true,
1774
2597
  job: job.id,
1775
2598
  title,
2599
+ ...(parentJob ? { parent: parentJob.id } : {}),
1776
2600
  memberMax: JOB_MEMBER_MAX,
1777
- hint: "把这个 id 传给 spawn_wizard / clone_wizard / send_peer 的 `job` 参数, 它们就归到这个工单名下 (期间不再逐条出气泡); 活干完调 close_job 收尾并回收临时分身。",
2601
+ hint: "把这个 id 传给 spawn_wizard / clone_wizard / tell_peer 的 `job` 参数, 它们就归到这个工单名下 (一律私聊, 不进群); 活干完调 close_job 留档并回收临时分身, 给人的结论写在你自己这一轮的最终回复里。",
1778
2602
  });
1779
2603
  });
1780
2604
  http.register("POST /jobs/close", async (req, res) => {
@@ -1794,14 +2618,97 @@ const main = async () => {
1794
2618
  json(res, 409, { ok: false, reason: `工单 '${id}' 已经收工了`, closedAt: job.closedAt });
1795
2619
  return;
1796
2620
  }
1797
- // 回收只针对**为这个工单生出来的**分身: 被拉来帮忙的长期 wizard 不该因为一次
1798
- // 活结束就被杀掉。调用方自己也不收 —— 那会在这次工具调用里把自己干掉。
2621
+ // 只有这张单或它某个祖先单的 owner 能关 (成员、同群的旁人不行)。
2622
+ if (![job, ...jobAncestors(jobs.get, job)].some((j) => j.owner === self)) {
2623
+ json(res, 403, { ok: false, reason: `工单 '${id}' 不是你开的, 也不是你开的某张上级单的后代 —— 只有开单者 (或上级单的开单者) 能收工` });
2624
+ return;
2625
+ }
2626
+ // 四种收法 (只 accept / cancel 真关单): shelve / resume 只动需求根单的搁置标记, 单照旧开着。
2627
+ const how = ["accept", "cancel", "shelve", "resume"].find((x) => x === (b.as ?? "accept"));
2628
+ if (!how) {
2629
+ json(res, 400, { ok: false, reason: `as 只认 accept / cancel / shelve / resume, 收到 '${b.as}'` });
2630
+ return;
2631
+ }
2632
+ if ((how === "shelve" || how === "resume") && job.kind !== "req") {
2633
+ json(res, 400, { ok: false, reason: `只有需求根单能搁置 / 恢复, '${id}' 不是 (它不是 dispatch({lead:true}) 开的根单)` });
2634
+ return;
2635
+ }
2636
+ if (how === "shelve" || how === "resume") {
2637
+ // 恢复不是「又交付了」: 保留 n, lastAt 记成此刻, 下一次冒泡从现在起算, 不一恢复就立刻冒。
2638
+ jobs.mark(id, how === "shelve" ? { hold: Date.now() } : { hold: undefined, nudge: { n: job.nudge?.n ?? 0, lastAt: Date.now() } });
2639
+ json(res, 200, { ok: true, job: id, [how === "shelve" ? "shelved" : "resumed"]: true });
2640
+ return;
2641
+ }
2642
+ // 关一张单连它还开着的后代单一起关 (深的先关): 否则子 lead 的分身留在原地、账本上挂着没人收的单。
2643
+ // 回收只针对**为这个工单生出来的**分身: 被拉来帮忙的长期 wizard 不该因为一次活结束就被杀掉。
2644
+ // 调用方自己也不收 —— 那会在这次工具调用里把自己干掉。
1799
2645
  const recycle = b.stop !== false;
1800
- const victims = recycle ? job.members.filter((mm) => mm.spawned && mm.target !== self) : [];
1801
- const killed = await victims.reduce(async (acc, mm) => (await acc) + ((await m.killPane(mm.target)).ok ? 1 : 0), Promise.resolve(0));
1802
- const closed = jobs.close(id, (b.summary ?? "").toString());
1803
- notifyChat(job.base, renderJobClose(closed, (t) => relayLabel(t, job.base), killed));
1804
- json(res, 200, { ok: true, job: id, members: closed.members.length, recycled: killed, kept: victims.length - killed });
2646
+ const openKids = (j) => jobChildren(jobs.all(), j.id).filter((c) => c.status === "open").flatMap((c) => [...openKids(c), c]);
2647
+ const cascade = openKids(job);
2648
+ const closeOne = async (j, summary, mark) => {
2649
+ const victims = recycle ? j.members.filter((mm) => mm.spawned && mm.target !== self) : [];
2650
+ // 收掉之前把它们手上没落定的活记成 canceled: 不然发起者收工之后还会收到一串 dead 回执。
2651
+ victims.forEach((mm) => receipts.cancel(mm.target, j.owner));
2652
+ // 留档要的 sessionId 得在回收之前取: killPane 连绑定一起删。
2653
+ const sids = new Map([j.owner, ...j.members.map((mm) => mm.target)].map((t) => [t, m.sessionInfo(t)?.sessionId ?? ""]));
2654
+ const killed = await victims.reduce(async (acc, mm) => (await acc) + ((await m.killPane(mm.target)).ok ? 1 : 0), Promise.resolve(0));
2655
+ // 取消: 还没落定、又不是为这张单生的成员 (长住 wizard) 不杀, 但它们名下这张单派的活记成 canceled, 发起者不会再收到一串回执。
2656
+ if (how === "cancel")
2657
+ j.members.filter((mm) => !mm.outcome && !victims.includes(mm)).forEach((mm) => receipts.cancel(mm.target, j.owner, (x) => x.from === j.owner));
2658
+ const closed = jobs.close(j.id, summary, mark);
2659
+ ledger.closeJob(j.id);
2660
+ // 收工结论留档到开单者名下 (与交接简报同一份 jsonl); 空工单 (没人、没结论) 不记。
2661
+ if (closed.members.length || closed.summary?.trim()) {
2662
+ const ep = jobEpisode(closed, { nameOf: episodeName, sidOf: (t) => sids.get(t) ?? "", chat: chatNameOf(cfg, j.base) || j.base });
2663
+ if (!appendEpisode(episodePath(cfg.daemon.stateDir, ep.name), ep))
2664
+ log.warn({ job: j.id, name: ep.name }, "job episode not archived");
2665
+ }
2666
+ return { members: closed.members.length, killed, kept: victims.length - killed };
2667
+ };
2668
+ const rs = await [...cascade.map((c) => [c, `随上级单 ${id} 收工`]), [job, how === "cancel" ? `取消: ${(b.summary ?? "").toString()}` : (b.summary ?? "").toString()]]
2669
+ // 根单收工: accept = G3 验收过闸 (人认可), cancel = 取消; 非根单没有闸。
2670
+ .reduce(async (acc, [j, sum]) => [...(await acc), await closeOne(j, sum, j !== job ? undefined : how === "cancel" ? { end: "cancel" } : job.kind === "req" ? { gate: { g3: Date.now() }, end: "accept" } : undefined)], Promise.resolve([]));
2671
+ const sum = (f) => rs.reduce((n, r) => n + f(r), 0);
2672
+ json(res, 200, { ok: true, job: id, members: rs[rs.length - 1].members, recycled: sum((r) => r.killed), kept: sum((r) => r.kept), ...(cascade.length ? { cascaded: cascade.map((c) => c.id) } : {}) });
2673
+ });
2674
+ // 挂起事项全表 (见 pending-items.ts): 你派出去、还没了结的事。`drop` 显式消项;
2675
+ // `name` 看别人的 (只读)。读过全表就算刚给过一次: 自动的全表提醒从此刻重新计。
2676
+ http.register("POST /pending", async (req, res) => {
2677
+ const { self, body } = await readPeerBody(req);
2678
+ if (!self) {
2679
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2680
+ return;
2681
+ }
2682
+ const b = body;
2683
+ const who = (b.name ?? "").toString().trim();
2684
+ const r = who ? resolvePeer(self, who) : undefined;
2685
+ if (r && !r.ok) {
2686
+ json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
2687
+ return;
2688
+ }
2689
+ const owner = r?.ok ? r.target : self;
2690
+ const drop = Array.isArray(b.drop) ? b.drop.map(String) : typeof b.drop === "string" ? [b.drop] : [];
2691
+ if (drop.length && owner !== self) {
2692
+ json(res, 403, { ok: false, reason: "只能消自己派出去的" });
2693
+ return;
2694
+ }
2695
+ const dropped = drop.length ? ledger.drop(self, drop, (b.why ?? "").toString().trim()) : [];
2696
+ const now = Date.now();
2697
+ const items = ledger.openOf(owner);
2698
+ if (owner === self) {
2699
+ const i = m.sessionInfo(self);
2700
+ if (i)
2701
+ pendingMarks.set(self, { sid: i.sessionId, ctx: i.contextTokens, ctxAtFull: i.contextTokens, drains: 0 });
2702
+ }
2703
+ const missed = drop.filter((t) => !dropped.some((x) => x.turn === t.replace(/[`\s]/g, "")));
2704
+ json(res, 200, {
2705
+ ok: true,
2706
+ text: [
2707
+ ...(dropped.length ? [`已消掉: ${dropped.map((x) => `\`${x.turn}\``).join(" ")}`] : []),
2708
+ ...(missed.length ? [`没找到 (不是你开着的): ${missed.join(" ")}`] : []),
2709
+ renderTable(items, liveOfTurns(), pendingNames, now, awaitingRows(owner, now), stalledRows(owner), shelvedRows(owner, now)),
2710
+ ].join("\n"),
2711
+ });
1805
2712
  });
1806
2713
  http.register("POST /jobs/list", async (req, res) => {
1807
2714
  const { self } = await readPeerBody(req);
@@ -1809,15 +2716,36 @@ const main = async () => {
1809
2716
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1810
2717
  return;
1811
2718
  }
2719
+ const inflight = receipts.states();
2720
+ const live = jobLive();
2721
+ const everything = jobs.all();
2722
+ // 本群还开着的, 外加它们名下已收工的子单 (账本留着, 面包屑 / 树才不断)。
2723
+ const shown = everything.filter((j) => j.base === baseOfKey(self) && (j.status === "open" || jobAncestors(jobs.get, j).some((a) => a.status === "open")));
1812
2724
  json(res, 200, {
1813
2725
  ok: true,
1814
- jobs: jobs.openOf(baseOfKey(self)).map((j) => ({
2726
+ jobs: treeOrder(shown).map(([j, depth]) => ({
1815
2727
  job: j.id,
1816
2728
  title: j.title,
2729
+ depth,
2730
+ ...(j.parent ? { parent: j.parent } : {}),
2731
+ ...(j.kind ? { kind: j.kind } : {}),
2732
+ stage: jobStage(j, everything, live),
2733
+ ...(j.criteria ? { criteria: j.criteria } : {}),
2734
+ status: j.status,
1817
2735
  owner: displayName(j.owner),
1818
2736
  mine: j.owner === self,
1819
2737
  openedAt: j.openedAt,
1820
- members: j.members.map((mm) => ({ address: peerAddress(cfg, self, mm.target), task: mm.task.split("\n")[0] ?? "", spawned: mm.spawned })),
2738
+ ...jobs.tally(j.id),
2739
+ ...(j.maxTurns ? { budget: `${j.turns ?? 0}/${j.maxTurns}` } : {}),
2740
+ members: j.members.map((mm) => ({
2741
+ address: peerAddress(cfg, self, mm.target),
2742
+ role: mm.role ?? "exec",
2743
+ task: mm.task.split("\n")[0] ?? "",
2744
+ spawned: mm.spawned,
2745
+ state: mm.outcome ?? inflight.find((x) => x.to === mm.target && x.job === j.id)?.state ?? "unknown",
2746
+ ...(mm.forkOf ? { forkOf: peerAddress(cfg, self, mm.forkOf) } : {}),
2747
+ ...(mm.artifacts?.length ? { artifacts: mm.artifacts } : {}),
2748
+ })),
1821
2749
  })),
1822
2750
  });
1823
2751
  });
@@ -1826,7 +2754,7 @@ const main = async () => {
1826
2754
  // (injectText), 所以 pane 死了会被拉起来, 输出照常落进群和详情页。
1827
2755
  /** 日程归谁: 排班那个 wizard (createdBy); 老文件没写就归执行它的那个。 */
1828
2756
  const ownerOf = (t) => t.owner || (cfg.defaultChat ?? "");
1829
- http.register("POST /tasks/schedule", async (req, res) => {
2757
+ http.register("POST /tasks/schedule", guarded(async (req, res) => {
1830
2758
  const { self, body } = await readPeerBody(req);
1831
2759
  if (!self) {
1832
2760
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
@@ -1865,7 +2793,7 @@ const main = async () => {
1865
2793
  const fresh = typeof b.fresh === "boolean" ? b.fresh : !tag || promptWantsFreshWizard(prompt);
1866
2794
  const note = (b.note ?? "").toString();
1867
2795
  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 });
2796
+ const created = tasks.create({ id, trigger, prompt, target, fresh, quiet: b.quiet === true, note, createdBy: self });
1869
2797
  if ("error" in created) {
1870
2798
  json(res, 400, { ok: false, reason: created.error });
1871
2799
  return;
@@ -1877,7 +2805,7 @@ const main = async () => {
1877
2805
  address: peerAddress(cfg, self, target),
1878
2806
  owner: peerAddress(cfg, self, self),
1879
2807
  });
1880
- });
2808
+ }));
1881
2809
  http.register("POST /tasks/list", async (req, res) => {
1882
2810
  const { self, body } = await readPeerBody(req);
1883
2811
  if (!self) {
@@ -1920,17 +2848,199 @@ const main = async () => {
1920
2848
  }
1921
2849
  json(res, 200, { ok: true, removed: renderTask(gone, state) });
1922
2850
  });
1923
- // POST /config/set — modify daemon config from MCP
1924
- http.register("POST /config/set", async (req, res) => {
1925
- const body = (await readBody(req));
1926
- const r = configSet(cfg, sourcePath, body.key, body.value, body.action);
1927
- json(res, r.ok ? 200 : 400, r);
1928
- });
2851
+ // 确认卡在请求里最多等 4 分钟 (留出 fetch 5 分钟收头的余量), 卡本身半小时有效。
2852
+ const CONFIRM_INLINE_MS = 240_000;
2853
+ const CONFIRM_TIMEOUT_MS = 30 * 60_000;
2854
+ // computeUsage 要扫一周的 transcript, 一分钟内的连问共用一份。
2855
+ let usageMemo;
2856
+ const tierUsage = () => {
2857
+ if (usageMemo && Date.now() - usageMemo.at < 60_000)
2858
+ return usageMemo.u;
2859
+ const r = computeUsage();
2860
+ const fold = (m) => new Map([...m].map(([id, x]) => [id, { tokens: sumTotal(x), cost: costOf(id, x) }]));
2861
+ usageMemo = { at: Date.now(), u: { today: fold(r.today), week: fold(r.week) } };
2862
+ return usageMemo.u;
2863
+ };
2864
+ const configEvidence = (p) => tierEvidence(cfg.models, p, tierUsage, (tier) => wizards.all().filter((w) => w.tier === tier).length, { tokens: fmtTokens, cost: fmtCost });
2865
+ // ── 改配置会不会动到宪章 ─────────────────────────────────────────
2866
+ // 宪章是 --append-system-prompt 压进去的, 随进程终身: 改了进宪章的配置, 已在跑的 wizard
2867
+ // 看不见, 要 handoff 重开 (重新渲染宪章) 才换上。不按字段清单判 —— 宪章读哪些配置随代码变,
2868
+ // 清单迟早漏; 改前改后各渲染一遍有会话的 wizard 的宪章, 变了的就是受影响的。
2869
+ const charterSections = (text) => new Map(text.split(/^(?=## )/m).map((sec) => [sec.split("\n", 1)[0].replace(/^## /, ""), sec]));
2870
+ const changedSections = (a, b) => {
2871
+ const x = charterSections(a), y = charterSections(b);
2872
+ return [...new Set([...x.keys(), ...y.keys()])].filter((k) => x.get(k) !== y.get(k));
2873
+ };
2874
+ /** pane 活着的 wizard —— 宪章在效的只有它们; 冷的醒来时宪章重渲染, 自然是新的。一次 list-panes 答完。 */
2875
+ const liveWizards = async () => {
2876
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
2877
+ const live = new Set(snap.ok ? snap.stdout.split("\n").map((l) => l.trim()).filter(Boolean) : []);
2878
+ return wizards.all().map((w) => w.target).filter((t) => live.has(m.sessionInfo(t)?.tmuxPane ?? ""));
2879
+ };
2880
+ /** 这些 wizard 此刻的宪章正文。 */
2881
+ const charterSnapshot = (targets) => new Map(targets.map((t) => [t, charterText(t, charterOptsOf(t, m.getCwd(t).runningCwd || undefined), true)]));
2882
+ /** 把 `next` 那一节换进活的 cfg 渲染一遍再换回 —— 同步完成, 中间没有 await, 别的模块看不见这一瞬。
2883
+ * reload 项落盘后活值不变, 只有这样才算得出它 reload 之后的宪章。 */
2884
+ const charterUnder = (pl, targets) => {
2885
+ const live = cfg;
2886
+ const key = pl.path[0];
2887
+ const saved = live[key];
2888
+ live[key] = patchedTop(cfg, pl);
2889
+ try {
2890
+ return charterSnapshot(targets);
2891
+ }
2892
+ finally {
2893
+ live[key] = saved;
2894
+ }
2895
+ };
2896
+ const charterImpact = (before, pl) => {
2897
+ const after = charterUnder(pl, [...before.keys()]);
2898
+ return [...before].flatMap(([t, a]) => { const secs = changedSections(a, after.get(t) ?? a); return secs.length ? [{ target: t, sections: secs }] : []; });
2899
+ };
2900
+ /** 受影响的 wizard 各挂一条提醒 (随它下一条消息到); 给调用方的那段话。调用方自己受影响就直说要它 handoff。 */
2901
+ const announceCharterImpact = (pl, hits, self) => {
2902
+ if (!hits.length)
2903
+ return "";
2904
+ const when = pl.apply === "reload" ? "等守护进程 reload 之后再" : "";
2905
+ const ask = `${when}调 \`handoff({brief})\` 原地重开 (手上这件告一段落就交; 宪章在 handoff 时重新生成)`;
2906
+ const others = hits.filter((h) => h.target !== self);
2907
+ others.forEach((h) => notices.post([h.target], `\`config_set ${pl.path.join(".")}\` 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}`));
2908
+ const mine = hits.find((h) => h.target === self);
2909
+ return [
2910
+ "",
2911
+ `⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${hits.length} 个: ${hits.map((h) => displayName(h.target)).join(" ")}`,
2912
+ ...(others.length ? [`已给其余 ${others.length} 个各挂一条提醒 (随它们下一条消息到), 让它们${ask}`] : []),
2913
+ ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}`] : []),
2914
+ ].join("\n");
2915
+ };
2916
+ // POST /config/get —— 新 MCP 带 `path` (可空) 拿渲染好的文本; 老 MCP 带 `key` 拿 {key, value}。
1929
2917
  http.register("POST /config/get", async (req, res) => {
1930
2918
  const body = (await readBody(req));
1931
- const r = configGet(cfg, body.key);
2919
+ if (typeof body.key === "string") {
2920
+ const r = legacyGet(cfg, body.key);
2921
+ json(res, r.ok ? 200 : 400, r);
2922
+ return;
2923
+ }
2924
+ const r = configGet(cfg, sourcePath, body.path, configEvidence);
1932
2925
  json(res, r.ok ? 200 : 400, r);
1933
2926
  });
2927
+ // chatPolicy 的键一律落成 base principal: 聊天名会改, 拿它当键一改名策略就悄悄失效。写名字照收,
2928
+ // 换成它此刻指向的 principal; 认不出的拒写 (写错的键静默无效, 比报错难查)。unset 一个盘上原样存在的
2929
+ // 键不换 —— 留给清理老写法 / 失效的键。
2930
+ const pinChatKeys = (req) => {
2931
+ const p = parsePath(req.path);
2932
+ if (p[0] !== "chatPolicy")
2933
+ return req;
2934
+ const bad = (k) => ({ ok: false, reason: `认不出聊天 \`${k}\`: 键写 base principal (\`chat:wr…\` / \`user:…\`) 或聊天名 (/chats)` });
2935
+ if (p.length === 1) {
2936
+ if (req.value === undefined || typeof req.value !== "object" || Array.isArray(req.value))
2937
+ return req;
2938
+ const pairs = Object.entries(req.value).map(([k, v]) => [k, chatBaseOf(cfg, k), v]);
2939
+ const miss = pairs.find(([, b]) => !b);
2940
+ return miss ? bad(miss[0]) : { ...req, value: Object.fromEntries(pairs.map(([, b, v]) => [b, v])) };
2941
+ }
2942
+ const key = p[1];
2943
+ if (req.op === "unset" && p.length === 2 && key in cfg.chatPolicy)
2944
+ return req;
2945
+ const base = chatBaseOf(cfg, key);
2946
+ return base ? { ...req, path: ["chatPolicy", base, ...p.slice(2)].join(".") } : bad(key);
2947
+ };
2948
+ // POST /config/set —— 口令门见 `guarded`。分级在这里而不在 hook: `card` 项由守护进程
2949
+ // 自己推确认卡, 所以 danger.skipAll / ⏱窗口 都放不掉它。卡等 CONFIRM_INLINE_MS 还没人点
2950
+ // 就先回 pending (fetch 默认 5 分钟收不到头就断), 卡仍有效, 迟到的确认照常落盘并捎一句给调用方。
2951
+ http.register("POST /config/set", guarded(async (req, res) => {
2952
+ const body = (await readBody(req));
2953
+ const legacy = typeof body.key === "string";
2954
+ const asked = legacy ? fromLegacy(body.key, body.value, body.action) : body;
2955
+ if ("ok" in asked) {
2956
+ json(res, 400, asked);
2957
+ return;
2958
+ }
2959
+ const ask = pinChatKeys(asked);
2960
+ if ("ok" in ask) {
2961
+ json(res, 400, ask);
2962
+ return;
2963
+ }
2964
+ const reply = (pl, text, extra = {}) => json(res, 200, legacy
2965
+ ? { ok: true, key: body.key, before: pl.before, after: pl.after, ...extra }
2966
+ : { ok: true, text, ...extra });
2967
+ const pl = planSet(cfg, sourcePath, ask);
2968
+ if (!pl.ok) {
2969
+ json(res, 400, pl);
2970
+ return;
2971
+ }
2972
+ if (ask.dryRun || !pl.changed) {
2973
+ reply(pl, `${ask.dryRun ? "dryRun, 没写:\n" : ""}${renderPlan(pl)}`);
2974
+ return;
2975
+ }
2976
+ // 落盘时重算一遍: 等卡期间文件可能被别人改过, 按那一刻的文件打补丁、再校验。
2977
+ // 宪章快照取在落盘之前: hot 项一 applyHot, 活的 cfg 就已经是新的了。
2978
+ // 不用 resolveSelf: 它最后回落到 defaultChat, 会把认不出的调用方当成那个群的管家。
2979
+ const caller = (body.sessionId?.trim() && m.targetForSession(body.sessionId.trim())) || (body.tmuxPane?.trim() && m.targetForPane(body.tmuxPane.trim())) || undefined;
2980
+ const commit = async () => {
2981
+ const targets = await liveWizards();
2982
+ // 从这里到 applyHot 之间不能有 await: 快照与落盘之间别人的 config_set 插进来, 前后就对不上了。
2983
+ const now = planSet(cfg, sourcePath, ask);
2984
+ if (!now.ok)
2985
+ return now;
2986
+ const before = charterSnapshot(targets);
2987
+ patchJsonc(sourcePath, [{ path: now.jsonPath, value: now.value }]);
2988
+ applyHot(cfg, now);
2989
+ // 已经落盘了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
2990
+ try {
2991
+ return { ...now, charter: announceCharterImpact(now, charterImpact(before, now), caller) };
2992
+ }
2993
+ catch (e) {
2994
+ log.warn({ err: errText(e), path: now.path.join(".") }, "config_set: 宪章影响比对失败");
2995
+ return { ...now, charter: `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的配置, 受影响的 wizard 要 handoff 才换上)` };
2996
+ }
2997
+ };
2998
+ const done = (now) => `已写入${now.apply === "hot" ? ", 已生效" : "; 需 reload (`./cli/wezard.sh reload`) 才生效"}:\n${renderPlan(now)}${now.charter}`;
2999
+ if (pl.gate !== "card") {
3000
+ const now = await commit();
3001
+ if (!now.ok) {
3002
+ json(res, 400, now);
3003
+ return;
3004
+ }
3005
+ reply(now, done(now));
3006
+ return;
3007
+ }
3008
+ const self = resolveSelf(body);
3009
+ if (!self) {
3010
+ json(res, 400, { ok: false, reason: "cannot resolve caller session; 这一项要发确认卡给人点" });
3011
+ return;
3012
+ }
3013
+ let decided;
3014
+ try {
3015
+ ({ decided } = await requestConfirm({
3016
+ client: ws.client, principal: self, who: displayName(self),
3017
+ title: `配置变更待确认 · ${pl.path.join(".")}`,
3018
+ body: `${displayName(self)} 要改配置:\n${renderPlan(pl)}`,
3019
+ timeoutMs: CONFIRM_TIMEOUT_MS,
3020
+ }));
3021
+ }
3022
+ catch (e) {
3023
+ json(res, 503, { ok: false, reason: `确认卡发不出去 (${errText(e)}), 没写` });
3024
+ return;
3025
+ }
3026
+ const settle = async (yes) => {
3027
+ if (!yes)
3028
+ return `人没确认 (拒绝或超时), \`${pl.path.join(".")}\` 没写`;
3029
+ const now = await commit();
3030
+ return now.ok ? done(now) : `人确认了, 但落盘前重算没过, 没写: ${now.reason}`;
3031
+ };
3032
+ const inline = await Promise.race([decided, sleep(CONFIRM_INLINE_MS).then(() => undefined)]);
3033
+ if (inline !== undefined) {
3034
+ const text = await settle(inline);
3035
+ json(res, 200, legacy ? { ok: inline, key: body.key, before: pl.before, after: inline ? pl.after : pl.before, note: text } : { ok: true, text, confirmed: inline });
3036
+ return;
3037
+ }
3038
+ void decided.then(async (yes) => notices.post([self], `config_set 的确认卡有结果了: ${await settle(yes)}`))
3039
+ .catch((e) => log.warn({ err: errText(e) }, "config_set: 迟到的确认落盘失败"));
3040
+ json(res, 200, legacy
3041
+ ? { ok: false, reason: "确认卡已发到群里, 还没人点; 点了之后自动落盘" }
3042
+ : { ok: true, pending: true, text: `确认卡已发到群里, 还没人点 (卡 ${CONFIRM_TIMEOUT_MS / 60_000} 分钟内有效)。点了之后守护进程自动落盘, 结果会捎在你下一条收到的消息上 —— 不用重发。\n${renderPlan(pl)}` });
3043
+ }));
1934
3044
  // POST /graph/run — declare a loop graph over this chat's tagged sessions
1935
3045
  // and start walking it. Fire-and-forget: returns a runId immediately, then
1936
3046
  // narrates progress into the chat while it advances.
@@ -1955,6 +3065,9 @@ const main = async () => {
1955
3065
  return;
1956
3066
  }
1957
3067
  const graphLog = log.child({ mod: "graph", base: spec.base });
3068
+ // 每一步、每条进度都在发起这次 run 的那一轮的频道里: 人在群里叫它发起的才进群,
3069
+ // 私聊轮里发起的整场只进 rolepage —— 步骤不带频道就等于在节点的 home 群里开口。
3070
+ const channel = m.currentChannel(self);
1958
3071
  const run = startGraph(spec, {
1959
3072
  // Reuse a live tagged pane; only spawn when the node doesn't exist yet
1960
3073
  // (or its pane died). Re-spawning a healthy node would throw away the
@@ -1969,10 +3082,11 @@ const main = async () => {
1969
3082
  const r = await m.newSession(target, node.tag, node.cli, { model: node.model, cwd: node.cwd, silent: true });
1970
3083
  return { ok: r.ok, reason: r.reason };
1971
3084
  },
1972
- send: async (target, text, origin) => (await handedOff(target), m.injectText(target, text, origin)),
3085
+ send: async (target, text, origin) => (await handedOff(target), m.injectText(target, text, origin, { channel })),
1973
3086
  isBusy: m.isBusy,
1974
3087
  lastText: async (target) => m.lastText(target),
1975
- notify: notifyChat,
3088
+ notify: (_base, markdown) => { if (channel)
3089
+ notifyChat(channel, markdown); },
1976
3090
  log: graphLog,
1977
3091
  });
1978
3092
  json(res, 200, { ok: true, runId: run.runId, base: run.base, nodes: spec.nodes.length, steps: spec.steps.length, rounds: spec.rounds ?? 1 });
@@ -2027,12 +3141,74 @@ const main = async () => {
2027
3141
  // 还要连触发时间点一起被它那一轮拖走。所以到点起一个白板 wizard 单独执行,
2028
3142
  // 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
2029
3143
  // 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
2030
- scheduledTaskInject = async (target, text, { taskId, fresh }) => {
3144
+ scheduledTaskInject = async (target, text, { taskId, fresh, quiet }) => {
3145
+ // 安静任务私下跑 (channel ""): 镜像不推它的任何一句, 终句由 relayUnlessQuiet 判断要不要进群。
3146
+ const inNamed = async () => {
3147
+ const at = Date.now();
3148
+ const turn = newTurn();
3149
+ const inj = await m.injectText(target, text, undefined, quiet
3150
+ ? { from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
3151
+ : { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
3152
+ if (inj.ok && quiet)
3153
+ void relayUnlessQuiet(target, at, turn, baseOfKey(target));
3154
+ return inj;
3155
+ };
2031
3156
  if (!fresh)
2032
3157
  await handedOff(target); // 交接中途 pane 是空的, 别当成闲着投进去
2033
3158
  const busy = fresh ? false : await m.isBusy(target);
2034
3159
  if (!fresh && !busy)
2035
- return m.injectText(target, text, undefined, { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
3160
+ return inNamed();
3161
+ // 点名的目标正忙: 顺延到它这一轮结束再投 (与 tell_peer 默认的 normal 同一口径) ——
3162
+ // 「在它那儿继续」是排班时要求的, 不该因为撞上它在干活就换成白板。后台等, 别拖住
3163
+ // 调度器的其它任务; 等满 30 分钟仍忙才退回起白板。
3164
+ const inRunner = (wasBusy) => runTaskInRunner(target, text, taskId, quiet, wasBusy);
3165
+ if (!fresh && busy) {
3166
+ // 同一条任务已有一枪在等这个目标: 这一枪并进去 —— 等它闲下来时几枪一起投, 会挤进同一个输入框。
3167
+ if (postponed.has(taskId))
3168
+ return { ok: true, reason: "上一枪还在等它闲下来, 这一枪并入" };
3169
+ postponed.add(taskId);
3170
+ void (async () => {
3171
+ const wr = await m.untilIdle(target, TASK_POSTPONE_MS);
3172
+ postponed.delete(taskId);
3173
+ const r = wr.idle ? await inNamed() : await inRunner(true);
3174
+ log.info({ taskId, target, postponed: true, idle: wr.idle, ok: r.ok, reason: r.reason }, "scheduled task: 目标正忙, 顺延");
3175
+ if (!r.ok)
3176
+ notifyChat(baseOfKey(target), withTagHeader(target, `⏰ 定时任务 \`${taskId}\` 顺延后注入失败: ${r.reason ?? "unknown"}`));
3177
+ })();
3178
+ return { ok: true, reason: "目标正忙, 顺延到它闲下来" };
3179
+ }
3180
+ return inRunner(busy);
3181
+ };
3182
+ /** 顺延中的任务 (内存即可: reload 丢了只是少放一枪, 下一次到点照常)。 */
3183
+ const postponed = new Set();
3184
+ /** 安静任务那一轮的终句: 它私下跑, 镜像不推; 等它这一轮跑完, 终句不是 `QUIET` 就替它
3185
+ * 发进群 (头写它的名字, 人知道是谁说的)。按信封件号定位答句 (同回执): 按时刻取会把紧随
3186
+ * 其后的私聊 / 人话那一轮的答案当成它的, 把私下内容发进群。 */
3187
+ const relayUnlessQuiet = async (t, at, turn, base) => {
3188
+ quietRelays.set(turn, { target: t, at, base });
3189
+ const end = at + TASK_QUIET_WAIT_MS;
3190
+ // 先等它动起来: 刚注入时注册表还报 idle。
3191
+ await sleep(5000);
3192
+ while (Date.now() < end) {
3193
+ await m.untilIdle(t, end - Date.now());
3194
+ const reply = (m.replyToPeer(t, "", at, turn)?.text ?? "").trim();
3195
+ if (reply && (await m.idleNow(t))) {
3196
+ if (!isQuiet(reply))
3197
+ notifyChat(base, withTagHeader(t, reply));
3198
+ log.info({ target: t, quiet: isQuiet(reply), len: reply.length }, "quiet task: 一轮结束");
3199
+ quietRelays.drop(turn);
3200
+ return;
3201
+ }
3202
+ await sleep(5000);
3203
+ }
3204
+ log.warn({ target: t }, "quiet task: 等满仍没收口, 不转发");
3205
+ quietRelays.drop(turn);
3206
+ };
3207
+ // 落盘: 这个仓库里 reload 是常态, 守在内存里的那一轮一 reload 就既不转发也不说 QUIET 了。
3208
+ 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)));
3209
+ void m.restored.then(() => Object.entries(quietRelays.all()).forEach(([turn, r]) => void relayUnlessQuiet(r.target, r.at, turn, r.base)));
3210
+ /** 到点起一个白板 wizard 执行这一枪, 跑完自动收掉。 */
3211
+ const runTaskInRunner = async (target, text, taskId, quiet, busy) => {
2036
3212
  // 执行体挂在日程的主人名下: 名字 `<主人>-task-xxxx`, parent = 主人 —— 它的
2037
3213
  // rolepage 里看得见这一枪是谁的日程放的。落在执行目标的群/目录里。
2038
3214
  const base = baseOfKey(target);
@@ -2056,10 +3232,16 @@ const main = async () => {
2056
3232
  wizards.drop(runner);
2057
3233
  return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
2058
3234
  }
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) });
3235
+ if (!quiet) {
3236
+ notifyChat(base, withTagHeader(target, busy
3237
+ ? `⏰ 定时任务到点时正忙, 已起白板 wizard .${runnerName} 单独执行, 完成后自动收掉`
3238
+ : `⏰ 定时任务已起白板 wizard .${runnerName} 执行, 完成后自动收掉`));
3239
+ }
3240
+ const at = Date.now();
3241
+ const turn = newTurn();
3242
+ const inj = await m.injectText(runner, text, undefined, quiet
3243
+ ? { from: { kind: "task", taskId }, channel: "", envelope: renderTaskEnvelope(taskId, { turn }) }
3244
+ : { fromChat: true, from: { kind: "task", taskId }, envelope: renderTaskEnvelope(taskId) });
2063
3245
  if (!inj.ok) {
2064
3246
  wizards.drop(runner);
2065
3247
  return inj;
@@ -2068,6 +3250,9 @@ const main = async () => {
2068
3250
  // 内没闲下来就放弃自动回收 (它大概率还在干一个长活), 留给人手动处理 ——
2069
3251
  // 比杀掉一个还在跑的 pane 安全。
2070
3252
  void (async () => {
3253
+ // 安静任务先把终句取走 (收掉 pane 之后就读不到了)。
3254
+ if (quiet)
3255
+ await relayUnlessQuiet(runner, at, turn, base);
2071
3256
  const idle = await waitForIdle(runner, m.isBusy, 30 * 60_000, () => false);
2072
3257
  if (!idle.idle) {
2073
3258
  log.warn({ runner, reason: idle.reason }, "task runner: 30min 未闲下来, 放弃自动回收");
@@ -2078,6 +3263,66 @@ const main = async () => {
2078
3263
  })();
2079
3264
  return inj;
2080
3265
  };
3266
+ // 记忆整理者的一轮: 在内部 key 上起白板 wizard, 私聊注入, 闲下来就收 —— 全程不碰
3267
+ // 任何群 (silent spawn、channel ""、无 notifyChat)。它没有人能点审批卡, 所以这一轮
3268
+ // 给它的 key 开一个 ⏱ 窗口; 危险名单照旧逐条要卡, 而它的活 (读收件箱、改 md) 不在其列。
3269
+ stewardRun = async (prompt) => {
3270
+ const target = STEWARD_TARGET;
3271
+ wizards.upsert(target, { description: "共享记忆整理的一次性执行体 (守护进程内建, 不属于任何群)", bornAt: Date.now() });
3272
+ const name = wizards.rename(target, "memsteward-run");
3273
+ setAutoWindow(target, STEWARD_RUN_MS + 5 * 60_000);
3274
+ const done = async (r) => {
3275
+ await m.killPane(target).catch(() => undefined);
3276
+ wizards.drop(target);
3277
+ clearAutoWindow(target);
3278
+ return r;
3279
+ };
3280
+ const spawned = await m.newSession(target, name, undefined, { cwd: memoryRoot(cfg.daemon.stateDir), silent: true, keepalive: false });
3281
+ if (!spawned.ok)
3282
+ return done({ ok: false, reason: `spawn: ${spawned.reason ?? "unknown"}` });
3283
+ const inj = await m.injectText(target, prompt, undefined, { from: { kind: "task", taskId: STEWARD_ID }, channel: "", envelope: stewardEnvelope() });
3284
+ if (!inj.ok)
3285
+ return done(inj);
3286
+ // 超时也收: 没人会对它喊 stop_wizard; 认领留着, 孤儿回收会再派一轮。
3287
+ const idle = await waitForIdle(target, m.isBusy, STEWARD_RUN_MS, () => false);
3288
+ return done(idle.idle ? { ok: true } : { ok: false, reason: idle.reason });
3289
+ };
3290
+ // 整理者与在场 wizard 之间的两根线 (memory.md ①②): 合并前给它看 CLAUDE.md /
3291
+ // auto-memory 好去重, 合并后告诉读这份 md 的 wizard —— 它们的宪章是出生时的快照。
3292
+ // md ↔ wizard 一律正向算 (mdsOf / cwdOfMd), 不从文件名反推。
3293
+ const stateDir = cfg.daemon.stateDir;
3294
+ // 全量绑定 (含冷的), 与 settleAll 同一份 —— 不依赖身份记录在不在。
3295
+ const boundTargets = () => m.chatRoster("").flatMap((c) => c.targets).filter((t) => !isInternalKey(t));
3296
+ const cwdOfTarget = (t) => { const c = m.getCwd(t); return c.runningCwd || c.defaultCwd; };
3297
+ // 人和各家 CLI 自己读的那几份项目说明 + 各 backend 的 auto-memory 索引。
3298
+ const PROJECT_DOCS = ["CLAUDE.md", "CODEBUDDY.md", "AGENTS.md", join(".claude", "CLAUDE.md")];
3299
+ stewardWiring = {
3300
+ refsOf: (md) => {
3301
+ // cwd 候选: 提议里带的 (提议者可能早收工了) + 在场绑定的; 再正向算出 md 比对。
3302
+ const cwds = [...new Set([...proposedCwds(stateDir), ...boundTargets().map(cwdOfTarget)].filter(Boolean))];
3303
+ const cwd = cwdOfMd(stateDir, md, cwds);
3304
+ if (!cwd)
3305
+ return [];
3306
+ return [...PROJECT_DOCS.map((f) => join(cwd, f)), ...projectDirsFor(cwd).map((d) => join(d.dir, "memory", "MEMORY.md"))].filter((f) => existsSync(f));
3307
+ },
3308
+ // 只投给 pane 活着的: 冷的那些醒来时宪章重渲染, 新记忆已在里面, 再提一句就重复了。
3309
+ onMerged: (mds) => void (async () => {
3310
+ const changed = new Set(mds);
3311
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
3312
+ if (!snap.ok)
3313
+ return;
3314
+ const live = new Set(snap.stdout.split("\n").map((l) => l.trim()).filter(Boolean));
3315
+ for (const t of boundTargets()) {
3316
+ const pane = m.sessionInfo(t)?.tmuxPane;
3317
+ if (!pane || !live.has(pane))
3318
+ continue;
3319
+ const where = (scope) => (scope === "workspace" ? "本工作区" : baseOfKey(t).startsWith("user:") ? "本单聊" : "本群");
3320
+ for (const h of mdsOf(stateDir, baseOfKey(t), cwdOfTarget(t)).filter((x) => changed.has(x.md))) {
3321
+ notices.post([t], `${where(h.scope)}记忆已更新, 全文见 \`${h.md}\``);
3322
+ }
3323
+ }
3324
+ })().catch((e) => log.warn({ err: e.message }, "memory steward: notice fan-out failed")),
3325
+ };
2081
3326
  }
2082
3327
  // 定时调度器 — 每 20s 检查任务表, 到点把 prompt 注入目标 wizard。
2083
3328
  migrateLegacySchedules(cfg, sourcePath, tasks, log.child({ mod: "tasks" }));
@@ -2090,6 +3335,10 @@ const main = async () => {
2090
3335
  cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
2091
3336
  fallbackTarget: () => (cfg.defaultChat ?? "").trim(),
2092
3337
  });
3338
+ // 共享记忆整理 — daemon 内建, 不进任务表 / 日程, 不出任何气泡。
3339
+ const steward = stewardRun
3340
+ ? startSteward({ root: memoryRoot(cfg.daemon.stateDir), log: log.child({ mod: "steward" }), run: stewardRun, ...stewardWiring })
3341
+ : undefined;
2093
3342
  // pane 上限 — 每新建一个 wizard 数一次: 活着的会话 pane 超过 wrc.mirror.maxPanes
2094
3343
  // (默认 20, 0=关) 就从最久没动的收起。数量只在出生时增长, 所以不设定时器。只收
2095
3344
  // pane 不收绑定: 下一条消息 `--resume` 复活。豁免: 名下有定时任务的 wizard (调度
@@ -2114,6 +3363,7 @@ const main = async () => {
2114
3363
  const shutdown = async (signal) => {
2115
3364
  log.info({ signal }, "shutdown signal");
2116
3365
  scheduler.stop();
3366
+ steward?.stop();
2117
3367
  tasks.stop();
2118
3368
  netWatch.stop();
2119
3369
  // 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。