wezard 1.4.2 → 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 (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CLAUDE.md +17 -13
  4. package/README.md +130 -77
  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 +11 -7
  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/chat-name.js +8 -18
  14. package/dist/daemon/chat-name.js.map +1 -1
  15. package/dist/daemon/detail.js +54 -13
  16. package/dist/daemon/detail.js.map +1 -1
  17. package/dist/daemon/graph.js +5 -7
  18. package/dist/daemon/graph.js.map +1 -1
  19. package/dist/daemon/inbound.js +188 -254
  20. package/dist/daemon/inbound.js.map +1 -1
  21. package/dist/daemon/index.js +459 -258
  22. package/dist/daemon/index.js.map +1 -1
  23. package/dist/daemon/jobs.js +2 -2
  24. package/dist/daemon/memory-steward.js +82 -0
  25. package/dist/daemon/memory-steward.js.map +1 -0
  26. package/dist/daemon/mirror-bridge.js +370 -170
  27. package/dist/daemon/mirror-bridge.js.map +1 -1
  28. package/dist/daemon/mirror-store.js.map +1 -1
  29. package/dist/daemon/model-select.js +74 -0
  30. package/dist/daemon/model-select.js.map +1 -0
  31. package/dist/daemon/peers.js +10 -16
  32. package/dist/daemon/peers.js.map +1 -1
  33. package/dist/daemon/session-name.js +4 -27
  34. package/dist/daemon/session-name.js.map +1 -1
  35. package/dist/daemon/spawn-tmux.js +61 -16
  36. package/dist/daemon/spawn-tmux.js.map +1 -1
  37. package/dist/daemon/task-gate.js +97 -0
  38. package/dist/daemon/task-gate.js.map +1 -0
  39. package/dist/daemon/task-registry.js +132 -0
  40. package/dist/daemon/task-registry.js.map +1 -0
  41. package/dist/daemon/tasks.js +126 -54
  42. package/dist/daemon/tasks.js.map +1 -1
  43. package/dist/daemon/wizard-memory.js +52 -0
  44. package/dist/daemon/wizard-memory.js.map +1 -0
  45. package/dist/daemon/wizard.js +107 -41
  46. package/dist/daemon/wizard.js.map +1 -1
  47. package/dist/mcp/server.js +181 -209
  48. package/dist/mcp/server.js.map +1 -1
  49. package/dist/shared/chat-http.js +187 -108
  50. package/dist/shared/chat-http.js.map +1 -1
  51. package/dist/shared/chat-render.js +25 -34
  52. package/dist/shared/chat-render.js.map +1 -1
  53. package/dist/shared/chat-view.js +14 -2
  54. package/dist/shared/chat-view.js.map +1 -1
  55. package/dist/shared/cli-backends.js +2 -7
  56. package/dist/shared/cli-backends.js.map +1 -1
  57. package/dist/shared/config.js +26 -9
  58. package/dist/shared/config.js.map +1 -1
  59. package/dist/shared/detail-render.js +12 -6
  60. package/dist/shared/detail-render.js.map +1 -1
  61. package/dist/shared/detail-store.js +1 -1
  62. package/dist/shared/detail-store.js.map +1 -1
  63. package/dist/shared/role-render.js +55 -0
  64. package/dist/shared/role-render.js.map +1 -0
  65. package/dist/shared/role-view.js +228 -0
  66. package/dist/shared/role-view.js.map +1 -0
  67. package/dist/shared/schedule-spec.js +92 -116
  68. package/dist/shared/schedule-spec.js.map +1 -1
  69. package/dist/shared/session-label.js +103 -72
  70. package/dist/shared/session-label.js.map +1 -1
  71. package/dist/shared/std.js +3 -0
  72. package/dist/shared/std.js.map +1 -1
  73. package/dist/shared/task-file.js +120 -0
  74. package/dist/shared/task-file.js.map +1 -0
  75. package/dist/shared/trigger.js +232 -0
  76. package/dist/shared/trigger.js.map +1 -0
  77. package/dist/shared/world.js +17 -5
  78. package/dist/shared/world.js.map +1 -1
  79. package/dist/svr/index.js +67 -2
  80. package/dist/svr/index.js.map +1 -1
  81. package/hooks/pre-tool-use.sh +4 -4
  82. package/package.json +1 -2
  83. package/web/chat.css +424 -212
  84. package/web/chat.js +953 -323
  85. package/commands/wrc.md +0 -7
  86. package/dist/daemon/cc-bridge.js +0 -183
  87. package/dist/daemon/cc-bridge.js.map +0 -1
  88. package/dist/daemon/sessions.js +0 -4
  89. package/dist/daemon/sessions.js.map +0 -1
@@ -7,27 +7,29 @@ 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";
16
- import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, buildChatUrl, latestTurnIdFor, chatTicketFor, setWorldFactsProvider } from "./detail.js";
14
+ import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider } from "./detail.js";
17
15
  import { EMPTY_FACTS } from "../shared/world.js";
18
16
  import { initAutoWindowPersistence } from "./session-cache.js";
19
17
  import { makeMessageHandler } from "./outbound.js";
20
18
  import { makeCardHandler, makeAskHandler, installAskEventListener } from "./ask.js";
21
- import { drainForReload } from "./pending.js";
19
+ import { drainForReload, listPending } from "./pending.js";
22
20
  import { makeClaimStartHandler, makeClaimStatusHandler, makeClaimResetHandler, } from "./claim.js";
23
21
  import { makeWedocBridge } from "./wedoc.js";
24
22
  import { installResponseTracker } from "./last-response.js";
25
23
  import { scanClaudeSessions } from "./session-scan.js";
26
- import { startScheduler, listSchedules, addSchedule, removeScheduleById, renderSchedule, } from "./tasks.js";
27
- import { describeWhen, nextFire, parseWhen } from "../shared/schedule-spec.js";
28
- import { baseOfKey, keyOf, labelFor, normalizeTag, tagFromCwd, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
24
+ import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderTask, sinceOf, } from "./tasks.js";
25
+ import { openTaskRegistry } from "./task-registry.js";
26
+ import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
27
+ import { slugify, uniqueId } from "../shared/task-file.js";
28
+ import { baseOfKey, bindTagLinker, keyOf, linkTags, normalizeTag, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
29
+ import { clipForCharter, inboxPath, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
30
+ import { ensureStewardTask } from "./memory-steward.js";
29
31
  import { applyChatNames, chatBaseOf, chatNameOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName } from "./chat-name.js";
30
- import { bindWizardStore, loadWizardStore, wizardName, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
32
+ import { bindWizardStore, loadWizardStore, wizardStore, settleName, settleAll, childrenOf, ancestorsOf, renderCharter, } from "./wizard.js";
31
33
  import { bindNoticeBox, createNoticeBox, chatAudience } from "./notices.js";
32
34
  import { loadJobStore, renderJobOpen, renderJobClose, JOB_MEMBER_MAX } from "./jobs.js";
33
35
  import { extractResult } from "./peers.js";
@@ -47,6 +49,9 @@ const main = async () => {
47
49
  name: "wezard-daemon",
48
50
  });
49
51
  log.info({ sourcePath, pid: process.pid }, "daemon start");
52
+ // 定时调度器的 `inject` 真正实现, 赋值在 mirror-mode 的大块里 (要用到那里面的
53
+ // wizards / m / notifyChat), 但 startScheduler 在那块外面接线 —— 见文件尾。
54
+ let scheduledTaskInject;
50
55
  // Bind the CLI backend registry. `primary` (= defaultCli) drives new-session
51
56
  // spawns; `backends` is every installed CLI whose transcript root exists, so
52
57
  // sessions from all of them can be mirrored concurrently — each attachment
@@ -74,63 +79,57 @@ const main = async () => {
74
79
  // last-response tracker enables inbound's `quote` dedup of bot self-replies,
75
80
  // and its chat-gate drops header-only pushes (empty messages) daemon-wide.
76
81
  installResponseTracker(ws.client, log.child({ mod: "chat-gate" }));
77
- const sessions = loadSessionStore(cfg.wrc.sessionMapFile);
78
82
  const mirrorStore = loadMirrorStore(cfg.wrc.mirror.attachmentsFile);
79
- const bridge = cfg.wrc.mode === "mirror"
80
- ? startMirror({ cfg, log: log.child({ mod: "mirror" }), client: ws.client, store: mirrorStore })
81
- : makeBridge({ cfg, log: log.child({ mod: "bridge" }), client: ws.client, sessions });
82
- if (!bridge) {
83
- log.fatal("bridge failed to start");
84
- fatalExit("bridge failed to start");
83
+ // 定时任务表 —— 每条任务是 ~/.wezard/tasks/<id>.task.mjs 一份可注入代码的配置
84
+ // (见 shared/task-file.ts)。目录是热加载的: wizard 改完文件不用 reload 守护进程。
85
+ // 共享记忆的整理者是一条定时任务 (memory-steward.ts), 缺了就补一份默认的。
86
+ try {
87
+ ensureStewardTask(memoryRoot(cfg.daemon.stateDir));
88
+ }
89
+ catch (e) {
90
+ log.warn({ err: e.message }, "memory steward task not written");
85
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 });
94
+ // 群里每个 `.name` 都挂它的 rolepage (见 session-label 的 TagLinker)。正文提及
95
+ // 只认真有其人的 —— 随手写的 `.gitignore` 不该变成一个开在空栏上的链接。
96
+ const known = (t) => bridge.chatTargets(t).includes(t);
97
+ // 名册在下面的 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
98
+ bindTagLinker({
99
+ urlOf: (t) => chatUrlFor(cfg.daemon, t, baseOfKey(t).replace(/^(user|chat|group):/, "")),
100
+ resolve: (name) => {
101
+ const t = wizardStore()?.byName(name)?.target;
102
+ return t && known(t) ? t : undefined;
103
+ },
104
+ nameOf: (t) => settleName(wizardStore(), chatNameOf(cfg, t), t),
105
+ });
86
106
  installInboundRouter(ws.client, cfg, log, bridge, sourcePath);
87
107
  // approval click → finalize 当前 liveStream, 后续 tool/text 落到 standalone。
88
108
  // 规则 2: 用户点击授权那一刻就是"上一段对话"的边界, 截断 stream 让授权后的
89
109
  // 工作单独成块, 比让 stream 一直长到下一个 inbound / hardTimer 更清晰。
90
- // headless 模式没有 liveStream 概念, onApproved 只在 mirror 模式接。
91
- const onApproved = cfg.wrc.mode === "mirror"
92
- ? (sid) => bridge.terminateLiveStream(sid)
93
- : undefined;
110
+ const onApproved = (sid) => bridge.terminateLiveStream(sid);
94
111
  installApprovalEventListener(ws.client, log.child({ mod: "approval" }), cfg, onApproved);
95
112
  // Route approval cards to the WeCom chat bound to the requesting session.
96
- // mirror: the chat this session's pane is attached to; headless: the principal
97
- // (user:/chat:) that /wrc-dispatched this session — reverse of the sessions store.
98
- // Falls back to cfg.approval.approvers / cfg.defaultChat when nothing is bound.
99
- const getMirrorTarget = cfg.wrc.mode === "mirror"
100
- ? (sid) => bridge.targetForSession(sid)
101
- : (sid) => {
102
- for (const [principal, s] of Object.entries(sessions.all())) {
103
- if (s === sid)
104
- return principal;
105
- }
106
- return undefined;
107
- };
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);
108
116
  // Pre-card barrier: drain pending mirror text/tool markdown for this session
109
117
  // and await its FIFO so vote/approval cards never overtake the "thinking" bubble.
110
- // Headless mode has no mirror pipe — leave undefined so approval skips the call.
111
- const flushBeforeCard = cfg.wrc.mode === "mirror"
112
- ? (sid, expect) => bridge.flushBeforeCard(sid, expect)
113
- : undefined;
118
+ const flushBeforeCard = (sid, expect) => bridge.flushBeforeCard(sid, expect);
114
119
  // `.claude/**` 写守卫要用的四个 pane 原语。cancel/tell 复用现成的 target 级方法
115
120
  // (先 sessionId → target 再调), 只有 hasPane / answerNativeModal 是 pane 级新增。
116
- // headless 模式没有 live pane 可按 → 留 undefined, 守卫自动不介入。
117
- const nativeModal = cfg.wrc.mode === "mirror"
118
- ? (() => {
119
- const b = bridge;
120
- return {
121
- hasPane: (sid) => b.hasLivePane(sid),
122
- answer: (sid, opts) => b.answerNativeModal(sid, opts),
123
- cancel: async (sid) => {
124
- const t = b.targetForSession(sid);
125
- return t ? await b.interruptPane(t) : { ok: false, reason: "no mirror target for session" };
126
- },
127
- tell: async (sid, text) => {
128
- const t = b.targetForSession(sid);
129
- return t ? await b.injectText(t, text) : { ok: false, reason: "no mirror target for session" };
130
- },
131
- };
132
- })()
133
- : 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
+ };
134
133
  const http = startHttp({ cfg, ws, log, sourcePath });
135
134
  http.register("POST /approve", makeApproveHandler({ cfg, log: log.child({ mod: "approval" }), client: ws.client, sourcePath, getMirrorTarget, flushBeforeCard, nativeModal }));
136
135
  http.register("POST /message", makeMessageHandler(ws.client, log.child({ mod: "outbound" })));
@@ -209,20 +208,11 @@ const main = async () => {
209
208
  });
