wezard 2.6.2 → 2.6.4

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 (81) 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/bot-mux.js +4 -0
  8. package/dist/daemon/bot-mux.js.map +1 -1
  9. package/dist/daemon/cfg-sync.js +1 -1
  10. package/dist/daemon/cfg-sync.js.map +1 -1
  11. package/dist/daemon/chat-name.js +4 -4
  12. package/dist/daemon/chat-name.js.map +1 -1
  13. package/dist/daemon/config-api.js +2 -2
  14. package/dist/daemon/config-api.js.map +1 -1
  15. package/dist/daemon/image-input.js +54 -0
  16. package/dist/daemon/image-input.js.map +1 -0
  17. package/dist/daemon/inbound.js +7 -13
  18. package/dist/daemon/inbound.js.map +1 -1
  19. package/dist/daemon/index.js +1134 -331
  20. package/dist/daemon/index.js.map +1 -1
  21. package/dist/daemon/jobs.js +39 -15
  22. package/dist/daemon/jobs.js.map +1 -1
  23. package/dist/daemon/last-response.js +37 -7
  24. package/dist/daemon/last-response.js.map +1 -1
  25. package/dist/daemon/message-events.js +361 -0
  26. package/dist/daemon/message-events.js.map +1 -0
  27. package/dist/daemon/message-format.js +40 -0
  28. package/dist/daemon/message-format.js.map +1 -0
  29. package/dist/daemon/mirror-bridge.js +847 -343
  30. package/dist/daemon/mirror-bridge.js.map +1 -1
  31. package/dist/daemon/pane-input.js +48 -0
  32. package/dist/daemon/pane-input.js.map +1 -0
  33. package/dist/daemon/peers.js +17 -16
  34. package/dist/daemon/peers.js.map +1 -1
  35. package/dist/daemon/pending-items.js +8 -0
  36. package/dist/daemon/pending-items.js.map +1 -1
  37. package/dist/daemon/receipts.js +427 -139
  38. package/dist/daemon/receipts.js.map +1 -1
  39. package/dist/daemon/scheduled-delivery.js +52 -0
  40. package/dist/daemon/scheduled-delivery.js.map +1 -0
  41. package/dist/daemon/steward-model.js +10 -10
  42. package/dist/daemon/steward-model.js.map +1 -1
  43. package/dist/daemon/tasks.js +83 -27
  44. package/dist/daemon/tasks.js.map +1 -1
  45. package/dist/daemon/tmux-buffer.js +16 -6
  46. package/dist/daemon/tmux-buffer.js.map +1 -1
  47. package/dist/daemon/weixin-cmd.js +2 -4
  48. package/dist/daemon/weixin-cmd.js.map +1 -1
  49. package/dist/daemon/weixin.js +111 -7
  50. package/dist/daemon/weixin.js.map +1 -1
  51. package/dist/daemon/wizard.js +2 -1
  52. package/dist/daemon/wizard.js.map +1 -1
  53. package/dist/daemon/workflow-nodes.js +217 -0
  54. package/dist/daemon/workflow-nodes.js.map +1 -0
  55. package/dist/daemon/workflow-plan-http.js +83 -0
  56. package/dist/daemon/workflow-plan-http.js.map +1 -0
  57. package/dist/daemon/workflow-plans.js +274 -0
  58. package/dist/daemon/workflow-plans.js.map +1 -0
  59. package/dist/mcp/server.js +27 -1
  60. package/dist/mcp/server.js.map +1 -1
  61. package/dist/shared/cli-backends.js.map +1 -1
  62. package/dist/shared/codex.js +1 -1
  63. package/dist/shared/codex.js.map +1 -1
  64. package/dist/shared/config.js +12 -10
  65. package/dist/shared/config.js.map +1 -1
  66. package/dist/shared/detail-store.js.map +1 -1
  67. package/dist/shared/json-map-store.js +46 -9
  68. package/dist/shared/json-map-store.js.map +1 -1
  69. package/dist/shared/keyed-scheduler.js +51 -0
  70. package/dist/shared/keyed-scheduler.js.map +1 -0
  71. package/dist/shared/message-footer.js +34 -0
  72. package/dist/shared/message-footer.js.map +1 -0
  73. package/dist/shared/model-tiers.js +1 -2
  74. package/dist/shared/model-tiers.js.map +1 -1
  75. package/dist/shared/reminder.js +9 -1
  76. package/dist/shared/reminder.js.map +1 -1
  77. package/dist/shared/session-label.js +19 -1
  78. package/dist/shared/session-label.js.map +1 -1
  79. package/dist/shared/workflow-plan.js +36 -0
  80. package/dist/shared/workflow-plan.js.map +1 -0
  81. package/package.json +1 -1
@@ -1,3 +1,5 @@
1
+ import { finalId } from "./message-events.js";
2
+ import { parseEnvelope } from "../shared/reminder.js";
1
3
  import { codexSessions, codexSessionForPane } from "../shared/codex.js";
2
4
  import { codexHookLaunchArgs, codexHookCommand } from "../shared/codex-hooks.js";
3
5
  import { fileURLToPath } from "node:url";
@@ -24,6 +26,9 @@ import { newStewardSpec, resumedStewardSpec } from "./steward-model.js";
24
26
  import { expandHome, sanitizeId } from "../shared/paths.js";
25
27
  import { augmentedPath } from "../shared/exec-path.js";
26
28
  import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
29
+ import { withImageClipboard, pasteVerifiedImage } from "./image-input.js";
30
+ import { createKeyedScheduler } from "../shared/keyed-scheduler.js";
31
+ import { withPaneInput } from "./pane-input.js";
27
32
  import { activeBackends, resolveCliBackend, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
28
33
  import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, modalFingerprint, looksLikePicker } from "../shared/modal-pane.js";
29
34
  import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
@@ -37,12 +42,11 @@ import { hasRegistry, liveSessionPids, markTranscript, probeOf, sessionOnPane, s
37
42
  import { waitForIdle } from "./graph.js";
38
43
  import { wizardStore } from "./wizard.js";
39
44
  import { startSubagentWatch } from "./subagent-tail.js";
40
- import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
45
+ import { getDetail, knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
41
46
  import { errText } from "./last-response.js";
42
- import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey, sameBot } from "../shared/session-label.js";
47
+ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, parseTagHeader, MAX_BODY_LINKS, isInternalKey, sameBot } from "../shared/session-label.js";
43
48
  import { splitMarkdown } from "../shared/md-chunk.js";
44
49
  import { isWxFrame } from "../shared/wx-text.js";
45
- import { randomTip } from "./tips.js";
46
50
  import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
47
51
  import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
48
52
  import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
