wezard 2.0.0 → 2.0.2

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 (59) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CLAUDE.md +7 -8
  4. package/README.md +20 -32
  5. package/cli/wezard.sh +0 -62
  6. package/config.example.jsonc +3 -9
  7. package/dist/cli/init.js +12 -21
  8. package/dist/cli/init.js.map +1 -1
  9. package/dist/daemon/approval.js +2 -4
  10. package/dist/daemon/approval.js.map +1 -1
  11. package/dist/daemon/cc-session.js +136 -0
  12. package/dist/daemon/cc-session.js.map +1 -0
  13. package/dist/daemon/detail.js +2 -3
  14. package/dist/daemon/detail.js.map +1 -1
  15. package/dist/daemon/inbound.js +90 -110
  16. package/dist/daemon/inbound.js.map +1 -1
  17. package/dist/daemon/index.js +130 -111
  18. package/dist/daemon/index.js.map +1 -1
  19. package/dist/daemon/memory-steward.js +82 -0
  20. package/dist/daemon/memory-steward.js.map +1 -0
  21. package/dist/daemon/mirror-bridge.js +201 -62
  22. package/dist/daemon/mirror-bridge.js.map +1 -1
  23. package/dist/daemon/session-name.js +4 -27
  24. package/dist/daemon/session-name.js.map +1 -1
  25. package/dist/daemon/spawn-tmux.js +35 -13
  26. package/dist/daemon/spawn-tmux.js.map +1 -1
  27. package/dist/daemon/wizard-memory.js +21 -26
  28. package/dist/daemon/wizard-memory.js.map +1 -1
  29. package/dist/daemon/wizard.js +54 -11
  30. package/dist/daemon/wizard.js.map +1 -1
  31. package/dist/mcp/server.js +63 -153
  32. package/dist/mcp/server.js.map +1 -1
  33. package/dist/shared/chat-http.js +44 -16
  34. package/dist/shared/chat-http.js.map +1 -1
  35. package/dist/shared/chat-render.js +10 -8
  36. package/dist/shared/chat-render.js.map +1 -1
  37. package/dist/shared/cli-backends.js +2 -7
  38. package/dist/shared/cli-backends.js.map +1 -1
  39. package/dist/shared/config.js +10 -12
  40. package/dist/shared/config.js.map +1 -1
  41. package/dist/shared/detail-render.js +35 -16
  42. package/dist/shared/detail-render.js.map +1 -1
  43. package/dist/shared/detail-store.js +14 -3
  44. package/dist/shared/detail-store.js.map +1 -1
  45. package/dist/shared/role-render.js +4 -1
  46. package/dist/shared/role-render.js.map +1 -1
  47. package/dist/shared/role-view.js +49 -11
  48. package/dist/shared/role-view.js.map +1 -1
  49. package/dist/shared/world.js +11 -1
  50. package/dist/shared/world.js.map +1 -1
  51. package/hooks/pre-tool-use.sh +4 -4
  52. package/package.json +1 -2
  53. package/web/chat.css +103 -58
  54. package/web/chat.js +153 -82
  55. package/commands/wrc.md +0 -7
  56. package/dist/daemon/cc-bridge.js +0 -183
  57. package/dist/daemon/cc-bridge.js.map +0 -1
  58. package/dist/daemon/sessions.js +0 -4
  59. package/dist/daemon/sessions.js.map +0 -1
@@ -7,9 +7,7 @@ import { startNetWatch } from "./net-watch.js";
7
7
  import { startHttp, json, readBody } from "./http.js";
8
8
  import { configGet, configSet } from "./config-api.js";
9
9
  import { installInboundRouter } from "./inbound.js";
10
- import { loadSessionStore } from "./sessions.js";
11
10
  import { loadMirrorStore } from "./mirror-store.js";
12
- import { makeBridge } from "./cc-bridge.js";
13
11
  import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
