wezard 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) 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 +84 -2
  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 +265 -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 +10 -10
  19. package/dist/daemon/inbound.js.map +1 -1
  20. package/dist/daemon/index.js +872 -214
  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 +95 -83
  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 +145 -33
  29. package/dist/daemon/notices.js.map +1 -1
  30. package/dist/daemon/peers.js +88 -74
  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 +54 -20
  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/wizard.js +104 -61
  43. package/dist/daemon/wizard.js.map +1 -1
  44. package/dist/mcp/server.js +75 -25
  45. package/dist/mcp/server.js.map +1 -1
  46. package/dist/shared/chat-http.js +8 -8
  47. package/dist/shared/chat-http.js.map +1 -1
  48. package/dist/shared/config-meta.js +91 -0
  49. package/dist/shared/config-meta.js.map +1 -0
  50. package/dist/shared/config-writer.js +5 -6
  51. package/dist/shared/config-writer.js.map +1 -1
  52. package/dist/shared/config.js +269 -294
  53. package/dist/shared/config.js.map +1 -1
  54. package/dist/shared/detail-render.js +121 -32
  55. package/dist/shared/detail-render.js.map +1 -1
  56. package/dist/shared/detail-store.js.map +1 -1
  57. package/dist/shared/keepalive.js +5 -5
  58. package/dist/shared/keepalive.js.map +1 -1
  59. package/dist/shared/role-render.js +49 -11
  60. package/dist/shared/role-render.js.map +1 -1
  61. package/dist/shared/role-view.js +18 -4
  62. package/dist/shared/role-view.js.map +1 -1
  63. package/dist/shared/world.js +82 -13
  64. package/dist/shared/world.js.map +1 -1
  65. package/hooks/pre-tool-use.sh +5 -6
  66. package/package.json +1 -1
  67. package/web/chat.css +57 -37
  68. package/web/chat.js +454 -227
@@ -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,21 @@ 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 { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, backfillBorn, sweepUnborn, BOOT_ID, evictStaleName, childrenOf, ancestorsOf, renderCharter, renderLead, renderLeadPlaybook, ROSTER_FRESH_MS, renderRoster, handoffAt, memoryNudgeAt, MEMORY_NUDGE_FLOOR, MEMORY_MAX, NOTE_MAX, } from "./wizard.js";
44
+ import { bindNoticeBox, createNoticeBox, createSeenRing, gcSeen, chatAudience, hears } from "./notices.js";
45
+ import { createLedger, digest, pendingGc, renderDigest, renderTable, renderAwaiting, renderStalled, renderShelved, ago as agoMs, HEARD_EVERY_MS } from "./pending-items.js";
46
+ import { loadJobStore, dutyLine, awaitingAccept, stalledRoots, shelvedRoots, nudgeDue, dayClamp, childrenOf as jobChildren, jobEpisode, rejectReason, ACCEPTS, JOB_MEMBER_MAX, JOB_DEPTH_MAX, depthOf, ancestorsOf as jobAncestors, treeOrder, jobStage } from "./jobs.js";
47
+ import { cacheTouchMs, cacheTtlSec, clipMiddle, contextFiles, firstStamp, parseClosing, lastContextTokens, lastExchange, lastModel, openingTurn, replyClosedBefore, talkTurns, renderPeerEnvelope, renderReceiptEnvelope, renderTaskEnvelope } from "./peers.js";
44
48
  import { keepalivePingSigs } from "../shared/keepalive.js";
45
49
  import { expandHome } from "../shared/paths.js";
46
50
  import { loadJsonMap } from "../shared/json-map-store.js";
47
- import { createReceipts, deadlineOf, kAttr, newTurn } from "./receipts.js";
51
+ import { createReceipts, deadlineOf, kAttr, kOfAttr, newTurn } from "./receipts.js";
48
52
  import { renderInFlight } from "../shared/turn-state.js";
53
+ import { startIdleNudge } from "./idle-nudge.js";
54
+ import { renderReminder } from "../shared/reminder.js";
49
55
  import { createHandoffs, handedOff, handingOff } from "./handoff.js";
50
- import { rankCandidates, renderCandidates, wakeNoteOf } from "./route.js";
56
+ import { coldGateOf, decide, rankCandidates, renderCandidates, tierFor, wakeNoteOf } from "./route.js";
51
57
  import { parseWhen, renderChatLog, UNKNOWN_HUMAN } from "./chat-log.js";
52
58
  import { audienceOf } from "../shared/role-view.js";
53
59
  import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
@@ -150,9 +156,10 @@ const main = async () => {
150
156
  const t = bridge.targetForSession(sid);
151
157
  return t ? await bridge.interruptPane(t) : { ok: false, reason: "no mirror target for session" };
152
158
  },
159
+ // 告知那一句接着被取消的那一轮说: 频道照它的走 —— 不带频道就等于在 home 群里开口。
153
160
  tell: async (sid, text) => {
154
161
  const t = bridge.targetForSession(sid);
155
- return t ? await bridge.injectText(t, text) : { ok: false, reason: "no mirror target for session" };
162
+ return t ? await bridge.injectText(t, text, undefined, { channel: bridge.currentChannel(t) }) : { ok: false, reason: "no mirror target for session" };
156
163
  },
157
164
  };
158
165
  const http = startHttp({ cfg, ws, log, sourcePath });
@@ -398,13 +405,8 @@ const main = async () => {
398
405
  const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive, silent: true });
399
406
  if (!r.ok && !existed)
400
407
  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
408
  if (r.ok)
