wezard 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) 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 +10 -11
  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 +1 -1
  14. package/dist/daemon/detail.js.map +1 -1
  15. package/dist/daemon/inbound.js +59 -102
  16. package/dist/daemon/inbound.js.map +1 -1
  17. package/dist/daemon/index.js +98 -86
  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 +149 -36
  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 +31 -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 +3 -1
  34. package/dist/shared/chat-http.js.map +1 -1
  35. package/dist/shared/chat-render.js +0 -1
  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 +4 -6
  40. package/dist/shared/config.js.map +1 -1
  41. package/dist/shared/detail-store.js +1 -1
  42. package/dist/shared/detail-store.js.map +1 -1
  43. package/dist/shared/role-render.js +4 -1
  44. package/dist/shared/role-render.js.map +1 -1
  45. package/dist/shared/role-view.js +23 -5
  46. package/dist/shared/role-view.js.map +1 -1
  47. package/dist/shared/world.js +11 -1
  48. package/dist/shared/world.js.map +1 -1
  49. package/hooks/pre-tool-use.sh +4 -4
  50. package/package.json +1 -2
  51. package/web/chat.css +45 -28
  52. package/web/chat.js +45 -26
  53. package/commands/wrc.md +0 -7
  54. package/dist/daemon/cc-bridge.js +0 -183
  55. package/dist/daemon/cc-bridge.js.map +0 -1
  56. package/dist/daemon/sessions.js +0 -4
  57. 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,7 +26,8 @@ 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
32
  import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
34
33
  import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.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