@@ -524,7 +528,7 @@ const renderLine = (raw, deps) => {
524
528
  if (line.type === "attachment" && line.attachment?.type === "queued_command" && typeof line.attachment.prompt === "string") {
525
529
  const said = cleanUserText(line.attachment.prompt);
526
530
  const ts = Date.parse(line.timestamp ?? "");
527
- return said ? [{ kind: "user_inject", said, ts: Number.isNaN(ts) ? Date.now() : ts }] : [];
531
+ return said ? [{ kind: "user_inject", said, eventId: parseEnvelope(line.attachment.prompt)?.eventId, ts: Number.isNaN(ts) ? Date.now() : ts }] : [];
528
532
  }
529
533
  const out = [];
530
534
  if (line.type === "user") {
@@ -574,8 +578,9 @@ const renderLine = (raw, deps) => {
574
578
  if (deps.isKeepalivePing?.(text))
575
579
  return [{ kind: "keepalive_start", body: text }];
576
580
  // 自己注入的那一行不回显, 但要报个到: 它的印章 (谁说的、回复去哪) 在这一刻认领。
577
- if (deps.isOwnInject(text))
578
- return [{ kind: "user_query", body: unwrapPasted(c).trim(), said: text, ts: lineTs(line) }];
581
+ const eventId = c.includes('wezard="message-event"') ? parseEnvelope(c)?.eventId : undefined;
582
+ if (eventId || deps.isOwnInject(text))
583
+ return [{ kind: "user_query", eventId, body: unwrapPasted(c).trim(), said: text, ts: lineTs(line) }];
579
584
  const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
580
585
  // includeUser 只决定"渲不渲染", 不决定"算不算边界" —— quiet 的这一条照样发出,
581
586
  // onItem 消费完边界语义后自己丢掉。
@@ -586,11 +591,11 @@ const renderLine = (raw, deps) => {
586
591
  // 回显去重对得上; 记进详情页的则是带占位符的原样文本, 图本身不画。
587
592
  const raw = c.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("\n");
588
593
  const text = cleanUserText(raw.replace(IMAGE_REF_RE, ""));
589
- if (text && !deps.isOwnInject(text)) {
594
+ if (text && !parseEnvelope(raw)?.eventId && !deps.isOwnInject(text)) {
590
595
  out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser, ts: lineTs(line) });
591
596
  }
592
597
  else {
593
- out.push({ kind: "user_query", body: unwrapPasted(raw).trim(), said: text, ts: lineTs(line) });
598
+ out.push({ kind: "user_query", eventId: parseEnvelope(raw)?.eventId, body: unwrapPasted(raw).trim(), said: text, ts: lineTs(line) });
594
599
  }
595
600
  }
596
601
  else if (Array.isArray(c)) {
@@ -1159,10 +1164,10 @@ const detectModalPicker = async (target) => {
1159
1164
  const MODAL_PRESS_SETTLE_MS = 400;
1160
1165
  const MODAL_ARROW_SETTLE_MS = 150;
1161
1166
  const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
1162
- const pressModalIndex = async (pane, before, index) => {
1167
+ const pressModalIndex = async (pane, before, index, live = () => true) => {
1163
1168
  const fingerprint = modalFingerprint(before.screen);
1164
1169
  const current = await detectModalPicker(pane);
1165
- if (!current.modal || modalFingerprint(current.screen) !== fingerprint)
1170
+ if (!live() || !current.modal || modalFingerprint(current.screen) !== fingerprint)
1166
1171
  return current;
1167
1172
  await runTmux(["send-keys", "-t", pane, String(index)]);
1168
1173
  await sleep(MODAL_PRESS_SETTLE_MS);
@@ -1171,6 +1176,8 @@ const pressModalIndex = async (pane, before, index) => {
1171
1176
  return after;
1172
1177
  // 数字键没关掉框: 把高亮挪到目标项。guard 上限盖过任何确认框的选项数。
1173
1178
  for (let guard = 0; guard < 8; guard++) {
1179
+ if (!live())
1180
+ return after;
1174
1181
  const cur = highlightedIndex(after.screen);
1175
1182
  if (cur === undefined || cur === index)
1176
1183
  break;
@@ -1180,7 +1187,7 @@ const pressModalIndex = async (pane, before, index) => {
1180
1187
  if (!after.modal || modalFingerprint(after.screen) !== fingerprint)
1181
1188
  return after;
1182
1189
  }
1183
- if (highlightedIndex(after.screen) !== index)
1190
+ if (!live() || highlightedIndex(after.screen) !== index)
1184
1191
  return after; // 核不准, 绝不盲按
1185
1192
  await runTmux(["send-keys", "-t", pane, "Enter"]);
1186
1193
  await sleep(MODAL_PRESS_SETTLE_MS);
@@ -1301,7 +1308,7 @@ export const parseInputBox = (cap, styledLayout = cap) => {
1301
1308
  layout.pop();
1302
1309
  const footer = layout.length - 1;
1303
1310
  // Codex hides the shortcuts row while the composer contains text.
1304
- const shortcuts = /\? for shortcuts\s*$/u.test(layout[footer] ?? "");
1311
+ const shortcuts = /(?:^|\s)\? for shortcuts(?:\s|$)/u.test(layout[footer] ?? "");
1305
1312
  if (shortcuts || (layout[footer] ?? "").includes("·")) {
1306
1313
  let status = shortcuts ? footer - 1 : footer;
1307
1314
  while (status >= 0 && !layout[status].trim())
@@ -1433,7 +1440,7 @@ const witnessFor = (target, homeDir, jsonlPath, live) => {
1433
1440
  // but the trailing Enter is eaten while the TUI is still initializing, so
1434
1441
  // the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
1435
1442
  // 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
1436
- const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false) => {
1443
+ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard) => {
1437
1444
  const backendName = backend.name;
1438
1445
  log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
1439
1446
  // 就绪门: pane 里得真有一个进入了交互态的 AI 会话, 才轮得到贴。先于 modal guard ——
@@ -1474,33 +1481,53 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1474
1481
  // dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
1475
1482
  // withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
1476
1483
  if (backendName === "codebuddy") {
1477
- return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness);
1484
+ return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, guard);
1478
1485
  }
1479
1486
  // Pump images first via clipboard+C-v so each one is attached as a separate
1480
1487
  // image content block. Each C-v needs a brief settle for Claude Code's TUI
1481
1488
  // to read the clipboard before the next overwrite. Fresh spawn extends.
1482
1489
  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;
1490
+ const pumped = images.length ? await withImageClipboard(async () => {
1491
+ for (const imgPath of images) {
1492
+ const paste = async () => {
1493
+ const cb = await setMacClipboardImage(imgPath);
1494
+ if (!cb.ok)
1495
+ return cb;
1496
+ if (guard && !guard("paste"))
1497
+ return { ok: false, reason: "image paste canceled by dispatch guard" };
1498
+ const cv = await runTmux(["send-keys", "-t", target, "C-v"]);
1499
+ return cv.ok ? { ok: true } : { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
1500
+ };
1501
+ // Cold Codex can ignore C-v before its composer exists. Do not submit
1502
+ // the text until the composer proves this attachment has landed.
1503
+ const result = backendName === "codex"
1504
+ ? await pasteVerifiedImage({ read: () => readInputBox(target), paste, valid: () => !guard || guard("paste") })
1505
+ : await paste();
1506
+ if (!result.ok) {
1507
+ log.warn({ target, imgPath, reason: result.reason }, "mirror inject: image not confirmed, refusing text submission");
1508
+ return result;
1509
+ }
1510
+ // Attachment rendering can precede the TUI finishing its paste handler.
1511
+ // Preserve the key settle interval before another C-v or text paste.
1512
+ await sleep(IMG_SETTLE_MS);
1513
+ log.info({ target, imgPath, verified: backendName === "codex" }, "mirror inject: image attached");
1488
1514
  }
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
- }
1515
+ return { ok: true };
1516
+ }) : { ok: true };
1517
+ if (!pumped.ok)
1518
+ return { ...pumped, uncertain: true };
1494
1519
  // Image-only message: TUI input box now holds the attached images; press
1495
1520
  // Enter and we're done. No fingerprint to verify (the input itself is binary
1496
1521
  // image refs, not text).
1497
1522
  if (!text) {
1498
1523
  if (images.length === 0)
1499
1524
  return { ok: true };
1525
+ if (guard && !guard("submit"))
1526
+ return { ok: false, reason: "submit canceled by dispatch guard" };
1500
1527
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1501
1528
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
1502
1529
  }
1503
- return injectViaTmuxText(target, text, log, freshSpawn, witness);
1530
+ return injectViaTmuxText(target, text, log, freshSpawn, witness, guard);
1504
1531
  };
1505
1532
  // 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
1506
1533
  // 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
@@ -1522,7 +1549,7 @@ const withImageRefs = (text, images) => {
1522
1549
  // 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
1523
1550
  // 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
1524
1551
  // 每次补之前再问一遍证人, 已提交就不补。
1525
- const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
1552
+ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard) => {
1526
1553
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1527
1554
  // started, transcript still loading): extended timings — bracketed-paste
1528
1555
  // end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
@@ -1559,7 +1586,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1559
1586
  const baseBody = alreadyTyped ? "" : preBox.body;
1560
1587
  /** 框里还是贴之前那样 (或彻底空了) = 我们的文本不在框里。 */
1561
1588
  const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === baseBody);
1562
- const loadAndPaste = () => pasteTmuxBuffer(target, text);
1589
+ const loadAndPaste = () => pasteTmuxBuffer(target, text, guard ? () => guard("paste") : undefined);
1563
1590
  // 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
1564
1591
  // 头部可能在框顶, 所以窗口要宽)。
1565
1592
  const sawLanded = async (timeoutMs) => {
@@ -1583,6 +1610,9 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1583
1610
  let pasteSeen = alreadyTyped;
1584
1611
  let pastedAt = Date.now();
1585
1612
  if (alreadyTyped) {
1613
+ // Existing text is already exposed, even if permission was just withdrawn.
1614
+ if (guard && !guard("paste"))
1615
+ return { ok: false, uncertain: true, reason: "existing input submission canceled by dispatch guard" };
1586
1616
  log.warn({ target, headFp }, "mirror inject: 输入框里已有这段文本 (上次未提交) — 不重贴, 直接提交");
1587
1617
  }
1588
1618
  else {
@@ -1647,6 +1677,8 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1647
1677
  await sleep(Math.max(0, POST_PASTE_SETTLE_FALLBACK_MS - (Date.now() - pastedAt)));
1648
1678
  }
1649
1679
  const sendEnter = async () => {
1680
+ if (guard && !guard("submit"))
1681
+ return { ok: false, reason: "submit canceled by dispatch guard" };
1650
1682
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1651
1683
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
1652
1684
  };
@@ -1749,6 +1781,8 @@ const injectViaSpawn = async (args) => {
1749
1781
  ...cfg.wrc.extraArgs,
1750
1782
  ];
1751
1783
  log.info({ sessionId, bin, len: text.length }, "mirror inject");
1784
+ if (args.guard && !args.guard("submit"))
1785
+ return { ok: false, reason: "spawn canceled by dispatch guard" };
1752
1786
  return new Promise((resolve) => {
1753
1787
  const proc = spawn(bin, cliArgs, {
1754
1788
  cwd,
@@ -1778,11 +1812,58 @@ const injectViaSpawn = async (args) => {
1778
1812
  });
1779
1813
  });
1780
1814
  };
1781
- const inject = (args) => {
1815
+ const inject = async (args) => {
1782
1816
  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);
1817
+ if (!target) {
1818
+ if (args.requireIdle)
1819
+ return { ok: false, retryable: true, reason: "idle delivery requires a live pane" };
1820
+ try {
1821
+ if (args.guard && !args.guard())
1822
+ return { ok: false, retryable: true, reason: "spawn permission withdrawn" };
1823
+ const text = args.prepareText?.() ?? args.text;
1824
+ let activated = false;
1825
+ const result = await injectViaSpawn({ ...args, text, guard: (phase) => {
1826
+ if (args.guard && !args.guard(phase))
1827
+ return false;
1828
+ if (phase && !activated) {
1829
+ activated = true;
1830
+ args.beforeInput?.();
1831
+ }
1832
+ return true;
1833
+ } });
1834
+ return result.ok ? result : { ...result, uncertain: true };
1835
+ }
1836
+ catch (error) {
1837
+ return { ok: false, uncertain: true, reason: String(error) };
1838
+ }
1839
+ }
1840
+ let exposed = false;
1841
+ const classify = (result) => result.ok ? result
1842
+ : result.uncertain || exposed ? { ...result, uncertain: true, retryable: false }
1843
+ : { ...result, retryable: true };
1844
+ try {
1845
+ return classify(await withPaneInput(target, async (live) => {
1846
+ // Busy and unknown are both a deferred delivery. Never hold the input
1847
+ // lock while waiting for a model's whole turn to finish.
1848
+ const idleApproved = !args.requireIdle || await args.requireIdle();
1849
+ if (!idleApproved)
1850
+ return { ok: false, reason: "target has no confirmed idle evidence" };
1851
+ const text = args.prepareText?.() ?? args.text;
1852
+ const guard = (phase) => {
1853
+ if (!live() || (!exposed && !idleApproved) || (args.guard && !args.guard(phase)))
1854
+ return false;
1855
+ if (phase && !exposed) {
1856
+ exposed = true;
1857
+ args.beforeInput?.();
1858
+ }
1859
+ return true;
1860
+ };
1861
+ return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard);
1862
+ }, { guard: args.guard }));
1863
+ }
1864
+ catch (error) {
1865
+ return classify({ ok: false, reason: String(error) });
1866
+ }
1786
1867
  };
1787
1868
  const queues = new Map();
1788
1869
  /** Ceiling for one whole inject job (respawn + paste + submit verification).
@@ -1967,6 +2048,7 @@ export const startMirror = (deps) => {
1967
2048
  turnRegistry.delete(k);
1968
2049
  };
1969
2050
  const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
2051
+ let eventFinalGate = () => true;
1970
2052
  const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
1971
2053
  const newMarkId = () => `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
1972
2054
  const detailCardFor = (s, target) => {
@@ -1989,7 +2071,11 @@ export const startMirror = (deps) => {
1989
2071
  const content = s.acc;
1990
2072
  s.lastSent = content;
1991
2073
  try {
1992
- await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
2074
+ await (s.pending = (s.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2075
+ if (s.closed || s.dead)
2076
+ return;
2077
+ await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
2078
+ }));
1993
2079
  log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
1994
2080
  }
1995
2081
  catch (e) {
@@ -2030,6 +2116,7 @@ export const startMirror = (deps) => {
2030
2116
  if (!s.dead && body.trim()) {
2031
2117
  const card = detailCardFor(s, a.target);
2032
2118
  try {
2119
+ await s.pending?.catch(() => undefined);
2033
2120
  if (card) {
2034
2121
  s.cardSent = true;
2035
2122
  await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
@@ -2079,7 +2166,7 @@ export const startMirror = (deps) => {
2079
2166
  // 头挂 chat 详情页的票据: 本轮那条 turn。没有活 turn 可挂 → undefined, 头退回裸
2080
2167
  // `emoji #tag` (withLinkedTagHeader 自己兜)。
2081
2168
  const chatTurnUrl = (target, turnId) => turnId
2082
- ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target))
2169
+ ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target), target)
2083
2170
  : undefined;
2084
2171
  // `[mirror]` / `[wezard]` 系统提示不是答给谁的话, 不标方向。
2085
2172
  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 +2181,21 @@ export const startMirror = (deps) => {
2094
2181
  const sendStandalone = (a, content) => {
2095
2182
  if (!hasVisibleBody(content))
2096
2183
  return;
2184
+ if (answerSilent(a))
2185
+ return;
2097
2186
  if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
2098
2187
  return;
2099
2188
  const chatId = replyChatId(a);
2100
- const turnId = a.briefTurnId; // 推送是排队异步的, 到发出时 turn 早已收口
2189
+ const turnId = a.briefTurnId ?? a.liveStream?.turnId; // 推送是排队异步的, 到发出时 turn 早已收口
2101
2190
  const pieces = splitMarkdown(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
2102
2191
  const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
2103
2192
  a.standalonePending = a.standalonePending
2104
2193
  .then(async () => {
2194
+ if (silenceAnswer(a.target, turnId ?? ""))
2195
+ return;
2105
2196
  for (const c of chunks) {
2197
+ if (silenceAnswer(a.target, turnId ?? ""))
2198
+ return;
2106
2199
  try {
2107
2200
  const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
2108
2201
  log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
@@ -2119,9 +2212,11 @@ export const startMirror = (deps) => {
2119
2212
  if (!hasVisibleBody(content))
2120
2213
  return;
2121
2214
  const chatId = replyChatId(a);
2122
- const turnId = a.briefTurnId;
2215
+ const turnId = a.briefTurnId ?? a.liveStream?.turnId;
2123
2216
  a.standalonePending = a.standalonePending
2124
2217
  .then(async () => {
2218
+ if (silenceAnswer(a.target, turnId ?? ""))
2219
+ return;
2125
2220
  try {
2126
2221
  await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
2127
2222
  log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
@@ -2310,7 +2405,7 @@ export const startMirror = (deps) => {
2310
2405
  // sees the bubble; subsequent items grow it as today.
2311
2406
  void (async () => {
2312
2407
  try {
2313
- await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
2408
+ await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
2314
2409
  }
2315
2410
  catch (e) {
2316
2411
  log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
@@ -2415,11 +2510,15 @@ export const startMirror = (deps) => {
2415
2510
  const b = a.briefBubble;
2416
2511
  const turnId = a.briefTurnId;
2417
2512
  // 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
2418
- if (!text || !b || b.done || !turnId || a.briefConcluded)
2513
+ if (!text || !b || b.done || !turnId || a.briefConcluded || answerSilent(a))
2419
2514
  return;
2420
2515
  a.cotLastSent = text;
2421
2516
  try {
2422
- await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, `\`${text}\``), false);
2517
+ await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2518
+ if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || answerSilent(a))
2519
+ return;
2520
+ await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, `\`${text}\``), false);
2521
+ }));
2423
2522
  }
2424
2523
  catch (e) {
2425
2524
  log.debug({ sessionId: a.sessionId, turnId, err: e.message }, "brief: cot refresh failed");
@@ -2450,7 +2549,231 @@ export const startMirror = (deps) => {
2450
2549
  // `[mirror]` 系统提示 —— 它们都不走这道门。
2451
2550
  // 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
2452
2551
  // 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
2453
- const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
2552
+ let silenceAnswer = () => false;
2553
+ const answerSilent = (a) => silenceAnswer(a.target, a.briefTurnId ?? a.liveStream?.turnId ?? "");
2554
+ const turnSilent = (a) => answerSilent(a) || isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
2555
+ const formatFinal = (target, turn, channel, body) => {
2556
+ const pieces = splitMarkdown(body, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
2557
+ return pieces.map((text, n) => withLinkedTagHeader(target, text, chatTurnUrl(target, turn), pieces.length > 1 ? `${n + 1}/${pieces.length}` : undefined, undefined, channel));
2558
+ };
2559
+ const finalProgress = new Map();
2560
+ const settleFinalProgress = async (id) => {
2561
+ await finalProgress.get(id)?.catch(() => undefined);
2562
+ finalProgress.delete(id);
2563
+ };
2564
+ const queueFinal = (target, turn, channel, body, eventId, bubble) => {
2565
+ if (!deps.events || !channel || !body.trim())
2566
+ return undefined;
2567
+ const revision = finalId(eventId || turn, channel, body);
2568
+ const id = finalId(eventId || turn, channel, revision);
2569
+ const chunks = formatFinal(target, turn, channel, body);
2570
+ if (bubble?.pending)
2571
+ finalProgress.set(id, bubble.pending);
2572
+ deps.events.delivery({ id, eventId, target, turn, channel, body, revision, chunks,
2573
+ ...(bubble && !bubble.done ? { stream: { frame: bubble.frame, id: bubble.streamId, expires: bubble.expires ?? Date.now() } } : {}) });
2574
+ if (bubble) {
2575
+ bubble.done = true;
2576
+ clearTimeout(bubble.hardTimer);
2577
+ }
2578
+ return id;
2579
+ };
2580
+ const recoverEventFinal = (event, bubble) => {
2581
+ if (!event.result || event.interjected || event.canceled)
2582
+ return;
2583
+ const turn = event.turn || event.id;
2584
+ const record = getDetail(turn);
2585
+ if (!record)
2586
+ recordTurnStart({ id: turn, eventId: event.id, target: event.target,
2587
+ userQuery: event.payload.text, createdAt: event.originalAt ?? event.at, channel: event.channel,
2588
+ speaker: event.payload.human ? event.speaker : undefined, from: event.payload.peer?.from });
2589
+ if (record?.kind !== "turn" || !record.items.some((x) => x.t === "text" && x.final && x.body === event.result.body))
2590
+ recordTurnItem(turn, { t: "text", final: true, body: event.result.body, ts: event.result.at });
2591
+ recordTurnClose(turn);
2592
+ if (!event.turn)
2593
+ deps.events?.bind(event.id, turn);
2594
+ const parent = event.replyTo?.kind === "chat" ? deps.events?.get(event.replyTo.turn) : undefined;
2595
+ if (parent && event.channel === parent.channel) {
2596
+ const original = deps.events.continueResult(event.id);
2597
+ if (!original)
2598
+ return;
2599
+ if (eventFinalGate({ ...original, awaitingContinuation: false }))
2600
+ deps.events.defer(original.id, false);
2601
+ const fresh = deps.events.get(original.id);
2602
+ if (fresh.replyTo?.kind === "chat")
2603
+ recoverEventFinal(fresh, bubble);
2604
+ else if (eventFinalGate(fresh))
2605
+ queueFinal(fresh.target, fresh.turn || fresh.id, fresh.channel, event.result.body, fresh.id, bubble);
2606
+ }
2607
+ else if (event.channel && eventFinalGate(event))
2608
+ queueFinal(event.target, turn, event.channel, event.result.body, event.id, bubble);
2609
+ };
2610
+ const answerBubbles = new Map();
2611
+ const answerLine = (held, text) => withLinkedTagHeader(held.receiver, text, chatTurnUrl(held.receiver, held.sourceTurn), undefined, undefined, held.channel);
2612
+ // Serialise the old owner's in-flight refresh, reset, progress and final.
2613
+ // A delayed ACK must never let an earlier writer overtake the new owner.
2614
+ const writeAnswerProgress = (held, text) => {
2615
+ held.pending = held.pending.catch(() => undefined).then(async () => {
2616
+ if (!held.active || held.stopped || !held.bubble || held.bubble.done)
2617
+ return;
2618
+ await client.replyStream(held.bubble.frame, held.bubble.streamId, answerLine(held, text), false);
2619
+ }).catch((e) => log.warn({ err: errText(e) }, "answer transfer progress refresh failed"));
2620
+ };
2621
+ const updateAnswerProgress = (a, item) => {
2622
+ // Queued metadata is not authority: only the input boundary activates a task.
2623
+ const held = a.activePeerTurn ? answerBubbles.get(a.activePeerTurn) : undefined;
2624
+ if (!held || held.receiver !== a.target || held.stopped || held.bubble?.done)
2625
+ return;
2626
+ const raw = item.kind === "tool_use" ? cotToolLabel(item.calls)
2627
+ : item.kind === "thinking" || (item.kind === "text" && item.final !== true) ? item.body : "";
2628
+ const line = cotLine(raw);
2629
+ if (!line)
2630
+ return;
2631
+ held.progress = line;
2632
+ if (!held.active)
2633
+ return;
2634
+ if (held.timer)
2635
+ return;
2636
+ held.timer = setTimeout(() => {
2637
+ held.timer = undefined;
2638
+ if (held.progress)
2639
+ writeAnswerProgress(held, `\`${held.progress}\``);
2640
+ }, COT_FLUSH_MS);
2641
+ };
2642
+ const answerContext = (target) => {
2643
+ const a = byTarget.get(target);
2644
+ if (!a || a.briefConcluded || a.briefIsSlash || isPrivate(a) || a.turnFromChat === false)
2645
+ return undefined;
2646
+ const turn = a.briefTurnId ?? a.liveStream?.turnId;
2647
+ return turn ? { turn, channel: a.channel ?? baseOfKey(target), prefix: "" } : undefined;
2648
+ };
2649
+ const prepareAnswer = (id, source, expectedTurn, receiver, channel) => {
2650
+ const a = byTarget.get(source);
2651
+ if (!a || (a.briefTurnId ?? a.liveStream?.turnId) !== expectedTurn || answerBubbles.has(id))
2652
+ return false;
2653
+ if ((!a.briefBubble || a.briefBubble.done) && (!a.liveStream || a.liveStream.closed || a.liveStream.dead))
2654
+ return false;
2655
+ answerBubbles.set(id, {
2656
+ source, receiver, sourceTurn: expectedTurn, channel, active: false, stopped: false,
2657
+ bubble: a.briefBubble, stream: a.liveStream, pending: Promise.resolve(),
2658
+ });
2659
+ return true;
2660
+ };
2661
+ const cancelAnswerPreparation = (id) => {
2662
+ const held = answerBubbles.get(id);
2663
+ if (held && !held.active)
2664
+ answerBubbles.delete(id);
2665
+ };
2666
+ const stopAnswerProgress = (id) => {
2667
+ const held = answerBubbles.get(id);
2668
+ if (!held)
2669
+ return;
2670
+ held.stopped = true;
2671
+ clearTimeout(held.timer);
2672
+ held.timer = undefined;
2673
+ };
2674
+ const takeAnswer = async (id, target, expectedTurn, receiver, channel) => {
2675
+ const a = byTarget.get(target);
2676
+ const held = answerBubbles.get(id);
2677
+ if (!held || held.active || held.source !== target || held.sourceTurn !== expectedTurn || held.receiver !== receiver || held.channel !== channel)
2678
+ return { taken: false, cleared: false };
2679
+ const sameTurn = a && (a.briefTurnId ?? a.liveStream?.turnId) === expectedTurn;
2680
+ if (sameTurn) {
2681
+ clearCot(a);
2682
+ a.cotLastSent = undefined;
2683
+ if (a.standaloneBuf) {
2684
+ clearTimeout(a.standaloneBuf.timer);
2685
+ a.standaloneBuf = undefined;
2686
+ }
2687
+ // Only this source turn becomes private; a concurrent new human turn is untouched.
2688
+ a.channel = "";
2689
+ }
2690
+ const s = held.stream;
2691
+ if (s && !s.closed && !s.dead) {
2692
+ s.closed = true;
2693
+ clearTimeout(s.flushTimer);
2694
+ clearTimeout(s.idleTimer);
2695
+ clearTimeout(s.hardTimer);
2696
+ // Read the current chain, not prepare's snapshot: source may have flushed during injection.
2697
+ held.bubble = { frame: s.frame, streamId: s.streamId, done: false, pending: s.pending };
2698
+ if (a?.liveStream === s)
2699
+ a.liveStream = undefined;
2700
+ }
2701
+ else if (s)
2702
+ held.bubble = undefined;
2703
+ const b = held.bubble;
2704
+ held.active = true;
2705
+ if (!b || b.done)
2706
+ return { taken: true, cleared: false };
2707
+ clearTimeout(b.hardTimer);
2708
+ if (a?.briefBubble === b)
2709
+ a.briefBubble = undefined;
2710
+ let cleared = false;
2711
+ held.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2712
+ if (b.done)
2713
+ return;
2714
+ await client.replyStream(b.frame, b.streamId, answerLine(held, "…"), false);
2715
+ cleared = true;
2716
+ });
2717
+ try {
2718
+ await held.pending;
2719
+ }
2720
+ catch (e) {
2721
+ cleared = false;
2722
+ log.warn({ id, err: errText(e) }, "answer transfer reset not acknowledged");
2723
+ }
2724
+ // Keep delivery possible after a reset error; reset never carries the final answer.
2725
+ held.pending = held.pending.catch(() => undefined);
2726
+ if (!held.stopped && held.progress)
2727
+ writeAnswerProgress(held, `\`${held.progress}\``);
2728
+ return { taken: true, cleared };
2729
+ };
2730
+ const deliverAnswer = async (id, target, turn, channel, _prefix, body, standalone, frozen = false) => {
2731
+ const held = answerBubbles.get(id);
2732
+ const text = body.trim();
2733
+ const content = frozen ? body : withLinkedTagHeader(target, text, chatTurnUrl(target, turn), undefined, undefined, channel);
2734
+ stopAnswerProgress(id);
2735
+ if (held?.bubble && !held.bubble.done) {
2736
+ held.bubble.done = true;
2737
+ clearTimeout(held.timer);
2738
+ await held.pending;
2739
+ try {
2740
+ // Failure closes progress first; the terminal explanation is a standalone message.
2741
+ const progress = withLinkedTagHeader(target, "回答已转交", chatTurnUrl(target, turn), undefined, undefined, channel);
2742
+ const ack = frozen && !standalone
2743
+ ? await deps.sendFrozen(channel, content, { frame: held.bubble.frame, id: held.bubble.streamId })
2744
+ : await client.replyStream(held.bubble.frame, held.bubble.streamId, standalone ? progress : content, true);
2745
+ if (!standalone) {
2746
+ answerBubbles.delete(id);
2747
+ return ack;
2748
+ }
2749
+ }
2750
+ catch (e) {
2751
+ const ack = e && typeof e === "object" ? e : undefined;
2752
+ // Only a structured non-zero server ACK proves the stream frame was
2753
+ // rejected. Timeout/disconnect errors are ambiguous: standalone retry
2754
+ // could publish the same final answer twice, so let the caller persist
2755
+ // `uncertain` and require a human resend instead.
2756
+ const rejected = !!ack && typeof ack.headers === "object" && !!ack.headers && typeof ack.errcode === "number" && ack.errcode !== 0 && typeof ack.errmsg === "string";
2757
+ if (!rejected && !standalone) {
2758
+ answerBubbles.delete(id);
2759
+ throw e;
2760
+ }
2761
+ if (!rejected) {
2762
+ log.warn({ id, err: errText(e) }, "answer transfer progress close acknowledgement uncertain; continuing with the one standalone terminal explanation");
2763
+ // In standalone mode this stream only closes progress; it never
2764
+ // carries the final body, so sending the terminal explanation once
2765
+ // remains necessary even when progress-close ACK is ambiguous.
2766
+ }
2767
+ else {
2768
+ log.warn({ id, err: errText(e), errcode: ack.errcode }, "answer transfer stream was explicitly rejected; standalone fallback");
2769
+ }
2770
+ }
2771
+ }
2772
+ const ack = frozen ? await deps.sendFrozen(channel, content)
2773
+ : await client.sendMessage(channel.replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content } });
2774
+ answerBubbles.delete(id);
2775
+ return ack;
2776
+ };
2454
2777
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
2455
2778
  // raw=true skips withTagHeader (used when content already contains the linked tag header).
2456
2779
  // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
@@ -2492,6 +2815,7 @@ export const startMirror = (deps) => {
2492
2815
  a.briefHadTool = false;
2493
2816
  a.briefConcluded = false;
2494
2817
  a.briefLastText = undefined;
2818
+ a.cotLastSent = undefined;
2495
2819
  clearCot(a); // turn 级状态 —— 进度行跟着气泡走, 不能跨 turn 残留
2496
2820
  log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
2497
2821
  };
@@ -2525,6 +2849,7 @@ export const startMirror = (deps) => {
2525
2849
  const openingOf = (a) => {
2526
2850
  const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
2527
2851
  const env = t?.env;
2852
+ a.activeEventId = env?.eventId;
2528
2853
  if (!env)
2529
2854
  return undefined;
2530
2855
  const home = baseOfKey(a.target);
@@ -2586,7 +2911,7 @@ export const startMirror = (deps) => {
2586
2911
  return undefined;
2587
2912
  };
2588
2913
  /** 本轮落 turn 记录的频道字段。 */
2589
- const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker });
2914
+ const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker, eventId: a.activeEventId });
2590
2915
  /** 私聊轮: 回复只落 turn 记录。 */
2591
2916
  const isPrivate = (a) => a.channel === "";
2592
2917
  /** 本轮回复该推去的 chatId。 */
@@ -2612,9 +2937,9 @@ export const startMirror = (deps) => {
2612
2937
  };
2613
2938
  /** 露面的这一行认领它的印章, 先注入的先认。CLI 把排队的几句并成一行时, 包含在里面的都算
2614
2939
  * 露过面, 出处取第一枚。认不出 = undefined。 */
2615
- const takeSeal = (a, said, pick = () => true) => {
2940
+ const takeSeal = (a, said, pick = () => true, eventId) => {
2616
2941
  const t = said.trim();
2617
- const hit = (x) => pick(x) && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k)));
2942
+ const hit = (x) => pick(x) && (eventId ? x.eventId === eventId : !x.eventId && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k))));
2618
2943
  const seals = a.seals ?? [];
