wezard 2.6.2 → 2.6.3

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 (71) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/config.example.jsonc +3 -1
  3. package/dist/daemon/answer-transfer.js +56 -0
  4. package/dist/daemon/answer-transfer.js.map +1 -0
  5. package/dist/daemon/approval.js +1 -1
  6. package/dist/daemon/approval.js.map +1 -1
  7. package/dist/daemon/cfg-sync.js +1 -1
  8. package/dist/daemon/cfg-sync.js.map +1 -1
  9. package/dist/daemon/chat-name.js +4 -4
  10. package/dist/daemon/chat-name.js.map +1 -1
  11. package/dist/daemon/config-api.js +2 -2
  12. package/dist/daemon/config-api.js.map +1 -1
  13. package/dist/daemon/image-input.js +34 -0
  14. package/dist/daemon/image-input.js.map +1 -0
  15. package/dist/daemon/inbound.js +4 -11
  16. package/dist/daemon/inbound.js.map +1 -1
  17. package/dist/daemon/index.js +880 -280
  18. package/dist/daemon/index.js.map +1 -1
  19. package/dist/daemon/jobs.js +32 -13
  20. package/dist/daemon/jobs.js.map +1 -1
  21. package/dist/daemon/last-response.js +16 -6
  22. package/dist/daemon/last-response.js.map +1 -1
  23. package/dist/daemon/message-format.js +40 -0
  24. package/dist/daemon/message-format.js.map +1 -0
  25. package/dist/daemon/mirror-bridge.js +673 -295
  26. package/dist/daemon/mirror-bridge.js.map +1 -1
  27. package/dist/daemon/pane-input.js +48 -0
  28. package/dist/daemon/pane-input.js.map +1 -0
  29. package/dist/daemon/peers.js +15 -14
  30. package/dist/daemon/peers.js.map +1 -1
  31. package/dist/daemon/pending-items.js +8 -0
  32. package/dist/daemon/pending-items.js.map +1 -1
  33. package/dist/daemon/receipts.js +346 -120
  34. package/dist/daemon/receipts.js.map +1 -1
  35. package/dist/daemon/scheduled-delivery.js +52 -0
  36. package/dist/daemon/scheduled-delivery.js.map +1 -0
  37. package/dist/daemon/steward-model.js +10 -10
  38. package/dist/daemon/steward-model.js.map +1 -1
  39. package/dist/daemon/tasks.js +83 -27
  40. package/dist/daemon/tasks.js.map +1 -1
  41. package/dist/daemon/tmux-buffer.js +16 -6
  42. package/dist/daemon/tmux-buffer.js.map +1 -1
  43. package/dist/daemon/weixin-cmd.js +2 -4
  44. package/dist/daemon/weixin-cmd.js.map +1 -1
  45. package/dist/daemon/wizard.js +2 -1
  46. package/dist/daemon/wizard.js.map +1 -1
  47. package/dist/daemon/workflow-nodes.js +217 -0
  48. package/dist/daemon/workflow-nodes.js.map +1 -0
  49. package/dist/daemon/workflow-plan-http.js +83 -0
  50. package/dist/daemon/workflow-plan-http.js.map +1 -0
  51. package/dist/daemon/workflow-plans.js +274 -0
  52. package/dist/daemon/workflow-plans.js.map +1 -0
  53. package/dist/mcp/server.js +27 -1
  54. package/dist/mcp/server.js.map +1 -1
  55. package/dist/shared/config.js +12 -10
  56. package/dist/shared/config.js.map +1 -1
  57. package/dist/shared/json-map-store.js +46 -9
  58. package/dist/shared/json-map-store.js.map +1 -1
  59. package/dist/shared/keyed-scheduler.js +51 -0
  60. package/dist/shared/keyed-scheduler.js.map +1 -0
  61. package/dist/shared/message-footer.js +34 -0
  62. package/dist/shared/message-footer.js.map +1 -0
  63. package/dist/shared/model-tiers.js +1 -2
  64. package/dist/shared/model-tiers.js.map +1 -1
  65. package/dist/shared/reminder.js +3 -0
  66. package/dist/shared/reminder.js.map +1 -1
  67. package/dist/shared/session-label.js +19 -1
  68. package/dist/shared/session-label.js.map +1 -1
  69. package/dist/shared/workflow-plan.js +36 -0
  70. package/dist/shared/workflow-plan.js.map +1 -0
  71. package/package.json +1 -1
@@ -24,6 +24,9 @@ import { newStewardSpec, resumedStewardSpec } from "./steward-model.js";
24
24
  import { expandHome, sanitizeId } from "../shared/paths.js";
25
25
  import { augmentedPath } from "../shared/exec-path.js";
26
26
  import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
27
+ import { withImageClipboard, pasteVerifiedImage } from "./image-input.js";
28
+ import { createKeyedScheduler } from "../shared/keyed-scheduler.js";
29
+ import { withPaneInput } from "./pane-input.js";
27
30
  import { activeBackends, resolveCliBackend, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
28
31
  import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, modalFingerprint, looksLikePicker } from "../shared/modal-pane.js";
29
32
  import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
@@ -42,7 +45,6 @@ import { errText } from "./last-response.js";
42
45
  import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey, sameBot } from "../shared/session-label.js";
43
46
  import { splitMarkdown } from "../shared/md-chunk.js";
44
47
  import { isWxFrame } from "../shared/wx-text.js";
45
- import { randomTip } from "./tips.js";
46
48
  import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
47
49
  import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
48
50
  import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
