wezard 2.3.0 → 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +5 -5
  3. package/config.example.jsonc +12 -0
  4. package/dist/daemon/approval.js +103 -15
  5. package/dist/daemon/approval.js.map +1 -1
  6. package/dist/daemon/chat-name.js +15 -0
  7. package/dist/daemon/chat-name.js.map +1 -1
  8. package/dist/daemon/config-api.js +281 -70
  9. package/dist/daemon/config-api.js.map +1 -1
  10. package/dist/daemon/danger.js +5 -7
  11. package/dist/daemon/danger.js.map +1 -1
  12. package/dist/daemon/detail.js +10 -2
  13. package/dist/daemon/detail.js.map +1 -1
  14. package/dist/daemon/handoff.js +4 -3
  15. package/dist/daemon/handoff.js.map +1 -1
  16. package/dist/daemon/idle-nudge.js +55 -0
  17. package/dist/daemon/idle-nudge.js.map +1 -0
  18. package/dist/daemon/inbound.js +17 -14
  19. package/dist/daemon/inbound.js.map +1 -1
  20. package/dist/daemon/index.js +957 -225
  21. package/dist/daemon/index.js.map +1 -1
  22. package/dist/daemon/jobs.js +122 -13
  23. package/dist/daemon/jobs.js.map +1 -1
  24. package/dist/daemon/mirror-bridge.js +97 -84
  25. package/dist/daemon/mirror-bridge.js.map +1 -1
  26. package/dist/daemon/model-select.js +3 -0
  27. package/dist/daemon/model-select.js.map +1 -1
  28. package/dist/daemon/notices.js +172 -33
  29. package/dist/daemon/notices.js.map +1 -1
  30. package/dist/daemon/peers.js +105 -78
  31. package/dist/daemon/peers.js.map +1 -1
  32. package/dist/daemon/pending-items.js +171 -0
  33. package/dist/daemon/pending-items.js.map +1 -0
  34. package/dist/daemon/receipts.js +58 -24
  35. package/dist/daemon/receipts.js.map +1 -1
  36. package/dist/daemon/route.js +76 -15
  37. package/dist/daemon/route.js.map +1 -1
  38. package/dist/daemon/spawn-tmux.js +2 -2
  39. package/dist/daemon/spawn-tmux.js.map +1 -1
  40. package/dist/daemon/steward-guard.js +32 -0
  41. package/dist/daemon/steward-guard.js.map +1 -0
  42. package/dist/daemon/top-only-nudge.js +29 -0
  43. package/dist/daemon/top-only-nudge.js.map +1 -0
  44. package/dist/daemon/wizard.js +114 -61
  45. package/dist/daemon/wizard.js.map +1 -1
  46. package/dist/mcp/server.js +75 -25
  47. package/dist/mcp/server.js.map +1 -1
  48. package/dist/shared/chat-http.js +23 -13
  49. package/dist/shared/chat-http.js.map +1 -1
  50. package/dist/shared/config-meta.js +91 -0
  51. package/dist/shared/config-meta.js.map +1 -0
  52. package/dist/shared/config-writer.js +5 -6
  53. package/dist/shared/config-writer.js.map +1 -1
  54. package/dist/shared/config.js +279 -294
  55. package/dist/shared/config.js.map +1 -1
  56. package/dist/shared/detail-render.js +121 -32
  57. package/dist/shared/detail-render.js.map +1 -1
  58. package/dist/shared/detail-store.js.map +1 -1
  59. package/dist/shared/keepalive.js +5 -5
  60. package/dist/shared/keepalive.js.map +1 -1
  61. package/dist/shared/role-render.js +52 -12
  62. package/dist/shared/role-render.js.map +1 -1
  63. package/dist/shared/role-view.js +84 -26
  64. package/dist/shared/role-view.js.map +1 -1
  65. package/dist/shared/world.js +82 -13
  66. package/dist/shared/world.js.map +1 -1
  67. package/hooks/pre-tool-use.sh +14 -12
  68. package/package.json +1 -1
  69. package/web/chat.css +63 -37
  70. package/web/chat.js +513 -283
@@ -5,21 +5,24 @@ import { timingSafeEqual } from "node:crypto";
5
5
  import { loadOrCreateSvrToken } from "../shared/svr-token.js";
6
6
  import { DAEMON_TOKEN_FILE, DAEMON_TOKEN_HEADER } from "../shared/daemon-token.js";
7
7
  import { homedir } from "node:os";
8
- import { loadConfig } from "../shared/config.js";
8
+ import { TIERS, loadConfig } from "../shared/config.js";
9
9
  import { makeLogger } from "../shared/log.js";
10
- import { clipLine, sleep } from "../shared/std.js";
10
+ import { clipLine, sleep, truncate } from "../shared/std.js";
11
+ import { patchJsonc } from "../shared/config-writer.js";
11
12
  import { EFFORTS, parseEffort } from "../shared/effort.js";
12
13
  import { bindCliBackends, projectDirsFor } from "../shared/cli-backends.js";
13
14
  import { startWs } from "./ws.js";
14
15
  import { startNetWatch } from "./net-watch.js";
15
16
  import { startHttp, json, readBody } from "./http.js";
16
- 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";
17
20
  import { installInboundRouter } from "./inbound.js";
18
21
  import { loadMirrorStore } from "./mirror-store.js";
19
22
  import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
20
23
  import { runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
21
- import { installApprovalEventListener, makeApproveHandler } from "./approval.js";
22
- import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, recordPost, recordCharter, backfillCharter } from "./detail.js";
24
+ import { installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
25
+ import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, currentHumanQueryOf, recordPost, recordCharter, backfillCharter } from "./detail.js";
23
26
  import { EMPTY_FACTS } from "../shared/world.js";
24
27
  import { clearAutoWindow, initAutoWindowPersistence, setAutoWindow } from "./session-cache.js";
25
28
  import { makeMessageHandler } from "./outbound.js";
@@ -36,18 +39,22 @@ import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
36
39
  import { baseOfKey, bindTagLinker, isInternalKey, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
37
40
  import { appendEpisode, clipForCharter, cwdOfMd, episodePath, inboxPath, mdsOf, proposedCwds, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
38
41
  import { retireStewardTask, startSteward, stewardEnvelope, STEWARD_ID, STEWARD_RUN_MS, STEWARD_TARGET } from "./memory-steward.js";
39
- import { applyChatNames, chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
40
- import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, backfillBorn, sweepUnborn, BOOT_ID, evictStaleName, childrenOf, ancestorsOf, renderCharter, ROSTER_FRESH_MS, renderRoster, handoffAt, memoryNudgeAt, MEMORY_NUDGE_FLOOR, MEMORY_MAX, NOTE_MAX, } from "./wizard.js";
41
- import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.js";
42
- import { loadJobStore, jobEpisode, rejectReason, ACCEPTS, JOB_MEMBER_MAX } from "./jobs.js";
43
- import { cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, lastContextTokens, lastExchange, lastModel, openingOf, replyClosedBefore, talkTurns, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
42
+ import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
43
+ import { createTopOnlyNudge, gcNudge, policyKeyOf, renderNudge } from "./top-only-nudge.js";
44
+ import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, backfillBorn, sweepUnborn, BOOT_ID, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderLead, renderLeadPlaybook, ROSTER_FRESH_MS, renderRoster, handoffAt, memoryNudgeAt, MEMORY_NUDGE_FLOOR, MEMORY_MAX, NOTE_MAX, } from "./wizard.js";
45
+ import { bindNoticeBox, createNoticeBox, createSeenRing, gcSeen, chatAudience, hears } from "./notices.js";
46
+ import { createLedger, digest, pendingGc, renderDigest, renderTable, renderAwaiting, renderStalled, renderShelved, ago as agoMs, HEARD_EVERY_MS } from "./pending-items.js";
47
+ import { loadJobStore, dutyLine, awaitingAccept, stalledRoots, shelvedRoots, nudgeDue, dayClamp, childrenOf as jobChildren, jobEpisode, rejectReason, ACCEPTS, JOB_MEMBER_MAX, JOB_DEPTH_MAX, depthOf, ancestorsOf as jobAncestors, treeOrder, jobStage } from "./jobs.js";
48
+ import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, lastContextTokens, lastExchange, lastModel, openingTurn, replyClosedBefore, talkTurns, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
44
49
  import { keepalivePingSigs } from "../shared/keepalive.js";
45
50
  import { expandHome } from "../shared/paths.js";
46
51
  import { loadJsonMap } from "../shared/json-map-store.js";
47
- import { createReceipts, deadlineOf, kAttr, newTurn } from "./receipts.js";
52
+ import { createReceipts, deadlineOf, kAttr, kOfAttr, newTurn } from "./receipts.js";
48
53
  import { renderInFlight } from "../shared/turn-state.js";
54
+ import { startIdleNudge } from "./idle-nudge.js";
55
+ import { renderReminder } from "../shared/reminder.js";
49
56
  import { createHandoffs, handedOff, handingOff } from "./handoff.js";
50
- import { rankCandidates, renderCandidates, wakeNoteOf } from "./route.js";
57
+ import { coldGateOf, decide, rankCandidates, renderCandidates, tierFor, wakeNoteOf } from "./route.js";
51
58
  import { parseWhen, renderChatLog, UNKNOWN_HUMAN } from "./chat-log.js";
52
59
  import { audienceOf } from "../shared/role-view.js";
53
60
  import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
@@ -128,7 +135,9 @@ const main = async () => {
128
135
  });
129
136
  // 人话也过交接的闸 (见 handoff.ts): 交接期间说的话进新会话, 不被旧会话带走、也不在
130
137
  // 重开的空档里 resume 回旧 sid。
131
- installInboundRouter(ws.client, cfg, log, { ...bridge, dispatch: async (a) => (await handedOff(a.principal), bridge.dispatch(a)) }, sourcePath);
138
+ // 宪章守卫 (见「改配置会不会动到宪章」): 要等下面宪章渲染那一块立起来才有; 之前 (启动时那次自动补名) 只写不比对。
139
+ let guardCharter = (write) => ({ r: write(), note: Promise.resolve("") });
140
+ installInboundRouter(ws.client, cfg, log, { ...bridge, dispatch: async (a) => (await handedOff(a.principal), bridge.dispatch(a)) }, sourcePath, (w, c) => guardCharter(w, c));
132
141
  // approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
133
142
  // 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
134
143
  // 工作单独成块, 比让 stream 一直长到下一个 inbound / hardTimer 更清晰。
@@ -150,9 +159,10 @@ const main = async () => {
150
159
  const t = bridge.targetForSession(sid);
151
160
  return t ? await bridge.interruptPane(t) : { ok: false, reason: "no mirror target for session" };
152
161
  },
162
+ // 告知那一句接着被取消的那一轮说: 频道照它的走 —— 不带频道就等于在 home 群里开口。
153
163
  tell: async (sid, text) => {
154
164
  const t = bridge.targetForSession(sid);
155
- return t ? await bridge.injectText(t, text) : { ok: false, reason: "no mirror target for session" };
165
+ return t ? await bridge.injectText(t, text, undefined, { channel: bridge.currentChannel(t) }) : { ok: false, reason: "no mirror target for session" };
156
166
  },
157
167
  };
158
168
  const http = startHttp({ cfg, ws, log, sourcePath });
@@ -398,13 +408,8 @@ const main = async () => {
398
408
  const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive, silent: true });
399
409
  if (!r.ok && !existed)
400
410
  wizards.drop(target);
401
- // r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
402
- // 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
403
- const modelNote = r.model
404
- ? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
405
- : "";
406
411
  if (r.ok)
407
- postRoster(base, [target, self], `新 wizard **.${name}** 就位${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— 空白起步, 由 ${displayName(self)} 造的`);
412
+ postRoster(base, [target, self], { op: "born", name: `.${name}`, by: displayName(self), cwd: r.cwd }, subjectOf(wizards.get(target)));
408
413
  json(res, r.ok ? 200 : 500, r.ok
409
414
  ? {
410
415
  ok: true,
@@ -546,24 +551,31 @@ const main = async () => {
546
551
  const raw = (body.name ?? "").toString().trim();
547
552
  // "-" is the same erase gesture `/name -` uses in the chat — one verb,
548
553
  // one meaning, whether a human or an agent performs it.
554
+ // 聊天名进宪章: 过宪章守卫, 受影响的 wizard 被提醒 handoff, 调用方自己受影响的那段话放在 `charter`。
555
+ const byHuman = !!askerOf(self)?.who.startsWith("user:");
556
+ const charterOf = async (note) => ((t) => (t ? { charter: t } : {}))((await note).trim());
549
557
  if (raw === "-") {
550
- const gone = clearChatName(cfg, sourcePath, self);
551
- json(res, 200, { ok: true, base: baseOfKey(self), name: "", previous: gone });
558
+ const { r: gone, note } = guardCharter(() => clearChatName(cfg, sourcePath, self), (g) => g ? { what: `\`name_chat\` 取消了聊天名 \`${g}\``, self, byHuman } : undefined);
559
+ json(res, 200, { ok: true, base: baseOfKey(self), name: "", previous: gone, ...(await charterOf(note)) });
552
560
  return;
553
561
  }
554
562
  const previous = chatNameOf(cfg, self);
555
- const r = setChatName(cfg, sourcePath, self, raw);
563
+ const home = baseOfKey(self);
564
+ const { r, note } = guardCharter(() => {
565
+ const r = setChatName(cfg, sourcePath, self, raw);
566
+ if (!r.ok)
567
+ return r;
568
+ // 默认 wizard 的名字取自聊天名; 它还叫旧聊天名 (没被单独改过) 就跟着改。
569
+ const cur = wizards.get(home)?.name ?? "";
570
+ return { ...r, wizardRenamed: previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined };
571
+ }, (r) => r.ok && r.name !== previous ? { what: `\`name_chat\` 把聊天${previous ? ` \`${previous}\`` : ""}改名为 \`${r.name}\``, self, byHuman } : undefined);
556
572
  if (!r.ok) {
557
573
  json(res, 409, { ok: false, reason: r.reason });
558
574
  return;
559
575
  }
560
- // 默认 wizard 的名字取自聊天名; 它还叫旧聊天名 (没被单独改过) 就跟着改。
561
- const home = baseOfKey(self);
562
- const cur = wizards.get(home)?.name ?? "";
563
- const wizardRenamed = previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined;
564
- if (wizardRenamed)
576
+ if (r.wizardRenamed)
565
577
  void m.retitlePane(home);
566
- json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(wizardRenamed ? { wizardRenamed } : {}) });
578
+ json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(r.wizardRenamed ? { wizardRenamed: r.wizardRenamed } : {}), ...(await charterOf(note)) });
567
579
  });