2619
2944
  const hits = seals.filter(hit);
2620
2945
  // CLI 把排队的几句并成一行, 这一轮只能有一个去向: 里面有私聊的那句就是私聊 —— 它的答复不能
@@ -2635,7 +2960,11 @@ export const startMirror = (deps) => {
2635
2960
  * `at` = 那一行落盘的时刻: 排队的那句从这一刻才开始被处理, 轮次的起点以它为准; 闲时当场接上就是投递那一刻。 */
2636
2961
  const activateDispatch = (a, s, at = s.at) => {
2637
2962
  const q = s.turn;
2963
+ if (a.briefTurnId && a.briefTurnId !== q.turnId)
2964
+ closeBriefTurn(a);
2965
+ a.activeEventId = s.eventId;
2638
2966
  s.activated = true;
2967
+ a.activePeerTurn = undefined;
2639
2968
  a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
2640
2969
  a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
2641
2970
  a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
@@ -2645,9 +2974,7 @@ export const startMirror = (deps) => {
2645
2974
  a.pendingBriefQuery = undefined;
2646
2975
  a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
2647
2976
  consumeChannel(a, true, undefined);
2648
- recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: at, ...channelFields(a) });
2649
- if (a.briefTurnId)
2650
- closeBriefTurn(a);
2977
+ recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: (s.eventId ? deps.events?.get(s.eventId)?.originalAt : undefined) ?? at, ...channelFields(a) });
2651
2978
  openBriefTurn(a, q);