@@ -1159,10 +1161,10 @@ const detectModalPicker = async (target) => {
1159
1161
  const MODAL_PRESS_SETTLE_MS = 400;
1160
1162
  const MODAL_ARROW_SETTLE_MS = 150;
1161
1163
  const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
1162
- const pressModalIndex = async (pane, before, index) => {
1164
+ const pressModalIndex = async (pane, before, index, live = () => true) => {
1163
1165
  const fingerprint = modalFingerprint(before.screen);
1164
1166
  const current = await detectModalPicker(pane);
1165
- if (!current.modal || modalFingerprint(current.screen) !== fingerprint)
1167
+ if (!live() || !current.modal || modalFingerprint(current.screen) !== fingerprint)
1166
1168
  return current;
1167
1169
  await runTmux(["send-keys", "-t", pane, String(index)]);
1168
1170
  await sleep(MODAL_PRESS_SETTLE_MS);
@@ -1171,6 +1173,8 @@ const pressModalIndex = async (pane, before, index) => {
1171
1173
  return after;
1172
1174
  // 数字键没关掉框: 把高亮挪到目标项。guard 上限盖过任何确认框的选项数。
1173
1175
  for (let guard = 0; guard < 8; guard++) {
1176
+ if (!live())
1177
+ return after;
1174
1178
  const cur = highlightedIndex(after.screen);
1175
1179
  if (cur === undefined || cur === index)
1176
1180
  break;
@@ -1180,7 +1184,7 @@ const pressModalIndex = async (pane, before, index) => {
1180
1184
  if (!after.modal || modalFingerprint(after.screen) !== fingerprint)
1181
1185
  return after;
1182
1186
  }
1183
- if (highlightedIndex(after.screen) !== index)
1187
+ if (!live() || highlightedIndex(after.screen) !== index)
1184
1188
  return after; // 核不准, 绝不盲按
1185
1189
  await runTmux(["send-keys", "-t", pane, "Enter"]);
1186
1190
  await sleep(MODAL_PRESS_SETTLE_MS);
@@ -1301,7 +1305,7 @@ export const parseInputBox = (cap, styledLayout = cap) => {
1301
1305
  layout.pop();
1302
1306
  const footer = layout.length - 1;
1303
1307
  // Codex hides the shortcuts row while the composer contains text.
1304
- const shortcuts = /\? for shortcuts\s*$/u.test(layout[footer] ?? "");
1308
+ const shortcuts = /(?:^|\s)\? for shortcuts(?:\s|$)/u.test(layout[footer] ?? "");
1305
1309
  if (shortcuts || (layout[footer] ?? "").includes("·")) {
1306
1310
  let status = shortcuts ? footer - 1 : footer;
1307
1311
  while (status >= 0 && !layout[status].trim())
@@ -1433,7 +1437,7 @@ const witnessFor = (target, homeDir, jsonlPath, live) => {
1433
1437
  // but the trailing Enter is eaten while the TUI is still initializing, so
1434
1438
  // the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
1435
1439
  // 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
1436
- const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false) => {
1440
+ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard) => {
1437
1441
  const backendName = backend.name;
1438
1442
  log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
1439
1443
  // 就绪门: pane 里得真有一个进入了交互态的 AI 会话, 才轮得到贴。先于 modal guard ——
@@ -1474,33 +1478,53 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1474
1478
  // dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
1475
1479
  // withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
1476
1480
  if (backendName === "codebuddy") {
1477
- return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness);
1481
+ return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, guard);
1478
1482
  }
1479
1483
  // Pump images first via clipboard+C-v so each one is attached as a separate
1480
1484
  // image content block. Each C-v needs a brief settle for Claude Code's TUI
1481
1485
  // to read the clipboard before the next overwrite. Fresh spawn extends.
1482
1486
  const IMG_SETTLE_MS = freshSpawn ? 700 : 350;
1483
- for (const imgPath of images) {
1484
- const cb = await setMacClipboardImage(imgPath);
1485
- if (!cb.ok) {
1486
- log.warn({ imgPath, reason: cb.reason }, "mirror inject: clipboard set failed, skipping image");
1487
- continue;
1487
+ const pumped = images.length ? await withImageClipboard(async () => {
1488
+ for (const imgPath of images) {
1489
+ const paste = async () => {
1490
+ const cb = await setMacClipboardImage(imgPath);
1491
+ if (!cb.ok)
1492
+ return cb;
1493
+ if (guard && !guard("paste"))
1494
+ return { ok: false, reason: "image paste canceled by dispatch guard" };
1495
+ const cv = await runTmux(["send-keys", "-t", target, "C-v"]);
1496
+ return cv.ok ? { ok: true } : { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
1497
+ };
1498
+ // Cold Codex can ignore C-v before its composer exists. Do not submit
1499
+ // the text until the composer proves this attachment has landed.
1500
+ const result = backendName === "codex"
1501
+ ? await pasteVerifiedImage({ read: () => readInputBox(target), paste, valid: () => !guard || guard("paste") })
1502
+ : await paste();
1503
+ if (!result.ok) {
1504
+ log.warn({ target, imgPath, reason: result.reason }, "mirror inject: image not confirmed, refusing text submission");
1505
+ return result;
1506
+ }
1507
+ // Attachment rendering can precede the TUI finishing its paste handler.
1508
+ // Preserve the key settle interval before another C-v or text paste.
1509
+ await sleep(IMG_SETTLE_MS);
1510
+ log.info({ target, imgPath, verified: backendName === "codex" }, "mirror inject: image attached");
1488
1511
  }
1489
- const cv = await runTmux(["send-keys", "-t", target, "C-v"]);
1490
- if (!cv.ok)
1491
- return { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
1492
- await sleep(IMG_SETTLE_MS);
1493
- }
1512
+ return { ok: true };
1513
+ }) : { ok: true };
1514
+ if (!pumped.ok)
1515
+ return { ...pumped, uncertain: true };
1494
1516
  // Image-only message: TUI input box now holds the attached images; press
1495
1517
  // Enter and we're done. No fingerprint to verify (the input itself is binary
1496
1518
  // image refs, not text).
1497
1519
  if (!text) {
1498
1520
  if (images.length === 0)
1499
1521
  return { ok: true };
1522
+ if (guard && !guard("submit"))
1523
+ return { ok: false, reason: "submit canceled by dispatch guard" };
1500
1524
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1501
1525
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
1502
1526
  }
1503
- return injectViaTmuxText(target, text, log, freshSpawn, witness);
1527
+ return injectViaTmuxText(target, text, log, freshSpawn, witness, guard);
1504
1528
  };
1505
1529
  // 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
1506
1530
  // 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
@@ -1522,7 +1546,7 @@ const withImageRefs = (text, images) => {
1522
1546
  // 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
1523
1547
  // 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
1524
1548
  // 每次补之前再问一遍证人, 已提交就不补。
1525
- const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
1549
+ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard) => {
1526
1550
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1527
1551
  // started, transcript still loading): extended timings — bracketed-paste
1528
1552
  // end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
@@ -1559,7 +1583,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1559
1583
  const baseBody = alreadyTyped ? "" : preBox.body;
1560
1584
  /** 框里还是贴之前那样 (或彻底空了) = 我们的文本不在框里。 */
1561
1585
  const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === baseBody);
1562
- const loadAndPaste = () => pasteTmuxBuffer(target, text);
1586
+ const loadAndPaste = () => pasteTmuxBuffer(target, text, guard ? () => guard("paste") : undefined);
1563
1587
  // 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
1564
1588
  // 头部可能在框顶, 所以窗口要宽)。
1565
1589
  const sawLanded = async (timeoutMs) => {
@@ -1583,6 +1607,9 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1583
1607
  let pasteSeen = alreadyTyped;
1584
1608
  let pastedAt = Date.now();
1585
1609
  if (alreadyTyped) {
1610
+ // Existing text is already exposed, even if permission was just withdrawn.
1611
+ if (guard && !guard("paste"))
1612
+ return { ok: false, uncertain: true, reason: "existing input submission canceled by dispatch guard" };
1586
1613
  log.warn({ target, headFp }, "mirror inject: 输入框里已有这段文本 (上次未提交) — 不重贴, 直接提交");
1587
1614
  }
1588
1615
  else {
@@ -1647,6 +1674,8 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1647
1674
  await sleep(Math.max(0, POST_PASTE_SETTLE_FALLBACK_MS - (Date.now() - pastedAt)));
1648
1675
  }
1649
1676
  const sendEnter = async () => {
1677
+ if (guard && !guard("submit"))
1678
+ return { ok: false, reason: "submit canceled by dispatch guard" };
1650
1679
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1651
1680
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
1652
1681
  };
@@ -1749,6 +1778,8 @@ const injectViaSpawn = async (args) => {
1749
1778
  ...cfg.wrc.extraArgs,
1750
1779
  ];
1751
1780
  log.info({ sessionId, bin, len: text.length }, "mirror inject");
1781
+ if (args.guard && !args.guard("submit"))
1782
+ return { ok: false, reason: "spawn canceled by dispatch guard" };
1752
1783
  return new Promise((resolve) => {
1753
1784
  const proc = spawn(bin, cliArgs, {
1754
1785
  cwd,
@@ -1778,11 +1809,57 @@ const injectViaSpawn = async (args) => {
1778
1809
  });
1779
1810
  });
1780
1811
  };
1781
- const inject = (args) => {
1812
+ const inject = async (args) => {
1782
1813
  const target = (args.tmuxTarget ?? "").trim();
1783
- if (!target)
1784
- return injectViaSpawn(args);
1785
- return injectViaTmux(target, args.text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false);
1814
+ if (!target) {
1815
+ if (args.requireIdle)
1816
+ return { ok: false, retryable: true, reason: "idle delivery requires a live pane" };
1817
+ try {
1818
+ if (args.guard && !args.guard())
1819
+ return { ok: false, retryable: true, reason: "spawn permission withdrawn" };
1820
+ const text = args.prepareText?.() ?? args.text;
1821
+ let activated = false;
1822
+ const result = await injectViaSpawn({ ...args, text, guard: (phase) => {
1823
+ if (args.guard && !args.guard(phase))
1824
+ return false;
1825
+ if (phase && !activated) {
1826
+ activated = true;
1827
+ args.beforeInput?.();
1828
+ }
1829
+ return true;
1830
+ } });
1831
+ return result.ok ? result : { ...result, uncertain: true };
1832
+ }
1833
+ catch (error) {
1834
+ return { ok: false, uncertain: true, reason: String(error) };
1835
+ }
1836
+ }
1837
+ let exposed = false;
1838
+ const classify = (result) => result.ok ? result
1839
+ : result.uncertain || exposed ? { ...result, uncertain: true, retryable: false }
1840
+ : { ...result, retryable: true };
1841
+ try {
1842
+ return classify(await withPaneInput(target, async (live) => {
1843
+ // Busy and unknown are both a deferred delivery. Never hold the input
1844
+ // lock while waiting for a model's whole turn to finish.
1845
+ if (args.requireIdle && !args.requireIdle())
1846
+ return { ok: false, reason: "target has no confirmed idle evidence" };
1847
+ const text = args.prepareText?.() ?? args.text;
1848
+ const guard = (phase) => {
1849
+ if (!live() || (!exposed && args.requireIdle && !args.requireIdle()) || (args.guard && !args.guard(phase)))
1850
+ return false;
1851
+ if (phase && !exposed) {
1852
+ exposed = true;
1853
+ args.beforeInput?.();
1854
+ }
1855
+ return true;
1856
+ };
1857
+ return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard);
1858
+ }, { guard: args.guard }));
1859
+ }
1860
+ catch (error) {
1861
+ return classify({ ok: false, reason: String(error) });
1862
+ }
1786
1863
  };
1787
1864
  const queues = new Map();
1788
1865
  /** Ceiling for one whole inject job (respawn + paste + submit verification).
@@ -1989,7 +2066,11 @@ export const startMirror = (deps) => {
1989
2066
  const content = s.acc;
1990
2067
  s.lastSent = content;
1991
2068
  try {
1992
- await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
2069
+ await (s.pending = (s.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2070
+ if (s.closed || s.dead)
2071
+ return;
2072
+ await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
2073
+ }));
1993
2074
  log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
1994
2075
  }
1995
2076
  catch (e) {
@@ -2030,6 +2111,7 @@ export const startMirror = (deps) => {
2030
2111
  if (!s.dead && body.trim()) {
2031
2112
  const card = detailCardFor(s, a.target);
2032
2113
  try {
2114
+ await s.pending?.catch(() => undefined);
2033
2115
  if (card) {
2034
2116
  s.cardSent = true;
2035
2117
  await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
@@ -2079,7 +2161,7 @@ export const startMirror = (deps) => {
2079
2161
  // 头挂 chat 详情页的票据: 本轮那条 turn。没有活 turn 可挂 → undefined, 头退回裸
2080
2162
  // `emoji #tag` (withLinkedTagHeader 自己兜)。
2081
2163
  const chatTurnUrl = (target, turnId) => turnId
2082
- ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target))
2164
+ ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target), target)
2083
2165
  : undefined;
2084
2166
  // `[mirror]` / `[wezard]` 系统提示不是答给谁的话, 不标方向。
2085
2167
  const withLinkedTag = (a, content, seq, turnId) => withLinkedTagHeader(a.target, content, chatTurnUrl(a.target, turnId ?? a.briefTurnId), seq, /^\[(mirror|wezard)\]/.test(content.trim()) ? undefined : a.replyTo, a.channel || undefined);
@@ -2094,15 +2176,21 @@ export const startMirror = (deps) => {
2094
2176
  const sendStandalone = (a, content) => {
2095
2177
  if (!hasVisibleBody(content))
2096
2178
  return;
2179
+ if (answerSilent(a))
2180
+ return;
2097
2181
  if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
2098
2182
  return;
2099
2183
  const chatId = replyChatId(a);
2100
- const turnId = a.briefTurnId; // 推送是排队异步的, 到发出时 turn 早已收口
2184
+ const turnId = a.briefTurnId ?? a.liveStream?.turnId; // 推送是排队异步的, 到发出时 turn 早已收口
2101
2185
  const pieces = splitMarkdown(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
2102
2186
  const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
2103
2187
  a.standalonePending = a.standalonePending
2104
2188
  .then(async () => {
2189
+ if (silenceAnswer(a.target, turnId ?? ""))
2190
+ return;
2105
2191
  for (const c of chunks) {
2192
+ if (silenceAnswer(a.target, turnId ?? ""))
2193
+ return;
2106
2194
  try {
2107
2195
  const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
2108
2196
  log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
@@ -2119,9 +2207,11 @@ export const startMirror = (deps) => {
2119
2207
  if (!hasVisibleBody(content))
2120
2208
  return;
2121
2209
  const chatId = replyChatId(a);
2122
- const turnId = a.briefTurnId;
2210
+ const turnId = a.briefTurnId ?? a.liveStream?.turnId;
2123
2211
  a.standalonePending = a.standalonePending
2124
2212
  .then(async () => {
2213
+ if (silenceAnswer(a.target, turnId ?? ""))
2214
+ return;
2125
2215
  try {
2126
2216
  await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
2127
2217
  log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
@@ -2310,7 +2400,7 @@ export const startMirror = (deps) => {
2310
2400
  // sees the bubble; subsequent items grow it as today.
2311
2401
  void (async () => {
2312
2402
  try {
2313
- await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
2403
+ await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
2314
2404
  }
2315
2405
  catch (e) {
2316
2406
  log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
@@ -2415,11 +2505,15 @@ export const startMirror = (deps) => {
2415
2505
  const b = a.briefBubble;
2416
2506
  const turnId = a.briefTurnId;
2417
2507
  // 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
2418
- if (!text || !b || b.done || !turnId || a.briefConcluded)
2508
+ if (!text || !b || b.done || !turnId || a.briefConcluded || answerSilent(a))
2419
2509
  return;
2420
2510
  a.cotLastSent = text;
2421
2511
  try {
2422
- await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, `\`${text}\``), false);
2512
+ await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2513
+ if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || answerSilent(a))
2514
+ return;
2515
+ await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, `\`${text}\``), false);
2516
+ }));
2423
2517
  }
2424
2518
  catch (e) {
2425
2519
  log.debug({ sessionId: a.sessionId, turnId, err: e.message }, "brief: cot refresh failed");
@@ -2450,7 +2544,172 @@ export const startMirror = (deps) => {
2450
2544
  // `[mirror]` 系统提示 —— 它们都不走这道门。
2451
2545
  // 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
2452
2546
  // 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
2453
- const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
2547
+ let silenceAnswer = () => false;
2548
+ const answerSilent = (a) => silenceAnswer(a.target, a.briefTurnId ?? a.liveStream?.turnId ?? "");
2549
+ const turnSilent = (a) => answerSilent(a) || isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
2550
+ const answerBubbles = new Map();
2551
+ const answerLine = (held, text) => withLinkedTagHeader(held.receiver, text, chatTurnUrl(held.receiver, held.sourceTurn), undefined, undefined, held.channel);
2552
+ // Serialise the old owner's in-flight refresh, reset, progress and final.
2553
+ // A delayed ACK must never let an earlier writer overtake the new owner.
2554
+ const writeAnswerProgress = (held, text) => {
2555
+ held.pending = held.pending.catch(() => undefined).then(async () => {
2556
+ if (!held.active || held.stopped || !held.bubble || held.bubble.done)
2557
+ return;
2558
+ await client.replyStream(held.bubble.frame, held.bubble.streamId, answerLine(held, text), false);
2559
+ }).catch((e) => log.warn({ err: errText(e) }, "answer transfer progress refresh failed"));
2560
+ };
2561
+ const updateAnswerProgress = (a, item) => {
2562
+ // Queued metadata is not authority: only the input boundary activates a task.
2563
+ const held = a.activePeerTurn ? answerBubbles.get(a.activePeerTurn) : undefined;
2564
+ if (!held || held.receiver !== a.target || held.stopped || held.bubble?.done)
2565
+ return;
2566
+ const raw = item.kind === "tool_use" ? cotToolLabel(item.calls)
2567
+ : item.kind === "thinking" || (item.kind === "text" && item.final !== true) ? item.body : "";
2568
+ const line = cotLine(raw);
2569
+ if (!line)
2570
+ return;
2571
+ held.progress = line;
2572
+ if (!held.active)
2573
+ return;
2574
+ if (held.timer)
2575
+ return;
2576
+ held.timer = setTimeout(() => {
2577
+ held.timer = undefined;
2578
+ if (held.progress)
2579
+ writeAnswerProgress(held, `\`${held.progress}\``);
2580
+ }, COT_FLUSH_MS);
2581
+ };
2582
+ const answerContext = (target) => {
2583
+ const a = byTarget.get(target);
2584
+ if (!a || a.briefConcluded || a.briefIsSlash || isPrivate(a) || a.turnFromChat === false)
2585
+ return undefined;
2586
+ const turn = a.briefTurnId ?? a.liveStream?.turnId;
2587
+ return turn ? { turn, channel: a.channel ?? baseOfKey(target), prefix: "" } : undefined;
2588
+ };
2589
+ const prepareAnswer = (id, source, expectedTurn, receiver, channel) => {
2590
+ const a = byTarget.get(source);
2591
+ if (!a || (a.briefTurnId ?? a.liveStream?.turnId) !== expectedTurn || answerBubbles.has(id))
2592
+ return false;
2593
+ if ((!a.briefBubble || a.briefBubble.done) && (!a.liveStream || a.liveStream.closed || a.liveStream.dead))
2594
+ return false;
2595
+ answerBubbles.set(id, {
2596
+ source, receiver, sourceTurn: expectedTurn, channel, active: false, stopped: false,
2597
+ bubble: a.briefBubble, stream: a.liveStream, pending: Promise.resolve(),
2598
+ });
2599
+ return true;
2600
+ };
2601
+ const cancelAnswerPreparation = (id) => {
2602
+ const held = answerBubbles.get(id);
2603
+ if (held && !held.active)
2604
+ answerBubbles.delete(id);
2605
+ };
2606
+ const stopAnswerProgress = (id) => {
2607
+ const held = answerBubbles.get(id);
2608
+ if (!held)
2609
+ return;
2610
+ held.stopped = true;
2611
+ clearTimeout(held.timer);
2612
+ held.timer = undefined;
2613
+ };
2614
+ const takeAnswer = async (id, target, expectedTurn, receiver, channel) => {
2615
+ const a = byTarget.get(target);
2616
+ const held = answerBubbles.get(id);
2617
+ if (!held || held.active || held.source !== target || held.sourceTurn !== expectedTurn || held.receiver !== receiver || held.channel !== channel)
2618
+ return { taken: false, cleared: false };
2619
+ const sameTurn = a && (a.briefTurnId ?? a.liveStream?.turnId) === expectedTurn;
2620
+ if (sameTurn) {
2621
+ clearCot(a);
2622
+ a.cotLastSent = undefined;
2623
+ if (a.standaloneBuf) {
2624
+ clearTimeout(a.standaloneBuf.timer);
2625
+ a.standaloneBuf = undefined;
2626
+ }
2627
+ // Only this source turn becomes private; a concurrent new human turn is untouched.
2628
+ a.channel = "";
2629
+ }
2630
+ const s = held.stream;
2631
+ if (s && !s.closed && !s.dead) {
2632
+ s.closed = true;
2633
+ clearTimeout(s.flushTimer);
2634
+ clearTimeout(s.idleTimer);
2635
+ clearTimeout(s.hardTimer);
2636
+ // Read the current chain, not prepare's snapshot: source may have flushed during injection.
2637
+ held.bubble = { frame: s.frame, streamId: s.streamId, done: false, pending: s.pending };
2638
+ if (a?.liveStream === s)
2639
+ a.liveStream = undefined;
2640
+ }
2641
+ else if (s)
2642
+ held.bubble = undefined;
2643
+ const b = held.bubble;
2644
+ held.active = true;
2645
+ if (!b || b.done)
2646
+ return { taken: true, cleared: false };
2647
+ clearTimeout(b.hardTimer);
2648
+ if (a?.briefBubble === b)
2649
+ a.briefBubble = undefined;
2650
+ let cleared = false;
2651
+ held.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2652
+ if (b.done)
2653
+ return;
2654
+ await client.replyStream(b.frame, b.streamId, answerLine(held, "…"), false);
2655
+ cleared = true;
2656
+ });
2657
+ try {
2658
+ await held.pending;
2659
+ }
2660
+ catch (e) {
2661
+ cleared = false;
2662
+ log.warn({ id, err: errText(e) }, "answer transfer reset not acknowledged");
2663
+ }
2664
+ // Keep delivery possible after a reset error; reset never carries the final answer.
2665
+ held.pending = held.pending.catch(() => undefined);
2666
+ if (!held.stopped && held.progress)
2667
+ writeAnswerProgress(held, `\`${held.progress}\``);
2668
+ return { taken: true, cleared };
2669
+ };
2670
+ const deliverAnswer = async (id, target, turn, channel, _prefix, body, standalone) => {
2671
+ const held = answerBubbles.get(id);
2672
+ const text = body.trim();
2673
+ const content = withLinkedTagHeader(target, text, chatTurnUrl(target, turn), undefined, undefined, channel);
2674
+ stopAnswerProgress(id);
2675
+ if (held?.bubble && !held.bubble.done) {
2676
+ held.bubble.done = true;
2677
+ clearTimeout(held.timer);
2678
+ await held.pending;
2679
+ try {
2680
+ // Failure closes progress first; the terminal explanation is a standalone message.
2681
+ const progress = withLinkedTagHeader(target, "回答已转交", chatTurnUrl(target, turn), undefined, undefined, channel);
2682
+ await client.replyStream(held.bubble.frame, held.bubble.streamId, standalone ? progress : content, true);
2683
+ if (!standalone) {
2684
+ answerBubbles.delete(id);
2685
+ return;
2686
+ }
2687
+ }
2688
+ catch (e) {
2689
+ const ack = e && typeof e === "object" ? e : undefined;
2690
+ // Only a structured non-zero server ACK proves the stream frame was
2691
+ // rejected. Timeout/disconnect errors are ambiguous: standalone retry
2692
+ // could publish the same final answer twice, so let the caller persist
2693
+ // `uncertain` and require a human resend instead.
2694
+ const rejected = !!ack && typeof ack.headers === "object" && !!ack.headers && typeof ack.errcode === "number" && ack.errcode !== 0 && typeof ack.errmsg === "string";
2695
+ if (!rejected && !standalone) {
2696
+ answerBubbles.delete(id);
2697
+ throw e;
2698
+ }
2699
+ if (!rejected) {
2700
+ log.warn({ id, err: errText(e) }, "answer transfer progress close acknowledgement uncertain; continuing with the one standalone terminal explanation");
2701
+ // In standalone mode this stream only closes progress; it never
2702
+ // carries the final body, so sending the terminal explanation once
2703
+ // remains necessary even when progress-close ACK is ambiguous.
2704
+ }
2705
+ else {
2706
+ log.warn({ id, err: errText(e), errcode: ack.errcode }, "answer transfer stream was explicitly rejected; standalone fallback");
2707
+ }
2708
+ }
2709
+ }
2710
+ await client.sendMessage(channel.replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content } });
2711
+ answerBubbles.delete(id);
2712
+ };
2454
2713
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
2455
2714
  // raw=true skips withTagHeader (used when content already contains the linked tag header).
2456
2715
  // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
@@ -2492,6 +2751,7 @@ export const startMirror = (deps) => {
2492
2751
  a.briefHadTool = false;
2493
2752
  a.briefConcluded = false;
2494
2753
  a.briefLastText = undefined;
2754
+ a.cotLastSent = undefined;
2495
2755
  clearCot(a); // turn 级状态 —— 进度行跟着气泡走, 不能跨 turn 残留
2496
2756
  log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
2497
2757
  };
@@ -2636,6 +2896,7 @@ export const startMirror = (deps) => {
2636
2896
  const activateDispatch = (a, s, at = s.at) => {
2637
2897
  const q = s.turn;
2638
2898
  s.activated = true;
2899
+ a.activePeerTurn = undefined;
2639
2900
  a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
2640
2901
  a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
2641
2902
  a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
@@ -2681,12 +2942,18 @@ export const startMirror = (deps) => {
2681
2942
  if (a.briefTurnId)
2682
2943
  closeBriefTurn(a);
2683
2944
  const now = Date.now();
2945
+ a.activePeerTurn = s.from?.kind === "peer" ? s.from.turn : undefined;
2684
2946
  a.pendingFrom = s.from ? { from: s.from, at: now } : undefined;
2685
2947
  a.pendingOrigin = s.origin ? { origin: s.origin, at: now } : undefined;
2686
2948
  // 没写频道的注入 (graph 步骤 / 手动 inject) 等同在 home 里开口: 印章就是全部出处, 不沿用上一轮的。
2687
- a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), at: now };
2949
+ a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: now };
2688
2950
  a.pendingBriefQuery = s.query;
2689
2951
  a.pendingBriefAt = ts;
2952
+ if (!cfg.wrc.mirror.brief) {
2953
+ consumeChannel(a, false, consumeFrom(a));
2954
+ if (a.liveStream && !a.liveStream.closed)
2955
+ a.liveStream.dest = s.channel ?? baseOfKey(a.target);
2956
+ }
2690
2957
  return s;
2691
2958
  };
2692
2959
  const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
@@ -2725,7 +2992,7 @@ export const startMirror = (deps) => {
2725
2992
  // 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
2726
2993
  // 尾随 `…` 表示"正文还没到", 正文到位时整条内容被 `链接 正文` 覆盖。
2727
2994
  try {
2728
- await client.replyStream(frame, streamId, briefLine(a, turnId, "…", channel), false);
2995
+ await (bubble.pending = client.replyStream(frame, streamId, briefLine(a, turnId, "…", channel), false));
2729
2996
  }
2730
2997
  catch (e) {
2731
2998
  log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
@@ -2749,6 +3016,7 @@ export const startMirror = (deps) => {
2749
3016
  a.channel = r.channel === undefined || r.channel === baseOfKey(a.target) ? undefined : r.channel;
2750
3017
  a.speaker = r.speaker;
2751
3018
  a.convFrom = r.from;
3019
+ a.activePeerTurn = r.from?.kind === "peer" ? r.from.turn : undefined;
2752
3020
  a.replyTo = publicPeer(r.from);
2753
3021
  a.turnFromChat = a.turnFromChat ?? true;
2754
3022
  a.briefTurnId = r.id;
@@ -2835,7 +3103,10 @@ export const startMirror = (deps) => {
2835
3103
  a.lastConcludedBody = body;
2836
3104
  a.lastConcludedEpoch = a.queryEpoch ?? 0;
2837
3105
  a.lastConcludedAt = Date.now();
2838
- if (a.briefBubble && !a.briefBubble.done) {
3106
+ if (answerSilent(a)) {
3107
+ log.info({ target: a.target, turnId }, "answer transferred; source conclusion suppressed");
3108
+ }
3109
+ else if (a.briefBubble && !a.briefBubble.done) {
2839
3110
  void finishBriefBubble(a, briefLine(a, turnId, linkTags(a.target, body)), true);
2840
3111
  }
2841
3112
  else if (turnSilent(a)) {
@@ -2975,6 +3246,9 @@ export const startMirror = (deps) => {
2975
3246
  : item.kind === "thinking" || item.kind === "text"
2976
3247
  ? item.body
2977
3248
  : "";
3249
+ // A prompt (even an exact match) is not a child/parent-call identity. Late
3250
+ // children can reuse prompts after history eviction, so their content must
3251
+ // never enter a transferred answer bubble without a verifiable ID binding.
2978
3252
  if (activity)
2979
3253
  updateBriefProgress(a, `🤖 ${run.label} · ${activity}`);
2980
3254
  };
@@ -3328,7 +3602,12 @@ export const startMirror = (deps) => {
3328
3602
  // 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
3329
3603
  a.pendingBriefQuery = item.body;
3330
3604
  a.pendingBriefAt = item.ts;
3605
+ a.activePeerTurn = undefined;
3606
+ a.pendingFrom = undefined;
3607
+ a.pendingChannel = undefined;
3331
3608
  a.pendingFromCli = true;
3609
+ if (!cfg.wrc.mirror.brief)
3610
+ consumeChannel(a, true, undefined);
3332
3611
  }
3333
3612
  if (item.kind === "user_inject") {
3334
3613
  interject(a, item.said, item.ts);
@@ -3413,6 +3692,7 @@ export const startMirror = (deps) => {
3413
3692
  // 的配置下, 人在 CLI 敲的那一行就这么原样回显进了群, 正好破了 chatOriginOnly。
3414
3693
  if (item.kind === "user_text" && (item.quiet || cfg.wrc.mirror.brief))
3415
3694
  return;
3695
+ updateAnswerProgress(a, item);
3416
3696
  // thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
3417
3697
  // 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
3418
3698
  // 那就变回已下线的 thinkStyle 了。
@@ -3560,6 +3840,8 @@ export const startMirror = (deps) => {
3560
3840
  }
3561
3841
  }
3562
3842
  }
3843
+ if (!cfg.wrc.mirror.brief && isPrivate(a))
3844
+ return;
3563
3845
  // /goal mode overrides everything below: the session-scoped Stop hook
3564
3846
  // self-drives the model with no terminal stop_reason, so turn_end never fires
3565
3847
  // and brief-mode would swallow the entire run into the turn store (silent
@@ -3864,7 +4146,9 @@ export const startMirror = (deps) => {
3864
4146
  };
3865
4147
  // `panes` = boot 快照; 给了就不再为每个 principal 单独问 tmux。lazy 调用方
3866
4148
  // 不传, 维持原来的逐个探活。
3867
- const restoreFromStore = async (principal, panes) => {
4149
+ const restoreFromStore = async (principal, panes, permitted) => {
4150
+ if (permitted && !permitted())
4151
+ return undefined;
3868
4152
  const rec = deps.store.get(principal);
3869
4153
  if (!rec)
3870
4154
  return undefined;
@@ -3882,6 +4166,8 @@ export const startMirror = (deps) => {
3882
4166
  // unrelated newest-in-cwd file is exactly the cross-wire we must avoid.
3883
4167
  const storedPane = (rec.tmuxPane ?? "").trim();
3884
4168
  const livePane = storedPane && (panes ? panes.has(storedPane) : await tmuxPaneAlive(storedPane)) ? storedPane : "";
4169
+ if (permitted && !permitted())
4170
+ return undefined;
3885
4171
  if (!existsSync(jsonlAbs)) {
3886
4172
  // First: the SAME-sid transcript may have merely relocated to a sibling
3887
4173
  // project dir (Claude Code EnterWorktree/ExitWorktree moved it while the
@@ -3937,6 +4223,8 @@ export const startMirror = (deps) => {
3937
4223
  const paneCwd = panes
3938
4224
  ? (panes.get(livePane) ?? "")
3939
4225
  : (await runTmux(["display-message", "-p", "-t", livePane, "#{pane_current_path}"])).stdout.trim();
4226
+ if (permitted && !permitted())
4227
+ return undefined;
3940
4228
  if (paneCwd) {
3941
4229
  // Encode under the backend that owns the bound transcript — comparing
3942
4230
  // with the primary dialect would report a phantom drift for every
@@ -3956,6 +4244,8 @@ export const startMirror = (deps) => {
3956
4244
  }
3957
4245
  }
3958
4246
  }
4247
+ if (permitted && !permitted())
4248
+ return undefined;
3959
4249
  const r = attach({
3960
4250
  sessionId: rec.sessionId,
3961
4251
  jsonlPath: jsonlAbs,
@@ -4542,10 +4832,10 @@ export const startMirror = (deps) => {
4542
4832
  // `header` folds the caller's ack ("created") into this same bubble — /new
4543
4833
  // must land as exactly ONE WeCom message, not card + separate reply.
4544
4834
  // 会话边界回执一律不携带 cwd 信息 (📂 项目行 / 下次切换), 需要时 `/pwd` 可查。
4545
- // slashAckFirstLine 进一步省去 💡 tip, 只剩首行 ack。
4546
4835
  const pushProjectInfo = (target, header) => {
4547
- const concise = cfg.wrc.mirror.slashAckFirstLine && !!header;
4548
- const md = header ? (concise ? header : `${header}\n\n${randomTip()}`) : randomTip();
4836
+ if (!header)
4837
+ return;
4838
+ const md = header;
4549
4839
  const a = byTarget.get(target);
4550
4840
  if (a) {
4551
4841
  sendStandalone(a, md);
@@ -4606,12 +4896,13 @@ export const startMirror = (deps) => {
4606
4896
  return undefined;
4607
4897
  }
4608
4898
  };
4609
- /** 死 pane 重生 (`--resume` 同一个会话) 的模型: 绑定里记着的优先 —— set_model 换上的也记在那;
4610
- * 没记 (老绑定 / CLI 默认) 的管家补上管家档。 */
4899
+ /** 死 pane 重生: 管家重新取 router 档位, 普通 wizard 沿用绑定模型。 */
4611
4900
  const respawnSpec = (a) => {
4612
4901
  return resumedStewardSpec(cfg, a.target, backendForPath(a.jsonlPath).name, a);
4613
4902
  };
4614
4903
  const newSession = async (target, windowName, cli, opts) => {
4904
+ if (opts?.guard && !opts.guard())
4905
+ return { ok: false, reason: "spawn guard withdrew permission" };
4615
4906
  const prev = byTarget.get(target);
4616
4907
  // Resolution precedence (all chat-scoped except the running-cwd fallback):
4617
4908
  // base.pending > caller.pending > target.running > base.running > default
@@ -4637,8 +4928,8 @@ export const startMirror = (deps) => {
4637
4928
  const boundPath = prev?.jsonlPath ?? rec?.jsonlPath;
4638
4929
  const base = baseOfKey(target);
4639
4930
  const effCli = resolveCliBackend(cfg.wrc).name;
4640
- // 管家 (群的默认会话) 新起的会话按管家档起 (models.steward / chatPolicy), 档压过绑定里记下的
4641
- // effort —— `/new`、换目录、交接重开都是让改过的档位生效的那一下。点名了模型就整档让开。
4931
+ // 管家 (群的默认会话) 固定按 models.router 起, 压过显式模型及绑定里记下的 effort;
4932
+ // `/new`、换目录、交接重开都是让改过的档位生效的那一下。
4642
4933
  // 已在跑的管家不动 (换模型会让缓存整份重读); 死 pane 重生见 respawnSpec。
4643
4934
  const backendChanged = Boolean(boundPath && effCli && backendForPath(expandHome(boundPath)).name !== effCli);
4644
4935
  const { model: effModel, effort: effEffort } = newStewardSpec(cfg, target, effCli, opts, backendChanged ? undefined : prev?.effort ?? deps.store.get(target)?.effort);
@@ -4666,6 +4957,12 @@ export const startMirror = (deps) => {
4666
4957
  inFlightSids.delete(freshSid);
4667
4958
  return { ok: false, reason: r.reason };
4668
4959
  }
4960
+ if (opts?.guard && !opts.guard()) {
4961
+ inFlightSids.delete(freshSid);
4962
+ if (r.tmuxPane)
4963
+ await runTmux(["kill-pane", "-t", r.tmuxPane]);
4964
+ return { ok: false, reason: "spawn completed after permission withdrawal; binding withheld" };
4965
+ }
4669
4966
  const att = attach({
4670
4967
  sessionId: r.sessionId,
4671
4968
  jsonlPath: r.jsonlPath,
@@ -4783,14 +5080,16 @@ export const startMirror = (deps) => {
4783
5080
  return undefined;
4784
5081
  };
4785
5082
  const cloneSession = async (args) => {
4786
- const p = byTarget.get(args.parent) ?? (await restoreFromStore(args.parent));
5083
+ if (args.guard && !args.guard("paste"))
5084
+ return { ok: false, reason: "spawn guard withdrew permission", inherited: false };
5085
+ const p = byTarget.get(args.parent) ?? (await restoreFromStore(args.parent, undefined, () => !args.guard || args.guard()));
4787
5086
  // 继承上下文要求父亲有一个活着的 transcript, 且分身必须待在同一个项目目录 ——
4788
5087
  // `--resume` 是按项目目录寻址 session 的。调用方点名换目录 = 明确放弃继承。
4789
5088
  const sameCwd = !args.cwd?.trim() || expandHome(args.cwd.trim()) === (p?.runningCwd ?? "");
4790
5089
  const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
4791
5090
  if (!canInherit) {
4792
5091
  const r = await newSession(args.target, args.windowName, args.cli, {
4793
- model: args.model, effort: args.effort, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive,
5092
+ model: args.model, effort: args.effort, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive, guard: args.guard ? () => args.guard("paste") : undefined,
4794
5093
  });
4795
5094
  return { ...r, inherited: false };
4796
5095
  }
@@ -4802,6 +5101,8 @@ export const startMirror = (deps) => {
4802
5101
  const parentCli = backendForPath(parent.jsonlPath).name;
4803
5102
  const model = args.model?.trim() || parent.model || lastModel(parent.jsonlPath);
4804
5103
  const effort = args.effort ?? parseEffort(parent.effort);
5104
+ if (args.guard && !args.guard("paste"))
5105
+ return { ok: false, reason: "spawn guard withdrew permission", inherited: false };
4805
5106
  const r = await spawnTmuxClaude({
4806
5107
  cfg,
4807
5108
  log: lg,
@@ -4825,7 +5126,7 @@ export const startMirror = (deps) => {
4825
5126
  const boot = await inject({
4826
5127
  text: hello, images: [], cfg, log: lg,
4827
5128
  sessionId: parentCli === "codex" ? r.sessionId : parent.sessionId, jsonlPath: parentCli === "codex" ? r.jsonlPath : parent.jsonlPath,
4828
- tmuxTarget: r.tmuxPane, freshSpawn: true,
5129
+ tmuxTarget: r.tmuxPane, freshSpawn: true, guard: args.guard,
4829
5130
  });
4830
5131
  if (!boot.ok) {
4831
5132
  await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
@@ -4841,6 +5142,12 @@ export const startMirror = (deps) => {
4841
5142
  await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
4842
5143
  return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
4843
5144
  }
5145
+ if (args.guard && !args.guard("submit")) {
5146
+ inFlightSids.delete(fork.sessionId);
5147
+ // The bootstrap may already have executed; withhold binding, report the
5148
+ // unconfirmed birth to its caller instead of claiming cancellation success.
5149
+ return { ok: false, reason: "fork executed after permission withdrawal; reconcile pane manually", inherited: false };
5150
+ }
4844
5151
  const att = attach({
4845
5152
  sessionId: fork.sessionId,
4846
5153
  jsonlPath: fork.jsonlPath,
@@ -5340,7 +5647,9 @@ export const startMirror = (deps) => {
5340
5647
  catch { /* unreadable tail */ }
5341
5648
  return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), cacheAt: cacheTouchMs(jsonlPath) };
5342
5649
  };
5343
- const fireKeepalive = async (a) => {
5650
+ const fireKeepalive = async (a, guard) => {
5651
+ if (!guard())
5652
+ return;
5344
5653
  const kc = cfg.wrc.mirror.keepalive;
5345
5654
  // The SAME full instruction every round: a bare "ping" used to be sent from round 2 on
5346
5655
  // (the instruction was already in context), but the model reads a bare "ping"
@@ -5354,16 +5663,23 @@ export const startMirror = (deps) => {
5354
5663
  // ping turn (reply included). The 60s fail-safe clears the quiet window if
5355
5664
  // the turn somehow never emits turn_end, so a later real turn is never muted;
5356
5665
  // it also closes the detail turn so it can't hang open in the chat timeline.
5357
- rememberInject(text);
5358
- a.keepaliveQuiet = setTimeout(() => {
5359
- a.keepaliveQuiet = undefined;
5360
- if (a.keepaliveTurnId) {
5361
- recordTurnClose(a.keepaliveTurnId);
5362
- a.keepaliveTurnId = undefined;
5363
- }
5364
- }, 60_000);
5365
5666
  const r = await inject({
5366
5667
  text, images: [], cfg,
5668
+ guard,
5669
+ requireIdle: () => idleVerdict(a.target) === true,
5670
+ beforeInput: () => {
5671
+ // A queued heartbeat must not mute the peer turn that won the lane.
5672
+ // Drain its tail first, then open swallow only at permitted physical input.
5673
+ a.tail.drain();
5674
+ rememberInject(text);
5675
+ a.keepaliveQuiet = setTimeout(() => {
5676
+ a.keepaliveQuiet = undefined;
5677
+ if (a.keepaliveTurnId) {
5678
+ recordTurnClose(a.keepaliveTurnId);
5679
+ a.keepaliveTurnId = undefined;
5680
+ }
5681
+ }, 60_000);
5682
+ },
5367
5683
  log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
5368
5684
  sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
5369
5685
  });
@@ -5374,6 +5690,8 @@ export const startMirror = (deps) => {
5374
5690
  log.warn({ target: a.target, reason: r.reason }, "keepalive: inject failed");
5375
5691
  return;
5376
5692
  }
5693
+ if (!guard())
5694
+ return;
5377
5695
  const tokens = lastContextTokens(a.jsonlPath);
5378
5696
  const round = a.keepalive ? (a.keepalive.round += 1) : 1;
5379
5697
  log.info({ target: a.target, tokens, round, totalRounds: Math.max(1, kc.rounds) }, "keepalive: ping injected");
@@ -5389,116 +5707,132 @@ export const startMirror = (deps) => {
5389
5707
  recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
5390
5708
  }
5391
5709
  };
5392
- let keepaliveTicking = false;
5393
- const keepaliveTick = async () => {
5394
- const kc = cfg.wrc.mirror.keepalive;
5395
- if (!kc.enabled || keepaliveTicking)
5710
+ // One pool for both maintenance paths: slow panes do not stall other panes,
5711
+ // and a keepalive never overlaps a picker scan/automatic press on the same pane.
5712
+ const maintenance = createKeyedScheduler(4);
5713
+ const maintenanceValid = (a, pane, sid, gen, signal) => !signal.aborted && byTarget.get(a.target) === a && a.tmuxPane === pane && a.sessionId === sid
5714
+ && (a.injectGen ?? 0) === gen && !a.migrationWatcher;
5715
+ const keepaliveOne = async (a, pane, sid, gen, signal) => {
5716
+ const valid = () => maintenanceValid(a, pane, sid, gen, signal) && cfg.wrc.mirror.keepalive.enabled;
5717
+ if (!valid())
5396
5718
  return;
5397
- keepaliveTicking = true;
5719
+ const kc = cfg.wrc.mirror.keepalive;
5720
+ const pingSigs = [normAssistant(kc.ping).slice(0, 40)].filter((s) => s.length > 0);
5721
+ const now = Date.now();
5722
+ if (a.keepaliveDisabled)
5723
+ return; // sticky spawn-time opt-out — never pings
5724
+ if (!a.tmuxPane)
5725
+ return; // spawn-mode: no live pane to warm
5726
+ if (a.migrationWatcher)
5727
+ return; // session rotating — skip
5728
+ if (a.liveStream && !a.liveStream.closed)
5729
+ return; // mid typewriter — don't inject
5730
+ if (!(await tmuxPaneAlive(pane)) || !valid())
5731
+ return; // dead pane — nothing to keep alive
5732
+ let mtime = 0;
5398
5733
  try {
5399
- const pingSigs = [normAssistant(kc.ping).slice(0, 40)].filter((s) => s.length > 0);
5400
- const now = Date.now();
5401
- for (const a of byTarget.values()) {
5402
- if (a.keepaliveDisabled)
5403
- continue; // sticky spawn-time opt-out — never pings
5404
- if (!a.tmuxPane)
5405
- continue; // spawn-mode: no live pane to warm
5406
- if (a.migrationWatcher)
5407
- continue; // session rotating — skip
5408
- if (a.liveStream && !a.liveStream.closed)
5409
- continue; // mid typewriter — don't inject
5410
- if (!(await tmuxPaneAlive(a.tmuxPane)))
5411
- continue; // dead pane — nothing to keep alive
5412
- let mtime = 0;
5413
- try {
5414
- mtime = statSync(a.jsonlPath).mtimeMs;
5415
- }
5416
- catch {
5417
- continue;
5418
- }
5419
- if (!mtime)
5420
- continue;
5421
- const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, pingSigs));
5422
- // File mtime is only a cheap "did anything change → re-read the tail" gate.
5423
- // The clocks themselves come from message turns, so metadata churn (which
5424
- // bumps mtime but is not a turn) can never move them. `grewSinceLast` now
5425
- // means a NEW message turn appeared — not that the file grew.
5426
- const prevRealMs = k.lastRealMs;
5427
- if (mtime > k.seenMtime + 1000) {
5428
- const s = keepaliveStamps(a.jsonlPath, pingSigs);
5429
- if (s.stamped) {
5430
- k.lastMs = s.lastMs;
5431
- k.lastRealMs = s.lastRealMs;
5432
- }
5433
- else {
5434
- k.lastMs = mtime;
5435
- k.lastRealMs = mtime;
5436
- } // backend without timestamps → fall back to mtime
5437
- k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
5438
- k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
5439
- }
5440
- k.seenMtime = mtime;
5441
- // Round reset / real-activity re-anchor keys off REAL turns only — a
5442
- // keepalive ping+pong advances lastMs but NOT lastRealMs, so the heartbeat
5443
- // no longer reads as activity to itself (which was pinning round at 1/N).
5444
- const grewSinceLast = k.lastRealMs > prevRealMs + 1000;
5445
- const paneTail = await capturePaneTail(a.tmuxPane, 16);
5446
- const busy = paneIsBusy(paneTail);
5447
- if (k.pinging) {
5448
- // Waiting for our own ping to land and settle: until a NEWER message turn
5449
- // (the ping's own user line) appears and the pane goes idle, keep waiting.
5450
- if (busy || k.lastMs <= k.pingMtime)
5451
- continue;
5452
- k.pinging = false;
5453
- k.settledAt = now;
5454
- continue;
5455
- }
5456
- // A new real message turn (or a busy pane) re-anchors: reset the round
5457
- // counter, and past the /stop grace lift a busy-resume. grewSinceLast is
5458
- // message-based now, so `/stop`'s Esc settling writes (metadata, no new
5459
- // turn) can no longer self-resume the pause — only a busy pane past grace,
5460
- // or a WeCom inbound in dispatch, does.
5461
- // Suppress the grewSinceLast reset for 30s after pinging settles — the
5462
- // pong's tail flush can lag a tick and would otherwise fake real activity.
5463
- const justSettled = k.settledAt > 0 && now - k.settledAt < 30_000;
5464
- if (busy || (grewSinceLast && !justSettled)) {
5465
- k.round = 0;
5466
- k.settledAt = 0;
5467
- if (a.keepaliveOff && busy && now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS) {
5468
- a.keepaliveOff = false;
5469
- persistPause(a);
5470
- }
5471
- if (busy)
5472
- continue;
5473
- }
5474
- if (a.keepaliveOff)
5475
- continue; // paused by /stop until real activity returns
5476
- // Cache clock = start of the newest answered request (TTL runs from there),
5477
- // not the last turn's write time; no request on record → the turn clock.
5478
- const sinceCache = now - (k.cacheAt || k.lastMs || mtime);
5479
- // The TTL the session's cache entries were actually written with (1h on a
5480
- // Claude Code subscription), not a guess: pinging at the 5min cadence
5481
- // against a 1h entry re-reads the whole context ~14x per hour for nothing.
5482
- const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
5483
- const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
5484
- if (sinceCache < idleTriggerMs)
5485
- continue; // cache still comfortably warm
5486
- if (sinceCache >= ttlMs)
5487
- continue; // cache already cold — a ping would cold-rewrite for nothing
5488
- if (sinceCache > KEEPALIVE_MAX_IDLE_MS)
5489
- continue; // idle past 10min (a long-TTL entry) — not worth warming
5490
- if (k.round >= kc.rounds)
5491
- continue; // budget spent — let it go cold
5492
- k.pinging = true;
5493
- k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
5494
- await fireKeepalive(a);
5734
+ mtime = statSync(a.jsonlPath).mtimeMs;
5735
+ }
5736
+ catch {
5737
+ return;
5738
+ }
5739
+ if (!mtime)
5740
+ return;
5741
+ const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, pingSigs));
5742
+ // File mtime is only a cheap "did anything change → re-read the tail" gate.
5743
+ // The clocks themselves come from message turns, so metadata churn (which
5744
+ // bumps mtime but is not a turn) can never move them. `grewSinceLast` now
5745
+ // means a NEW message turn appeared — not that the file grew.
5746
+ const prevRealMs = k.lastRealMs;
5747
+ if (mtime > k.seenMtime + 1000) {
5748
+ const s = keepaliveStamps(a.jsonlPath, pingSigs);
5749
+ if (s.stamped) {
5750
+ k.lastMs = s.lastMs;
5751
+ k.lastRealMs = s.lastRealMs;
5495
5752
  }
5753
+ else {
5754
+ k.lastMs = mtime;
5755
+ k.lastRealMs = mtime;
5756
+ } // backend without timestamps → fall back to mtime
5757
+ k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
5758
+ k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
5759
+ }
5760
+ k.seenMtime = mtime;
5761
+ // Round reset / real-activity re-anchor keys off REAL turns only — a
5762
+ // keepalive ping+pong advances lastMs but NOT lastRealMs, so the heartbeat
5763
+ // no longer reads as activity to itself (which was pinning round at 1/N).
5764
+ const grewSinceLast = k.lastRealMs > prevRealMs + 1000;
5765
+ const paneTail = await capturePaneTail(pane, 16);
5766
+ if (!valid())
5767
+ return;
5768
+ const busy = paneIsBusy(paneTail);
5769
+ if (k.pinging) {
5770
+ // Waiting for our own ping to land and settle: until a NEWER message turn
5771
+ // (the ping's own user line) appears and the pane goes idle, keep waiting.
5772
+ if (busy || k.lastMs <= k.pingMtime)
5773
+ return;
5774
+ k.pinging = false;
5775
+ k.settledAt = now;
5776
+ return;
5496
5777
  }
5497
- catch (e) {
5498
- log.warn({ err: e.message }, "keepalive tick failed");
5778
+ // A new real message turn (or a busy pane) re-anchors: reset the round
5779
+ // counter, and past the /stop grace lift a busy-resume. grewSinceLast is
5780
+ // message-based now, so `/stop`'s Esc settling writes (metadata, no new
5781
+ // turn) can no longer self-resume the pause — only a busy pane past grace,
5782
+ // or a WeCom inbound in dispatch, does.
5783
+ // Suppress the grewSinceLast reset for 30s after pinging settles — the
5784
+ // pong's tail flush can lag a tick and would otherwise fake real activity.
5785
+ const justSettled = k.settledAt > 0 && now - k.settledAt < 30_000;
5786
+ if (busy || (grewSinceLast && !justSettled)) {
5787
+ k.round = 0;
5788
+ k.settledAt = 0;
5789
+ if (a.keepaliveOff && busy && now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS) {
5790
+ a.keepaliveOff = false;
5791
+ persistPause(a);
5792
+ }
5793
+ if (busy)
5794
+ return;
5499
5795
  }
5500
- finally {
5501
- keepaliveTicking = false;
5796
+ if (a.keepaliveOff)
5797
+ return; // paused by /stop until real activity returns
5798
+ // Cache clock = start of the newest answered request (TTL runs from there),
5799
+ // not the last turn's write time; no request on record → the turn clock.
5800
+ const sinceCache = now - (k.cacheAt || k.lastMs || mtime);
5801
+ // The TTL the session's cache entries were actually written with (1h on a
5802
+ // Claude Code subscription), not a guess: pinging at the 5min cadence
5803
+ // against a 1h entry re-reads the whole context ~14x per hour for nothing.
5804
+ const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
5805
+ const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
5806
+ if (sinceCache < idleTriggerMs)
5807
+ return; // cache still comfortably warm
5808
+ if (sinceCache >= ttlMs)
5809
+ return; // cache already cold — a ping would cold-rewrite for nothing
5810
+ if (sinceCache > KEEPALIVE_MAX_IDLE_MS)
5811
+ return; // idle past 10min (a long-TTL entry) — not worth warming
5812
+ if (k.round >= kc.rounds)
5813
+ return; // budget spent — let it go cold
5814
+ if (idleVerdict(a.target) !== true)
5815
+ return; // no confirmed idle → no heartbeat input
5816
+ k.pinging = true;
5817
+ k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
5818
+ await fireKeepalive(a, () => valid() && !a.keepaliveOff);
5819
+ };
5820
+ const keepaliveTick = () => {
5821
+ if (!cfg.wrc.mirror.keepalive.enabled)
5822
+ return;
5823
+ for (const a of byTarget.values()) {
5824
+ if (!a.tmuxPane || a.keepaliveDisabled)
5825
+ continue;
5826
+ const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
5827
+ void maintenance.schedule(`keepalive:${a.target}`, pane, (signal) => keepaliveOne(a, pane, sid, gen, signal))
5828
+ ?.catch((e) => {
5829
+ if (maintenanceValid(a, pane, sid, gen, maintenance.signal)) {
5830
+ if (a.keepalive)
5831
+ a.keepalive.pinging = false;
5832
+ endKeepaliveSwallow(a);
5833
+ }
5834
+ log.warn({ target: a.target, err: e.message }, "keepalive tick failed");
5835
+ });
5502
5836
  }
5503
5837
  };
5504
5838
  const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
@@ -5513,118 +5847,143 @@ export const startMirror = (deps) => {
5513
5847
  // - 其它 → 把框转成标准审批卡发到本 chat (runMirrorPickerFlow), 点击后代按
5514
5848
  // 对应选项; 本地先按掉则 moot 作废卡。代按/发卡都发 chat 回执留痕。
5515
5849
  // WEZARD_PICKER_SENTINEL=0 整体关闭。
5516
- let pickerTicking = false;
5517
- const pickerTick = async () => {
5518
- if (pickerTicking || process.env.WEZARD_PICKER_SENTINEL === "0")
5519
- return;
5520
- if (!cfg.approval.enabled)
5850
+ const pickerOne = async (a, pane, sid, gen, signal) => {
5851
+ const guard = () => maintenanceValid(a, pane, sid, gen, signal) && cfg.approval.enabled
5852
+ && process.env.WEZARD_PICKER_SENTINEL !== "0";
5853
+ if (!guard())
5521
5854
  return;
5522
- pickerTicking = true;
5523
- try {
5524
- for (const a of byTarget.values()) {
5525
- if (!a.tmuxPane || a.migrationWatcher)
5526
- continue;
5527
- // Codex PermissionRequest has its own independent native gate. Screen
5528
- // options, tool windows and skipAll never authorize its sandbox.
5529
- if (cliOf(a) === "codex")
5530
- continue;
5531
- if (!(await tmuxPaneAlive(a.tmuxPane)))
5532
- continue;
5533
- const v = await detectModalPicker(a.tmuxPane);
5534
- if (!v.modal) {
5535
- // 审批卡在途而框已消失 = 本地先按掉了 → 作废卡等待 (无卡则 no-op)。
5536
- mootMirrorPicker(a.sessionId);
5537
- // 诊断: 屏上有高亮编号行却没判成 modal = 布局盲区候选 (askq/plan 面板
5538
- // 也长这样, 属正常)。每屏记一次日志, 供远端排查"哨兵为何没反应"。
5539
- if (looksLikePicker(v.screen)) {
5540
- if (a.pickerSentinelFp !== v.screen) {
5541
- a.pickerSentinelFp = v.screen;
5542
- log.info({ target: a.target, tail: v.screen.split("\n").slice(-8).join("\\n").slice(-600) }, "picker sentinel: numbered picker shape on pane but not judged modal");
5543
- }
5544
- }
5545
- else {
5546
- a.pickerSentinelFp = undefined;
5855
+ await withPaneInput(pane, async (live) => {
5856
+ const valid = () => guard() && live();
5857
+ if (!a.tmuxPane || a.migrationWatcher)
5858
+ return;
5859
+ // Codex PermissionRequest has its own independent native gate. Screen
5860
+ // options, tool windows and skipAll never authorize its sandbox.
5861
+ if (cliOf(a) === "codex")
5862
+ return;
5863
+ if (!(await tmuxPaneAlive(pane)) || !valid())
5864
+ return;
5865
+ const v = await detectModalPicker(pane);
5866
+ if (!valid())
5867
+ return;
5868
+ if (!v.modal) {
5869
+ // 审批卡在途而框已消失 = 本地先按掉了 → 作废卡等待 (无卡则 no-op)。
5870
+ mootMirrorPicker(a.sessionId);
5871
+ // 诊断: 屏上有高亮编号行却没判成 modal = 布局盲区候选 (askq/plan 面板
5872
+ // 也长这样, 属正常)。每屏记一次日志, 供远端排查"哨兵为何没反应"。
5873
+ if (looksLikePicker(v.screen)) {
5874
+ if (a.pickerSentinelFp !== v.screen) {
5875
+ a.pickerSentinelFp = v.screen;
5876
+ log.info({ target: a.target, tail: v.screen.split("\n").slice(-8).join("\\n").slice(-600) }, "picker sentinel: numbered picker shape on pane but not judged modal");
5547
5877
  }
5548
- continue;
5549
5878
  }
5550
- if (hasMirrorPicker(a.sessionId))
5551
- continue; // 审批卡在途, 等点击 / moot
5552
- if (a.pickerSentinelFp === v.screen)
5553
- continue; // 同一屏已处理过, 等它变化
5554
- a.pickerSentinelFp = v.screen;
5555
- const pick = pickAutoAllowAnswer(parseModalOptions(v.screen));
5556
- if (!pick) {
5557
- log.info({ target: a.target, title: v.title }, "picker sentinel: modal on pane but not a trustworthy permission picker — leaving it");
5558
- continue;
5559
- }
5560
- const ctx = parseConfirmContext(v.screen);
5561
- const policy = pickerPolicy(cfg, cliOf(a), ctx, isAutoWindowActive(a.target));
5562
- if (policy.decision === "leave") {
5563
- log.info({ target: a.target, reason: policy.reason }, "picker sentinel: independent approval left on CLI");
5879
+ else {
5880
+ a.pickerSentinelFp = undefined;
5564
5881
  }
5565
- await dispatchPickerPolicy(policy, {
5566
- deny: async () => {
5567
- const result = await pressNativePicker(a, modalFingerprint(v.screen), "no");
5568
- log.info({ target: a.target, reason: policy.reason, result }, "picker sentinel: deny rule");
5569
- sendStandalone(a, `[mirror] 🚫 ${policy.reason},${result.ok ? "已拒绝 CLI 确认" : "确认框已变更,请回 CLI 处理"}`);
5570
- },
5571
- allow: async () => {
5572
- const after = await pressModalIndex(a.tmuxPane, v, pick.index);
5573
- if (after.modal) {
5574
- // 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
5575
- a.pickerSentinelFp = after.screen;
5576
- log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
5577
- return;
5578
- }
5579
- a.pickerSentinelFp = undefined;
5580
- log.info({ target: a.target, title: v.title, via: policy.reason, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
5581
- sendStandalone(a, `[mirror] 🔓 ${policy.reason} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
5582
- },
5583
- manual: () => {
5584
- // 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
5585
- // 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
5586
- log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, reason: policy.reason }, "picker sentinel: forwarding native picker as approval card");
5587
- runMirrorPickerFlow({
5588
- log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
5589
- client,
5590
- cfg,
5591
- sessionId: a.sessionId,
5592
- chatKey: a.target,
5593
- cwd: a.runningCwd,
5594
- jsonlPath: a.jsonlPath,
5595
- toolName: ctx.toolName,
5596
- toolInput: ctx.toolInput,
5597
- agent: ctx.agent,
5598
- danger: policy.forceSingle,
5599
- voteTimeoutMs: cfg.approval.longPollSec * 1000,
5600
- press: (what) => pressNativePicker(a, modalFingerprint(v.screen), what),
5601
- }).then((sent) => {
5602
- // 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
5603
- // 否则这只框会被指纹永久静默。
5604
- if (!sent)
5605
- a.pickerSentinelFp = undefined;
5606
- }).catch((e) => log.warn({ err: e.message, target: a.target }, "mirror picker flow failed"));
5607
- },
5608
- });
5882
+ return;
5609
5883
  }
5610
- }
5611
- catch (e) {
5612
- log.warn({ err: e.message }, "picker sentinel tick failed");
5613
- }
5614
- finally {
5615
- pickerTicking = false;
5884
+ if (hasMirrorPicker(a.sessionId))
5885
+ return; // 审批卡在途, 等点击 / moot
5886
+ if (a.pickerSentinelFp === v.screen)
5887
+ return; // 同一屏已处理过, 等它变化
5888
+ a.pickerSentinelFp = v.screen;
5889
+ const pick = pickAutoAllowAnswer(parseModalOptions(v.screen));
5890
+ if (!pick) {
5891
+ log.info({ target: a.target, title: v.title }, "picker sentinel: modal on pane but not a trustworthy permission picker — leaving it");
5892
+ return;
5893
+ }
5894
+ const ctx = parseConfirmContext(v.screen);
5895
+ const policy = pickerPolicy(cfg, cliOf(a), ctx, isAutoWindowActive(a.target));
5896
+ if (policy.decision === "leave") {
5897
+ log.info({ target: a.target, reason: policy.reason }, "picker sentinel: independent approval left on CLI");
5898
+ }
5899
+ await dispatchPickerPolicy(policy, {
5900
+ deny: async () => {
5901
+ if (!valid())
5902
+ return;
5903
+ const result = await pressNativePickerUnlocked(a, modalFingerprint(v.screen), "no", valid);
5904
+ if (!valid())
5905
+ return;
5906
+ log.info({ target: a.target, reason: policy.reason, result }, "picker sentinel: deny rule");
5907
+ sendStandalone(a, `[mirror] 🚫 ${policy.reason},${result.ok ? "已拒绝 CLI 确认" : "确认框已变更,请回 CLI 处理"}`);
5908
+ },
5909
+ allow: async () => {
5910
+ if (!valid())
5911
+ return;
5912
+ const after = await pressModalIndex(pane, v, pick.index, valid);
5913
+ if (!valid())
5914
+ return;
5915
+ if (after.modal) {
5916
+ // 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
5917
+ a.pickerSentinelFp = after.screen;
5918
+ log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
5919
+ return;
5920
+ }
5921
+ a.pickerSentinelFp = undefined;
5922
+ log.info({ target: a.target, title: v.title, via: policy.reason, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
5923
+ sendStandalone(a, `[mirror] 🔓 ${policy.reason} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
5924
+ },
5925
+ manual: () => {
5926
+ if (!valid())
5927
+ return;
5928
+ // 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
5929
+ // 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
5930
+ log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, reason: policy.reason }, "picker sentinel: forwarding native picker as approval card");
5931
+ runMirrorPickerFlow({
5932
+ log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
5933
+ client,
5934
+ cfg,
5935
+ sessionId: a.sessionId,
5936
+ chatKey: a.target,
5937
+ cwd: a.runningCwd,
5938
+ jsonlPath: a.jsonlPath,
5939
+ toolName: ctx.toolName,
5940
+ toolInput: ctx.toolInput,
5941
+ agent: ctx.agent,
5942
+ danger: policy.forceSingle,
5943
+ voteTimeoutMs: cfg.approval.longPollSec * 1000,
5944
+ // Human waiting is outside the lock; the eventual press obtains a new transaction.
5945
+ press: (what) => pressNativePicker(a, modalFingerprint(v.screen), what, guard),
5946
+ }).then((sent) => {
5947
+ // 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
5948
+ // 否则这只框会被指纹永久静默。
5949
+ if (!sent && guard())
5950
+ a.pickerSentinelFp = undefined;
5951
+ }).catch((e) => {
5952
+ if (guard())
5953
+ a.pickerSentinelFp = undefined;
5954
+ log.warn({ err: e.message, target: a.target }, "mirror picker flow failed");
5955
+ });
5956
+ },
5957
+ });
5958
+ }, { guard });
5959
+ };
5960
+ const pickerTick = () => {
5961
+ if (!cfg.approval.enabled || process.env.WEZARD_PICKER_SENTINEL === "0")
5962
+ return;
5963
+ for (const a of byTarget.values()) {
5964
+ if (!a.tmuxPane || cliOf(a) === "codex")
5965
+ continue;
5966
+ const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
5967
+ void maintenance.schedule(`picker:${a.target}`, pane, (signal) => pickerOne(a, pane, sid, gen, signal))
5968
+ ?.catch((e) => {
5969
+ if (maintenanceValid(a, pane, sid, gen, maintenance.signal))
5970
+ a.pickerSentinelFp = undefined;
5971
+ log.warn({ target: a.target, err: e.message }, "picker sentinel tick failed");
5972
+ });
5616
5973
  }
5617
5974
  };
5618
5975
  const pickerTimer = setInterval(() => void pickerTick(), 5_000);
5619
5976
  // 审批卡点击后的代按回调。点击与本地按键之间隔着人的反应时间, 必须重读屏并
5620
5977
  // 校验仍是**同一个调用**的确认框 —— 期间本地按掉旧框又弹了新框的话, 盲按就是
5621
5978
  // 替用户批准了他没看过的东西。
5622
- const pressNativePicker = async (a, expectFingerprint, what) => {
5623
- if (!a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
5979
+ const pressNativePickerUnlocked = async (a, expectFingerprint, what, live) => {
5980
+ if (!live() || !a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
5624
5981
  return { ok: false, reason: "pane 已不在" };
5625
5982
  if (cliOf(a) === "codex")
5626
5983
  return { ok: false, reason: "Codex 原生沙箱审批必须单独处理" };
5627
5984
  const v = await detectModalPicker(a.tmuxPane);
5985
+ if (!live())
5986
+ return { ok: false, reason: "pane 事务已撤回" };
5628
5987
  if (!v.modal)
5629
5988
  return { ok: false, reason: "确认框已不在(可能已在本地处理)" };
5630
5989
  if (pickerPolicy(cfg, cliOf(a), parseConfirmContext(v.screen), false).decision === "leave") {
@@ -5645,11 +6004,12 @@ export const startMirror = (deps) => {
5645
6004
  const pick = sess ?? bare;
5646
6005
  if (!pick)
5647
6006
  return { ok: false, reason: "确认框上找不到可按的 Yes 选项" };
5648
- const after = await pressModalIndex(a.tmuxPane, v, pick.index);
6007
+ const after = await pressModalIndex(a.tmuxPane, v, pick.index, live);
5649
6008
  return after.modal
5650
6009
  ? { ok: false, reason: `按下 ${pick.index} 后确认框仍在` }
5651
6010
  : { ok: true, label: `${pick.index}. ${pick.label}` };
5652
6011
  };
6012
+ const pressNativePicker = async (a, fingerprint, what, guard) => withPaneInput(a.tmuxPane, (live) => pressNativePickerUnlocked(a, fingerprint, what, live), { guard });
5653
6013
  return {
5654
6014
  attach,
5655
6015
  chatTargets,
@@ -5873,35 +6233,24 @@ export const startMirror = (deps) => {
5873
6233
  // to in this process lifetime (or any session after a reload) has an empty
5874
6234
  // in-memory slot but a perfectly good persisted binding. Without this, an
5875
6235
  // agent driving a cold sibling gets "not attached" for a live pane.
5876
- const a = byTarget.get(target) ?? (await restoreFromStore(target));
5877
- if (!a)
5878
- return { ok: false, reason: "no mirror attached for target" };
6236
+ const permission = () => !opts?.guard || opts.guard();
6237
+ const denied = { ok: false, retryable: true, reason: "inject permission withdrawn before respawn/binding" };
5879
6238
  if (!text.trim())
5880
- return { ok: false, reason: "empty text" };
5881
- // 出处改写要早于 inject: tail 可能在 promise resolve 之前就开出 turn。
5882
- // 守护进程注入的一轮 (回执 / peer / 定时任务 / graph) 不是人在 CLI 手敲的: 可见性
5883
- // 只由频道定 ("" 私聊才静默)。pendingFromCli 必须一并清 —— 它是粘性的, 不开轮的
5884
- // CLI 行 (spawn 时 model-select 按下的 `/model`) 会把它一直挂到下一轮, 让紧跟着的
5885
- // 注入轮被认成 CLI 发起, 公开频道里的回复因此被出处门吞掉。
5886
- a.pendingFromCli = false;
5887
- a.turnFromChat = true;
5888
- // 名册增量搭这趟车进去 —— 同伴派活 / 定时任务 / graph 步骤都走这里, 所以
5889
- // 一个从不被人直接说话的分身也能知道群里多了谁、少了谁。人说的话那一条
5890
- // 路径在 inbound.send 上挂 (dispatch 有自己的 inject)。
5891
- // 信封 (谁说的、私聊还是公开、回执怎么交) 同理挂在尾巴上; slash 命令按行解析,
5892
- // 多挂一段就不再是命令, 跳过。`pendingBriefQuery` 记的仍是不带信封的原话。
5893
- const envelope = text.trimStart().startsWith("/") ? "" : opts?.envelope ?? "";
5894
- const full = text + envelope + noticeSuffixFor(target, text);
5895
- // Pre-record so the tail's user-line emission is suppressed by the
5896
- // recentInjects dedupe (otherwise the user sees their own demo prompt
5897
- // echoed back as a quoted bubble).
5898
- rememberInject(full);
5899
- // 印章要早于 inject 落地 —— tail 是独立轮询的, 它可能在 inject 的 promise
5900
- // resolve 之前就看到 user 行并开出 turn, 那时归因必须已经在位。
5901
- // 印章随这一行走 (见 Seal): 行露面时才落成那一轮的出处 / 频道 / 问话。
5902
- pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
6239
+ return { ok: false, retryable: true, reason: "empty text" };
6240
+ if (!permission())
6241
+ return denied;
6242
+ const a = byTarget.get(target) ?? (await restoreFromStore(target, undefined, permission));
6243
+ if (!permission())
6244
+ return denied;
6245
+ if (!a)
6246
+ return { ok: false, retryable: true, reason: "no mirror attached for target" };
5903
6247
  const sid = a.sessionId;
6248
+ const generation = a.injectGen ?? 0;
6249
+ const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
6250
+ const probedPane = a.tmuxPane;
5904
6251
  const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
6252
+ if (!permitted() || a.tmuxPane !== probedPane)
6253
+ return denied;
5905
6254
  // 同 dispatch: 只有刚生 / 刚重生的 pane 才要冷启动的宽限 —— 对一个热着、正忙的 pane 贴 `now`
5906
6255
  // 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
5907
6256
  let fresh = a.justSpawned === true;
@@ -5912,7 +6261,16 @@ export const startMirror = (deps) => {
5912
6261
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
5913
6262
  const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: displayName(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, ...respawnSpec(a), systemPrompt: charterFor(target, { cwd: a.runningCwd, cli: backendForPath(a.jsonlPath).name }) });
5914
6263
  if (!r.ok || !r.tmuxPane)
5915
- return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
6264
+ return { ok: false, retryable: true, reason: `respawn failed: ${r.reason ?? "unknown"}` };
6265
+ if (!permitted() || a.tmuxPane !== probedPane) {
6266
+ // This pane was born for an unsubmitted injection. Never kill the old
6267
+ // executor or bind the withdrawn birth. A failed cleanup is explicitly
6268
+ // uncertain and must be reconciled; it is not proof execution stopped.
6269
+ const cleanup = await runTmux(["kill-pane", "-t", r.tmuxPane]).catch(() => undefined);
6270
+ return { ...denied, ...(cleanup?.ok ? {} : { uncertain: true, retryable: false }), reason: cleanup?.ok
6271
+ ? "respawn permission withdrawn; new pane removed before injection, binding withheld"
6272
+ : `respawn permission withdrawn; binding withheld, reconcile new pane ${r.tmuxPane}` };
6273
+ }
5916
6274
  a.tmuxPane = r.tmuxPane;
5917
6275
  a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
5918
6276
  if (r.cwd)
@@ -5925,18 +6283,34 @@ export const startMirror = (deps) => {
5925
6283
  startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
5926
6284
  fresh = true;
5927
6285
  }
5928
- // 同 dispatch: 在 inject 之前解除静默, 防止验证循环中 LLM 回复被永久吞掉。
5929
- a.muteUntilInject = false;
5930
- a.justSpawned = false;
6286
+ if (!permitted())
6287
+ return denied;
6288
+ // Busy/queued retries must not consume notices or stamp a future turn.
6289
+ // Render only after the pane lane grants delivery, then stamp exactly at
6290
+ // the first permitted input. activePeerTurn still changes in bindLine.
6291
+ const envelope = text.trimStart().startsWith("/") ? "" : opts?.envelope ?? "";
6292
+ let full = text + envelope;
6293
+ const beforeInput = () => {
6294
+ a.pendingFromCli = false;
6295
+ a.turnFromChat = true;
6296
+ rememberInject(full);
6297
+ pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
6298
+ a.muteUntilInject = false;
6299
+ a.justSpawned = false;
6300
+ };
5931
6301
  // 同 dispatch 的 /clear 与静默分叉跟随: self-handoff / 同伴 / 定时任务也走这里注入,
5932
6302
  // 漏了它 tail 就钉在旧 jsonl 上, 直到下一条人话经 dispatch 才把它拽回来。
5933
6303
  const clearing = isClearCommand(text);
5934
6304
  const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
6305
+ const injectPane = a.tmuxPane;
5935
6306
  const r = await inject({
5936
- text: full, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
6307
+ text: full, images: [], cfg, guard: (phase) => permitted() && a.tmuxPane === injectPane && (!opts?.guard || opts.guard(phase)), log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
5937
6308
  sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
6309
+ requireIdle: opts?.requireIdle ? () => idleVerdict(target) === true : undefined,
6310
+ prepareText: () => (full = text + envelope + noticeSuffixFor(target, text)),
6311
+ beforeInput,
5938
6312
  });
5939
- if (r.ok && a.tmuxPane) {
6313
+ if (r.ok && a.tmuxPane && permitted()) {
5940
6314
  if (preClearBaseline) {
5941
6315
  a.clearRebind = { baseline: preClearBaseline };
5942
6316
  startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
@@ -6252,6 +6626,7 @@ export const startMirror = (deps) => {
6252
6626
  clearInterval(paneDriftTimer);
6253
6627
  clearInterval(keepaliveTimer);
6254
6628
  clearInterval(pickerTimer);
6629
+ maintenance.stop();
6255
6630
  for (const a of bySessionId.values()) {
6256
6631
  if (a.outbound?.kind === "deferred")
6257
6632
  clearTimeout(a.outbound.timer);
@@ -6269,6 +6644,8 @@ export const startMirror = (deps) => {
6269
6644
  return a.convFrom.asker;
6270
6645
  return a?.speaker ? { who: a.speaker, chat: a.channel ?? baseOfKey(target) } : undefined;
6271
6646
  },
6647
+ answerContext, prepareAnswer, takeAnswer, cancelAnswerPreparation, stopAnswerProgress, deliverAnswer,
6648
+ setAnswerSilencer: (fn) => { silenceAnswer = fn; },
6272
6649
  currentChannel: (target) => {
6273
6650
  const a = byTarget.get(target);
6274
6651
  return a?.channel ?? baseOfKey(target);
@@ -6406,7 +6783,7 @@ export const startMirror = (deps) => {
6406
6783
  if (s) {
6407
6784
  a.liveStream = s;
6408
6785
  try {
6409
- await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
6786
+ await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
6410
6787
  }
6411
6788
  catch (e) {
6412
6789
  log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
@@ -6503,6 +6880,8 @@ export const startMirror = (deps) => {
6503
6880
  log.warn({ target: a.target, sessionId: sid, myGen, gen: a.injectGen }, "inject aborted — superseded by /stop or watchdog");
6504
6881
  return;
6505
6882
  }
6883
+ if (!brief)
6884
+ pushSeal(a, { keys: sealKeys(effText), at: Date.now(), query: effText, channel: channel ?? baseOfKey(principal), speaker, fresh: true });
6506
6885
  rememberInject(effText);
6507
6886
  // 解除静默必须在 inject 之前: inject 内部的 freshSpawn 验证循环可达 10+s
6508
6887
  // (paste-verify + settle + waitForCleared + retry), 而 LLM 的回复可能在 Enter
@@ -6511,9 +6890,11 @@ export const startMirror = (deps) => {
6511
6890
  // 安全性: newSession 的 fresh pane 在 inject 前只写过 system/init (→ 无 item),
6512
6891
  // 用户行有 isOwnInject 过滤 —— 提前解除不会泄漏脏数据。
6513
6892
  a.muteUntilInject = false;
6893
+ const injectPane = a.tmuxPane;
6514
6894
  const r = await inject({
6515
6895
  text: effText, images: effImages, cfg, log: log.child({ principal, sessionId: sid }),
6516
6896
  sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
6897
+ guard: () => byTarget.get(a.target) === a && a.tmuxPane === injectPane && (a.injectGen ?? 0) === myGen,
6517
6898
  });
6518
6899
  if (!r.ok) {
6519
6900
  if (s) {
@@ -6577,10 +6958,7 @@ export const startMirror = (deps) => {
6577
6958
  // and surface a standalone "cleared" so the user gets explicit
6578
6959
  // feedback (the skip-stream path otherwise leaves WeCom silent).
6579
6960
  if (armMigration) {
6580
- // cwd 不随回执下发 (/pwd 可查); slashAckFirstLine 连 tip 也省去 (与 /new 一致)。
6581
- sendStandalone(a, cfg.wrc.mirror.slashAckFirstLine
6582
- ? "cleared"
6583
- : `cleared\n\n${randomTip()}`);
6961
+ sendStandalone(a, "cleared");
6584
6962
  a.clearRebind = { baseline: preClearBaseline };
6585
6963
  startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
6586
6964
  }