14
12
  import { setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
15
13
  import { installApprovalEventListener, makeApproveHandler } from "./approval.js";
@@ -28,9 +26,10 @@ import { openTaskRegistry } from "./task-registry.js";
28
26
  import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
29
27
  import { slugify, uniqueId } from "../shared/task-file.js";
30
28
  import { baseOfKey, bindTagLinker, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
31
- import { appendMemory, forgetMemory, memoryPath, readMemory } from "./wizard-memory.js";
29
+ import { clipForCharter, inboxPath, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
30
+ import { ensureStewardTask } from "./memory-steward.js";
32
31
  import { applyChatNames, chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
33
- import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
32
+ import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, evictStaleName, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
34
33
  import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.js";
35
34
  import { loadJobStore, renderJobOpen, renderJobClose, JOB_MEMBER_MAX } from "./jobs.js";
36
35
  import { extractResult } from "./peers.js";
@@ -80,23 +79,22 @@ const main = async () => {
80
79
  // last-response tracker enables inbound's `quote` dedup of bot self-replies,
81
80
  // and its chat-gate drops header-only pushes (empty messages) daemon-wide.
82
81
  installResponseTracker(ws.client, log.child({ mod: "chat-gate" }));
83
- const sessions = loadSessionStore(cfg.wrc.sessionMapFile);
84
82
  const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
85
83
  // 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
86
84
  // (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
87
- const tasks = await openTaskRegistry(log.child({ mod: "tasks" }), (added, removed) => log.info({ added, removed }, "task files changed"));
88
- const bridge = cfg.wrc.mode === "mirror"
89
- ? startMirror({ cfg, log: log.child({ mod: "mirror" }), client: ws.client, store: mirrorStore })
90
- : makeBridge({ cfg, log: log.child({ mod: "bridge" }), client: ws.client, sessions });
91
- if (!bridge) {
92
- log.fatal("bridge failed to start");
93
- fatalExit("bridge failed to start");
85
+ // 共享记忆的整理者是一条定时任务 (memory-steward.ts), 缺了就补一份默认的。
86
+ try {
87
+ ensureStewardTask(memoryRoot(cfg.daemon.stateDir));
94
88
  }
89
+ catch (e) {
90
+ log.warn({ err: e.message }, "memory steward task not written");
91
+ }
92
+ const tasks = await openTaskRegistry(log.child({ mod: "tasks" }), (added, removed) => log.info({ added, removed }, "task files changed"));
93
+ const bridge = startMirror({ cfg, log: log.child({ mod: "mirror" }), client: ws.client, store: mirrorStore });
95
94
  // 群里每个 `.name` 都挂它的 rolepage (见 session-label 的 TagLinker)。正文提及
96
- // 只认真有其人的 —— 随手写的 `.gitignore` 不该变成一个开在空栏上的链接; headless
97
- // 没有 wizard 名册, 只挂头。
98
- const known = cfg.wrc.mode === "mirror" ? (t) => bridge.chatTargets(t).includes(t) : () => false;
99
- // 名册在 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
95
+ // 只认真有其人的 —— 随手写的 `.gitignore` 不该变成一个开在空栏上的链接。
96
+ const known = (t) => bridge.chatTargets(t).includes(t);
97
+ // 名册在下面的 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
100
98
  bindTagLinker({
101
99
  urlOf: (t) => chatUrlFor(cfg.daemon, t, baseOfKey(t).replace(/^(user|chat|group):/, "")),
102
100
  resolve: (name) => {
@@ -109,50 +107,29 @@ const main = async () => {
109
107
  // approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
110
108
  // 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
111
109
  // 工作单独成块, 比让 stream 一直长到下一个 inbound / hardTimer 更清晰。
112
- // headless 模式没有 liveStream 概念, onApproved 只在 mirror 模式接。
113
- const onApproved = cfg.wrc.mode === "mirror"
114
- ? (sid) => bridge.terminateLiveStream(sid)
115
- : undefined;
110
+ const onApproved = (sid) => bridge.terminateLiveStream(sid);
116
111
  installApprovalEventListener(ws.client, log.child({ mod: "approval" }), cfg, onApproved);
117
112
  // Route approval cards to the WeCom chat bound to the requesting session.
118
- // mirror: the chat this session's pane is attached to; headless: the principal
119
- // (user:/chat:) that /wrc-dispatched this session — reverse of the sessions store.
120
- // Falls back to cfg.approval.approvers / cfg.defaultChat when nothing is bound.
121
- const getMirrorTarget = cfg.wrc.mode === "mirror"
122
- ? (sid) => bridge.targetForSession(sid)
123
- : (sid) => {
124
- for (const [principal, s] of Object.entries(sessions.all())) {
125
- if (s === sid)
126
- return principal;
127
- }
128
- return undefined;
129
- };
113
+ // — the chat this session's pane is attached to. Falls back to
114
+ // cfg.approval.approvers / cfg.defaultChat when nothing is bound.
115
+ const getMirrorTarget = (sid) => bridge.targetForSession(sid);
130
116
  // Pre-card barrier: drain pending mirror text/tool markdown for this session
131
117
  // and await its FIFO so vote/approval cards never overtake the "thinking" bubble.
132
- // Headless mode has no mirror pipe — leave undefined so approval skips the call.
133
- const flushBeforeCard = cfg.wrc.mode === "mirror"
134
- ? (sid, expect) => bridge.flushBeforeCard(sid, expect)
135
- : undefined;
118
+ const flushBeforeCard = (sid, expect) => bridge.flushBeforeCard(sid, expect);
136
119
  // `.claude/**` 写守卫要用的四个 pane 原语。cancel/tell 复用现成的 target 级方法
137
120
  // (先 sessionId → target 再调), 只有 hasPane / answerNativeModal 是 pane 级新增。
138
- // headless 模式没有 live pane 可按 → 留 undefined, 守卫自动不介入。
139
- const nativeModal = cfg.wrc.mode === "mirror"
140
- ? (() => {
141
- const b = bridge;
142
- return {
143
- hasPane: (sid) => b.hasLivePane(sid),
144
- answer: (sid, opts) => b.answerNativeModal(sid, opts),
145
- cancel: async (sid) => {
146
- const t = b.targetForSession(sid);
147
- return t ? await b.interruptPane(t) : { ok: false, reason: "no mirror target for session" };
148
- },
149
- tell: async (sid, text) => {
150
- const t = b.targetForSession(sid);
151
- return t ? await b.injectText(t, text) : { ok: false, reason: "no mirror target for session" };
152
- },
153
- };
154
- })()
155
- : undefined;
121
+ const nativeModal = {
122
+ hasPane: (sid) => bridge.hasLivePane(sid),
123
+ answer: (sid, opts) => bridge.answerNativeModal(sid, opts),
124
+ cancel: async (sid) => {
125
+ const t = bridge.targetForSession(sid);
126
+ return t ? await bridge.interruptPane(t) : { ok: false, reason: "no mirror target for session" };
127
+ },
128
+ tell: async (sid, text) => {
129
+ const t = bridge.targetForSession(sid);
130
+ return t ? await bridge.injectText(t, text) : { ok: false, reason: "no mirror target for session" };
131
+ },
132
+ };
156
133
  const http = startHttp({ cfg, ws, log, sourcePath });
157
134
  http.register("POST /approve", makeApproveHandler({ cfg, log: log.child({ mod: "approval" }), client: ws.client, sourcePath, getMirrorTarget, flushBeforeCard, nativeModal }));
158
135
  http.register("POST /message", makeMessageHandler(ws.client, log.child({ mod: "outbound" })));
@@ -231,20 +208,11 @@ const main = async () => {
231
208
  });
232
209
  wedocLog.info("wedoc bridge ready");
233
210
  }
234
- // Mirror-mode: expose attach/status so a slash command can pin the live session.
235
- if (cfg.wrc.mode === "mirror") {
211
+ // Mirror-mode: status + the spawn/switch routes the chat side drives.
212
+ {
236
213
  const m = bridge;
237
214
  installMirrorEventListener(ws.client, m, log.child({ mod: "mirror" }));
238
215
  http.register("GET /mirror/status", (_req, res) => json(res, 200, m.status()));
239
- http.register("POST /mirror/attach", async (req, res) => {
240
- const body = (await readBody(req));
241
- if (!body.sessionId || !body.jsonlPath) {
242
- json(res, 400, { ok: false, reason: "sessionId and jsonlPath required" });
243
- return;
244
- }
245
- const r = m.attach({ sessionId: body.sessionId, jsonlPath: body.jsonlPath, target: body.target, tmuxPane: body.tmuxPane, tmuxSession: body.tmuxSession });
246
- json(res, r.ok ? 200 : 400, r);
247
- });
248
216
  // Manual auto-spawn trigger — used by `wezard init` to materialize a
249
217
  // tmux+claude pane immediately after claim, instead of waiting for the
250
218
  // first inbound. Body: { target?: "user:xxx" | "chat:xxx" }. Falls back
@@ -375,7 +343,7 @@ const main = async () => {
375
343
  return;
376
344
  }
377
345
  const foreign = base !== baseOfKey(self);
378
- const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), tagFromCwd(cwd) || "peer");
346
+ const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), tagFromCwd(cwd) || "peer", [self]);
379
347
  if (!slotR.ok) {
380
348
  json(res, slotR.status, slotR.body);
381
349
  return;
@@ -609,20 +577,31 @@ const main = async () => {
609
577
  const channelOf = (self) => m.currentChannel(self) || baseOfKey(self);
610
578
  /** 入参里的地址: 新字段 `name`, 老 MCP 进程 (正在跑的 wizard) 仍在传 `tag`。 */
611
579
  const addrOf = (b) => String(b.name ?? b.tag ?? "");
580
+ /** 撞上的名字属于一个静默超过一天的 wizard → 顶掉它 (见 evictStaleName), 同群的
581
+ * wizard 收到一行名册变动; 返回是否腾出了名字。 */
582
+ const displace = async (name, keep) => {
583
+ const gone = await evictStaleName(wizards, name, keep, { lastActivity: m.lastActivity, retire: m.killPane });
584
+ if (!gone)
585
+ return false;
586
+ const slot = !!tagOfKey(gone.target);
587
+ log.child({ mod: "wizard" }).info({ target: gone.target, name: gone.name, slot }, "stale name evicted");
588
+ postRoster(baseOfKey(gone.target), [], `**.${gone.name}** 静默超过一天, ${slot ? "已被同名的新 wizard 顶掉并收工" : "名字让给了同名的新 wizard"}`);
589
+ return true;
590
+ };
612
591
  /** 给一个要出生的 wizard 挑槽位 (target key) 并落定名字。名字全局唯一:
613
- * - 撞上的是一个还绑着会话的 wizard → 409, 附上它的死活, 调用方据此换名或先收掉它;
614
592
  * - 撞上的只是目标群里同一个槽位的冷记录 (会话早没了) → 复用这个槽, 即原名重生,
615
593
  * 与从前「同一个 tag 重开」一样, 家谱/记忆都还在;
616
- * - 撞上别的群里的冷记录 → 409: 名字仍被那条记录占着。
594
+ * - 撞上的 wizard 静默超过一天 (不在 `keep` 里) → 顶掉它, 名字归新的;
595
+ * - 其余 (还新鲜的) → 409, 附上它的死活, 调用方据此换名或先收掉它。
617
596
  * 槽位 (key 里的 `#k`) 只是内部 id: 默认取名字本身, 被占了就加序号, 与名字无关。 */
618
- const claimSlot = async (base, want, fallback) => {
597
+ const claimSlot = async (base, want, fallback, keep = []) => {
619
598
  const asked = normalizeTag(want);
620
599
  const clash = asked ? wizards.byName(asked) : undefined;
621
- if (clash) {
622
- const bound = m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
623
- const reusable = !bound && baseOfKey(clash.target) === base && !!tagOfKey(clash.target);
624
- if (reusable)
625
- return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
600
+ const bound = !!clash && m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
601
+ if (clash && !bound && baseOfKey(clash.target) === base && tagOfKey(clash.target)) {
602
+ return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
603
+ }
604
+ if (clash && !(await displace(asked, keep))) {
626
605
  const info = bound ? (await m.peers(clash.target)).find((p) => p.target === clash.target) : undefined;
627
606
  const alive = info?.paneAlive ?? false;
628
607
  const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
@@ -631,7 +610,7 @@ const main = async () => {
631
610
  ? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个名字重新生, 别把不相关的活塞给一个已经有职责的 wizard"
632
611
  : bound
633
612
  ? `pane 已经不在了 (${idleDesc}), 真要复用这个名字就先 stop_wizard({name:"${clash.name}", mode:"end"}) 收掉, 再用同一个名字重新生 —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`
634
- : "这个名字被别的群里一个已经不在跑的 wizard 占着 —— 换个名字";
613
+ : "这个名字被一个一天内还活动过、眼下没有会话的 wizard 占着 —— 换个名字 (它静默满一天后名字会自动让出)";
635
614
  return {
636
615
  ok: false,
637
616
  status: 409,
@@ -1009,6 +988,7 @@ const main = async () => {
1009
988
  alive: p.paneAlive,
1010
989
  parent: w?.parent,
1011
990
  clonedFrom: w?.clonedFrom,
991
+ forkOf: w?.forkOf,
1012
992
  bornAt: w?.bornAt,
1013
993
  lastActivity: p.lastActivity,
1014
994
  summary: p.summary,
@@ -1027,6 +1007,7 @@ const main = async () => {
1027
1007
  busy: false, alive: false,
1028
1008
  parent: w.parent,
1029
1009
  clonedFrom: w.clonedFrom,
1010
+ forkOf: w.forkOf,
1030
1011
  bornAt: w.bornAt,
1031
1012
  lastActivity: 0,
1032
1013
  summary: "(未运行)",
@@ -1074,6 +1055,10 @@ const main = async () => {
1074
1055
  });
1075
1056
  /** 自己的地址 = 全局名字, 与住在哪个群无关。 */
1076
1057
  const selfAddress = (target) => settleName(wizards, chatNameOf(cfg, target), target);
1058
+ const sharedMemory = (scope, key) => {
1059
+ const path = memoryPath(cfg.daemon.stateDir, scope, key);
1060
+ return clipForCharter(readMemory(path), path);
1061
+ };
1077
1062
  /** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
1078
1063
  * 让它一睁眼就知道自己不是一个人在跑。 */
1079
1064
  const charterFor = (target, o) => renderCharter({
@@ -1083,14 +1068,16 @@ const main = async () => {
1083
1068
  principal: baseOfKey(target),
1084
1069
  parent: o.parent ? briefOf(target, o.parent) : undefined,
1085
1070
  inherited: !!o.inherited,
1071
+ forkOf: o.forkOf ? briefOf(target, o.forkOf) : undefined,
1086
1072
  cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
1087
1073
  siblings: m.chatTargets(baseOfKey(target)).filter((t) => t !== target).map((t) => briefOf(target, t)),
1088
1074
  memory: wizards.get(target)?.memory ?? [],
1089
- chatMemory: readMemory(memoryPath(cfg.daemon.stateDir, "chat", baseOfKey(target))),
1075
+ chatMemory: sharedMemory("chat", baseOfKey(target)),
1090
1076
  workspaceMemory: (() => {
1091
1077
  const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
1092
- return cwd ? readMemory(memoryPath(cfg.daemon.stateDir, "workspace", cwd)) : "";
1078
+ return cwd ? sharedMemory("workspace", cwd) : "";
1093
1079
  })(),
1080
+ steward: !tagOfKey(target),
1094
1081
  });
1095
1082
  // 所有 spawn 路径 (群里手打 /new、pane 自愈重生、编排出来的分身) 都从这里
1096
1083
  // 取身份, 于是「是谁」不再取决于是哪段代码把它生出来的。
@@ -1100,7 +1087,7 @@ const main = async () => {
1100
1087
  // 「(未命名)」的地方。所以补名要发生在渲染之前, 不能等它以后自己去查。
1101
1088
  ensureChatNames(target);
1102
1089
  const rec = wizards.get(target);
1103
- return charterFor(target, { parent: rec?.parent, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
1090
+ return charterFor(target, { parent: rec?.parent, forkOf: rec?.forkOf, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
1104
1091
  });
1105
1092
  // 开机也补一次: IM 侧的 `/peers`、`/help` 直接读名字, 不经过任何 MCP 路由。
1106
1093
  // 延后是因为附着表要先从盘上恢复完, 否则这一刻还看不见那些聊天; 补不全也无妨,
@@ -1132,6 +1119,7 @@ const main = async () => {
1132
1119
  named: !!(rec?.name ?? "").trim(),
1133
1120
  bornAt: rec?.bornAt,
1134
1121
  inheritedFrom: rec?.clonedFrom || "",
1122
+ ...(rec?.forkOf ? { forkOf: briefOf(self, rec.forkOf) } : {}),
1135
1123
  memory: rec?.memory ?? [],
1136
1124
  ...kinOf(self, wizards.all(), target),
1137
1125
  sessionId: info?.sessionId ?? "",
@@ -1261,8 +1249,9 @@ const main = async () => {
1261
1249
  : {}),
1262
1250
  });
1263
1251
  });
1264
- // 记忆三种作用域: self 跟着自己 (wizards.json); chat / workspace 是共享的 md,
1265
- // 每个在那个群 / 那个目录出生的 wizard 都会读到 (见 wizard-memory.ts)。
1252
+ // 记忆三种作用域: self 跟着自己 (wizards.json), 直写; chat / workspace 是共享的 md,
1253
+ // 每个在那个群 / 那个目录出生的 wizard 都会读到 —— 它们只收提议, 由整理者合并
1254
+ // (见 wizard-memory.ts / memory-steward.ts)。
1266
1255
  http.register("POST /wizard/remember", async (req, res) => {
1267
1256
  const { self, body } = await readPeerBody(req);
1268
1257
  if (!self) {
@@ -1279,10 +1268,25 @@ const main = async () => {
1279
1268
  json(res, 400, { ok: false, reason: "这个 wizard 没有工作区, 记不了 workspace 记忆" });
1280
1269
  return;
1281
1270
  }
1271
+ if (!note && !forget) {
1272
+ json(res, 400, { ok: false, reason: "note 和 forget 至少给一个" });
1273
+ return;
1274
+ }
1282
1275
  const path = memoryPath(cfg.daemon.stateDir, scope, key);
1283
- const forgotten = forget ? forgetMemory(path, forget) : 0;
1284
- const added = note ? appendMemory(path, note) : false;
1285
- json(res, 200, { ok: true, scope, file: path, memory: readMemory(path), added, forgotten });
1276
+ const queued = proposeMemory(inboxPath(cfg.daemon.stateDir, scope, key), {
1277
+ at: Date.now(),
1278
+ by: selfAddress(self),
1279
+ ...(note ? { note } : {}),
1280
+ ...(forget ? { forget } : {}),
1281
+ });
1282
+ if (!queued) {
1283
+ json(res, 500, { ok: false, reason: "提议没写进收件箱" });
1284
+ return;
1285
+ }
1286
+ json(res, 200, {
1287
+ ok: true, scope, queued, file: path, memory: readMemory(path),
1288
+ hint: "已提交给记忆整理者, 下一轮整理 (半小时内) 合并进上面这份记忆; 此后出生的 wizard 才读得到",
1289
+ });
1286
1290
  return;
1287
1291
  }
1288
1292
  const cur = wizards.get(self)?.memory ?? [];
@@ -1317,8 +1321,29 @@ const main = async () => {
1317
1321
  json(res, 400, { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" });
1318
1322
  return;
1319
1323
  }
1320
- const alivePeers = await m.peers(self);
1321
1324
  const inherit = b.inherit;
1325
+ // 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
1326
+ // 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
1327
+ // 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
1328
+ const fromAddr = (b.from ?? "").toString().trim();
1329
+ if (fromAddr && !inherit) {
1330
+ json(res, 400, { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" });
1331
+ return;
1332
+ }
1333
+ const fromR = fromAddr ? resolvePeer(self, fromAddr) : { ok: true, target: self };
1334
+ if (!fromR.ok) {
1335
+ json(res, fromR.status, { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) });
1336
+ return;
1337
+ }
1338
+ const source = fromR.target;
1339
+ const sourceInfo = m.sessionInfo(source);
1340
+ // 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
1341
+ // 要的恰恰是它的上下文, 给一张白纸等于答非所问。
1342
+ if (source !== self && !sourceInfo?.sessionId) {
1343
+ json(res, 409, { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` });
1344
+ return;
1345
+ }
1346
+ const alivePeers = await m.peers(self);
1322
1347
  // 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
1323
1348
  // close_job 整批回收一个工单的临时分身。只数**本聊天里**活着的 ——
1324
1349
  // 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
@@ -1342,27 +1367,28 @@ const main = async () => {
1342
1367
  return;
1343
1368
  }
1344
1369
  const askedName = String(b.name ?? b.tag ?? "");
1345
- const slotR = await claimSlot(base, askedName, normalizeTag(b.description?.split(/\s+/)[0]) || "clone");
1370
+ const slotR = await claimSlot(base, askedName, normalizeTag(b.description?.split(/\s+/)[0]) || "clone", [self, source]);
1346
1371
  if (!slotR.ok) {
1347
1372
  json(res, slotR.status, slotR.body);
1348
1373
  return;
1349
1374
  }
1350
1375
  const { target, slot: tag } = slotR;
1351
- const parentInfo = m.sessionInfo(self);
1376
+ const forkOf = inherit && source !== self ? source : undefined;
1352
1377
  // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
1353
1378
  wizards.upsert(target, {
1354
1379
  description: (b.description ?? "").toString().trim(),
1355
1380
  parent: self,
1356
1381
  bornAt: Date.now(),
1357
- clonedFrom: inherit ? parentInfo?.sessionId ?? "" : "",
1382
+ clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
1383
+ ...(forkOf ? { forkOf } : {}),
1358
1384
  });
1359
1385
  const name = wizards.rename(target, normalizeTag(askedName) || tag);
1360
- const charter = charterFor(target, { parent: self, inherited: inherit });
1386
+ const charter = charterFor(target, { parent: self, forkOf, inherited: inherit });
1361
1387
  const task = (b.task ?? "").toString().trim();
1362
1388
  // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
1363
1389
  const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
1364
1390
  const r = await m.cloneSession({
1365
- parent: self,
1391
+ parent: source,
1366
1392
  target,
1367
1393
  windowName: name,
1368
1394
  cli: b.cli,
@@ -1380,7 +1406,7 @@ const main = async () => {
1380
1406
  json(res, 500, { ok: false, reason: r.reason });
1381
1407
  return;
1382
1408
  }
1383
- wizards.upsert(target, { clonedFrom: r.inherited ? parentInfo?.sessionId ?? "" : "" });
1409
+ wizards.upsert(target, { clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
1384
1410
  const kid = briefOf(self, target);
1385
1411
  // r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
1386
1412
  // 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
@@ -1390,7 +1416,7 @@ const main = async () => {
1390
1416
  // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
1391
1417
  // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
1392
1418
  if (!jobId) {
1393
- postRoster(base, [target, self], `新 wizard **.${name}** 就位${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— ${displayName(self)} 的分身${r.inherited ? " (继承了它的上下文)" : ""}`);
1419
+ postRoster(base, [target, self], `新 wizard **.${name}** 就位${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— ${displayName(self)} 的分身${r.inherited ? ` (继承了${forkOf ? ` ${displayName(forkOf)} ` : "它"}的上下文)` : ""}`);
1394
1420
  }
1395
1421
  // 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
1396
1422
  let dispatched = r.inherited && !!task;
@@ -1501,7 +1527,7 @@ const main = async () => {
1501
1527
  job: job.id,
1502
1528
  title,
1503
1529
  memberMax: JOB_MEMBER_MAX,
1504
- hint: "把这个 id 传给 spawn_clone / send_peer 的 `job` 参数, 它们就归到这个工单名下 (期间不再逐条出气泡); 活干完调 close_job 收尾并回收临时分身。",
1530
+ hint: "把这个 id 传给 spawn_wizard / clone_wizard / send_peer 的 `job` 参数, 它们就归到这个工单名下 (期间不再逐条出气泡); 活干完调 close_job 收尾并回收临时分身。",
1505
1531
  });
1506
1532
  });
1507
1533
  http.register("POST /jobs/close", async (req, res) => {
@@ -1805,49 +1831,42 @@ const main = async () => {
1805
1831
  };
1806
1832
  }
1807
1833
  // 定时调度器 — 每 20s 检查任务表, 到点把 prompt 注入目标 wizard。
1808
- // inject 只在 mirror 模式给得出 (headless 模式没有常驻会话可注入)。
1809
1834
  migrateLegacySchedules(cfg, sourcePath, tasks, log.child({ mod: "tasks" }));
1810
1835
  const scheduler = startScheduler({
1811
1836
  client: ws.client,
1812
1837
  registry: tasks,
1813
1838
  log: log.child({ mod: "tasks" }),
1814
- inject: cfg.wrc.mode === "mirror" ? scheduledTaskInject : undefined,
1839
+ inject: scheduledTaskInject,
1815
1840
  // gate 里的 `sh` 默认就在目标 wizard 此刻的工作区跑 —— 任务文件里不必写死绝对路径。
1816
- cwdOf: cfg.wrc.mode === "mirror"
1817
- ? (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; }
1818
- : undefined,
1841
+ cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
1819
1842
  fallbackTarget: () => (cfg.defaultChat ?? "").trim(),
1820
1843
  });
1821
- // 闲置 pane 收割机 — 默认 48h 静默才动 (wrc.mirror.idleReapHours, 0=关)。
1822
- // 只收 pane 不收绑定: 下一条消息 `--resume` 复活。豁免: 名下有定时任务的
1823
- // wizard (调度本身会唤醒/需要它), 挂着审批长轮询的会话 (hook 还停在等点击),
1824
- // 忙着的 / 人正盯着的 (bridge 内部判)。keepalive 会话靠 ping 刷新 transcript,
1825
- // 天然到不了阈值。
1826
- let reapTimer;
1827
- if (cfg.wrc.mode === "mirror" && cfg.wrc.mirror.idleReapHours > 0) {
1828
- const ttlMs = cfg.wrc.mirror.idleReapHours * 3.6e6;
1844
+ // pane 上限 — 每新建一个 wizard 数一次: 活着的会话 pane 超过 wrc.mirror.maxPanes
1845
+ // (默认 20, 0=关) 就从最久没动的收起。数量只在出生时增长, 所以不设定时器。只收
1846
+ // pane 不收绑定: 下一条消息 `--resume` 复活。豁免: 名下有定时任务的 wizard (调度
1847
+ // 本身会唤醒/需要它), 挂着审批长轮询的会话 (hook 还停在等点击), 忙着的 / 人正
1848
+ // 盯着的 (bridge 内部判)。
1849
+ if (cfg.wrc.mirror.maxPanes > 0) {
1850
+ const max = cfg.wrc.mirror.maxPanes;
1829
1851
  const sweep = async () => {
1830
1852
  const taskOwners = new Set(tasks.list()
1831
1853
  .filter((t) => t.enabled)
1832
1854
  .map((t) => wizardStore()?.byName(t.owner)?.target)
1833
1855
  .filter((t) => !!t));
1834
1856
  const pendingSids = new Set(listPending().map((p) => p.meta.sessionId).filter((s) => !!s));
1835
- const r = await bridge.reapIdle(ttlMs, (target, sid) => taskOwners.has(target) ? "owns scheduled task"
1857
+ const r = await bridge.reapOverflow(max, (target, sid) => taskOwners.has(target) ? "owns scheduled task"
1836
1858
  : pendingSids.has(sid) ? "pending approval"
1837
1859
  : undefined);
1838
1860
  if (r.reaped.length)
1839
- log.info({ reaped: r.reaped.length, targets: r.reaped }, "idle reap sweep");
1861
+ log.info({ reaped: r.reaped.length, targets: r.reaped, max }, "pane cap sweep");
1840
1862
  };
1841
- reapTimer = setInterval(() => void sweep().catch((e) => log.warn({ err: e.message }, "idle reap sweep failed")), 30 * 60_000);
1842
- reapTimer.unref();
1863
+ bridge.onSpawn(() => void sweep().catch((e) => log.warn({ err: e.message }, "pane cap sweep failed")));
1843
1864
  }
1844
1865
  const shutdown = async (signal) => {
1845
1866
  log.info({ signal }, "shutdown signal");
1846
1867
  scheduler.stop();
1847
1868
  tasks.stop();
1848
1869
  netWatch.stop();
1849
- if (reapTimer)
1850
- clearInterval(reapTimer);
1851
1870
  // 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。
1852
1871
  log.info(drainForReload(), "pending drained for reload");
1853
1872
  // Hard-exit watchdog: http.close() blocks until every in-flight connection