2652
2979
  };
2653
2980
  /** 一句插话记进它插进的那一轮: 出处、频道、发话人都取它自己的印章 (没写频道 = 在 home 里开口)。 */
@@ -2655,16 +2982,21 @@ export const startMirror = (deps) => {
2655
2982
  /** `attachment.queued_command` 露面: 那句被正在跑的这一轮中途吃进去了。认领它的印章记成插话。
2656
2983
  * 人从聊天里说的那句 (印章带预建的轮) 不在这里认: 它已有自己的轮次记录。认不出 (人在 CLI 里
2657
2984
  * 忙时敲的) 不记 —— 说不清是谁、在哪个频道说的。 */
2658
- const interject = (a, said, ts) => {
2985
+ const interject = (a, said, ts, eventId) => {
2659
2986
  if (!a.briefTurnId)
2660
2987
  return;
2661
- const s = takeSeal(a, said, (x) => !x.turn);
2988
+ const s = takeSeal(a, said, (x) => !x.turn, eventId);
2662
2989
  if (s)
2663
2990
  injectInto(a.briefTurnId, a, s, ts);
2664
2991
  };
2665
2992
  /** 一行注入露面了: 把它的印章落成下一轮的出处 / 频道。返回认领到的印章。`ts` = 那一行落盘的时刻。 */
2666
- const bindLine = (a, said, ts) => {
2667
- const s = takeSeal(a, said);
2993
+ const bindLine = (a, said, ts, eventId) => {
2994
+ const recovered = eventId ? deps.events?.get(eventId) : undefined;
2995
+ const s = takeSeal(a, said, undefined, eventId) ?? (recovered?.target === a.target ? {
2996
+ eventId, keys: [], at: recovered.at, query: recovered.payload.text, channel: recovered.channel,
2997
+ speaker: recovered.payload.human ? recovered.speaker : undefined, from: recovered.payload.peer?.from,
2998
+ fresh: recovered.priority === "normal" || idleVerdict(a.target) === true,
2999
+ } : undefined);
2668
3000
  if (!s)
2669
3001
  return undefined;
2670
3002
  if (s.turn) {
@@ -2680,19 +3012,28 @@ export const startMirror = (deps) => {
2680
3012
  }
2681
3013
  if (a.briefTurnId)
2682
3014
  closeBriefTurn(a);
3015
+ a.activeEventId = s.eventId;
2683
3016
  const now = Date.now();
3017
+ a.activePeerTurn = s.from?.kind === "peer" ? s.from.turn : undefined;
2684
3018
  a.pendingFrom = s.from ? { from: s.from, at: now } : undefined;
2685
3019
  a.pendingOrigin = s.origin ? { origin: s.origin, at: now } : undefined;
2686
3020
  // 没写频道的注入 (graph 步骤 / 手动 inject) 等同在 home 里开口: 印章就是全部出处, 不沿用上一轮的。
2687
- a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), at: now };
3021
+ a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: now };
2688
3022
  a.pendingBriefQuery = s.query;
2689
3023
  a.pendingBriefAt = ts;
3024
+ if (!cfg.wrc.mirror.brief) {
3025
+ consumeChannel(a, false, consumeFrom(a));
3026
+ if (a.liveStream && !a.liveStream.closed)
3027
+ a.liveStream.dest = s.channel ?? baseOfKey(a.target);
3028
+ }
2690
3029
  return s;
2691
3030
  };
2692
- const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
3031
+ const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker, eventId) => {
2693
3032
  const turnId = newTurnId();
2694
3033
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
2695
- const bubble = { frame, streamId, done: false };
3034
+ const bubble = { frame, streamId, done: false, expires: (eventId ? deps.events?.get(eventId)?.at ?? Date.now() : Date.now()) + HARD_TIMEOUT_MS };
3035
+ if (eventId)
3036
+ deps.events?.bind(eventId, turnId);
2696
3037
  const q = { turnId, bubble, isSlash };
2697
3038
  // 微信帧不挂: 没有 stream 窗口要赶, 气泡等终句来了再一次发出 (见 openStream 同一条)。
2698
3039
  if (!isWxFrame(frame))
@@ -2703,29 +3044,23 @@ export const startMirror = (deps) => {
2703
3044
  const live = a.briefBubble === bubble && !a.briefConcluded;
2704
3045
  void finishBubble(a, bubble, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
2705
3046
  }, HARD_TIMEOUT_MS);
2706
- const seal = { keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
3047
+ const seal = { eventId, keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
2707
3048
  // 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
2708
3049
  // 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
2709
3050
  // 先建好, ack 里的链接点得开。斜杠命令的行未必露面, 当场接。
2710
3051
  const queued = !isSlash && idleVerdict(a.target) === false;
2711
3052
  pushSeal(a, seal);
2712
3053
  if (queued)
2713
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, channel: channel ?? baseOfKey(a.target), speaker });
3054
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, eventId, createdAt: eventId ? deps.events?.get(eventId)?.originalAt : undefined, channel: channel ?? baseOfKey(a.target), speaker });
2714
3055
  else
2715
3056
  activateDispatch(a, seal);
2716
- // 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
2717
- // closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
2718
- // 覆盖消息)。旧 turn 在 CLI 侧的剩余产出自然流入新气泡 —— "新回复走最新气泡"
2719
- // 正是这个语义。代价是旧 turn 页提前标完、尾部 item 记到新 turn 名下, 但远小于
2720
- // 排队的代价: 排队 turn 的 frame 在前一个长 turn 期间 (可达数分钟) 过期, 激活后
2721
- // replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。所以只在前面真有别人的行排着时
2722
- // 才排 (见上): 接上时气泡若已过期, 正文走 standalone (concludeBriefTurn)。
3057
+ // Durable normal inputs arrive only after the preceding full turn; expired bubbles use the final outbox standalone path.
2723
3058
  // ack 即详情链接。URL 的三个入参 (turnId / target / host) 在收消息这一刻全部已知 ——
2724
3059
  // turnId 是本地生成的, 详情页记录也已在 recordTurnStart 建好, 所以不必等 CLI 产出,
2725
3060
  // 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
2726
3061
  // 尾随 `…` 表示"正文还没到", 正文到位时整条内容被 `链接 正文` 覆盖。
2727
3062
  try {
2728
- await client.replyStream(frame, streamId, briefLine(a, turnId, "…", channel), false);
3063
+ await (bubble.pending = client.replyStream(frame, streamId, briefLine(a, turnId, "…", channel), false));
2729
3064
  }
