wezard 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CLAUDE.md +3 -0
  3. package/README.md +43 -1
  4. package/cli/wezard.sh +9 -1
  5. package/config.example.jsonc +22 -5
  6. package/dist/cli/codex-hook.js +91 -0
  7. package/dist/cli/codex-hook.js.map +1 -0
  8. package/dist/cli/codex-sync.js +64 -0
  9. package/dist/cli/codex-sync.js.map +1 -0
  10. package/dist/cli/init.js +7 -3
  11. package/dist/cli/init.js.map +1 -1
  12. package/dist/cli/sync.js +16 -5
  13. package/dist/cli/sync.js.map +1 -1
  14. package/dist/daemon/aibot-media.js +6 -2
  15. package/dist/daemon/aibot-media.js.map +1 -1
  16. package/dist/daemon/approval.js +44 -21
  17. package/dist/daemon/approval.js.map +1 -1
  18. package/dist/daemon/bot-mux.js +157 -0
  19. package/dist/daemon/bot-mux.js.map +1 -0
  20. package/dist/daemon/cc-session.js +8 -1
  21. package/dist/daemon/cc-session.js.map +1 -1
  22. package/dist/daemon/cfg-sync.js +1 -0
  23. package/dist/daemon/cfg-sync.js.map +1 -1
  24. package/dist/daemon/claim.js +45 -1
  25. package/dist/daemon/claim.js.map +1 -1
  26. package/dist/daemon/config-api.js +59 -23
  27. package/dist/daemon/config-api.js.map +1 -1
  28. package/dist/daemon/detail.js +4 -0
  29. package/dist/daemon/detail.js.map +1 -1
  30. package/dist/daemon/http.js +9 -0
  31. package/dist/daemon/http.js.map +1 -1
  32. package/dist/daemon/inbound.js +54 -28
  33. package/dist/daemon/inbound.js.map +1 -1
  34. package/dist/daemon/index.js +181 -139
  35. package/dist/daemon/index.js.map +1 -1
  36. package/dist/daemon/jobs.js +1 -1
  37. package/dist/daemon/jobs.js.map +1 -1
  38. package/dist/daemon/last-response.js +12 -6
  39. package/dist/daemon/last-response.js.map +1 -1
  40. package/dist/daemon/mirror-bridge.js +351 -144
  41. package/dist/daemon/mirror-bridge.js.map +1 -1
  42. package/dist/daemon/peers.js +12 -16
  43. package/dist/daemon/peers.js.map +1 -1
  44. package/dist/daemon/picker-policy.js +46 -0
  45. package/dist/daemon/picker-policy.js.map +1 -0
  46. package/dist/daemon/quota.js +10 -7
  47. package/dist/daemon/quota.js.map +1 -1
  48. package/dist/daemon/reach.js +5 -1
  49. package/dist/daemon/reach.js.map +1 -1
  50. package/dist/daemon/receipts.js +1 -1
  51. package/dist/daemon/receipts.js.map +1 -1
  52. package/dist/daemon/route.js +67 -26
  53. package/dist/daemon/route.js.map +1 -1
  54. package/dist/daemon/session-scan.js +34 -10
  55. package/dist/daemon/session-scan.js.map +1 -1
  56. package/dist/daemon/spawn-tmux.js +181 -76
  57. package/dist/daemon/spawn-tmux.js.map +1 -1
  58. package/dist/daemon/steward-model.js +24 -0
  59. package/dist/daemon/steward-model.js.map +1 -0
  60. package/dist/daemon/subagent-tail.js +5 -0
  61. package/dist/daemon/subagent-tail.js.map +1 -1
  62. package/dist/daemon/task-registry.js +6 -1
  63. package/dist/daemon/task-registry.js.map +1 -1
  64. package/dist/daemon/tasks.js +1 -1
  65. package/dist/daemon/tasks.js.map +1 -1
  66. package/dist/daemon/tips.js +1 -1
  67. package/dist/daemon/tips.js.map +1 -1
  68. package/dist/daemon/wizard-memory.js +12 -6
  69. package/dist/daemon/wizard-memory.js.map +1 -1
  70. package/dist/daemon/wizard.js +7 -5
  71. package/dist/daemon/wizard.js.map +1 -1
  72. package/dist/daemon/ws.js +79 -10
  73. package/dist/daemon/ws.js.map +1 -1
  74. package/dist/mcp/server.js +19 -18
  75. package/dist/mcp/server.js.map +1 -1
  76. package/dist/shared/chat-http.js +63 -29
  77. package/dist/shared/chat-http.js.map +1 -1
  78. package/dist/shared/chat-view.js +10 -3
  79. package/dist/shared/chat-view.js.map +1 -1
  80. package/dist/shared/cli-backends.js +11 -0
  81. package/dist/shared/cli-backends.js.map +1 -1
  82. package/dist/shared/codex-approval.js +57 -0
  83. package/dist/shared/codex-approval.js.map +1 -0
  84. package/dist/shared/codex-hooks.js +134 -0
  85. package/dist/shared/codex-hooks.js.map +1 -0
  86. package/dist/shared/codex.js +125 -0
  87. package/dist/shared/codex.js.map +1 -0
  88. package/dist/shared/config.js +85 -21
  89. package/dist/shared/config.js.map +1 -1
  90. package/dist/shared/detail-render.js +24 -16
  91. package/dist/shared/detail-render.js.map +1 -1
  92. package/dist/shared/detail-store.js +11 -10
  93. package/dist/shared/detail-store.js.map +1 -1
  94. package/dist/shared/exec-path.js +12 -0
  95. package/dist/shared/exec-path.js.map +1 -1
  96. package/dist/shared/modal-pane.js +19 -1
  97. package/dist/shared/modal-pane.js.map +1 -1
  98. package/dist/shared/model-tier-migration.js +43 -0
  99. package/dist/shared/model-tier-migration.js.map +1 -0
  100. package/dist/shared/model-tiers.js +35 -0
  101. package/dist/shared/model-tiers.js.map +1 -0
  102. package/dist/shared/reminder.js +19 -4
  103. package/dist/shared/reminder.js.map +1 -1
  104. package/dist/shared/session-label.js +27 -2
  105. package/dist/shared/session-label.js.map +1 -1
  106. package/dist/shared/tool-hints.js +144 -0
  107. package/dist/shared/tool-hints.js.map +1 -0
  108. package/dist/shared/workspace-trust.js +69 -0
  109. package/dist/shared/workspace-trust.js.map +1 -0
  110. package/dist/shared/world.js +5 -4
  111. package/dist/shared/world.js.map +1 -1
  112. package/dist/svr/index.js +3 -0
  113. package/dist/svr/index.js.map +1 -1
  114. package/hooks/pre-tool-use.sh +1 -1
  115. package/package.json +6 -1
@@ -5,13 +5,14 @@ import { timingSafeEqual } from "node:crypto";
5
5
  import { loadOrCreateSvrToken } from "../shared/svr-token.js";
6
6
  import { DAEMON_TOKEN_FILE, DAEMON_TOKEN_HEADER } from "../shared/daemon-token.js";
7
7
  import { homedir } from "node:os";
8
- import { TIERS, loadConfig } from "../shared/config.js";
8
+ import { TIERS, defaultChatFor, tiersForBackend, loadConfig } from "../shared/config.js";
9
9
  import { makeLogger } from "../shared/log.js";
10
10
  import { clipLine, sleep, truncate } from "../shared/std.js";
11
11
  import { patchJsonc } from "../shared/config-writer.js";
12
12
  import { EFFORTS, parseEffort } from "../shared/effort.js";
13
13
  import { bindCliBackends, projectDirsFor } from "../shared/cli-backends.js";
14
- import { startWs } from "./ws.js";
14
+ import { bindCodexStateDir } from "../shared/codex.js";
15
+ import { startMux } from "./bot-mux.js";
15
16
  import { startWeixin } from "./weixin.js";
16
17
  import { installWeixinPort } from "./weixin-port.js";
17
18
  import { bindWxCommands, makeOnBound, makeWxCommands, routeSendMedia, wecomBindSink, weixinSendMedia, wxAdmins, wxRoutes } from "./weixin-cmd.js";
@@ -20,13 +21,13 @@ import { aibotSendMedia } from "./aibot-media.js";
20
21
  import { startNetWatch } from "./net-watch.js";
21
22
  import { startHttp, json, readBody } from "./http.js";
22
23
  import { parsePath } from "../shared/config-meta.js";
23
- import { applyHot, patchedTop, configGet, fromLegacy, legacyGet, pickTier, planSet, renderPlan, tierEvidence } from "./config-api.js";
24
+ import { applyHot, cfgSeenBy, patchedTop, pinChatKeys, configGet, fromLegacy, legacyGet, pickTier, planSet, renderPlan, tierEvidence } from "./config-api.js";
24
25
  import { computeUsage, costOf, fmtCost, fmtTokens, sumTotal } from "./usage.js";
25
26
  import { installInboundRouter } from "./inbound.js";
26
27
  import { loadMirrorStore } from "./mirror-store.js";
27
28
  import { startMirror, installMirrorEventListener } from "./mirror-bridge.js";