210
209
  wedocLog.info("wedoc bridge ready");
211
210
  }
212
- // Mirror-mode: expose attach/status so a slash command can pin the live session.
213
- if (cfg.wrc.mode === "mirror") {
211
+ // Mirror-mode: status + the spawn/switch routes the chat side drives.
212
+ {
214
213
  const m = bridge;
215
214
  installMirrorEventListener(ws.client, m, log.child({ mod: "mirror" }));
216
215
  http.register("GET /mirror/status", (_req, res) => json(res, 200, m.status()));
217
- http.register("POST /mirror/attach", async (req, res) => {
218
- const body = (await readBody(req));
219
- if (!body.sessionId || !body.jsonlPath) {
220
- json(res, 400, { ok: false, reason: "sessionId and jsonlPath required" });
221
- return;
222
- }
223
- const r = m.attach({ sessionId: body.sessionId, jsonlPath: body.jsonlPath, target: body.target, tmuxPane: body.tmuxPane, tmuxSession: body.tmuxSession });
224
- json(res, r.ok ? 200 : 400, r);
225
- });
226
216
  // Manual auto-spawn trigger — used by `wezard init` to materialize a
227
217
  // tmux+claude pane immediately after claim, instead of waiting for the
228
218
  // first inbound. Body: { target?: "user:xxx" | "chat:xxx" }. Falls back
@@ -353,21 +343,28 @@ const main = async () => {
353
343
  return;
354
344
  }
355
345
  const foreign = base !== baseOfKey(self);
356
- const taken = new Set(m.chatTargets(base).map(tagOfKey).filter(Boolean));
357
- const asked = normalizeTag(body.tag);
358
- // 复用一个活着的 tag 等于 respawn —— 也就是杀掉那个 wizard。调用方要的是
359
- // 「再来一个」, 不是「把那个重启」; 让它换个名字。
360
- if (asked && taken.has(asked)) {
361
- json(res, 409, { ok: false, reason: `peer '#${asked}' already exists in ${foreign ? `chat '${wantChat}'` : "this chat"} — pick a different tag, or drive that one with send_peer`, tag: asked });
346
+ const slotR = await claimSlot(base, String(body.name ?? body.tag ?? ""), tagFromCwd(cwd) || "peer");
347
+ if (!slotR.ok) {
348
+ json(res, slotR.status, slotR.body);
362
349
  return;
363
350
  }
364
- const tag = asked || uniqueTag(tagFromCwd(cwd) || "peer", taken);
365
- const target = keyOf(base, tag);
351
+ const { target, slot: tag } = slotR;
352
+ const existed = !!wizards.get(target);
353
+ const name = wizards.rename(target, normalizeTag(String(body.name ?? body.tag ?? "")) || tag);
366
354
  const model = (body.model ?? "").toString().trim();
367
- log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model }, "spawning peer session");
368
- const r = await m.newSession(target, tag, body.cli, { cwd, model });
355
+ // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
356
+ const keepalive = typeof body.keepalive === "boolean" ? body.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
357
+ log.child({ mod: "mirror", sub: "sessions-new", target }).info({ self, cwd, foreign, cli: body.cli, model, keepalive }, "spawning peer session");
358
+ const r = await m.newSession(target, name, body.cli, { cwd, model, keepalive });
359
+ if (!r.ok && !existed)
360
+ wizards.drop(target);
361
+ // r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
362
+ // 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
363
+ const modelNote = r.model
364
+ ? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
365
+ : "";
369
366
  if (r.ok)
370
- postRoster(base, [target, self], `新 wizard **#${tag}** 就位 · 地址 \`${peerAddress(cfg, base, target)}\`${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${model ? ` · 模型 ${model}` : ""} —— 空白起步, 由 ${displayName(self)} 造的`);
367
+ postRoster(base, [target, self], `新 wizard **.${name}** 就位${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${modelNote}${keepalive ? "" : " · 已关闭 keepalive"} —— 空白起步, 由 ${displayName(self)} 造的`);
371
368
  json(res, r.ok ? 200 : 500, r.ok
372
369
  ? {
373
370
  ok: true,
@@ -375,10 +372,12 @@ const main = async () => {
375
372
  self,
376
373
  target,
377
374
  base,
378
- tag,
375
+ name,
379
376
  cwd: r.cwd,
380
- ...(model ? { model } : {}),
377
+ ...(r.model ? { model: r.model } : {}),
378
+ ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}),
381
379
  foreign,
380
+ keepalive,
382
381
  // 调用方之后拿这个串 send_peer / peek_peer 驱动它。
383
382
  address: peerAddress(cfg, self, target),
384
383
  }
@@ -432,8 +431,15 @@ const main = async () => {
432
431
  json(res, 400, { ok: false, reason: "target required (or pass sessionId of an attached chat)" });
433
432
  return;
434
433
  }
434
+ // keep = 人回答了「不用换, 就用现在这个」。只落一笔确认, 不碰 pane —— 把
435
+ // 「保持现状」也走成 setPendingCwd + newSession 的话, 代价是白杀一个会话。
436
+ if (body.keep === true) {
437
+ const c = m.confirmCwd(target);
438
+ json(res, c.ok ? 200 : 400, c.ok ? { ok: true, target, cwd: c.cwd, confirmed: true } : { ok: false, reason: c.reason, target });
439
+ return;
440
+ }
435
441
  if (!cwd) {
436
- json(res, 400, { ok: false, reason: "cwd required" });
442
+ json(res, 400, { ok: false, reason: "cwd required (or keep:true to confirm the current one)" });
437
443
  return;
438
444
  }
439
445
  const set = m.setPendingCwd(target, cwd);
@@ -484,7 +490,7 @@ const main = async () => {
484
490
  target: t,
485
491
  tag: tagOfKey(t),
486
492
  address: self ? peerAddress(cfg, self, t) : t,
487
- name: wizardName(wizards.get(t), chatNameOf(cfg, t), t),
493
+ name: settleName(wizards, chatNameOf(cfg, t), t),
488
494
  description: wizards.get(t)?.description ?? "",
489
495
  cwd: m.getCwd(t).runningCwd || m.getCwd(t).defaultCwd,
490
496
  })),
@@ -507,7 +513,15 @@ const main = async () => {
507
513
  }
508
514
  const previous = chatNameOf(cfg, self);