2730
3065
  catch (e) {
2731
3066
  log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
@@ -2738,8 +3073,11 @@ export const startMirror = (deps) => {
2738
3073
  if (a.channelSeeded)
2739
3074
  return false;
2740
3075
  const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
2741
- const said = head(a.jsonlPath ? lastUserTurn(a.jsonlPath)?.text : undefined);
2742
- const r = said ? openTurnsOf(a.target, a.sessionId).find((t) => head(t.userQuery) === said) : undefined;
3076
+ const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
3077
+ const said = head(opening?.text);
3078
+ const eventId = opening?.env?.eventId;
3079
+ const r = openTurnsOf(a.target, a.sessionId).find((t) => eventId ? t.eventId === eventId : !!said && head(t.userQuery) === said);
3080
+ a.activeEventId = eventId;
2743
3081
  if (!r)
2744
3082
  return false;
2745
3083
  a.channelSeeded = true;
@@ -2749,6 +3087,7 @@ export const startMirror = (deps) => {
2749
3087
  a.channel = r.channel === undefined || r.channel === baseOfKey(a.target) ? undefined : r.channel;
2750
3088
  a.speaker = r.speaker;
2751
3089
  a.convFrom = r.from;
3090
+ a.activePeerTurn = r.from?.kind === "peer" ? r.from.turn : undefined;
2752
3091
  a.replyTo = publicPeer(r.from);
2753
3092
  a.turnFromChat = a.turnFromChat ?? true;
2754
3093
  a.briefTurnId = r.id;
@@ -2784,6 +3123,8 @@ export const startMirror = (deps) => {
2784
3123
  const from = fresh ?? a.convFrom;
2785
3124
  recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: query || opened || undefined, origin: consumeOrigin(a), from, ...channelFields(a), ...(query && at ? { createdAt: at } : {}) });
2786
3125
  a.briefTurnId = turnId;
3126
+ if (a.activeEventId)
3127
+ deps.events?.bind(a.activeEventId, turnId);
2787
3128
  a.briefBubble = undefined;
2788
3129
  a.briefIsSlash = false;
2789
3130
  a.briefHadTool = false;
@@ -2826,25 +3167,26 @@ export const startMirror = (deps) => {
2826
3167
  return;
2827
3168
  if (!body.trim())
2828
3169
  return;
2829
- if (isDuplicateConclusion(a, body)) {
3170
+ if (!a.activeEventId && isDuplicateConclusion(a, body)) {
2830
3171
  a.briefConcluded = true; // 本轮已"定稿", 只是不投递
2831
3172
  log.info({ sessionId: a.sessionId, turnId, epoch: a.queryEpoch ?? 0 }, "brief: duplicate conclusion suppressed");
2832
3173
  return;
2833
3174
  }
3175
+ const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
3176
+ if (event)
3177
+ deps.events.result(event.id, body, finalId(event.id, event.channel, body), turnId);
3178
+ if (!answerSilent(a) && !turnSilent(a) && (!event || eventFinalGate(event))) {
3179
+ if (event)
3180
+ recoverEventFinal(deps.events.get(event.id), a.briefBubble);
3181
+ else
3182
+ queueFinal(a.target, turnId, a.channel ?? baseOfKey(a.target), body, undefined, a.briefBubble);
3183
+ }
3184
+ // The durable result/outbox must exist before this in-memory dedup bit advances.
2834
3185
  a.briefConcluded = true;
2835
3186
  a.lastConcludedBody = body;
2836
3187
  a.lastConcludedEpoch = a.queryEpoch ?? 0;
2837
3188
  a.lastConcludedAt = Date.now();
2838
- if (a.briefBubble && !a.briefBubble.done) {
2839
- void finishBriefBubble(a, briefLine(a, turnId, linkTags(a.target, body)), true);
2840
- }
2841
- else if (turnSilent(a)) {
2842
- // CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
2843
- log.info({ sessionId: a.sessionId, turnId }, "brief: CLI-origin conclusion kept out of chat");
2844
- }
2845
- else {
2846
- sendStandalone(a, body); // 详情链接由 withLinkedTag 统一前缀, 不在这里拼
2847
- }
3189
+ clearCot(a);
2848
3190
  // 只保留当前 turn: 其余仍开着的 turn 记录是漏收的 close, 一并扫掉。
2849
3191
  recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: [turnId] });
2850
3192
  };
@@ -2975,6 +3317,9 @@ export const startMirror = (deps) => {
2975
3317
  : item.kind === "thinking" || item.kind === "text"
2976
3318
  ? item.body
2977
3319
  : "";
3320
+ // A prompt (even an exact match) is not a child/parent-call identity. Late
3321
+ // children can reuse prompts after history eviction, so their content must
3322
+ // never enter a transferred answer bubble without a verifiable ID binding.
2978
3323
  if (activity)
2979
3324
  updateBriefProgress(a, `🤖 ${run.label} · ${activity}`);
2980
3325
  };
@@ -3108,6 +3453,8 @@ export const startMirror = (deps) => {
3108
3453
  return;
3109
3454
  }
3110
3455
  if (item.kind === "turn_end") {
3456
+ if (a.activeEventId && !item.soft)
3457
+ deps.events?.finished(a.activeEventId);
3111
3458
  closeBriefTurn(a);
3112
3459
  return;
3113
3460
  }
@@ -3316,7 +3663,7 @@ export const startMirror = (deps) => {
3316
3663
  if (item.kind === "user_text") {
3317
3664
  // 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
3318
3665
  // 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
3319
- if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts))
3666
+ if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts, item.eventId))
3320
3667
  return;
3321
3668
  endKeepaliveSwallow(a);
3322
3669
  // 对话边界 = 旧 turn 清算。上一 turn 派发而未返回的前台 Agent/Task 到此作废,
@@ -3328,17 +3675,23 @@ export const startMirror = (deps) => {
3328
3675
  // 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
3329
3676
  a.pendingBriefQuery = item.body;
3330
3677
  a.pendingBriefAt = item.ts;
3678
+ a.activePeerTurn = undefined;
3679
+ a.pendingFrom = undefined;
3680
+ a.pendingChannel = undefined;
3681
+ a.activeEventId = item.eventId;
3331
3682
  a.pendingFromCli = true;
3683
+ if (!cfg.wrc.mirror.brief)
3684
+ consumeChannel(a, true, undefined);
3332
3685
  }
3333
3686
  if (item.kind === "user_inject") {
3334
- interject(a, item.said, item.ts);
3687
+ interject(a, item.said, item.ts, item.eventId);
3335
3688
  return;
3336
3689
  }
3337
3690
  // 还有一轮等着懒建 (peer / graph 注入先记了 pendingBriefQuery) 就换掉它; 否则这句话
3338
3691
  // 就是已开好的那一轮 (IM 侧说的, startBriefTurn 在注入前建好)。