28
- import { runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
29
- import { installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
29
+ import { backendFor, runTmux, setTmuxTimeoutReporter, spawnTmuxClaude } from "./spawn-tmux.js";
30
+ import { bindCardChannel, installApprovalEventListener, makeApproveHandler, requestConfirm } from "./approval.js";
30
31
  import { initDetailPersistence, makeDetailHandler, chatHandlers, configureRemoteForward, chatUrlFor, setWorldFactsProvider, currentHumanQueryOf, recordPost, recordCharter, backfillCharter } from "./detail.js";
31
32
  import { EMPTY_FACTS } from "../shared/world.js";
32
33
  import { clearAutoWindow, initAutoWindowPersistence, setAutoWindow } from "./session-cache.js";
@@ -41,7 +42,7 @@ import { startScheduler, migrateLegacySchedules, promptWantsFreshWizard, renderT
41
42
  import { openTaskRegistry } from "./task-registry.js";
42
43
  import { describeTrigger, nextFire, parseTrigger, WHEN_HELP } from "../shared/trigger.js";
43
44
  import { isQuiet, slugify, uniqueId } from "../shared/task-file.js";
44
- import { baseOfKey, bindTagLinker, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, speakerHead, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
45
+ import { baseOfKey, bindMultiBot, bindTagLinker, botOfKey, headed, ownHead, isInternalKey, keyOf, linkTags, normalizeTag, sameBot, speakerHead, tagFromCwd, tagHead, tagLink, tagOfKey, uniqueTag, withTagHeader } from "../shared/session-label.js";
45
46
  import { appendEpisode, clipForCharter, cwdOfMd, episodePath, inboxPath, mdsOf, proposedCwds, memoryPath, memoryRoot, proposeMemory, readMemory } from "./wizard-memory.js";
46
47
  import { retireStewardTask, startSteward, stewardEnvelope, STEWARD_ID, STEWARD_RUN_MS, STEWARD_TARGET } from "./memory-steward.js";
47
48
  import { applyChatNames, chatBaseOf, chatNameOf, chatPolicyOf, clearChatName, listChatNames, normChatName, peerAddress, planChatNames, setChatName, stewardTierOf } from "./chat-name.js";
@@ -58,9 +59,9 @@ import { loadJsonMap } from "../shared/json-map-store.js";
58
59
  import { createReceipts, deadlineOf, kAttr, kOfAttr, newTurn } from "./receipts.js";
59
60
  import { renderInFlight } from "../shared/turn-state.js";
60
61
  import { startIdleNudge } from "./idle-nudge.js";
61
- import { renderReminder } from "../shared/reminder.js";
62
+ import { askerFromEnvelope, renderReminder } from "../shared/reminder.js";
62
63
  import { createHandoffs, handedOff, handingOff } from "./handoff.js";
63
- import { coldGateOf, decide, rankCandidates, renderCandidates, tierFor, wakeCostOf, wakeNoteOf } from "./route.js";
64
+ import { coldGateOf, decide, rankCandidates, renderCandidates, routeDutiesOf, tierFor, wakeCostOf, wakeNoteOf } from "./route.js";
64
65
  import { parseWhen, renderChatLog, UNKNOWN_HUMAN } from "./chat-log.js";
65
66
  import { audienceOf } from "../shared/role-view.js";
66
67
  import { startGraph, stopRun, getRun, listRuns, validateSpec, waitForIdle, waitForQuorum, } from "./graph.js";
@@ -73,6 +74,7 @@ const fatalExit = (msg, extra) => {
73
74
  };
74
75
  const main = async () => {
75
76
  const { config: cfg, sourcePath } = loadConfig();
77
+ bindMultiBot(cfg.bots.length > 0);
76
78
  const log = makeLogger({
77
79
  logFile: cfg.daemon.logFile,
78
80
  logLevel: cfg.daemon.logLevel,
@@ -91,6 +93,7 @@ const main = async () => {
91
93
  // derives its dialect from its own jsonl path. Must run BEFORE any mirror
92
94
  // attach / tmux spawn.
93
95
  const { primary, backends } = bindCliBackends({ ...cfg.wrc, projectsDirOverride: cfg.wrc.mirror.projectsDir });
96
+ bindCodexStateDir(cfg.daemon.stateDir);
94
97
  log.info({ primary: primary.name, bin: primary.bin, backends: backends.map((b) => b.name) }, "cli backends bound");
95
98
  // A killed-on-timeout tmux call is the ONLY externally visible symptom of a
96
99
  // wedged tmux server, and the exec helper has no logger of its own. Wire it
@@ -103,7 +106,9 @@ const main = async () => {
103
106
  // links from messages already on the user's WeCom timeline.
104
107
  initDetailPersistence(cfg.daemon.stateDir, log.child({ mod: "detail" }));
105
108
  configureRemoteForward(cfg.daemon.detailRemoteBase, cfg.daemon.detailRemoteToken);
106
- const ws = startWs(cfg, log);
109
+ // 一个或多个企微机器人 (bot + bots): 对下面所有模块只露主机器人那个 client, 分流在 bot-mux 里。
110
+ // 必须先于 weixin-port / response-tracker 建好 —— 它们包在 mux 外层。
111
+ const ws = startMux(cfg, log);
107
112
  // 切网 (换 WiFi / VPN 起停) 后原地重建 WS, 等价于自动做了一次 reload 的
108
113
  // 联通性部分 — 但保留全部内存态 (graph 运行、pending 长轮询、镜像绑定),
109
114
  // 也不依赖 launchd/systemd 的 respawn 策略 (nohup fallback 没有 supervisor)。
@@ -166,9 +171,9 @@ const main = async () => {
166
171
  // 名册在下面的 mirror 块里才绑 (bindWizardStore) —— 这里全走 wizardStore() 惰性取。
167
172
  bindTagLinker({
168
173
  urlOf: (t) => chatUrlFor(cfg.daemon, t),
169
- resolve: (name) => {
174
+ resolve: (name, from) => {
170
175
  const t = wizardStore()?.byName(name)?.target;
171
- return t && known(t) ? t : undefined;
176
+ return t && known(t) && sameBot(from, t) ? t : undefined;
172
177
  },
173
178
  nameOf: (t) => settleName(wizardStore(), chatNameOf(cfg, t), t),
174
179
  voiceOf: (t) => wizardStore()?.get(t)?.voiceOf,
@@ -334,7 +339,7 @@ const main = async () => {
334
339
  return;
335
340
  }
336
341
  const spawnLog = log.child({ mod: "mirror", sub: "spawn-init", target });
337
- const r = await spawnTmuxClaude({ cfg, log: spawnLog, windowName: target, systemPrompt: charterFor(target, {}) });
342
+ const r = await spawnTmuxClaude({ cfg, log: spawnLog, windowName: target, systemPrompt: charterFor(target, { cli: backendFor(cfg).name }) });
338
343
  if (!r.ok) {
339
344
  json(res, 500, { ok: false, reason: r.reason });
340
345
  return;
@@ -342,21 +347,16 @@ const main = async () => {
342
347
  const att = m.attach({ sessionId: r.sessionId, jsonlPath: r.jsonlPath, target, tmuxPane: r.tmuxPane, tmuxSession: r.tmuxSession });
343
348
  json(res, att.ok ? 200 : 500, att.ok ? { ok: true, sessionId: r.sessionId, tmuxSession: r.tmuxSession, tmuxPane: r.tmuxPane, target } : { ok: false, reason: att.reason });
344
349
  });
345
- // Which chat is the caller? An MCP tool can only see its own process, so
346
- // every route an agent drives has to be told: explicit target → sessionId
347
- // → tmuxPane (stable across `/clear`, which rotates sessionId) → the
348
- // configured defaultChat. Same precedence as /mirror/workspace.
350
+ // A missing agent identity is an error, never an invitation to impersonate defaultChat.
351
+ // Pane wins across native session switches; the MCP session id is a spawn-time snapshot.
349
352
  const resolveSelf = (b) => {
350
353
  const explicit = (b.target ?? "").trim();
351
354
  if (explicit)
352
355
  return explicit;
353
- const bySid = b.sessionId?.trim() ? m.targetForSession(b.sessionId.trim()) : undefined;
354
- if (bySid)
355
- return bySid;
356
- const byPane = b.tmuxPane?.trim() ? m.targetForPane(b.tmuxPane.trim()) : undefined;
357
- if (byPane)
358
- return byPane;
359
- return (cfg.defaultChat ?? "").trim();
356
+ const pane = b.tmuxPane?.trim();
357
+ if (pane)
358
+ return m.targetForPane(pane) ?? "";
359
+ return b.sessionId?.trim() ? m.targetForSession(b.sessionId.trim()) ?? "" : "";
360
360
  };
361
361
  // ── Session discovery / switching (conversational, via MCP tools) ───────
362
362
  // GET /sessions/list — enumerate live claude sessions in tmux + a one-line
@@ -395,7 +395,8 @@ const main = async () => {
395
395
  return;
396
396
  }
397
397
  const sessions = await scanClaudeSessions();
398
- const hit = sessions.find((s) => s.sessionId === sessionId);
398
+ // 已绑在别的机器人聊天上的会话与不存在同形。
399
+ const hit = sessions.find((s) => s.sessionId === sessionId && ((t) => !t || sameBot(target, t))(m.targetForSession(s.sessionId)));
399
400
  if (!hit) {
400
401
  json(res, 404, { ok: false, reason: `session ${sessionId} not found among live tmux sessions` });
401
402
  return;
@@ -437,16 +438,16 @@ const main = async () => {
437
438
  }
438
439
  const self = resolveSelf(body);
439
440
  if (!self) {
440
- json(res, 400, { ok: false, reason: "cannot resolve caller chat (pass target/sessionId/tmuxPane, or set cfg.defaultChat)" });
441
+ json(res, 400, { ok: false, reason: "cannot resolve caller chat (pass target/sessionId/tmuxPane)" });
441
442
  return;
442
443
  }
443
444
  const wantChat = (body.chat ?? "").toString().trim();
444
- const base = wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self);
445
+ const base = ((b) => (b && sameBot(self, b) ? b : ""))(wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self));
445
446
  if (!base) {
446
447
  json(res, 404, {
447
448
  ok: false,
448
449
  reason: `unknown chat '${wantChat}' — only NAMED chats can be spawned into; have someone run \`/name ${normChatName(wantChat) || "<name>"}\` there first`,
449
- candidates: listChatNames(cfg).map((c) => c.name),
450
+ candidates: chatNamesFor(self),
450
451
  });
451
452
  return;
452
453
  }
@@ -508,29 +509,14 @@ const main = async () => {
508
509
  // One-shot project switch: bind the chat-wide cwd AND apply it right here
509
510
  // by walking the exact /new path (kill pane → respawn in pendingCwd →
510
511
  // attach → "📂 当前项目" push). Resolved target
511
- // precedence: explicit body.target → sessionId-derived (so MCP can omit
512
- // it) → tmuxPane (stable across /clear) → cfg.defaultChat. When the caller
512
+ // precedence follows resolveSelf (explicit target, then stable pane, then session). When the caller
513
513
  // IS the session being replaced, its pane dies mid-tool-call — the
514
514
  // project-info bubble is the receipt. On spawn failure the pendingCwd
515
515
  // stays queued, so a manual /new from WeCom still completes the switch.
516
516
  http.register("POST /mirror/workspace", async (req, res) => {
517
517
  const body = (await readBody(req));
518
518
  const cwd = (body.cwd ?? "").toString();
519
- let target = (body.target ?? "").trim();
520
- if (!target && body.sessionId) {
521
- const t = m.targetForSession(body.sessionId.trim());
522
- if (t)
523
- target = t;
524
- }
525
- // sessionId rotates on /clear; the MCP caller's env sessionId may be stale.
526
- // Pane id is stable, so resolve by it before falling back to defaultChat.
527
- if (!target && body.tmuxPane) {
528
- const t = m.targetForPane(body.tmuxPane.trim());
529
- if (t)
530
- target = t;
531
- }
532
- if (!target)
533
- target = (cfg.defaultChat ?? "").trim();
519
+ const target = resolveSelf(body);
534
520
  if (!target) {
535
521
  json(res, 400, { ok: false, reason: "target required (or pass sessionId of an attached chat)" });
536
522
  return;
@@ -567,12 +553,18 @@ const main = async () => {
567
553
  // 地址语义见 mirror-bridge.resolvePeerTag: "" = 本聊天的默认 wizard (它是正当的
568
554
  // 协作对象 ——「回去跟主会话汇报」); 裸 `fix` 先找本聊天、再退回全机唯一的那个;
569
555
  // `daily#fix` 直接指名聊天, 不要求任何唯一性。
556
+ // 多机器人隔离: 别的机器人名下的 wizard 由 resolvePeerTag 当作查无此名 (同形、candidates 只列
557
+ // 同机器人的) —— 点名寻址 (peek / tell / wait / stop / clone from / handoff / set_model / pending /
558
+ // schedule) 全过这一道。
570
559
  const resolvePeer = (self, tag) => {
571
560
  const r = m.resolvePeerTag(self, tag);
572
561
  if (r.ok)
573
562
  return r;
574
563
  return { ok: false, status: 404, reason: r.reason, candidates: r.candidates };
575
564
  };
565
+ /** 聊天名候选只列与调用方同机器人的 —— 别的机器人的聊天名也是它的存在。指名别的机器人的聊天,
566
+ * 各路由一律走它们自己「认不出这个聊天」的分支 (同状态码同原话), 不说「那边有, 但归别人」。 */
567
+ const chatNamesFor = (self) => listChatNames(cfg).filter((c) => sameBot(self, c.base)).map((c) => c.name);
576
568
  // ── Chat naming ────────────────────────────────────────────────────
577
569
  // A WeCom chat's identity is an unreadable `chat:wrkS…` id. Naming it is
578
570
  // what makes cross-chat addressing (`daily#fix`) and cross-chat spawning
@@ -581,7 +573,7 @@ const main = async () => {
581
573
  const { self } = await readPeerBody(req);
582
574
  if (self)
583
575
  ensureChatNames(self);
584
- const roster = self ? m.chatRoster(self) : listChatNames(cfg).map((c) => ({ ...c, self: false, targets: [] }));
576
+ const roster = self ? m.chatRoster(self).filter((c) => sameBot(self, c.base)) : listChatNames(cfg).map((c) => ({ ...c, self: false, targets: [] }));
585
577
  json(res, 200, {
586
578
  ok: true,
587
579
  self,
@@ -735,7 +727,7 @@ const main = async () => {
735
727
  rebound: async (x, why) => (await m.injectText(x.to, `(守护进程代 ${displayName(x.from)} 打回一次: 这件活是工单里的一份, 你的回复${why}。请按约定收口 —— 末尾一行 \`RESULT: <结论>\`, 交付了文件每个加一行 \`ARTIFACT: <路径> — 一句话\`; 真缺信息就 \`NEED: <问题>\`。)`, undefined, {
736
728
  from: { kind: "peer", from: x.from, turn: x.turn, ...(x.asker ? { asker: x.asker } : {}) },
737
729
  channel: x.channel,
738
- envelope: envelopeFor(x.from, x.channel, { turn: x.turn, legs: x.legs, re: true }),
730
+ envelope: envelopeFor(x.from, x.channel, { turn: x.turn, legs: x.legs, re: true, asker: x.asker }),
739
731
  })).ok,
740
732
  untilIdle: m.untilIdle,
741
733
  paneLive: m.paneLive,
@@ -760,6 +752,7 @@ const main = async () => {
760
752
  envelope: renderReceiptEnvelope(displayName(meta.from), meta.channel ? chatNameOf(cfg, meta.channel) : undefined, meta.job ? { job: meta.job, done: meta.done, total: meta.total, closed: jobs.get(meta.job)?.status !== "open", ...(jobs.get(meta.job)?.kind === "req" ? { req: true } : {}) } : undefined, meta.status, meta.turn || undefined, {
761
753
  ...(meta.replyTo ? { replyTo: meta.replyTo.kind === "chat" ? chatNameOf(cfg, meta.replyTo.channel) : displayName(meta.replyTo.from) } : {}),
762
754
  ...(meta.k ? { k: kAttr(meta.k) } : {}),
755
+ ...(meta.asker ? { asker: meta.asker } : {}),
763
756
  ...(meta.pending ? { pending: meta.pending } : {}),
764
757
  ...(audienceName(meta.channel, meta.asker) ? { audience: audienceName(meta.channel, meta.asker) } : {}),
765
758
  }),
@@ -815,7 +808,7 @@ const main = async () => {
815
808
  return {
816
809
  from: { kind: "peer", from: o.from, ...(o.turn ? { turn: o.turn } : {}), ...(o.channel ? { public: true } : {}), ...(o.asker ? { asker: o.asker } : {}) },
817
810
  channel: o.channel,
818
- envelope: envelopeFor(o.from, o.channel, o.turn ? { turn: o.turn } : undefined),
811
+ envelope: envelopeFor(o.from, o.channel, { turn: o.turn ?? "", asker: o.asker }),
819
812
  };
820
813
  }
821
814
  /** 简报那一轮之后, 余下的欠账一份一轮: 等它接住上一句 (至多 20s 动起来) 并答完, 再贴下一份。 */
@@ -873,13 +866,12 @@ const main = async () => {
873
866
  const env = openingOfSelf(self)?.env;
874
867
  const home = baseOfKey(self);
875
868
  if (!env)
876
- return home.startsWith("user:") ? { who: home, chat: home } : undefined;
877
- if (env.kind === "human") {
878
- const chat = env.chat ? chatBaseOf(cfg, env.chat) : home;
879
- return chat ? { who: `user:${env.from}`, chat } : undefined;
880
- }
881
- return env.kind === "peer" ? receipts.askerOf(self, env) : undefined;
869
+ return m.currentAsker(self);
870
+ return askerFromEnvelope(env, (chat) => chat ? chatBaseOf(cfg, chat) : home)
871
+ ?? (env.kind === "peer" ? receipts.askerOf(self, env) : undefined);
882
872
  };
873
+ // A private dispatch keeps its human origin even though its output channel is empty.
874
+ bindCardChannel((key) => askerOf(key)?.chat || m.currentChannel(key));
883
875
  // 交接先续做: 它的闸要在回执续守之前立起来, 否则续守的 watcher 会读到换了一半的会话。
884
876
  void m.restored.then(() => { handoffs.resume(); receipts.resume(); });
885
877
  // 闲置追问 (见 idle-nudge.ts): 对方持续闲着又没回执 → 代发话方同件号问一句进展, 答复照常回执。
@@ -898,7 +890,7 @@ const main = async () => {
898
890
  return (await m.injectText(to, text, undefined, {
899
891
  from: { kind: "peer", from, turn, ...(x.asker ? { asker: x.asker } : {}) },
900
892
  channel: x.channel,
901
- envelope: envelopeFor(from, x.channel, { turn, legs: x.legs, re: true }),
893
+ envelope: envelopeFor(from, x.channel, { turn, legs: x.legs, re: true, asker: x.asker }),
902
894
  })).ok;
903
895
  },
904
896
  nameOf: displayName,
@@ -927,6 +919,11 @@ const main = async () => {
927
919
  const claimSlot = async (base, want, fallback, keep = []) => {
928
920
  const asked = normalizeTag(want);
929
921
  const clash = asked ? wizards.byName(asked) : undefined;
922
+ // 名字全机唯一, 撞上别的机器人的 wizard 照样拒 —— 但不顶掉它、不复用它的槽, 也不报它的死活:
923
+ // 那边的 wizard 在这个机器人眼里不存在, 只剩「名字被占了」这一个事实。
924
+ if (clash && !sameBot(base, clash.target)) {
925
+ return { ok: false, status: 409, body: { ok: false, reason: `名字 '.${clash.name}' 已被占用 (名字全局唯一) —— 换个名字`, name: clash.name } };
926
+ }
930
927
  const bound = !!clash && m.chatTargets(baseOfKey(clash.target)).includes(clash.target);
931
928
  if (clash && !bound && baseOfKey(clash.target) === base && tagOfKey(clash.target)) {
932
929
  return { ok: true, target: clash.target, slot: tagOfKey(clash.target) };
@@ -996,7 +993,7 @@ const main = async () => {
996
993
  // 认不出的名字原样报回去, 绝不静默跳过 ——「发了但没人收到」是这类工具最难查
997
994
  // 的故障; 有一个收件人错了整条就不发, 部分送达比不送达更难对账。
998
995
  const refs = (b.to ?? []).map((r) => (r ?? "").trim()).filter(Boolean);
999
- const bad = refs.filter((r) => !chatBaseOf(cfg, r));
996
+ const bad = refs.filter((r) => ((b) => !b || !sameBot(self, b))(chatBaseOf(cfg, r)));
1000
997
  if (bad.length) {
1001
998
  json(res, 400, { ok: false, reason: `认不出这些聊天: ${bad.join(", ")} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
1002
999
  return;
@@ -1040,7 +1037,7 @@ const main = async () => {
1040
1037
  ensureChatNames(self);
1041
1038
  const ref = (b.to ?? "").trim();
1042
1039
  const dest = ref ? chatBaseOf(cfg, ref) : channelOf(self);
1043
- if (!dest) {
1040
+ if (!dest || !sameBot(self, dest)) {
1044
1041
  json(res, 400, { ok: false, reason: `认不出聊天: ${ref} (wizard_roster 每行有 home 聊天名, 人侧 /chats; 没起名的聊天寻址不到)` });
1045
1042
  return;
1046
1043
  }
@@ -1137,7 +1134,8 @@ const main = async () => {
1137
1134
  const [roleRef, chatRef, targetRef, sinceRef, untilRef] = [b.role, b.chat, b.with, b.since, b.until].map((x) => (x ?? "").toString().trim());
1138
1135
  // 什么都没给 = 调用方这一轮所在的群; 只给 target = 调用方与它的往来。
1139
1136
  const base = chatRef ? chatBaseOf(cfg, chatRef) : roleRef || targetRef ? "" : channelOf(self);
1140
- if (chatRef && !base) {
1137
+ // 别的机器人的聊天与认不出同形 (不说它在)。
1138
+ if (chatRef && (!base || !sameBot(self, base))) {
1141
1139
  json(res, 404, { ok: false, reason: `认不出聊天 '${chatRef}' (wizard_roster 每行有 home 聊天名, 人侧 /chats)` });
1142
1140
  return;
1143
1141
  }
@@ -1152,7 +1150,8 @@ const main = async () => {
1152
1150
  const limit = Math.min(Math.max(Number(b.limit ?? 30) || 30, 1), 200);
1153
1151
  const per = Math.min(Math.max(Number(b.per ?? 500) || 500, 40), 4000);
1154
1152
  const pinned = new Set([self, role?.target, target?.target]);
1155
- const all = m.transcripts();
1153
+ // 只读与调用方同机器人的会话 —— 别的机器人那边的往来连条数都不该露出来。
1154
+ const all = m.transcripts().filter((t) => sameBot(self, t.target));
1156
1155
  // 两方之间的话只可能落在这两方里的 wizard 自己的 transcript 里 (人没有 transcript)
1157
1156
  // —— 不必去翻别的会话, 「往回还有没有」也因此答得准。
1158
1157
  const between = [role?.target, target?.target].filter((t) => !!t);
@@ -1234,7 +1233,10 @@ const main = async () => {
1234
1233
  // 件号先于一切: `re` 续问沿用那件活的件号、工单与频道 (见 receipts.prepare)。
1235
1234
  const re = (body.re ?? "").toString().trim();
1236
1235
  // 放行一件停放着的 (after): 件号 / 频道 / 父 k / 链头都沿用停放那一刻定的 —— 依赖边不改 k。
1237
- const turn = held ? { turn: held.turn, legs: 1, channel: held.channel } : receipts.prepare(self, target, re || undefined);
1236
+ const turn = held ? { turn: held.turn, legs: 1, channel: held.channel, asker: held.asker, k: held.k } : receipts.prepare(self, target, re || undefined);
1237
+ // Capture the origin before waiting for another pane; our own turn may change meanwhile.
1238
+ const asker = held ? held.asker : turn.legs > 1 ? turn.asker : askerOf(self);
1239
+ const k = held ? held.k : body.chain === false ? undefined : turn.legs > 1 ? turn.k : parentKOf(self);
1238
1240
  // 续问只在那个工单还开着时沿用它: 收了工的工单不再收成员, 续问照样能发。
1239
1241
  // 没带工单的一句顶掉一份还没落定的工单活, 也算在那张工单上 (receipts.register 同理
1240
1242
  // 继承): 不然换个不带 job 的 tell_peer 就绕开了预算。
@@ -1337,11 +1339,9 @@ const main = async () => {
1337
1339
  // 放行时原样沿用; 工单成员与预算也现在就记, 「齐了吗」照常数。
1338
1340
  if (parkIt) {
1339
1341
  const bp = body;
1340
- const pk = bp.chain === false ? undefined : parentKOf(self);
1341
- const pasker = askerOf(self);
1342
1342
  receipts.hold({
1343
1343
  from: self, to: target, turn: turn.turn, at: Date.now(), after: afters, channel, job: jobId,
1344
- ...(pk ? { k: pk } : {}), ...(pasker ? { asker: pasker } : {}),
1344
+ ...(k ? { k } : {}), ...(asker ? { asker } : {}),
1345
1345
  // force: 冷门控上面已过一遍, 放行时缓存又凉了也不再退回。
1346
1346
  body: { name: peerAddress(cfg, self, target), text, kind, force: true, ...(jobId ? { job: jobId } : {}), ...(bp.role ? { role: bp.role } : {}), ...(bp.priority ? { priority: bp.priority } : {}), ...(bp.waitSec ? { waitSec: bp.waitSec } : {}), ...(bp.deadline !== undefined ? { deadline: bp.deadline } : {}), ...(bp.receipt === false ? { receipt: false } : {}) },
1347
1347
  });
@@ -1390,12 +1390,12 @@ const main = async () => {
1390
1390
  // 是"放出去就不管了"的那种派活; fyi 一定不要。对方的信封据此改口, 不再让它收口。
1391
1391
  const wantReceipt = kind !== "fyi" && body.receipt !== false;
1392
1392
  const deadlineAt = deadlineOf(at, deadlineSec === undefined ? undefined : Number(deadlineSec));
1393
- const asker = held ? held.asker : askerOf(self);
1394
1393
  const inj = await m.injectText(target, text, undefined, {
1395
1394
  from: { kind: "peer", from: self, turn: turn.turn, ...(jobId ? { job: jobId } : {}), ...(isPublic ? { public: true } : {}), ...(asker ? { asker } : {}) },
1396
1395
  channel,
1397
1396
  envelope: envelopeFor(self, channel, {
1398
1397
  turn: turn.turn,
1398
+ asker,
1399
1399
  legs: turn.legs,
1400
1400
  ...(turn.legs > 1 ? { re: true } : {}),
1401
1401
  // 期限只在发话方明说时写给对方看: 默认的一小时不值得每轮多一句。
@@ -1408,7 +1408,6 @@ const main = async () => {
1408
1408
  // 一份不守的槽会以同一对的 key 顶掉发话方还在等它答的那件活, 那件的回执就再也来不了。
1409
1409
  // 父 k: 默认这件活算发话方此刻在答的那件的子活 (链式续回); `chain:false` = 旁支,
1410
1410
  // 回执照常回来, 但不挂住它给上游的交代 —— 只有发话方知道这件活是不是为上游派的。
1411
- const k = held ? held.k : body.chain === false ? undefined : parentKOf(self);
1412
1411
  if (inj.ok && wantReceipt)
1413
1412
  receipts.register({ from: self, to: target, channel, job: jobId, at, turn: turn.turn, legs: turn.legs, deadlineAt, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
1414
1413
  if (inj.ok && wantReceipt) {
@@ -1543,12 +1542,14 @@ const main = async () => {
1543
1542
  });
1544
1543
  // 交接的「重开」一步: 走 /new (杀旧 pane、起新进程) 而不是往原 pane 注入 /clear。
1545
1544
  // /clear 只换会话不换进程 —— MCP 子进程、charter 都还是旧的, 新工具拿不到; 镜像
1546
- // 还得靠 sid 轮换探测才跟得上。CLI / cwd / keepalive 照旧沿用; 模型也沿用, 管家除外 ——
1545
+ // 还得靠 sid 轮换探测才跟得上。CLI 使用 cfg 的默认 backend; cwd / keepalive 照旧沿用。
1546
+ // 同 backend 时模型也沿用, 管家除外 ——
1547
1547
  // 它换上此刻的管家档 (newSession 里定), 改了 models.steward 后交接一次就是让它生效的那一下。
1548
1548
  const restartFresh = (target) => {
1549
1549
  const info = m.sessionInfo(target);
1550
- const model = stewardTierOf(cfg, target) ? undefined : info?.model || undefined;
1551
- return m.newSession(target, displayName(target) || tagOfKey(target) || target, info?.cli, { model, silent: true, warm: true });
1550
+ const cli = cfg.wrc.defaultCli ?? primary.name;
1551
+ const model = stewardTierOf(cfg, target) || info?.cli !== cli ? undefined : info?.model || undefined;
1552
+ return m.newSession(target, displayName(target) || tagOfKey(target) || target, cli, { model, silent: true, warm: true });
1552
1553
  };
1553
1554
  // POST /handoff — 交接一个 pane 的会话给一个全新会话。先让目标会话把当前工作
1554
1555
  // 压成一份"零上下文也能接手"的交接简报,等它写完并抓取,再 restartFresh 换一个
@@ -1575,7 +1576,8 @@ const main = async () => {
1575
1576
  let target;
1576
1577
  if (pane) {
1577
1578
  target = m.targetForPane(pane);
1578
- if (!target) {
1579
+ // 别的机器人的 pane 与没绑定同形 —— pane 号是另一条绕过 resolvePeer 的寻址。
1580
+ if (!target || !sameBot(self, target)) {
1579
1581
  json(res, 404, { ok: false, reason: `no mirror session bound to pane ${pane}` });
1580
1582
  return;
1581
1583
  }
@@ -1752,7 +1754,8 @@ const main = async () => {
1752
1754
  log.warn({ err: e.message }, "accept nudge failed");
1753
1755
  } }, NUDGE_TICK_MS).unref();
1754
1756
  /** `who` = 这次变动的当事人 (见 hears): 不给 = 聊天自己的事 (改名), 全群都听。 */
1755
- const postRoster = (base, except, n, who) => notices.post(chatAudience(m.chatTargets(base), base, except).filter(who ? hears(who) : () => true), n);
1757
+ // 听众只在主角那个聊天里 (同 base ⇒ 同机器人); sameBot 再写一遍是隔离的明文, 不靠 key 的拼法兜着。
1758
+ const postRoster = (base, except, n, who) => notices.post(chatAudience(m.chatTargets(base), base, except).filter((t) => sameBot(base, t)).filter(who ? hears(who) : () => true), n);
1756
1759
  /** 注册表里的当事人。要在 forget 抹掉记录之前取; 没有记录 = 谁也不告诉。 */
1757
1760
  const subjectOf = (w) => (w
1758
1761
  ? { parent: w.parent, description: w.description, owners: jobs.all().filter((j) => j.status === "open" && j.members.some((x) => x.target === w.target)).map((j) => j.owner) }
@@ -1923,7 +1926,7 @@ const main = async () => {
1923
1926
  const loadError = tasks.errors()[x.id];
1924
1927
  return {
1925
1928
  id: x.id,
1926
- target: x.target || (cfg.defaultChat ?? ""),
1929
+ target: x.target || defaultChatFor(cfg, x.owner || (cfg.defaultChat ?? "")),
1927
1930
  when: describeTrigger(x.trigger),
1928
1931
  nextAt: nextFire(x.trigger, sinceOf(st), nowDate),
1929
1932
  lastFired: st.lastFired,
@@ -1971,8 +1974,8 @@ const main = async () => {
1971
1974
  });
1972
1975
  /** 自己的地址 = 全局名字, 与住在哪个群无关。 */
1973
1976
  const selfAddress = (target) => settleName(wizards, chatNameOf(cfg, target), target);
1974
- const sharedMemory = (scope, key) => {
1975
- const path = memoryPath(cfg.daemon.stateDir, scope, key);
1977
+ const sharedMemory = (scope, key, bot = "") => {
1978
+ const path = memoryPath(cfg.daemon.stateDir, scope, key, bot);
1976
1979
  return clipForCharter(readMemory(path), path);
1977
1980
  };
1978
1981
  /** transcript 最后写入的时刻; 没有会话 / 读不到 = 0。 */
@@ -2007,17 +2010,18 @@ const main = async () => {
2007
2010
  // 最近动过的排前面: 名册只点名前几个, 截掉的该是早就停了的那些。
2008
2011
  // 没有 transcript 的冷记录按 bornAt 算, 刚生下还没开口的照样在。
2009
2012
  siblings: m.chatTargets(baseOfKey(target))
2010
- .filter((t) => t !== target)
2013
+ // 同 base 即同机器人; 显式再滤一道, 宪章名册的隔离不依赖 key 的拼法。
2014
+ .filter((t) => t !== target && sameBot(target, t))
2011
2015
  .map((t) => [t, Math.max(lastTouched(t), wizards.get(t)?.bornAt ?? 0)])
2012
2016
  .filter(([, at]) => Date.now() - at <= ROSTER_FRESH_MS)
2013
2017
  .sort((x, y) => y[1] - x[1])
2014
2018
  .map(([t]) => briefOf(target, t)),
2015
2019
  memory: wizards.get(target)?.memory ?? [],
2016
- tiers: forDiff ? [] : TIERS.map((n) => { const s = cfg.models.tiers[n]; return `${n}=${s.model || "默认"}${s.effort ? `·${s.effort}` : ""}`; }),
2020
+ tiers: forDiff ? [] : TIERS.map((n) => { const s = tiersForBackend(cfg, o.cli ?? m.sessionInfo(target)?.cli)[n]; return `${n}=${s.model || "默认"}${s.effort ? `·${s.effort}` : ""}`; }),
2017
2021
  chatMemory: sharedMemory("chat", baseOfKey(target)),
2018
2022
  workspaceMemory: (() => {
2019
2023
  const cwd = o.cwd || m.getCwd(target).runningCwd || m.getCwd(target).defaultCwd;
2020
- return cwd ? sharedMemory("workspace", cwd) : "";
2024
+ return cwd ? sharedMemory("workspace", cwd, botOfKey(target)) : "";
2021
2025
  })(),
2022
2026
  steward: !tagOfKey(target),
2023
2027
  stewardBudget: chatPolicyOf(cfg, target).stewardBudget,
@@ -2062,7 +2066,7 @@ const main = async () => {
2062
2066
  // 信箱里装的是相对上一份宪章的变化; 新宪章一渲染就都过期了 (名册、记忆都已在里面)。
2063
2067
  notices.drain(target);
2064
2068
  notices.forget(target);
2065
- return charterFor(target, charterOptsOf(target, ctx?.cwd));
2069
+ return charterFor(target, { ...charterOptsOf(target, ctx?.cwd), cli: ctx?.cli });
2066
2070
  });
2067
2071
  // 宪章记录是后来才有的: 已经在跑的 wizard 拿 spawn 落盘的那份 (state/charters/<sid>.md) 补上,
2068
2072
  // 否则要等它下次重生, rolepage 的「宪章」入口才出现。与自动命名同一个延迟: 等附着表恢复完。
@@ -2089,10 +2093,11 @@ const main = async () => {
2089
2093
  log.warn({ err: e.message }, "boot auto-name failed");
2090
2094
  }
2091
2095
  }, 15_000).unref();
2096
+ // 家谱只列与观察者同机器人的 (跨机器人的分身生不出来, 这是给老数据 / 手改注册表兜的底)。
2092
2097
  const kinOf = (self, all, target) => ({
2093
- parent: wizards.get(target)?.parent ? briefOf(self, wizards.get(target).parent) : undefined,
2094
- ancestors: ancestorsOf(all, target).map((t) => briefOf(self, t)),
2095
- clones: childrenOf(all, target).map((w) => briefOf(self, w.target)),
2098
+ parent: ((p) => (p && sameBot(self, p) ? briefOf(self, p) : undefined))(wizards.get(target)?.parent),
2099
+ ancestors: ancestorsOf(all, target).filter((t) => sameBot(self, t)).map((t) => briefOf(self, t)),
2100
+ clones: childrenOf(all, target).filter((w) => sameBot(self, w.target)).map((w) => briefOf(self, w.target)),
2096
2101
  });
2097
2102
  const identityOf = (self, target) => {
2098
2103
  const rec = wizards.get(target);
@@ -2143,8 +2148,9 @@ const main = async () => {
2143
2148
  };
2144
2149
  });
2145
2150
  const seen = new Set(live.map((r) => r.target));
2151
+ // live 已由 peers (同 base) / foreignPeers (sameBot) 收窄; 冷记录在这里按机器人滤。
2146
2152
  const cold = all
2147
- .filter((w) => !seen.has(w.target))
2153
+ .filter((w) => !seen.has(w.target) && sameBot(self, w.target))
2148
2154
  .map((w) => ({
2149
2155
  ...briefOf(self, w.target),
2150
2156
  target: w.target,
@@ -2269,13 +2275,18 @@ const main = async () => {
2269
2275
  const routeRowsOf = async (self) => {
2270
2276
  ensureChatNames(self);
2271
2277
  const warm = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
2272
- return (await rosterOf(self))
2278
+ const roster = await rosterOf(self);
2279
+ const openJobs = jobs.all();
2280
+ const inflight = receipts.states();
2281
+ return roster
2273
2282
  .filter((r) => !r.self)
2274
2283
  .map((r) => {
2275
2284
  const live = r.jsonlPath ? r.jsonlPath : "";
2276
2285
  const ttl = live ? cacheTtlSec(live) : 0;
2277
2286
  return {
2278
2287
  ...r,
2288
+ lead: wizards.get(r.target)?.lead === true,
2289
+ duties: routeDutiesOf(openJobs, r.target, inflight),
2279
2290
  contextTokens: live ? lastContextTokens(live) : 0,
2280
2291
  ...(ttl ? { cacheTtlMs: ttl * 1000 } : {}),
2281
2292
  ...(live ? { cacheAt: cacheTouchMs(live) } : {}),
@@ -2321,15 +2332,20 @@ const main = async () => {
2321
2332
  const to = (b.to ?? "").toString().trim();
2322
2333
  const now = Date.now();
2323
2334
  const ttlMs = cfg.wrc.mirror.keepalive.ttlSec * 1000;
2324
- // 自动选只在这些里选: 在线的 (没 pane 的 ctx 量不出, 成本门会盲过)、不是哪个群的管家、
2325
- // 不是别人开着的工单里的队员 —— 点名 (`to`) 不受此限。
2326
- const inOthersJob = (t) => jobs.openOf(baseOfKey(t)).some((j) => j.owner !== self && j.members.some((mm) => mm.target === t));
2327
- const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target) && !inOthersJob(r.target));
2328
- const cands = rankCandidates(task, m.getCwd(self).runningCwd, pool, { now, ttlMs }).slice(0, 5);
2335
+ // 占用保护由 decide 统一判断, 留在候选表里说明为什么不能自动复用。
2336
+ // 显式 to 保留人为选定; force 只影响成本门, 不绕 lead/工单/连续性保护。
2337
+ const pool = to || b.spawn || lead ? [] : (await routeRowsOf(self)).filter((r) => r.alive && !!tagOfKey(r.target));
2338
+ const tierArg = (b.tier ?? "").toString().trim();
2339
+ if (tierArg && !TIERS.includes(tierArg)) {
2340
+ json(res, 400, { ok: false, reason: `unknown tier: ${tierArg}` });
2341
+ return;
2342
+ }
2343
+ const taskTier = (tierArg || tierFor(task, lead));
2344
+ const cands = rankCandidates(task, m.getCwd(self).runningCwd, pool, { now, ttlMs });
2329
2345
  let d = to ? { kind: "existing", why: `你点名了 ${to}` }
2330
2346
  : b.spawn ? { kind: "spawn", why: "你要了新的" }
2331
2347
  : lead ? { kind: "spawn", why: "复杂活: 白板起一个 hard 档的 lead, 由它组队" }
2332
- : decide(task, cands, now, ttlMs, force);
2348
+ : decide(task, cands, now, ttlMs, force, taskTier);
2333
2349
  // 复杂活 (lead:true) 是一件「需求」: 先开需求根单 (账本, 不占 pane), 这一句带它派下去, lead 记成根单成员。
2334
2350
  // 小活不开单。dryRun 不落账, 所以放在它后面开 (见下)。
2335
2351
  const criteria = (b.criteria ?? "").toString().trim();
@@ -2342,7 +2358,7 @@ const main = async () => {
2342
2358
  }, extra);
2343
2359
  const shown = cands.slice(0, 3).map((e) => `.${e.row.address || e.row.name}${e.row.busy ? " 忙" : ""}`);
2344
2360
  if (b.dryRun) {
2345
- json(res, 200, { ok: true, dryRun: true, decision: d.kind, reason: d.why, ...(d.kind === "existing" ? { name: to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "") } : { tier: (b.tier ?? "").toString().trim() || tierFor(task, lead) }), ...(shown.length ? { candidates: shown } : {}) });
2361
+ json(res, 200, { ok: true, dryRun: true, decision: d.kind, reason: d.why, ...(d.kind === "existing" ? { name: to || (d.kind === "existing" && d.row ? d.row.address || d.row.name : "") } : { tier: taskTier }), ...(shown.length ? { candidates: shown } : {}) });
2346
2362
  return;
2347
2363
  }
2348
2364
  if (lead) {
@@ -2388,7 +2404,7 @@ const main = async () => {
2388
2404
  json(res, 400, { ok: false, decision: "spawn", reason: `${d.why} —— 要新生一个, 带上 name (这件事的短名) 与 description (它的职责) 重发`, ...(shown.length ? { candidates: shown } : {}) });
2389
2405
  return;
2390
2406
  }
2391
- const tier = (b.tier ?? "").toString().trim() || tierFor(task, lead);
2407
+ const tier = taskTier;
2392
2408
  const born = await bearAs(self, { inherit: false, name, description, tier, ...(lead ? { lead: true } : {}) });
2393
2409
  if (born.status !== 200) {
2394
2410
  settleRoot(false);
@@ -2434,8 +2450,10 @@ const main = async () => {
2434
2450
  json(res, 400, { ok: false, reason: "note 和 forget 至少给一个" });
2435
2451
  return;
2436
2452
  }
2437
- const path = memoryPath(cfg.daemon.stateDir, scope, key);
2438
- const queued = proposeMemory(inboxPath(cfg.daemon.stateDir, scope, key), {
2453
+ // 工作区记忆按机器人分份 (见 wizard-memory.fileOf); chat 的 key 本身就带机器人。
2454
+ const bot = botOfKey(self);
2455
+ const path = memoryPath(cfg.daemon.stateDir, scope, key, bot);
2456
+ const queued = proposeMemory(inboxPath(cfg.daemon.stateDir, scope, key, bot), {
2439
2457
  at: Date.now(),
2440
2458
  by: selfAddress(self),
2441
2459
  ...(scope === "workspace" ? { cwd: key } : {}),
@@ -2476,15 +2494,12 @@ const main = async () => {
2476
2494
  /** spawn_wizard / clone_wizard 的本体 —— HTTP 路由与 dispatch 共用。 */
2477
2495
  const bearAs = async (self, body) => {
2478
2496
  const b = body;
2497
+ const asker = b.task?.trim() ? askerOf(self) : undefined;
2498
+ const k = body.chain === false ? undefined : parentKOf(self);
2479
2499
  const askedEffort = parseEffort(b.effort);
2480
2500
  if (b.effort !== undefined && !askedEffort) {
2481
2501
  return { status: 400, body: { ok: false, reason: `effort 只有这几档: ${EFFORTS.join(" / ")}` } };
2482
2502
  }
2483
- const pick = pickTier(cfg.models, b.tier, { cli: b.cli, model: b.model, effort: askedEffort }, b.inherit === true);
2484
- if (!pick.ok) {
2485
- return { status: 400, body: pick };
2486
- }
2487
- const effort = pick.effort;
2488
2503
  // 工单先验: 生完分身才发现工单号打错了, 那个分身就成了没人认领的孤儿。
2489
2504
  const jobId = (b.job ?? "").trim();
2490
2505
  if (jobId) {
@@ -2524,6 +2539,12 @@ const main = async () => {
2524
2539
  }
2525
2540
  const source = fromR.target;
2526
2541
  const sourceInfo = m.sessionInfo(source);
2542
+ const forkBackend = inherit && sourceInfo && existsSync(sourceInfo.jsonlPath)
2543
+ && (!b.cwd?.trim() || expandHome(b.cwd.trim()) === sourceInfo.cwd) ? sourceInfo.cli : undefined;
2544
+ const pick = pickTier(cfg, b.tier, { cli: b.cli, model: b.model, effort: askedEffort }, inherit, forkBackend);
2545
+ if (!pick.ok)
2546
+ return { status: 400, body: pick };
2547
+ const effort = pick.effort;
2527
2548
  // 别人的会话 fork 不出来就是失败, 不能像克隆自己那样悄悄退化成白板 —— 调用方
2528
2549
  // 要的恰恰是它的上下文, 给一张白纸等于答非所问。
2529
2550
  if (source !== self && !sourceInfo?.sessionId) {
@@ -2554,12 +2575,12 @@ const main = async () => {
2554
2575
  log.info({ self, slept: idle.map((pp) => pp.target) }, "clone cap: idle clones put to sleep");
2555
2576
  }
2556
2577
  const wantChat = (b.chat ?? "").toString().trim();
2557
- const base = wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self);
2578
+ const base = ((b) => (b && sameBot(self, b) ? b : ""))(wantChat ? chatBaseOf(cfg, wantChat) : baseOfKey(self));
2558
2579
  if (!base) {
2559
2580
  return { status: 404, body: {
2560
2581
  ok: false,
2561
2582
  reason: `unknown chat '${wantChat}' — 只有起过名字的聊天能被指名; 让那边先 /name`,
2562
- candidates: listChatNames(cfg).map((c) => c.name),
2583
+ candidates: chatNamesFor(self),
2563
2584
  } };
2564
2585
  }
2565
2586
  const askedName = String(b.name ?? b.tag ?? "");
@@ -2592,7 +2613,7 @@ const main = async () => {
2592
2613
  spawning: BOOT_ID,
2593
2614
  });
2594
2615
  const name = wizards.rename(target, normalizeTag(askedName) || tag);
2595
- const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit });
2616
+ const charter = charterFor(target, { ...(detached ? {} : { parent: self }), forkOf, inherited: inherit, cli: pick.cli });
2596
2617
  const task = (b.task ?? "").toString().trim();
2597
2618
  // 没点名就按配置的 keepalive.spawnDefault 来 —— 调用方明说的永远优先。
2598
2619
  const keepalive = typeof b.keepalive === "boolean" ? b.keepalive : cfg.wrc.mirror.keepalive.spawnDefault;
@@ -2600,7 +2621,6 @@ const main = async () => {
2600
2621
  // wait_peer 才不会把它们当成分身对这件活的答复。
2601
2622
  const taskAt = Date.now();
2602
2623
  const taskTurn = newTurn();
2603
- const asker = task ? askerOf(self) : undefined;
2604
2624
  const r = await m.cloneSession({
2605
2625
  parent: source,
2606
2626
  target,
@@ -2614,7 +2634,7 @@ const main = async () => {
2614
2634
  // 继承路径上第一句话是分叉的触发器, 所以直接把活当开场白 —— 少一次往返,
2615
2635
  // 也少一次"就位了但没事干"的空转。
2616
2636
  // 子 lead 的宪章只有层级规矩, 打法跟第一句的信封走 (同 dispatch 对已有 wizard 的做法)。
2617
- bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "") : undefined,
2637
+ bootstrap: task ? task + envelopeFor(self, "", { turn: taskTurn, asker }) + (b.lead === true ? renderReminder({ wezard: "lead" }, renderLeadPlaybook()) : "") : undefined,
2618
2638
  ...(task ? { bootstrapFrom: { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, query: task } } : {}),
2619
2639
  keepalive,
2620
2640
  }).catch((e) => ({ ok: false, reason: `spawn threw: ${String(e)}`, inherited: false }));
@@ -2641,7 +2661,7 @@ const main = async () => {
2641
2661
  // 继承路径上活已经随开场白进去了, 空白分身才需要在这里补一次注入 (私聊)。
2642
2662
  let dispatched = r.inherited && !!task;
2643
2663
  if (task && !r.inherited) {
2644
- const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, channel: "", envelope: envelopeFor(self, "", { turn: taskTurn }) });
2664
+ const inj = await m.injectText(target, task, undefined, { from: { kind: "peer", from: self, turn: taskTurn, ...(jobId ? { job: jobId } : {}), ...(asker ? { asker } : {}) }, channel: "", envelope: envelopeFor(self, "", { turn: taskTurn, asker }) });
2645
2665
  dispatched = inj.ok;
2646
2666
  }
2647
2667
  if (jobId) {
@@ -2653,7 +2673,6 @@ const main = async () => {
2653
2673
  jobs.spend(jobId);
2654
2674
  // 分身的第一件活也守回执: fan-out 最常见的形状就是 clone_wizard({task}) × N,
2655
2675
  // 让它们干完自己把结论送回来, 发起方不必挂在 wait_peer 上。
2656
- const k = body.chain === false ? undefined : parentKOf(self);
2657
2676
  if (dispatched)
2658
2677
  receipts.register({ from: self, to: target, channel: "", job: jobId, at: taskAt, turn: taskTurn, ...(k ? { k } : {}), ...(asker ? { asker } : {}) });
2659
2678
  if (dispatched)
@@ -2687,7 +2706,7 @@ const main = async () => {
2687
2706
  const r = resolvePeer(self, addrOf(b));
2688
2707
  // 有身份没会话 (冷记录 / 生到一半的僵尸): resolvePeer 够不着它, 但收它不需要
2689
2708
  // 会话 —— end 视为早已结束, forget 删掉记录、腾出名字。interrupt 无从谈起。
2690
- const found = r.ok || r.candidates?.length ? undefined : wizards.byName(addrOf(b));
2709
+ const found = r.ok || r.candidates?.length ? undefined : ((w) => (w && sameBot(self, w.target) ? w : undefined))(wizards.byName(addrOf(b)));
2691
2710
  const cold = found && !m.chatTargets(baseOfKey(found.target)).includes(found.target) ? found : undefined;
2692
2711
  if (cold && (b.mode ?? "end") === "end") {
2693
2712
  const detached = b.forget ? forgetWizard(cold.target) : [];
@@ -2999,7 +3018,7 @@ const main = async () => {
2999
3018
  openedAt: j.openedAt,
3000
3019
  ...jobs.tally(j.id),
3001
3020
  ...(j.maxTurns ? { budget: `${j.turns ?? 0}/${j.maxTurns}` } : {}),
3002
- members: j.members.map((mm) => ({
3021
+ members: j.members.filter((mm) => sameBot(self, mm.target)).map((mm) => ({
3003
3022
  address: peerAddress(cfg, self, mm.target),
3004
3023
  role: mm.role ?? "exec",
3005
3024
  task: mm.task.split("\n")[0] ?? "",
@@ -3047,6 +3066,11 @@ const main = async () => {
3047
3066
  }
3048
3067
  target = r.target;
3049
3068
  }
3069
+ // owner = self; resolvePeer 已拦住别的机器人的名字, 这一道是给以后别的取 target 的路兜底。
3070
+ if (!sameBot(self, target)) {
3071
+ json(res, 404, { ok: false, reason: `no wizard named '.${tag}' — wizard_roster lists every name` });
3072
+ return;
3073
+ }
3050
3074
  // 到点是新建还是续用, 在**排班时**就定死。两条都指向新建:
3051
3075
  // · 没点名 wizard —— 定时的 prompt 本就要求零上下文自洽, 没理由挑一个会话挤进去;
3052
3076
  // · prompt 自己就在说「建两个 wizard 去干 xxx」—— 这时 tag 只是「在谁的聊天/
@@ -3078,10 +3102,10 @@ const main = async () => {
3078
3102
  const now = new Date();
3079
3103
  const errs = tasks.errors();
3080
3104
  const rows = tasks.list()
3081
- .filter((t) => !mineOnly || ownerOf(t) === self)
3105
+ .filter((t) => sameBot(self, ownerOf(t)) && (!mineOnly || ownerOf(t) === self))
3082
3106
  .map((t) => ({
3083
3107
  ...renderTask(t, tasks.stateOf(t.id), now, errs[t.id]),
3084
- address: peerAddress(cfg, self, t.target || (cfg.defaultChat ?? "")),
3108
+ address: peerAddress(cfg, self, t.target || defaultChatFor(cfg, ownerOf(t))),
3085
3109
  owner: peerAddress(cfg, self, ownerOf(t)),
3086
3110
  }));
3087
3111
  // 加载失败的那些没有记录可回显, 但必须出现在列表里 —— 否则 wizard 改错了
@@ -3102,6 +3126,12 @@ const main = async () => {
3102
3126
  json(res, 400, { ok: false, reason: "id required (list_tasks 里那个 id)" });
3103
3127
  return;
3104
3128
  }
3129
+ // 别的机器人的日程与不存在同形。
3130
+ const rec = tasks.get(id);
3131
+ if (rec && !sameBot(self, ownerOf(rec))) {
3132
+ json(res, 404, { ok: false, reason: `no schedule with id ${id}` });
3133
+ return;
3134
+ }
3105
3135
  const state = tasks.stateOf(id);
3106
3136
  const gone = tasks.remove(id);
3107
3137
  if (!gone) {
@@ -3141,7 +3171,7 @@ const main = async () => {
3141
3171
  ];
3142
3172
  };
3143
3173
  const configEvidence = (p) => [
3144
- ...tierEvidence(cfg.models, p, tierUsage, (tier) => wizards.all().filter((w) => w.tier === tier).length, { tokens: fmtTokens, cost: fmtCost }),
3174
+ ...tierEvidence(cfg, p, tierUsage, (tier) => wizards.all().filter((w) => w.tier === tier).length, { tokens: fmtTokens, cost: fmtCost }),
3145
3175
  ...chatPolicyEvidence(p),
3146
3176
  ];
3147
3177
  // ── 改配置会不会动到宪章 ─────────────────────────────────────────
@@ -3190,10 +3220,14 @@ const main = async () => {
3190
3220
  const others = hits.filter((h) => h.target !== c.self);
3191
3221
  others.forEach((h) => notices.post([h.target], `${c.what} 改动了你的宪章 (系统提示, 变了的段: ${h.sections.join(" / ")}); 已在跑的你看不见新规矩 —— ${ask}${lag(h.target)}`));
3192
3222
  const mine = hits.find((h) => h.target === c.self);
3223
+ // 提醒照旧全机发 (各收各的); 回给调用方的名单与人数只含它看得见的 (sameBot) —— 别的机器人的 wizard
3224
+ // 连名字带个数都不报。没有调用方 (人 /name、自动补名) = 主机器人视角, 单机器人时就是全部。
3225
+ const shown = hits.filter((h) => sameBot(c.self ?? "", h.target));
3226
+ const shownOthers = shown.filter((h) => h.target !== c.self);
3193
3227
  return [
3194
3228
  "",
3195
- `⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${hits.length} 个: ${hits.map((h) => displayName(h.target)).join(" ")}`,
3196
- ...(others.length ? [`已给其余 ${others.length} 个各挂一条提醒 (随它们下一条消息到), 让它们${ask}`] : []),
3229
+ `⚠ 这次改动进了宪章 (系统提示): 已在跑的 wizard 看不见, 要 handoff 才换上。受影响 ${shown.length} 个: ${shown.map((h) => displayName(h.target)).join(" ")}`,
3230
+ ...(shownOthers.length ? [`已给其余 ${shownOthers.length} 个各挂一条提醒 (随它们下一条消息到), 让它们${ask}`] : []),
3197
3231
  ...(mine ? [`**你自己也在其中** (变了的段: ${mine.sections.join(" / ")}): 这一轮收尾后${ask}${lag(mine.target)}`] : []),
3198
3232
  ].join("\n");
3199
3233
  };
@@ -3219,41 +3253,24 @@ const main = async () => {
3219
3253
  return { r, note: Promise.resolve(fail(e)) };
3220
3254
  }
3221
3255
  };
3256
+ /** 配置的调用方: 只认 sessionId / pane, 不回落 defaultChat (同 config_set 的 caller)。认不出 = "" (主机器人)。 */
3257
+ const configCaller = (b) => (b.sessionId?.trim() && m.targetForSession(b.sessionId.trim())) || (b.tmuxPane?.trim() && m.targetForPane(b.tmuxPane.trim())) || "";
3222
3258
  // POST /config/get —— 新 MCP 带 `path` (可空) 拿渲染好的文本; 老 MCP 带 `key` 拿 {key, value}。
3223
3259
  http.register("POST /config/get", async (req, res) => {
3224
3260
  const body = (await readBody(req));
3261
+ const seen = cfgSeenBy(cfg, configCaller(body));
3225
3262
  if (typeof body.key === "string") {
3226
- const r = legacyGet(cfg, body.key);
3263
+ const r = legacyGet(seen, body.key);
3227
3264
  json(res, r.ok ? 200 : 400, r);
3228
3265
  return;
3229
3266
  }
3230
3267
  // chatPolicy.<聊天名>… 换成它指向的 principal 再查 (同 config_set 的 pinChatKeys); 认不出的原样查。
3268
+ // 名字按调用方看得见的表解析: 别的机器人的聊天名认不出, principal 写法在 seen 里也查无此键。
3231
3269
  const p = parsePath(body.path);
3232
- const base = p[0] === "chatPolicy" && p[1] ? chatBaseOf(cfg, p[1]) : "";
3233
- const r = configGet(cfg, sourcePath, base ? ["chatPolicy", base, ...p.slice(2)].join(".") : body.path, configEvidence);
3270
+ const base = p[0] === "chatPolicy" && p[1] ? chatBaseOf(seen, p[1]) : "";
3271
+ const r = configGet(seen, sourcePath, base ? ["chatPolicy", base, ...p.slice(2)].join(".") : body.path, configEvidence);
3234
3272
  json(res, r.ok ? 200 : 400, r);
3235
3273
  });
3236
- // chatPolicy 的键一律落成 base principal: 聊天名会改, 拿它当键一改名策略就悄悄失效。写名字照收,
3237
- // 换成它此刻指向的 principal; 认不出的拒写 (写错的键静默无效, 比报错难查)。unset 一个盘上原样存在的
3238
- // 键不换 —— 留给清理老写法 / 失效的键。
3239
- const pinChatKeys = (req) => {
3240
- const p = parsePath(req.path);
3241
- if (p[0] !== "chatPolicy")
3242
- return req;
3243
- const bad = (k) => ({ ok: false, reason: `认不出聊天 \`${k}\`: 键写 base principal (\`chat:wr…\` / \`user:…\`) 或聊天名 (/chats)` });
3244
- if (p.length === 1) {
3245
- if (req.value === undefined || typeof req.value !== "object" || Array.isArray(req.value))
3246
- return req;
3247
- const pairs = Object.entries(req.value).map(([k, v]) => [k, chatBaseOf(cfg, k), v]);
3248
- const miss = pairs.find(([, b]) => !b);
3249
- return miss ? bad(miss[0]) : { ...req, value: Object.fromEntries(pairs.map(([, b, v]) => [b, v])) };
3250
- }
3251
- const key = p[1];
3252
- if (req.op === "unset" && p.length === 2 && key in cfg.chatPolicy)
3253
- return req;
3254
- const base = chatBaseOf(cfg, key);
3255
- return base ? { ...req, path: ["chatPolicy", base, ...p.slice(2)].join(".") } : bad(key);
3256
- };
3257
3274
  // POST /config/set —— 口令门见 `guarded`。分级在这里而不在 hook: `card` 项由守护进程
3258
3275
  // 自己推确认卡, 所以 danger.skipAll / ⏱窗口 都放不掉它。卡等 CONFIRM_INLINE_MS 还没人点
3259
3276
  // 就先回 pending (fetch 默认 5 分钟收不到头就断), 卡仍有效, 迟到的确认照常落盘并捎一句给调用方。
@@ -3265,7 +3282,7 @@ const main = async () => {
3265
3282
  json(res, 400, asked);
3266
3283
  return;
3267
3284
  }
3268
- const ask = pinChatKeys(asked);
3285
+ const ask = pinChatKeys(cfg, asked, configCaller(body));
3269
3286
  if ("ok" in ask) {
3270
3287
  json(res, 400, ask);
3271
3288
  return;
@@ -3284,8 +3301,7 @@ const main = async () => {
3284
3301
  }
3285
3302
  // 落盘时重算一遍: 等卡期间文件可能被别人改过, 按那一刻的文件打补丁、再校验。
3286
3303
  // 宪章快照取在落盘之前 (守卫里): hot 项一 applyHot, 活的 cfg 就已经是新的了。
3287
- // 不用 resolveSelf: 它最后回落到 defaultChat, 会把认不出的调用方当成那个群的管家。
3288
- const caller = (body.sessionId?.trim() && m.targetForSession(body.sessionId.trim())) || (body.tmuxPane?.trim() && m.targetForPane(body.tmuxPane.trim())) || undefined;
3304
+ const caller = resolveSelf({ sessionId: body.sessionId, tmuxPane: body.tmuxPane }) || undefined;
3289
3305
  // 人发起 = 调用方这一轮的链头是人 (人在群里 / 单聊里叫它改的)。
3290
3306
  const byHuman = !!caller && !!askerOf(caller)?.who.startsWith("user:");
3291
3307
  const commit = async () => {
@@ -3368,6 +3384,12 @@ const main = async () => {
3368
3384
  json(res, 400, { ok: false, reason: bad });
3369
3385
  return;
3370
3386
  }
3387
+ // 节点是 base 下的 tag, 构造上与调用方同机器人; 这一道防的是 tag 里夹带别的机器人的 key。
3388
+ const xnode = spec.nodes.find((n) => !sameBot(self, keyOf(spec.base, n.tag)));
3389
+ if (xnode) {
3390
+ json(res, 400, { ok: false, reason: `node.tag \`${xnode.tag}\` 不是一个合法的 tag` });
3391
+ return;
3392
+ }
3371
3393
  const graphLog = log.child({ mod: "graph", base: spec.base });
3372
3394
  // 每一步、每条进度都在发起这次 run 的那一轮的频道里: 人在群里叫它发起的才进群,
3373
3395
  // 私聊轮里发起的整场只进 rolepage —— 步骤不带频道就等于在节点的 home 群里开口。
@@ -3413,6 +3435,12 @@ const main = async () => {
3413
3435
  json(res, 400, { ok: false, reason: "runId required" });
3414
3436
  return;
3415
3437
  }
3438
+ // 别的机器人那边跑的流水线与不存在同形。
3439
+ const run = getRun(runId);
3440
+ if (run && !sameBot(resolveSelf(body), run.base)) {
3441
+ json(res, 404, { ok: false, reason: `run ${runId} not found or already finished` });
3442
+ return;
3443
+ }
3416
3444
  const stopped = stopRun(runId);
3417
3445
  json(res, stopped ? 200 : 404, stopped ? { ok: true, runId } : { ok: false, reason: `run ${runId} not found or already finished` });
3418
3446
  });
@@ -3446,6 +3474,10 @@ const main = async () => {
3446
3474
  // 跑完自动收掉。只有排班时点名了某个 wizard (fresh=false) 且它此刻闲着,
3447
3475
  // 才把这句话直接投进它那一轮 —— 「在已有会话里继续」是要求出来的, 不是默认。
3448
3476
  scheduledTaskInject = async (target, text, { taskId, fresh, quiet }) => {
3477
+ // 日程文件是可手改的源码: target 与排班者不同机器人就不放枪 (schedule_task 那头已拦, 这里拦手改的)。
3478
+ const owner = ((r) => (r ? ownerOf(r) : target))(tasks.get(taskId));
3479
+ if (!sameBot(owner, target))
3480
+ return { ok: false, reason: `日程 ${taskId} 的目标与排班者归不同的企微机器人, 不跨机器人放枪` };
3449
3481
  // 安静任务私下跑 (channel ""): 镜像不推它的任何一句, 终句由 relayUnlessQuiet 判断要不要进群。
3450
3482
  const inNamed = async () => {
3451
3483
  const at = Date.now();
@@ -3605,7 +3637,7 @@ const main = async () => {
3605
3637
  refsOf: (md) => {
3606
3638
  // cwd 候选: 提议里带的 (提议者可能早收工了) + 在场绑定的; 再正向算出 md 比对。
3607
3639
  const cwds = [...new Set([...proposedCwds(stateDir), ...boundTargets().map(cwdOfTarget)].filter(Boolean))];
3608
- const cwd = cwdOfMd(stateDir, md, cwds);
3640
+ const cwd = cwdOfMd(stateDir, md, cwds, ["", ...cfg.bots.map((b) => b.id)]);
3609
3641
  if (!cwd)
3610
3642
  return [];
3611
3643
  return [...PROJECT_DOCS.map((f) => join(cwd, f)), ...projectDirsFor(cwd).map((d) => join(d.dir, "memory", "MEMORY.md"))].filter((f) => existsSync(f));
@@ -3638,7 +3670,8 @@ const main = async () => {
3638
3670
  inject: scheduledTaskInject,
3639
3671
  // gate 里的 `sh` 默认就在目标 wizard 此刻的工作区跑 —— 任务文件里不必写死绝对路径。
3640
3672
  cwdOf: (t) => { const c = bridge.getCwd(t); return c.runningCwd || c.pendingCwd || c.defaultCwd; },
3641
- fallbackTarget: () => (cfg.defaultChat ?? "").trim(),
3673
+ // 没写 target 的日程兜底到排班者那个机器人的默认聊天 (副机器人没配就不放枪, 不冒用主机器人)。
3674
+ fallbackTarget: (t) => defaultChatFor(cfg, t.owner),
3642
3675
  });
3643
3676
  // 共享记忆整理 — daemon 内建, 不进任务表 / 日程, 不出任何气泡。
3644
3677
  const steward = stewardRun
@@ -3664,6 +3697,15 @@ const main = async () => {
3664
3697
  log.info({ reaped: r.reaped.length, targets: r.reaped, orphans: r.orphans.length, max }, "pane cap sweep");
3665
3698
  };
3666
3699
  bridge.onSpawn(() => void sweep().catch((e) => log.warn({ err: e.message }, "pane cap sweep failed")));
3700
+ // 定时清扫: 无绑定孤儿 (restore 丢绑定留下的老 window) 不靠 spawn 也会攒, 只在
3701
+ // onSpawn 数等于没人收。绑定在 store 里启动即读, 新生 pane 有 ORPHAN_GRACE 宽限,
3702
+ // 所以启动后扫一次是安全的。
3703
+ const periodSec = cfg.wrc.mirror.paneSweepSec;
3704
+ if (periodSec > 0) {
3705
+ const timer = setInterval(() => void sweep().catch((e) => log.warn({ err: e.message }, "pane cap sweep failed")), periodSec * 1000);
3706
+ timer.unref();
3707
+ }
3708
+ setTimeout(() => void sweep().catch((e) => log.warn({ err: e.message }, "pane cap sweep failed")), 15_000).unref();
3667
3709
  }
3668
3710
  const shutdown = async (signal) => {
3669
3711
  log.info({ signal }, "shutdown signal");