509
515
  const r = setChatName(cfg, sourcePath, self, raw);
510
- json(res, r.ok ? 200 : 409, r.ok ? { ok: true, base: r.base, name: r.name, previous } : { ok: false, reason: r.reason });
516
+ if (!r.ok) {
517
+ json(res, 409, { ok: false, reason: r.reason });
518
+ return;
519
+ }
520
+ // 默认 wizard 的名字取自聊天名; 它还叫旧聊天名 (没被单独改过) 就跟着改。
521
+ const home = baseOfKey(self);
522
+ const cur = wizards.get(home)?.name ?? "";
523
+ const wizardRenamed = previous && cur.toLowerCase() === previous.toLowerCase() ? wizards.rename(home, r.name) : undefined;
524
+ json(res, 200, { ok: true, base: r.base, name: r.name, previous, ...(wizardRenamed ? { wizardRenamed } : {}) });
511
525
  });
512
526
  const readPeerBody = async (req) => {
513
527
  const body = (await readBody(req));
@@ -525,63 +539,87 @@ const main = async () => {
525
539
  });
526
540
  // Push a plain markdown bubble into a chat. `base` may carry a `#tag` — a
527
541
  // tagged key strips down to the same WeCom chatid as its base.
542
+ // 头不走 withTagHeader 的那些 (relay / notify / 工单) 正文里的 tag 在这里挂链;
543
+ // 已挂过的 linkTags 认得出, 不会再套一层。
528
544
  const chatIdOf = (t) => baseOfKey(t).replace(/^(user|chat|group):/, "");
529
545
  const notifyChat = (base, markdown) => {
530
546
  const chatId = chatIdOf(base);
531
547
  void ws.client
532
- .sendMessage(chatId, { msgtype: "markdown", markdown: { content: markdown } })
548
+ .sendMessage(chatId, { msgtype: "markdown", markdown: { content: linkTags(base, markdown) } })
533
549
  .catch((e) => log.warn({ err: e.message }, "chat notify failed"));
534
550
  };
535
551
  // wizard 在群里的称呼, 只用在**正文**里。气泡的头照旧交给 withTagHeader ——
536
552
  // 那一段是路由信息 (`emoji #tag`), parseTagHeader 靠它反解, 群里引用一条气泡
537
553
  // 就能直接跟那个 wizard 说话; 把它换成名字会把这条通路弄断。
538
554
  const displayName = (t) => {
539
- const tag = tagOfKey(t);
540
- const name = wizardName(wizards.get(t), chatNameOf(cfg, t), t);
541
- return name || (tag ? `#${tag}` : "默认 wizard");
555
+ const name = settleName(wizards, chatNameOf(cfg, t), t);
556
+ return name ? `.${name}` : "默认 wizard";
542
557
  };
543
- // wizard 之间的往返本来是看不见的: 它发生在两个没人盯着的 pane 里。关键的那几
544
- // 次 (派活、结论) 各自成一条气泡, 头写成 `<from> → <to>`, 方向一眼可读。
558
+ // wizard 之间的往返默认是私聊, 不进群。只有发话方判断「该当着人说」的那一次
559
+ // (send_peer public:true) 在公开频道里成一条气泡, 头写成 `.a → .b`, 方向一眼可读。
545
560
  const RELAY_MAX = 1200;