@@ -1009,6 +977,7 @@ const main = async () => {
1009
977
  alive: p.paneAlive,
1010
978
  parent: w?.parent,
1011
979
  clonedFrom: w?.clonedFrom,
980
+ forkOf: w?.forkOf,
1012
981
  bornAt: w?.bornAt,
1013
982
  lastActivity: p.lastActivity,
1014
983
  summary: p.summary,
@@ -1027,6 +996,7 @@ const main = async () => {
1027
996
  busy: false, alive: false,
1028
997
  parent: w.parent,
1029
998
  clonedFrom: w.clonedFrom,
999
+ forkOf: w.forkOf,
1030
1000
  bornAt: w.bornAt,
1031
1001
  lastActivity: 0,
1032
1002
  summary: "(未运行)",
@@ -1074,6 +1044,10 @@ const main = async () => {
1074
1044
  });
1075
1045
  /** 自己的地址 = 全局名字, 与住在哪个群无关。 */
1076
1046
  const selfAddress = (target) => settleName(wizards, chatNameOf(cfg, target), target);
1047
+ const sharedMemory = (scope, key) => {
1048
+ const path = memoryPath(cfg.daemon.stateDir, scope, key);
1049
+ return clipForCharter(readMemory(path), path);
1050
+ };
1077
1051
  /** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
1078
1052
  * 让它一睁眼就知道自己不是一个人在跑。 */
1079
1053
  const charterFor = (target, o) => renderCharter({
@@ -1083,14 +1057,16 @@ const main = async () => {
1083
1057
  principal: baseOfKey(target),
1084
1058
  parent: o.parent ? briefOf(target, o.parent) : undefined,
1085
1059
  inherited: !!o.inherited,
1060
+ forkOf: o.forkOf ? briefOf(target, o.forkOf) : undefined,
1086
1061
  cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
1087
1062
  siblings: m.chatTargets(baseOfKey(target)).filter((t) => t !== target).map((t) => briefOf(target, t)),
1088
1063
  memory: wizards.get(target)?.memory ?? [],
1089
- chatMemory: readMemory(memoryPath(cfg.daemon.stateDir, "chat", baseOfKey(target))),
1064
+ chatMemory: sharedMemory("chat", baseOfKey(target)),
1090
1065
  workspaceMemory: (() => {
1091
1066
  const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
1092
- return cwd ? readMemory(memoryPath(cfg.daemon.stateDir, "workspace", cwd)) : "";
1067
+ return cwd ? sharedMemory("workspace", cwd) : "";
1093
1068
  })(),
1069
+ steward: !tagOfKey(target),
1094
1070
  });
1095
1071
  // 所有 spawn 路径 (群里手打 /new、pane 自愈重生、编排出来的分身) 都从这里
1096
1072
  // 取身份, 于是「是谁」不再取决于是哪段代码把它生出来的。
@@ -1100,7 +1076,7 @@ const main = async () => {
1100
1076
  // 「(未命名)」的地方。所以补名要发生在渲染之前, 不能等它以后自己去查。
1101
1077
  ensureChatNames(target);
1102
1078
  const rec = wizards.get(target);
1103
- return charterFor(target, { parent: rec?.parent, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
1079
+ return charterFor(target, { parent: rec?.parent, forkOf: rec?.forkOf, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
1104
1080
  });
1105
1081
  // 开机也补一次: IM 侧的 `/peers`、`/help` 直接读名字, 不经过任何 MCP 路由。
1106
1082
  // 延后是因为附着表要先从盘上恢复完, 否则这一刻还看不见那些聊天; 补不全也无妨,
@@ -1132,6 +1108,7 @@ const main = async () => {
1132
1108
  named: !!(rec?.name ?? "").trim(),
1133
1109
  bornAt: rec?.bornAt,
1134
1110
  inheritedFrom: rec?.clonedFrom || "",
1111
+ ...(rec?.forkOf ? { forkOf: briefOf(self, rec.forkOf) } : {}),
1135
1112
  memory: rec?.memory ?? [],
1136
1113
  ...kinOf(self, wizards.all(), target),
1137
1114
  sessionId: info?.sessionId ?? "",
@@ -1261,8 +1238,9 @@ const main = async () => {
1261
1238
  : {}),
1262
1239
  });
1263
1240
  });
1264
- // 记忆三种作用域: self 跟着自己 (wizards.json); chat / workspace 是共享的 md,
1265
- // 每个在那个群 / 那个目录出生的 wizard 都会读到 (见 wizard-memory.ts)。
1241
+ // 记忆三种作用域: self 跟着自己 (wizards.json), 直写; chat / workspace 是共享的 md,
1242
+ // 每个在那个群 / 那个目录出生的 wizard 都会读到 —— 它们只收提议, 由整理者合并
1243
+ // (见 wizard-memory.ts / memory-steward.ts)。
1266
1244
  http.register("POST /wizard/remember", async (req, res) => {
1267
1245
  const { self, body } = await readPeerBody(req);
1268
1246
  if (!self) {
@@ -1279,10 +1257,25 @@ const main = async () => {
1279
1257
  json(res, 400, { ok: false, reason: "这个 wizard 没有工作区, 记不了 workspace 记忆" });
1280
1258
  return;
1281
1259
  }
1260
+ if (!note && !forget) {
1261
+ json(res, 400, { ok: false, reason: "note 和 forget 至少给一个" });
1262
+ return;
1263
+ }
1282
1264
  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 });
1265
+ const queued = proposeMemory(inboxPath(cfg.daemon.stateDir, scope, key), {
1266
+ at: Date.now(),
1267
+ by: selfAddress(self),
1268
+ ...(note ? { note } : {}),
1269
+ ...(forget ? { forget } : {}),
1270
+ });
1271
+ if (!queued) {
1272
+ json(res, 500, { ok: false, reason: "提议没写进收件箱" });
1273
+ return;
1274
+ }
1275
+ json(res, 200, {
1276
+ ok: true, scope, queued, file: path, memory: readMemory(path),
1277
+ hint: "已提交给记忆整理者, 下一轮整理 (半小时内) 合并进上面这份记忆; 此后出生的 wizard 才读得到",
1278
+ });
1286
1279
  return;
1287
1280
  }
1288
1281
  const cur = wizards.get(self)?.memory ?? [];
@@ -1317,8 +1310,29 @@ const main = async () => {
1317
1310
  json(res, 400, { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" });
1318
1311
  return;
1319
1312
  }
1320
- const alivePeers = await m.peers(self);
1321
1313
  const inherit = b.inherit;
1314
+ // 克隆的源头: 默认是调用方自己; `from` 点名就 fork 那个 wizard 此刻的上下文。
1315
+ // 分身仍归调用方管 (parent = self: 占它的预算、随它的工单回收), 只有上下文来自别处。
1316
+ // 空串不能交给 resolvePeer —— 那是「本聊天的默认 wizard」, 不是「我自己」。
1317
+ const fromAddr = (b.from ?? "").toString().trim();
1318
+ if (fromAddr && !inherit) {
1319
+ json(res, 400, { ok: false, reason: "from 只对克隆有意义 —— 白板生出来的 wizard 不带任何人的上下文" });
1320
+ return;
1321
+ }
1322
+ const fromR = fromAddr ? resolvePeer(self, fromAddr) : { ok: true, target: self };
1323
+ if (!fromR.ok) {
1324
+ json(res, fromR.status, { ok: false, reason: fromR.reason, ...(fromR.candidates ? { candidates: fromR.candidates } : {}) });
1325
+ return;
1326
+ }
1327
+ const source = fromR.target;
1328
+ const sourceInfo = m.sessionInfo(source);
1329
+ // 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
1330
+ // 要的恰恰是它的上下文, 给一张白纸等于答非所问。
1331
+ if (source !== self && !sourceInfo?.sessionId) {
1332
+ json(res, 409, { ok: false, reason: `${displayName(source)} 没有可 fork 的会话 (还没说过话, 或绑定已失效)` });
1333
+ return;
1334
+ }
1335
+ const alivePeers = await m.peers(self);
1322
1336
  // 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
1323
1337
  // close_job 整批回收一个工单的临时分身。只数**本聊天里**活着的 ——
1324
1338
  // 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
@@ -1348,21 +1362,22 @@ const main = async () => {
1348
1362
  return;
1349
1363
  }
1350
1364
  const { target, slot: tag } = slotR;
1351
- const parentInfo = m.sessionInfo(self);
1365
+ const forkOf = inherit && source !== self ? source : undefined;
1352
1366
  // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
1353
1367
  wizards.upsert(target, {
1354
1368
  description: (b.description ?? "").toString().trim(),
1355
1369
  parent: self,
1356
1370
  bornAt: Date.now(),
1357
- clonedFrom: inherit ? parentInfo?.sessionId ?? "" : "",
1371
+ clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
1372
+ ...(forkOf ? { forkOf } : {}),
1358
1373
  });
1359
1374
  const name = wizards.rename(target, normalizeTag(askedName) || tag);
1360
- const charter = charterFor(target, { parent: self, inherited: inherit });
1375
+ const charter = charterFor(target, { parent: self, forkOf, inherited: inherit });
1361
1376
  const task = (b.task ?? "").toString().trim();
1362
1377
  // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
1363
1378
  const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
1364
1379
  const r = await m.cloneSession({
1365
- parent: self,
1380
+ parent: source,
1366
1381
  target,
1367
1382
  windowName: name,
1368
1383
  cli: b.cli,
@@ -1380,7 +1395,7 @@ const main = async () => {
1380
1395
  json(res, 500, { ok: false, reason: r.reason });
1381
1396
  return;
1382
1397
  }
1383
- wizards.upsert(target, { clonedFrom: r.inherited ? parentInfo?.sessionId ?? "" : "" });
1398
+ wizards.upsert(target, { clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
1384
1399
  const kid = briefOf(self, target);
1385
1400
  // r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
1386
1401
  // 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
@@ -1390,7 +1405,7 @@ const main = async () => {
1390
1405
  // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
1391
1406
  // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
1392
1407
  if (!jobId) {
1393
- postRoster(base, [target, self], `新 wizard **.${name}** 就位${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— ${displayName(self)} 的分身${r.inherited ? " (继承了它的上下文)" : ""}`);
1408
+ 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
1409
  }
1395
1410
  // 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
1396
1411
  let dispatched = r.inherited && !!task;
@@ -1501,7 +1516,7 @@ const main = async () => {
1501
1516
  job: job.id,
1502
1517
  title,
1503
1518
  memberMax: JOB_MEMBER_MAX,
1504
- hint: "把这个 id 传给 spawn_clone / send_peer 的 `job` 参数, 它们就归到这个工单名下 (期间不再逐条出气泡); 活干完调 close_job 收尾并回收临时分身。",
1519
+ hint: "把这个 id 传给 spawn_wizard / clone_wizard / send_peer 的 `job` 参数, 它们就归到这个工单名下 (期间不再逐条出气泡); 活干完调 close_job 收尾并回收临时分身。",
1505
1520
  });
1506
1521
  });
1507
1522
  http.register("POST /jobs/close", async (req, res) => {
@@ -1805,17 +1820,14 @@ const main = async () => {
1805
1820
  };
1806
1821
  }
1807
1822
  // 定时调度器 — 每 20s 检查任务表, 到点把 prompt 注入目标 wizard。
1808
- // inject 只在 mirror 模式给得出 (headless 模式没有常驻会话可注入)。
1809
1823
  migrateLegacySchedules(cfg, sourcePath, tasks, log.child({ mod: "tasks" }));
1810
1824
  const scheduler = startScheduler({
1811
1825
  client: ws.client,
1812
1826
  registry: tasks,
1813
1827
  log: log.child({ mod: "tasks" }),
1814
- inject: cfg.wrc.mode === "mirror" ? scheduledTaskInject : undefined,
1828
+ inject: scheduledTaskInject,
1815
1829
  // 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,
1830
+ cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
1819
1831
  fallbackTarget: () => (cfg.defaultChat ?? "").trim(),
1820
1832
  });
1821
1833
  // 闲置 pane 收割机 — 默认 48h 静默才动 (wrc.mirror.idleReapHours, 0=关)。
@@ -1824,7 +1836,7 @@ const main = async () => {
1824
1836
  // 忙着的 / 人正盯着的 (bridge 内部判)。keepalive 会话靠 ping 刷新 transcript,
1825
1837
  // 天然到不了阈值。
1826
1838
  let reapTimer;
1827
- if (cfg.wrc.mode === "mirror" && cfg.wrc.mirror.idleReapHours > 0) {
1839
+ if (cfg.wrc.mirror.idleReapHours > 0) {
1828
1840
  const ttlMs = cfg.wrc.mirror.idleReapHours * 3.6e6;
1829
1841
  const sweep = async () => {
1830
1842
  const taskOwners = new Set(tasks.list()