407
- postRoster(base, [target, self], `新 wizard **.${name}** 就位${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— 空白起步, 由 ${displayName(self)} 造的`);
409
+ postRoster(base, [target, self], { op: "born", name: `.${name}`, by: displayName(self), cwd: r.cwd }, subjectOf(wizards.get(target)));
408
410
  json(res, r.ok ? 200 : 500, r.ok
409
411
  ? {
410
412
  ok: true,
@@ -633,8 +635,11 @@ const main = async () => {
633
635
  const id = audienceOf(channel, asker);
634
636
  return id === "human:" ? "" : id.slice("human:".length);
635
637
  };
638
+ // 挂起事项表 (见 pending-items.ts): 派活时记一行, 回执落定时由下面的 onOutcome 记账。
639
+ const ledger = createLedger(loadJsonMap(cfg.wrc.mirror.pendingFile, pendingGc));
636
640
  const receipts = createReceipts({
637
641
  idleNow: m.idleNow,
642
+ onOutcome: (s, status, bodyText, bySender) => ledger.outcome(s.from, s.turn ?? "", status, parseClosing(bodyText).text || bodyText, bySender),
638
643
  jobTally: (id) => jobs.tally(id),
639
644
  parkedNow: m.parkedNow,
640
645
  settleJob: (id, target, outcome, artifacts) => { jobs.settle(id, target, outcome, artifacts); },
@@ -658,7 +663,7 @@ const main = async () => {
658
663
  // 只有 tell_peer 那条路登记 watcher, 这里是直接注入。
659
664
  from: { kind: "peer", from: meta.from, receipt: true, status: meta.status, ...(meta.turn ? { turn: meta.turn } : {}), ...(meta.job ? { job: meta.job, done: meta.done, total: meta.total } : {}), ...(meta.asker ? { asker: meta.asker } : {}) },
660
665
  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, {
666
+ envelope: renderReceiptEnvelope(displayName(meta.from), meta.channel ? chatNameOf(cfg, meta.channel) : undefined, meta.job ? { job: meta.job, done: meta.done, total: meta.total, closed: jobs.get(meta.job)?.status !== "open", ...(jobs.get(meta.job)?.kind === "req" ? { req: true } : {}) } : undefined, meta.status, meta.turn || undefined, {
662
667
  ...(meta.replyTo ? { replyTo: meta.replyTo.kind === "chat" ? chatNameOf(cfg, meta.replyTo.channel) : displayName(meta.replyTo.from) } : {}),
663
668
  ...(meta.k ? { k: kAttr(meta.k) } : {}),
664
669
  ...(meta.pending ? { pending: meta.pending } : {}),
@@ -678,15 +683,19 @@ const main = async () => {
678
683
  isBusy: m.isBusy,
679
684
  sessionId: (t) => m.sessionInfo(t)?.sessionId ?? "",
680
685
  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),
686
+ // 欠着回执的发话方挂上它的信封: 新会话那一轮就是在答它, 回执照常按信封定位, 频道也接回
687
+ // 原来那一句所在的地方 (私聊不进群)。欠好几份时每份各开一轮 (见 carryRest): 并成一句,
688
+ // 那一轮就只有一个去向, 别的上游拿到的是答给第一份的话。
689
+ inject: async (t, text, owe, channel) => {
690
+ const [first, ...rest] = owe;
691
+ if (!first)
692
+ return m.injectText(t, text, undefined, { channel });
693
+ const r = await m.injectText(t, text, undefined, carryOpts(first));
694
+ if (r.ok && rest.length)
695
+ void carryRest(t, rest);
696
+ return r;
697
+ },
698
+ channelOf: m.currentChannel,
690
699
  lastText: m.lastText,
691
700
  answeredBefore: (to, from, since, until, turn) => {
692
701
  const p = m.sessionInfo(to)?.jsonlPath;
@@ -707,6 +716,24 @@ const main = async () => {
707
716
  log.warn({ target: e.target, name }, "handoff episode not archived");
708
717
  },
709
718
  });
719
+ /** 交接续答一份欠账的那一句: 挂发话方的信封, 出处与频道都是它当初那一句的。 */
720
+ function carryOpts(o) {
721
+ return {
722
+ from: { kind: "peer", from: o.from, ...(o.turn ? { turn: o.turn } : {}), ...(o.channel ? { public: true } : {}), ...(o.asker ? { asker: o.asker } : {}) },
723
+ channel: o.channel,
724
+ envelope: envelopeFor(o.from, o.channel, o.turn ? { turn: o.turn } : undefined),
725
+ };
726
+ }
727
+ /** 简报那一轮之后, 余下的欠账一份一轮: 等它接住上一句 (至多 20s 动起来) 并答完, 再贴下一份。 */
728
+ async function carryRest(t, owe) {
729
+ for (const o of owe) {
730
+ for (const end = Date.now() + 20_000; Date.now() < end && (await m.idleNow(t));)
731
+ await sleep(1000);
732
+ await m.untilIdle(t, 3600_000);
733
+ const r = await m.injectText(t, `(交接续答)接着上面的简报: 你交接前还欠 ${displayName(o.from)} 一个交代${o.turn ? ` (件 \`${o.turn}\`)` : ""} —— 现在只就这件收口。`, undefined, carryOpts(o));
734
+ log.info({ mod: "handoff", target: t, from: o.from, turn: o.turn, ok: r.ok, reason: r.reason }, "handoff: 欠账续答");
735
+ }
736
+ }
710
737
  /** `self` 此刻这一轮派出去的活, 结论该交给谁 (见 receipts.parentOf)。只读它自己
711
738
  * transcript 里开这一轮的那句 user 行的信封: attachment 上的 channel 在注入时就被
712
739
  * 排队的下一句改写了, transcript 才记着「这一轮是谁发起的」, reload 后也读得到。 */
@@ -714,7 +741,7 @@ const main = async () => {
714
741
  /** `target` 手上这一轮若是某个 wizard 派来的活: 发话方的称呼 (`.x`) 与件号。人 / 定时 / 回执轮 = undefined。 */
715
742
  const openingAsk = (target) => {
716
743
  const p = m.sessionInfo(target)?.jsonlPath;
717
- const env = p ? openingOf(talkTurns(expandHome(p), 80, pingSigs, false, true))?.env : undefined;
744
+ const env = p ? openingTurn(expandHome(p), pingSigs)?.env : undefined;
718
745
  return env?.kind === "peer" && !env.receipt ? { from: env.from, ...(env.turn ? { turn: env.turn } : {}) } : undefined;
719
746
  };
720
747
  const currentAsk = (target) => {
@@ -729,7 +756,7 @@ const main = async () => {
729
756
  if (await m.idleNow(target))
730
757
  return undefined;
731
758
  const p = m.sessionInfo(target)?.jsonlPath;
732
- const env = p ? openingOf(talkTurns(expandHome(p), 80, pingSigs, false, true))?.env : undefined;
759
+ const env = p ? openingTurn(expandHome(p), pingSigs)?.env : undefined;
733
760
  if (env?.receipt)
734
761
  return undefined;
735
762
  if (env?.kind === "peer")
@@ -738,11 +765,12 @@ const main = async () => {
738
765
  };
739
766
  const openingOfSelf = (self) => {
740
767
  const p = m.sessionInfo(self)?.jsonlPath;
741
- return p ? openingOf(talkTurns(expandHome(p), 80, pingSigs, false, true)) : undefined;
768
+ return p ? openingTurn(expandHome(p), pingSigs) : undefined;
742
769
  };
743
770
  const parentKOf = (self) => {
744
771
  const opening = openingOfSelf(self);
745
- return receipts.parentOf(self, opening?.env, channelOf(self), String(opening?.ms ?? 0));
772
+ // 不用 channelOf: 它把私聊轮 ("") 回落成 home, 私聊轮派出的活就会续回 home 群。
773
+ return receipts.parentOf(self, opening?.env, m.currentChannel(self), String(opening?.ms ?? 0));
746
774
  };
747
775
  /** `self` 此刻这一轮的链头 (见 Asker), 同样认开头那句的信封: 人在群里说的 → 信封上的
748
776
  * userid 与群; 与人的单聊里没挂信封 → 那个人; 同伴派的活 / 回执 → 登记那件活时记下的;
@@ -760,6 +788,29 @@ const main = async () => {
760
788
  };
761
789
  // 交接先续做: 它的闸要在回执续守之前立起来, 否则续守的 watcher 会读到换了一半的会话。
762
790
  void m.restored.then(() => { handoffs.resume(); receipts.resume(); });
791
+ // 闲置追问 (见 idle-nudge.ts): 对方持续闲着又没回执 → 代发话方同件号问一句进展, 答复照常回执。
792
+ // 注入照抄工单打回 (rebound) 那条: 私聊信封挂发话方的名义与件号, 父 k 等由 reask 沿用原件。
793
+ void m.restored.then(() => startIdleNudge({
794
+ inflight: () => receipts.states(),
795
+ idleNow: m.idleNow,
796
+ handingOff,
797
+ jobOpen: (id) => jobs.get(id)?.status === "open",
798
+ policy: () => ({ afterMs: cfg.wrc.mirror.idleNudge.afterMin * 60_000, max: cfg.wrc.mirror.idleNudge.max }),
799
+ ask: async ({ from, to, turn, text }) => {
800
+ const x = receipts.reask(from, to, turn);
801
+ if (!x)
802
+ return false;
803
+ ledger.touch(from, turn);
804
+ return (await m.injectText(to, text, undefined, {
805
+ from: { kind: "peer", from, turn, ...(x.asker ? { asker: x.asker } : {}) },
806
+ channel: x.channel,
807
+ envelope: envelopeFor(from, x.channel, { turn, legs: x.legs, re: true }),
808
+ })).ok;
809
+ },
810
+ nameOf: displayName,
811
+ store: loadJsonMap(join(cfg.daemon.stateDir, "idle-nudges.json")),
812
+ log: log.child({ mod: "idle-nudge" }),
813
+ }));
763
814
  /** 入参里的地址: 新字段 `name`, 老 MCP 进程 (正在跑的 wizard) 仍在传 `tag`。 */
764
815
  const addrOf = (b) => String(b.name ?? b.tag ?? "");
765
816
  /** 撞上的名字属于一个静默超过一天的 wizard → 顶掉它 (见 evictStaleName), 同群的
@@ -770,7 +821,7 @@ const main = async () => {
770
821
  return false;
771
822
  const slot = !!tagOfKey(gone.target);
772
823
  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"}`);
824
+ postRoster(baseOfKey(gone.target), [], { op: "ended", name: `.${gone.name}`, why: slot ? "evicted (idle >1d, name reused)" : "name yielded (idle >1d)" }, subjectOf(gone));
774
825
  return true;
775
826
  };
776
827
  /** 给一个要出生的 wizard 挑槽位 (target key) 并落定名字。名字全局唯一:
@@ -846,6 +897,16 @@ const main = async () => {
846
897
  return;
847
898
  }
848
899
  const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
900
+ // 顶层模式: 只有顶层 wizard 对人说话。拦的是「这一轮在为同伴干活」的: 同伴私聊派来的活, 或
901
+ // 回执轮但那件活是为上游同伴派的 (k 是 peer)。人说的、定时任务放的、旁支 (chain:false, 无 k)
902
+ // 的回执轮都不拦; 管家只在自己的群里豁免。
903
+ const env = openingOfSelf(self)?.env;
904
+ const belowTop = env?.kind === "peer" && (env.receipt ? kOfAttr(env.k ?? "")?.kind === "peer" : env.private);
905
+ const muted = dests.filter((d) => chatPolicyOf(cfg, d).topOnly && !(!tagOfKey(self) && baseOfKey(self) === d));
906
+ if (muted.length && belowTop) {
907
+ json(res, 409, { ok: false, topOnly: true, reason: `${muted.map((d) => chatNameOf(cfg, d) || d).join(", ")} 开着顶层模式: 你这一轮是同伴派来的活, 不对人说话 —— 要说的写进终句 (\`RESULT:\` / \`NEED:\`), 由派你的那个代你向人交代` });
908
+ return;
909
+ }
849
910
  const asker = askerOf(self);
850
911
  for (const dest of dests) {
851
912
  notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
@@ -971,16 +1032,23 @@ const main = async () => {
971
1032
  // 等待」: 调用方注入完就返回, 对方干完那一轮的结论由守护进程自动送回来 (receipts)。
972
1033
  // `/peers/send` 保留为同一个处理函数: 正在跑的 wizard 的 MCP 进程是旧代码, 换名字
973
1034
  // 不能把它们的通路掐断。
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
- }
1035
+ // 冷与否 = 缓存最近一次被碰 (最近一次真实请求的开始; 读不到退回 mtime) 距今多久 vs 它实际按多长 TTL 写的
1036
+ // (与 route_candidates 同一口径)。
1037
+ const touchOf = (p) => { try {
1038
+ return cacheTouchMs(p) || statSync(p).mtimeMs;
1039
+ }
1040
+ catch {
1041
+ return 0;
1042
+ } };
1043
+ const ttlOf = (p) => (cacheTtlSec(p) || cfg.wrc.mirror.keepalive.ttlSec) * 1000;
1044
+ // 被冷门控退回过的 `self\0target` → 到期时刻: 老 MCP 进程原样重发即视为 force。
1045
+ const coldRefused = new Map();
1046
+ /** tell_peer 的本体: 以 `self` 的名义说一句 —— HTTP 路由与 dispatch 共用。`extra` = 挂在信封后面的
1047
+ * 一段 (只进对方输入框, 不进 `text`: 公开时 text 会成群气泡)。 */
1048
+ const tellPeerAs = async (self, body, extra = "") => {
980
1049
  const text = (body.text ?? "").toString();
981
1050
  if (!text.trim()) {
982
- json(res, 400, { ok: false, reason: "text required" });
983
- return;
1051
+ return { status: 400, body: { ok: false, reason: "text required" } };
984
1052
  }
985
1053
  // 这句话要对方怎么接 (priority 管何时投, receipt 管要不要回执, 这个管它怎么接):
986
1054
  // task 一件活 (默认) / ask 只答一问 / fyi 知会 —— fyi 不要回执、不进工单、不进群。
@@ -988,8 +1056,7 @@ const main = async () => {
988
1056
  const kind = rawKind === "ask" || rawKind === "fyi" ? rawKind : "task";
989
1057
  const r = resolvePeer(self, addrOf(body));
990
1058
  if (!r.ok) {
991
- json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
992
- return;
1059
+ return { status: r.status, body: { ok: false, reason: r.reason, candidates: r.candidates } };
993
1060
  }
994
1061
  const { target, foreign } = r;
995
1062
  // 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
@@ -1001,12 +1068,16 @@ const main = async () => {
1001
1068
  const open = receipts.pending(self, target);
1002
1069
  const isOpen = (id) => (id && jobs.get(id)?.status === "open" ? id : "");
1003
1070
  const askedJob = (body.job ?? "").trim();
1004
- const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || isOpen(open?.job);
1071
+ // 挂起等子活的那份不算被顶掉: 它会按件号停放、自己计数, 新的一件不继承它的工单。
1072
+ // 返工兜底: 回执槽 24h 过期 / 被顶掉后, 带了 re 却落空成新件, 不带 job 就和根单断了 ——
1073
+ // 只在这种情形下, 发话方是某张开着的需求根单的 owner、目标恰是它的 lead 时才补上;
1074
+ // 没带 re 的普通 task / ask / dispatch 一律不并进根单。
1075
+ const leadJob = jobs.all().find((j) => j.kind === "req" && j.status === "open" && j.owner === self && j.members.some((x) => x.target === target && x.role === "lead"))?.id ?? "";
1076
+ const jobId = kind === "fyi" ? "" : askedJob || isOpen(turn.job) || (open?.deferred ? "" : isOpen(open?.job)) || (re && turn.reUnknown ? leadJob : "");
1005
1077
  if (jobId) {
1006
1078
  const jc = checkJob(jobId, target, !!open?.need && open.turn === turn.turn);
1007
1079
  if (!jc.ok) {
1008
- json(res, jc.status, { ok: false, reason: jc.reason });
1009
- return;
1080
+ return { status: jc.status, body: { ok: false, reason: jc.reason } };
1010
1081
  }
1011
1082
  }
1012
1083
  // 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
@@ -1015,13 +1086,16 @@ const main = async () => {
1015
1086
  // 掉的分身不是谁, 它们的往来进群只会留下一段人找不到主的对话 —— 降为私聊。
1016
1087
  // 工单里的派活一律私聊: 人看不懂 fan-out 的过程, 群里只留发起者自己的收口。
1017
1088
  // 知会不进群: 公开信封要对方「照对人说话的方式答」, 一句「收到」会被发给一群人。
1018
- const isPublic = !jobId && kind !== "fyi" && (turn.channel !== undefined ? !!turn.channel : body.public === true) && !!wizards.get(self) && !!wizards.get(target);
1089
+ // 顶层模式 (chatPolicy.topOnly): 那个群里 wizard 之间不当着人说话 —— 公开与否不归模型判断,
1090
+ // 续问沿用的公开频道也一样降为私聊; 人只从顶层 wizard 的收口里听到结论。
1091
+ const wantPublic = !jobId && kind !== "fyi" && (turn.channel !== undefined ? !!turn.channel : body.public === true) && !!wizards.get(self) && !!wizards.get(target);
1092
+ const topOnly = wantPublic && chatPolicyOf(cfg, turn.channel || channelOf(self)).topOnly;
1093
+ const isPublic = wantPublic && !topOnly;
1019
1094
  const channel = isPublic ? turn.channel || channelOf(self) : "";
1020
1095
  // Injecting into your own pane would type into the box you're generating
1021
1096
  // from — Claude Code queues it and the caller deadlocks waiting for itself.
1022
1097
  if (target === self) {
1023
- json(res, 400, { ok: false, reason: "refusing to inject into the calling session itself" });
1024
- return;
1098
+ return { status: 400, body: { ok: false, reason: "refusing to inject into the calling session itself" } };
1025
1099
  }
1026
1100
  // 它正在交接: 这句话等它换完会话再进 —— 投进旧会话会被交接一并带走, 投进重开中的
1027
1101
  // 空 pane 会 resume 回旧 sid (见 handoff.ts)。发话时刻取在这之后。
@@ -1040,6 +1114,23 @@ const main = async () => {
1040
1114
  : bp.when === "idle" ? "normal"
1041
1115
  : re ? "now" : "normal";
1042
1116
  const waitSec = Math.min(Math.max(Number(body.waitSec ?? 600) || 600, 10), 3600);
1117
+ // 冷门控: 派新活 (task、不是续问、不是插话) 给一个冷且大的 wizard, 先退回一行让派活的
1118
+ // 算账 —— 唤醒它要整段重写缓存, 点名或职责对口只说明相关, 不说明划算。`force:true`
1119
+ // 是「真依赖它那段上下文」的明说。老 MCP 进程的 schema 里没有 force (请求里压根没这个键,
1120
+ // 新的一律显式带上): 同一对 10 分钟内原样再发一次就算 force, 不然它们被拦下就没有出路。
1121
+ const force = body.force;
1122
+ if (kind === "task" && !re && asked !== "now" && force !== true) {
1123
+ const gk = `${self}\0${target}`;
1124
+ const retried = force === undefined && (coldRefused.get(gk) ?? 0) > Date.now();
1125
+ coldRefused.delete(gk);
1126
+ const gi = retried ? undefined : m.sessionInfo(target);
1127
+ const gate = gi ? coldGateOf(`.${displayName(target).replace(/^\./, "")}`, gi.contextTokens, touchOf(gi.jsonlPath), Date.now(), ttlOf(gi.jsonlPath)) : "";
1128
+ if (gate) {
1129
+ if (force === undefined)
1130
+ coldRefused.set(gk, Date.now() + 10 * 60_000);
1131
+ return { status: 409, body: { ok: false, gated: "cold", reason: gate + (force === undefined ? " (你的 MCP 是旧版、没有 force 参数: 10 分钟内原样再发一次即视为 force)" : "") } };
1132
+ }
1133
+ }
1043
1134
  // 「忙」= 这一轮还没结束, 或停在审批上等人 —— 都不是能接新活的时候。
1044
1135
  const wasBusy = !(await m.idleNow(target));
1045
1136
  // urgent 只打断你自己派的那一轮 (或它自己的回执轮): 手上若是别的 wizard / 人 / 定时任务的活,
@@ -1054,8 +1145,7 @@ const main = async () => {
1054
1145
  const wr = await m.untilIdle(target, waitSec * 1000);
1055
1146
  waitedMs = Date.now() - t0;
1056
1147
  if (!wr.idle) {
1057
- json(res, 409, { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪; 真急就 priority:"urgent" 打断它` });
1058
- return;
1148
+ return { status: 409, body: { ok: false, target, foreign, wasBusy, waitedMs, reason: `它一直在忙, ${waitSec}s 内没闲下来 —— peek_peer 看看它卡在哪; 真急就 priority:"urgent" 打断它` } };
1059
1149
  }
1060
1150
  }
1061
1151
  if (policy === "urgent" && wasBusy) {
@@ -1072,12 +1162,7 @@ const main = async () => {
1072
1162
  // 唤醒代价同样要在注入之前量: 投进去 transcript 一动, 就看不出它冷了多久。
1073
1163
  const info = m.sessionInfo(target);
1074
1164
  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)
1165
+ ? wakeNoteOf(info.contextTokens, touchOf(info.jsonlPath), at, ttlOf(info.jsonlPath))
1081
1166
  : "";
1082
1167
  const deadlineSec = body.deadline;
1083
1168
  // 回执: 对方干完那一轮, 守护进程把它的结论自动送回来 (默认开)。`receipt:false`
@@ -1096,7 +1181,7 @@ const main = async () => {
1096
1181
  ...(deadlineSec !== undefined ? { deadline: deadlineAt } : {}),
1097
1182
  ...(kind !== "task" ? { act: kind } : {}),
1098
1183
  ...(wantReceipt ? {} : { quiet: true }),
1099
- }),
1184
+ }) + extra,
1100
1185
  });
1101
1186
  // 注入失败就不守 —— 没有问话, 也不会有回答。不要回执 (receipt:false / fyi) 就不登记:
1102
1187
  // 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
@@ -1105,39 +1190,61 @@ const main = async () => {
1105
1190
  const k = body.chain === false ? undefined : parentKOf(self);
1106
1191
  if (inj.ok && wantReceipt)
1107
1192
  receipts.register({ from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
1193
+ if (inj.ok && wantReceipt) {
1194
+ if (turn.legs > 1)
1195
+ ledger.touch(self, turn.turn);
1196
+ else
1197
+ ledger.open({ turn: turn.turn, owner: self, to: target, text, at, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
1198
+ }
1108
1199
  // 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
1109
1200
  // 续问 (re) 不是一段新活: 工单页与留档里该列的仍是当初派的那段。
1110
- if (inj.ok && jobId)
1111
- jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false });
1201
+ if (inj.ok && jobId) {
1202
+ const role = roleParam(body.role);
1203
+ // G2: 需求根单的 owner 答了 lead 的 NEED —— 方案闸过了 (只记第一次)。
1204
+ const rj = jobs.get(jobId);
1205
+ if (rj?.kind === "req" && self === rj.owner && !rj.gate?.g2 && !!open?.need && open.turn === turn.turn && rj.members.some((x) => x.target === target && x.role === "lead"))
1206
+ jobs.mark(jobId, { gate: { g2: Date.now() } });
1207
+ jobs.attach(jobId, { target, task: turn.legs > 1 ? "" : text, spawned: false, ...(role ? { role } : {}) }, kind === "ask" ? "ask" : "task");
1208
+ }
1112
1209
  const spent = inj.ok && jobId ? jobs.spend(jobId) : undefined;
1113
1210
  if (inj.ok && isPublic)
1114
1211
  relayPeer(self, target, text, channel);
1115
1212
  // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
1116
1213
  // 它这一轮后面, 而不是马上被读到。
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
- });
1214
+ return { status: inj.ok ? 200 : 502, body: {
1215
+ ...inj,
1216
+ name: peerAddress(cfg, self, target),
1217
+ public: isPublic,
1218
+ ...(topOnly ? { topOnly: "这个群开着顶层模式: wizard 之间一律私聊, 已按私聊投出; 结论回到你这儿, 由你 (或你的上游) 对人收口" } : {}),
1219
+ priority: policy,
1220
+ ...(kind !== "task" ? { kind } : {}),
1221
+ ...(kind === "fyi" && askedJob ? { jobIgnored: true } : {}),
1222
+ ...(others ? { urgentDowngraded: `它此刻在做 ${others.from} 派的活, 没打断它 —— 按 normal 等它这一轮做完再投` } : {}),
1223
+ wasBusy,
1224
+ ...(interrupted !== undefined ? { interrupted } : {}),
1225
+ ...(waitedMs ? { waitedMs } : {}),
1226
+ ...(jobId ? { job: jobId } : {}),
1227
+ ...(inj.ok ? { turn: turn.turn } : {}),
1228
+ ...(spent?.maxTurns ? { budget: `${spent.turns}/${spent.maxTurns}${spent.turns >= spent.maxTurns ? " —— 用完了, 这是最后一次派活, 下一步收口" : ""}` } : {}),
1229
+ ...(inj.ok && wake ? { wakeCost: wake } : {}),
1230
+ ...(turn.reUnknown ? { reUnknown: true, reNote: `re "${re}" 不是你正在等 ${displayName(target)} 答的那件, 按新活发出` } : {}),
1231
+ // 回执怎么回来 —— 写在回包里, 调用方 (模型) 不必从工具描述里回忆。
1232
+ ...(inj.ok && wantReceipt
1233
+ ? { receipt: "它干完那一轮, 结论会作为新的一轮自动进到你这里 —— 不要挂在 wait_peer 上等, 接着干你自己的事" }
1234
+ : {}),
1235
+ ...(inj.ok && wantReceipt && k?.kind === "peer"
1236
+ ? { chained: `这件活算在你答 ${displayName(k.from)} 的那件 (${k.turn}) 名下: 它回来之前, 你这一轮的终句不会先交给 ${displayName(k.from)}; 与那件活无关就带 chain:false 发` }
1237
+ : {}),
1238
+ } };
1239
+ };
1240
+ const tellPeer = async (req, res) => {
1241
+ const { self, body } = await readPeerBody(req);
1242
+ if (!self) {
1243
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
1244
+ return;
1245
+ }
1246
+ const r = await tellPeerAs(self, body);
1247
+ json(res, r.status, r.body);
1141
1248
  };
1142
1249
  http.register("POST /peers/tell", tellPeer);
1143
1250
  http.register("POST /peers/send", tellPeer); // 老 MCP 进程还在调这个名字
@@ -1306,8 +1413,113 @@ const main = async () => {
1306
1413
  nudged.add(i.sessionId);
1307
1414
  return [`你的上下文已到 ${Math.round(i.contextTokens / 1000)}k, 快到自动压缩了: 这段里学到的、值得跨会话活下来的东西, 先 \`wizard_remember\` 记下 (自己的选 self, 属于群 / 仓库的选 chat / workspace), 再考虑 \`handoff\``];
1308
1415
  };
1309
- const notices = bindNoticeBox(createNoticeBox(12, memoryNudge));
1310
- const postRoster = (base, except, line) => notices.post(chatAudience(m.chatTargets(base), base, except), line);
1416
+ // 越过交接判断线 (handoffAt) 也只提一次: 交接不是压缩, 不该等快满。同样每段会话一次 ——
1417
+ // 交接后换了 sid, 新会话再越线会再提。
1418
+ const handoffNudged = new Set();
1419
+ const handoffNudge = (t) => {
1420
+ const i = m.sessionInfo(t);
1421
+ if (!i?.sessionId || handoffNudged.has(i.sessionId) || i.contextTokens < handoffAt(lastModel(i.jsonlPath) || i.model))
1422
+ return [];
1423
+ handoffNudged.add(i.sessionId);
1424
+ return [`你的上下文已到 ${Math.round(i.contextTokens / 1000)}k: 该判断要不要 \`handoff\` 了 —— 手上这摊活告一段落、或往后的活不再依赖前面的大段材料, 就交接 (值得跨会话留下的先 \`wizard_remember\`); 还在一件离不开它们的活中间, 做完这一段再交`];
1425
+ };
1426
+ // 挂起事项的提醒 (见 pending-items.digest): 只给管家 —— 各群的默认会话, 同时挂着多件活、
1427
+ // 又被人随时打断的就是它。上次全表时的样子纯内存: reload 后头一次见到只立基线。
1428
+ const pendingMarks = new Map();
1429
+ /** 上次注入之后被 /clear 或压缩截断过的 (onContextCut 记, 下一次注入取走)。 */
1430
+ const pendingCuts = new Map();
1431
+ const liveOfTurns = () => {
1432
+ let byTurn;
1433
+ return (turn) => (byTurn ??= new Map(receipts.states().map((x) => [x.turn, x]))).get(turn);
1434
+ };
1435
+ /** 管家等人验收的根单行 (需求已交付、单还开着)。 */
1436
+ const stalledRows = (owner) => stalledRoots(jobs.all(), owner, jobLive()).map(({ job, status }) => renderStalled(job.title, job.id, status));
1437
+ const awaitingRows = (owner, now) => awaitingAccept(jobs.all(), owner, jobLive()).map(({ job, at }) => renderAwaiting(job.title, job.id, at, now));
1438
+ const shelvedRows = (owner, now) => shelvedRoots(jobs.all(), owner).map((j) => renderShelved(j.title, j.id, j.hold ?? j.openedAt, now));
1439
+ const pendingNames = { nameOf: displayName, chatOf: (b) => chatNameOf(cfg, b) || b };
1440
+ /** 人在某个群最近一次开口 (内存; reload 丢了就当没说过): 重试冒泡避开人在场的时候。 */
1441
+ const humanAt = new Map();
1442
+ /** 「人这句可能在验收某张」同一张至多隔 HEARD_EVERY_MS 提一次。 */
1443
+ const acceptHeard = new Map();
1444
+ const pendingDigest = (t, via) => (seen) => {
1445
+ const cut = pendingCuts.get(t);
1446
+ pendingCuts.delete(t);
1447
+ const now = Date.now();
1448
+ if (via.human)
1449
+ humanAt.set(via.human.channel, now);
1450
+ if (tagOfKey(t))
1451
+ return { text: "", keys: [] };
1452
+ // 人在等验收那件需求的群里开了口: 这句可能就是验收 (是不是由管家判断)。与 digest 的 heard 同一个节流。
1453
+ const acceptLines = (via.human ? awaitingAccept(jobs.all(), t, jobLive()) : [])
1454
+ .filter(({ job }) => job.origin?.chat === via.human?.channel && now - (acceptHeard.get(job.id) ?? 0) >= HEARD_EVERY_MS)
1455
+ .map(({ job, at }) => {
1456
+ acceptHeard.set(job.id, now);
1457
+ return `heard? accept ${job.id} "${job.title}" delivered ${agoMs(now - at)} ago, awaiting the human's verdict: if this line is the verdict → close_job (ok) / tell_peer re to lead (not ok); else ignore`;
1458
+ });
1459
+ const rule = !seen("rule:pending");
1460
+ const items = ledger.openOf(t);
1461
+ // 没挂着的事: 不读会话, 也不留基线 —— 有了第一件再从那一刻立。
1462
+ if (!items.length) {
1463
+ pendingMarks.delete(t);
1464
+ const text = acceptLines.length ? renderDigest({ full: "", lines: acceptLines, touched: [], mark: { sid: "", ctx: 0, ctxAtFull: 0, drains: 0 } }, "", rule) : "";
1465
+ return { text, keys: text && rule ? ["rule:pending"] : [] };
1466
+ }
1467
+ const i = m.sessionInfo(t);
1468
+ const o = digest({ items, mark: pendingMarks.get(t), ...(cut ? { cut } : {}), session: i ? { sid: i.sessionId, ctx: i.contextTokens } : undefined, ...(via.human ? { human: via.human } : {}), now, nameOf: displayName });
1469
+ pendingMarks.set(t, o.mark);
1470
+ o.touched.forEach(ledger.put);
1471
+ const text = renderDigest({ ...o, lines: [...o.lines, ...acceptLines] }, o.full ? renderTable(items, liveOfTurns(), pendingNames, now, awaitingRows(t, now), stalledRows(t), shelvedRows(t, now)) : "", rule);
1472
+ return { text, keys: text && rule ? ["rule:pending"] : [] };
1473
+ };
1474
+ const notices = bindNoticeBox(createNoticeBox({
1475
+ probe: (t) => [...handoffNudge(t), ...memoryNudge(t)],
1476
+ digest: pendingDigest,
1477
+ seen: createSeenRing(loadJsonMap(cfg.wrc.mirror.noticeSeenFile, gcSeen), (t) => m.sessionInfo(t)?.sessionId ?? ""),
1478
+ cwdOf: (t) => ((c) => c.runningCwd || c.defaultCwd)(m.getCwd(t)),
1479
+ }));
1480
+ // 重试冒泡 (设计文档 §3.3.1): 做完了没人验收的需求, 守护进程以管家的名义往原群提醒一句, 不占管家一轮、
1481
+ // 不带单号 (人只认标题); 同时往管家的信箱挂一行带单号的, 人下一句进来时它知道可能在答哪张。
1482
+ // 节奏由根单上的 nudge {n, lastAt} 现算 (见 jobs.nextNudgeAt), 只在白天, 人刚说过话的群顺延 —— reload 不丢、不重发。
1483
+ const NUDGE_TICK_MS = 5 * 60_000;
1484
+ const HUMAN_QUIET_MS = 30 * 60_000;
1485
+ const agoZh = (ms) => ms < 3_600_000 ? `${Math.max(1, Math.round(ms / 60_000))} 分钟前` : ms < 86_400_000 ? `${Math.round(ms / 3_600_000)} 小时前` : `${Math.round(ms / 86_400_000)} 天前`;
1486
+ const nudgeTick = (now = Date.now()) => {
1487
+ if (dayClamp(now) !== now)
1488
+ return 0;
1489
+ const owners = [...new Set(jobs.all().filter((j) => j.kind === "req" && j.status === "open").map((j) => j.owner))];
1490
+ // 只冒群默认管家 (无 tag) 自己的根单: lead 自己 dispatch 出来的子需求不该推给人; 与 pendingDigest 只服务默认会话一致。
1491
+ // owner 须是已登记的 wizard: 伪造的 owner/群 (调试、回归) 不得往 turn store 落 post, 否则 rolepage 凭空长出一个群和一个「人」。
1492
+ const due = owners
1493
+ .filter((o) => !tagOfKey(o) && !!wizards.get(o))
1494
+ .flatMap((o) => awaitingAccept(jobs.all(), o, jobLive()))
1495
+ .filter(({ job, at }) => !!job.origin?.chat && baseOfKey(job.owner) === job.origin.chat && nudgeDue(at, job.nudge, now) && now - (humanAt.get(job.origin.chat) ?? 0) >= HUMAN_QUIET_MS);
1496
+ // 按 (群, owner) 分组: 署名与信箱都跟着 owner。
1497
+ const byChat = due.reduce((acc, x) => { const k = `${x.job.origin.chat}\0${x.job.owner}`; return acc.set(k, [...(acc.get(k) ?? []), x]); }, new Map());
1498
+ byChat.forEach((xs) => {
1499
+ const owner = xs[0].job.owner;
1500
+ const chat = xs[0].job.origin.chat;
1501
+ const one = xs.length === 1;
1502
+ const mark = (i) => (one ? "" : "①②③④⑤⑥⑦⑧⑨⑩"[i] ?? `${i + 1}.`);
1503
+ const text = `有 ${xs.length} 件做完了等你看: ${xs.map(({ job, at }, i) => `${mark(i)}「${job.title}」(${agoZh(now - at)}交付)`).join(" ")} —— 回一句「好」就收, 或者说哪里不对。`;
1504
+ notifyChat(chat, `${relayLabel(owner)}\n\n${text}`);
1505
+ recordPost({ target: owner, channel: chat, body: text });
1506
+ xs.forEach(({ job }) => jobs.mark(job.id, { nudge: { n: (job.nudge?.n ?? 0) + 1, lastAt: now } }));
1507
+ notices.post([owner], `nudged the human (daemon, in your name) about finished requests; their next line may be the verdict on one: ${xs.map(({ job }) => `${job.id} "${job.title}"`).join(" · ")}`);
1508
+ });
1509
+ return due.length;
1510
+ };
1511
+ setInterval(() => { try {
1512
+ nudgeTick();
1513
+ }
1514
+ catch (e) {
1515
+ log.warn({ err: e.message }, "accept nudge failed");
1516
+ } }, NUDGE_TICK_MS).unref();
1517
+ /** `who` = 这次变动的当事人 (见 hears): 不给 = 聊天自己的事 (改名), 全群都听。 */
1518
+ const postRoster = (base, except, n, who) => notices.post(chatAudience(m.chatTargets(base), base, except).filter(who ? hears(who) : () => true), n);
1519
+ /** 注册表里的当事人。要在 forget 抹掉记录之前取; 没有记录 = 谁也不告诉。 */
1520
+ const subjectOf = (w) => (w
1521
+ ? { parent: w.parent, description: w.description, owners: jobs.all().filter((j) => j.status === "open" && j.members.some((x) => x.target === w.target)).map((j) => j.owner) }
1522
+ : {});
1311
1523
  /** 一个聊天的工作区: 它名下各会话跑在哪个目录, 取最多的那个。聊天与工作区是
1312
1524
  * 一对多, 但绝大多数聊天只围着一个项目转。 */
1313
1525
  const chatCwd = (targets) => {
@@ -1341,7 +1553,7 @@ const main = async () => {
1341
1553
  log.info({ base, name }, "chat: auto-named from workspace");
1342
1554
  // 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
1343
1555
  // 得知道自己的地址变了 —— 它的 charter 里写的还是「(未命名)」。
1344
- postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— notify / spawn_wizard 的 \`chat\` 可以用这个名字指到这里。`);
1556
+ postRoster(base, [], { op: "chat", name });
1345
1557
  }
1346
1558
  return named;
1347
1559
  };
@@ -1364,6 +1576,13 @@ const main = async () => {
1364
1576
  log.child({ mod: "wizard" }).info({ killed }, "unborn windows killed"); })
1365
1577
  .catch((e) => log.warn({ err: e.message }, "unborn window reap failed"));
1366
1578
  }
1579
+ /** 成员角色参数: 只认 exec / reviewer / expert (lead 只由 dispatch 在派成之后记, 免得谁都能自封 lead); 不认识的 = 不给。 */
1580
+ const roleParam = (v) => ["exec", "reviewer", "expert"].find((x) => x === v);
1581
+ /** 成员此刻的态: 定论 > 在飞的实时态。每次取一份回执快照。 */
1582
+ const jobLive = () => {
1583
+ const inflight = receipts.states();
1584
+ return (j, mm) => mm.outcome ?? inflight.find((x) => x.to === mm.target && x.job === j.id)?.state;
1585
+ };
1367
1586
  /** 派活前先验工单: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。 */
1368
1587
  /** `free` = 这次不花预算 (不带 task 的 spawn; 答一份停在 NEED 上的反问 —— 拒了它就只能干等到超时)。 */
1369
1588
  const checkJob = (id, target, free = false) => {
@@ -1374,7 +1593,7 @@ const main = async () => {
1374
1593
  return { ok: false, status: 409, reason: `工单 '${id}' 已经收工了` };
1375
1594
  // 已在册的成员 (续问 / 再派一句) 不占新名额。
1376
1595
  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} 个) —— 分身是有成本的, 拆成两个工单, 或者先收工回收掉一批` };
1596
+ return { ok: false, status: 409, reason: `工单 '${id}' 的成员已经满了 (${JOB_MEMBER_MAX} 个直接成员) —— 管辖幅度有限: 把一块子活交给现有成员 (它自己开子单、长一层子 lead), 或者先收工回收掉一批` };
1378
1597
  // 预算是硬的: 与单件活的 legs 不同, 这里拒了也无路可绕 —— 换个人派照样记在这张工单上。
1379
1598
  if (!free && j.maxTurns && (j.turns ?? 0) >= j.maxTurns)
1380
1599
  return { ok: false, status: 409, reason: `工单 '${id}' 的派活预算用完了 (${j.turns}/${j.maxTurns} 次) —— 拿手上已有的结论 close_job 收口, 缺的如实写; 真要接着干就 open_job 开一张新的` };
@@ -1451,11 +1670,13 @@ const main = async () => {
1451
1670
  const nowDate = new Date();
1452
1671
  return {
1453
1672
  wizards: [...live, ...cold],
1454
- jobs: jobs.all().map((j) => ({
1673
+ jobs: ((all, live) => all.map((j) => ({
1455
1674
  id: j.id, base: j.base, owner: j.owner, title: j.title, status: j.status,
1456
1675
  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
- })),
1676
+ ...(j.parent ? { parent: j.parent } : {}), ...(j.kind ? { kind: j.kind } : {}), ...(j.criteria ? { criteria: j.criteria } : {}), ...(j.origin ? { origin: j.origin } : {}), ...(j.end ? { end: j.end } : {}),
1677
+ stage: jobStage(j, all, live),
1678
+ members: j.members.map((mm) => ({ target: mm.target, task: mm.task, spawned: mm.spawned, ...(mm.outcome ? { outcome: mm.outcome } : {}), ...(mm.artifacts?.length ? { artifacts: mm.artifacts } : {}), ...(mm.role ? { role: mm.role } : {}), ...(mm.forkOf ? { forkOf: mm.forkOf } : {}) })),
1679
+ })))(jobs.all(), jobLive()),
1459
1680
  // rolepage 的待办托盘: 与名册 / peek 的「在等 / 欠着」同一份 (receipts.states)。
1460
1681
  inflight: receipts.states().map(({ ageMs, ...x }) => ({ ...x, at: Date.now() - ageMs })),
1461
1682
  schedules: tasks.list().filter((x) => x.enabled).map((x) => {
@@ -1522,39 +1743,53 @@ const main = async () => {
1522
1743
  // 新记的那些就只活在被抹掉的对话里 —— onContextCut 据这份快照补发差集。纯内存: reload
1523
1744
  // 之后没有快照, 只指路不补发。
1524
1745
  const chartered = new Map();
1746
+ const charterOptsOf = (target, cwd) => {
1747
+ const rec = wizards.get(target);
1748
+ return { parent: rec?.parent, forkOf: rec?.forkOf, inherited: !!rec?.clonedFrom, cwd };
1749
+ };
1750
+ /** 宪章正文, 只读 —— 只由 cfg 与此刻的注册表 / 附着表算出, config_set 据此比对前后。`forDiff` 去掉
1751
+ * 宪章里自认是出生快照的配置 (档位那一行, 真相在 config_get): 改档位不该叫全员 handoff。 */
1752
+ const charterText = (target, o, forDiff = false) => renderCharter({
1753
+ // o.cwd = 正在启动的那个 pane 的目录; 没给才退回"现在记着的那个"。
1754
+ self: { ...briefOf(target, target), address: selfAddress(target), ...(o.cwd ? { cwd: o.cwd } : {}) },
1755
+ chat: chatNameOf(cfg, target),
1756
+ principal: baseOfKey(target),
1757
+ parent: o.parent ? briefOf(target, o.parent) : undefined,
1758
+ inherited: !!o.inherited,
1759
+ forkOf: o.forkOf ? briefOf(target, o.forkOf) : undefined,
1760
+ cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
1761
+ // 最近动过的排前面: 名册只点名前几个, 截掉的该是早就停了的那些。
1762
+ // 没有 transcript 的冷记录按 bornAt 算, 刚生下还没开口的照样在。
1763
+ siblings: m.chatTargets(baseOfKey(target))
1764
+ .filter((t) => t !== target)
1765
+ .map((t) => [t, Math.max(lastTouched(t), wizards.get(t)?.bornAt ?? 0)])
1766
+ .filter(([, at]) => Date.now() - at <= ROSTER_FRESH_MS)
1767
+ .sort((x, y) => y[1] - x[1])
1768
+ .map(([t]) => briefOf(target, t)),
1769
+ memory: wizards.get(target)?.memory ?? [],
1770
+ tiers: forDiff ? [] : TIERS.map((n) => { const s = cfg.models.tiers[n]; return `${n}=${s.model || "默认"}${s.effort ? `·${s.effort}` : ""}`; }),
1771
+ chatMemory: sharedMemory("chat", baseOfKey(target)),
1772
+ workspaceMemory: (() => {
1773
+ const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
1774
+ return cwd ? sharedMemory("workspace", cwd) : "";
1775
+ })(),
1776
+ steward: !tagOfKey(target),
1777
+ stewardBudget: chatPolicyOf(cfg, target).stewardBudget,
1778
+ lead: !!wizards.get(target)?.lead,
1779
+ topOnly: chatPolicyOf(cfg, target).topOnly,
1780
+ });
1525
1781
  /** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
1526
1782
  * 让它一睁眼就知道自己不是一个人在跑。 */
1527
1783
  const charterFor = (target, o) => {
1528
1784
  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);
1785
+ const text = charterText(target, o);
1786
+ recordCharter(target, text, o.parent ? { parent: o.parent, kind: o.inherited ? "clone" : "spawn", ...(o.forkOf ? { forkOf: o.forkOf } : {}) } : undefined);
1555
1787
  return text;
1556
1788
  };
1557
1789
  m.onContextCut((t, cut) => {
1790
+ pendingCuts.set(t, cut);
1791
+ // 之前挂过的提示随对话一起没了: 去重窗口清零, 下一次该给的规矩照给。
1792
+ notices.forget(t);
1558
1793
  const now = wizards.get(t)?.memory ?? [];
1559
1794
  const seen = chartered.get(t);
1560
1795
  // 没有快照 (reload 之后) 就不猜差集, 只指路 —— 整份塞进一行能有几万字。
@@ -1580,8 +1815,8 @@ const main = async () => {
1580
1815
  ensureChatNames(target);
1581
1816
  // 信箱里装的是相对上一份宪章的变化; 新宪章一渲染就都过期了 (名册、记忆都已在里面)。
1582
1817
  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 });
1818
+ notices.forget(target);
1819
+ return charterFor(target, charterOptsOf(target, ctx?.cwd));
1585
1820
  });
1586
1821
  // 宪章记录是后来才有的: 已经在跑的 wizard 拿 spawn 落盘的那份 (state/charters/<sid>.md) 补上,
1587
1822
  // 否则要等它下次重生, rolepage 的「宪章」入口才出现。与自动命名同一个延迟: 等附着表恢复完。
@@ -1716,7 +1951,9 @@ const main = async () => {
1716
1951
  // 职责是别人决定"该不该找你"的依据, 改了就得让同群的知道 —— 否则他们照着
1717
1952
  // 出生快照里那句旧的 (或者空的) 职责派活。
1718
1953
  if (asked || description)
1719
- postRoster(baseOfKey(self), [self], `${before && before !== name ? `**.${before}** 改名为 **.${name}**` : `**.${name}** 改了身份`}${description ? ` · 职责: ${description}` : ""}`);
1954
+ postRoster(baseOfKey(self), [self], before && before !== name
1955
+ ? { op: "renamed", name: `.${name}`, was: `.${before}`, ...(description ? { job: description } : {}) }
1956
+ : { op: "job", name: `.${name}`, job: description }, subjectOf(wizards.get(self)));
1720
1957
  json(res, 200, {
1721
1958
  ok: true,
1722
1959
  ...me,
@@ -1757,6 +1994,7 @@ const main = async () => {
1757
1994
  const shown = ranked.slice(0, limit).map((r) => ({
1758
1995
  ...r,
1759
1996
  turns: turnsLine(inflight, r.target),
1997
+ duty: dutyLine(jobs.all(), r.target),
1760
1998
  model: r.model || (r.jsonlPath ? lastModel(r.jsonlPath) : ""),
1761
1999
  summary: r.jsonlPath ? lastExchange(r.jsonlPath, 80, warm) : "",
1762
2000
  contextTokens: r.jsonlPath ? lastContextTokens(r.jsonlPath) : 0,
@@ -1777,21 +2015,11 @@ const main = async () => {
1777
2015
  });
1778
2016
  // 管家分派前的候选表: 事实在这里算 (交集、读过的文件、ctx), 权衡留给管家。
1779
2017
  // 只为活着的读 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
- }
2018
+ /** 候选的原料: 除自己之外每个 wizard 的上下文事实 (文件集 / 最近的话 / ctx / 缓存 TTL)。 */
2019
+ const routeRowsOf = async (self) => {
1792
2020
  ensureChatNames(self);
1793
2021
  const warm = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1794
- const rows = (await rosterOf(self))
2022
+ return (await rosterOf(self))
1795
2023
  .filter((r) => !r.self)
1796
2024
  .map((r) => {
1797
2025
  const live = r.jsonlPath ? r.jsonlPath : "";
@@ -1800,15 +2028,139 @@ const main = async () => {
1800
2028
  ...r,
1801
2029
  contextTokens: live ? lastContextTokens(live) : 0,
1802
2030
  ...(ttl ? { cacheTtlMs: ttl * 1000 } : {}),
2031
+ ...(live ? { cacheAt: cacheTouchMs(live) } : {}),
1803
2032
  files: live ? contextFiles(live) : [],
1804
2033
  asks: live ? talkTurns(live, 12, warm).filter((t) => t.role === "user").map((t) => t.text) : [],
1805
2034
  summary: live ? lastExchange(live, 80, warm) : "",
1806
2035
  };
1807
2036
  });
2037
+ };
2038
+ http.register("POST /wizard/route", async (req, res) => {
2039
+ const { self, body } = await readPeerBody(req);
2040
+ if (!self) {
2041
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2042
+ return;
2043
+ }
2044
+ const b = body;
2045
+ const task = (b.task ?? "").trim();
2046
+ if (!task) {
2047
+ json(res, 400, { ok: false, reason: "task is required" });
2048
+ return;
2049
+ }
1808
2050
  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);
2051
+ const cands = rankCandidates(task, (b.cwd ?? "").trim() || m.getCwd(self).runningCwd, await routeRowsOf(self)).slice(0, limit);
1810
2052
  json(res, 200, { ok: true, text: renderCandidates(task, cands, Date.now(), cfg.wrc.mirror.keepalive.ttlSec * 1000, homedir()) });
1811
2053
  });
2054
+ // 派活收成一步 (见 route.decide): 查候选 → 过成本门控 → 转给已有的或按档白板 spawn →
2055
+ // tell_peer 投出去, 回决定与理由。默认决定由守护进程算; 管家不同意就 `to` 点名、
2056
+ // `force` 越过冷门控、`spawn:true` 硬要新的 —— 推翻要显式说。
2057
+ http.register("POST /dispatch", async (req, res) => {
2058
+ const { self, body } = await readPeerBody(req);
2059
+ if (!self) {
2060
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2061
+ return;
2062
+ }
2063
+ const b = body;
2064
+ const task = (b.task ?? "").toString().trim();
2065
+ if (!task) {
2066
+ json(res, 400, { ok: false, reason: "task is required" });
2067
+ return;
2068
+ }
2069
+ const lead = b.lead === true;
2070
+ const force = b.force === true;
2071
+ const to = (b.to ?? "").toString().trim();
2072
+ const now = Date.now();
2073
+ const ttlMs = cfg.wrc.mirror.keepalive.ttlSec * 1000;
2074
+ // 自动选只在这些里选: 在线的 (没 pane 的 ctx 量不出, 成本门会盲过)、不是哪个群的管家、
2075
+ // 不是别人开着的工单里的队员 —— 点名 (`to`) 不受此限。
2076
+ const inOthersJob = (t) => jobs.openOf(baseOfKey(t)).some((j) => j.owner !== self && j.members.some((mm) => mm.target === t));
2077
+ const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target) && !inOthersJob(r.target));
2078
+ const cands = rankCandidates(task, m.getCwd(self).runningCwd, pool).slice(0, 5);
2079
+ let d = to ? { kind: "existing", why: `你点名了 ${to}` }
2080
+ : b.spawn ? { kind: "spawn", why: "你要了新的" }
2081
+ : lead ? { kind: "spawn", why: "复杂活: 白板起一个 hard 档的 lead, 由它组队" }
2082
+ : decide(task, cands, now, ttlMs, force);
2083
+ // 复杂活 (lead:true) 是一件「需求」: 先开需求根单 (账本, 不占 pane), 这一句带它派下去, lead 记成根单成员。
2084
+ // 小活不开单。dryRun 不落账, 所以放在它后面开 (见下)。
2085
+ const criteria = (b.criteria ?? "").toString().trim();
2086
+ let rootId = "";
2087
+ const tell = (name, text, extra = "") => tellPeerAs(self, {
2088
+ name, text, public: b.public !== false, force,
2089
+ // lead 的活远超默认的 1h: 不给 deadline 就按 12h (回执的上限)。
2090
+ ...(b.deadline !== undefined ? { deadline: b.deadline } : lead ? { deadline: 43200 } : {}),
2091
+ ...(rootId ? { job: rootId } : {}),
2092
+ }, extra);
2093
+ const shown = cands.slice(0, 3).map((e) => `.${e.row.address || e.row.name}${e.row.busy ? " 忙" : ""}`);
2094
+ if (b.dryRun) {
2095
+ json(res, 200, { ok: true, dryRun: true, decision: d.kind, reason: d.why, ...(d.kind === "existing" ? { name: to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "") } : { tier: (b.tier ?? "").toString().trim() || tierFor(task, lead) }), ...(shown.length ? { candidates: shown } : {}) });
2096
+ return;
2097
+ }
2098
+ if (lead) {
2099
+ const head = (b.description ?? "").toString().trim() || task.split("\n")[0].trim();
2100
+ rootId = jobs.open(baseOfKey(self), self, truncate(head, 60), {
2101
+ kind: "req", ...(criteria ? { criteria, gate: { g1: now } } : {}),
2102
+ origin: { text: (currentHumanQueryOf(self) || task).trim(), chat: channelOf(self) },
2103
+ }).id;
2104
+ }
2105
+ // 没投成的根单当场收掉: 留着只是一张没人干的空单。
2106
+ const settleRoot = (ok) => { if (rootId && !ok)
2107
+ jobs.close(rootId, "派活没投成"); };
2108
+ const withRoot = (body) => rootId
2109
+ ? { ...body, job: rootId, hint: "需求根单已开, 对人**别提单号**。lead 交付后根单仍开着, 等人验收: 人认可 → close_job(job) 归档并回收 lead; 人说不对 → tell_peer({name, re, job}) 给 lead 返工 (同一张单, 不开新单); 人说不要了 → close_job({job, as:\"cancel\"}); 先放着 → close_job({job, as:\"shelve\"}) (停提醒, 不关单)。" }
2110
+ : body;
2111
+ if (d.kind === "existing") {
2112
+ const name = to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "");
2113
+ const r = resolvePeer(self, name);
2114
+ if (!r.ok) {
2115
+ settleRoot(false);
2116
+ json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
2117
+ return;
2118
+ }
2119
+ // 已有 wizard 当 lead: 宪章是出生时定的 (里面没有 lead 一节), 规矩加打法挂在信封后面带过去;
2120
+ // 已是 lead 的宪章里已有层级规矩, 只带打法。投成了才记 lead, 它下次重生宪章里就有。
2121
+ const t = await tell(name, task, lead ? renderReminder({ wezard: "lead" }, wizards.get(r.target)?.lead ? renderLeadPlaybook() : renderLead()) : "");
2122
+ if (lead && t.status === 200)
2123
+ wizards.upsert(r.target, { lead: true });
2124
+ if (rootId && t.status === 200)
2125
+ jobs.attach(rootId, { target: r.target, task: "", spawned: false, role: "lead" });
2126
+ // 自动选中的被冷门控退回 (decide 用名册的活动时刻, 门控用 transcript 的, 口径差一点): 改走 spawn。
2127
+ if (!(t.body.gated === "cold" && !to)) {
2128
+ settleRoot(t.status === 200);
2129
+ json(res, t.status, withRoot({ ...t.body, decision: "existing", reason: d.why, ...(shown.length ? { candidates: shown } : {}) }));
2130
+ return;
2131
+ }
2132
+ d = { kind: "spawn", why: `${d.why}; 但它被冷门控退回 (${String(t.body.reason)}) —— 改为白板 spawn` };
2133
+ }
2134
+ const name = (b.name ?? "").toString().trim();
2135
+ const description = (b.description ?? "").toString().trim();
2136
+ if (!name || !description) {
2137
+ settleRoot(false);
2138
+ json(res, 400, { ok: false, decision: "spawn", reason: `${d.why} —— 要新生一个, 带上 name (这件事的短名) 与 description (它的职责) 重发`, ...(shown.length ? { candidates: shown } : {}) });
2139
+ return;
2140
+ }
2141
+ const tier = (b.tier ?? "").toString().trim() || tierFor(task, lead);
2142
+ const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) });
2143
+ if (born.status !== 200) {
2144
+ settleRoot(false);
2145
+ json(res, born.status, { ...born.body, decision: "spawn", reason: d.why });
2146
+ return;
2147
+ }
2148
+ // 宪章里只有层级规矩, 打法随信封带 (宪章每轮都在上下文里, 打法只这一件活用得上)。
2149
+ const t = await tell(String(born.body.name), task, lead ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "");
2150
+ settleRoot(t.status === 200);
2151
+ // 为这张根单生出来的: 归档 close_job 时要回收它 (tell 记的是 spawned:false, 再 attach 一次补上; lead 角色也在这里记)。
2152
+ if (rootId && t.status === 200)
2153
+ jobs.attach(rootId, { target: String(born.body.target), task: "", spawned: true, role: "lead" });
2154
+ json(res, t.status, withRoot({
2155
+ ...t.body,
2156
+ decision: "spawn",
2157
+ reason: d.why,
2158
+ tier,
2159
+ ...(born.body.model ? { model: born.body.model } : {}),
2160
+ ...(born.body.effort ? { effort: born.body.effort } : {}),
2161
+ ...(shown.length ? { candidates: shown } : {}),
2162
+ }));
2163
+ });
1812
2164
  // 记忆三种作用域: self 跟着自己 (wizards.json), 直写; chat / workspace 是共享的 md,
1813
2165
  // 每个在那个群 / 那个目录出生的 wizard 都会读到 —— 它们只收提议, 由整理者合并
1814
2166
  // (见 wizard-memory.ts / memory-steward.ts)。
@@ -1871,62 +2223,56 @@ const main = async () => {
1871
2223
  });
1872
2224
  // 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
1873
2225
  // 个干活的"之所以省事的原因: 材料只读一遍, 却进了 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
- }
2226
+ /** spawn_wizard / clone_wizard 的本体 —— HTTP 路由与 dispatch 共用。 */
2227
+ const bearAs = async (self, body) => {
1880
2228
  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;
2229
+ const askedEffort = parseEffort(b.effort);
2230
+ if (b.effort !== undefined && !askedEffort) {
2231
+ return { status: 400, body: { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` } };
2232
+ }
2233
+ const pick = pickTier(cfg.models, b.tier, { cli: b.cli, model: b.model, effort: askedEffort }, b.inherit === true);
2234
+ if (!pick.ok) {
2235
+ return { status: 400, body: pick };
1885
2236
  }
2237
+ const effort = pick.effort;
1886
2238
  // 工单先验: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。
1887
2239
  const jobId = (b.job ?? "").trim();
1888
2240
  if (jobId) {
1889
2241
  const jc = checkJob(jobId, undefined, !(b.task ?? "").toString().trim());
1890
2242
  if (!jc.ok) {
1891
- json(res, jc.status, { ok: false, reason: jc.reason });
1892
- return;
2243
+ return { status: jc.status, body: { ok: false, reason: jc.reason } };
1893
2244
  }
1894
2245
  }
1895
2246
  // `inherit` 没有默认值 —— 继承与否是两种完全不同的分身 (一种开局就带着你
1896
2247
  // 读过的一切, 另一种白纸一张), 猜错了要么白烧一份上下文, 要么让它从零重读。
1897
2248
  // 逼调用方每次自己说。
1898
2249
  if (typeof b.inherit !== "boolean") {
1899
- json(res, 400, { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" });
1900
- return;
2250
+ return { status: 400, body: { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" } };
1901
2251
  }
1902
2252
  const inherit = b.inherit;
1903
2253
  // detached = 独立长住 (原 new_claude_session / 人在群里 `/new .x`): 不挂家谱、不占
1904
2254
  // 调用方的分身名额、不随工单回收。它和「fork 我的上下文」「归某张工单」都互斥。
1905
2255
  const detached = b.detached === true;
1906
2256
  if (detached && (inherit || (b.job ?? "").trim())) {
1907
- json(res, 400, { ok: false, reason: "detached 只用于白板 (inherit:false) 且不带 job —— 独立长住的 wizard 不归你管, 也不随工单回收" });
1908
- return;
2257
+ return { status: 400, body: { ok: false, reason: "detached 只用于白板 (inherit:false) 且不带 job —— 独立长住的 wizard 不归你管, 也不随工单回收" } };
1909
2258
  }
1910
2259
  // 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
1911
2260
  // 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
1912
2261
  // 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
1913
2262
  const fromAddr = (b.from ?? "").toString().trim();
1914
2263
  if (fromAddr && !inherit) {
1915
- json(res, 400, { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" });
1916
- return;
2264
+ return { status: 400, body: { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" } };
1917
2265
  }
1918
2266
  const fromR = fromAddr ? resolvePeer(self, fromAddr) : { ok: true, target: self };
1919
2267
  if (!fromR.ok) {
1920
- json(res, fromR.status, { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) });
1921
- return;
2268
+ return { status: fromR.status, body: { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) } };
1922
2269
  }
1923
2270
  const source = fromR.target;
1924
2271
  const sourceInfo = m.sessionInfo(source);
1925
2272
  // 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
1926
2273
  // 要的恰恰是它的上下文, 给一张白纸等于答非所问。
1927
2274
  if (source !== self && !sourceInfo?.sessionId) {
1928
- json(res, 409, { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` });
1929
- return;
2275
+ return { status: 409, body: { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` } };
1930
2276
  }
1931
2277
  const alivePeers = detached ? [] : await m.peers(self);
1932
2278
  // 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
@@ -1934,30 +2280,27 @@ const main = async () => {
1934
2280
  // 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
1935
2281
  const aliveKids = childrenOf(wizards.all(), self).filter((k) => alivePeers.some((pp) => pp.target === k.target && pp.paneAlive));
1936
2282
  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;
2283
+ return { status: 429, body: {
2284
+ ok: false,
2285
+ reason: `你名下已经有 ${aliveKids.length} 个活着的分身 (上限 ${cfg.wrc.mirror.cloneMax}) —— 先 stop_wizard 收掉干完活的那些, 或者 close_job 整批回收一个工单`,
2286
+ clones: aliveKids.map((k) => peerAddress(cfg, self, k.target)),
2287
+ } };
1943
2288
  }
1944
2289
  const wantChat = (b.chat ?? "").toString().trim();
1945
2290
  const base = wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self);
1946
2291
  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;
2292
+ return { status: 404, body: {
2293
+ ok: false,
2294
+ reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
2295
+ candidates: listChatNames(cfg).map((c) => c.name),
2296
+ } };
1953
2297
  }
1954
2298
  const askedName = String(b.name ?? b.tag ?? "");
1955
2299
  // 没起名: 独立长住的按目录名 (同 /new 与 new_claude_session), 分身按职责的首词。
1956
2300
  const fallback = (detached && b.cwd ? tagFromCwd(b.cwd) : "") || normalizeTag(b.description?.split(/\s+/)[0]) || "clone";
1957
2301
  const slotR = await claimSlot(base, askedName, fallback, [self, source]);
1958
2302
  if (!slotR.ok) {
1959
- json(res, slotR.status, slotR.body);
1960
- return;
2303
+ return { status: slotR.status, body: slotR.body };
1961
2304
  }
1962
2305
  const { target, slot: tag } = slotR;
1963
2306
  // claimSlot 会复用同名的冷记录 (detached 正是「叫回一个老名字」的场景): 生不出来时
@@ -1975,6 +2318,9 @@ const main = async () => {
1975
2318
  // fork 了上下文就一并继承 self 记忆: 系统提示不随 fork 走 (分身拿的是自己的宪章),
1976
2319
  // 不拷的话它开局就没有, /clear 或交接重开后更是彻底没了。标上来源; 已经是继承来的不再套一层。
1977
2320
  ...(inherit ? { memory: (wizards.get(source)?.memory ?? []).map((x) => x.startsWith("(继承自 ") ? x : `(继承自 ${displayName(source)}) ${x}`) } : {}),
2321
+ tier: pick.tier,
2322
+ // 显式写 undefined: 复用的冷记录可能当过 lead。
2323
+ lead: b.lead === true ? true : undefined,
1978
2324
  // 生完才清; 这个进程中途没了, 下次开机由 sweepUnborn 认出并收掉。
1979
2325
  spawning: BOOT_ID,
1980
2326
  });
@@ -1992,15 +2338,16 @@ const main = async () => {
1992
2338
  parent: source,
1993
2339
  target,
1994
2340
  windowName: name,
1995
- cli: b.cli,
1996
- model: b.model,
2341
+ cli: pick.cli,
2342
+ model: pick.model,
1997
2343
  effort,
1998
2344
  cwd: b.cwd,
1999
2345
  systemPrompt: charter,
2000
2346
  inherit,
2001
2347
  // 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
2002
2348
  // 也少一次"就位了但没事干"的空转。
2003
- bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn }) : undefined,
2349
+ // 子 lead 的宪章只有层级规矩, 打法跟第一句的信封走 (同 dispatch 对已有 wizard 的做法)。
2350
+ bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "") : undefined,
2004
2351
  ...(task ? { bootstrapFrom: { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, query: task } } : {}),
2005
2352
  keepalive,
2006
2353
  }).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
@@ -2010,20 +2357,19 @@ const main = async () => {
2010
2357
  wizards.upsert(target, { spawning: undefined });
2011
2358
  else
2012
2359
  wizards.drop(target);
2013
- json(res, 500, { ok: false, reason: r.reason });
2014
- return;
2360
+ return { status: 500, body: { ok: false, reason: r.reason } };
2015
2361
  }
2016
2362
  wizards.upsert(target, { spawning: undefined, clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
2017
2363
  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
2364
  // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
2024
2365
  // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
2025
2366
  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)} ` : "它"}的上下文)` : ""}`);
2367
+ // 模型 / keepalive 不报: 那是生它的那位的事, 旁人要时 wizard_roster 有。
2368
+ postRoster(base, [target, self], {
2369
+ op: "born", name: `.${name}`, by: displayName(self), clone: !detached,
2370
+ ...(r.inherited ? { ctx: forkOf ? displayName(forkOf) : displayName(self) } : {}),
2371
+ ...(kid.description ? { job: kid.description } : {}), cwd: r.cwd,
2372
+ }, subjectOf(wizards.get(target)));
2027
2373
  }
2028
2374
  // 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
2029
2375
  let dispatched = r.inherited && !!task;
@@ -2031,8 +2377,11 @@ const main = async () => {
2031
2377
  const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, channel: "", envelope: envelopeFor(self, "", { turn: taskTurn }) });
2032
2378
  dispatched = inj.ok;
2033
2379
  }
2034
- if (jobId)
2035
- jobs.attach(jobId, { target, task, spawned: true });
2380
+ if (jobId) {
2381
+ const role = roleParam(body.role);
2382
+ const from = inherit ? source : undefined;
2383
+ jobs.attach(jobId, { target, task, spawned: true, ...(role ? { role } : {}), ...(from ? { forkOf: from } : {}) });
2384
+ }
2036
2385
  if (jobId && dispatched)
2037
2386
  jobs.spend(jobId);
2038
2387
  // 分身的第一件活也守回执: fan-out 最常见的形状就是 clone_wizard({task}) × N,
@@ -2040,7 +2389,18 @@ const main = async () => {
2040
2389
  const k = body.chain === false ? undefined : parentKOf(self);
2041
2390
  if (dispatched)
2042
2391
  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 } : {}) });
2392
+ if (dispatched)
2393
+ ledger.open({ turn: taskTurn, owner: self, to: target, text: task, at: taskAt, ...(jobId ? { job: jobId } : {}), ...(k ? { for: k } : {}), ...(asker ? { asker } : {}) });
2394
+ return { status: 200, body: { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, ...(dispatched ? { turn: taskTurn } : {}), keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(r.effort ? { effort: r.effort } : {}), ...(pick.tier ? { tier: pick.tier } : {}), via: pick.via, ...(pick.note ? { tierNote: pick.note } : {}), ...(jobId ? { job: jobId } : {}), ...(detached ? { detached: true } : {}) } };
2395
+ };
2396
+ http.register("POST /wizard/clone", async (req, res) => {
2397
+ const { self, body } = await readPeerBody(req);
2398
+ if (!self) {
2399
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2400
+ return;
2401
+ }
2402
+ const r = await bearAs(self, body);
2403
+ json(res, r.status, r.body);
2044
2404
  });
2045
2405
  // 收掉一个 wizard。interrupt = 打断它这一轮 (Esc); end = 结束它并回收 pane。
2046
2406
  // 自己终结自己是合法的 (分身干完活自我了结), 代价是这次工具调用不会返回 ——
@@ -2073,6 +2433,8 @@ const main = async () => {
2073
2433
  }
2074
2434
  const { target } = r;
2075
2435
  const victim = briefOf(self, target);
2436
+ // 当事人要在 forget 抹掉记录之前取 (见 subjectOf)。
2437
+ const victimRec = wizards.get(target);
2076
2438
  const end = (b.mode ?? "end") === "end";
2077
2439
  if (!end && target === self) {
2078
2440
  json(res, 400, { ok: false, reason: "打断自己没有意义 —— 你就是正在生成的那一个" });
@@ -2122,7 +2484,7 @@ const main = async () => {
2122
2484
  const detached = end && b.forget ? forgetWizard(target) : [];
2123
2485
  // 打断只是停了它这一轮, 它还在; 只有结束才是名册变了。
2124
2486
  if (end)
2125
- postRoster(baseOfKey(target), [target, self], `**${victim.name || target}** 已收工 · 由 ${displayName(self)} 结束${b.forget ? " (记录一并抹掉)" : ""}`);
2487
+ postRoster(baseOfKey(target), [target, self], { op: "ended", name: victim.name ? `.${victim.name}` : target, by: displayName(self), ...(b.forget ? { why: "forgotten" } : {}) }, subjectOf(victimRec));
2126
2488
  json(res, 200, { ok: true, target, name: victim.name, mode: end ? "end" : "interrupt", forgotten: !!(end && b.forget), ...(canceled ? { canceled } : {}), ...(detached.length ? { canceledIn: detached } : {}) });
2127
2489
  });
2128
2490
  // 换模型 / effort: 省略名字 = 换自己 (选择器盖在正在跑的这一轮上面照样能开), 点名 =
@@ -2203,13 +2565,38 @@ const main = async () => {
2203
2565
  const base = baseOfKey(self);
2204
2566
  const maxTurns = Math.min(200, Math.max(0, Math.floor(Number(b.maxTurns) || 0)));
2205
2567
  const plan = (b.plan ?? "").toString().trim();
2568
+ const criteria = (b.criteria ?? "").toString().trim();
2569
+ // 上级工单: 显式传的要求自己是它的成员或开单者; 不传 = 自己作为未落定成员所在的、最近开的那张
2570
+ // (lead 在需求根单里领了活, 开的第一张子单自然挂在根单下, 不用自己记号)。
2571
+ const all = jobs.all();
2572
+ const mineIn = (j) => j.owner === self || j.members.some((x) => x.target === self);
2573
+ const askedParent = (b.parent ?? "").toString().trim();
2574
+ // 推断: 同一个群里、我作为未落定成员的开着的单, 取我**最近一次领活** (mm.at) 的那张; 它已在最深一层就不挂了 (退成根单, 不 409)。
2575
+ const inferred = all
2576
+ .filter((j) => j.base === base && j.status === "open")
2577
+ .flatMap((j) => j.members.filter((x) => x.target === self && !x.outcome).map((x) => ({ j, at: x.at })))
2578
+ .sort((x, y) => y.at - x.at)[0]?.j;
2579
+ const parentJob = askedParent ? jobs.get(askedParent) : inferred && depthOf(jobs.get, inferred) < JOB_DEPTH_MAX ? inferred : undefined;
2580
+ if (askedParent && !parentJob) {
2581
+ json(res, 404, { ok: false, reason: `没有上级工单 '${askedParent}'` });
2582
+ return;
2583
+ }
2584
+ if (askedParent && parentJob && (parentJob.status !== "open" || !mineIn(parentJob))) {
2585
+ json(res, 409, { ok: false, reason: `上级工单 '${askedParent}' 已收工, 或你既不是它的成员也不是它的开单者` });
2586
+ return;
2587
+ }
2588
+ if (parentJob && depthOf(jobs.get, parentJob) + 1 > JOB_DEPTH_MAX) {
2589
+ json(res, 409, { ok: false, reason: `工单树最深 ${JOB_DEPTH_MAX} 层, 你这张 (挂在 ${parentJob.id} 下) 会是第 ${depthOf(jobs.get, parentJob) + 1} 层 —— 子活交给现有成员, 别再往下长` });
2590
+ return;
2591
+ }
2206
2592
  // 不出群气泡: 工单是私下的过程, 人看到的只有发起者那一轮的最终回复。
2207
2593
  const accept = ACCEPTS.find((x) => x === b.accept);
2208
- const job = jobs.open(base, self, title, { ...(plan ? { plan } : {}), ...(accept ? { accept } : {}), ...(expect ? { expect } : {}), ...(maxTurns ? { maxTurns } : {}) });
2594
+ const job = jobs.open(base, self, title, { ...(plan ? { plan } : {}), ...(accept ? { accept } : {}), ...(expect ? { expect } : {}), ...(maxTurns ? { maxTurns } : {}), ...(parentJob ? { parent: parentJob.id } : {}), ...(criteria ? { criteria } : {}) });
2209
2595
  json(res, 200, {
2210
2596
  ok: true,
2211
2597
  job: job.id,
2212
2598
  title,
2599
+ ...(parentJob ? { parent: parentJob.id } : {}),
2213
2600
  memberMax: JOB_MEMBER_MAX,
2214
2601
  hint: "把这个 id 传给 spawn_wizard / clone_wizard / tell_peer 的 `job` 参数, 它们就归到这个工单名下 (一律私聊, 不进群); 活干完调 close_job 留档并回收临时分身, 给人的结论写在你自己这一轮的最终回复里。",
2215
2602
  });
@@ -2231,23 +2618,97 @@ const main = async () => {
2231
2618
  json(res, 409, { ok: false, reason: `工单 '${id}' 已经收工了`, closedAt: job.closedAt });
2232
2619
  return;
2233
2620
  }
2234
- // 回收只针对**为这个工单生出来的**分身: 被拉来帮忙的长期 wizard 不该因为一次
2235
- // 活结束就被杀掉。调用方自己也不收 —— 那会在这次工具调用里把自己干掉。
2621
+ // 只有这张单或它某个祖先单的 owner 能关 (成员、同群的旁人不行)。
2622
+ if (![job, ...jobAncestors(jobs.get, job)].some((j) => j.owner === self)) {
2623
+ json(res, 403, { ok: false, reason: `工单 '${id}' 不是你开的, 也不是你开的某张上级单的后代 —— 只有开单者 (或上级单的开单者) 能收工` });
2624
+ return;
2625
+ }
2626
+ // 四种收法 (只 accept / cancel 真关单): shelve / resume 只动需求根单的搁置标记, 单照旧开着。
2627
+ const how = ["accept", "cancel", "shelve", "resume"].find((x) => x === (b.as ?? "accept"));
2628
+ if (!how) {
2629
+ json(res, 400, { ok: false, reason: `as 只认 accept / cancel / shelve / resume, 收到 '${b.as}'` });
2630
+ return;
2631
+ }
2632
+ if ((how === "shelve" || how === "resume") && job.kind !== "req") {
2633
+ json(res, 400, { ok: false, reason: `只有需求根单能搁置 / 恢复, '${id}' 不是 (它不是 dispatch({lead:true}) 开的根单)` });
2634
+ return;
2635
+ }
2636
+ if (how === "shelve" || how === "resume") {
2637
+ // 恢复不是「又交付了」: 保留 n, lastAt 记成此刻, 下一次冒泡从现在起算, 不一恢复就立刻冒。
2638
+ jobs.mark(id, how === "shelve" ? { hold: Date.now() } : { hold: undefined, nudge: { n: job.nudge?.n ?? 0, lastAt: Date.now() } });
2639
+ json(res, 200, { ok: true, job: id, [how === "shelve" ? "shelved" : "resumed"]: true });
2640
+ return;
2641
+ }
2642
+ // 关一张单连它还开着的后代单一起关 (深的先关): 否则子 lead 的分身留在原地、账本上挂着没人收的单。
2643
+ // 回收只针对**为这个工单生出来的**分身: 被拉来帮忙的长期 wizard 不该因为一次活结束就被杀掉。
2644
+ // 调用方自己也不收 —— 那会在这次工具调用里把自己干掉。
2236
2645
  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 });
2646
+ const openKids = (j) => jobChildren(jobs.all(), j.id).filter((c) => c.status === "open").flatMap((c) => [...openKids(c), c]);
2647
+ const cascade = openKids(job);
2648
+ const closeOne = async (j, summary, mark) => {
2649
+ const victims = recycle ? j.members.filter((mm) => mm.spawned && mm.target !== self) : [];
2650
+ // 收掉之前把它们手上没落定的活记成 canceled: 不然发起者收工之后还会收到一串 dead 回执。
2651
+ victims.forEach((mm) => receipts.cancel(mm.target, j.owner));
2652
+ // 留档要的 sessionId 得在回收之前取: killPane 连绑定一起删。
2653
+ const sids = new Map([j.owner, ...j.members.map((mm) => mm.target)].map((t) => [t, m.sessionInfo(t)?.sessionId ?? ""]));
2654
+ const killed = await victims.reduce(async (acc, mm) => (await acc) + ((await m.killPane(mm.target)).ok ? 1 : 0), Promise.resolve(0));
2655
+ // 取消: 还没落定、又不是为这张单生的成员 (长住 wizard) 不杀, 但它们名下这张单派的活记成 canceled, 发起者不会再收到一串回执。
2656
+ if (how === "cancel")
2657
+ j.members.filter((mm) => !mm.outcome && !victims.includes(mm)).forEach((mm) => receipts.cancel(mm.target, j.owner, (x) => x.from === j.owner));
2658
+ const closed = jobs.close(j.id, summary, mark);
2659
+ ledger.closeJob(j.id);
2660
+ // 收工结论留档到开单者名下 (与交接简报同一份 jsonl); 空工单 (没人、没结论) 不记。
2661
+ if (closed.members.length || closed.summary?.trim()) {
2662
+ const ep = jobEpisode(closed, { nameOf: episodeName, sidOf: (t) => sids.get(t) ?? "", chat: chatNameOf(cfg, j.base) || j.base });
2663
+ if (!appendEpisode(episodePath(cfg.daemon.stateDir, ep.name), ep))
2664
+ log.warn({ job: j.id, name: ep.name }, "job episode not archived");
2665
+ }
2666
+ return { members: closed.members.length, killed, kept: victims.length - killed };
2667
+ };
2668
+ const rs = await [...cascade.map((c) => [c, `随上级单 ${id} 收工`]), [job, how === "cancel" ? `取消: ${(b.summary ?? "").toString()}` : (b.summary ?? "").toString()]]
2669
+ // 根单收工: accept = G3 验收过闸 (人认可), cancel = 取消; 非根单没有闸。
2670
+ .reduce(async (acc, [j, sum]) => [...(await acc), await closeOne(j, sum, j !== job ? undefined : how === "cancel" ? { end: "cancel" } : job.kind === "req" ? { gate: { g3: Date.now() }, end: "accept" } : undefined)], Promise.resolve([]));
2671
+ const sum = (f) => rs.reduce((n, r) => n + f(r), 0);
2672
+ json(res, 200, { ok: true, job: id, members: rs[rs.length - 1].members, recycled: sum((r) => r.killed), kept: sum((r) => r.kept), ...(cascade.length ? { cascaded: cascade.map((c) => c.id) } : {}) });
2673
+ });
2674
+ // 挂起事项全表 (见 pending-items.ts): 你派出去、还没了结的事。`drop` 显式消项;
2675
+ // `name` 看别人的 (只读)。读过全表就算刚给过一次: 自动的全表提醒从此刻重新计。
2676
+ http.register("POST /pending", async (req, res) => {
2677
+ const { self, body } = await readPeerBody(req);
2678
+ if (!self) {
2679
+ json(res, 400, { ok: false, reason: "cannot resolve caller session" });
2680
+ return;
2681
+ }
2682
+ const b = body;
2683
+ const who = (b.name ?? "").toString().trim();
2684
+ const r = who ? resolvePeer(self, who) : undefined;
2685
+ if (r && !r.ok) {
2686
+ json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
2687
+ return;
2688
+ }
2689
+ const owner = r?.ok ? r.target : self;
2690
+ const drop = Array.isArray(b.drop) ? b.drop.map(String) : typeof b.drop === "string" ? [b.drop] : [];
2691
+ if (drop.length && owner !== self) {
2692
+ json(res, 403, { ok: false, reason: "只能消自己派出去的" });
2693
+ return;
2694
+ }
2695
+ const dropped = drop.length ? ledger.drop(self, drop, (b.why ?? "").toString().trim()) : [];
2696
+ const now = Date.now();
2697
+ const items = ledger.openOf(owner);
2698
+ if (owner === self) {
2699
+ const i = m.sessionInfo(self);
2700
+ if (i)
2701
+ pendingMarks.set(self, { sid: i.sessionId, ctx: i.contextTokens, ctxAtFull: i.contextTokens, drains: 0 });
2702
+ }
2703
+ const missed = drop.filter((t) => !dropped.some((x) => x.turn === t.replace(/[`\s]/g, "")));
2704
+ json(res, 200, {
2705
+ ok: true,
2706
+ text: [
2707
+ ...(dropped.length ? [`已消掉: ${dropped.map((x) => `\`${x.turn}\``).join(" ")}`] : []),
2708
+ ...(missed.length ? [`没找到 (不是你开着的): ${missed.join(" ")}`] : []),
2709
+ renderTable(items, liveOfTurns(), pendingNames, now, awaitingRows(owner, now), stalledRows(owner), shelvedRows(owner, now)),
2710
+ ].join("\n"),
2711
+ });
2251
2712
  });
2252
2713
  http.register("POST /jobs/list", async (req, res) => {
2253
2714
  const { self } = await readPeerBody(req);
@@ -2256,11 +2717,21 @@ const main = async () => {
2256
2717
  return;
2257
2718
  }
2258
2719
  const inflight = receipts.states();
2720
+ const live = jobLive();
2721
+ const everything = jobs.all();
2722
+ // 本群还开着的, 外加它们名下已收工的子单 (账本留着, 面包屑 / 树才不断)。
2723
+ const shown = everything.filter((j) => j.base === baseOfKey(self) && (j.status === "open" || jobAncestors(jobs.get, j).some((a) => a.status === "open")));
2259
2724
  json(res, 200, {
2260
2725
  ok: true,
2261
- jobs: jobs.openOf(baseOfKey(self)).map((j) => ({
2726
+ jobs: treeOrder(shown).map(([j, depth]) => ({
2262
2727
  job: j.id,
2263
2728
  title: j.title,
2729
+ depth,
2730
+ ...(j.parent ? { parent: j.parent } : {}),
2731
+ ...(j.kind ? { kind: j.kind } : {}),
2732
+ stage: jobStage(j, everything, live),
2733
+ ...(j.criteria ? { criteria: j.criteria } : {}),
2734
+ status: j.status,
2264
2735
  owner: displayName(j.owner),
2265
2736
  mine: j.owner === self,
2266
2737
  openedAt: j.openedAt,
@@ -2268,9 +2739,11 @@ const main = async () => {
2268
2739
  ...(j.maxTurns ? { budget: `${j.turns ?? 0}/${j.maxTurns}` } : {}),
2269
2740
  members: j.members.map((mm) => ({
2270
2741
  address: peerAddress(cfg, self, mm.target),
2742
+ role: mm.role ?? "exec",
2271
2743
  task: mm.task.split("\n")[0] ?? "",
2272
2744
  spawned: mm.spawned,
2273
2745
  state: mm.outcome ?? inflight.find((x) => x.to === mm.target && x.job === j.id)?.state ?? "unknown",
2746
+ ...(mm.forkOf ? { forkOf: peerAddress(cfg, self, mm.forkOf) } : {}),
2274
2747
  ...(mm.artifacts?.length ? { artifacts: mm.artifacts } : {}),
2275
2748
  })),
2276
2749
  })),
@@ -2375,18 +2848,199 @@ const main = async () => {
2375
2848
  }
2376
2849
  json(res, 200, { ok: true, removed: renderTask(gone, state) });
2377
2850
  });
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
- }));
2851
+ // 确认卡在请求里最多等 4 分钟 (留出 fetch 5 分钟收头的余量), 卡本身半小时有效。
2852
+ const CONFIRM_INLINE_MS = 240_000;
2853
+ const CONFIRM_TIMEOUT_MS = 30 * 60_000;
2854
+ // computeUsage 要扫一周的 transcript, 一分钟内的连问共用一份。
2855
+ let usageMemo;
2856
+ const tierUsage = () => {
2857
+ if (usageMemo && Date.now() - usageMemo.at < 60_000)
2858
+ return usageMemo.u;
2859
+ const r = computeUsage();
2860
+ const fold = (m) => new Map([...m].map(([id, x]) => [id, { tokens: sumTotal(x), cost: costOf(id, x) }]));
2861
+ usageMemo = { at: Date.now(), u: { today: fold(r.today), week: fold(r.week) } };
2862
+ return usageMemo.u;
2863
+ };
2864
+ const configEvidence = (p) => tierEvidence(cfg.models, p, tierUsage, (tier) => wizards.all().filter((w) => w.tier === tier).length, { tokens: fmtTokens, cost: fmtCost });
2865
+ // ── 改配置会不会动到宪章 ─────────────────────────────────────────
2866
+ // 宪章是 --append-system-prompt 压进去的, 随进程终身: 改了进宪章的配置, 已在跑的 wizard
2867
+ // 看不见, 要 handoff 重开 (重新渲染宪章) 才换上。不按字段清单判 —— 宪章读哪些配置随代码变,
2868
+ // 清单迟早漏; 改前改后各渲染一遍有会话的 wizard 的宪章, 变了的就是受影响的。
2869
+ const charterSections = (text) => new Map(text.split(/^(?=## )/m).map((sec) => [sec.split("\n", 1)[0].replace(/^## /, ""), sec]));
2870
+ const changedSections = (a, b) => {
2871
+ const x = charterSections(a), y = charterSections(b);
2872
+ return [...new Set([...x.keys(), ...y.keys()])].filter((k) => x.get(k) !== y.get(k));
2873
+ };
2874
+ /** pane 活着的 wizard —— 宪章在效的只有它们; 冷的醒来时宪章重渲染, 自然是新的。一次 list-panes 答完。 */
2875
+ const liveWizards = async () => {
2876
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
2877
+ const live = new Set(snap.ok ? snap.stdout.split("\n").map((l) => l.trim()).filter(Boolean) : []);
2878
+ return wizards.all().map((w) => w.target).filter((t) => live.has(m.sessionInfo(t)?.tmuxPane ?? ""));
2879
+ };
2880
+ /** 这些 wizard 此刻的宪章正文。 */
2881
+ const charterSnapshot = (targets) => new Map(targets.map((t) => [t, charterText(t, charterOptsOf(t, m.getCwd(t).runningCwd || undefined), true)]));
2882
+ /** 把 `next` 那一节换进活的 cfg 渲染一遍再换回 —— 同步完成, 中间没有 await, 别的模块看不见这一瞬。
2883
+ * reload 项落盘后活值不变, 只有这样才算得出它 reload 之后的宪章。 */
2884
+ const charterUnder = (pl, targets) => {
2885
+ const live = cfg;
2886
+ const key = pl.path[0];
2887
+ const saved = live[key];
2888
+ live[key] = patchedTop(cfg, pl);
2889
+ try {
2890
+ return charterSnapshot(targets);
2891
+ }
2892
+ finally {
2893
+ live[key] = saved;
2894
+ }
2895
+ };
2896
+ const charterImpact = (before, pl) => {
2897
+ const after = charterUnder(pl, [...before.keys()]);
2898
+ return [...before].flatMap(([t, a]) => { const secs = changedSections(a, after.get(t) ?? a); return secs.length ? [{ target: t, sections: secs }] : []; });
2899
+ };
2900
+ /** 受影响的 wizard 各挂一条提醒 (随它下一条消息到); 给调用方的那段话。调用方自己受影响就直说要它 handoff。 */
2901
+ const announceCharterImpact = (pl, hits, self) => {
2902
+ if (!hits.length)
2903
+ return "";
2904
+ const when = pl.apply === "reload" ? "等守护进程 reload 之后再" : "";
2905
+ const ask = `${when}调 \`handoff({brief})\` 原地重开 (手上这件告一段落就交; 宪章在 handoff 时重新生成)`;
2906
+ const others = hits.filter((h) => h.target !== self);
2907
+ others.forEach((h) => notices.post([h.target], `\`config_set ${pl.path.join(".")}\` 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}`));
2908
+ const mine = hits.find((h) => h.target === self);
2909
+ return [
2910
+ "",
2911
+ `⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${hits.length} 个: ${hits.map((h) => displayName(h.target)).join(" ")}`,
2912
+ ...(others.length ? [`已给其余 ${others.length} 个各挂一条提醒 (随它们下一条消息到), 让它们${ask}`] : []),
2913
+ ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}`] : []),
2914
+ ].join("\n");
2915
+ };
2916
+ // POST /config/get —— 新 MCP 带 `path` (可空) 拿渲染好的文本; 老 MCP 带 `key` 拿 {key, value}。
2385
2917
  http.register("POST /config/get", async (req, res) => {
2386
2918
  const body = (await readBody(req));
2387
- const r = configGet(cfg, body.key);
2919
+ if (typeof body.key === "string") {
2920
+ const r = legacyGet(cfg, body.key);
2921
+ json(res, r.ok ? 200 : 400, r);
2922
+ return;
2923
+ }
2924
+ const r = configGet(cfg, sourcePath, body.path, configEvidence);
2388
2925
  json(res, r.ok ? 200 : 400, r);
2389
2926
  });
2927
+ // chatPolicy 的键一律落成 base principal: 聊天名会改, 拿它当键一改名策略就悄悄失效。写名字照收,
2928
+ // 换成它此刻指向的 principal; 认不出的拒写 (写错的键静默无效, 比报错难查)。unset 一个盘上原样存在的
2929
+ // 键不换 —— 留给清理老写法 / 失效的键。
2930
+ const pinChatKeys = (req) => {
2931
+ const p = parsePath(req.path);
2932
+ if (p[0] !== "chatPolicy")
2933
+ return req;
2934
+ const bad = (k) => ({ ok: false, reason: `认不出聊天 \`${k}\`: 键写 base principal (\`chat:wr…\` / \`user:…\`) 或聊天名 (/chats)` });
2935
+ if (p.length === 1) {
2936
+ if (req.value === undefined || typeof req.value !== "object" || Array.isArray(req.value))
2937
+ return req;
2938
+ const pairs = Object.entries(req.value).map(([k, v]) => [k, chatBaseOf(cfg, k), v]);
2939
+ const miss = pairs.find(([, b]) => !b);
2940
+ return miss ? bad(miss[0]) : { ...req, value: Object.fromEntries(pairs.map(([, b, v]) => [b, v])) };
2941
+ }
2942
+ const key = p[1];
2943
+ if (req.op === "unset" && p.length === 2 && key in cfg.chatPolicy)
2944
+ return req;
2945
+ const base = chatBaseOf(cfg, key);
2946
+ return base ? { ...req, path: ["chatPolicy", base, ...p.slice(2)].join(".") } : bad(key);
2947
+ };
2948
+ // POST /config/set —— 口令门见 `guarded`。分级在这里而不在 hook: `card` 项由守护进程
2949
+ // 自己推确认卡, 所以 danger.skipAll / ⏱窗口 都放不掉它。卡等 CONFIRM_INLINE_MS 还没人点
2950
+ // 就先回 pending (fetch 默认 5 分钟收不到头就断), 卡仍有效, 迟到的确认照常落盘并捎一句给调用方。
2951
+ http.register("POST /config/set", guarded(async (req, res) => {
2952
+ const body = (await readBody(req));
2953
+ const legacy = typeof body.key === "string";
2954
+ const asked = legacy ? fromLegacy(body.key, body.value, body.action) : body;
2955
+ if ("ok" in asked) {
2956
+ json(res, 400, asked);
2957
+ return;
2958
+ }
2959
+ const ask = pinChatKeys(asked);
2960
+ if ("ok" in ask) {
2961
+ json(res, 400, ask);
2962
+ return;
2963
+ }
2964
+ const reply = (pl, text, extra = {}) => json(res, 200, legacy
2965
+ ? { ok: true, key: body.key, before: pl.before, after: pl.after, ...extra }
2966
+ : { ok: true, text, ...extra });
2967
+ const pl = planSet(cfg, sourcePath, ask);
2968
+ if (!pl.ok) {
2969
+ json(res, 400, pl);
2970
+ return;
2971
+ }
2972
+ if (ask.dryRun || !pl.changed) {
2973
+ reply(pl, `${ask.dryRun ? "dryRun, 没写:\n" : ""}${renderPlan(pl)}`);
2974
+ return;
2975
+ }
2976
+ // 落盘时重算一遍: 等卡期间文件可能被别人改过, 按那一刻的文件打补丁、再校验。
2977
+ // 宪章快照取在落盘之前: hot 项一 applyHot, 活的 cfg 就已经是新的了。
2978
+ // 不用 resolveSelf: 它最后回落到 defaultChat, 会把认不出的调用方当成那个群的管家。
2979
+ const caller = (body.sessionId?.trim() && m.targetForSession(body.sessionId.trim())) || (body.tmuxPane?.trim() && m.targetForPane(body.tmuxPane.trim())) || undefined;
2980
+ const commit = async () => {
2981
+ const targets = await liveWizards();
2982
+ // 从这里到 applyHot 之间不能有 await: 快照与落盘之间别人的 config_set 插进来, 前后就对不上了。
2983
+ const now = planSet(cfg, sourcePath, ask);
2984
+ if (!now.ok)
2985
+ return now;
2986
+ const before = charterSnapshot(targets);
2987
+ patchJsonc(sourcePath, [{ path: now.jsonPath, value: now.value }]);
2988
+ applyHot(cfg, now);
2989
+ // 已经落盘了: 比对失败只少一句提醒, 不能让调用方以为没写而重发。
2990
+ try {
2991
+ return { ...now, charter: announceCharterImpact(now, charterImpact(before, now), caller) };
2992
+ }
2993
+ catch (e) {
2994
+ log.warn({ err: errText(e), path: now.path.join(".") }, "config_set: 宪章影响比对失败");
2995
+ return { ...now, charter: `\n(宪章影响没比对出来: ${errText(e)} —— 改的若是进宪章的配置, 受影响的 wizard 要 handoff 才换上)` };
2996
+ }
2997
+ };
2998
+ const done = (now) => `已写入${now.apply === "hot" ? ", 已生效" : "; 需 reload (`./cli/wezard.sh reload`) 才生效"}:\n${renderPlan(now)}${now.charter}`;
2999
+ if (pl.gate !== "card") {
3000
+ const now = await commit();
3001
+ if (!now.ok) {
3002
+ json(res, 400, now);
3003
+ return;
3004
+ }
3005
+ reply(now, done(now));
3006
+ return;
3007
+ }
3008
+ const self = resolveSelf(body);
3009
+ if (!self) {
3010
+ json(res, 400, { ok: false, reason: "cannot resolve caller session; 这一项要发确认卡给人点" });
3011
+ return;
3012
+ }
3013
+ let decided;
3014
+ try {
3015
+ ({ decided } = await requestConfirm({
3016
+ client: ws.client, principal: self, who: displayName(self),
3017
+ title: `配置变更待确认 · ${pl.path.join(".")}`,
3018
+ body: `${displayName(self)} 要改配置:\n${renderPlan(pl)}`,
3019
+ timeoutMs: CONFIRM_TIMEOUT_MS,
3020
+ }));
3021
+ }
3022
+ catch (e) {
3023
+ json(res, 503, { ok: false, reason: `确认卡发不出去 (${errText(e)}), 没写` });
3024
+ return;
3025
+ }
3026
+ const settle = async (yes) => {
3027
+ if (!yes)
3028
+ return `人没确认 (拒绝或超时), \`${pl.path.join(".")}\` 没写`;
3029
+ const now = await commit();
3030
+ return now.ok ? done(now) : `人确认了, 但落盘前重算没过, 没写: ${now.reason}`;
3031
+ };
3032
+ const inline = await Promise.race([decided, sleep(CONFIRM_INLINE_MS).then(() => undefined)]);
3033
+ if (inline !== undefined) {
3034
+ const text = await settle(inline);
3035
+ json(res, 200, legacy ? { ok: inline, key: body.key, before: pl.before, after: inline ? pl.after : pl.before, note: text } : { ok: true, text, confirmed: inline });
3036
+ return;
3037
+ }
3038
+ void decided.then(async (yes) => notices.post([self], `config_set 的确认卡有结果了: ${await settle(yes)}`))
3039
+ .catch((e) => log.warn({ err: errText(e) }, "config_set: 迟到的确认落盘失败"));
3040
+ json(res, 200, legacy
3041
+ ? { ok: false, reason: "确认卡已发到群里, 还没人点; 点了之后自动落盘" }
3042
+ : { ok: true, pending: true, text: `确认卡已发到群里, 还没人点 (卡 ${CONFIRM_TIMEOUT_MS / 60_000} 分钟内有效)。点了之后守护进程自动落盘, 结果会捎在你下一条收到的消息上 —— 不用重发。\n${renderPlan(pl)}` });
3043
+ }));
2390
3044
  // POST /graph/run — declare a loop graph over this chat's tagged sessions
2391
3045
  // and start walking it. Fire-and-forget: returns a runId immediately, then
2392
3046
  // narrates progress into the chat while it advances.
@@ -2411,6 +3065,9 @@ const main = async () => {
2411
3065
  return;
2412
3066
  }
2413
3067
  const graphLog = log.child({ mod: "graph", base: spec.base });
3068
+ // 每一步、每条进度都在发起这次 run 的那一轮的频道里: 人在群里叫它发起的才进群,
3069
+ // 私聊轮里发起的整场只进 rolepage —— 步骤不带频道就等于在节点的 home 群里开口。
3070
+ const channel = m.currentChannel(self);
2414
3071
  const run = startGraph(spec, {
2415
3072
  // Reuse a live tagged pane; only spawn when the node doesn't exist yet
2416
3073
  // (or its pane died). Re-spawning a healthy node would throw away the
@@ -2425,10 +3082,11 @@ const main = async () => {
2425
3082
  const r = await m.newSession(target, node.tag, node.cli, { model: node.model, cwd: node.cwd, silent: true });
2426
3083
  return { ok: r.ok, reason: r.reason };
2427
3084
  },
2428
- send: async (target, text, origin) => (await handedOff(target), m.injectText(target, text, origin)),
3085
+ send: async (target, text, origin) => (await handedOff(target), m.injectText(target, text, origin, { channel })),
2429
3086
  isBusy: m.isBusy,
2430
3087
  lastText: async (target) => m.lastText(target),
2431
- notify: notifyChat,
3088
+ notify: (_base, markdown) => { if (channel)
3089
+ notifyChat(channel, markdown); },
2432
3090
  log: graphLog,
2433
3091
  });
2434
3092
  json(res, 200, { ok: true, runId: run.runId, base: run.base, nodes: spec.nodes.length, steps: spec.steps.length, rounds: spec.rounds ?? 1 });