546
- /** 一个 wizard 在 `dest` 群里的称呼。本群: 照旧 `emoji #tag` —— 那一段是路由
547
- * 信息, parseTagHeader 靠它反解, 引用气泡就能直接跟它说话。外群: 换成带聊天
548
- * 名的全称 `emoji chat#tag`, 一个裸 `#tag` 在别人群里既认不出是谁, 又会被当
549
- * 成本群的 tag 误投到一个不存在的会话上。 */
550
- const relayLabel = (t, dest) => {
551
- const name = baseOfKey(t) === dest
552
- ? withTagHeader(t, "").trim()
553
- : `${tagOfKey(t) ? labelFor(tagOfKey(t)) : "🧙"} ${peerAddress(cfg, dest, t)}`;
554
- const url = chatDetailUrl(t);
555
- return url ? `[${name}](${url})` : name;
556
- };
557
- /** 头上的名字挂它自己的 chat 详情页 —— 看见「A → B」的人下一步想问的永远是
558
- * 「A 那边在干嘛」, 链接就省掉他去翻群找 A 气泡这一步。票据优先取该 wizard 自己
559
- * 最近那条 turn (mirror 的 linkedTagPrefix 同源), 取不到就退到它所在聊天的长期
560
- * 票据 —— `?id=` 的授权范围本来就是整个聊天, 开在谁那一栏由 `target=` 明说, 所以
561
- * 换票不多给权限也不会开错栏。少了这层兜底, `A → B` 里的 B 几乎总是不可点:
562
- * detail store 只留 24h, 而被派活的那个 wizard 恰恰常常是刚出生 / 闲了一天的,
563
- * 跨聊天派活时它所在的那个群更可能整个群都没有 turn 记录。 */
564
- const chatDetailUrl = (t) => {
565
- const id = latestTurnIdFor(t) ?? chatTicketFor(baseOfKey(t));
566
- return id ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, chatIdOf(t), t) : undefined;
567
- };
568
- /** 一条 relay 只落在**收信那一方**的群里, 从不两头都发:
569
- * - 派活 → to 的群。同群时那就是双方共处的那个群 (行为照旧); 跨群时源头群
570
- * 不再复述 —— 那句话本来就是它自己说出口的, 贴回自己群里只是噪音, 真正
571
- * 需要看见的是 to 那边的人: 活是谁派来的。
572
- * - 回程结论 (`relayPeer(target, self)`) → 问话人的群。答话方自己的群里,
573
- * 它的回复早就以它自己的气泡出现过了。 */
574
- const relayPeer = (from, to, body) => {
561
+ /** 一个 wizard 在群里的称呼: `emoji .name`, 挂它的 rolepage。名字全局唯一, 不再
562
+ * 因为落在哪个群而换写法 —— 引用这一段就能跟它说话 (parseTagHeader 认得)。 */
563
+ const relayLabel = (t, _dest) => tagLink(t, tagHead(t));
564
+ /** 公开的 wizard 间对话: 落在 `channel` 这个群。 */
565
+ const relayPeer = (from, to, body, channel) => {
575
566
  const text = body.trim();
576
- if (!text)
567
+ if (!text || !channel)
577
568
  return;
578
- const dest = baseOfKey(to);
579
- const head = `${relayLabel(from, dest)} → ${relayLabel(to, dest)}`;
569
+ const head = `${relayLabel(from)} → ${relayLabel(to)}`;
580
570
  const clipped = text.length > RELAY_MAX ? `${text.slice(0, RELAY_MAX)}…` : text;
581
571
  // 头独占一行, 正文自成一个块 —— 只隔一个换行的话, markdown 会把正文首行
582
572
  // 当成头那一段的续行; 表格因此整张塌成一行带竖线的文字 (表格不能打断段落)。
583
- // 方向已经写在头里了, 正文不再加任何引用/缩进标记。
584
- notifyChat(dest, `${head}\n\n${clipped}`);
573
+ notifyChat(channel, `${head}\n\n${clipped}`);
574
+ };
575
+ /** 调用方这一轮所在的公开频道: 人从哪个群叫的它 / 公开 peer 轮的那个群;
576
+ * 私聊轮或无记录 → 它的 home 群。notify 与 public send_peer 默认发到这里。 */
577
+ const channelOf = (self) => m.currentChannel(self) || baseOfKey(self);
578
+ /** 入参里的地址: 新字段 `name`, 老 MCP 进程 (正在跑的 wizard) 仍在传 `tag`。 */
579
+ const addrOf = (b) => String(b.name ?? b.tag ?? "");
580
+ /** 给一个要出生的 wizard 挑槽位 (target key) 并落定名字。名字全局唯一:
581
+ * - 撞上的是一个还绑着会话的 wizard → 409, 附上它的死活, 调用方据此换名或先收掉它;
582
+ * - 撞上的只是目标群里同一个槽位的冷记录 (会话早没了) → 复用这个槽, 即原名重生,
583
+ * 与从前「同一个 tag 重开」一样, 家谱/记忆都还在;
584
+ * - 撞上别的群里的冷记录 → 409: 名字仍被那条记录占着。
585
+ * 槽位 (key 里的 `#k`) 只是内部 id: 默认取名字本身, 被占了就加序号, 与名字无关。 */
586
+ const claimSlot = async (base, want, fallback) => {
587
+ const asked = normalizeTag(want);
588
+ const clash = asked ? wizards.byName(asked) : undefined;
589
+ if (clash) {
590
+ const bound = m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
591
+ const reusable = !bound && baseOfKey(clash.target) === base && !!tagOfKey(clash.target);
592
+ if (reusable)
593
+ return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
594
+ const info = bound ? (await m.peers(clash.target)).find((p) => p.target === clash.target) : undefined;
595
+ const alive = info?.paneAlive ?? false;
596
+ const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
597
+ const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
598
+ const advice = alive
599
+ ? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个名字重新生, 别把不相关的活塞给一个已经有职责的 wizard"
600
+ : bound
601
+ ? `pane 已经不在了 (${idleDesc}), 真要复用这个名字就先 stop_wizard({name:"${clash.name}", mode:"end"}) 收掉, 再用同一个名字重新生 —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`
602
+ : "这个名字被别的群里一个已经不在跑的 wizard 占着 —— 换个名字";
603
+ return {
604
+ ok: false,
605
+ status: 409,
606
+ body: {
607
+ ok: false,
608
+ reason: `名字 '.${clash.name}' 已经属于一个${alive ? "活着的" : "不再活跃的"} wizard (名字全局唯一) —— ${advice}`,
609
+ name: clash.name,
610
+ address: clash.name,
611
+ alive,
612
+ busy: info?.busy ?? false,
613
+ idleForMs,
614
+ },
615
+ };
616
+ }
617
+ const taken = new Set([
618
+ ...m.chatTargets(base).map(tagOfKey),
619
+ ...wizards.all().filter((w) => baseOfKey(w.target) === base).map((w) => tagOfKey(w.target)),
620
+ ].filter(Boolean));
621
+ const slot = uniqueTag(asked || normalizeTag(fallback) || "wizard", taken);
622
+ return { ok: true, target: keyOf(base, slot), slot };
585
623
  };
586
624
  // ── 给人看的消息 ──────────────────────────────────────────────────
587
625
  // send_peer 把话塞进另一个 agent 的输入框 (驱动它干活); 这条把话贴进一个聊天
@@ -611,9 +649,9 @@ const main = async () => {
611
649
  json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (list_chats 看有哪些; 没起名的聊天寻址不到)` });
612
650
  return;
613
651
  }
614
- const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [baseOfKey(self)])];
652
+ const dests = [...new Set(refs.length ? refs.map((r) => chatBaseOf(cfg, r)) : [channelOf(self)])];
615
653
  for (const dest of dests)
616
- notifyChat(dest, `${relayLabel(self, dest)}\n\n${content}`);
654
+ notifyChat(dest, `${relayLabel(self)}\n\n${content}`);
617
655
  json(res, 200, { ok: true, sent: dests.map((d) => chatNameOf(cfg, d) || d) });
618
656
  });
619
657
  http.register("POST /peers/peek", async (req, res) => {
@@ -622,7 +660,7 @@ const main = async () => {
622
660
  json(res, 400, { ok: false, reason: "cannot resolve caller session" });
623
661
  return;
624
662
  }
625
- const r = resolvePeer(self, body.tag ?? "");
663
+ const r = resolvePeer(self, addrOf(body));
626
664
  if (!r.ok) {
627
665
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
628
666
  return;
@@ -663,12 +701,16 @@ const main = async () => {
663
701
  return;
664
702
  }
665
703
  }
666
- const r = resolvePeer(self, body.tag ?? "");
704
+ const r = resolvePeer(self, addrOf(body));
667
705
  if (!r.ok) {
668
706
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
669
707
  return;
670
708
  }
671
709
  const { target, foreign } = r;
710
+ // 公开与否由发话方 (LLM) 判断: 公开 = 在它这一轮的公开频道里说, 气泡进群、对方
711
+ // 那一轮的回复也发进这个群; 私聊 (默认) = 只落双方的 rolepage, 回复靠 wait_peer 取。
712
+ const isPublic = body.public === true;
713
+ const channel = isPublic ? channelOf(self) : "";
672
714
  // Injecting into your own pane would type into the box you're generating
673
715
  // from — Claude Code queues it and the caller deadlocks waiting for itself.
674
716
  if (target === self) {
@@ -694,17 +736,18 @@ const main = async () => {
694
736
  return;
695
737
  }
696
738
  }
697
- const inj = await m.injectText(target, text, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) } });
698
- // 归到工单名下的往返不单独出气泡: 五路 fan-out 的每一次派活都发一条, 群里
699
- // 就只剩交叉的气泡, 读不出结构。它照旧落在对方的 chat 详情页里, 收工那一条
700
- // 会把成员和各自那段活一起列出来。
739
+ const inj = await m.injectText(target, text, undefined, {
740
+ from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}) },
741
+ channel,
742
+ });
743
+ // 工单成员照旧记账 (收工那一条会列出各自那段活); 公开的那一句在群里成气泡。
701
744
  if (inj.ok && jobId)
702
745
  jobs.attach(jobId, { target, task: text, spawned: false });
703
- if (inj.ok && !jobId)
704
- relayPeer(self, target, text);
746
+ if (inj.ok && isPublic)
747
+ relayPeer(self, target, text, channel);
705
748
  // `wasBusy` 是给调用方的判断依据: 立刻投给一个正在生成的会话, 这句话会排在
706
749
  // 它这一轮后面, 而不是马上被读到。
707
- json(res, inj.ok ? 200 : 502, { ...inj, target, foreign, when, wasBusy, waitedMs, ...(jobId ? { job: jobId } : {}) });
750
+ json(res, inj.ok ? 200 : 502, { ...inj, target, name: peerAddress(cfg, self, target), foreign, public: isPublic, when, wasBusy, waitedMs, ...(jobId ? { job: jobId } : {}) });
708
751
  });
709
752
  // 等一个 wizard, 或者等一**组**。fan-out 之后 join 必须是并行的: 串行地等五个
710
753
  // 分身, 墙钟是五个之和, 而它们本来就在同时干活。`need` 把 all / any / 过半收进
@@ -716,7 +759,8 @@ const main = async () => {
716
759
  return;
717
760
  }
718
761
  const b = body;
719
- const asked = (Array.isArray(b.tags) && b.tags.length > 0 ? b.tags : [b.tag ?? ""]).map((x) => String(x ?? ""));
762
+ const many = Array.isArray(b.names) && b.names.length > 0 ? b.names : b.tags;
763
+ const asked = (Array.isArray(many) && many.length > 0 ? many : [addrOf(b)]).map((x) => String(x ?? ""));
720
764
  if (asked.length > 16) {
721
765
  json(res, 400, { ok: false, reason: "一次最多等 16 个 wizard" });
722
766
  return;
@@ -741,13 +785,7 @@ const main = async () => {
741
785
  const results = uniq.map((h, i) => {
742
786
  const wr = wrs[i];
743
787
  const lastText = m.lastText(h.target);
744
- // 回程只在跨聊天时下发。同一个群里, 对方的回复本来就会以它自己的 `emoji #tag`
745
- // 气泡出现 —— 再 relay 一条 `B → A` 就是同一句话在同一个群里出现两次, 正是
746
- // 「A 告诉 B 之后不必再展示 B 收到了」要消灭的那种重复。跨聊天则相反: A 的群
747
- // 里看不到 B 的任何气泡, 这条 relay 是那边唯一能看见结论的地方 —— 所以它落在
748
- // self (问话人) 的群, 而不是答话方那边。
749
- if (wr.idle && h.foreign)
750
- relayPeer(h.target, self, lastText);
788
+ // 回程不再进群: 私聊的结论由调用方自己收口给人; 公开轮的回复 mirror 早已发进那个群。
751
789
  // `result` 是它自己收口的那一行 (见 peers.extractResult); 捞不到就是 "",
752
790
  // 调用方照旧读 lastText。
753
791
  return { address: h.address, target: h.target, foreign: h.foreign, idle: wr.idle, reason: wr.reason, result: extractResult(lastText), lastText };
@@ -784,7 +822,7 @@ const main = async () => {
784
822
  }
785
823
  }
786
824
  else {
787
- const r = resolvePeer(self, body.tag ?? "");
825
+ const r = resolvePeer(self, addrOf(body));
788
826
  if (!r.ok) {
789
827
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
790
828
  return;
@@ -839,8 +877,8 @@ const main = async () => {
839
877
  : { ok: false, target, brief, reason: `handoff carry inject failed: ${inj2.reason}` });
840
878
  });
841
879
  // ── Wizard: 会话的身份层 ────────────────────────────────────────────
842
- // 一个会话 (`chat:xxx#tag`) 从此是一个 wizard: 名字 (= 聊天名, 带 tag 的分身读作
843
- // `chat#tag`)、一句职责、一份跨会话的记忆、一条家谱。身份不靠"在对话里讲一遍"
880
+ // 一个会话 (`chat:xxx#k`) 从此是一个 wizard: 一个全局唯一的名字 (默认会话出生时
881
+ // 取聊天名, 分身取自己的名字)、一句职责、一份跨会话的记忆、一条家谱。身份不靠"在对话里讲一遍"
844
882
  // 维持 —— spawn 时以 `--append-system-prompt` 压进那个进程, `/clear` 抹不掉、
845
883
  // 上下文窗口也挤不掉。
846
884
  // 这里只做三件事: 读身份、改身份、按身份生/收分身。tmux 一概不碰, 动作全在
@@ -885,10 +923,14 @@ const main = async () => {
885
923
  log.info({ base, name }, "chat: auto-named from workspace");
886
924
  // 群里不发气泡 (一次补名会命中十几个群, 那是刷屏), 但住在里面的 wizard
887
925
  // 得知道自己的地址变了 —— 它的 charter 里写的还是「(未命名)」。
888
- postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— 别的聊天从此能以 \`${name}#tag\` 叫到这里, 你的地址也随之可读了。`);
926
+ postRoster(base, [], `这个聊天现在叫 **${name}** (按工作区自动起的) —— notify / new_claude_session 可以用这个名字指到这里。`);
889
927
  }
890
928
  return named;
891
929
  };
930
+ // 名字全局唯一 (见 wizard.ts): 先给没名字的聊天补名, 再按「默认会话先挑」把
931
+ // 每个已知 wizard 的名字落定 —— 老记录 (名字还跟着聊天走的那一代) 在这里一次迁完。
932
+ ensureChatNames(cfg.defaultChat || "");
933
+ settleAll(wizards, [...m.chatRoster("").flatMap((c) => c.targets), ...wizards.all().map((w) => w.target)], (t) => chatNameOf(cfg, t));
892
934
  // 工单账本 (见 jobs.ts)。只在**显式传了 `job`** 时起作用 —— 不用工单的调用方
893
935
  // 行为与从前一模一样。
894
936
  const jobs = loadJobStore(cfg.wrc.mirror.jobsFile);