568
580
  const readPeerBody = async (req) => {
569
581
  const body = (await readBody(req));
@@ -605,6 +617,17 @@ const main = async () => {
605
617
  /** 一个 wizard 在群里的称呼: `emoji .name`, 挂它的 rolepage。名字全局唯一, 不再
606
618
  * 因为落在哪个群而换写法 —— 引用这一段就能跟它说话 (parseTagHeader 认得)。 */
607
619
  const relayLabel = (t, _dest) => tagLink(t, tagHead(t));
620
+ // 没开顶层模式的群里公开气泡多了 → 往那个群的管家 (群默认会话, target 就是 base principal) 信箱挂一行,
621
+ // 让它问人要不要开顶层模式 (见 top-only-nudge.ts)。路径里写聊天名: 管家照抄就能 config_set。
622
+ const topOnlyNudge = createTopOnlyNudge({
623
+ store: loadJsonMap("~/.wezard/top-only-nudge.json", gcNudge),
624
+ policy: () => ((p) => ({ windowMs: p.windowMin * 60_000, threshold: p.threshold, cooldownMs: p.cooldownH * 3_600_000 }))(cfg.wrc.mirror.topOnlyNudge),
625
+ nudge: (chat, n) => {
626
+ const p = cfg.wrc.mirror.topOnlyNudge;
627
+ notices.post([chat], renderNudge(policyKeyOf(chatNameOf(cfg, chat), chat), n, p.windowMin, p.cooldownH));
628
+ log.info({ chat, n }, "top-only nudge: 已提醒管家问人要不要开顶层模式");
629
+ },
630
+ });
608
631
  /** 公开的 wizard 间对话: 落在 `channel` 这个群。 */
609
632
  const relayPeer = (from, to, body, channel) => {
610
633
  const text = body.trim();
@@ -615,6 +638,8 @@ const main = async () => {
615
638
  // 头独占一行, 正文自成一个块 —— 只隔一个换行的话, markdown 会把正文首行
616
639
  // 当成头那一段的续行; 表格因此整张塌成一行带竖线的文字 (表格不能打断段落)。
617
640
  notifyChat(channel, `${head}\n\n${clipped}`);
641
+ if (!chatPolicyOf(cfg, channel).topOnly)
642
+ topOnlyNudge.count(baseOfKey(channel));
618
643
  };
619
644
  /** 调用方这一轮所在的公开频道: 人从哪个群叫的它 / 公开 peer 轮的那个群;
620
645
  * 私聊轮或无记录 → 它的 home 群。notify 与 public send_peer 默认发到这里。 */
@@ -633,8 +658,11 @@ const main = async () => {
633
658
  const id = audienceOf(channel, asker);
634
659
  return id === "human:" ? "" : id.slice("human:".length);
635
660
  };
661
+ // 挂起事项表 (见 pending-items.ts): 派活时记一行, 回执落定时由下面的 onOutcome 记账。
662
+ const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc));
636
663
  const receipts = createReceipts({
637
664
  idleNow: m.idleNow,
665
+ onOutcome: (s, status, bodyText, bySender) => ledger.outcome(s.from, s.turn ?? "", status, parseClosing(bodyText).text || bodyText, bySender),
638
666
  jobTally: (id) => jobs.tally(id),
639
667
  parkedNow: m.parkedNow,
640
668
  settleJob: (id, target, outcome, artifacts) => { jobs.settle(id, target, outcome, artifacts); },
@@ -658,7 +686,7 @@ const main = async () => {
658
686
  // 只有 tell_peer 那条路登记 watcher, 这里是直接注入。
659
687
  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 } : {}) },
660
688
  channel: meta.channel,
661
- envelope: renderReceiptEnvelope(displayName(meta.from), meta.channel ? chatNameOf(cfg, meta.channel) : undefined, meta.job ? { job: meta.job, done: meta.done, total: meta.total, closed: jobs.get(meta.job)?.status !== "open" } : undefined, meta.status, meta.turn || undefined, {
689
+ 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, {
662
690
  ...(meta.replyTo ? { replyTo: meta.replyTo.kind === "chat" ? chatNameOf(cfg, meta.replyTo.channel) : displayName(meta.replyTo.from) } : {}),
663
691
  ...(meta.k ? { k: kAttr(meta.k) } : {}),
664
692
  ...(meta.pending ? { pending: meta.pending } : {}),
@@ -678,15 +706,19 @@ const main = async () => {
678
706
  isBusy: m.isBusy,
679
707
  sessionId: (t) => m.sessionInfo(t)?.sessionId ?? "",
680
708
  restart: (t) => restartFresh(t),
681
- // 欠着回执的发话方挂上它们的信封: 新会话那一轮就是在答它们, 回执照常按信封定位,
682
- // 频道也接回原来那一句所在的地方 (私聊不进群)。
683
- inject: (t, text, owe) => owe.length
684
- ? m.injectText(t, text, undefined, {
685
- from: { kind: "peer", from: owe[0].from, ...(owe[0].turn ? { turn: owe[0].turn } : {}), ...(owe[0].asker ? { asker: owe[0].asker } : {}) },
686
- channel: owe[0].channel,
687
- envelope: owe.map((o) => envelopeFor(o.from, o.channel, o.turn ? { turn: o.turn } : undefined)).join(""),
688
- })
689
- : m.injectText(t, text),
709
+ // 欠着回执的发话方挂上它的信封: 新会话那一轮就是在答它, 回执照常按信封定位, 频道也接回
710
+ // 原来那一句所在的地方 (私聊不进群)。欠好几份时每份各开一轮 (见 carryRest): 并成一句,
711
+ // 那一轮就只有一个去向, 别的上游拿到的是答给第一份的话。
712
+ inject: async (t, text, owe, channel) => {
713
+ const [first, ...rest] = owe;
714
+ if (!first)
715
+ return m.injectText(t, text, undefined, { channel });
716
+ const r = await m.injectText(t, text, undefined, carryOpts(first));
717
+ if (r.ok && rest.length)
718
+ void carryRest(t, rest);
719
+ return r;
720
+ },
721
+ channelOf: m.currentChannel,
690
722
  lastText: m.lastText,
691
723
  answeredBefore: (to, from, since, until, turn) => {
692
724
  const p = m.sessionInfo(to)?.jsonlPath;
@@ -707,6 +739,24 @@ const main = async () => {
707
739
  log.warn({ target: e.target, name }, "handoff episode not archived");
708
740
  },
709
741
  });
742
+ /** 交接续答一份欠账的那一句: 挂发话方的信封, 出处与频道都是它当初那一句的。 */
743
+ function carryOpts(o) {
744
+ return {
745
+ from: { kind: "peer", from: o.from, ...(o.turn ? { turn: o.turn } : {}), ...(o.channel ? { public: true } : {}), ...(o.asker ? { asker: o.asker } : {}) },
746
+ channel: o.channel,
747
+ envelope: envelopeFor(o.from, o.channel, o.turn ? { turn: o.turn } : undefined),
748
+ };
749
+ }
750
+ /** 简报那一轮之后, 余下的欠账一份一轮: 等它接住上一句 (至多 20s 动起来) 并答完, 再贴下一份。 */
751
+ async function carryRest(t, owe) {
752
+ for (const o of owe) {
753
+ for (const end = Date.now() + 20_000; Date.now() < end && (await m.idleNow(t));)
754
+ await sleep(1000);
755
+ await m.untilIdle(t, 3600_000);
756
+ const r = await m.injectText(t, `(交接续答)接着上面的简报: 你交接前还欠 ${displayName(o.from)} 一个交代${o.turn ? ` (件 \`${o.turn}\`)` : ""} —— 现在只就这件收口。`, undefined, carryOpts(o));
757
+ log.info({ mod: "handoff", target: t, from: o.from, turn: o.turn, ok: r.ok, reason: r.reason }, "handoff: 欠账续答");
758
+ }
759
+ }
710
760
  /** `self` 此刻这一轮派出去的活, 结论该交给谁 (见 receipts.parentOf)。只读它自己
711
761
  * transcript 里开这一轮的那句 user 行的信封: attachment 上的 channel 在注入时就被
712
762
  * 排队的下一句改写了, transcript 才记着「这一轮是谁发起的」, reload 后也读得到。 */
@@ -714,7 +764,7 @@ const main = async () => {
714
764
  /** `target` 手上这一轮若是某个 wizard 派来的活: 发话方的称呼 (`.x`) 与件号。人 / 定时 / 回执轮 = undefined。 */
715
765
  const openingAsk = (target) => {
716
766
  const p = m.sessionInfo(target)?.jsonlPath;
717
- const env = p ? openingOf(talkTurns(expandHome(p), 80, pingSigs, false, true))?.env : undefined;
767
+ const env = p ? openingTurn(expandHome(p), pingSigs)?.env : undefined;
718
768
  return env?.kind === "peer" && !env.receipt ? { from: env.from, ...(env.turn ? { turn: env.turn } : {}) } : undefined;
719
769
  };
720
770
  const currentAsk = (target) => {
@@ -729,7 +779,7 @@ const main = async () => {
729
779
  if (await m.idleNow(target))
730
780
  return undefined;
731
781
  const p = m.sessionInfo(target)?.jsonlPath;
732
- const env = p ? openingOf(talkTurns(expandHome(p), 80, pingSigs, false, true))?.env : undefined;
782
+ const env = p ? openingTurn(expandHome(p), pingSigs)?.env : undefined;
733
783
  if (env?.receipt)
734
784
  return undefined;
735
785
  if (env?.kind === "peer")
@@ -738,11 +788,12 @@ const main = async () => {
738
788
  };
739
789
  const openingOfSelf = (self) => {
740
790
  const p = m.sessionInfo(self)?.jsonlPath;
741
- return p ? openingOf(talkTurns(expandHome(p), 80, pingSigs, false, true)) : undefined;
791
+ return p ? openingTurn(expandHome(p), pingSigs) : undefined;
742
792
  };
743
793
  const parentKOf = (self) => {
744
794
  const opening = openingOfSelf(self);
745
- return receipts.parentOf(self, opening?.env, channelOf(self), String(opening?.ms ?? 0));
795
+ // 不用 channelOf: 它把私聊轮 ("") 回落成 home, 私聊轮派出的活就会续回 home 群。
796
+ return receipts.parentOf(self, opening?.env, m.currentChannel(self), String(opening?.ms ?? 0));
746
797
  };
747
798
  /** `self` 此刻这一轮的链头 (见 Asker), 同样认开头那句的信封: 人在群里说的 → 信封上的
748
799
  * userid 与群; 与人的单聊里没挂信封 → 那个人; 同伴派的活 / 回执 → 登记那件活时记下的;
@@ -760,6 +811,29 @@ const main = async () => {
760
811
  };
761
812
  // 交接先续做: 它的闸要在回执续守之前立起来, 否则续守的 watcher 会读到换了一半的会话。
762
813
  void m.restored.then(() => { handoffs.resume(); receipts.resume(); });
814
+ // 闲置追问 (见 idle-nudge.ts): 对方持续闲着又没回执 → 代发话方同件号问一句进展, 答复照常回执。
815
+ // 注入照抄工单打回 (rebound) 那条: 私聊信封挂发话方的名义与件号, 父 k 等由 reask 沿用原件。
816
+ void m.restored.then(() => startIdleNudge({
817
+ inflight: () => receipts.states(),
818
+ idleNow: m.idleNow,
819
+ handingOff,
820
+ jobOpen: (id) => jobs.get(id)?.status === "open",
821
+ policy: () => ({ afterMs: cfg.wrc.mirror.idleNudge.afterMin * 60_000, max: cfg.wrc.mirror.idleNudge.max }),
822
+ ask: async ({ from, to, turn, text }) => {
823
+ const x = receipts.reask(from, to, turn);
824
+ if (!x)
825
+ return false;
826
+ ledger.touch(from, turn);
827
+ return (await m.injectText(to, text, undefined, {
828
+ from: { kind: "peer", from, turn, ...(x.asker ? { asker: x.asker } : {}) },
829
+ channel: x.channel,
830
+ envelope: envelopeFor(from, x.channel, { turn, legs: x.legs, re: true }),
831
+ })).ok;
832
+ },
833
+ nameOf: displayName,
834
+ store: loadJsonMap(join(cfg.daemon.stateDir, "idle-nudges.json")),
835
+ log: log.child({ mod: "idle-nudge" }),
836
+ }));
763
837
  /** 入参里的地址: 新字段 `name`, 老 MCP 进程 (正在跑的 wizard) 仍在传 `tag`。 */
764
838
  const addrOf = (b) => String(b.name ?? b.tag ?? "");
765
839
  /** 撞上的名字属于一个静默超过一天的 wizard → 顶掉它 (见 evictStaleName), 同群的
@@ -770,7 +844,7 @@ const main = async () => {
770
844
  return false;
771
845
  const slot = !!tagOfKey(gone.target);
772
846
  log.child({ mod: "wizard" }).info({ target: gone.target, name: gone.name, slot }, "stale name evicted");
773
- postRoster(baseOfKey(gone.target), [], `**.${gone.name}** 静默超过一天, ${slot ? "已被同名的新 wizard 顶掉并收工" : "名字让给了同名的新 wizard"}`);
847
+ postRoster(baseOfKey(gone.target), [], { op: "ended", name: `.${gone.name}`, why: slot ? "evicted (idle >1d, name reused)" : "name yielded (idle >1d)" }, subjectOf(gone));
774
848
  return true;
775
849
  };
776
850
  /** 给一个要出生的 wizard 挑槽位 (target key) 并落定名字。名字全局唯一:
@@ -846,6 +920,16 @@ const main = async () => {
846
920
  return;
847
921
  }
848
922
  const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
923
+ // 顶层模式: 只有顶层 wizard 对人说话。拦的是「这一轮在为同伴干活」的: 同伴私聊派来的活, 或
924
+ // 回执轮但那件活是为上游同伴派的 (k 是 peer)。人说的、定时任务放的、旁支 (chain:false, 无 k)
925
+ // 的回执轮都不拦; 管家只在自己的群里豁免。
926
+ const env = openingOfSelf(self)?.env;
927
+ const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
928
+ const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
929
+ if (muted.length && belowTop) {
930
+ json(res, 409, { ok: false, topOnly: true, reason: `${muted.map((d) => chatNameOf(cfg, d) || d).join(", ")} 开着顶层模式: 你这一轮是同伴派来的活, 不对人说话 —— 要说的写进终句 (\`RESULT:\` / \`NEED:\`), 由派你的那个代你向人交代` });
931
+ return;
932
+ }
849
933
  const asker = askerOf(self);
850
934
  for (const dest of dests) {
851
935
  notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
@@ -971,16 +1055,23 @@ const main = async () => {
971
1055
  // 等待」: 调用方注入完就返回, 对方干完那一轮的结论由守护进程自动送回来 (receipts)。
972
1056
  // `/peers/send` 保留为同一个处理函数: 正在跑的 wizard 的 MCP 进程是旧代码, 换名字
973
1057
  // 不能把它们的通路掐断。
974
- const tellPeer = async (req, res) => {
975
- const { self, body } = await readPeerBody(req);
976
- if (!self) {
977
- json(res, 400, { ok: false, reason: "cannot resolve caller session" });
978
- return;
979
- }
1058
+ // 冷与否 = 缓存最近一次被碰 (最近一次真实请求的开始; 读不到退回 mtime) 距今多久 vs 它实际按多长 TTL 写的
1059
+ // (与 route_candidates 同一口径)。
1060
+ const touchOf = (p) => { try {
1061
+ return cacheTouchMs(p) || statSync(p).mtimeMs;
1062
+ }
1063
+ catch {
1064
+ return 0;
1065
+ } };
1066
+ const ttlOf = (p) => (cacheTtlSec(p) || cfg.wrc.mirror.keepalive.ttlSec) * 1000;
1067
+ // 被冷门控退回过的 `self\0target` → 到期时刻: 老 MCP 进程原样重发即视为 force。
1068
+ const coldRefused = new Map();
1069
+ /** tell_peer 的本体: 以 `self` 的名义说一句 —— HTTP 路由与 dispatch 共用。`extra` = 挂在信封后面的
1070
+ * 一段 (只进对方输入框, 不进 `text`: 公开时 text 会成群气泡)。 */
1071
+ const tellPeerAs = async (self, body, extra = "") => {
980
1072
  const text = (body.text ?? "").toString();
981
1073
  if (!text.trim()) {
982
- json(res, 400, { ok: false, reason: "text required" });
983
- return;
1074
+ return { status: 400, body: { ok: false, reason: "text required" } };
984
1075
  }
985
1076
  // 这句话要对方怎么接 (priority 管何时投, receipt 管要不要回执, 这个管它怎么接):
986
1077
  // task 一件活 (默认) / ask 只答一问 / fyi 知会 —— fyi 不要回执、不进工单、不进群。
@@ -988,8 +1079,7 @@ const main = async () => {
988
1079
  const kind = rawKind === "ask" || rawKind === "fyi" ? rawKind : "task";
989
1080
  const r = resolvePeer(self, addrOf(body));
990
1081
  if (!r.ok) {
991
- json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
992
- return;
1082
+ return { status: r.status, body: { ok: false, reason: r.reason, candidates: r.candidates } };
993
1083
  }
994
1084
  const { target, foreign } = r;
995
1085
  // 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
@@ -1001,12 +1091,16 @@ const main = async () => {
1001
1091
  const open = receipts.pending(self, target);
1002
1092
  const isOpen = (id) => (id && jobs.get(id)?.status === "open" ? id : "");
1003
1093
  const askedJob = (body.job ?? "").trim();
1004
- const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || isOpen(open?.job);
1094
+ // 挂起等子活的那份不算被顶掉: 它会按件号停放、自己计数, 新的一件不继承它的工单。
1095
+ // 返工兜底: 回执槽 24h 过期 / 被顶掉后, 带了 re 却落空成新件, 不带 job 就和根单断了 ——
1096
+ // 只在这种情形下, 发话方是某张开着的需求根单的 owner、目标恰是它的 lead 时才补上;
1097
+ // 没带 re 的普通 task / ask / dispatch 一律不并进根单。
1098
+ const leadJob = jobs.all().find((j) => j.kind === "req" && j.status === "open" && j.owner === self && j.members.some((x) => x.target === target && x.role === "lead"))?.id ?? "";
1099
+ const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || (open?.deferred ? "" : isOpen(open?.job)) || (re && turn.reUnknown ? leadJob : "");
1005
1100
  if (jobId) {
1006
1101
  const jc = checkJob(jobId, target, !!open?.need && open.turn === turn.turn);
1007
1102
  if (!jc.ok) {
1008
- json(res, jc.status, { ok: false, reason: jc.reason });
1009
- return;
1103
+ return { status: jc.status, body: { ok: false, reason: jc.reason } };
1010
1104
  }
1011
1105
  }
1012
1106
  // 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
@@ -1015,13 +1109,16 @@ const main = async () => {
1015
1109
  // 掉的分身不是谁, 它们的往来进群只会留下一段人找不到主的对话 —— 降为私聊。
1016
1110
  // 工单里的派活一律私聊: 人看不懂 fan-out 的过程, 群里只留发起者自己的收口。
1017
1111
  // 知会不进群: 公开信封要对方「照对人说话的方式答」, 一句「收到」会被发给一群人。
1018
- const isPublic = !jobId && kind !== "fyi" && (turn.channel !== undefined ? !!turn.channel : body.public === true) && !!wizards.get(self) && !!wizards.get(target);
1112
+ // 顶层模式 (chatPolicy.topOnly): 那个群里 wizard 之间不当着人说话 —— 公开与否不归模型判断,
1113
+ // 续问沿用的公开频道也一样降为私聊; 人只从顶层 wizard 的收口里听到结论。
1114
+ const wantPublic = !jobId && kind !== "fyi" && (turn.channel !== undefined ? !!turn.channel : body.public === true) && !!wizards.get(self) && !!wizards.get(target);
1115
+ const topOnly = wantPublic && chatPolicyOf(cfg, turn.channel || channelOf(self)).topOnly;
1116
+ const isPublic = wantPublic && !topOnly;
1019
1117
  const channel = isPublic ? turn.channel || channelOf(self) : "";
1020
1118
  // Injecting into your own pane would type into the box you're generating
1021
1119
  // from — Claude Code queues it and the caller deadlocks waiting for itself.
1022
1120
  if (target === self) {
1023
- json(res, 400, { ok: false, reason: "refusing to inject into the calling session itself" });
1024
- return;
1121
+ return { status: 400, body: { ok: false, reason: "refusing to inject into the calling session itself" } };
1025
1122
  }
1026
1123
  // 它正在交接: 这句话等它换完会话再进 —— 投进旧会话会被交接一并带走, 投进重开中的
1027
1124
  // 空 pane 会 resume 回旧 sid (见 handoff.ts)。发话时刻取在这之后。
@@ -1040,6 +1137,23 @@ const main = async () => {
1040
1137
  : bp.when === "idle" ? "normal"
1041
1138
  : re ? "now" : "normal";
1042
1139
  const waitSec = Math.min(Math.max(Number(body.waitSec ?? 600) || 600, 10), 3600);
1140
+ // 冷门控: 派新活 (task、不是续问、不是插话) 给一个冷且大的 wizard, 先退回一行让派活的
1141
+ // 算账 —— 唤醒它要整段重写缓存, 点名或职责对口只说明相关, 不说明划算。`force:true`
1142
+ // 是「真依赖它那段上下文」的明说。老 MCP 进程的 schema 里没有 force (请求里压根没这个键,
1143
+ // 新的一律显式带上): 同一对 10 分钟内原样再发一次就算 force, 不然它们被拦下就没有出路。
1144
+ const force = body.force;
1145
+ if (kind === "task" && !re && asked !== "now" && force !== true) {
1146
+ const gk = `${self}\0${target}`;
1147
+ const retried = force === undefined && (coldRefused.get(gk) ?? 0) > Date.now();
1148
+ coldRefused.delete(gk);
1149
+ const gi = retried ? undefined : m.sessionInfo(target);
1150
+ const gate = gi ? coldGateOf(`.${displayName(target).replace(/^\./, "")}`, gi.contextTokens, touchOf(gi.jsonlPath), Date.now(), ttlOf(gi.jsonlPath)) : "";
1151
+ if (gate) {
1152
+ if (force === undefined)
1153
+ coldRefused.set(gk, Date.now() + 10 * 60_000);
1154
+ return { status: 409, body: { ok: false, gated: "cold", reason: gate + (force === undefined ? " (你的 MCP 是旧版、没有 force 参数: 10 分钟内原样再发一次即视为 force)" : "") } };
1155
+ }
1156
+ }
1043
1157
  // 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
1044
1158
  const wasBusy = !(await m.idleNow(target));
1045
1159
  // urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
@@ -1054,8 +1168,7 @@ const main = async () => {
1054
1168
  const wr = await m.untilIdle(target, waitSec * 1000);
1055
1169
  waitedMs = Date.now() - t0;
1056
1170
  if (!wr.idle) {
1057
- json(res, 409, { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪; 真急就 priority:"urgent" 打断它` });
1058
- return;
1171
+ return { status: 409, body: { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪; 真急就 priority:"urgent" 打断它` } };
1059
1172
  }
1060
1173
  }
1061
1174
  if (policy === "urgent" && wasBusy) {
@@ -1072,12 +1185,7 @@ const main = async () => {
1072
1185
  // 唤醒代价同样要在注入之前量: 投进去 transcript 一动, 就看不出它冷了多久。
1073
1186
  const info = m.sessionInfo(target);
1074
1187
  const wake = info?.jsonlPath
1075
- ? wakeNoteOf(info.contextTokens, (() => { try {
1076
- return statSync(info.jsonlPath).mtimeMs;
1077
- }
1078
- catch {
1079
- return 0;
1080
- } })(), at, (cacheTtlSec(info.jsonlPath) || cfg.wrc.mirror.keepalive.ttlSec) * 1000)
1188
+ ? wakeNoteOf(info.contextTokens, touchOf(info.jsonlPath), at, ttlOf(info.jsonlPath))
1081
1189
  : "";
1082
1190
  const deadlineSec = body.deadline;
1083
1191
  // 回执: 对方干完那一轮, 守护进程把它的结论自动送回来 (默认开)。`receipt:false`
@@ -1096,7 +1204,7 @@ const main = async () => {
1096
1204
  ...(deadlineSec !== undefined ? { deadline: deadlineAt } : {}),
1097
1205
  ...(kind !== "task" ? { act: kind } : {}),
1098
1206
  ...(wantReceipt ? {} : { quiet: true }),
1099
- }),
1207
+ }) + extra,
1100
1208
  });
1101
1209
  // 注入失败就不守 —— 没有问话, 也不会有回答。不要回执 (receipt:false / fyi) 就不登记:
1102
1210
  // 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
@@ -1105,39 +1213,61 @@ const main = async () => {
1105
1213
  const k = body.chain === false ? undefined : parentKOf(self);
1106
1214
  if (inj.ok && wantReceipt)
1107
1215
  receipts.register({ from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
1216
+ if (inj.ok && wantReceipt) {
1217
+ if (turn.legs > 1)
1218
+ ledger.touch(self, turn.turn);
1219
+ else
1220
+ ledger.open({ turn: turn.turn, owner: self, to: target, text, at, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
1221
+ }
1108
1222
  // 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
1109
1223
  // 续问 (re) 不是一段新活: 工单页与留档里该列的仍是当初派的那段。
1110
- if (inj.ok && jobId)
1111
- jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false });
1224
+ if (inj.ok && jobId) {
1225
+ const role = roleParam(body.role);
1226
+ // G2: 需求根单的 owner 答了 lead 的 NEED —— 方案闸过了 (只记第一次)。
1227
+ const rj = jobs.get(jobId);
1228
+ 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"))
1229
+ jobs.mark(jobId, { gate: { g2: Date.now() } });
1230
+ jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
1231
+ }
1112
1232
  const spent = inj.ok && jobId ? jobs.spend(jobId) : undefined;
1113
1233
  if (inj.ok && isPublic)
1114
1234
  relayPeer(self, target, text, channel);
1115
1235
  // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
1116
1236
  // 它这一轮后面, 而不是马上被读到。
1117
- json(res, inj.ok ? 200 : 502, {
1118
- ...inj,
1119
- name: peerAddress(cfg, self, target),
1120
- public: isPublic,
1121
- priority: policy,
1122
- ...(kind !== "task" ? { kind } : {}),
1123
- ...(kind === "fyi" && askedJob ? { jobIgnored: true } : {}),
1124
- ...(others ? { urgentDowngraded: `它此刻在做 ${others.from} 派的活, 没打断它 —— 按 normal 等它这一轮做完再投` } : {}),
1125
- wasBusy,
1126
- ...(interrupted !== undefined ? { interrupted } : {}),
1127
- ...(waitedMs ? { waitedMs } : {}),
1128
- ...(jobId ? { job: jobId } : {}),
1129
- ...(inj.ok ? { turn: turn.turn } : {}),
1130
- ...(spent?.maxTurns ? { budget: `${spent.turns}/${spent.maxTurns}${spent.turns >= spent.maxTurns ? " —— 用完了, 这是最后一次派活, 下一步收口" : ""}` } : {}),
1131
- ...(inj.ok && wake ? { wakeCost: wake } : {}),
1132
- ...(turn.reUnknown ? { reUnknown: true, reNote: `re "${re}" 不是你正在等 ${displayName(target)} 答的那件, 按新活发出` } : {}),
1133
- // 回执怎么回来 —— 写在回包里, 调用方 (模型) 不必从工具描述里回忆。
1134
- ...(inj.ok && wantReceipt
1135
- ? { receipt: "它干完那一轮, 结论会作为新的一轮自动进到你这里 —— 不要挂在 wait_peer 上等, 接着干你自己的事" }
1136
- : {}),
1137
- ...(inj.ok && wantReceipt && k?.kind === "peer"
1138
- ? { chained: `这件活算在你答 ${displayName(k.from)} 的那件 (${k.turn}) 名下: 它回来之前, 你这一轮的终句不会先交给 ${displayName(k.from)}; 与那件活无关就带 chain:false 发` }
1139
- : {}),
1140
- });
1237
+ return { status: inj.ok ? 200 : 502, body: {
1238
+ ...inj,
1239
+ name: peerAddress(cfg, self, target),
1240
+ public: isPublic,
1241
+ ...(topOnly ? { topOnly: "这个群开着顶层模式: wizard 之间一律私聊, 已按私聊投出; 结论回到你这儿, 由你 (或你的上游) 对人收口" } : {}),
1242
+ priority: policy,
1243
+ ...(kind !== "task" ? { kind } : {}),
1244
+ ...(kind === "fyi" && askedJob ? { jobIgnored: true } : {}),
1245
+ ...(others ? { urgentDowngraded: `它此刻在做 ${others.from} 派的活, 没打断它 —— 按 normal 等它这一轮做完再投` } : {}),
1246
+ wasBusy,
1247
+ ...(interrupted !== undefined ? { interrupted } : {}),
1248
+ ...(waitedMs ? { waitedMs } : {}),
1249
+ ...(jobId ? { job: jobId } : {}),
1250
+ ...(inj.ok ? { turn: turn.turn } : {}),
1251
+ ...(spent?.maxTurns ? { budget: `${spent.turns}/${spent.maxTurns}${spent.turns >= spent.maxTurns ? " —— 用完了, 这是最后一次派活, 下一步收口" : ""}` } : {}),
1252
+ ...(inj.ok && wake ? { wakeCost: wake } : {}),
1253
+ ...(turn.reUnknown ? { reUnknown: true, reNote: `re "${re}" 不是你正在等 ${displayName(target)} 答的那件, 按新活发出` } : {}),
1254
+ // 回执怎么回来 —— 写在回包里, 调用方 (模型) 不必从工具描述里回忆。
1255
+ ...(inj.ok && wantReceipt
1256
+ ? { receipt: "它干完那一轮, 结论会作为新的一轮自动进到你这里 —— 不要挂在 wait_peer 上等, 接着干你自己的事" }
1257
+ : {}),
1258
+ ...(inj.ok && wantReceipt && k?.kind === "peer"
1259
+ ? { chained: `这件活算在你答 ${displayName(k.from)} 的那件 (${k.turn}) 名下: 它回来之前, 你这一轮的终句不会先交给 ${displayName(k.from)}; 与那件活无关就带 chain:false 发` }
1260
+ : {}),
1261
+ } };
1262
+ };
1263
+ const tellPeer = async (req, res) => {
1264
+ const { self, body } = await readPeerBody(req);
1265
+ if (!self) {
1266
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1267
+ return;
1268
+ }
1269
+ const r = await tellPeerAs(self, body);
1270
+ json(res, r.status, r.body);
1141
1271
  };
1142
1272
  http.register("POST /peers/tell", tellPeer);
1143
1273
  http.register("POST /peers/send", tellPeer); // 老 MCP 进程还在调这个名字
@@ -1306,8 +1436,120 @@ const main = async () => {
1306
1436
  nudged.add(i.sessionId);
1307
1437
  return [`你的上下文已到 ${Math.round(i.contextTokens / 1000)}k, 快到自动压缩了: 这段里学到的、值得跨会话活下来的东西, 先 \`wizard_remember\` 记下 (自己的选 self, 属于群 / 仓库的选 chat / workspace), 再考虑 \`handoff\``];
1308
1438
  };
1309
- const notices = bindNoticeBox(createNoticeBox(12, memoryNudge));
1310
- const postRoster = (base, except, line) => notices.post(chatAudience(m.chatTargets(base), base, except), line);
1439
+ // 越过交接判断线 (handoffAt) 也只提一次: 交接不是压缩, 不该等快满。同样每段会话一次 ——
1440
+ // 交接后换了 sid, 新会话再越线会再提。
1441
+ const handoffNudged = new Set();
1442
+ const handoffNudge = (t) => {
1443
+ const i = m.sessionInfo(t);
1444
+ if (!i?.sessionId || handoffNudged.has(i.sessionId) || i.contextTokens < handoffAt(lastModel(i.jsonlPath) || i.model))
1445
+ return [];
1446
+ handoffNudged.add(i.sessionId);
1447
+ return [`你的上下文已到 ${Math.round(i.contextTokens / 1000)}k: 该判断要不要 \`handoff\` 了 —— 手上这摊活告一段落、或往后的活不再依赖前面的大段材料, 就交接 (值得跨会话留下的先 \`wizard_remember\`); 还在一件离不开它们的活中间, 做完这一段再交`];
1448
+ };
1449
+ // 挂起事项的提醒 (见 pending-items.digest): 只给管家 —— 各群的默认会话, 同时挂着多件活、
1450
+ // 又被人随时打断的就是它。上次全表时的样子纯内存: reload 后头一次见到只立基线。
1451
+ const pendingMarks = new Map();
1452
+ /** 上次注入之后被 /clear 或压缩截断过的 (onContextCut 记, 下一次注入取走)。 */
1453
+ const pendingCuts = new Map();
1454
+ const liveOfTurns = () => {
1455
+ let byTurn;
1456
+ return (turn) => (byTurn ??= new Map(receipts.states().map((x) => [x.turn, x]))).get(turn);
1457
+ };
1458
+ /** 管家等人验收的根单行 (需求已交付、单还开着)。 */
1459
+ const stalledRows = (owner) => stalledRoots(jobs.all(), owner, jobLive()).map(({ job, status }) => renderStalled(job.title, job.id, status));
1460
+ const awaitingRows = (owner, now) => awaitingAccept(jobs.all(), owner, jobLive()).map(({ job, at }) => renderAwaiting(job.title, job.id, at, now));
1461
+ const shelvedRows = (owner, now) => shelvedRoots(jobs.all(), owner).map((j) => renderShelved(j.title, j.id, j.hold ?? j.openedAt, now));
1462
+ const pendingNames = { nameOf: displayName, chatOf: (b) => chatNameOf(cfg, b) || b };
1463
+ /** 人在某个群最近一次开口 (内存; reload 丢了就当没说过): 重试冒泡避开人在场的时候。 */
1464
+ const humanAt = new Map();
1465
+ /** 「人这句可能在验收某张」同一张至多隔 HEARD_EVERY_MS 提一次。 */
1466
+ const acceptHeard = new Map();
1467
+ const pendingDigest = (t, via) => (seen) => {
1468
+ const cut = pendingCuts.get(t);
1469
+ pendingCuts.delete(t);
1470
+ const now = Date.now();
1471
+ if (via.human)
1472
+ humanAt.set(via.human.channel, now);
1473
+ if (tagOfKey(t))
1474
+ return { text: "", keys: [] };
1475
+ // 人在等验收那件需求的群里开了口: 这句可能就是验收 (是不是由管家判断)。与 digest 的 heard 同一个节流。
1476
+ const acceptLines = (via.human ? awaitingAccept(jobs.all(), t, jobLive()) : [])
1477
+ .filter(({ job }) => job.origin?.chat === via.human?.channel && now - (acceptHeard.get(job.id) ?? 0) >= HEARD_EVERY_MS)
1478
+ .map(({ job, at }) => {
1479
+ acceptHeard.set(job.id, now);
1480
+ 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`;
1481
+ });
1482
+ const rule = !seen("rule:pending");
1483
+ const items = ledger.openOf(t);
1484
+ // 没挂着的事: 不读会话, 也不留基线 —— 有了第一件再从那一刻立。
1485
+ if (!items.length) {
1486
+ pendingMarks.delete(t);
1487
+ const text = acceptLines.length ? renderDigest({ full: "", lines: acceptLines, touched: [], mark: { sid: "", ctx: 0, ctxAtFull: 0, drains: 0 } }, "", rule) : "";
1488
+ return { text, keys: text && rule ? ["rule:pending"] : [] };
1489
+ }
1490
+ const i = m.sessionInfo(t);
1491
+ 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 });
1492
+ pendingMarks.set(t, o.mark);
1493
+ o.touched.forEach(ledger.put);
1494
+ const text = renderDigest({ ...o, lines: [...o.lines, ...acceptLines] }, o.full ? renderTable(items, liveOfTurns(), pendingNames, now, awaitingRows(t, now), stalledRows(t), shelvedRows(t, now)) : "", rule);
1495
+ return { text, keys: text && rule ? ["rule:pending"] : [] };
1496
+ };
1497
+ const notices = bindNoticeBox(createNoticeBox({
1498
+ probe: (t) => [...handoffNudge(t), ...memoryNudge(t)],
1499
+ digest: pendingDigest,
1500
+ // 群况快照只给长住的 (没有 parent): 分身的协调人是生它的那位, 宪章里的出生快照够用。
1501
+ // 表里是这个群的常驻面: 管家、长住有职责的、管家生的 (群里的执行者)、收件人自己生的;
1502
+ // 再往下一层的 (分身的分身, 回归测试那批 rr-*) 不进表 —— 同名册变动 hears 的取舍。
1503
+ snapshot: (t) => (wizards.get(t)?.parent ? undefined : () => m.chatTargets(t)
1504
+ .filter((x) => x !== t && wizards.get(x)?.name)
1505
+ .filter((x) => ((p) => !tagOfKey(x) || (!p ? !!wizards.get(x)?.description : p === t || p === baseOfKey(t)))(wizards.get(x)?.parent))
1506
+ .map((x) => ({ name: displayName(x), state: ((v) => (v === undefined ? "-" : v ? "idle" : "busy"))(m.idleVerdict(x)), job: wizards.get(x)?.description || (tagOfKey(x) ? "" : "steward") }))),
1507
+ seen: createSeenRing(loadJsonMap(cfg.wrc.mirror.noticeSeenFile, gcSeen), (t) => m.sessionInfo(t)?.sessionId ?? ""),
1508
+ cwdOf: (t) => ((c) => c.runningCwd || c.defaultCwd)(m.getCwd(t)),
1509
+ }));
1510
+ // 重试冒泡 (设计文档 §3.3.1): 做完了没人验收的需求, 守护进程以管家的名义往原群提醒一句, 不占管家一轮、
1511
+ // 不带单号 (人只认标题); 同时往管家的信箱挂一行带单号的, 人下一句进来时它知道可能在答哪张。
1512
+ // 节奏由根单上的 nudge {n, lastAt} 现算 (见 jobs.nextNudgeAt), 只在白天, 人刚说过话的群顺延 —— reload 不丢、不重发。
1513
+ const NUDGE_TICK_MS = 5 * 60_000;
1514
+ const HUMAN_QUIET_MS = 30 * 60_000;
1515
+ 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)} 天前`;
1516
+ const nudgeTick = (now = Date.now()) => {
1517
+ if (dayClamp(now) !== now)
1518
+ return 0;
1519
+ const owners = [...new Set(jobs.all().filter((j) => j.kind === "req" && j.status === "open").map((j) => j.owner))];
1520
+ // 只冒群默认管家 (无 tag) 自己的根单: lead 自己 dispatch 出来的子需求不该推给人; 与 pendingDigest 只服务默认会话一致。
1521
+ // owner 须是已登记的 wizard: 伪造的 owner/群 (调试、回归) 不得往 turn store 落 post, 否则 rolepage 凭空长出一个群和一个「人」。
1522
+ const due = owners
1523
+ .filter((o) => !tagOfKey(o) && !!wizards.get(o))
1524
+ .flatMap((o) => awaitingAccept(jobs.all(), o, jobLive()))
1525
+ .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);
1526
+ // 按 (群, owner) 分组: 署名与信箱都跟着 owner。
1527
+ 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());
1528
+ byChat.forEach((xs) => {
1529
+ const owner = xs[0].job.owner;
1530
+ const chat = xs[0].job.origin.chat;
1531
+ const one = xs.length === 1;
1532
+ const mark = (i) => (one ? "" : "①②③④⑤⑥⑦⑧⑨⑩"[i] ?? `${i + 1}.`);
1533
+ const text = `有 ${xs.length} 件做完了等你看: ${xs.map(({ job, at }, i) => `${mark(i)}「${job.title}」(${agoZh(now - at)}交付)`).join(" ")} —— 回一句「好」就收, 或者说哪里不对。`;
1534
+ notifyChat(chat, `${relayLabel(owner)}\n\n${text}`);
1535
+ recordPost({ target: owner, channel: chat, body: text });
1536
+ xs.forEach(({ job }) => jobs.mark(job.id, { nudge: { n: (job.nudge?.n ?? 0) + 1, lastAt: now } }));
1537
+ 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(" · ")}`);
1538
+ });
1539
+ return due.length;
1540
+ };
1541
+ setInterval(() => { try {
1542
+ nudgeTick();
1543
+ }
1544
+ catch (e) {
1545
+ log.warn({ err: e.message }, "accept nudge failed");
1546
+ } }, NUDGE_TICK_MS).unref();
1547
+ /** `who` = 这次变动的当事人 (见 hears): 不给 = 聊天自己的事 (改名), 全群都听。 */
1548
+ const postRoster = (base, except, n, who) => notices.post(chatAudience(m.chatTargets(base), base, except).filter(who ? hears(who) : () => true), n);
1549
+ /** 注册表里的当事人。要在 forget 抹掉记录之前取; 没有记录 = 谁也不告诉。 */
1550
+ const subjectOf = (w) => (w
1551
+ ? { 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) }
1552
+ : {});
1311
1553
  /** 一个聊天的工作区: 它名下各会话跑在哪个目录, 取最多的那个。聊天与工作区是
1312
1554
  * 一对多, 但绝大多数聊天只围着一个项目转。 */
1313
1555
  const chatCwd = (targets) => {
@@ -1336,12 +1578,14 @@ const main = async () => {
1336
1578
  .slice()
1337
1579
  .sort((x, y) => y.targets.length - x.targets.length)
1338
1580
  .map((c) => ({ base: c.base, cwd: chatCwd(c.targets) })));
1339
- const named = applyChatNames(cfg, sourcePath, plan);
1581
+ if (!Object.keys(plan).length)
1582
+ return {};
1583
+ const { r: named } = guardCharter(() => applyChatNames(cfg, sourcePath, plan), (n) => Object.keys(n).length ? { what: `自动补的聊天名 (${Object.values(n).map((x) => `\`${x}\``).join(" ")})` } : undefined);
1340
1584
  for (const [base, name] of Object.entries(named)) {
1341
1585
  log.info({ base, name }, "chat: auto-named from workspace");
1342
1586
  // 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
1343
1587
  // 得知道自己的地址变了 —— 它的 charter 里写的还是「(未命名)」。
1344
- postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— notify / spawn_wizard 的 \`chat\` 可以用这个名字指到这里。`);
1588
+ postRoster(base, [], { op: "chat", name });
1345
1589
  }
1346
1590
  return named;
1347
1591
  };
@@ -1364,6 +1608,13 @@ const main = async () => {
1364
1608
  log.child({ mod: "wizard" }).info({ killed }, "unborn windows killed"); })
1365
1609
  .catch((e) => log.warn({ err: e.message }, "unborn window reap failed"));
1366
1610
  }
1611
+ /** 成员角色参数: 只认 exec / reviewer / expert (lead 只由 dispatch 在派成之后记, 免得谁都能自封 lead); 不认识的 = 不给。 */
1612
+ const roleParam = (v) => ["exec", "reviewer", "expert"].find((x) => x === v);
1613
+ /** 成员此刻的态: 定论 > 在飞的实时态。每次取一份回执快照。 */
1614
+ const jobLive = () => {
1615
+ const inflight = receipts.states();
1616
+ return (j, mm) => mm.outcome ?? inflight.find((x) => x.to === mm.target && x.job === j.id)?.state;
1617
+ };
1367
1618
  /** 派活前先验工单: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。 */
1368
1619
  /** `free` = 这次不花预算 (不带 task 的 spawn; 答一份停在 NEED 上的反问 —— 拒了它就只能干等到超时)。 */
1369
1620
  const checkJob = (id, target, free = false) => {
@@ -1374,7 +1625,7 @@ const main = async () => {
1374
1625
  return { ok: false, status: 409, reason: `工单 '${id}' 已经收工了` };
1375
1626
  // 已在册的成员 (续问 / 再派一句) 不占新名额。
1376
1627
  if (j.members.length >= JOB_MEMBER_MAX && !j.members.some((x) => x.target === target))
1377
- return { ok: false, status: 409, reason: `工单 '${id}' 的成员已经满了 (${JOB_MEMBER_MAX} 个) —— 分身是有成本的, 拆成两个工单, 或者先收工回收掉一批` };
1628
+ return { ok: false, status: 409, reason: `工单 '${id}' 的成员已经满了 (${JOB_MEMBER_MAX} 个直接成员) —— 管辖幅度有限: 把一块子活交给现有成员 (它自己开子单、长一层子 lead), 或者先收工回收掉一批` };
1378
1629
  // 预算是硬的: 与单件活的 legs 不同, 这里拒了也无路可绕 —— 换个人派照样记在这张工单上。
1379
1630
  if (!free && j.maxTurns && (j.turns ?? 0) >= j.maxTurns)
1380
1631
  return { ok: false, status: 409, reason: `工单 '${id}' 的派活预算用完了 (${j.turns}/${j.maxTurns} 次) —— 拿手上已有的结论 close_job 收口, 缺的如实写; 真要接着干就 open_job 开一张新的` };
@@ -1451,11 +1702,13 @@ const main = async () => {
1451
1702
  const nowDate = new Date();
1452
1703
  return {
1453
1704
  wizards: [...live, ...cold],
1454
- jobs: jobs.all().map((j) => ({
1705
+ jobs: ((all, live) => all.map((j) => ({
1455
1706
  id: j.id, base: j.base, owner: j.owner, title: j.title, status: j.status,
1456
1707
  openedAt: j.openedAt, closedAt: j.closedAt, summary: j.summary, ...(j.expect ? { expect: j.expect } : {}), ...(j.plan ? { plan: j.plan } : {}),
1457
- members: j.members.map((mm) => ({ target: mm.target, task: mm.task, spawned: mm.spawned, ...(mm.outcome ? { outcome: mm.outcome } : {}), ...(mm.artifacts?.length ? { artifacts: mm.artifacts } : {}) })),
1458
- })),
1708
+ ...(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 } : {}),
1709
+ stage: jobStage(j, all, live),
1710
+ 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 } : {}) })),
1711
+ })))(jobs.all(), jobLive()),
1459
1712
  // rolepage 的待办托盘: 与名册 / peek 的「在等 / 欠着」同一份 (receipts.states)。
1460
1713
  inflight: receipts.states().map(({ ageMs, ...x }) => ({ ...x, at: Date.now() - ageMs })),
1461
1714
  schedules: tasks.list().filter((x) => x.enabled).map((x) => {
@@ -1522,39 +1775,53 @@ const main = async () => {
1522
1775
  // 新记的那些就只活在被抹掉的对话里 —— onContextCut 据这份快照补发差集。纯内存: reload
1523
1776
  // 之后没有快照, 只指路不补发。
1524
1777
  const chartered = new Map();
1778
+ const charterOptsOf = (target, cwd) => {
1779
+ const rec = wizards.get(target);
1780
+ return { parent: rec?.parent, forkOf: rec?.forkOf, inherited: !!rec?.clonedFrom, cwd };
1781
+ };
1782
+ /** 宪章正文, 只读 —— 只由 cfg 与此刻的注册表 / 附着表算出, config_set 据此比对前后。`forDiff` 去掉
1783
+ * 宪章里自认是出生快照的配置 (档位那一行, 真相在 config_get): 改档位不该叫全员 handoff。 */
1784
+ const charterText = (target, o, forDiff = false) => renderCharter({
1785
+ // o.cwd = 正在启动的那个 pane 的目录; 没给才退回"现在记着的那个"。
1786
+ self: { ...briefOf(target, target), address: selfAddress(target), ...(o.cwd ? { cwd: o.cwd } : {}) },
1787
+ chat: chatNameOf(cfg, target),
1788
+ principal: baseOfKey(target),
1789
+ parent: o.parent ? briefOf(target, o.parent) : undefined,
1790
+ inherited: !!o.inherited,
1791
+ forkOf: o.forkOf ? briefOf(target, o.forkOf) : undefined,
1792
+ cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
1793
+ // 最近动过的排前面: 名册只点名前几个, 截掉的该是早就停了的那些。
1794
+ // 没有 transcript 的冷记录按 bornAt 算, 刚生下还没开口的照样在。
1795
+ siblings: m.chatTargets(baseOfKey(target))
1796
+ .filter((t) => t !== target)
1797
+ .map((t) => [t, Math.max(lastTouched(t), wizards.get(t)?.bornAt ?? 0)])
1798
+ .filter(([, at]) => Date.now() - at <= ROSTER_FRESH_MS)
1799
+ .sort((x, y) => y[1] - x[1])
1800
+ .map(([t]) => briefOf(target, t)),
1801
+ memory: wizards.get(target)?.memory ?? [],
1802
+ tiers: forDiff ? [] : TIERS.map((n) => { const s = cfg.models.tiers[n]; return `${n}=${s.model || "默认"}${s.effort ? `·${s.effort}` : ""}`; }),
1803
+ chatMemory: sharedMemory("chat", baseOfKey(target)),
1804
+ workspaceMemory: (() => {
1805
+ const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
1806
+ return cwd ? sharedMemory("workspace", cwd) : "";
1807
+ })(),
1808
+ steward: !tagOfKey(target),
1809
+ stewardBudget: chatPolicyOf(cfg, target).stewardBudget,
1810
+ lead: !!wizards.get(target)?.lead,
1811
+ topOnly: chatPolicyOf(cfg, target).topOnly,
1812
+ });
1525
1813
  /** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
1526
1814
  * 让它一睁眼就知道自己不是一个人在跑。 */
1527
1815
  const charterFor = (target, o) => {
1528
1816
  chartered.set(target, wizards.get(target)?.memory ?? []);
1529
- const text = renderCharter({
1530
- // o.cwd = 正在启动的那个 pane 的目录; 没给才退回"现在记着的那个"。
1531
- self: { ...briefOf(target, target), address: selfAddress(target), ...(o.cwd ? { cwd: o.cwd } : {}) },
1532
- chat: chatNameOf(cfg, target),
1533
- principal: baseOfKey(target),
1534
- parent: o.parent ? briefOf(target, o.parent) : undefined,
1535
- inherited: !!o.inherited,
1536
- forkOf: o.forkOf ? briefOf(target, o.forkOf) : undefined,
1537
- cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
1538
- // 最近动过的排前面: 名册只点名前几个, 截掉的该是早就停了的那些。
1539
- // 没有 transcript 的冷记录按 bornAt 算, 刚生下还没开口的照样在。
1540
- siblings: m.chatTargets(baseOfKey(target))
1541
- .filter((t) => t !== target)
1542
- .map((t) => [t, Math.max(lastTouched(t), wizards.get(t)?.bornAt ?? 0)])
1543
- .filter(([, at]) => Date.now() - at <= ROSTER_FRESH_MS)
1544
- .sort((x, y) => y[1] - x[1])
1545
- .map(([t]) => briefOf(target, t)),
1546
- memory: wizards.get(target)?.memory ?? [],
1547
- chatMemory: sharedMemory("chat", baseOfKey(target)),
1548
- workspaceMemory: (() => {
1549
- const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
1550
- return cwd ? sharedMemory("workspace", cwd) : "";
1551
- })(),
1552
- steward: !tagOfKey(target),
1553
- });
1554
- recordCharter(target, text);
1817
+ const text = charterText(target, o);
1818
+ recordCharter(target, text, o.parent ? { parent: o.parent, kind: o.inherited ? "clone" : "spawn", ...(o.forkOf ? { forkOf: o.forkOf } : {}) } : undefined);
1555
1819
  return text;
1556
1820
  };
1557
1821
  m.onContextCut((t, cut) => {
1822
+ pendingCuts.set(t, cut);
1823
+ // 之前挂过的提示随对话一起没了: 去重窗口清零, 下一次该给的规矩照给。
1824
+ notices.forget(t);
1558
1825
  const now = wizards.get(t)?.memory ?? [];
1559
1826
  const seen = chartered.get(t);
1560
1827
  // 没有快照 (reload 之后) 就不猜差集, 只指路 —— 整份塞进一行能有几万字。
@@ -1580,8 +1847,8 @@ const main = async () => {
1580
1847
  ensureChatNames(target);
1581
1848
  // 信箱里装的是相对上一份宪章的变化; 新宪章一渲染就都过期了 (名册、记忆都已在里面)。
1582
1849
  notices.drain(target);
1583
- const rec = wizards.get(target);
1584
- return charterFor(target, { parent: rec?.parent, forkOf: rec?.forkOf, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
1850
+ notices.forget(target);
1851
+ return charterFor(target, charterOptsOf(target, ctx?.cwd));
1585
1852
  });
1586
1853
  // 宪章记录是后来才有的: 已经在跑的 wizard 拿 spawn 落盘的那份 (state/charters/<sid>.md) 补上,
1587
1854
  // 否则要等它下次重生, rolepage 的「宪章」入口才出现。与自动命名同一个延迟: 等附着表恢复完。
@@ -1716,7 +1983,9 @@ const main = async () => {
1716
1983
  // 职责是别人决定"该不该找你"的依据, 改了就得让同群的知道 —— 否则他们照着
1717
1984
  // 出生快照里那句旧的 (或者空的) 职责派活。
1718
1985
  if (asked || description)
1719
- postRoster(baseOfKey(self), [self], `${before && before !== name ? `**.${before}** 改名为 **.${name}**` : `**.${name}** 改了身份`}${description ? ` · 职责: ${description}` : ""}`);
1986
+ postRoster(baseOfKey(self), [self], before && before !== name
1987
+ ? { op: "renamed", name: `.${name}`, was: `.${before}`, ...(description ? { job: description } : {}) }
1988
+ : { op: "job", name: `.${name}`, job: description }, subjectOf(wizards.get(self)));
1720
1989
  json(res, 200, {
1721
1990
  ok: true,
1722
1991
  ...me,
@@ -1757,6 +2026,7 @@ const main = async () => {
1757
2026
  const shown = ranked.slice(0, limit).map((r) => ({
1758
2027
  ...r,
1759
2028
  turns: turnsLine(inflight, r.target),
2029
+ duty: dutyLine(jobs.all(), r.target),
1760
2030
  model: r.model || (r.jsonlPath ? lastModel(r.jsonlPath) : ""),
1761
2031
  summary: r.jsonlPath ? lastExchange(r.jsonlPath, 80, warm) : "",
1762
2032
  contextTokens: r.jsonlPath ? lastContextTokens(r.jsonlPath) : 0,
@@ -1777,21 +2047,11 @@ const main = async () => {
1777
2047
  });
1778
2048
  // 管家分派前的候选表: 事实在这里算 (交集、读过的文件、ctx), 权衡留给管家。
1779
2049
  // 只为活着的读 transcript —— 冷的没有 jsonl 可读, 靠职责命中进表。
1780
- http.register("POST /wizard/route", async (req, res) => {
1781
- const { self, body } = await readPeerBody(req);
1782
- if (!self) {
1783
- json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1784
- return;
1785
- }
1786
- const b = body;
1787
- const task = (b.task ?? "").trim();
1788
- if (!task) {
1789
- json(res, 400, { ok: false, reason: "task is required" });
1790
- return;
1791
- }
2050
+ /** 候选的原料: 除自己之外每个 wizard 的上下文事实 (文件集 / 最近的话 / ctx / 缓存 TTL)。 */
2051
+ const routeRowsOf = async (self) => {
1792
2052
  ensureChatNames(self);
1793
2053
  const warm = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1794
- const rows = (await rosterOf(self))
2054
+ return (await rosterOf(self))
1795
2055
  .filter((r) => !r.self)
1796
2056
  .map((r) => {
1797
2057
  const live = r.jsonlPath ? r.jsonlPath : "";
@@ -1800,15 +2060,139 @@ const main = async () => {
1800
2060
  ...r,
1801
2061
  contextTokens: live ? lastContextTokens(live) : 0,
1802
2062
  ...(ttl ? { cacheTtlMs: ttl * 1000 } : {}),
2063
+ ...(live ? { cacheAt: cacheTouchMs(live) } : {}),
1803
2064
  files: live ? contextFiles(live) : [],
1804
2065
  asks: live ? talkTurns(live, 12, warm).filter((t) => t.role === "user").map((t) => t.text) : [],
1805
2066
  summary: live ? lastExchange(live, 80, warm) : "",
1806
2067
  };
1807
2068
  });
2069
+ };
2070
+ http.register("POST /wizard/route", async (req, res) => {
2071
+ const { self, body } = await readPeerBody(req);
2072
+ if (!self) {
2073
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2074
+ return;
2075
+ }
2076
+ const b = body;
2077
+ const task = (b.task ?? "").trim();
2078
+ if (!task) {
2079
+ json(res, 400, { ok: false, reason: "task is required" });
2080
+ return;
2081
+ }
1808
2082
  const limit = Math.min(Math.max(Number(b.limit ?? 5) || 5, 1), 20);
1809
- const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, rows).slice(0, limit);
2083
+ const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, await routeRowsOf(self)).slice(0, limit);
1810
2084
  json(res, 200, { ok: true, text: renderCandidates(task, cands, Date.now(), cfg.wrc.mirror.keepalive.ttlSec * 1000, homedir()) });
1811
2085
  });
2086
+ // 派活收成一步 (见 route.decide): 查候选 → 过成本门控 → 转给已有的或按档白板 spawn →
2087
+ // tell_peer 投出去, 回决定与理由。默认决定由守护进程算; 管家不同意就 `to` 点名、
2088
+ // `force` 越过冷门控、`spawn:true` 硬要新的 —— 推翻要显式说。
2089
+ http.register("POST /dispatch", async (req, res) => {
2090
+ const { self, body } = await readPeerBody(req);
2091
+ if (!self) {
2092
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2093
+ return;
2094
+ }
2095
+ const b = body;
2096
+ const task = (b.task ?? "").toString().trim();
2097
+ if (!task) {
2098
+ json(res, 400, { ok: false, reason: "task is required" });
2099
+ return;
2100
+ }
2101
+ const lead = b.lead === true;
2102
+ const force = b.force === true;
2103
+ const to = (b.to ?? "").toString().trim();
2104
+ const now = Date.now();
2105
+ const ttlMs = cfg.wrc.mirror.keepalive.ttlSec * 1000;
2106
+ // 自动选只在这些里选: 在线的 (没 pane 的 ctx 量不出, 成本门会盲过)、不是哪个群的管家、
2107
+ // 不是别人开着的工单里的队员 —— 点名 (`to`) 不受此限。
2108
+ const inOthersJob = (t) => jobs.openOf(baseOfKey(t)).some((j) => j.owner !== self && j.members.some((mm) => mm.target === t));
2109
+ const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target) && !inOthersJob(r.target));
2110
+ const cands = rankCandidates(task, m.getCwd(self).runningCwd, pool).slice(0, 5);
2111
+ let d = to ? { kind: "existing", why: `你点名了 ${to}` }
2112
+ : b.spawn ? { kind: "spawn", why: "你要了新的" }
2113
+ : lead ? { kind: "spawn", why: "复杂活: 白板起一个 hard 档的 lead, 由它组队" }
2114
+ : decide(task, cands, now, ttlMs, force);
2115
+ // 复杂活 (lead:true) 是一件「需求」: 先开需求根单 (账本, 不占 pane), 这一句带它派下去, lead 记成根单成员。
2116
+ // 小活不开单。dryRun 不落账, 所以放在它后面开 (见下)。
2117
+ const criteria = (b.criteria ?? "").toString().trim();
2118
+ let rootId = "";
2119
+ const tell = (name, text, extra = "") => tellPeerAs(self, {
2120
+ name, text, public: b.public !== false, force,
2121
+ // lead 的活远超默认的 1h: 不给 deadline 就按 12h (回执的上限)。
2122
+ ...(b.deadline !== undefined ? { deadline: b.deadline } : lead ? { deadline: 43200 } : {}),
2123
+ ...(rootId ? { job: rootId } : {}),
2124
+ }, extra);
2125
+ const shown = cands.slice(0, 3).map((e) => `.${e.row.address || e.row.name}${e.row.busy ? " 忙" : ""}`);
2126
+ if (b.dryRun) {
2127
+ 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 } : {}) });
2128
+ return;
2129
+ }
2130
+ if (lead) {
2131
+ const head = (b.description ?? "").toString().trim() || task.split("\n")[0].trim();
2132
+ rootId = jobs.open(baseOfKey(self), self, truncate(head, 60), {
2133
+ kind: "req", ...(criteria ? { criteria, gate: { g1: now } } : {}),
2134
+ origin: { text: (currentHumanQueryOf(self) || task).trim(), chat: channelOf(self) },
2135
+ }).id;
2136
+ }
2137
+ // 没投成的根单当场收掉: 留着只是一张没人干的空单。
2138
+ const settleRoot = (ok) => { if (rootId && !ok)
2139
+ jobs.close(rootId, "派活没投成"); };
2140
+ const withRoot = (body) => rootId
2141
+ ? { ...body, job: rootId, hint: "需求根单已开, 对人**别提单号**。lead 交付后根单仍开着, 等人验收: 人认可 → close_job(job) 归档并回收 lead; 人说不对 → tell_peer({name, re, job}) 给 lead 返工 (同一张单, 不开新单); 人说不要了 → close_job({job, as:\"cancel\"}); 先放着 → close_job({job, as:\"shelve\"}) (停提醒, 不关单)。" }
2142
+ : body;
2143
+ if (d.kind === "existing") {
2144
+ const name = to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "");
2145
+ const r = resolvePeer(self, name);
2146
+ if (!r.ok) {
2147
+ settleRoot(false);
2148
+ json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
2149
+ return;
2150
+ }
2151
+ // 已有 wizard 当 lead: 宪章是出生时定的 (里面没有 lead 一节), 规矩加打法挂在信封后面带过去;
2152
+ // 已是 lead 的宪章里已有层级规矩, 只带打法。投成了才记 lead, 它下次重生宪章里就有。
2153
+ const t = await tell(name, task, lead ? renderReminder({ wezard: "lead" }, wizards.get(r.target)?.lead ? renderLeadPlaybook() : renderLead()) : "");
2154
+ if (lead && t.status === 200)
2155
+ wizards.upsert(r.target, { lead: true });
2156
+ if (rootId && t.status === 200)
2157
+ jobs.attach(rootId, { target: r.target, task: "", spawned: false, role: "lead" });
2158
+ // 自动选中的被冷门控退回 (decide 用名册的活动时刻, 门控用 transcript 的, 口径差一点): 改走 spawn。
2159
+ if (!(t.body.gated === "cold" && !to)) {
2160
+ settleRoot(t.status === 200);
2161
+ json(res, t.status, withRoot({ ...t.body, decision: "existing", reason: d.why, ...(shown.length ? { candidates: shown } : {}) }));
2162
+ return;
2163
+ }
2164
+ d = { kind: "spawn", why: `${d.why}; 但它被冷门控退回 (${String(t.body.reason)}) —— 改为白板 spawn` };
2165
+ }
2166
+ const name = (b.name ?? "").toString().trim();
2167
+ const description = (b.description ?? "").toString().trim();
2168
+ if (!name || !description) {
2169
+ settleRoot(false);
2170
+ json(res, 400, { ok: false, decision: "spawn", reason: `${d.why} —— 要新生一个, 带上 name (这件事的短名) 与 description (它的职责) 重发`, ...(shown.length ? { candidates: shown } : {}) });
2171
+ return;
2172
+ }
2173
+ const tier = (b.tier ?? "").toString().trim() || tierFor(task, lead);
2174
+ const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) });
2175
+ if (born.status !== 200) {
2176
+ settleRoot(false);
2177
+ json(res, born.status, { ...born.body, decision: "spawn", reason: d.why });
2178
+ return;
2179
+ }
2180
+ // 宪章里只有层级规矩, 打法随信封带 (宪章每轮都在上下文里, 打法只这一件活用得上)。
2181
+ const t = await tell(String(born.body.name), task, lead ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "");
2182
+ settleRoot(t.status === 200);
2183
+ // 为这张根单生出来的: 归档 close_job 时要回收它 (tell 记的是 spawned:false, 再 attach 一次补上; lead 角色也在这里记)。
2184
+ if (rootId && t.status === 200)
2185
+ jobs.attach(rootId, { target: String(born.body.target), task: "", spawned: true, role: "lead" });
2186
+ json(res, t.status, withRoot({
2187
+ ...t.body,
2188
+ decision: "spawn",
2189
+ reason: d.why,
2190
+ tier,
2191
+ ...(born.body.model ? { model: born.body.model } : {}),
2192
+ ...(born.body.effort ? { effort: born.body.effort } : {}),
2193
+ ...(shown.length ? { candidates: shown } : {}),
2194
+ }));
2195
+ });
1812
2196
  // 记忆三种作用域: self 跟着自己 (wizards.json), 直写; chat / workspace 是共享的 md,
1813
2197
  // 每个在那个群 / 那个目录出生的 wizard 都会读到 —— 它们只收提议, 由整理者合并
1814
2198
  // (见 wizard-memory.ts / memory-steward.ts)。
@@ -1871,62 +2255,56 @@ const main = async () => {
1871
2255
  });
1872
2256
  // 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
1873
2257
  // 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
1874
- http.register("POST /wizard/clone", async (req, res) => {
1875
- const { self, body } = await readPeerBody(req);
1876
- if (!self) {
1877
- json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1878
- return;
1879
- }
2258
+ /** spawn_wizard / clone_wizard 的本体 —— HTTP 路由与 dispatch 共用。 */
2259
+ const bearAs = async (self, body) => {
1880
2260
  const b = body;
1881
- const effort = parseEffort(b.effort);
1882
- if (b.effort !== undefined && !effort) {
1883
- json(res, 400, { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` });
1884
- return;
2261
+ const askedEffort = parseEffort(b.effort);
2262
+ if (b.effort !== undefined && !askedEffort) {
2263
+ return { status: 400, body: { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` } };
2264
+ }
2265
+ const pick = pickTier(cfg.models, b.tier, { cli: b.cli, model: b.model, effort: askedEffort }, b.inherit === true);
2266
+ if (!pick.ok) {
2267
+ return { status: 400, body: pick };
1885
2268
  }
2269
+ const effort = pick.effort;
1886
2270
  // 工单先验: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。
1887
2271
  const jobId = (b.job ?? "").trim();
1888
2272
  if (jobId) {
1889
2273
  const jc = checkJob(jobId, undefined, !(b.task ?? "").toString().trim());
1890
2274
  if (!jc.ok) {
1891
- json(res, jc.status, { ok: false, reason: jc.reason });
1892
- return;
2275
+ return { status: jc.status, body: { ok: false, reason: jc.reason } };
1893
2276
  }
1894
2277
  }
1895
2278
  // `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
1896
2279
  // 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
1897
2280
  // 逼调用方每次自己说。
1898
2281
  if (typeof b.inherit !== "boolean") {
1899
- json(res, 400, { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" });
1900
- return;
2282
+ return { status: 400, body: { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" } };
1901
2283
  }
1902
2284
  const inherit = b.inherit;
1903
2285
  // detached = 独立长住 (原 new_claude_session / 人在群里 `/new .x`): 不挂家谱、不占
1904
2286
  // 调用方的分身名额、不随工单回收。它和「fork 我的上下文」「归某张工单」都互斥。
1905
2287
  const detached = b.detached === true;
1906
2288
  if (detached && (inherit || (b.job ?? "").trim())) {
1907
- json(res, 400, { ok: false, reason: "detached 只用于白板 (inherit:false) 且不带 job —— 独立长住的 wizard 不归你管, 也不随工单回收" });
1908
- return;
2289
+ return { status: 400, body: { ok: false, reason: "detached 只用于白板 (inherit:false) 且不带 job —— 独立长住的 wizard 不归你管, 也不随工单回收" } };
1909
2290
  }
1910
2291
  // 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
1911
2292
  // 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
1912
2293
  // 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
1913
2294
  const fromAddr = (b.from ?? "").toString().trim();
1914
2295
  if (fromAddr && !inherit) {
1915
- json(res, 400, { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" });
1916
- return;
2296
+ return { status: 400, body: { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" } };
1917
2297
  }
1918
2298
  const fromR = fromAddr ? resolvePeer(self, fromAddr) : { ok: true, target: self };
1919
2299
  if (!fromR.ok) {
1920
- json(res, fromR.status, { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) });
1921
- return;
2300
+ return { status: fromR.status, body: { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) } };
1922
2301
  }
1923
2302
  const source = fromR.target;
1924
2303
  const sourceInfo = m.sessionInfo(source);
1925
2304
  // 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
1926
2305
  // 要的恰恰是它的上下文, 给一张白纸等于答非所问。
1927
2306
  if (source !== self && !sourceInfo?.sessionId) {
1928
- json(res, 409, { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` });
1929
- return;
2307
+ return { status: 409, body: { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` } };
1930
2308
  }
1931
2309
  const alivePeers = detached ? [] : await m.peers(self);
1932
2310
  // 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
@@ -1934,30 +2312,27 @@ const main = async () => {
1934
2312
  // 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
1935
2313
  const aliveKids = childrenOf(wizards.all(), self).filter((k) => alivePeers.some((pp) => pp.target === k.target && pp.paneAlive));
1936
2314
  if (!detached && aliveKids.length >= cfg.wrc.mirror.cloneMax) {
1937
- json(res, 429, {
1938
- ok: false,
1939
- reason: `你名下已经有 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) —— 先 stop_wizard 收掉干完活的那些, 或者 close_job 整批回收一个工单`,
1940
- clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
1941
- });
1942
- return;
2315
+ return { status: 429, body: {
2316
+ ok: false,
2317
+ reason: `你名下已经有 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) —— 先 stop_wizard 收掉干完活的那些, 或者 close_job 整批回收一个工单`,
2318
+ clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
2319
+ } };
1943
2320
  }
1944
2321
  const wantChat = (b.chat ?? "").toString().trim();
1945
2322
  const base = wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self);
1946
2323
  if (!base) {
1947
- json(res, 404, {
1948
- ok: false,
1949
- reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
1950
- candidates: listChatNames(cfg).map((c) => c.name),
1951
- });
1952
- return;
2324
+ return { status: 404, body: {
2325
+ ok: false,
2326
+ reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
2327
+ candidates: listChatNames(cfg).map((c) => c.name),
2328
+ } };
1953
2329
  }
1954
2330
  const askedName = String(b.name ?? b.tag ?? "");
1955
2331
  // 没起名: 独立长住的按目录名 (同 /new 与 new_claude_session), 分身按职责的首词。
1956
2332
  const fallback = (detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone";
1957
2333
  const slotR = await claimSlot(base, askedName, fallback, [self, source]);
1958
2334
  if (!slotR.ok) {
1959
- json(res, slotR.status, slotR.body);
1960
- return;
2335
+ return { status: slotR.status, body: slotR.body };
1961
2336
  }
1962
2337
  const { target, slot: tag } = slotR;
1963
2338
  // claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
@@ -1975,6 +2350,9 @@ const main = async () => {
1975
2350
  // fork 了上下文就一并继承 self 记忆: 系统提示不随 fork 走 (分身拿的是自己的宪章),
1976
2351
  // 不拷的话它开局就没有, /clear 或交接重开后更是彻底没了。标上来源; 已经是继承来的不再套一层。
1977
2352
  ...(inherit ? { memory: (wizards.get(source)?.memory ?? []).map((x) => x.startsWith("(继承自 ") ? x : `(继承自 ${displayName(source)}) ${x}`) } : {}),
2353
+ tier: pick.tier,
2354
+ // 显式写 undefined: 复用的冷记录可能当过 lead。
2355
+ lead: b.lead === true ? true : undefined,
1978
2356
  // 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
1979
2357
  spawning: BOOT_ID,
1980
2358
  });
@@ -1992,15 +2370,16 @@ const main = async () => {
1992
2370
  parent: source,
1993
2371
  target,
1994
2372
  windowName: name,
1995
- cli: b.cli,
1996
- model: b.model,
2373
+ cli: pick.cli,
2374
+ model: pick.model,
1997
2375
  effort,
1998
2376
  cwd: b.cwd,
1999
2377
  systemPrompt: charter,
2000
2378
  inherit,
2001
2379
  // 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
2002
2380
  // 也少一次"就位了但没事干"的空转。
2003
- bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn }) : undefined,
2381
+ // 子 lead 的宪章只有层级规矩, 打法跟第一句的信封走 (同 dispatch 对已有 wizard 的做法)。
2382
+ bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "") : undefined,
2004
2383
  ...(task ? { bootstrapFrom: { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, query: task } } : {}),
2005
2384
  keepalive,
2006
2385
  }).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
@@ -2010,20 +2389,19 @@ const main = async () => {
2010
2389
  wizards.upsert(target, { spawning: undefined });
2011
2390
  else
2012
2391
  wizards.drop(target);
2013
- json(res, 500, { ok: false, reason: r.reason });
2014
- return;
2392
+ return { status: 500, body: { ok: false, reason: r.reason } };
2015
2393
  }
2016
2394
  wizards.upsert(target, { spawning: undefined, clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
2017
2395
  const kid = briefOf(self, target);
2018
- // r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
2019
- // 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
2020
- const modelNote = (r.model
2021
- ? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
2022
- : "") + (r.effort ? ` · effort ${r.effort}` : "");
2023
2396
  // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
2024
2397
  // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
2025
2398
  if (!jobId) {
2026
- postRoster(base, [target, self], `新 wizard **.${name}** 就位${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— ${detached ? `空白起步, 由 ${displayName(self)} 造的` : `${displayName(self)} 的分身`}${r.inherited ? ` (继承了${forkOf ? ` ${displayName(forkOf)} ` : "它"}的上下文)` : ""}`);
2399
+ // 模型 / keepalive 不报: 那是生它的那位的事, 旁人要时 wizard_roster 有。
2400
+ postRoster(base, [target, self], {
2401
+ op: "born", name: `.${name}`, by: displayName(self), clone: !detached,
2402
+ ...(r.inherited ? { ctx: forkOf ? displayName(forkOf) : displayName(self) } : {}),
2403
+ ...(kid.description ? { job: kid.description } : {}), cwd: r.cwd,
2404
+ }, subjectOf(wizards.get(target)));
2027
2405
  }
2028
2406
  // 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
2029
2407
  let dispatched = r.inherited && !!task;
@@ -2031,8 +2409,11 @@ const main = async () => {
2031
2409
  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 }) });
2032
2410
  dispatched = inj.ok;
2033
2411
  }
2034
- if (jobId)
2035
- jobs.attach(jobId, { target, task, spawned: true });
2412
+ if (jobId) {
2413
+ const role = roleParam(body.role);
2414
+ const from = inherit ? source : undefined;
2415
+ jobs.attach(jobId, { target, task, spawned: true, ...(role ? { role } : {}), ...(from ? { forkOf: from } : {}) });
2416
+ }
2036
2417
  if (jobId && dispatched)
2037
2418
  jobs.spend(jobId);
2038
2419
  // 分身的第一件活也守回执: fan-out 最常见的形状就是 clone_wizard({task}) × N,
@@ -2040,7 +2421,18 @@ const main = async () => {
2040
2421
  const k = body.chain === false ? undefined : parentKOf(self);
2041
2422
  if (dispatched)
2042
2423
  receipts.register({ from: self, to: target, channel: "", job: jobId, at: taskAt, turn: taskTurn, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
2043
- json(res, 200, { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(r.effort ? { effort: r.effort } : {}), ...(jobId ? { job: jobId } : {}), ...(detached ? { detached: true } : {}) });
2424
+ if (dispatched)
2425
+ ledger.open({ turn: taskTurn, owner: self, to: target, text: task, at: taskAt, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
2426
+ 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 } : {}) } };
2427
+ };
2428
+ http.register("POST /wizard/clone", async (req, res) => {
2429
+ const { self, body } = await readPeerBody(req);
2430
+ if (!self) {
2431
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2432
+ return;
2433
+ }
2434
+ const r = await bearAs(self, body);
2435
+ json(res, r.status, r.body);
2044
2436
  });
2045
2437
  // 收掉一个 wizard。interrupt = 打断它这一轮 (Esc); end = 结束它并回收 pane。
2046
2438
  // 自己终结自己是合法的 (分身干完活自我了结), 代价是这次工具调用不会返回 ——
@@ -2073,6 +2465,8 @@ const main = async () => {
2073
2465
  }
2074
2466
  const { target } = r;
2075
2467
  const victim = briefOf(self, target);
2468
+ // 当事人要在 forget 抹掉记录之前取 (见 subjectOf)。
2469
+ const victimRec = wizards.get(target);
2076
2470
  const end = (b.mode ?? "end") === "end";
2077
2471
  if (!end && target === self) {
2078
2472
  json(res, 400, { ok: false, reason: "打断自己没有意义 —— 你就是正在生成的那一个" });
@@ -2122,7 +2516,7 @@ const main = async () => {
2122
2516
  const detached = end && b.forget ? forgetWizard(target) : [];
2123
2517
  // 打断只是停了它这一轮, 它还在; 只有结束才是名册变了。
2124
2518
  if (end)
2125
- postRoster(baseOfKey(target), [target, self], `**${victim.name || target}** 已收工 · 由 ${displayName(self)} 结束${b.forget ? " (记录一并抹掉)" : ""}`);
2519
+ postRoster(baseOfKey(target), [target, self], { op: "ended", name: victim.name ? `.${victim.name}` : target, by: displayName(self), ...(b.forget ? { why: "forgotten" } : {}) }, subjectOf(victimRec));
2126
2520
  json(res, 200, { ok: true, target, name: victim.name, mode: end ? "end" : "interrupt", forgotten: !!(end && b.forget), ...(canceled ? { canceled } : {}), ...(detached.length ? { canceledIn: detached } : {}) });
2127
2521
  });
2128
2522
  // 换模型 / effort: 省略名字 = 换自己 (选择器盖在正在跑的这一轮上面照样能开), 点名 =
@@ -2203,13 +2597,38 @@ const main = async () => {
2203
2597
  const base = baseOfKey(self);
2204
2598
  const maxTurns = Math.min(200, Math.max(0, Math.floor(Number(b.maxTurns) || 0)));
2205
2599
  const plan = (b.plan ?? "").toString().trim();
2600
+ const criteria = (b.criteria ?? "").toString().trim();
2601
+ // 上级工单: 显式传的要求自己是它的成员或开单者; 不传 = 自己作为未落定成员所在的、最近开的那张
2602
+ // (lead 在需求根单里领了活, 开的第一张子单自然挂在根单下, 不用自己记号)。
2603
+ const all = jobs.all();
2604
+ const mineIn = (j) => j.owner === self || j.members.some((x) => x.target === self);
2605
+ const askedParent = (b.parent ?? "").toString().trim();
2606
+ // 推断: 同一个群里、我作为未落定成员的开着的单, 取我**最近一次领活** (mm.at) 的那张; 它已在最深一层就不挂了 (退成根单, 不 409)。
2607
+ const inferred = all
2608
+ .filter((j) => j.base === base && j.status === "open")
2609
+ .flatMap((j) => j.members.filter((x) => x.target === self && !x.outcome).map((x) => ({ j, at: x.at })))
2610
+ .sort((x, y) => y.at - x.at)[0]?.j;
2611
+ const parentJob = askedParent ? jobs.get(askedParent) : inferred && depthOf(jobs.get, inferred) < JOB_DEPTH_MAX ? inferred : undefined;
2612
+ if (askedParent && !parentJob) {
2613
+ json(res, 404, { ok: false, reason: `没有上级工单 '${askedParent}'` });
2614
+ return;
2615
+ }
2616
+ if (askedParent && parentJob && (parentJob.status !== "open" || !mineIn(parentJob))) {
2617
+ json(res, 409, { ok: false, reason: `上级工单 '${askedParent}' 已收工, 或你既不是它的成员也不是它的开单者` });
2618
+ return;
2619
+ }
2620
+ if (parentJob && depthOf(jobs.get, parentJob) + 1 > JOB_DEPTH_MAX) {
2621
+ json(res, 409, { ok: false, reason: `工单树最深 ${JOB_DEPTH_MAX} 层, 你这张 (挂在 ${parentJob.id} 下) 会是第 ${depthOf(jobs.get, parentJob) + 1} 层 —— 子活交给现有成员, 别再往下长` });
2622
+ return;
2623
+ }
2206
2624
  // 不出群气泡: 工单是私下的过程, 人看到的只有发起者那一轮的最终回复。
2207
2625
  const accept = ACCEPTS.find((x) => x === b.accept);
2208
- const job = jobs.open(base, self, title, { ...(plan ? { plan } : {}), ...(accept ? { accept } : {}), ...(expect ? { expect } : {}), ...(maxTurns ? { maxTurns } : {}) });
2626
+ const job = jobs.open(base, self, title, { ...(plan ? { plan } : {}), ...(accept ? { accept } : {}), ...(expect ? { expect } : {}), ...(maxTurns ? { maxTurns } : {}), ...(parentJob ? { parent: parentJob.id } : {}), ...(criteria ? { criteria } : {}) });
2209
2627
  json(res, 200, {
2210
2628
  ok: true,
2211
2629
  job: job.id,
2212
2630
  title,
2631
+ ...(parentJob ? { parent: parentJob.id } : {}),
2213
2632
  memberMax: JOB_MEMBER_MAX,
2214
2633
  hint: "把这个 id 传给 spawn_wizard / clone_wizard / tell_peer 的 `job` 参数, 它们就归到这个工单名下 (一律私聊, 不进群); 活干完调 close_job 留档并回收临时分身, 给人的结论写在你自己这一轮的最终回复里。",
2215
2634
  });
@@ -2231,23 +2650,97 @@ const main = async () => {
2231
2650
  json(res, 409, { ok: false, reason: `工单 '${id}' 已经收工了`, closedAt: job.closedAt });
2232
2651
  return;
2233
2652
  }
2234
- // 回收只针对**为这个工单生出来的**分身: 被拉来帮忙的长期 wizard 不该因为一次
2235
- // 活结束就被杀掉。调用方自己也不收 —— 那会在这次工具调用里把自己干掉。
2653
+ // 只有这张单或它某个祖先单的 owner 能关 (成员、同群的旁人不行)。
2654
+ if (![job, ...jobAncestors(jobs.get, job)].some((j) => j.owner === self)) {
2655
+ json(res, 403, { ok: false, reason: `工单 '${id}' 不是你开的, 也不是你开的某张上级单的后代 —— 只有开单者 (或上级单的开单者) 能收工` });
2656
+ return;
2657
+ }
2658
+ // 四种收法 (只 accept / cancel 真关单): shelve / resume 只动需求根单的搁置标记, 单照旧开着。
2659
+ const how = ["accept", "cancel", "shelve", "resume"].find((x) => x === (b.as ?? "accept"));
2660
+ if (!how) {
2661
+ json(res, 400, { ok: false, reason: `as 只认 accept / cancel / shelve / resume, 收到 '${b.as}'` });
2662
+ return;
2663
+ }
2664
+ if ((how === "shelve" || how === "resume") && job.kind !== "req") {
2665
+ json(res, 400, { ok: false, reason: `只有需求根单能搁置 / 恢复, '${id}' 不是 (它不是 dispatch({lead:true}) 开的根单)` });
2666
+ return;
2667
+ }
2668
+ if (how === "shelve" || how === "resume") {
2669
+ // 恢复不是「又交付了」: 保留 n, lastAt 记成此刻, 下一次冒泡从现在起算, 不一恢复就立刻冒。
2670
+ jobs.mark(id, how === "shelve" ? { hold: Date.now() } : { hold: undefined, nudge: { n: job.nudge?.n ?? 0, lastAt: Date.now() } });
2671
+ json(res, 200, { ok: true, job: id, [how === "shelve" ? "shelved" : "resumed"]: true });
2672
+ return;
2673
+ }
2674
+ // 关一张单连它还开着的后代单一起关 (深的先关): 否则子 lead 的分身留在原地、账本上挂着没人收的单。
2675
+ // 回收只针对**为这个工单生出来的**分身: 被拉来帮忙的长期 wizard 不该因为一次活结束就被杀掉。
2676
+ // 调用方自己也不收 —— 那会在这次工具调用里把自己干掉。
2236
2677
  const recycle = b.stop !== false;
2237
- const victims = recycle ? job.members.filter((mm) => mm.spawned && mm.target !== self) : [];
2238
- // 收掉之前把它们手上没落定的活记成 canceled: 不然发起者收工之后还会收到一串 dead 回执。
2239
- victims.forEach((mm) => receipts.cancel(mm.target, self));
2240
- // 留档要的 sessionId 得在回收之前取: killPane 连绑定一起删。
2241
- const sids = new Map([job.owner, ...job.members.map((mm) => mm.target)].map((t) => [t, m.sessionInfo(t)?.sessionId ?? ""]));
2242
- const killed = await victims.reduce(async (acc, mm) => (await acc) + ((await m.killPane(mm.target)).ok ? 1 : 0), Promise.resolve(0));
2243
- const closed = jobs.close(id, (b.summary ?? "").toString());
2244
- // 收工结论留档到开单者名下 (与交接简报同一份 jsonl); 空工单 (没人、没结论) 不记。
2245
- if (closed.members.length || closed.summary?.trim()) {
2246
- const ep = jobEpisode(closed, { nameOf: episodeName, sidOf: (t) => sids.get(t) ?? "", chat: chatNameOf(cfg, job.base) || job.base });
2247
- if (!appendEpisode(episodePath(cfg.daemon.stateDir, ep.name), ep))
2248
- log.warn({ job: id, name: ep.name }, "job episode not archived");
2249
- }
2250
- json(res, 200, { ok: true, job: id, members: closed.members.length, recycled: killed, kept: victims.length - killed });
2678
+ const openKids = (j) => jobChildren(jobs.all(), j.id).filter((c) => c.status === "open").flatMap((c) => [...openKids(c), c]);
2679
+ const cascade = openKids(job);
2680
+ const closeOne = async (j, summary, mark) => {
2681
+ const victims = recycle ? j.members.filter((mm) => mm.spawned && mm.target !== self) : [];
2682
+ // 收掉之前把它们手上没落定的活记成 canceled: 不然发起者收工之后还会收到一串 dead 回执。
2683
+ victims.forEach((mm) => receipts.cancel(mm.target, j.owner));
2684
+ // 留档要的 sessionId 得在回收之前取: killPane 连绑定一起删。
2685
+ const sids = new Map([j.owner, ...j.members.map((mm) => mm.target)].map((t) => [t, m.sessionInfo(t)?.sessionId ?? ""]));
2686
+ const killed = await victims.reduce(async (acc, mm) => (await acc) + ((await m.killPane(mm.target)).ok ? 1 : 0), Promise.resolve(0));
2687
+ // 取消: 还没落定、又不是为这张单生的成员 (长住 wizard) 不杀, 但它们名下这张单派的活记成 canceled, 发起者不会再收到一串回执。
2688
+ if (how === "cancel")
2689
+ j.members.filter((mm) => !mm.outcome && !victims.includes(mm)).forEach((mm) => receipts.cancel(mm.target, j.owner, (x) => x.from === j.owner));
2690
+ const closed = jobs.close(j.id, summary, mark);
2691
+ ledger.closeJob(j.id);
2692
+ // 收工结论留档到开单者名下 (与交接简报同一份 jsonl); 空工单 (没人、没结论) 不记。
2693
+ if (closed.members.length || closed.summary?.trim()) {
2694
+ const ep = jobEpisode(closed, { nameOf: episodeName, sidOf: (t) => sids.get(t) ?? "", chat: chatNameOf(cfg, j.base) || j.base });
2695
+ if (!appendEpisode(episodePath(cfg.daemon.stateDir, ep.name), ep))
2696
+ log.warn({ job: j.id, name: ep.name }, "job episode not archived");
2697
+ }
2698
+ return { members: closed.members.length, killed, kept: victims.length - killed };
2699
+ };
2700
+ const rs = await [...cascade.map((c) => [c, `随上级单 ${id} 收工`]), [job, how === "cancel" ? `取消: ${(b.summary ?? "").toString()}` : (b.summary ?? "").toString()]]
2701
+ // 根单收工: accept = G3 验收过闸 (人认可), cancel = 取消; 非根单没有闸。
2702
+ .reduce(async (acc, [j, sum]) => [...(await acc), await closeOne(j, sum, j !== job ? undefined : how === "cancel" ? { end: "cancel" } : job.kind === "req" ? { gate: { g3: Date.now() }, end: "accept" } : undefined)], Promise.resolve([]));
2703
+ const sum = (f) => rs.reduce((n, r) => n + f(r), 0);
2704
+ json(res, 200, { ok: true, job: id, members: rs[rs.length - 1].members, recycled: sum((r) => r.killed), kept: sum((r) => r.kept), ...(cascade.length ? { cascaded: cascade.map((c) => c.id) } : {}) });
2705
+ });
2706
+ // 挂起事项全表 (见 pending-items.ts): 你派出去、还没了结的事。`drop` 显式消项;
2707
+ // `name` 看别人的 (只读)。读过全表就算刚给过一次: 自动的全表提醒从此刻重新计。
2708
+ http.register("POST /pending", async (req, res) => {
2709
+ const { self, body } = await readPeerBody(req);
2710
+ if (!self) {
2711
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2712
+ return;
2713
+ }
2714
+ const b = body;
2715
+ const who = (b.name ?? "").toString().trim();
2716
+ const r = who ? resolvePeer(self, who) : undefined;
2717
+ if (r && !r.ok) {
2718
+ json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
2719
+ return;
2720
+ }
2721
+ const owner = r?.ok ? r.target : self;
2722
+ const drop = Array.isArray(b.drop) ? b.drop.map(String) : typeof b.drop === "string" ? [b.drop] : [];
2723
+ if (drop.length && owner !== self) {
2724
+ json(res, 403, { ok: false, reason: "只能消自己派出去的" });
2725
+ return;
2726
+ }
2727
+ const dropped = drop.length ? ledger.drop(self, drop, (b.why ?? "").toString().trim()) : [];
2728
+ const now = Date.now();
2729
+ const items = ledger.openOf(owner);
2730
+ if (owner === self) {
2731
+ const i = m.sessionInfo(self);
2732
+ if (i)
2733
+ pendingMarks.set(self, { sid: i.sessionId, ctx: i.contextTokens, ctxAtFull: i.contextTokens, drains: 0 });
2734
+ }
2735
+ const missed = drop.filter((t) => !dropped.some((x) => x.turn === t.replace(/[`\s]/g, "")));
2736
+ json(res, 200, {
2737
+ ok: true,
2738
+ text: [
2739
+ ...(dropped.length ? [`已消掉: ${dropped.map((x) => `\`${x.turn}\``).join(" ")}`] : []),
2740
+ ...(missed.length ? [`没找到 (不是你开着的): ${missed.join(" ")}`] : []),
2741
+ renderTable(items, liveOfTurns(), pendingNames, now, awaitingRows(owner, now), stalledRows(owner), shelvedRows(owner, now)),
2742
+ ].join("\n"),
2743
+ });
2251
2744
  });
2252
2745
  http.register("POST /jobs/list", async (req, res) => {
2253
2746
  const { self } = await readPeerBody(req);
@@ -2256,11 +2749,21 @@ const main = async () => {
2256
2749
  return;
2257
2750
  }
2258
2751
  const inflight = receipts.states();
2752
+ const live = jobLive();
2753
+ const everything = jobs.all();
2754
+ // 本群还开着的, 外加它们名下已收工的子单 (账本留着, 面包屑 / 树才不断)。
2755
+ const shown = everything.filter((j) => j.base === baseOfKey(self) && (j.status === "open" || jobAncestors(jobs.get, j).some((a) => a.status === "open")));
2259
2756
  json(res, 200, {
2260
2757
  ok: true,
2261
- jobs: jobs.openOf(baseOfKey(self)).map((j) => ({
2758
+ jobs: treeOrder(shown).map(([j, depth]) => ({
2262
2759
  job: j.id,
2263
2760
  title: j.title,
2761
+ depth,
2762
+ ...(j.parent ? { parent: j.parent } : {}),
2763
+ ...(j.kind ? { kind: j.kind } : {}),
2764
+ stage: jobStage(j, everything, live),
2765
+ ...(j.criteria ? { criteria: j.criteria } : {}),
2766
+ status: j.status,
2264
2767
  owner: displayName(j.owner),
2265
2768
  mine: j.owner === self,
2266
2769
  openedAt: j.openedAt,
@@ -2268,9 +2771,11 @@ const main = async () => {
2268
2771
  ...(j.maxTurns ? { budget: `${j.turns ?? 0}/${j.maxTurns}` } : {}),
2269
2772
  members: j.members.map((mm) => ({
2270
2773
  address: peerAddress(cfg, self, mm.target),
2774
+ role: mm.role ?? "exec",
2271
2775
  task: mm.task.split("\n")[0] ?? "",
2272
2776
  spawned: mm.spawned,
2273
2777
  state: mm.outcome ?? inflight.find((x) => x.to === mm.target && x.job === j.id)?.state ?? "unknown",
2778
+ ...(mm.forkOf ? { forkOf: peerAddress(cfg, self, mm.forkOf) } : {}),
2274
2779
  ...(mm.artifacts?.length ? { artifacts: mm.artifacts } : {}),
2275
2780
  })),
2276
2781
  })),
@@ -2375,18 +2880,241 @@ const main = async () => {
2375
2880
  }
2376
2881
  json(res, 200, { ok: true, removed: renderTask(gone, state) });
2377
2882
  });
2378
- // POST /config/set — modify daemon config from MCP. 口令门见 `guarded`; 模型还得先过
2379
- // config_set 的审批卡 (danger.ts) 才走得到这里。
2380
- http.register("POST /config/set", guarded(async (req, res) => {
2381
- const body = (await readBody(req));
2382
- const r = configSet(cfg, sourcePath, body.key, body.value, body.action);
2383
- json(res, r.ok ? 200 : 400, r);
2384
- }));
2883
+ // 确认卡在请求里最多等 4 分钟 (留出 fetch 5 分钟收头的余量), 卡本身半小时有效。
2884
+ const CONFIRM_INLINE_MS = 240_000;
2885
+ const CONFIRM_TIMEOUT_MS = 30 * 60_000;
2886
+ // computeUsage 要扫一周的 transcript, 一分钟内的连问共用一份。
2887
+ let usageMemo;
2888
+ const tierUsage = () => {
2889
+ if (usageMemo && Date.now() - usageMemo.at < 60_000)
2890
+ return usageMemo.u;
2891
+ const r = computeUsage();
2892
+ const fold = (m) => new Map([...m].map(([id, x]) => [id, { tokens: sumTotal(x), cost: costOf(id, x) }]));
2893
+ usageMemo = { at: Date.now(), u: { today: fold(r.today), week: fold(r.week) } };
2894
+ return usageMemo.u;
2895
+ };
2896
+ /** chatPolicy 的依据: 各聊天名 ↔ principal 与它此刻的顶层模式 —— 键落盘是 principal, 人和模型认的是名字。 */
2897
+ const chatPolicyEvidence = (p) => {
2898
+ if (p[0] !== "chatPolicy")
2899
+ return [];
2900
+ const named = listChatNames(cfg);
2901
+ const line = (b) => `${named.filter((c) => c.base === b).map((c) => c.name).join(" / ") || "(未命名)"} = ${b} · 顶层模式 ${chatPolicyOf(cfg, b).topOnly ? "开" : "关"}`;
2902
+ // 查某一个聊天: 只报它; 查整张表: 报写过策略的, 其余命名聊天只报个数。
2903
+ if (p[1])
2904
+ return [line(p[1])];
2905
+ const set = Object.keys(cfg.chatPolicy);
2906
+ const rest = named.filter((c) => !set.includes(c.base)).length;
2907
+ return [
2908
+ "键写聊天名或 principal 都行, 落盘换成 principal:",
2909
+ ...set.map(line),
2910
+ ...(rest ? [`其余 ${rest} 个已命名聊天没写, 按默认 (顶层模式关) —— 查某个: config_get({path:"chatPolicy.<聊天名>"})`] : []),
2911
+ ];
2912
+ };
2913
+ const configEvidence = (p) => [
2914
+ ...tierEvidence(cfg.models, p, tierUsage, (tier) => wizards.all().filter((w) => w.tier === tier).length, { tokens: fmtTokens, cost: fmtCost }),
2915
+ ...chatPolicyEvidence(p),
2916
+ ];
2917
+ // ── 改配置会不会动到宪章 ─────────────────────────────────────────
2918
+ // 宪章是 --append-system-prompt 压进去的, 随进程终身: 改了进宪章的配置, 已在跑的 wizard
2919
+ // 看不见, 要 handoff 重开 (重新渲染宪章) 才换上。不按字段清单判 —— 宪章读哪些配置随代码变,
2920
+ // 清单迟早漏; 改前改后各渲染一遍有会话的 wizard 的宪章, 变了的就是受影响的。
2921
+ const charterSections = (text) => new Map(text.split(/^(?=## )/m).map((sec) => [sec.split("\n", 1)[0].replace(/^## /, ""), sec]));
2922
+ const changedSections = (a, b) => {
2923
+ const x = charterSections(a), y = charterSections(b);
2924
+ return [...new Set([...x.keys(), ...y.keys()])].filter((k) => x.get(k) !== y.get(k));
2925
+ };
2926
+ /** 宪章挂在 pane 上的 wizard —— 候选。同步取: 守卫要在写之前拍快照, 中间不能 await。 */
2927
+ const attachedWizards = () => wizards.all().map((w) => w.target).filter((t) => !!m.sessionInfo(t)?.tmuxPane);
2928
+ /** 其中 pane 活着的 —— 宪章在效的只有它们; 冷的醒来时宪章重渲染, 自然是新的。一次 list-panes 答完。 */
2929
+ const liveOf = async (targets) => {
2930
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
2931
+ const live = new Set(snap.ok ? snap.stdout.split("\n").map((l) => l.trim()).filter(Boolean) : []);
2932
+ return targets.filter((t) => live.has(m.sessionInfo(t)?.tmuxPane ?? ""));
2933
+ };
2934
+ /** 这些 wizard 此刻的宪章正文。 */
2935
+ const charterSnapshot = (targets) => new Map(targets.map((t) => [t, charterText(t, charterOptsOf(t, m.getCwd(t).runningCwd || undefined), true)]));
2936
+ /** 把 `next` 那一节换进活的 cfg 渲染一遍再换回 —— 同步完成, 中间没有 await, 别的模块看不见这一瞬。
2937
+ * reload 项落盘后活值不变, 只有这样才算得出它 reload 之后的宪章。 */
2938
+ const charterUnder = (pl) => (targets) => {
2939
+ const live = cfg;
2940
+ const key = pl.path[0];
2941
+ const saved = live[key];
2942
+ live[key] = patchedTop(cfg, pl);
2943
+ try {
2944
+ return charterSnapshot(targets);
2945
+ }
2946
+ finally {
2947
+ live[key] = saved;
2948
+ }
2949
+ };
2950
+ const charterImpact = (before, after) => [...before].flatMap(([t, a]) => { const secs = changedSections(a, after.get(t) ?? a); return secs.length ? [{ target: t, sections: secs }] : []; });
2951
+ /** 受影响的 wizard 各挂一条提醒 (随它下一条消息到); 给调用方的那段话。调用方自己受影响就直说要它 handoff。
2952
+ * 人发起的改动多一句「交接之前还是旧规矩」: 管家要知道它此刻向人汇报的方式还没变, 别让人以为已经生效。 */
2953
+ const announceCharterImpact = (c, hits) => {
2954
+ if (!hits.length)
2955
+ return "";
2956
+ const ask = `${c.reload ? "等守护进程 reload 之后再" : ""}调 \`handoff({brief})\` 原地重开 (手上这件告一段落就交; 宪章在 handoff 时重新生成)`;
2957
+ const lag = (t) => !c.byHuman ? "" : tagOfKey(t)
2958
+ ? " —— 这是人发起的改动: 交接之前你仍按旧规矩做事, handoff 之后才按新的"
2959
+ : " —— 这是人发起的改动: 交接之前你仍按旧规矩向人汇报, handoff 之后才会按新规矩汇报; 回人时说一句「交接后生效」";
2960
+ const others = hits.filter((h) => h.target !== c.self);
2961
+ others.forEach((h) => notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}${lag(h.target)}`));
2962
+ const mine = hits.find((h) => h.target === c.self);
2963
+ return [
2964
+ "",
2965
+ `⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${hits.length} 个: ${hits.map((h) => displayName(h.target)).join(" ")}`,
2966
+ ...(others.length ? [`已给其余 ${others.length} 个各挂一条提醒 (随它们下一条消息到), 让它们${ask}`] : []),
2967
+ ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}${lag(mine.target)}`] : []),
2968
+ ].join("\n");
2969
+ };
2970
+ // 所有会动宪章的写入 (config_set、人 `/name`、name_chat、自动补名) 共用这一个守卫: 写前写后各渲染一遍
2971
+ // 挂在 pane 上的 wizard 的宪章, 变了的、pane 还活着的挂提醒。快照到写完之间不能有 await —— 别人的写入
2972
+ // 插进来, 前后就对不上了。已经写了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
2973
+ guardCharter = (write, change) => {
2974
+ const targets = attachedWizards();
2975
+ const before = charterSnapshot(targets);
2976
+ const r = write();
2977
+ const c = change(r);
2978
+ if (!c)
2979
+ return { r, note: Promise.resolve("") };
2980
+ const fail = (e) => {
2981
+ log.warn({ err: errText(e), what: c.what }, "宪章影响比对失败");
2982
+ return `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的内容, 受影响的 wizard 要 handoff 才换上)`;
2983
+ };
2984
+ try {
2985
+ const hits = charterImpact(before, (c.after ?? charterSnapshot)(targets));
2986
+ return { r, note: liveOf(hits.map((h) => h.target)).then((live) => announceCharterImpact(c, hits.filter((h) => live.includes(h.target)))).catch(fail) };
2987
+ }
2988
+ catch (e) {
2989
+ return { r, note: Promise.resolve(fail(e)) };
2990
+ }
2991
+ };
2992
+ // POST /config/get —— 新 MCP 带 `path` (可空) 拿渲染好的文本; 老 MCP 带 `key` 拿 {key, value}。
2385
2993
  http.register("POST /config/get", async (req, res) => {
2386
2994
  const body = (await readBody(req));
2387
- const r = configGet(cfg, body.key);
2995
+ if (typeof body.key === "string") {
2996
+ const r = legacyGet(cfg, body.key);
2997
+ json(res, r.ok ? 200 : 400, r);
2998
+ return;
2999
+ }
3000
+ // chatPolicy.<聊天名>… 换成它指向的 principal 再查 (同 config_set 的 pinChatKeys); 认不出的原样查。
3001
+ const p = parsePath(body.path);
3002
+ const base = p[0] === "chatPolicy" && p[1] ? chatBaseOf(cfg, p[1]) : "";
3003
+ const r = configGet(cfg, sourcePath, base ? ["chatPolicy", base, ...p.slice(2)].join(".") : body.path, configEvidence);
2388
3004
  json(res, r.ok ? 200 : 400, r);
2389
3005
  });
3006
+ // chatPolicy 的键一律落成 base principal: 聊天名会改, 拿它当键一改名策略就悄悄失效。写名字照收,
3007
+ // 换成它此刻指向的 principal; 认不出的拒写 (写错的键静默无效, 比报错难查)。unset 一个盘上原样存在的
3008
+ // 键不换 —— 留给清理老写法 / 失效的键。
3009
+ const pinChatKeys = (req) => {
3010
+ const p = parsePath(req.path);
3011
+ if (p[0] !== "chatPolicy")
3012
+ return req;
3013
+ const bad = (k) => ({ ok: false, reason: `认不出聊天 \`${k}\`: 键写 base principal (\`chat:wr…\` / \`user:…\`) 或聊天名 (/chats)` });
3014
+ if (p.length === 1) {
3015
+ if (req.value === undefined || typeof req.value !== "object" || Array.isArray(req.value))
3016
+ return req;
3017
+ const pairs = Object.entries(req.value).map(([k, v]) => [k, chatBaseOf(cfg, k), v]);
3018
+ const miss = pairs.find(([, b]) => !b);
3019
+ return miss ? bad(miss[0]) : { ...req, value: Object.fromEntries(pairs.map(([, b, v]) => [b, v])) };
3020
+ }
3021
+ const key = p[1];
3022
+ if (req.op === "unset" && p.length === 2 && key in cfg.chatPolicy)
3023
+ return req;
3024
+ const base = chatBaseOf(cfg, key);
3025
+ return base ? { ...req, path: ["chatPolicy", base, ...p.slice(2)].join(".") } : bad(key);
3026
+ };
3027
+ // POST /config/set —— 口令门见 `guarded`。分级在这里而不在 hook: `card` 项由守护进程
3028
+ // 自己推确认卡, 所以 danger.skipAll / ⏱窗口 都放不掉它。卡等 CONFIRM_INLINE_MS 还没人点
3029
+ // 就先回 pending (fetch 默认 5 分钟收不到头就断), 卡仍有效, 迟到的确认照常落盘并捎一句给调用方。
3030
+ http.register("POST /config/set", guarded(async (req, res) => {
3031
+ const body = (await readBody(req));
3032
+ const legacy = typeof body.key === "string";
3033
+ const asked = legacy ? fromLegacy(body.key, body.value, body.action) : body;
3034
+ if ("ok" in asked) {
3035
+ json(res, 400, asked);
3036
+ return;
3037
+ }
3038
+ const ask = pinChatKeys(asked);
3039
+ if ("ok" in ask) {
3040
+ json(res, 400, ask);
3041
+ return;
3042
+ }
3043
+ const reply = (pl, text, extra = {}) => json(res, 200, legacy
3044
+ ? { ok: true, key: body.key, before: pl.before, after: pl.after, ...extra }
3045
+ : { ok: true, text, ...extra });
3046
+ const pl = planSet(cfg, sourcePath, ask);
3047
+ if (!pl.ok) {
3048
+ json(res, 400, pl);
3049
+ return;
3050
+ }
3051
+ if (ask.dryRun || !pl.changed) {
3052
+ reply(pl, `${ask.dryRun ? "dryRun, 没写:\n" : ""}${renderPlan(pl)}`);
3053
+ return;
3054
+ }
3055
+ // 落盘时重算一遍: 等卡期间文件可能被别人改过, 按那一刻的文件打补丁、再校验。
3056
+ // 宪章快照取在落盘之前 (守卫里): hot 项一 applyHot, 活的 cfg 就已经是新的了。
3057
+ // 不用 resolveSelf: 它最后回落到 defaultChat, 会把认不出的调用方当成那个群的管家。
3058
+ const caller = (body.sessionId?.trim() && m.targetForSession(body.sessionId.trim())) || (body.tmuxPane?.trim() && m.targetForPane(body.tmuxPane.trim())) || undefined;
3059
+ // 人发起 = 调用方这一轮的链头是人 (人在群里 / 单聊里叫它改的)。
3060
+ const byHuman = !!caller && !!askerOf(caller)?.who.startsWith("user:");
3061
+ const commit = async () => {
3062
+ const { r: now, note } = guardCharter(() => {
3063
+ const now = planSet(cfg, sourcePath, ask);
3064
+ if (!now.ok)
3065
+ return now;
3066
+ patchJsonc(sourcePath, [{ path: now.jsonPath, value: now.value }]);
3067
+ applyHot(cfg, now);
3068
+ return now;
3069
+ }, (now) => now.ok ? { what: `\`config_set ${now.path.join(".")}\``, self: caller, byHuman, reload: now.apply === "reload", after: charterUnder(now) } : undefined);
3070
+ return now.ok ? { ...now, charter: await note } : now;
3071
+ };
3072
+ const done = (now) => `已写入${now.apply === "hot" ? ", 已生效" : "; 需 reload (`./cli/wezard.sh reload`) 才生效"}:\n${renderPlan(now)}${now.charter}`;
3073
+ if (pl.gate !== "card") {
3074
+ const now = await commit();
3075
+ if (!now.ok) {
3076
+ json(res, 400, now);
3077
+ return;
3078
+ }
3079
+ reply(now, done(now));
3080
+ return;
3081
+ }
3082
+ const self = resolveSelf(body);
3083
+ if (!self) {
3084
+ json(res, 400, { ok: false, reason: "cannot resolve caller session; 这一项要发确认卡给人点" });
3085
+ return;
3086
+ }
3087
+ let decided;
3088
+ try {
3089
+ ({ decided } = await requestConfirm({
3090
+ client: ws.client, principal: self, who: displayName(self),
3091
+ title: `配置变更待确认 · ${pl.path.join(".")}`,
3092
+ body: `${displayName(self)} 要改配置:\n${renderPlan(pl)}`,
3093
+ timeoutMs: CONFIRM_TIMEOUT_MS,
3094
+ }));
3095
+ }
3096
+ catch (e) {
3097
+ json(res, 503, { ok: false, reason: `确认卡发不出去 (${errText(e)}), 没写` });
3098
+ return;
3099
+ }
3100
+ const settle = async (yes) => {
3101
+ if (!yes)
3102
+ return `人没确认 (拒绝或超时), \`${pl.path.join(".")}\` 没写`;
3103
+ const now = await commit();
3104
+ return now.ok ? done(now) : `人确认了, 但落盘前重算没过, 没写: ${now.reason}`;
3105
+ };
3106
+ const inline = await Promise.race([decided, sleep(CONFIRM_INLINE_MS).then(() => undefined)]);
3107
+ if (inline !== undefined) {
3108
+ const text = await settle(inline);
3109
+ 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 });
3110
+ return;
3111
+ }
3112
+ void decided.then(async (yes) => notices.post([self], `config_set 的确认卡有结果了: ${await settle(yes)}`))
3113
+ .catch((e) => log.warn({ err: errText(e) }, "config_set: 迟到的确认落盘失败"));
3114
+ json(res, 200, legacy
3115
+ ? { ok: false, reason: "确认卡已发到群里, 还没人点; 点了之后自动落盘" }
3116
+ : { ok: true, pending: true, text: `确认卡已发到群里, 还没人点 (卡 ${CONFIRM_TIMEOUT_MS / 60_000} 分钟内有效)。点了之后守护进程自动落盘, 结果会捎在你下一条收到的消息上 —— 不用重发。\n${renderPlan(pl)}` });
3117
+ }));
2390
3118
  // POST /graph/run — declare a loop graph over this chat's tagged sessions
2391
3119
  // and start walking it. Fire-and-forget: returns a runId immediately, then
2392
3120
  // narrates progress into the chat while it advances.
@@ -2411,6 +3139,9 @@ const main = async () => {
2411
3139
  return;
2412
3140
  }
2413
3141
  const graphLog = log.child({ mod: "graph", base: spec.base });
3142
+ // 每一步、每条进度都在发起这次 run 的那一轮的频道里: 人在群里叫它发起的才进群,
3143
+ // 私聊轮里发起的整场只进 rolepage —— 步骤不带频道就等于在节点的 home 群里开口。
3144
+ const channel = m.currentChannel(self);
2414
3145
  const run = startGraph(spec, {
2415
3146
  // Reuse a live tagged pane; only spawn when the node doesn't exist yet
2416
3147
  // (or its pane died). Re-spawning a healthy node would throw away the
@@ -2425,10 +3156,11 @@ const main = async () => {
2425
3156
  const r = await m.newSession(target, node.tag, node.cli, { model: node.model, cwd: node.cwd, silent: true });
2426
3157
  return { ok: r.ok, reason: r.reason };
2427
3158
  },
2428
- send: async (target, text, origin) => (await handedOff(target), m.injectText(target, text, origin)),
3159
+ send: async (target, text, origin) => (await handedOff(target), m.injectText(target, text, origin, { channel })),
2429
3160
  isBusy: m.isBusy,
2430
3161
  lastText: async (target) => m.lastText(target),
2431
- notify: notifyChat,
3162
+ notify: (_base, markdown) => { if (channel)
3163
+ notifyChat(channel, markdown); },
2432
3164
  log: graphLog,
2433
3165
  });
2434
3166
  json(res, 200, { ok: true, runId: run.runId, base: run.base, nodes: spec.nodes.length, steps: spec.steps.length, rounds: spec.rounds ?? 1 });