3339
3692
  if (item.kind === "user_query") {
3340
3693
  // 同伴 / 定时 / graph 注入的那一行: 问话就是印章里的原话 (不带信封)。
3341
- const sealed = bindLine(a, item.said, item.ts);
3694
+ const sealed = bindLine(a, item.said, item.ts, item.eventId);
3342
3695
  if (sealed && !sealed.turn)
3343
3696
  return;
3344
3697
  if (a.pendingBriefQuery !== undefined || !a.briefTurnId) {
@@ -3413,6 +3766,7 @@ export const startMirror = (deps) => {
3413
3766
  // 的配置下, 人在 CLI 敲的那一行就这么原样回显进了群, 正好破了 chatOriginOnly。
3414
3767
  if (item.kind === "user_text" && (item.quiet || cfg.wrc.mirror.brief))
3415
3768
  return;
3769
+ updateAnswerProgress(a, item);
3416
3770
  // thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
3417
3771
  // 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
3418
3772
  // 那就变回已下线的 thinkStyle 了。
@@ -3560,6 +3914,21 @@ export const startMirror = (deps) => {
3560
3914
  }
3561
3915
  }
3562
3916
  }
3917
+ if (!cfg.wrc.mirror.brief && item.kind === "text" && item.final !== false && a.activeEventId && deps.events) {
3918
+ const e = deps.events.get(a.activeEventId);
3919
+ if (e) {
3920
+ // Soft final text waits for transcript/idle evidence; it must not escape through the legacy stream.
3921
+ if (item.final !== true)
3922
+ return;
3923
+ const turn = a.liveStream?.turnId ?? e.id;
3924
+ deps.events.result(e.id, item.body, finalId(e.id, e.channel, item.body), turn);
3925
+ if (eventFinalGate(e))
3926
+ recoverEventFinal(deps.events.get(e.id));
3927
+ return;
3928
+ }
3929
+ }
3930
+ if (!cfg.wrc.mirror.brief && isPrivate(a))
3931
+ return;
3563
3932
  // /goal mode overrides everything below: the session-scoped Stop hook
3564
3933
  // self-drives the model with no terminal stop_reason, so turn_end never fires
3565
3934
  // and brief-mode would swallow the entire run into the turn store (silent
@@ -3864,7 +4233,9 @@ export const startMirror = (deps) => {
3864
4233
  };
3865
4234
  // `panes` = boot 快照; 给了就不再为每个 principal 单独问 tmux。lazy 调用方
3866
4235
  // 不传, 维持原来的逐个探活。
3867
- const restoreFromStore = async (principal, panes) => {
4236
+ const restoreFromStore = async (principal, panes, permitted) => {
4237
+ if (permitted && !permitted())
4238
+ return undefined;
3868
4239
  const rec = deps.store.get(principal);
3869
4240
  if (!rec)
3870
4241
  return undefined;
@@ -3882,6 +4253,8 @@ export const startMirror = (deps) => {
3882
4253
  // unrelated newest-in-cwd file is exactly the cross-wire we must avoid.
3883
4254
  const storedPane = (rec.tmuxPane ?? "").trim();
3884
4255
  const livePane = storedPane && (panes ? panes.has(storedPane) : await tmuxPaneAlive(storedPane)) ? storedPane : "";
4256
+ if (permitted && !permitted())
4257
+ return undefined;
3885
4258
  if (!existsSync(jsonlAbs)) {
3886
4259
  // First: the SAME-sid transcript may have merely relocated to a sibling
3887
4260
  // project dir (Claude Code EnterWorktree/ExitWorktree moved it while the
@@ -3937,6 +4310,8 @@ export const startMirror = (deps) => {
3937
4310
  const paneCwd = panes
3938
4311
  ? (panes.get(livePane) ?? "")
3939
4312
  : (await runTmux(["display-message", "-p", "-t", livePane, "#{pane_current_path}"])).stdout.trim();
4313
+ if (permitted && !permitted())
4314
+ return undefined;
3940
4315
  if (paneCwd) {
3941
4316
  // Encode under the backend that owns the bound transcript — comparing
3942
4317
  // with the primary dialect would report a phantom drift for every
@@ -3956,6 +4331,8 @@ export const startMirror = (deps) => {
3956
4331
  }
3957
4332
  }
3958
4333
  }
4334
+ if (permitted && !permitted())
4335
+ return undefined;
3959
4336
  const r = attach({
3960
4337
  sessionId: rec.sessionId,
3961
4338
  jsonlPath: jsonlAbs,
@@ -4542,10 +4919,10 @@ export const startMirror = (deps) => {
4542
4919
  // `header` folds the caller's ack ("created") into this same bubble — /new
4543
4920
  // must land as exactly ONE WeCom message, not card + separate reply.
4544
4921
  // 会话边界回执一律不携带 cwd 信息 (📂 项目行 / 下次切换), 需要时 `/pwd` 可查。
4545
- // slashAckFirstLine 进一步省去 💡 tip, 只剩首行 ack。
4546
4922
  const pushProjectInfo = (target, header) => {
4547
- const concise = cfg.wrc.mirror.slashAckFirstLine && !!header;
4548
- const md = header ? (concise ? header : `${header}\n\n${randomTip()}`) : randomTip();
4923
+ if (!header)
4924
+ return;
4925
+ const md = header;
4549
4926
  const a = byTarget.get(target);
4550
4927
  if (a) {
4551
4928
  sendStandalone(a, md);
@@ -4606,12 +4983,13 @@ export const startMirror = (deps) => {
4606
4983
  return undefined;
4607
4984
  }
4608
4985
  };
4609
- /** 死 pane 重生 (`--resume` 同一个会话) 的模型: 绑定里记着的优先 —— set_model 换上的也记在那;
4610
- * 没记 (老绑定 / CLI 默认) 的管家补上管家档。 */
4986
+ /** 死 pane 重生: 管家重新取 router 档位, 普通 wizard 沿用绑定模型。 */
4611
4987
  const respawnSpec = (a) => {
4612
4988
  return resumedStewardSpec(cfg, a.target, backendForPath(a.jsonlPath).name, a);
4613
4989
  };
4614
4990
  const newSession = async (target, windowName, cli, opts) => {
4991
+ if (opts?.guard && !opts.guard())
4992
+ return { ok: false, reason: "spawn guard withdrew permission" };
4615
4993
  const prev = byTarget.get(target);
4616
4994
  // Resolution precedence (all chat-scoped except the running-cwd fallback):
4617
4995
  // base.pending > caller.pending > target.running > base.running > default
@@ -4637,8 +5015,8 @@ export const startMirror = (deps) => {
4637
5015
  const boundPath = prev?.jsonlPath ?? rec?.jsonlPath;
4638
5016
  const base = baseOfKey(target);
4639
5017
  const effCli = resolveCliBackend(cfg.wrc).name;
4640
- // 管家 (群的默认会话) 新起的会话按管家档起 (models.steward / chatPolicy), 档压过绑定里记下的
4641
- // effort —— `/new`、换目录、交接重开都是让改过的档位生效的那一下。点名了模型就整档让开。
5018
+ // 管家 (群的默认会话) 固定按 models.router 起, 压过显式模型及绑定里记下的 effort;
5019
+ // `/new`、换目录、交接重开都是让改过的档位生效的那一下。
4642
5020
  // 已在跑的管家不动 (换模型会让缓存整份重读); 死 pane 重生见 respawnSpec。
4643
5021
  const backendChanged = Boolean(boundPath && effCli && backendForPath(expandHome(boundPath)).name !== effCli);
4644
5022
  const { model: effModel, effort: effEffort } = newStewardSpec(cfg, target, effCli, opts, backendChanged ? undefined : prev?.effort ?? deps.store.get(target)?.effort);
@@ -4666,6 +5044,12 @@ export const startMirror = (deps) => {
4666
5044
  inFlightSids.delete(freshSid);
4667
5045
  return { ok: false, reason: r.reason };
4668
5046
  }
5047
+ if (opts?.guard && !opts.guard()) {
5048
+ inFlightSids.delete(freshSid);
5049
+ if (r.tmuxPane)
5050
+ await runTmux(["kill-pane", "-t", r.tmuxPane]);
5051
+ return { ok: false, reason: "spawn completed after permission withdrawal; binding withheld" };
5052
+ }
4669
5053
  const att = attach({
4670
5054
  sessionId: r.sessionId,
4671
5055
  jsonlPath: r.jsonlPath,
@@ -4783,14 +5167,16 @@ export const startMirror = (deps) => {
4783
5167
  return undefined;
4784
5168
  };
4785
5169
  const cloneSession = async (args) => {
4786
- const p = byTarget.get(args.parent) ?? (await restoreFromStore(args.parent));
5170
+ if (args.guard && !args.guard("paste"))
5171
+ return { ok: false, reason: "spawn guard withdrew permission", inherited: false };
5172
+ const p = byTarget.get(args.parent) ?? (await restoreFromStore(args.parent, undefined, () => !args.guard || args.guard()));
4787
5173
  // 继承上下文要求父亲有一个活着的 transcript, 且分身必须待在同一个项目目录 ——
4788
5174
  // `--resume` 是按项目目录寻址 session 的。调用方点名换目录 = 明确放弃继承。
4789
5175
  const sameCwd = !args.cwd?.trim() || expandHome(args.cwd.trim()) === (p?.runningCwd ?? "");
4790
5176
  const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
4791
5177
  if (!canInherit) {
4792
5178
  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,
5179
+ 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
5180
  });
4795
5181
  return { ...r, inherited: false };
4796
5182
  }
@@ -4802,6 +5188,8 @@ export const startMirror = (deps) => {
4802
5188
  const parentCli = backendForPath(parent.jsonlPath).name;
4803
5189
  const model = args.model?.trim() || parent.model || lastModel(parent.jsonlPath);
4804
5190
  const effort = args.effort ?? parseEffort(parent.effort);
5191
+ if (args.guard && !args.guard("paste"))
5192
+ return { ok: false, reason: "spawn guard withdrew permission", inherited: false };
4805
5193
  const r = await spawnTmuxClaude({
4806
5194
  cfg,
4807
5195
  log: lg,
@@ -4825,7 +5213,7 @@ export const startMirror = (deps) => {
4825
5213
  const boot = await inject({
4826
5214
  text: hello, images: [], cfg, log: lg,
4827
5215
  sessionId: parentCli === "codex" ? r.sessionId : parent.sessionId, jsonlPath: parentCli === "codex" ? r.jsonlPath : parent.jsonlPath,
4828
- tmuxTarget: r.tmuxPane, freshSpawn: true,
5216
+ tmuxTarget: r.tmuxPane, freshSpawn: true, guard: args.guard,
4829
5217
  });
4830
5218
  if (!boot.ok) {
4831
5219
  await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
@@ -4841,6 +5229,12 @@ export const startMirror = (deps) => {
4841
5229
  await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
4842
5230
  return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
4843
5231
  }
5232
+ if (args.guard && !args.guard("submit")) {
5233
+ inFlightSids.delete(fork.sessionId);
5234
+ // The bootstrap may already have executed; withhold binding, report the
5235
+ // unconfirmed birth to its caller instead of claiming cancellation success.
5236
+ return { ok: false, reason: "fork executed after permission withdrawal; reconcile pane manually", inherited: false };
5237
+ }
4844
5238
  const att = attach({
4845
5239
  sessionId: fork.sessionId,
4846
5240
  jsonlPath: fork.jsonlPath,
@@ -5128,7 +5522,16 @@ export const startMirror = (deps) => {
5128
5522
  const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
5129
5523
  return live?.status === "idle" && hanging(target, live.sessionId);
5130
5524
  };
5131
- const idleNow = async (target) => idleVerdict(target) ?? !(await isBusy(target));
5525
+ const idleNow = async (target) => {
5526
+ const registry = idleVerdict(target);
5527
+ if (registry !== undefined)
5528
+ return registry;
5529
+ const pane = paneOf(target);
5530
+ if (!pane || !(await tmuxPaneAlive(pane)))
5531
+ return false;
5532
+ const { cap, box } = await readPaneInput(pane);
5533
+ return !!cap.trim() && box.known && !paneIsBusy(cap) && !isModalPane(cap).modal;
5534
+ };
5132
5535
  // 等到它闲下来, 事件驱动: 注册表目录一有写入就重判, 翻成 idle 的那一刻即返回。
5133
5536
  // 兜底轮询只防 watch 漏事件 (以及悬着的审批被点掉后 status 没再动的边角)。
5134
5537
  const IDLE_SAFETY_POLL_MS = 1000;
@@ -5340,7 +5743,9 @@ export const startMirror = (deps) => {
5340
5743
  catch { /* unreadable tail */ }
5341
5744
  return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), cacheAt: cacheTouchMs(jsonlPath) };
5342
5745
  };
5343
- const fireKeepalive = async (a) => {
5746
+ const fireKeepalive = async (a, guard) => {
5747
+ if (!guard())
5748
+ return;
5344
5749
  const kc = cfg.wrc.mirror.keepalive;
5345
5750
  // The SAME full instruction every round: a bare "ping" used to be sent from round 2 on
5346
5751
  // (the instruction was already in context), but the model reads a bare "ping"
@@ -5354,16 +5759,23 @@ export const startMirror = (deps) => {
5354
5759
  // ping turn (reply included). The 60s fail-safe clears the quiet window if
5355
5760
  // the turn somehow never emits turn_end, so a later real turn is never muted;
5356
5761
  // 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
5762
  const r = await inject({
5366
5763
  text, images: [], cfg,
5764
+ guard,
5765
+ requireIdle: () => idleVerdict(a.target) === true,
5766
+ beforeInput: () => {
5767
+ // A queued heartbeat must not mute the peer turn that won the lane.
5768
+ // Drain its tail first, then open swallow only at permitted physical input.
5769
+ a.tail.drain();
5770
+ rememberInject(text);
5771
+ a.keepaliveQuiet = setTimeout(() => {
5772
+ a.keepaliveQuiet = undefined;
5773
+ if (a.keepaliveTurnId) {
5774
+ recordTurnClose(a.keepaliveTurnId);
5775
+ a.keepaliveTurnId = undefined;
5776
+ }
5777
+ }, 60_000);
5778
+ },
5367
5779
  log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
5368
5780
  sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
5369
5781
  });
@@ -5374,6 +5786,8 @@ export const startMirror = (deps) => {
5374
5786
  log.warn({ target: a.target, reason: r.reason }, "keepalive: inject failed");
5375
5787
  return;
5376
5788
  }
5789
+ if (!guard())
5790
+ return;
5377
5791
  const tokens = lastContextTokens(a.jsonlPath);
5378
5792
  const round = a.keepalive ? (a.keepalive.round += 1) : 1;
5379
5793
  log.info({ target: a.target, tokens, round, totalRounds: Math.max(1, kc.rounds) }, "keepalive: ping injected");
@@ -5389,116 +5803,132 @@ export const startMirror = (deps) => {
5389
5803
  recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
5390
5804
  }
5391
5805
  };
5392
- let keepaliveTicking = false;
5393
- const keepaliveTick = async () => {
5394
- const kc = cfg.wrc.mirror.keepalive;
5395
- if (!kc.enabled || keepaliveTicking)
5806
+ // One pool for both maintenance paths: slow panes do not stall other panes,
5807
+ // and a keepalive never overlaps a picker scan/automatic press on the same pane.
5808
+ const maintenance = createKeyedScheduler(4);
5809
+ const maintenanceValid = (a, pane, sid, gen, signal) => !signal.aborted && byTarget.get(a.target) === a && a.tmuxPane === pane && a.sessionId === sid
5810
+ && (a.injectGen ?? 0) === gen && !a.migrationWatcher;
5811
+ const keepaliveOne = async (a, pane, sid, gen, signal) => {
5812
+ const valid = () => maintenanceValid(a, pane, sid, gen, signal) && cfg.wrc.mirror.keepalive.enabled;
5813
+ if (!valid())
5396
5814
  return;
5397
- keepaliveTicking = true;
5815
+ const kc = cfg.wrc.mirror.keepalive;
5816
+ const pingSigs = [normAssistant(kc.ping).slice(0, 40)].filter((s) => s.length > 0);
5817
+ const now = Date.now();
5818
+ if (a.keepaliveDisabled)
5819
+ return; // sticky spawn-time opt-out — never pings
5820
+ if (!a.tmuxPane)
5821
+ return; // spawn-mode: no live pane to warm
5822
+ if (a.migrationWatcher)
5823
+ return; // session rotating — skip
5824
+ if (a.liveStream && !a.liveStream.closed)
5825
+ return; // mid typewriter — don't inject
5826
+ if (!(await tmuxPaneAlive(pane)) || !valid())
5827
+ return; // dead pane — nothing to keep alive
5828
+ let mtime = 0;
5398
5829
  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);
5830
+ mtime = statSync(a.jsonlPath).mtimeMs;
5831
+ }
5832
+ catch {
5833
+ return;
5834
+ }
5835
+ if (!mtime)
5836
+ return;
5837
+ const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, pingSigs));
5838
+ // File mtime is only a cheap "did anything change → re-read the tail" gate.
5839
+ // The clocks themselves come from message turns, so metadata churn (which
5840
+ // bumps mtime but is not a turn) can never move them. `grewSinceLast` now
5841
+ // means a NEW message turn appeared — not that the file grew.
5842
+ const prevRealMs = k.lastRealMs;
5843
+ if (mtime > k.seenMtime + 1000) {
5844
+ const s = keepaliveStamps(a.jsonlPath, pingSigs);
5845
+ if (s.stamped) {
5846
+ k.lastMs = s.lastMs;
5847
+ k.lastRealMs = s.lastRealMs;
5495
5848
  }
5849
+ else {
5850
+ k.lastMs = mtime;
5851
+ k.lastRealMs = mtime;
5852
+ } // backend without timestamps → fall back to mtime
5853
+ k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
5854
+ k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
5855
+ }
5856
+ k.seenMtime = mtime;
5857
+ // Round reset / real-activity re-anchor keys off REAL turns only — a
5858
+ // keepalive ping+pong advances lastMs but NOT lastRealMs, so the heartbeat
5859
+ // no longer reads as activity to itself (which was pinning round at 1/N).
5860
+ const grewSinceLast = k.lastRealMs > prevRealMs + 1000;
5861
+ const paneTail = await capturePaneTail(pane, 16);
5862
+ if (!valid())
5863
+ return;
5864
+ const busy = paneIsBusy(paneTail);
5865
+ if (k.pinging) {
5866
+ // Waiting for our own ping to land and settle: until a NEWER message turn
5867
+ // (the ping's own user line) appears and the pane goes idle, keep waiting.
5868
+ if (busy || k.lastMs <= k.pingMtime)
5869
+ return;
5870
+ k.pinging = false;
5871
+ k.settledAt = now;
5872
+ return;
5496
5873
  }
5497
- catch (e) {
5498
- log.warn({ err: e.message }, "keepalive tick failed");
5874
+ // A new real message turn (or a busy pane) re-anchors: reset the round
5875
+ // counter, and past the /stop grace lift a busy-resume. grewSinceLast is
5876
+ // message-based now, so `/stop`'s Esc settling writes (metadata, no new
5877
+ // turn) can no longer self-resume the pause — only a busy pane past grace,
5878
+ // or a WeCom inbound in dispatch, does.
5879
+ // Suppress the grewSinceLast reset for 30s after pinging settles — the
5880
+ // pong's tail flush can lag a tick and would otherwise fake real activity.
5881
+ const justSettled = k.settledAt > 0 && now - k.settledAt < 30_000;
5882
+ if (busy || (grewSinceLast && !justSettled)) {
5883
+ k.round = 0;
5884
+ k.settledAt = 0;
5885
+ if (a.keepaliveOff && busy && now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS) {
5886
+ a.keepaliveOff = false;
5887
+ persistPause(a);
5888
+ }
5889
+ if (busy)
5890
+ return;
5499
5891
  }
5500
- finally {
5501
- keepaliveTicking = false;
5892
+ if (a.keepaliveOff)
5893
+ return; // paused by /stop until real activity returns
5894
+ // Cache clock = start of the newest answered request (TTL runs from there),
5895
+ // not the last turn's write time; no request on record → the turn clock.
5896
+ const sinceCache = now - (k.cacheAt || k.lastMs || mtime);
5897
+ // The TTL the session's cache entries were actually written with (1h on a
5898
+ // Claude Code subscription), not a guess: pinging at the 5min cadence
5899
+ // against a 1h entry re-reads the whole context ~14x per hour for nothing.
5900
+ const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
5901
+ const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
5902
+ if (sinceCache < idleTriggerMs)
5903
+ return; // cache still comfortably warm
5904
+ if (sinceCache >= ttlMs)
5905
+ return; // cache already cold — a ping would cold-rewrite for nothing
5906
+ if (sinceCache > KEEPALIVE_MAX_IDLE_MS)
5907
+ return; // idle past 10min (a long-TTL entry) — not worth warming
5908
+ if (k.round >= kc.rounds)
5909
+ return; // budget spent — let it go cold
5910
+ if (idleVerdict(a.target) !== true)
5911
+ return; // no confirmed idle → no heartbeat input
5912
+ k.pinging = true;
5913
+ k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
5914
+ await fireKeepalive(a, () => valid() && !a.keepaliveOff);
5915
+ };
5916
+ const keepaliveTick = () => {
5917
+ if (!cfg.wrc.mirror.keepalive.enabled)
5918
+ return;
5919
+ for (const a of byTarget.values()) {
5920
+ if (!a.tmuxPane || a.keepaliveDisabled)
5921
+ continue;
5922
+ const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
5923
+ void maintenance.schedule(`keepalive:${a.target}`, pane, (signal) => keepaliveOne(a, pane, sid, gen, signal))
5924
+ ?.catch((e) => {
5925
+ if (maintenanceValid(a, pane, sid, gen, maintenance.signal)) {
5926
+ if (a.keepalive)
5927
+ a.keepalive.pinging = false;
5928
+ endKeepaliveSwallow(a);
5929
+ }
5930
+ log.warn({ target: a.target, err: e.message }, "keepalive tick failed");
5931
+ });
5502
5932
  }
5503
5933
  };
5504
5934
  const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
@@ -5513,118 +5943,143 @@ export const startMirror = (deps) => {
5513
5943
  // - 其它 → 把框转成标准审批卡发到本 chat (runMirrorPickerFlow), 点击后代按
5514
5944
  // 对应选项; 本地先按掉则 moot 作废卡。代按/发卡都发 chat 回执留痕。
5515
5945
  // 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)
5946
+ const pickerOne = async (a, pane, sid, gen, signal) => {
5947
+ const guard = () => maintenanceValid(a, pane, sid, gen, signal) && cfg.approval.enabled
5948
+ && process.env.WEZARD_PICKER_SENTINEL !== "0";
5949
+ if (!guard())
5521
5950
  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;
5951
+ await withPaneInput(pane, async (live) => {
5952
+ const valid = () => guard() && live();
5953
+ if (!a.tmuxPane || a.migrationWatcher)
5954
+ return;
5955
+ // Codex PermissionRequest has its own independent native gate. Screen
5956
+ // options, tool windows and skipAll never authorize its sandbox.
5957
+ if (cliOf(a) === "codex")
5958
+ return;
5959
+ if (!(await tmuxPaneAlive(pane)) || !valid())
5960
+ return;
5961
+ const v = await detectModalPicker(pane);
5962
+ if (!valid())
5963
+ return;
5964
+ if (!v.modal) {
5965
+ // 审批卡在途而框已消失 = 本地先按掉了 → 作废卡等待 (无卡则 no-op)。
5966
+ mootMirrorPicker(a.sessionId);
5967
+ // 诊断: 屏上有高亮编号行却没判成 modal = 布局盲区候选 (askq/plan 面板
5968
+ // 也长这样, 属正常)。每屏记一次日志, 供远端排查"哨兵为何没反应"。
5969
+ if (looksLikePicker(v.screen)) {
5970
+ if (a.pickerSentinelFp !== v.screen) {
5971
+ a.pickerSentinelFp = v.screen;
5972
+ 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
5973
  }
5548
- continue;
5549
5974
  }
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");
5975
+ else {
5976
+ a.pickerSentinelFp = undefined;
5564
5977
  }
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
- });
5978
+ return;
5609
5979
  }
5610
- }
5611
- catch (e) {
5612
- log.warn({ err: e.message }, "picker sentinel tick failed");
5613
- }
5614
- finally {
5615
- pickerTicking = false;
5980
+ if (hasMirrorPicker(a.sessionId))
5981
+ return; // 审批卡在途, 等点击 / moot
5982
+ if (a.pickerSentinelFp === v.screen)
5983
+ return; // 同一屏已处理过, 等它变化
5984
+ a.pickerSentinelFp = v.screen;
5985
+ const pick = pickAutoAllowAnswer(parseModalOptions(v.screen));
5986
+ if (!pick) {
5987
+ log.info({ target: a.target, title: v.title }, "picker sentinel: modal on pane but not a trustworthy permission picker — leaving it");
5988
+ return;
5989
+ }
5990
+ const ctx = parseConfirmContext(v.screen);
5991
+ const policy = pickerPolicy(cfg, cliOf(a), ctx, isAutoWindowActive(a.target));
5992
+ if (policy.decision === "leave") {
5993
+ log.info({ target: a.target, reason: policy.reason }, "picker sentinel: independent approval left on CLI");
5994
+ }
5995
+ await dispatchPickerPolicy(policy, {
5996
+ deny: async () => {
5997
+ if (!valid())
5998
+ return;
5999
+ const result = await pressNativePickerUnlocked(a, modalFingerprint(v.screen), "no", valid);
6000
+ if (!valid())
6001
+ return;
6002
+ log.info({ target: a.target, reason: policy.reason, result }, "picker sentinel: deny rule");
6003
+ sendStandalone(a, `[mirror] 🚫 ${policy.reason},${result.ok ? "已拒绝 CLI 确认" : "确认框已变更,请回 CLI 处理"}`);
6004
+ },
6005
+ allow: async () => {
6006
+ if (!valid())
6007
+ return;
6008
+ const after = await pressModalIndex(pane, v, pick.index, valid);
6009
+ if (!valid())
6010
+ return;
6011
+ if (after.modal) {
6012
+ // 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
6013
+ a.pickerSentinelFp = after.screen;
6014
+ log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
6015
+ return;
6016
+ }
6017
+ a.pickerSentinelFp = undefined;
6018
+ log.info({ target: a.target, title: v.title, via: policy.reason, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
6019
+ sendStandalone(a, `[mirror] 🔓 ${policy.reason} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
6020
+ },
6021
+ manual: () => {
6022
+ if (!valid())
6023
+ return;
6024
+ // 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
6025
+ // 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
6026
+ log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, reason: policy.reason }, "picker sentinel: forwarding native picker as approval card");
6027
+ runMirrorPickerFlow({
6028
+ log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
6029
+ client,
6030
+ cfg,
6031
+ sessionId: a.sessionId,
6032
+ chatKey: a.target,
6033
+ cwd: a.runningCwd,
6034
+ jsonlPath: a.jsonlPath,
6035
+ toolName: ctx.toolName,
6036
+ toolInput: ctx.toolInput,
6037
+ agent: ctx.agent,
6038
+ danger: policy.forceSingle,
6039
+ voteTimeoutMs: cfg.approval.longPollSec * 1000,
6040
+ // Human waiting is outside the lock; the eventual press obtains a new transaction.
6041
+ press: (what) => pressNativePicker(a, modalFingerprint(v.screen), what, guard),
6042
+ }).then((sent) => {
6043
+ // 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
6044
+ // 否则这只框会被指纹永久静默。
6045
+ if (!sent && guard())
6046
+ a.pickerSentinelFp = undefined;
6047
+ }).catch((e) => {
6048
+ if (guard())
6049
+ a.pickerSentinelFp = undefined;
6050
+ log.warn({ err: e.message, target: a.target }, "mirror picker flow failed");
6051
+ });
6052
+ },
6053
+ });
6054
+ }, { guard });
6055
+ };
6056
+ const pickerTick = () => {
6057
+ if (!cfg.approval.enabled || process.env.WEZARD_PICKER_SENTINEL === "0")
6058
+ return;
6059
+ for (const a of byTarget.values()) {
6060
+ if (!a.tmuxPane || cliOf(a) === "codex")
6061
+ continue;
6062
+ const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
6063
+ void maintenance.schedule(`picker:${a.target}`, pane, (signal) => pickerOne(a, pane, sid, gen, signal))
6064
+ ?.catch((e) => {
6065
+ if (maintenanceValid(a, pane, sid, gen, maintenance.signal))
6066
+ a.pickerSentinelFp = undefined;
6067
+ log.warn({ target: a.target, err: e.message }, "picker sentinel tick failed");
6068
+ });
5616
6069
  }
5617
6070
  };
5618
6071
  const pickerTimer = setInterval(() => void pickerTick(), 5_000);
5619
6072
  // 审批卡点击后的代按回调。点击与本地按键之间隔着人的反应时间, 必须重读屏并
5620
6073
  // 校验仍是**同一个调用**的确认框 —— 期间本地按掉旧框又弹了新框的话, 盲按就是
5621
6074
  // 替用户批准了他没看过的东西。
5622
- const pressNativePicker = async (a, expectFingerprint, what) => {
5623
- if (!a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
6075
+ const pressNativePickerUnlocked = async (a, expectFingerprint, what, live) => {
6076
+ if (!live() || !a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
5624
6077
  return { ok: false, reason: "pane 已不在" };
5625
6078
  if (cliOf(a) === "codex")
5626
6079
  return { ok: false, reason: "Codex 原生沙箱审批必须单独处理" };
5627
6080
  const v = await detectModalPicker(a.tmuxPane);
6081
+ if (!live())
6082
+ return { ok: false, reason: "pane 事务已撤回" };
5628
6083
  if (!v.modal)
5629
6084
  return { ok: false, reason: "确认框已不在(可能已在本地处理)" };
5630
6085
  if (pickerPolicy(cfg, cliOf(a), parseConfirmContext(v.screen), false).decision === "leave") {
@@ -5645,11 +6100,12 @@ export const startMirror = (deps) => {
5645
6100
  const pick = sess ?? bare;
5646
6101
  if (!pick)
5647
6102
  return { ok: false, reason: "确认框上找不到可按的 Yes 选项" };
5648
- const after = await pressModalIndex(a.tmuxPane, v, pick.index);
6103
+ const after = await pressModalIndex(a.tmuxPane, v, pick.index, live);
5649
6104
  return after.modal
5650
6105
  ? { ok: false, reason: `按下 ${pick.index} 后确认框仍在` }
5651
6106
  : { ok: true, label: `${pick.index}. ${pick.label}` };
5652
6107
  };
6108
+ const pressNativePicker = async (a, fingerprint, what, guard) => withPaneInput(a.tmuxPane, (live) => pressNativePickerUnlocked(a, fingerprint, what, live), { guard });
5653
6109
  return {
5654
6110
  attach,
5655
6111
  chatTargets,
@@ -5873,35 +6329,24 @@ export const startMirror = (deps) => {
5873
6329
  // to in this process lifetime (or any session after a reload) has an empty
5874
6330
  // in-memory slot but a perfectly good persisted binding. Without this, an
5875
6331
  // 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" };
6332
+ const permission = () => !opts?.guard || opts.guard();
6333
+ const denied = { ok: false, retryable: true, reason: "inject permission withdrawn before respawn/binding" };
5879
6334
  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 });
6335
+ return { ok: false, retryable: true, reason: "empty text" };
6336
+ if (!permission())
6337
+ return denied;
6338
+ const a = byTarget.get(target) ?? (await restoreFromStore(target, undefined, permission));
6339
+ if (!permission())
6340
+ return denied;
6341
+ if (!a)
6342
+ return { ok: false, retryable: true, reason: "no mirror attached for target" };
5903
6343
  const sid = a.sessionId;
6344
+ const generation = a.injectGen ?? 0;
6345
+ const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
6346
+ const probedPane = a.tmuxPane;
5904
6347
  const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
6348
+ if (!permitted() || a.tmuxPane !== probedPane)
6349
+ return denied;
5905
6350
  // 同 dispatch: 只有刚生 / 刚重生的 pane 才要冷启动的宽限 —— 对一个热着、正忙的 pane 贴 `now`
5906
6351
  // 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
5907
6352
  let fresh = a.justSpawned === true;
@@ -5912,7 +6357,16 @@ export const startMirror = (deps) => {
5912
6357
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
5913
6358
  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
6359
  if (!r.ok || !r.tmuxPane)
5915
- return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
6360
+ return { ok: false, retryable: true, reason: `respawn failed: ${r.reason ?? "unknown"}` };
6361
+ if (!permitted() || a.tmuxPane !== probedPane) {
6362
+ // This pane was born for an unsubmitted injection. Never kill the old
6363
+ // executor or bind the withdrawn birth. A failed cleanup is explicitly
6364
+ // uncertain and must be reconciled; it is not proof execution stopped.
6365
+ const cleanup = await runTmux(["kill-pane", "-t", r.tmuxPane]).catch(() => undefined);
6366
+ return { ...denied, ...(cleanup?.ok ? {} : { uncertain: true, retryable: false }), reason: cleanup?.ok
6367
+ ? "respawn permission withdrawn; new pane removed before injection, binding withheld"
6368
+ : `respawn permission withdrawn; binding withheld, reconcile new pane ${r.tmuxPane}` };
6369
+ }
5916
6370
  a.tmuxPane = r.tmuxPane;
5917
6371
  a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
5918
6372
  if (r.cwd)
@@ -5925,18 +6379,34 @@ export const startMirror = (deps) => {
5925
6379
  startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
5926
6380
  fresh = true;
5927
6381
  }
5928
- // 同 dispatch: 在 inject 之前解除静默, 防止验证循环中 LLM 回复被永久吞掉。
5929
- a.muteUntilInject = false;
5930
- a.justSpawned = false;
6382
+ if (!permitted())
6383
+ return denied;
6384
+ // Busy/queued retries must not consume notices or stamp a future turn.
6385
+ // Render only after the pane lane grants delivery, then stamp exactly at
6386
+ // the first permitted input. activePeerTurn still changes in bindLine.
6387
+ const envelope = text.trimStart().startsWith("/") ? "" : opts?.envelope ?? "";
6388
+ let full = text + envelope;
6389
+ const beforeInput = () => {
6390
+ a.pendingFromCli = false;
6391
+ a.turnFromChat = true;
6392
+ rememberInject(full);
6393
+ pushSeal(a, { eventId: parseEnvelope(full)?.eventId, keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
6394
+ a.muteUntilInject = false;
6395
+ a.justSpawned = false;
6396
+ };
5931
6397
  // 同 dispatch 的 /clear 与静默分叉跟随: self-handoff / 同伴 / 定时任务也走这里注入,
5932
6398
  // 漏了它 tail 就钉在旧 jsonl 上, 直到下一条人话经 dispatch 才把它拽回来。
5933
6399
  const clearing = isClearCommand(text);
5934
6400
  const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
6401
+ const injectPane = a.tmuxPane;
5935
6402
  const r = await inject({
5936
- text: full, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
6403
+ 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
6404
  sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
6405
+ requireIdle: opts?.requireIdle ? () => idleNow(target) : undefined,
6406
+ prepareText: () => (full = text + envelope + noticeSuffixFor(target, text)),
6407
+ beforeInput,
5938
6408
  });
5939
- if (r.ok && a.tmuxPane) {
6409
+ if (r.ok && a.tmuxPane && permitted()) {
5940
6410
  if (preClearBaseline) {
5941
6411
  a.clearRebind = { baseline: preClearBaseline };
5942
6412
  startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
@@ -5947,9 +6417,15 @@ export const startMirror = (deps) => {
5947
6417
  return r;
5948
6418
  },
5949
6419
  interruptPane: async (target, opts) => {
6420
+ if (opts?.guard && !opts.guard())
6421
+ return { ok: false, reason: "interrupt authority changed" };
5950
6422
  const a = byTarget.get(target);
5951
6423
  if (!a)
5952
6424
  return { ok: false, reason: "no mirror attached for target" };
6425
+ const interruptedEvent = a.activeEventId;
6426
+ const interruptedPane = a.tmuxPane;
6427
+ if (opts?.teardown && interruptedEvent)
6428
+ deps.events?.cancel((e) => e.id === interruptedEvent);
5953
6429
  // `/stop` (teardown: true) must work when tmux itself is the problem, so
5954
6430
  // the order is: local state first, pane second. The old implementation
5955
6431
  // probed the pane FIRST and returned early on failure — meaning a wedged
@@ -5986,7 +6462,13 @@ export const startMirror = (deps) => {
5986
6462
  escReason = "tmux pane no longer alive";
5987
6463
  }
5988
6464
  else {
5989
- const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
6465
+ if (byTarget.get(target) !== a || a.tmuxPane !== interruptedPane || a.activeEventId !== interruptedEvent || (opts?.guard && !opts.guard()))
6466
+ return { ok: false, reason: "turn changed during interrupt probe; Escape withheld" };
6467
+ const r = await withPaneInput(interruptedPane, async (live) => {
6468
+ if (!live() || a.activeEventId !== interruptedEvent || a.tmuxPane !== interruptedPane || (opts?.guard && !opts.guard()))
6469
+ return { ok: false, stderr: "turn changed before Escape", stdout: "", code: 1 };
6470
+ return runTmux(["send-keys", "-t", interruptedPane, "Escape"]);
6471
+ });
5990
6472
  escOk = r.ok;
5991
6473
  if (!r.ok)
5992
6474
  escReason = `send-keys Escape failed: ${(r.stderr || r.stdout).slice(-200) || r.code}`;
@@ -6001,7 +6483,13 @@ export const startMirror = (deps) => {
6001
6483
  return { ok: false, reason: escReason };
6002
6484
  return { ok: true, torndown, escOk, escReason: escReason || undefined };
6003
6485
  },
6004
- killPane: (target) => downPane(target, true),
6486
+ killPane: async (target) => {
6487
+ deps.events?.cancel((e) => e.target === target);
6488
+ const r = await downPane(target, true);
6489
+ if (r.ok)
6490
+ deps.events?.stopped(target, "pane kill acknowledged; queued input revoked");
6491
+ return r;
6492
+ },
6005
6493
  sleepPane: (target) => downPane(target, false),
6006
6494
  lastActivity: (target) => {
6007
6495
  const jsonl = jsonlOf(target);
@@ -6252,6 +6740,7 @@ export const startMirror = (deps) => {
6252
6740
  clearInterval(paneDriftTimer);
6253
6741
  clearInterval(keepaliveTimer);
6254
6742
  clearInterval(pickerTimer);
6743
+ maintenance.stop();
6255
6744
  for (const a of bySessionId.values()) {
6256
6745
  if (a.outbound?.kind === "deferred")
6257
6746
  clearTimeout(a.outbound.timer);
@@ -6269,11 +6758,17 @@ export const startMirror = (deps) => {
6269
6758
  return a.convFrom.asker;
6270
6759
  return a?.speaker ? { who: a.speaker, chat: a.channel ?? baseOfKey(target) } : undefined;
6271
6760
  },
6761
+ answerContext, prepareAnswer, takeAnswer, cancelAnswerPreparation, stopAnswerProgress, deliverAnswer,
6762
+ setAnswerSilencer: (fn) => { silenceAnswer = fn; },
6272
6763
  currentChannel: (target) => {
6273
6764
  const a = byTarget.get(target);
6274
6765
  return a?.channel ?? baseOfKey(target);
6275
6766
  },
6276
- dispatch: async ({ principal, text, images, frame, streamId, channel, speaker }) => {
6767
+ formatFinal,
6768
+ recoverEventFinal,
6769
+ setEventFinalGate: (gate) => { eventFinalGate = gate; },
6770
+ settleFinalProgress,
6771
+ dispatch: async ({ principal, text, images, frame, streamId, channel, speaker, eventId, guard, onSubmitted }) => {
6277
6772
  // Route by inbound principal — the WeCom chat the user just messaged us
6278
6773
  // from is the same string we registered as `target` on attach. After a
6279
6774
  // daemon reload the in-memory map is empty; restore from the persisted
@@ -6291,6 +6786,10 @@ export const startMirror = (deps) => {
6291
6786
  }
6292
6787
  return;
6293
6788
  }
6789
+ if (eventId && a.tmuxPane && await tmuxPaneAlive(a.tmuxPane) && !(await idleNow(principal))) {
6790
+ onSubmitted?.({ ok: false, retryable: true, reason: "target became busy before opening the human turn" });
6791
+ return;
6792
+ }
6294
6793
  // `/clear` resets to an empty session — nothing in the cache to keep warm,
6295
6794
  // so pause keepalive like `/stop` instead of lifting it. Every other inbound
6296
6795
  // is real work: lift any prior pause and re-anchor both clocks to now.
@@ -6406,17 +6905,19 @@ export const startMirror = (deps) => {
6406
6905
  if (s) {
6407
6906
  a.liveStream = s;
6408
6907
  try {
6409
- await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
6908
+ await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
6410
6909
  }
6411
6910
  catch (e) {
6412
6911
  log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
6413
6912
  }
6414
6913
  }
6914
+ if (!brief)
6915
+ a.activeEventId = eventId;
6415
6916
  if (brief) {
6416
6917
  // 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
6417
6918
  // 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
6418
6919
  // 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
6419
- await startBriefTurn(a, frame, streamId, isSlash, effText, channel ?? baseOfKey(principal), speaker);
6920
+ await startBriefTurn(a, frame, streamId, isSlash, effText, channel ?? baseOfKey(principal), speaker, eventId);
6420
6921
  }
6421
6922
  else if (!armMigration && !eagerOpen) {
6422
6923
  enterDeferred(a, frame, streamId);
@@ -6503,6 +7004,8 @@ export const startMirror = (deps) => {
6503
7004
  log.warn({ target: a.target, sessionId: sid, myGen, gen: a.injectGen }, "inject aborted — superseded by /stop or watchdog");
6504
7005
  return;
6505
7006
  }
7007
+ if (!brief)
7008
+ pushSeal(a, { eventId: a.activeEventId, keys: sealKeys(effText), at: Date.now(), query: effText, channel: channel ?? baseOfKey(principal), speaker, fresh: true });
6506
7009
  rememberInject(effText);
6507
7010
  // 解除静默必须在 inject 之前: inject 内部的 freshSpawn 验证循环可达 10+s
6508
7011
  // (paste-verify + settle + waitForCleared + retry), 而 LLM 的回复可能在 Enter
@@ -6511,10 +7014,14 @@ export const startMirror = (deps) => {
6511
7014
  // 安全性: newSession 的 fresh pane 在 inject 前只写过 system/init (→ 无 item),
6512
7015
  // 用户行有 isOwnInject 过滤 —— 提前解除不会泄漏脏数据。
6513
7016
  a.muteUntilInject = false;
7017
+ const injectPane = a.tmuxPane;
6514
7018
  const r = await inject({
6515
7019
  text: effText, images: effImages, cfg, log: log.child({ principal, sessionId: sid }),
6516
7020
  sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
7021
+ requireIdle: eventId ? () => idleNow(a.target) : undefined,
7022
+ guard: (phase) => byTarget.get(a.target) === a && a.tmuxPane === injectPane && (a.injectGen ?? 0) === myGen && (!guard || guard(phase)),
6517
7023
  });
7024
+ onSubmitted?.(r);
6518
7025
  if (!r.ok) {
6519
7026
  if (s) {
6520
7027
  s.acc = s.acc ? `${s.acc}\n\n[mirror] ✗ ${r.reason ?? "failed"}` : `[mirror] ✗ ${r.reason ?? "failed"}`;
@@ -6577,10 +7084,7 @@ export const startMirror = (deps) => {
6577
7084
  // and surface a standalone "cleared" so the user gets explicit
6578
7085
  // feedback (the skip-stream path otherwise leaves WeCom silent).
6579
7086
  if (armMigration) {
6580
- // cwd 不随回执下发 (/pwd 可查); slashAckFirstLine 连 tip 也省去 (与 /new 一致)。
6581
- sendStandalone(a, cfg.wrc.mirror.slashAckFirstLine
6582
- ? "cleared"
6583
- : `cleared\n\n${randomTip()}`);
7087
+ sendStandalone(a, "cleared");
6584
7088
  a.clearRebind = { baseline: preClearBaseline };
6585
7089
  startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
6586
7090
  }