@@ -925,7 +967,7 @@ const main = async () => {
925
967
  const w = byTarget.get(p.target);
926
968
  return {
927
969
  target: p.target,
928
- name: wizardName(w, chatNameOf(cfg, p.target), p.target),
970
+ name: settleName(wizards, chatNameOf(cfg, p.target), p.target),
929
971
  description: w?.description ?? "",
930
972
  chat: chatNameOf(cfg, p.target) || p.chat,
931
973
  cwd: p.cwd,
@@ -935,6 +977,7 @@ const main = async () => {
935
977
  alive: p.paneAlive,
936
978
  parent: w?.parent,
937
979
  clonedFrom: w?.clonedFrom,
980
+ forkOf: w?.forkOf,
938
981
  bornAt: w?.bornAt,
939
982
  lastActivity: p.lastActivity,
940
983
  summary: p.summary,
@@ -946,13 +989,14 @@ const main = async () => {
946
989
  .filter((w) => !seen.has(w.target))
947
990
  .map((w) => ({
948
991
  target: w.target,
949
- name: wizardName(w, chatNameOf(cfg, w.target), w.target),
992
+ name: settleName(wizards, chatNameOf(cfg, w.target), w.target),
950
993
  description: w.description,
951
994
  chat: chatNameOf(cfg, w.target),
952
995
  cwd: "", model: "", cli: "",
953
996
  busy: false, alive: false,
954
997
  parent: w.parent,
955
998
  clonedFrom: w.clonedFrom,
999
+ forkOf: w.forkOf,
956
1000
  bornAt: w.bornAt,
957
1001
  lastActivity: 0,
958
1002
  summary: "(未运行)",
@@ -965,16 +1009,20 @@ const main = async () => {
965
1009
  openedAt: j.openedAt, closedAt: j.closedAt, summary: j.summary,
966
1010
  members: j.members.map((mm) => ({ target: mm.target, task: mm.task, spawned: mm.spawned })),
967
1011
  })),
968
- schedules: cfg.schedules.map((x) => ({
969
- id: x.id,
970
- target: x.target,
971
- when: describeWhen(x.when),
972
- nextAt: nextFire(x.when, x.lastFired ?? x.createdAt, nowDate),
973
- lastFired: x.lastFired,
974
- prompt: x.prompt,
975
- note: x.note,
976
- createdBy: x.createdBy,
977
- })),
1012
+ schedules: tasks.list().filter((x) => x.enabled).map((x) => {
1013
+ const st = tasks.stateOf(x.id);
1014
+ return {
1015
+ id: x.id,
1016
+ target: x.target || (cfg.defaultChat ?? ""),
1017
+ when: describeTrigger(x.trigger),
1018
+ nextAt: nextFire(x.trigger, sinceOf(st), nowDate),
1019
+ lastFired: st.lastFired,
1020
+ prompt: x.prompt,
1021
+ note: x.note,
1022
+ createdBy: x.createdBy,
1023
+ owner: x.owner || (cfg.defaultChat ?? ""),
1024
+ };
1025
+ }),
978
1026
  chatNames: Object.fromEntries(listChatNames(cfg).map((c) => [c.base, c.name])),
979
1027
  };
980
1028
  };
@@ -989,39 +1037,46 @@ const main = async () => {
989
1037
  * 给交接那一轮本身 —— 提示而已, 决定权在 wizard 自己。 */
990
1038
  const HANDOFF_HINT_TOKENS = 140_000;
991
1039
  const briefOf = (self, target) => ({
992
- name: wizardName(wizards.get(target), chatNameOf(cfg, target), target),
1040
+ name: settleName(wizards, chatNameOf(cfg, target), target),
993
1041
  address: peerAddress(cfg, self, target),
994
1042
  description: wizards.get(target)?.description ?? "",
995
1043
  cwd: m.getCwd(target).runningCwd,
996
1044
  });
997
- /** 自己的地址要写成**全局**形态 (`聊天名#tag`), 不是同群内部的裸 tag —— 宪章
998
- * 和 whoami 里的这个串会被它原样贴给别的聊天的 wizard, 裸 tag 到了那边只有
999
- * 在全机唯一时才碰巧能解析。聊天没起名时只能退回裸 tag。 */
1000
- const selfAddress = (target) => {
1001
- const chat = chatNameOf(cfg, target);
1002
- const tag = tagOfKey(target);
1003
- return chat ? `${chat}#${tag}` : tag;
1045
+ /** 自己的地址 = 全局名字, 与住在哪个群无关。 */
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);
1004
1050
  };
1005
1051
  /** 开局宪章。出生时的兄弟只是快照 —— 名册随时可查, 写进系统提示的那份只为了
1006
1052
  * 让它一睁眼就知道自己不是一个人在跑。 */
1007
1053
  const charterFor = (target, o) => renderCharter({
1008
- self: { ...briefOf(target, target), address: selfAddress(target) },
1054
+ // o.cwd = 正在启动的那个 pane 的目录; 没给才退回"现在记着的那个"。
1055
+ self: { ...briefOf(target, target), address: selfAddress(target), ...(o.cwd ? { cwd: o.cwd } : {}) },
1009
1056
  chat: chatNameOf(cfg, target),
1010
1057
  principal: baseOfKey(target),
1011
1058
  parent: o.parent ? briefOf(target, o.parent) : undefined,
1012
1059
  inherited: !!o.inherited,
1060
+ forkOf: o.forkOf ? briefOf(target, o.forkOf) : undefined,
1061
+ cwdUnconfirmed: m.cwdUnconfirmed(target, o.cwd),
1013
1062
  siblings: m.chatTargets(baseOfKey(target)).filter((t) => t !== target).map((t) => briefOf(target, t)),
1014
1063
  memory: wizards.get(target)?.memory ?? [],
1064
+ chatMemory: sharedMemory("chat", baseOfKey(target)),
1065
+ workspaceMemory: (() => {
1066
+ const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
1067
+ return cwd ? sharedMemory("workspace", cwd) : "";
1068
+ })(),
1069
+ steward: !tagOfKey(target),
1015
1070
  });
1016
1071
  // 所有 spawn 路径 (群里手打 /new、pane 自愈重生、编排出来的分身) 都从这里
1017
1072
  // 取身份, 于是「是谁」不再取决于是哪段代码把它生出来的。
1018
- m.setCharterProvider((target) => {
1073
+ m.setCharterProvider((target, ctx) => {
1019
1074
  // 这里是最要紧的那道拦截: 宪章把「所在聊天」写进系统提示, 而系统提示是随进程
1020
1075
  // 终身的 —— 一个在聊天还没名字时出生的 wizard, 会一辈子以为自己住在一个
1021
1076
  // 「(未命名)」的地方。所以补名要发生在渲染之前, 不能等它以后自己去查。
1022
1077
  ensureChatNames(target);
1023
1078
  const rec = wizards.get(target);
1024
- return charterFor(target, { parent: rec?.parent, inherited: !!rec?.clonedFrom });
1079
+ return charterFor(target, { parent: rec?.parent, forkOf: rec?.forkOf, inherited: !!rec?.clonedFrom, cwd: ctx?.cwd });
1025
1080
  });
1026
1081
  // 开机也补一次: IM 侧的 `/peers`、`/help` 直接读名字, 不经过任何 MCP 路由。
1027
1082
  // 延后是因为附着表要先从盘上恢复完, 否则这一刻还看不见那些聊天; 补不全也无妨,
@@ -1050,9 +1105,10 @@ const main = async () => {
1050
1105
  chat: chatNameOf(cfg, target),
1051
1106
  principal: baseOfKey(target),
1052
1107
  tag: tagOfKey(target),
1053
- named: !!(rec?.name ?? "").trim() || !!chatNameOf(cfg, target),
1108
+ named: !!(rec?.name ?? "").trim(),
1054
1109
  bornAt: rec?.bornAt,
1055
1110
  inheritedFrom: rec?.clonedFrom || "",
1111
+ ...(rec?.forkOf ? { forkOf: briefOf(self, rec.forkOf) } : {}),
1056
1112
  memory: rec?.memory ?? [],
1057
1113
  ...kinOf(self, wizards.all(), target),
1058
1114
  sessionId: info?.sessionId ?? "",
@@ -1114,9 +1170,8 @@ const main = async () => {
1114
1170
  ensureChatNames(self);
1115
1171
  json(res, 200, { ok: true, ...identityOf(self, self), peers: (await m.peers(self)).filter((p) => !p.self).length });
1116
1172
  });
1117
- // 起名字 / 写职责。默认会话的名字就是聊天的名字 (需求: wizard 的名字 = chat
1118
- // 的名字), 所以那一路同时写 chats 表 —— 否则别的聊天仍然叫不到它。带 tag 的
1119
- // 分身只写自己的记录, 它的地址本来就是 `chat#tag`。
1173
+ // 改名字 / 写职责。名字全局唯一、与聊天名脱钩 (默认会话只是出生时取了聊天名):
1174
+ // 撞名自动挂 `-N`, 返回里的 `name` 才是落定的那个。
1120
1175
  http.register("POST /wizard/identity", async (req, res) => {
1121
1176
  const { self, body } = await readPeerBody(req);
1122
1177
  if (!self) {
@@ -1124,31 +1179,26 @@ const main = async () => {
1124
1179
  return;
1125
1180
  }
1126
1181
  const b = body;
1127
- const name = (b.name ?? "").toString().trim();
1182
+ const asked = normalizeTag((b.name ?? "").toString());
1128
1183
  const description = (b.description ?? "").toString().trim();
1129
- const wasNamed = !!wizardName(wizards.get(self), chatNameOf(cfg, self), self);
1130
- let chatNamed;
1131
- if (name && !tagOfKey(self)) {
1132
- const r = setChatName(cfg, sourcePath, self, name);
1133
- if (!r.ok) {
1134
- json(res, 409, { ok: false, reason: r.reason });
1135
- return;
1136
- }
1137
- chatNamed = r.name;
1184
+ if ((b.name ?? "").toString().trim() && !asked) {
1185
+ json(res, 400, { ok: false, reason: "名字只能是 1-32 位字母/数字/`_`/`-`" });
1186
+ return;
1138
1187
  }
1139
- wizards.upsert(self, {
1140
- ...(name ? { name: chatNamed ?? name } : {}),
1141
- ...(description ? { description } : {}),
1142
- });
1188
+ const before = settleName(wizards, chatNameOf(cfg, self), self);
1189
+ const name = asked ? wizards.rename(self, asked) : before;
1190
+ if (description)
1191
+ wizards.upsert(self, { description });
1143
1192
  const me = identityOf(self, self);
1144
- // 起名是稀有事件, 值得在群里留一条 —— 人得知道群里这个角色叫什么了。
1145
- if (name && !wasNamed)
1146
- notifyChat(self, withTagHeader(self, `我是 **${me.name}**${description ? ` · ${description}` : ""}`));
1147
- // 职责是别人决定"该不该找你"的依据, 改了就得让同群的人知道 —— 否则他们照着
1193
+ // 职责是别人决定"该不该找你"的依据, 改了就得让同群的知道 —— 否则他们照着
1148
1194
  // 出生快照里那句旧的 (或者空的) 职责派活。
1149
- if (name || description)
1150
- postRoster(baseOfKey(self), [self], `**${me.name || me.address}** 改了身份 · 地址 \`${peerAddress(cfg, baseOfKey(self), self)}\`${description ? ` · 职责: ${description}` : ""}`);
1151
- json(res, 200, { ok: true, ...me, chatNamed });
1195
+ if (asked || description)
1196
+ postRoster(baseOfKey(self), [self], `${before && before !== name ? `**.${before}** 改名为 **.${name}**` : `**.${name}** 改了身份`}${description ? ` · 职责: ${description}` : ""}`);
1197
+ json(res, 200, {
1198
+ ok: true,
1199
+ ...me,
1200
+ ...(asked && name.toLowerCase() !== asked.toLowerCase() ? { renamed: `'.${asked}' 已被占用, 落定为 '.${name}'` } : {}),
1201
+ });
1152
1202
  });
1153
1203
  // 名册是**索引**, 不是转储: 这台机器上现在有 300+ 个会话, 整表吐出来是三万
1154
1204
  // token —— 一个 wizard 每次想找人都付这个价, 等于找不到人。所以服务端过滤
@@ -1188,6 +1238,9 @@ const main = async () => {
1188
1238
  : {}),
1189
1239
  });
1190
1240
  });
1241
+ // 记忆三种作用域: self 跟着自己 (wizards.json), 直写; chat / workspace 是共享的 md,
1242
+ // 每个在那个群 / 那个目录出生的 wizard 都会读到 —— 它们只收提议, 由整理者合并
1243
+ // (见 wizard-memory.ts / memory-steward.ts)。
1191
1244
  http.register("POST /wizard/remember", async (req, res) => {
1192
1245
  const { self, body } = await readPeerBody(req);
1193
1246
  if (!self) {
@@ -1195,14 +1248,42 @@ const main = async () => {
1195
1248
  return;
1196
1249
  }
1197
1250
  const b = body;
1198
- const cur = wizards.get(self)?.memory ?? [];
1199
1251
  const note = (b.note ?? "").toString().trim();
1200
1252
  const forget = (b.forget ?? "").toString().trim();
1253
+ const scope = (b.scope ?? "self").toString();
1254
+ if (scope === "chat" || scope === "workspace") {
1255
+ const key = scope === "chat" ? baseOfKey(self) : (m.getCwd(self).runningCwd || m.getCwd(self).defaultCwd);
1256
+ if (!key) {
1257
+ json(res, 400, { ok: false, reason: "这个 wizard 没有工作区, 记不了 workspace 记忆" });
1258
+ return;
1259
+ }
1260
+ if (!note && !forget) {
1261
+ json(res, 400, { ok: false, reason: "note 和 forget 至少给一个" });
1262
+ return;
1263
+ }
1264
+ const path = memoryPath(cfg.daemon.stateDir, scope, key);
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
+ });
1279
+ return;
1280
+ }
1281
+ const cur = wizards.get(self)?.memory ?? [];
1201
1282
  // 忘记按子串匹配: 模型记不住自己当初一字不差写了什么, 但记得大意。
1202
1283
  const next = forget ? cur.filter((x) => !x.includes(forget)) : cur;
1203
1284
  const memory = note ? [...next.filter((x) => x !== note), note] : next;
1204
1285
  wizards.upsert(self, { memory });
1205
- json(res, 200, { ok: true, memory, added: !!note, forgotten: cur.length - next.length });
1286
+ json(res, 200, { ok: true, scope: "self", memory, added: !!note, forgotten: cur.length - next.length });
1206
1287
  });
1207
1288
  // 生分身。默认 fork 调用方此刻的上下文 —— 这是"先把公共材料读进来, 再分出 N
1208
1289
  // 个干活的"之所以省事的原因: 材料只读一遍, 却进了 N 份上下文。
@@ -1229,8 +1310,29 @@ const main = async () => {
1229
1310
  json(res, 400, { ok: false, reason: "inherit 必填: true = fork 你此刻的上下文 (它开局就有你读过的材料, 必须留在同一个 cwd); false = 空白分身, 只继承身份" });
1230
1311
  return;
1231
1312
  }
1232
- const alivePeers = await m.peers(self);
1233
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);
1234
1336
  // 预算。撞到上限不是"不许再分", 是"先把干完活的收掉": stop_wizard 收单个,
1235
1337
  // close_job 整批回收一个工单的临时分身。只数**本聊天里**活着的 ——
1236
1338
  // 生到别的聊天去的归那边管, 为了数它们再探一遍 tmux 不值当。
@@ -1253,48 +1355,31 @@ const main = async () => {
1253
1355
  });
1254
1356
  return;
1255
1357
  }
1256
- const taken = new Set(m.chatTargets(base).map(tagOfKey).filter(Boolean));
1257
- const asked = normalizeTag(b.tag);
1258
- if (asked && taken.has(asked)) {
1259
- // "活着" 不能只看 taken 里有没有这个 tag —— taken 是 chatTargets(), 连早就
1260
- // 断线的冷绑定也算数。真探一次活, 把结论 (alive/busy/多久没动过) 和能直接
1261
- // 回传给 send_peer/stop_wizard 的完整地址一起吐回去, 调用方才判断得出这是
1262
- // "该换个名字" 还是"值得 stop_wizard 收掉腾地方再复用"。
1263
- const existing = keyOf(base, asked);
1264
- const info = (await m.peers(existing)).find((p) => p.target === existing);
1265
- const alive = info?.paneAlive ?? false;
1266
- const idleForMs = info?.lastActivity ? Date.now() - info.lastActivity : undefined;
1267
- const idleDesc = idleForMs !== undefined ? `静默 ${Math.round(idleForMs / 60000)} 分钟` : "从没动过";
1268
- const advice = alive
1269
- ? "它的 pane 还活着 —— 除非这就是同一件事的延续, 否则换个 tag 重新 spawn_clone, 别把不相关的活塞给一个已经有职责的 wizard"
1270
- : `pane 已经不在了 (${idleDesc}), 真要复用这个 tag 就先 stop_wizard({tag:"${asked}", mode:"end"}) 收掉腾出名字, 再用同一个 tag 重新 spawn_clone —— 拿到干净的上下文; 别直接 send_peer 唤醒它接手, 它会带着上一件事的记忆答你这件`;
1271
- json(res, 409, {
1272
- ok: false,
1273
- reason: `'#${asked}' 已经是一个${alive ? "活着的" : "不再活跃的"} wizard —— ${advice}`,
1274
- tag: asked,
1275
- address: peerAddress(cfg, self, existing),
1276
- alive,
1277
- busy: info?.busy ?? false,
1278
- idleForMs,
1279
- });
1358
+ const askedName = String(b.name ?? b.tag ?? "");
1359
+ const slotR = await claimSlot(base, askedName, normalizeTag(b.description?.split(/\s+/)[0]) || "clone");
1360
+ if (!slotR.ok) {
1361
+ json(res, slotR.status, slotR.body);
1280
1362
  return;
1281
1363
  }
1282
- const tag = asked || uniqueTag(normalizeTag(b.description?.split(/\s+/)[0]) || "clone", taken);
1283
- const target = keyOf(base, tag);
1284
- const parentInfo = m.sessionInfo(self);
1364
+ const { target, slot: tag } = slotR;
1365
+ const forkOf = inherit && source !== self ? source : undefined;
1285
1366
  // 先落身份再 spawn: 宪章是从注册表渲染出来的, 记录不在就渲染出一个无名分身。
1286
1367
  wizards.upsert(target, {
1287
1368
  description: (b.description ?? "").toString().trim(),
1288
1369
  parent: self,
1289
1370
  bornAt: Date.now(),
1290
- clonedFrom: inherit ? parentInfo?.sessionId ?? "" : "",
1371
+ clonedFrom: inherit ? sourceInfo?.sessionId ?? "" : "",
1372
+ ...(forkOf ? { forkOf } : {}),
1291
1373
  });
1292
- const charter = charterFor(target, { parent: self, inherited: inherit });
1374
+ const name = wizards.rename(target, normalizeTag(askedName) || tag);
1375
+ const charter = charterFor(target, { parent: self, forkOf, inherited: inherit });
1293
1376
  const task = (b.task ?? "").toString().trim();
1377
+ // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
1378
+ const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
1294
1379
  const r = await m.cloneSession({
1295
- parent: self,
1380
+ parent: source,
1296
1381
  target,
1297
- windowName: tag,
1382
+ windowName: name,
1298
1383
  cli: b.cli,
1299
1384
  model: b.model,
1300
1385
  cwd: b.cwd,
@@ -1303,34 +1388,34 @@ const main = async () => {
1303
1388
  // 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
1304
1389
  // 也少一次"就位了但没事干"的空转。
1305
1390
  bootstrap: task || undefined,
1391
+ keepalive,
1306
1392
  });
1307
1393
  if (!r.ok) {
1308
1394
  wizards.drop(target);
1309
1395
  json(res, 500, { ok: false, reason: r.reason });
1310
1396
  return;
1311
1397
  }
1312
- wizards.upsert(target, { clonedFrom: r.inherited ? parentInfo?.sessionId ?? "" : "" });
1398
+ wizards.upsert(target, { clonedFrom: r.inherited ? sourceInfo?.sessionId ?? "" : "", ...(r.inherited ? {} : { forkOf: undefined }) });
1313
1399
  const kid = briefOf(self, target);
1314
- // 分身出生要在群里留一条 —— 群里多了一个成员, 人有权当场知道。
1315
- // 工单里的分身是临时工: 出生、派活各发一条气泡, 五路 fan-out 就是十条交叉的
1316
- // 气泡, 人从里面读不出结构。它们攒到收工那一条里一起交代 (成员 + 各自那段活),
1317
- // 中间过程照旧在各自的 chat 详情页。同理不惊动同群的其他 wizard。
1400
+ // r.model 是 spawnTmuxClaude 通过 /model 实测确认落地的那个 —— 可能跟调用方
1401
+ // 传的原始字符串不一样 (口语化 → 目录里匹配到的关键词), 播报要报实情。
1402
+ const modelNote = r.model
1403
+ ? (r.modelWarning ? ` · 模型 ${r.model} (⚠️ ${r.modelWarning})` : ` · 模型 ${r.model}`)
1404
+ : "";
1405
+ // 出生不再发群气泡 (人要看的是结论, 不是谁生了谁 —— 过程在 rolepage 里);
1406
+ // 同群的 wizard 仍要知道群里多了一个成员。工单里的临时工连这条也省掉。
1318
1407
  if (!jobId) {
1319
- notifyChat(base, withTagHeader(target, `已就位 · ${r.inherited ? `${displayName(self)} 的分身 (继承了它的上下文)` : "全新 wizard (空白上下文)"}${kid.description ? ` · ${kid.description}` : ""}`));
1320
- postRoster(base, [target, self], `新 wizard **${kid.name || tag}** 就位 · 地址 \`${peerAddress(cfg, base, target)}\`${kid.description ? ` · ${kid.description}` : ""}${r.cwd ? ` · 工作区 ${r.cwd}` : ""}${b.model?.trim() ? ` · 模型 ${b.model.trim()}` : ""} —— ${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)} ` : "它"}的上下文)` : ""}`);
1321
1409
  }
1322
- // 派活在群里留一条 (它是关键节点)。继承路径上活已经随开场白进去了, 空白分身
1323
- // 才需要在这里补一次注入。
1410
+ // 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
1324
1411
  let dispatched = r.inherited && !!task;
1325
1412
  if (task && !r.inherited) {
1326
- const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) } });
1413
+ const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, ...(jobId ? { job: jobId } : {}) }, channel: "" });
1327
1414
  dispatched = inj.ok;
1328
1415
  }
1329
1416
  if (jobId)
1330
1417
  jobs.attach(jobId, { target, task, spawned: true });
1331
- else if (dispatched)
1332
- relayPeer(self, target, task);
1333
- json(res, 200, { ok: true, target, tag, address: peerAddress(cfg, self, target), name: kid.name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, ...(jobId ? { job: jobId } : {}) });
1418
+ json(res, 200, { ok: true, target, name, address: name, inherited: r.inherited, sessionId: r.sessionId, cwd: r.cwd, dispatched, keepalive, ...(r.model ? { model: r.model } : {}), ...(r.modelWarning ? { modelWarning: r.modelWarning } : {}), ...(jobId ? { job: jobId } : {}) });
1334
1419
  });
1335
1420
  // 收掉一个 wizard。interrupt = 打断它这一轮 (Esc); end = 结束它并回收 pane。
1336
1421
  // 自己终结自己是合法的 (分身干完活自我了结), 代价是这次工具调用不会返回 ——
@@ -1342,7 +1427,7 @@ const main = async () => {
1342
1427
  return;
1343
1428
  }
1344
1429
  const b = body;
1345
- const r = resolvePeer(self, b.tag ?? "");
1430
+ const r = resolvePeer(self, addrOf(b));
1346
1431
  if (!r.ok) {
1347
1432
  json(res, r.status, { ok: false, reason: r.reason, candidates: r.candidates });
1348
1433
  return;
@@ -1390,6 +1475,9 @@ const main = async () => {
1390
1475
  const idle = await waitForIdle(self, m.isBusy, 10 * 60_000, () => false);
1391
1476
  if (!idle.idle) {
1392
1477
  lg.warn({ reason: idle.reason }, "self-handoff: 目标一直忙, 放弃");
1478
+ // 之前这里只写日志: 调用方早就拿到 `scheduled:true` 挂了, 交接静悄悄
1479
+ // 放弃, 群里没有任何气泡告诉人。补一条 notify, 对齐它成功时的那条。
1480
+ notifyChat(self, withTagHeader(self, `上下文交接放弃: 一直没能闲下来 (${idle.reason}), 上下文和之前一样没动, 有空再试一次`));
1393
1481
  return;
1394
1482
  }
1395
1483
  const clr = await m.injectText(self, "/clear");
@@ -1428,7 +1516,7 @@ const main = async () => {
1428
1516
  job: job.id,
1429
1517
  title,
1430
1518
  memberMax: JOB_MEMBER_MAX,
1431
- hint: "把这个 id 传给 spawn_clone / send_peer 的 `job` 参数, 它们就归到这个工单名下 (期间不再逐条出气泡); 活干完调 close_job 收尾并回收临时分身。",
1519
+ hint: "把这个 id 传给 spawn_wizard / clone_wizard / send_peer 的 `job` 参数, 它们就归到这个工单名下 (期间不再逐条出气泡); 活干完调 close_job 收尾并回收临时分身。",
1432
1520
  });
1433
1521
  });
1434
1522
  http.register("POST /jobs/close", async (req, res) => {
@@ -1478,6 +1566,8 @@ const main = async () => {
1478
1566
  // ── 定时任务 ────────────────────────────────────────────────────
1479
1567
  // 到点把一句 prompt 注入一个 wizard 会话 —— 和人在群里对它说话走的是同一条路
1480
1568
  // (injectText), 所以 pane 死了会被拉起来, 输出照常落进群和详情页。
1569
+ /** 日程归谁: 排班那个 wizard (createdBy); 老文件没写就归执行它的那个。 */
1570
+ const ownerOf = (t) => t.owner || (cfg.defaultChat ?? "");
1481
1571
  http.register("POST /tasks/schedule", async (req, res) => {
1482
1572
  const { self, body } = await readPeerBody(req);
1483
1573
  if (!self) {
@@ -1491,15 +1581,15 @@ const main = async () => {
1491
1581
  json(res, 400, { ok: false, reason: "prompt required" });
1492
1582
  return;
1493
1583
  }
1494
- const when = parseWhen(whenText, new Date());
1495
- if (!when) {
1496
- json(res, 400, { ok: false, reason: `无法理解「${whenText}」。能认的说法: 每天/每个工作日/每周三 + 时刻 (晚上9:30 / 21:30 / 九点半), 每隔 N 分钟|小时, N 分钟后, 明早 9 点。` });
1584
+ const trigger = parseTrigger(whenText, new Date());
1585
+ if (!trigger) {
1586
+ json(res, 400, { ok: false, reason: WHEN_HELP(whenText) });
1497
1587
  return;
1498
1588
  }
1499
1589
  // tag 省略 = 排给调用者自己。这是最常见的用法: wizard 给自己定一个夜里跑的活。
1500
1590
  // 注入自身在**当下**是死锁 (往正在生成的输入框里打字), 但定时是未来的事,
1501
1591
  // 那时这一轮早已收工, 所以这里不套 /peers/send 的自我保护。
1502
- const tag = (body.tag ?? "").toString().trim();
1592
+ const tag = addrOf(body).trim();
1503
1593
  let target = self;
1504
1594
  if (tag) {
1505
1595
  const r = resolvePeer(self, tag);
@@ -1509,8 +1599,26 @@ const main = async () => {
1509
1599
  }
1510
1600
  target = r.target;
1511
1601
  }
1512
- const rec = addSchedule(cfg, sourcePath, { when, target, prompt, createdBy: self, note: (b.note ?? "").toString() });
1513
- json(res, 200, { ok: true, ...renderSchedule(rec), address: peerAddress(cfg, self, target) });
1602
+ // 到点是新建还是续用, 在**排班时**就定死。两条都指向新建:
1603
+ // · 没点名 wizard —— 定时的 prompt 本就要求零上下文自洽, 没理由挑一个会话挤进去;
1604
+ // · prompt 自己就在说「建两个 wizard 去干 xxx」—— 这时 tag 只是「在谁的聊天/
1605
+ // 目录下办」, 不是「在它的上下文里续」。
1606
+ // `fresh` 显式传入压过这两条推断 (要强行在 tag 那一轮里续就传 false)。
1607
+ const fresh = typeof b.fresh === "boolean" ? b.fresh : !tag || promptWantsFreshWizard(prompt);
1608
+ const note = (b.note ?? "").toString();
1609
+ const id = uniqueId(slugify((b.id ?? "").toString() || note || prompt.slice(0, 24), "task"), tasks.takenIds());
1610
+ const created = tasks.create({ id, trigger, prompt, target, fresh, note, createdBy: self });
1611
+ if ("error" in created) {
1612
+ json(res, 400, { ok: false, reason: created.error });
1613
+ return;
1614
+ }
1615
+ tasks.patchState(id, { createdAt: Date.now() });
1616
+ json(res, 200, {
1617
+ ok: true,
1618
+ ...renderTask(created, tasks.stateOf(id)),
1619
+ address: peerAddress(cfg, self, target),
1620
+ owner: peerAddress(cfg, self, self),
1621
+ });
1514
1622
  });
1515
1623
  http.register("POST /tasks/list", async (req, res) => {
1516
1624
  const { self, body } = await readPeerBody(req);
@@ -1520,9 +1628,20 @@ const main = async () => {
1520
1628
  }
1521
1629
  const mineOnly = body.mine === true;
1522
1630
  const now = new Date();
1523
- const tasks = listSchedules(cfg, mineOnly ? self : undefined)
1524
- .map((x) => ({ ...renderSchedule(x, now), address: peerAddress(cfg, self, x.target) }));
1525
- json(res, 200, { ok: true, self, tasks });
1631
+ const errs = tasks.errors();
1632
+ const rows = tasks.list()
1633
+ .filter((t) => !mineOnly || ownerOf(t) === self)
1634
+ .map((t) => ({
1635
+ ...renderTask(t, tasks.stateOf(t.id), now, errs[t.id]),
1636
+ address: peerAddress(cfg, self, t.target || (cfg.defaultChat ?? "")),
1637
+ owner: peerAddress(cfg, self, ownerOf(t)),
1638
+ }));
1639
+ // 加载失败的那些没有记录可回显, 但必须出现在列表里 —— 否则 wizard 改错了
1640
+ // 一个字, 那条定时就像凭空消失了。
1641
+ const broken = Object.entries(errs)
1642
+ .filter(([id]) => !tasks.get(id))
1643
+ .map(([id, loadError]) => ({ id, file: `${tasks.dir}/${id}.task.mjs`, loadError }));
1644
+ json(res, 200, { ok: true, self, dir: tasks.dir, tasks: [...rows, ...broken] });
1526
1645
  });
1527
1646
  http.register("POST /tasks/cancel", async (req, res) => {
1528
1647
  const { self, body } = await readPeerBody(req);
@@ -1535,12 +1654,13 @@ const main = async () => {
1535
1654
  json(res, 400, { ok: false, reason: "id required (list_tasks 里那个 id)" });
1536
1655
  return;
1537
1656
  }
1538
- const gone = removeScheduleById(cfg, sourcePath, id);
1657
+ const state = tasks.stateOf(id);
1658
+ const gone = tasks.remove(id);
1539
1659
  if (!gone) {
1540
1660
  json(res, 404, { ok: false, reason: `no schedule with id ${id}` });
1541
1661
  return;
1542
1662
  }
1543
- json(res, 200, { ok: true, removed: renderSchedule(gone) });
1663
+ json(res, 200, { ok: true, removed: renderTask(gone, state) });
1544
1664
  });
1545
1665
  // POST /config/set — modify daemon config from MCP
1546
1666
  http.register("POST /config/set", async (req, res) => {
@@ -1643,22 +1763,103 @@ const main = async () => {
1643
1763
  }
1644
1764
  json(res, 200, { ok: true, target, ...m.getCwd(target) });
1645
1765
  });
1766
+ // 定时任务的执行体。schedule_task 的 prompt 设计上本就要求"零上下文也能执行"
1767
+ // (到点时目标可能早已 /clear 过, 它只看得见这一句) —— 既然如此, 默认就不在任何
1768
+ // 已有会话里跑: 硬塞进去会把两件不相关的事挤进同一个 transcript, 目标正忙时
1769
+ // 还要连触发时间点一起被它那一轮拖走。所以到点起一个白板 wizard 单独执行,
1770
+ // 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
1771
+ // 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
1772
+ scheduledTaskInject = async (target, text, { taskId, fresh }) => {
1773
+ const busy = fresh ? false : await m.isBusy(target);
1774
+ if (!fresh && !busy)
1775
+ return m.injectText(target, text, undefined, { fromChat: true, from: { kind: "task", taskId } });
1776
+ // 执行体挂在日程的主人名下: 名字 `<主人>-task-xxxx`, parent = 主人 —— 它的
1777
+ // rolepage 里看得见这一枪是谁的日程放的。落在执行目标的群/目录里。
1778
+ const base = baseOfKey(target);
1779
+ const rec = tasks.get(taskId);
1780
+ const owner = rec ? ownerOf(rec) : target;
1781
+ const ownerName = settleName(wizards, chatNameOf(cfg, owner), owner) || "task";
1782
+ const slotR = await claimSlot(base, "", `${ownerName}-task-${taskId.slice(0, 4)}`);
1783
+ if (!slotR.ok)
1784
+ return { ok: false, reason: "起白板 wizard 失败: 挑不出槽位" };
1785
+ const runner = slotR.target;
1786
+ const info = m.sessionInfo(target);
1787
+ const why = busy ? ` (起因: ${displayName(target)} 当时正忙)` : "";
1788
+ wizards.upsert(runner, {
1789
+ description: `定时任务 ${taskId} 的一次性执行体${why}`,
1790
+ parent: owner,
1791
+ bornAt: Date.now(),
1792
+ });
1793
+ const runnerName = wizards.rename(runner, `${ownerName}-task-${taskId.slice(0, 4)}`);
1794
+ const spawned = await m.newSession(runner, runnerName, info?.cli, { cwd: info?.cwd, model: info?.model, silent: true });
1795
+ if (!spawned.ok) {
1796
+ wizards.drop(runner);
1797
+ return { ok: false, reason: `起白板 wizard 失败: ${spawned.reason ?? "unknown"}` };
1798
+ }
1799
+ notifyChat(base, withTagHeader(target, busy
1800
+ ? `⏰ 定时任务到点时正忙, 已起白板 wizard .${runnerName} 单独执行, 完成后自动收掉`
1801
+ : `⏰ 定时任务已起白板 wizard .${runnerName} 执行, 完成后自动收掉`));
1802
+ const inj = await m.injectText(runner, text, undefined, { fromChat: true, from: { kind: "task", taskId } });
1803
+ if (!inj.ok) {
1804
+ wizards.drop(runner);
1805
+ return inj;
1806
+ }
1807
+ // 没有人会对这个一次性分身喊 stop_wizard, 只能自己等它闲下来再收。30min
1808
+ // 内没闲下来就放弃自动回收 (它大概率还在干一个长活), 留给人手动处理 ——
1809
+ // 比杀掉一个还在跑的 pane 安全。
1810
+ void (async () => {
1811
+ const idle = await waitForIdle(runner, m.isBusy, 30 * 60_000, () => false);
1812
+ if (!idle.idle) {
1813
+ log.warn({ runner, reason: idle.reason }, "task runner: 30min 未闲下来, 放弃自动回收");
1814
+ return;
1815
+ }
1816
+ await m.killPane(runner);
1817
+ wizards.drop(runner);
1818
+ })();
1819
+ return inj;
1820
+ };
1646
1821
  }
1647
- // 定时调度器 — 每 20s 检查 cfg.schedules, 到点把 prompt 注入目标 wizard。
1648
- // inject 只在 mirror 模式给得出 (headless 模式没有常驻会话可注入)。
1822
+ // 定时调度器 — 每 20s 检查任务表, 到点把 prompt 注入目标 wizard。
1823
+ migrateLegacySchedules(cfg, sourcePath, tasks, log.child({ mod: "tasks" }));
1649
1824
  const scheduler = startScheduler({
1650
1825
  client: ws.client,
1651
- cfg,
1652
- sourcePath,
1826
+ registry: tasks,
1653
1827
  log: log.child({ mod: "tasks" }),
1654
- inject: cfg.wrc.mode === "mirror"
1655
- ? (target, text, taskId) => bridge.injectText(target, text, undefined, { fromChat: true, from: { kind: "task", taskId } })
1656
- : undefined,
1828
+ inject: scheduledTaskInject,
1829
+ // gate 里的 `sh` 默认就在目标 wizard 此刻的工作区跑 —— 任务文件里不必写死绝对路径。
1830
+ cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
1831
+ fallbackTarget: () => (cfg.defaultChat ?? "").trim(),
1657
1832
  });
1833
+ // 闲置 pane 收割机 — 默认 48h 静默才动 (wrc.mirror.idleReapHours, 0=关)。
1834
+ // 只收 pane 不收绑定: 下一条消息 `--resume` 复活。豁免: 名下有定时任务的
1835
+ // wizard (调度本身会唤醒/需要它), 挂着审批长轮询的会话 (hook 还停在等点击),
1836
+ // 忙着的 / 人正盯着的 (bridge 内部判)。keepalive 会话靠 ping 刷新 transcript,
1837
+ // 天然到不了阈值。
1838
+ let reapTimer;
1839
+ if (cfg.wrc.mirror.idleReapHours > 0) {
1840
+ const ttlMs = cfg.wrc.mirror.idleReapHours * 3.6e6;
1841
+ const sweep = async () => {
1842
+ const taskOwners = new Set(tasks.list()
1843
+ .filter((t) => t.enabled)
1844
+ .map((t) => wizardStore()?.byName(t.owner)?.target)
1845
+ .filter((t) => !!t));
1846
+ const pendingSids = new Set(listPending().map((p) => p.meta.sessionId).filter((s) => !!s));
1847
+ const r = await bridge.reapIdle(ttlMs, (target, sid) => taskOwners.has(target) ? "owns scheduled task"
1848
+ : pendingSids.has(sid) ? "pending approval"
1849
+ : undefined);
1850
+ if (r.reaped.length)
1851
+ log.info({ reaped: r.reaped.length, targets: r.reaped }, "idle reap sweep");
1852
+ };
1853
+ reapTimer = setInterval(() => void sweep().catch((e) => log.warn({ err: e.message }, "idle reap sweep failed")), 30 * 60_000);
1854
+ reapTimer.unref();
1855
+ }
1658
1856
  const shutdown = async (signal) => {
1659
1857
  log.info({ signal }, "shutdown signal");
1660
1858
  scheduler.stop();
1859
+ tasks.stop();
1661
1860
  netWatch.stop();
1861
+ if (reapTimer)
1862
+ clearInterval(reapTimer);
1662
1863
  // 同 POST /shutdown: 先把挂着的审批长轮询了结成「稍后续接」, 再关连接。
1663
1864
  log.info(drainForReload(), "pending drained for reload");
1664
1865
  // Hard-exit watchdog: http.close() blocks until every in-flight connection