wezard 2.7.2 → 2.7.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.
@@ -1,6 +1,6 @@
1
1
  import { finalId } from "./message-events.js";
2
2
  import { parseEnvelope, renderTail } from "../shared/reminder.js";
3
- import { codexSessions, codexSessionForPane } from "../shared/codex.js";
3
+ import { codexSessions, codexSessionForPane, recordCodexPaneSession } from "../shared/codex.js";
4
4
  import { codexHookLaunchArgs, codexHookCommand } from "../shared/codex-hooks.js";
5
5
  import { fileURLToPath } from "node:url";
6
6
  // Mirror-mode bridge.
@@ -1288,9 +1288,6 @@ const BOX_PROMPT_RE = /^\s*[❯>]\s?/u;
1288
1288
  const BOX_FOOTER_ROWS = 6;
1289
1289
  const UNKNOWN_BOX = { known: false, body: "" };
1290
1290
  export const parseInputBox = (cap, styledLayout = cap) => {
1291
- const lines = cap.split("\n");
1292
- while (lines.length && lines[lines.length - 1].trim() === "")
1293
- lines.pop();
1294
1291
  // Codex has no rulers. Anchor the composer to its bottom shortcuts footer,
1295
1292
  // then use the LAST › prompt: earlier prompts belong to the transcript.
1296
1293
  // Requiring a status row and a blank separator avoids treating arbitrary
@@ -1301,36 +1298,50 @@ export const parseInputBox = (cap, styledLayout = cap) => {
1301
1298
  while (layout.length && !layout[layout.length - 1].trim())
1302
1299
  layout.pop();
1303
1300
  const footer = layout.length - 1;
1304
- // Codex hides the shortcuts row while the composer contains text.
1305
- const shortcuts = /(?:^|\s)\? for shortcuts(?:\s|$)/u.test(layout[footer] ?? "");
1306
- if (shortcuts || (layout[footer] ?? "").includes("·")) {
1307
- let status = shortcuts ? footer - 1 : footer;
1301
+ // A populated Codex composer replaces shortcuts with "tab to queue message"
1302
+ // and may have no background SGR. Its footer still anchors the editable box;
1303
+ // missing it sends collapsed pastes through the 15s late-landing window.
1304
+ const inputHint = /(?:^|\s)(?:\? for shortcuts|tab to queue message)(?:\s|$)/u.test(layout[footer] ?? "");
1305
+ // Idle Codex hides the left hint after a paste but keeps a right-aligned
1306
+ // warnings footer. Its middle dot is not the status row's separator.
1307
+ const warnings = /^\s*⚠\s*\d+\s+warnings?\s*·\s*f2 to view\s*$/u.test(layout[footer] ?? "");
1308
+ // --no-alt-screen 的 composer 没有背景 SGR; 原生 Context + cwd 行才是状态锚, warning 的 · 不能充当它。
1309
+ const inlineStatus = /\bContext\s+\d+%\s+used\b/u.test(layout[footer] ?? "")
1310
+ && /·\s+(?:\/|~\/)/u.test(layout[footer] ?? "");
1311
+ if (inputHint || warnings || (layout[footer] ?? "").includes("·")) {
1312
+ let status = inputHint || warnings ? footer - 1 : footer;
1308
1313
  while (status >= 0 && !layout[status].trim())
1309
1314
  status--;
1310
1315
  if (status >= 0 && footer - status <= BOX_FOOTER_ROWS && layout[status].includes("·") && status > 0 && !layout[status - 1].trim()) {
1311
1316
  let prompt = status - 2;
1312
- // Continuation rows are indented; a › inside pasted content is not a
1313
- // new composer prompt.
1314
- while (prompt >= 0 && !/^›(?:\s|$)/u.test(layout[prompt]))
1317
+ // Only contiguous, indented continuation rows belong to this composer.
1318
+ // Crossing a blank/output row can otherwise select a historical user
1319
+ // prompt (which may carry the same background SGR as the live box).
1320
+ while (prompt >= 0 && /^\s{2,}\S/u.test(layout[prompt]))
1315
1321
  prompt--;
1316
- if (prompt >= 0 && (shortcuts || /\x1b\[[0-9;:]*48[;:]/u.test(styledLines[prompt]))) {
1322
+ if (prompt >= 0 && /^›(?:\s|$)/u.test(layout[prompt])
1323
+ && (prompt === 0 || !layout[prompt - 1].trim())
1324
+ && (inputHint || inlineStatus || /\x1b\[[0-9;:]*48[;:]/u.test(styledLines[prompt]))) {
1317
1325
  const body = dropDim(styledLines.slice(prompt, status).join("\n"))
1318
1326
  .replace(/^\s*›\s?/u, "").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
1319
1327
  return { known: true, body };
1320
1328
  }
1321
1329
  }
1322
1330
  }
1323
- const rulers = lines.reduce((acc, l, i) => (BOX_RULER_RE.test(l) ? [...acc, i] : acc), []);
1331
+ // Dim is presentation, not layout: removing it before locating rulers also
1332
+ // removes dim borders/prompts, leaving collapsed pastes unverifiable.
1333
+ const rulers = layout.reduce((acc, l, i) => (BOX_RULER_RE.test(l) ? [...acc, i] : acc), []);
1324
1334
  const bottom = rulers[rulers.length - 1];
1325
1335
  const top = rulers[rulers.length - 2];
1326
1336
  if (top === undefined || bottom === undefined || bottom - top < 1)
1327
1337
  return UNKNOWN_BOX;
1328
- if (lines.length - bottom > BOX_FOOTER_ROWS)
1338
+ if (layout.length - bottom > BOX_FOOTER_ROWS)
1329
1339
  return UNKNOWN_BOX;
1330
- const rows = lines.slice(top + 1, bottom);
1340
+ const rows = layout.slice(top + 1, bottom);
1331
1341
  if (!rows.length || !BOX_PROMPT_RE.test(rows[0]))
1332
1342
  return UNKNOWN_BOX;
1333
- const body = [rows[0].replace(BOX_PROMPT_RE, ""), ...rows.slice(1)].join("\n").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
1343
+ const body = dropDim(styledLines.slice(top + 1, bottom).join("\n"))
1344
+ .replace(BOX_PROMPT_RE, "").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
1334
1345
  return { known: true, body };
1335
1346
  };
1336
1347
  // CodeBuddy 忙时回车不落盘: 那句挂进输入框上方的待发队列, 一条一行
@@ -1438,8 +1449,12 @@ const paneHasLiveSession = async (paneId, homeDir, panePid, ps) => {
1438
1449
  const root = panePid ?? (await panePidOf(paneId));
1439
1450
  if (!root)
1440
1451
  return false;
1441
- const { kids } = ps ?? (await psSnap());
1442
- return registeredPidUnder(root, liveSessionPids(homeDir), kids) !== undefined;
1452
+ const snapshot = ps ?? (await psSnap());
1453
+ // Codex runs under a foreground bash wrapper and has no Claude registry.
1454
+ const codexPids = codexSessionForPane(paneId)
1455
+ ? [...snapshot.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid)
1456
+ : [];
1457
+ return registeredPidUnder(root, new Set([...liveSessionPids(homeDir), ...codexPids]), snapshot.kids) !== undefined;
1443
1458
  };
1444
1459
  /** 注入前问 pane 一句话: 还在吗 / 里面还有 AI 会话吗。
1445
1460
  * `unknown` 必须由调用方按「不知道」处理 —— 不许当 pane 死了去重生。 */
@@ -1456,6 +1471,19 @@ const probePane = async (paneId, jsonlPath) => {
1456
1471
  return { state: "unknown" };
1457
1472
  return SHELL_RE.test(cmd.stdout.trim()) ? { state: "shell" } : { state: "session" };
1458
1473
  };
1474
+ // Recover only an exact SessionStart transcript match; a shared cwd is not identity.
1475
+ const codexPaneForTranscript = async (jsonlPath) => {
1476
+ if (backendForPath(jsonlPath).name !== "codex")
1477
+ return "";
1478
+ const panes = await runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
1479
+ if (!panes.ok)
1480
+ return "";
1481
+ for (const pane of panes.stdout.trim().split("\n")) {
1482
+ if (codexSessionForPane(pane)?.jsonlPath === jsonlPath && (await probePane(pane, jsonlPath)).state === "session")
1483
+ return pane;
1484
+ }
1485
+ return "";
1486
+ };
1459
1487
  /** 注册表没有 tmux 字段的后端 (codebuddy): pane 里住着谁只能沿 pane 的进程树往下找
1460
1488
  * 注册表登记过、还活着的 pid。冷 pane 的注册行晚于进程出生, `waitMs` 内轮询。 */
1461
1489
  const hostPidOnPane = async (target, homeDir, waitMs) => {
@@ -1530,9 +1558,9 @@ const witnessFor = (target, homeDir, jsonlPath, live, actualTranscript) => {
1530
1558
  // but the trailing Enter is eaten while the TUI is still initializing, so
1531
1559
  // the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
1532
1560
  // 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
1533
- const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard, eventId, expectedSessionId) => {
1561
+ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard, eventId, expectedSessionId, physicalGuard) => {
1534
1562
  const backendName = backend.name;
1535
- log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
1563
+ log.info({ target, eventId, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
1536
1564
  // 就绪门: pane 里得真有一个进入了交互态的 AI 会话, 才轮得到贴。先于 modal guard ——
1537
1565
  // 这一步只读注册表, 会话都不在时也就不必去 capture pane 找对话框了。
1538
1566
  const gate = await awaitSession(target, backend.homeDir, freshSpawn, log);
@@ -1638,7 +1666,7 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1638
1666
  // dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
1639
1667
  // withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
1640
1668
  if (backendName === "codebuddy") {
1641
- return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, boundGuard, eventId);
1669
+ return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, boundGuard, eventId, bypassModalGuard, physicalGuard);
1642
1670
  }
1643
1671
  // Pump images first via clipboard+C-v so each one is attached as a separate
1644
1672
  // image content block. Each C-v needs a brief settle for Claude Code's TUI
@@ -1690,7 +1718,7 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1690
1718
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1691
1719
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
1692
1720
  }
1693
- return injectViaTmuxText(target, text, log, freshSpawn, witness, boundGuard, eventId);
1721
+ return injectViaTmuxText(target, text, log, freshSpawn, witness, boundGuard, eventId, bypassModalGuard, physicalGuard);
1694
1722
  };
1695
1723
  // 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
1696
1724
  // 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
@@ -1712,7 +1740,7 @@ const withImageRefs = (text, images) => {
1712
1740
  // 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
1713
1741
  // 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
1714
1742
  // 每次补之前再问一遍证人, 已提交就不补。
1715
- const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard, eventId) => {
1743
+ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard, eventId, bypassModalGuard = false, physicalGuard) => {
1716
1744
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1717
1745
  // started, transcript still loading): extended timings — bracketed-paste
1718
1746
  // end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
@@ -1740,8 +1768,16 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1740
1768
  // 时间门往前放 1s: jsonl 的 timestamp 与本机时钟同源, 只差写盘的毫秒。
1741
1769
  const since = Date.now() - 1000;
1742
1770
  let enterAt = Number.POSITIVE_INFINITY;
1743
- const submitted = () => requireEventEvidence
1744
- ? witness.submittedEvent(eventId, since) : witness.submitted(probe, since, enterAt);
1771
+ let confirmed = false;
1772
+ const submitted = () => {
1773
+ const yes = requireEventEvidence
1774
+ ? witness.submittedEvent(eventId, since) : witness.submitted(probe, since, enterAt);
1775
+ if (yes && !confirmed) {
1776
+ confirmed = true;
1777
+ log.info({ target, eventId, enterToEvidenceMs: Number.isFinite(enterAt) ? Date.now() - enterAt : undefined }, "mirror inject: submission confirmed");
1778
+ }
1779
+ return yes;
1780
+ };
1745
1781
  // 贴之前框里是什么: 空框、幽灵提示 (`Try "how do I log an error?"`)、甚至用户
1746
1782
  // 自己敲了一半的字 —— 一律作为基线, 免得去维护一张「哪些文案算空」的清单。
1747
1783
  const pre = await readPaneInput(target);
@@ -1755,9 +1791,23 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1755
1791
  const alreadyTyped = boxVerify && Boolean(probe) && stripWs(preBox.body).includes(probe);
1756
1792
  // 提交后框该回到的样子: 正常是贴之前的基线; 文本本就在框里时基线就是它, 只能以「空」为准。
1757
1793
  const baseBody = alreadyTyped ? "" : preBox.body;
1794
+ let landedBody = alreadyTyped ? preBox.body : undefined;
1758
1795
  /** 框里还是贴之前那样 (或彻底空了) = 我们的文本不在框里。 */
1759
1796
  const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === baseBody);
1760
- const loadAndPaste = () => pasteTmuxBuffer(target, text, guard ? () => guard("paste") : undefined);
1797
+ const loadAndPaste = async () => {
1798
+ const start = Date.now();
1799
+ const result = await pasteTmuxBuffer(target, text, async () => (!physicalGuard || await physicalGuard("paste")) && (!guard || guard("paste")));
1800
+ log.info({ target, eventId, tmuxPasteMs: Date.now() - start, boxVerify, ok: result.ok }, "mirror inject: paste completed");
1801
+ return result;
1802
+ };
1803
+ const landed = (cap, box) => {
1804
+ const seen = box.known ? (boxVerify ? !boxIntact(box) : box.body !== "") : Boolean(headFp) && stripWs(cap).includes(headFp);
1805
+ // Bind recovery to the first observed paste, not arbitrary contents found
1806
+ // after settling: another writer may have replaced the draft meanwhile.
1807
+ if (seen && box.known && landedBody === undefined)
1808
+ landedBody = box.body;
1809
+ return seen;
1810
+ };
1761
1811
  // 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
1762
1812
  // 头部可能在框顶, 所以窗口要宽)。
1763
1813
  const sawLanded = async (timeoutMs) => {
@@ -1766,7 +1816,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1766
1816
  const { cap, box } = await readPaneInput(target);
1767
1817
  // A redraw can hide the baseline. Once the composer reappears, its
1768
1818
  // nonempty body also proves landing of a collapsed paste.
1769
- if (box.known ? (boxVerify ? !boxIntact(box) : box.body !== "") : Boolean(headFp) && stripWs(cap).includes(headFp))
1819
+ if (landed(cap, box))
1770
1820
  return true;
1771
1821
  await sleep(POLL_MS);
1772
1822
  }
@@ -1778,7 +1828,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1778
1828
  if (submitted())
1779
1829
  return "submitted";
1780
1830
  const { cap, box } = await readPaneInput(target);
1781
- if (box.known ? (boxVerify ? !boxIntact(box) : box.body !== "") : Boolean(headFp) && stripWs(cap).includes(headFp))
1831
+ if (landed(cap, box))
1782
1832
  return "landed";
1783
1833
  if (Date.now() - t0 >= timeoutMs)
1784
1834
  return box.known ? "intact" : "unknown";
@@ -1797,6 +1847,8 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1797
1847
  let pastedAt = Date.now();
1798
1848
  if (alreadyTyped) {
1799
1849
  // Existing text is already exposed, even if permission was just withdrawn.
1850
+ if (physicalGuard && !(await physicalGuard("submit")))
1851
+ return { ok: false, uncertain: true, reason: "original handoff CLI identity changed before existing input submission" };
1800
1852
  if (guard && !guard("paste"))
1801
1853
  return { ok: false, uncertain: true, reason: "existing input submission canceled by dispatch guard" };
1802
1854
  log.warn({ target, headFp }, "mirror inject: 输入框里已有这段文本 (上次未提交) — 不重贴, 直接提交");
@@ -1878,8 +1930,28 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1878
1930
  await sleep(Math.max(0, POST_PASTE_SETTLE_FALLBACK_MS - (Date.now() - pastedAt)));
1879
1931
  }
1880
1932
  const sendEnter = async () => {
1933
+ // A picker can open after the pre-paste gate, especially during a delayed
1934
+ // render. Check again before every Enter; askq text entry keeps its bypass.
1935
+ if (process.env.WEZARD_MODAL_GUARD !== "0" && !bypassModalGuard) {
1936
+ const modal = await detectModalPicker(target);
1937
+ if (modal.modal || !modal.screen.trim())
1938
+ return { ok: false, reason: "native picker or unreadable pane before submission; refusing Enter" };
1939
+ }
1940
+ if (submitted())
1941
+ return { ok: true };
1942
+ if (landedBody !== undefined) {
1943
+ const box = await readInputBox(target);
1944
+ if (!box.known || box.body !== landedBody)
1945
+ return { ok: false, reason: "composer changed after paste verification; refusing Enter" };
1946
+ }
1947
+ if (physicalGuard && !(await physicalGuard("submit")))
1948
+ return { ok: false, reason: "original handoff CLI identity changed before submit" };
1881
1949
  if (guard && !guard("submit"))
1882
1950
  return { ok: false, reason: "submit canceled by dispatch guard" };
1951
+ if (tailFp && !Number.isFinite(enterAt)) {
1952
+ enterAt = Date.now();
1953
+ log.info({ target, eventId, pasteToEnterMs: enterAt - pastedAt, pasteSeen, boxTrusted, freshSpawn }, "mirror inject: first Enter");
1954
+ }
1883
1955
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1884
1956
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
1885
1957
  };
@@ -1930,19 +2002,26 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1930
2002
  }
1931
2003
  // 回车可能被冷启动中的 TUI 吞掉 (框里躺着没提交的提示)。框认得出来时判据可信,
1932
2004
  // 多补一次; 认不出来时维持原来的「只补一次」, 免得判据出错往真实对话里灌回车。
1933
- const boxTrusted = boxVerify && (await readInputBox(target)).known;
1934
- const attempts = requireEventEvidence ? 1 : boxTrusted ? 3 : 2;
2005
+ const pendingBox = await readInputBox(target);
2006
+ const boxTrusted = boxVerify && pendingBox.known;
2007
+ // An event may retry Enter only while the very same landed composer remains.
2008
+ // Empty/unknown/changed boxes need transcript evidence, never another paste
2009
+ // or a blind Enter that could confirm a newly opened native picker.
2010
+ const pendingBody = pasteSeen && pendingBox.known && pendingBox.body === landedBody
2011
+ ? landedBody : undefined;
2012
+ const attempts = requireEventEvidence ? pendingBody ? 3 : 1 : boxTrusted ? 3 : 2;
1935
2013
  for (let i = 0; i < attempts; i++) {
1936
2014
  if (i > 0) {
1937
2015
  await sleep(RETRY_SETTLE_MS);
1938
2016
  if (submitted())
1939
2017
  return { ok: true }; // 迟到的确认 —— 补的这一下会落在空框上, 省了
2018
+ if (requireEventEvidence) {
2019
+ const box = await readInputBox(target);
2020
+ if (!box.known || box.body !== pendingBody)
2021
+ break;
2022
+ }
1940
2023
  log.warn({ target, tailFp, attempt: i + 1 }, "mirror inject: 回车后输入框未清空, 再补一次");
1941
2024
  }
1942
- if (i === 0) {
1943
- enterAt = Date.now();
1944
- log.info({ target, pasteToEnterMs: enterAt - pastedAt, pasteSeen, boxTrusted, freshSpawn }, "mirror inject: first Enter");
1945
- }
1946
2025
  const e = await sendEnter();
1947
2026
  if (!e.ok)
1948
2027
  return e;
@@ -1965,11 +2044,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1965
2044
  log.warn({ target, eventId, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: 框空了且转录里始终没有这一笔, 判不确定并停止重贴");
1966
2045
  return { ok: false, uncertain: true, reason: "composer cleared without this event's transcript marker; delivery remains uncertain and will not be resubmitted" };
1967
2046
  }
1968
- // 补到头还没清空。通常是渲染滞后 (回车其实落了), 但也可能是目标会话正忙 /
1969
- // 上下文满了不消费输入 —— 那样用户这条消息会悄无声息地消失。不报硬失败 (用户
1970
- // 多半已经被服务到了), 标 uncertain 让调用方按需提示。
1971
- log.warn({ target, tailFp, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: clear not observed, trusting submit");
1972
- return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
2047
+ // 补到头仍没证据: 可能只是渲染/落盘迟到, 也可能根本没提交。
2048
+ // 保留 uncertain 阻止重贴, 但不能让只看 ok 的调用方把它当成已送达。
2049
+ log.warn({ target, tailFp, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: submission unconfirmed, stopping input");
2050
+ return { ok: false, uncertain: true, reason: "目标会话未确认收下消息,停止输入以避免误提交或重复投递" };
1973
2051
  };
1974
2052
  const injectViaSpawn = async (args) => {
1975
2053
  const { text, images = [], cfg, log, sessionId, jsonlPath } = args;
@@ -2100,7 +2178,7 @@ const inject = async (args) => {
2100
2178
  }
2101
2179
  return true;
2102
2180
  };
2103
- return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard, args.eventId, args.sessionId);
2181
+ return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard, args.eventId, args.sessionId, args.physicalGuard);
2104
2182
  }, { guard: args.guard }));
2105
2183
  }
2106
2184
  catch (error) {
@@ -2128,17 +2206,19 @@ const etimeSec = (s) => {
2128
2206
  return ((d * 24 + h) * 60 + mi) * 60 + se;
2129
2207
  };
2130
2208
  const psSnap = () => new Promise((resolve) => {
2131
- const p = spawn("ps", ["-axo", "pid=,ppid=,etime="], { env: { ...process.env, PATH: augmentedPath(process.env.PATH) }, stdio: ["ignore", "pipe", "ignore"] });
2209
+ const p = spawn("ps", ["-axo", "pid=,ppid=,etime=,comm="], { env: { ...process.env, PATH: augmentedPath(process.env.PATH) }, stdio: ["ignore", "pipe", "ignore"] });
2132
2210
  let out = "";
2133
2211
  p.stdout?.on("data", (c) => (out += c.toString("utf8")));
2134
2212
  const done = () => {
2135
2213
  const kids = new Map();
2136
2214
  const etimes = new Map();
2215
+ const commands = new Map();
2137
2216
  for (const line of out.split("\n")) {
2138
- const [pidS = "", ppidS = "", etS = ""] = line.trim().split(/\s+/);
2217
+ const [pidS = "", ppidS = "", etS = "", ...command] = line.trim().split(/\s+/);
2139
2218
  const pid = Number(pidS), ppid = Number(ppidS), et = etimeSec(etS);
2140
2219
  if (!pid || !ppid)
2141
2220
  continue;
2221
+ commands.set(pid, command.join(" "));
2142
2222
  const arr = kids.get(ppid);
2143
2223
  if (arr)
2144
2224
  arr.push(pid);
@@ -2147,10 +2227,10 @@ const psSnap = () => new Promise((resolve) => {
2147
2227
  if (!Number.isNaN(et))
2148
2228
  etimes.set(pid, et);
2149
2229
  }
2150
- resolve({ kids, etimes });
2230
+ resolve({ kids, etimes, commands });
2151
2231
  };
2152
2232
  p.on("close", done);
2153
- p.on("error", () => resolve({ kids: new Map(), etimes: new Map() }));
2233
+ p.on("error", () => resolve({ kids: new Map(), etimes: new Map(), commands: new Map() }));
2154
2234
  });
2155
2235
  /** Drop a session's queue chain. A hung job can't be cancelled (it's parked in a
2156
2236
  * tmux read), but detaching the chain means the NEXT message doesn't inherit
@@ -2292,6 +2372,97 @@ export const startMirror = (deps) => {
2292
2372
  };
2293
2373
  const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
2294
2374
  let eventFinalGate = () => true;
2375
+ let eventOwnerGate = () => true;
2376
+ const eventOwned = (id) => {
2377
+ const event = id ? deps.events?.get(id) : undefined;
2378
+ return !event || eventOwnerGate(event);
2379
+ };
2380
+ const briefMode = (a) => cfg.wrc.mirror.brief
2381
+ || !!(a.activeEventId && deps.events?.get(a.activeEventId)?.payload.peer?.transferred);
2382
+ const bubbleOwned = (a, b) => {
2383
+ const event = b.owner ? deps.events?.get(b.owner) : undefined;
2384
+ return !event || event.target === a.target && eventOwnerGate(event);
2385
+ };
2386
+ const replyBubbles = new Map();
2387
+ const rememberReplyBubble = (id, bubble) => {
2388
+ if (replyBubbles.has(id))
2389
+ return;
2390
+ replyBubbles.set(id, bubble);
2391
+ setTimeout(() => { if (replyBubbles.get(id) === bubble)
2392
+ replyBubbles.delete(id); }, Math.max(0, (bubble.expires ?? Date.now()) - Date.now()) + 60_000).unref?.();
2393
+ };
2394
+ const claimReplyBubble = (event) => {
2395
+ if (!deps.events || !eventOwnerGate(event) || !event.channel)
2396
+ return undefined;
2397
+ const origin = deps.events.replyOrigin(event);
2398
+ const human = origin?.payload.human;
2399
+ if (!origin || !human)
2400
+ return undefined;
2401
+ const source = byTarget.get(origin.target);
2402
+ const out = source?.outbound;
2403
+ if (source && out?.streamId === human.streamId) {
2404
+ if (out.kind === "deferred")
2405
+ clearTimeout(out.timer);
2406
+ source.outbound = undefined;
2407
+ }
2408
+ let bubble = replyBubbles.get(origin.id);
2409
+ if (!bubble) {
2410
+ const expires = origin.at + HARD_TIMEOUT_MS;
2411
+ if (expires <= Date.now() || deps.events.hasReplyDelivery(human.streamId))
2412
+ return undefined;
2413
+ bubble = { frame: human.frame, streamId: human.streamId, expires, done: false, owner: event.id };
2414
+ rememberReplyBubble(origin.id, bubble);
2415
+ }
2416
+ if (bubble.done || Date.now() >= (bubble.expires ?? 0))
2417
+ return undefined;
2418
+ if (bubble.source) {
2419
+ const stream = bubble.source;
2420
+ stream.closed = true;
2421
+ clearTimeout(stream.flushTimer);
2422
+ clearTimeout(stream.idleTimer);
2423
+ clearTimeout(stream.hardTimer);
2424
+ bubble.pending = stream.pending;
2425
+ const source = byTarget.get(stream.target);
2426
+ if (source?.liveStream === stream)
2427
+ source.liveStream = undefined;
2428
+ bubble.source = undefined;
2429
+ }
2430
+ if (bubble.owner !== event.id) {
2431
+ // The same object carries the last in-flight write into the receiver's FIFO.
2432
+ // Old final/cleanup/timer paths lose authority before this object is reused.
2433
+ byTarget.forEach((a) => {
2434
+ if (a.briefBubble === bubble) {
2435
+ clearCot(a);
2436
+ a.briefBubble = undefined;
2437
+ }
2438
+ });
2439
+ clearTimeout(bubble.hardTimer);
2440
+ bubble.owner = event.id;
2441
+ }
2442
+ return bubble;
2443
+ };
2444
+ const transferReply = (event) => {
2445
+ const bubble = claimReplyBubble(event);
2446
+ if (!bubble)
2447
+ return;
2448
+ const origin = deps.events?.replyOrigin(event);
2449
+ bubble.pending = (bubble.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2450
+ if (bubble.done || bubble.owner !== event.id || !eventOwnerGate(event))
2451
+ return;
2452
+ await client.replyStream(bubble.frame, bubble.streamId, withLinkedTagHeader(event.target, "…", chatTurnUrl(event.target, event.turn || origin?.turn), undefined, undefined, event.channel), false);
2453
+ }).catch((e) => { log.debug({ event: event.id, err: errText(e) }, "transfer reply refresh failed"); });
2454
+ };
2455
+ const armBriefBubble = (a, b, turnId, channel) => {
2456
+ clearTimeout(b.hardTimer);
2457
+ const owner = b.owner;
2458
+ if (!isWxFrame(b.frame))
2459
+ b.hardTimer = setTimeout(() => {
2460
+ if (b.owner !== owner || !bubbleOwned(a, b))
2461
+ return;
2462
+ const live = a.briefBubble === b && !a.briefConcluded;
2463
+ void finishBubble(a, b, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
2464
+ }, Math.max(0, (b.expires ?? Date.now()) - Date.now()));
2465
+ };
2295
2466
  const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2296
2467
  const newMarkId = () => `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2297
2468
  const detailCardFor = (s, target) => {
@@ -2309,13 +2480,13 @@ export const startMirror = (deps) => {
2309
2480
  };
2310
2481
  const flushStream = async (s) => {
2311
2482
  s.flushTimer = undefined;
2312
- if (s.closed || s.dead || s.acc === s.lastSent)
2483
+ if (s.closed || s.dead || s.acc === s.lastSent || !eventOwned(s.eventId))
2313
2484
  return;
2314
2485
  const content = s.acc;
2315
2486
  s.lastSent = content;
2316
2487
  try {
2317
2488
  await (s.pending = (s.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2318
- if (s.closed || s.dead)
2489
+ if (s.closed || s.dead || !eventOwned(s.eventId))
2319
2490
  return;
2320
2491
  await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
2321
2492
  }));
@@ -2356,10 +2527,12 @@ export const startMirror = (deps) => {
2356
2527
  // 例外是 hard cap 超时: turn 还在跑, 收口时缀上中间结束语 —— 这时即使没正文
2357
2528
  // 也有话可说, 不算空覆盖。
2358
2529
  const body = timedOut ? [s.acc, STILL_WORKING].filter((x) => x.trim()).join("\n\n") : s.acc;
2359
- if (!s.dead && body.trim()) {
2530
+ if (!s.dead && body.trim() && eventOwned(s.eventId)) {
2360
2531
  const card = detailCardFor(s, a.target);
2361
2532
  try {
2362
2533
  await s.pending?.catch(() => undefined);
2534
+ if (!eventOwned(s.eventId))
2535
+ return;
2363
2536
  if (card) {
2364
2537
  s.cardSent = true;
2365
2538
  await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
@@ -2388,8 +2561,9 @@ export const startMirror = (deps) => {
2388
2561
  if (a.liveStream === s)
2389
2562
  a.liveStream = undefined;
2390
2563
  };
2391
- const openStream = (a, frame, streamId) => {
2564
+ const openStream = (a, frame, streamId, eventId = a.activeEventId) => {
2392
2565
  const s = {
2566
+ eventId,
2393
2567
  turnId: newTurnId(),
2394
2568
  frame, streamId,
2395
2569
  target: a.target,
@@ -2398,6 +2572,11 @@ export const startMirror = (deps) => {
2398
2572
  capped: false, closed: false, dead: false, cardSent: false,
2399
2573
  tools: [], sawTool: false,
2400
2574
  };
2575
+ const event = eventId ? deps.events?.get(eventId) : undefined;
2576
+ if (event?.payload.human)
2577
+ rememberReplyBubble(event.id, {
2578
+ frame, streamId, done: false, owner: event.id, source: s, expires: event.at + HARD_TIMEOUT_MS,
2579
+ });
2401
2580
  // 微信帧没有 stream 窗口: 到点收口只会多发一条「仍在处理中」, 白占它那几格出站额度。
2402
2581
  if (!isWxFrame(frame))
2403
2582
  s.hardTimer = setTimeout(() => void finalizeStream(a, s, true), HARD_TIMEOUT_MS);
@@ -2422,7 +2601,8 @@ export const startMirror = (deps) => {
2422
2601
  // turnSilent 已被拦下; 非 brief 的逐条推送在这里兜一道 —— 只放 `[mirror]` /
2423
2602
  // `[wezard]` 系统提示 (它们回 home)。
2424
2603
  const sendStandalone = (a, content) => {
2425
- if (!hasVisibleBody(content))
2604
+ const eventId = a.activeEventId;
2605
+ if (!hasVisibleBody(content) || !eventOwned(eventId))
2426
2606
  return;
2427
2607
  if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
2428
2608
  return;
@@ -2433,6 +2613,8 @@ export const startMirror = (deps) => {
2433
2613
  a.standalonePending = a.standalonePending
2434
2614
  .then(async () => {
2435
2615
  for (const c of chunks) {
2616
+ if (!eventOwned(eventId))
2617
+ return;
2436
2618
  try {
2437
2619
  const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
2438
2620
  log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
@@ -2446,12 +2628,15 @@ export const startMirror = (deps) => {
2446
2628
  };
2447
2629
  // Like sendStandalone but skips withTagHeader — content already contains the tag header (e.g. as a link).
2448
2630
  const sendRaw = (a, content) => {
2449
- if (!hasVisibleBody(content))
2631
+ const eventId = a.activeEventId;
2632
+ if (!hasVisibleBody(content) || !eventOwned(eventId))
2450
2633
  return;
2451
2634
  const chatId = replyChatId(a);
2452
2635
  const turnId = a.briefTurnId ?? a.liveStream?.turnId;
2453
2636
  a.standalonePending = a.standalonePending
2454
2637
  .then(async () => {
2638
+ if (!eventOwned(eventId))
2639
+ return;
2455
2640
  try {
2456
2641
  await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
2457
2642
  log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
@@ -2560,13 +2745,22 @@ export const startMirror = (deps) => {
2560
2745
  };
2561
2746
  // Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
2562
2747
  // standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
2748
+ const discardUnownedOutbound = (a, out) => {
2749
+ if (eventOwned(out.eventId))
2750
+ return false;
2751
+ if (out.kind === "deferred")
2752
+ clearTimeout(out.timer);
2753
+ if (a.outbound === out)
2754
+ a.outbound = undefined;
2755
+ return true;
2756
+ };
2563
2757
  const promoteToStandalone = (a) => {
2564
2758
  const out = a.outbound;
2565
- if (out?.kind !== "deferred")
2759
+ if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
2566
2760
  return;
2567
2761
  clearTimeout(out.timer);
2568
2762
  const md = renderBuf(out.buf);
2569
- a.outbound = { kind: "awaiting_appr", frame: out.frame, streamId: out.streamId };
2763
+ a.outbound = { kind: "awaiting_appr", eventId: out.eventId, frame: out.frame, streamId: out.streamId };
2570
2764
  flushPendingStandalone(a);
2571
2765
  if (md)
2572
2766
  sendStandalone(a, md);
@@ -2586,7 +2780,7 @@ export const startMirror = (deps) => {
2586
2780
  // Pure-text turn or no trailing final text → no split, all into streamId.
2587
2781
  const exitDeferredAsFinalStream = (a) => {
2588
2782
  const out = a.outbound;
2589
- if (out?.kind !== "deferred")
2783
+ if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
2590
2784
  return;
2591
2785
  clearTimeout(out.timer);
2592
2786
  const buf = out.buf;
@@ -2611,15 +2805,15 @@ export const startMirror = (deps) => {
2611
2805
  try {
2612
2806
  // 有正文才写旧 streamId (收入 stream); 空缓冲一个字都不写 —— 否则
2613
2807
  // loading 气泡被 " " 覆盖, 结束处理自己凭空制造一条空消息。
2614
- if (bubbleMd)
2808
+ if (bubbleMd && eventOwned(out.eventId))
2615
2809
  await client.replyStream(out.frame, out.streamId, withLinkedTag(a, bubbleMd), true);
2616
2810
  }
2617
2811
  catch (e) {
2618
2812
  log.warn({ sessionId: a.sessionId, err: e.message }, "exit-deferred finalize failed; falling back to standalone");
2619
- if (bubbleMd)
2813
+ if (bubbleMd && eventOwned(out.eventId))
2620
2814
  sendStandalone(a, bubbleMd);
2621
2815
  }
2622
- if (trailingMd)
2816
+ if (trailingMd && eventOwned(out.eventId))
2623
2817
  sendStandalone(a, trailingMd);
2624
2818
  })();
2625
2819
  log.info({ sessionId: a.sessionId, items: buf.length, bubbleLen: bubbleMd.length, trailingLen: trailingMd.length, sawTool }, "outbound: DEFERRED → IDLE (turn_end, finalized to streamId)");
@@ -2629,17 +2823,19 @@ export const startMirror = (deps) => {
2629
2823
  // STREAMING branch since outbound is now undefined.
2630
2824
  const promoteToStream = (a) => {
2631
2825
  const out = a.outbound;
2632
- if (out?.kind !== "deferred")
2826
+ if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
2633
2827
  return;
2634
2828
  const { buf, frame, streamId } = out;
2635
2829
  a.outbound = undefined;
2636
- const s = openStream(a, frame, streamId);
2830
+ const s = openStream(a, frame, streamId, out.eventId);
2637
2831
  a.liveStream = s;
2638
2832
  if (buf.length === 0) {
2639
2833
  // Empty buffer — claude still thinking. Send the "…" ack so the user
2640
2834
  // sees the bubble; subsequent items grow it as today.
2641
2835
  void (async () => {
2642
2836
  try {
2837
+ if (!eventOwned(s.eventId) || s.closed)
2838
+ return;
2643
2839
  await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
2644
2840
  }
2645
2841
  catch (e) {
@@ -2662,7 +2858,7 @@ export const startMirror = (deps) => {
2662
2858
  // an empty buffer before any tool_use can be evaluated for approval-needs.
2663
2859
  const SAFETY_MS = 5 * 60_000;
2664
2860
  const timer = setTimeout(() => promoteToStream(a), SAFETY_MS);
2665
- a.outbound = { kind: "deferred", buf: [], frame, streamId, timer };
2861
+ a.outbound = { kind: "deferred", eventId: a.activeEventId, buf: [], frame, streamId, timer };
2666
2862
  log.info({ sessionId: a.sessionId, safetyMs: SAFETY_MS }, "outbound: IDLE → DEFERRED (safety net)");
2667
2863
  };
2668
2864
  // Buffer / decide while in DEFERRED. user_text is CLI-side typing (not the
@@ -2671,7 +2867,7 @@ export const startMirror = (deps) => {
2671
2867
  // subsequent items don't reset (bounded promote delay).
2672
2868
  const handleDeferredItem = (a, item) => {
2673
2869
  const out = a.outbound;
2674
- if (out?.kind !== "deferred")
2870
+ if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
2675
2871
  return;
2676
2872
  if (item.kind === "turn_end") {
2677
2873
  exitDeferredAsFinalStream(a);
@@ -2748,12 +2944,12 @@ export const startMirror = (deps) => {
2748
2944
  const b = a.briefBubble;
2749
2945
  const turnId = a.briefTurnId;
2750
2946
  // 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
2751
- if (!text || !b || b.done || !turnId || a.briefConcluded)
2947
+ if (!text || !b || b.done || !turnId || a.briefConcluded || !bubbleOwned(a, b))
2752
2948
  return;
2753
2949
  a.cotLastSent = text;
2754
2950
  try {
2755
2951
  await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2756
- if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId)
2952
+ if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || !bubbleOwned(a, b))
2757
2953
  return;
2758
2954
  await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, text), false);
2759
2955
  }));
@@ -2765,7 +2961,7 @@ export const startMirror = (deps) => {
2765
2961
  /** 推进气泡里的 CoT 进度。节流窗口内到达的多条只留最后一条 —— 进度永远只展示"最新"。 */
2766
2962
  const updateBriefProgress = (a, raw) => {
2767
2963
  const b = a.briefBubble;
2768
- if (!b || b.done || a.briefConcluded)
2964
+ if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b))
2769
2965
  return;
2770
2966
  const line = cotLine(raw);
2771
2967
  if (!line)
@@ -2779,7 +2975,7 @@ export const startMirror = (deps) => {
2779
2975
  * 只有没有新 text 的窗口才把最新 tool 行刷上气泡。节流与 text 共用一个计时器。 */
2780
2976
  const updateBriefToolProgress = (a, raw) => {
2781
2977
  const b = a.briefBubble;
2782
- if (!b || b.done || a.briefConcluded)
2978
+ if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b))
2783
2979
  return;
2784
2980
  const line = cotLine(raw);
2785
2981
  if (!line)
@@ -2801,7 +2997,7 @@ export const startMirror = (deps) => {
2801
2997
  // `[mirror]` 系统提示 —— 它们都不走这道门。
2802
2998
  // 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
2803
2999
  // 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
2804
- const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
3000
+ const turnSilent = (a) => !eventOwned(a.activeEventId) || isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
2805
3001
  const formatFinal = (target, turn, channel, body) => {
2806
3002
  const pieces = splitMarkdown(body, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
2807
3003
  return pieces.map((text, n) => withLinkedTagHeader(target, text, chatTurnUrl(target, turn), pieces.length > 1 ? `${n + 1}/${pieces.length}` : undefined, undefined, channel));
@@ -2830,12 +3026,13 @@ export const startMirror = (deps) => {
2830
3026
  return id;
2831
3027
  };
2832
3028
  const recoverEventFinal = (event, bubble) => {
2833
- if (!event.result || event.interjected || event.canceled)
3029
+ if ((deps.events?.get(event.id) ?? event).handoffCut || !event.result || event.interjected || event.canceled || !eventOwnerGate(event))
2834
3030
  return;
2835
3031
  // 事件泵的 evidence 探针 (observe → io.recovered) 与 brief 软收口赛跑: 两者同为
2836
3032
  // ~1s 周期, 探针常先跑到, 而它调这里不带气泡 —— queueFinal 拿不到 stream, 终句
2837
3033
  // 全部走 standalone, 气泡定格在最后一行 CoT 进度。没传气泡时按 target/turn 找回
2838
3034
  // 活着的那只 (turn 已换人/气泡已收则不挂, 维持 standalone)。
3035
+ bubble ??= claimReplyBubble(event);
2839
3036
  bubble ??= (() => {
2840
3037
  const a = byTarget.get(event.target);
2841
3038
  return a && (!event.turn || a.briefTurnId === event.turn) && a.briefBubble && !a.briefBubble.done ? a.briefBubble : undefined;
@@ -2860,6 +3057,15 @@ export const startMirror = (deps) => {
2860
3057
  if (event.channel && eventFinalGate(event))
2861
3058
  queueFinal(event.target, turn, event.channel, event.result.body, event.id, bubble);
2862
3059
  };
3060
+ const deliverTransferredFinal = (event, body) => {
3061
+ if (!eventOwnerGate(event) || !event.channel)
3062
+ return;
3063
+ const turn = event.turn || event.id;
3064
+ if (!getDetail(turn))
3065
+ recordTurnStart({ id: turn, eventId: event.id, target: event.target,
3066
+ userQuery: event.payload.text, createdAt: event.at, channel: event.channel, from: event.payload.peer?.from });
3067
+ queueFinal(event.target, turn, event.channel, body, event.id, claimReplyBubble(event));
3068
+ };
2863
3069
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
2864
3070
  // raw=true skips withTagHeader (used when content already contains the linked tag header).
2865
3071
  // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
@@ -2868,8 +3074,9 @@ export const startMirror = (deps) => {
2868
3074
  // standalone 自然排在 finish 之后; 额外加一小段延迟留给客户端渲染。
2869
3075
  const BUBBLE_FINISH_SETTLE_MS = 600;
2870
3076
  const finishBubble = async (a, b, content, raw = false) => {
2871
- if (!b || b.done)
3077
+ if (!b || b.done || !bubbleOwned(a, b))
2872
3078
  return;
3079
+ const owner = b.owner;
2873
3080
  b.done = true;
2874
3081
  clearTimeout(b.hardTimer);
2875
3082
  if (a.briefBubble === b)
@@ -2878,6 +3085,9 @@ export const startMirror = (deps) => {
2878
3085
  clearCot(a);
2879
3086
  const p = (async () => {
2880
3087
  try {
3088
+ await b.pending?.catch(() => undefined);
3089
+ if (b.owner !== owner || !bubbleOwned(a, b))
3090
+ return;
2881
3091
  await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
2882
3092
  // 给 WeCom 客户端留一点时间完成打字机动画, 再放行 standalone 队列。
2883
3093
  await new Promise((r) => setTimeout(r, BUBBLE_FINISH_SETTLE_MS));
@@ -2935,7 +3145,7 @@ export const startMirror = (deps) => {
2935
3145
  /** 这一轮挂在哪一笔上: 回执 (与老派活信封) 写在信封上; 新派活信封不带, 问注入事件的 `serves`。 */
2936
3146
  const rowOf = (env) => env.event ?? (env.eventId ? deps.events?.get(env.eventId)?.serves : undefined);
2937
3147
  const openingOf = (a) => {
2938
- const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
3148
+ const t = a.jsonlPath ? lastUserTurn(a.jsonlPath, a.openingOffset) : undefined;
2939
3149
  const env = t?.env;
2940
3150
  a.activeEventId = env?.eventId;
2941
3151
  if (!env)
@@ -3109,7 +3319,7 @@ export const startMirror = (deps) => {
3109
3319
  a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: now };
3110
3320
  a.pendingBriefQuery = s.query;
3111
3321
  a.pendingBriefAt = ts;
3112
- if (!cfg.wrc.mirror.brief) {
3322
+ if (!briefMode(a)) {
3113
3323
  consumeChannel(a, false, consumeFrom(a));
3114
3324
  if (a.liveStream && !a.liveStream.closed)
3115
3325
  a.liveStream.dest = s.channel ?? baseOfKey(a.target);
@@ -3119,19 +3329,14 @@ export const startMirror = (deps) => {
3119
3329
  const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker, eventId) => {
3120
3330
  const turnId = newTurnId();
3121
3331
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
3122
- const bubble = { frame, streamId, done: false, expires: (eventId ? deps.events?.get(eventId)?.at ?? Date.now() : Date.now()) + HARD_TIMEOUT_MS };
3332
+ const bubble = { frame, streamId, done: false, owner: eventId, expires: (eventId ? deps.events?.get(eventId)?.at ?? Date.now() : Date.now()) + HARD_TIMEOUT_MS };
3123
3333
  if (eventId)
3124
3334
  deps.events?.bind(eventId, turnId);
3125
3335
  const q = { turnId, bubble, isSlash };
3126
3336
  // 微信帧不挂: 没有 stream 窗口要赶, 气泡等终句来了再一次发出 (见 openStream 同一条)。
3127
- if (!isWxFrame(frame))
3128
- bubble.hardTimer = setTimeout(() => {
3129
- // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。finish 是整条替换 ——
3130
- // 定格在一行过期的 CoT 进度上看着像卡死, 光链接又像说完了; 气泡仍是本轮活跃
3131
- // 气泡 (turn 还在跑) 就明说「仍在处理中」, 已被顶替的旧轮只留链接。
3132
- const live = a.briefBubble === bubble && !a.briefConcluded;
3133
- void finishBubble(a, bubble, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
3134
- }, HARD_TIMEOUT_MS);
3337
+ if (eventId)
3338
+ rememberReplyBubble(eventId, bubble);
3339
+ armBriefBubble(a, bubble, turnId, channel);
3135
3340
  const seal = { eventId, keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
3136
3341
  // 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
3137
3342
  // 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
@@ -3161,7 +3366,7 @@ export const startMirror = (deps) => {
3161
3366
  if (a.channelSeeded)
3162
3367
  return false;
3163
3368
  const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
3164
- const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
3369
+ const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath, a.openingOffset) : undefined;
3165
3370
  const said = head(opening?.text);
3166
3371
  const eventId = opening?.env?.eventId;
3167
3372
  const r = openTurnsOf(a.target, a.sessionId).find((t) => eventId ? t.eventId === eventId : !!said && head(t.userQuery) === said);
@@ -3178,7 +3383,10 @@ export const startMirror = (deps) => {
3178
3383
  a.replyTo = publicPeer(r.from);
3179
3384
  a.turnFromChat = a.turnFromChat ?? true;
3180
3385
  a.briefTurnId = r.id;
3181
- a.briefBubble = undefined;
3386
+ const event = eventId ? deps.events?.get(eventId) : undefined;
3387
+ a.briefBubble = event ? claimReplyBubble(event) : undefined;
3388
+ if (a.briefBubble)
3389
+ armBriefBubble(a, a.briefBubble, r.id, a.channel);
3182
3390
  a.briefIsSlash = false;
3183
3391
  a.briefHadTool = r.items.some((it) => it.t === "tool_use");
3184
3392
  a.briefConcluded = false;
@@ -3212,7 +3420,10 @@ export const startMirror = (deps) => {
3212
3420
  a.briefTurnId = turnId;
3213
3421
  if (a.activeEventId)
3214
3422
  deps.events?.bind(a.activeEventId, turnId);
3215
- a.briefBubble = undefined;
3423
+ const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
3424
+ a.briefBubble = event ? claimReplyBubble(event) : undefined;
3425
+ if (a.briefBubble)
3426
+ armBriefBubble(a, a.briefBubble, turnId, a.channel);
3216
3427
  a.briefIsSlash = false;
3217
3428
  a.briefHadTool = false;
3218
3429
  a.briefConcluded = false;
@@ -3269,7 +3480,7 @@ export const startMirror = (deps) => {
3269
3480
  else
3270
3481
  queueFinal(a.target, turnId, a.channel ?? baseOfKey(a.target), body, undefined, a.briefBubble);
3271
3482
  }
3272
- else if (gated && !turnSilent(a)) {
3483
+ else if (gated && eventOwned(a.activeEventId) && !turnSilent(a)) {
3273
3484
  // 链式收口 (eventFinalGate: 子活未落定) 延后的是 closing 的释放, 不该扣住气泡:
3274
3485
  // queueFinal 没跑 = 没人收气泡, 它会定格在最后一行 CoT 进度上 (看着像卡在 tool
3275
3486
  // use), hardTimer 到点的空 finish 又被 chat-gate 当 empty push 丢掉。气泡还在
@@ -3699,7 +3910,7 @@ export const startMirror = (deps) => {
3699
3910
  handleGoalItem(a, { kind: "turn_end" });
3700
3911
  return;
3701
3912
  }
3702
- if (cfg.wrc.mirror.brief && a.briefTurnId) {
3913
+ if (briefMode(a) && a.briefTurnId) {
3703
3914
  closeBriefTurn(a);
3704
3915
  return;
3705
3916
  }
@@ -3710,6 +3921,8 @@ export const startMirror = (deps) => {
3710
3921
  // 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
3711
3922
  if (a.muteUntilInject)
3712
3923
  return;
3924
+ if (item.kind === "user_text" || item.kind === "user_query" || item.kind === "user_inject")
3925
+ a.openingOffset = undefined;
3713
3926
  if (item.kind === "user_text") {
3714
3927
  // 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
3715
3928
  // 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
@@ -3728,7 +3941,7 @@ export const startMirror = (deps) => {
3728
3941
  a.pendingChannel = undefined;
3729
3942
  a.activeEventId = item.eventId;
3730
3943
  a.pendingFromCli = true;
3731
- if (!cfg.wrc.mirror.brief)
3944
+ if (!briefMode(a))
3732
3945
  consumeChannel(a, true, undefined);
3733
3946
  }
3734
3947
  if (item.kind === "user_inject") {
@@ -3775,13 +3988,18 @@ export const startMirror = (deps) => {
3775
3988
  // 有活跃 turn 时 handleBriefItem 本就丢它, 没有 turn 时 (user_text 不是
3776
3989
  // BRIEF_TURN_OPENERS) 它会一路穿到底部的 standalone fallback —— includeUser=true
3777
3990
  // 的配置下, 人在 CLI 敲的那一行就这么原样回显进了群, 正好破了 chatOriginOnly。
3778
- if (item.kind === "user_text" && (item.quiet || cfg.wrc.mirror.brief))
3991
+ if (item.kind === "user_text" && (item.quiet || briefMode(a)))
3779
3992
  return;
3780
3993
  // thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
3781
3994
  // 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
3782
3995
  // 那就变回已下线的 thinkStyle 了。
3783
3996
  if (item.kind === "thinking") {
3784
- if (cfg.wrc.mirror.brief && a.briefTurnId)
3997
+ const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
3998
+ // A receiver can begin with thinking, before any text/tool opener. Bind its
3999
+ // turn and inherited bubble now; pending input alone never authorizes output.
4000
+ if (!a.briefTurnId && event?.payload.peer?.transferred && eventOwnerGate(event))
4001
+ ensureBriefTurn(a);
4002
+ if (briefMode(a) && a.briefTurnId)
3785
4003
  updateBriefProgress(a, item.body);
3786
4004
  return;
3787
4005
  }
@@ -3924,7 +4142,7 @@ export const startMirror = (deps) => {
3924
4142
  }
3925
4143
  }
3926
4144
  }
3927
- if (!cfg.wrc.mirror.brief && item.kind === "text" && item.final !== false && a.activeEventId && deps.events) {
4145
+ if (!briefMode(a) && item.kind === "text" && item.final !== false && a.activeEventId && deps.events) {
3928
4146
  const e = deps.events.get(a.activeEventId);
3929
4147
  if (e) {
3930
4148
  // Soft final text waits for transcript/idle evidence; it must not escape through the legacy stream.
@@ -3937,7 +4155,7 @@ export const startMirror = (deps) => {
3937
4155
  return;
3938
4156
  }
3939
4157
  }
3940
- if (!cfg.wrc.mirror.brief && isPrivate(a))
4158
+ if (!briefMode(a) && (isPrivate(a) || !eventOwned(a.activeEventId)))
3941
4159
  return;
3942
4160
  // /goal mode overrides everything below: the session-scoped Stop hook
3943
4161
  // self-drives the model with no terminal stop_reason, so turn_end never fires
@@ -3956,17 +4174,17 @@ export const startMirror = (deps) => {
3956
4174
  // Brief 模式下没有活跃 turn 时, 任何 assistant 侧产出都补开一个无气泡 turn ——
3957
4175
  // 覆盖 CLI 侧直接开的新一轮 (WeCom 没参与, 拿不到 frame) 与收口后的零星补写。
3958
4176
  // user 行不开 turn (CLI 敲字 ≠ 一定有回复), 它的 query/出处已在上面记下。
3959
- if (cfg.wrc.mirror.brief && !a.briefTurnId && BRIEF_TURN_OPENERS.has(item.kind))
4177
+ if (briefMode(a) && !a.briefTurnId && BRIEF_TURN_OPENERS.has(item.kind))
3960
4178
  ensureBriefTurn(a);
3961
4179
  // Usage snapshots never flow into WeCom bubbles — brief 分支下 handleBriefItem
3962
4180
  // 会把它写进 turn store, 非 brief 下直接吞掉, 保持 onItem 主流程只处理有渲染
3963
4181
  // 输出的 item 类型。
3964
- if (item.kind === "turn_usage" && !(cfg.wrc.mirror.brief && a.briefTurnId)) {
4182
+ if (item.kind === "turn_usage" && !(briefMode(a) && a.briefTurnId)) {
3965
4183
  return;
3966
4184
  }
3967
4185
  // Brief mode 短路: 所有正常流/deferral/awaiting 全跳过, 只写入 turn store,
3968
4186
  // 唯一发到群里的是 turn 结束时的 finish text (下文 handleBriefItem 处理)。
3969
- if (cfg.wrc.mirror.brief && a.briefTurnId) {
4187
+ if (briefMode(a) && a.briefTurnId) {
3970
4188
  handleBriefItem(a, item);
3971
4189
  return;
3972
4190
  }
@@ -3982,7 +4200,7 @@ export const startMirror = (deps) => {
3982
4200
  return;
3983
4201
  // Brief mode 短路: 所有正常流/deferral/awaiting 全跳过, 只写入 turn store,
3984
4202
  // 唯一发到群里的是 turn 结束时的 finish text (下文 handleBriefItem 处理)。
3985
- if (cfg.wrc.mirror.brief && a.briefTurnId) {
4203
+ if (briefMode(a) && a.briefTurnId) {
3986
4204
  handleBriefItem(a, item);
3987
4205
  return;
3988
4206
  }
@@ -3997,9 +4215,11 @@ export const startMirror = (deps) => {
3997
4215
  // 说明 claude 已继续执行 (hook 必然已返回 / 根本没拦), 把状态提升回 STREAMING,
3998
4216
  // 复用原 frame/streamId 让本轮回到打字机气泡。
3999
4217
  if (a.outbound?.kind === "awaiting_appr") {
4000
- const { frame, streamId } = a.outbound;
4218
+ if (discardUnownedOutbound(a, a.outbound))
4219
+ return;
4220
+ const { frame, streamId, eventId } = a.outbound;
4001
4221
  a.outbound = undefined;
4002
- const s = openStream(a, frame, streamId);
4222
+ const s = openStream(a, frame, streamId, eventId);
4003
4223
  a.liveStream = s;
4004
4224
  log.info({ sessionId: a.sessionId, kind: item.kind, turnId: s.turnId }, "outbound: AWAITING_APPR → STREAMING (hook bypassed, item arrived)");
4005
4225
  }
@@ -4133,11 +4353,21 @@ export const startMirror = (deps) => {
4133
4353
  const carryEffort = effort !== undefined ? effort.trim() : (prevByTarget?.effort ?? deps.store.get(target)?.effort ?? "");
4134
4354
  if (prevByTarget)
4135
4355
  detach(prevByTarget, "target reassigned");
4356
+ const startOffset = (() => { try {
4357
+ return statSync(jsonlPath).size;
4358
+ }
4359
+ catch {
4360
+ return 0;
4361
+ } })();
4136
4362
  // Build the attachment first so the tail's onItem closure can capture it.
4137
4363
  const a = {
4138
4364
  sessionId,
4139
4365
  jsonlPath,
4140
4366
  target,
4367
+ // Tail starts at EOF after reload; recover ownership before its first item,
4368
+ // including non-brief output that never enters ensureBriefTurn.
4369
+ activeEventId: lastUserTurn(jsonlPath, startOffset)?.env?.eventId,
4370
+ openingOffset: startOffset,
4141
4371
  tmuxPane: (tmuxPane ?? "").trim(),
4142
4372
  tmuxSession: (tmuxSession ?? "").trim(),
4143
4373
  // Explicit cwd (spawn path) wins; otherwise derive from jsonl head so
@@ -4153,6 +4383,7 @@ export const startMirror = (deps) => {
4153
4383
  };
4154
4384
  a.tail = startMirrorTail({
4155
4385
  jsonlPath,
4386
+ startOffset,
4156
4387
  log: log.child({ sub: "tail", sessionId }),
4157
4388
  includeUser: cfg.wrc.mirror.includeUser,
4158
4389
  includeTools: cfg.wrc.mirror.includeTools,
@@ -4252,7 +4483,7 @@ export const startMirror = (deps) => {
4252
4483
  // jsonl means "no input yet", NOT "rotated away" — healing it onto some
4253
4484
  // unrelated newest-in-cwd file is exactly the cross-wire we must avoid.
4254
4485
  const storedPane = (rec.tmuxPane ?? "").trim();
4255
- const livePane = storedPane && (panes ? panes.has(storedPane) : await tmuxPaneAlive(storedPane)) ? storedPane : "";
4486
+ const livePane = storedPane && (panes ? panes.has(storedPane) : await tmuxPaneAlive(storedPane)) ? storedPane : await codexPaneForTranscript(jsonlAbs);
4256
4487
  if (permitted && !permitted())
4257
4488
  return undefined;
4258
4489
  if (!existsSync(jsonlAbs)) {
@@ -4542,7 +4773,7 @@ export const startMirror = (deps) => {
4542
4773
  // freshly-rotated jsonl (only holds the /clear line + new content); resume
4543
4774
  // migration omits it (→ tail from EOF) because the fork file is seeded with
4544
4775
  // the FULL prior transcript and replaying from 0 would re-dump it to WeCom.
4545
- const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset) => {
4776
+ const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset, knownClear = false) => {
4546
4777
  // Guard: if newSessionId is already bound to a DIFFERENT principal, abort —
4547
4778
  // prevents silent cross-wire (path B: /clear rotation or drift landing on
4548
4779
  // a sibling's live session).
@@ -4556,7 +4787,7 @@ export const startMirror = (deps) => {
4556
4787
  // 迁移是所有会话轮换的唯一漏斗 (注入的 /clear、TUI 里手打的 /clear、resume fork、
4557
4788
  // worktree drift)。只有目标 jsonl 首条用户行是 /clear 的那种才是真清空 —— fork /
4558
4789
  // drift 上下文是延续的, 画中性的 "switch"。
4559
- const cleared = jsonlIsPostClearChild(newJsonlPath);
4790
+ const cleared = knownClear || jsonlIsPostClearChild(newJsonlPath);
4560
4791
  markCut(a.target, newSessionId, cleared ? "clear" : "switch");
4561
4792
  if (cleared)
4562
4793
  contextCut(a.target, "clear");
@@ -4593,6 +4824,8 @@ export const startMirror = (deps) => {
4593
4824
  tmuxSession: a.tmuxSession || undefined,
4594
4825
  tmuxPane: a.tmuxPane || undefined,
4595
4826
  cwd: a.runningCwd || undefined,
4827
+ model: a.model || undefined,
4828
+ effort: a.effort || undefined,
4596
4829
  pendingCwd: a.pendingCwd || undefined,
4597
4830
  });
4598
4831
  log.info({ target: a.target, oldSessionId, newSessionId, oldJsonlPath, newJsonlPath, startOffset }, "mirror migrated session");
@@ -5391,17 +5624,29 @@ export const startMirror = (deps) => {
5391
5624
  // store — e.g. after a reload that couldn't re-attach — is still killable.
5392
5625
  const a = byTarget.get(target);
5393
5626
  const pane = a?.tmuxPane || paneOf(target);
5627
+ const sid = a?.sessionId ?? deps.store.get(target)?.sessionId;
5628
+ const birth = wizardStore()?.get(target)?.birthToken;
5629
+ const same = () => byTarget.get(target) === a && (a?.tmuxPane || paneOf(target)) === pane
5630
+ && (a?.sessionId ?? deps.store.get(target)?.sessionId) === sid && wizardStore()?.get(target)?.birthToken === birth;
5631
+ const replaced = { ok: false, reason: "target identity changed during pane cleanup; cleanup withheld" };
5394
5632
  if (!a && !pane)
5395
5633
  return { ok: false, reason: "no session bound to target" };
5396
5634
  if (pane && (await tmuxPaneAlive(pane))) {
5635
+ if (!same())
5636
+ return replaced;
5397
5637
  // Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
5398
5638
  // flush its transcript before the TTY is yanked out from under it.
5399
5639
  await runTmux(["send-keys", "-t", pane, "Escape"]);
5400
5640
  await sleep(250);
5641
+ if (!same())
5642
+ return replaced;
5401
5643
  const r = await runTmux(["kill-pane", "-t", pane]);
5402
5644
  if (r.code !== 0)
5403
5645
  return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
5404
5646
  }
5647
+ // A late stop must not detach a new wizard that reused this target/name.
5648
+ if (!same())
5649
+ return replaced;
5405
5650
  if (a)
5406
5651
  detach(a, drop ? "/kill" : "sleep");
5407
5652
  if (!drop) {
@@ -5911,8 +6156,196 @@ export const startMirror = (deps) => {
5911
6156
  : { ok: true, label: `${pick.index}. ${pick.label}` };
5912
6157
  };
5913
6158
  const pressNativePicker = async (a, fingerprint, what, guard) => withPaneInput(a.tmuxPane, (live) => pressNativePickerUnlocked(a, fingerprint, what, live), { guard });
6159
+ // 调用方持有 pane 输入锁; clear 与 carry 的原生身份探测共用同一条 slash 提交流程。
6160
+ const nativeCommands = (target, pane, codex, sameProcess, sameSession, allowed = () => true) => {
6161
+ // 每次 slash 都在同一个物理输入锁里核对空框、idle 与进程; 不借用有补 Enter 的通用注入器。
6162
+ const command = async (text, before) => {
6163
+ if (!allowed() || !(await sameProcess()) || !(await idleNow(target)))
6164
+ return false;
6165
+ const input = await readPaneInput(pane);
6166
+ if (!input.box.known || input.box.body || paneIsBusy(input.cap) || isModalPane(input.cap).modal)
6167
+ return false;
6168
+ if (!(await sameSession()))
6169
+ return false;
6170
+ if (!(await sameProcess()))
6171
+ return false;
6172
+ if (!allowed())
6173
+ return false;
6174
+ before?.();
6175
+ if (!allowed())
6176
+ return false;
6177
+ if (!(await runTmux(["send-keys", "-t", pane, "-l", text])).ok)
6178
+ return false;
6179
+ for (const end = Date.now() + 3000; Date.now() < end; await sleep(50)) {
6180
+ const typed = await readPaneInput(pane);
6181
+ if (!(await sameProcess()) || await isBusy(target))
6182
+ return false;
6183
+ if (!(await sameSession()))
6184
+ return false;
6185
+ // Codex 的 slash 菜单会暂时藏掉 model/cwd 状态行, 只留精确命令框与 queue footer。
6186
+ const rows = typed.cap.trimEnd().split("\n");
6187
+ const end = rows.length - 1;
6188
+ const slashBox = codex && /^\s*tab to queue message(?:\s|$)/u.test(rows[end] ?? "")
6189
+ && !rows[end - 1]?.trim() && !rows[end - 3]?.trim() && rows[end - 2]?.trim() === `› ${text}`;
6190
+ if (((typed.box.known && typed.box.body === text) || slashBox) && !paneIsBusy(typed.cap)) {
6191
+ if (!allowed())
6192
+ return false;
6193
+ return (await runTmux(["send-keys", "-t", pane, "Enter"])).ok;
6194
+ }
6195
+ }
6196
+ return false;
6197
+ };
6198
+ const emptyComposer = async () => {
6199
+ const input = await readPaneInput(pane);
6200
+ return input.box.known && !input.box.body && !paneIsBusy(input.cap) && !isModalPane(input.cap).modal;
6201
+ };
6202
+ const statusId = async () => {
6203
+ const before = await runTmux(["capture-pane", "-t", pane, "-p", "-J", "-S", "-200"]);
6204
+ if (!before.ok || !(await command("/status")))
6205
+ return undefined;
6206
+ for (const end = Date.now() + 5000; Date.now() < end; await sleep(100)) {
6207
+ const screen = await runTmux(["capture-pane", "-t", pane, "-p", "-J", "-S", "-200"]);
6208
+ if (!screen.ok || !(await sameProcess()))
6209
+ return undefined;
6210
+ const lastStatus = [...screen.stdout.matchAll(/(?:^|\n)(?:›\s*)?\/status\s*\n/gu)].at(-1);
6211
+ const id = [...screen.stdout.matchAll(/\bSession:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gu)].at(-1);
6212
+ if (screen.stdout !== before.stdout && lastStatus && id && id.index > lastStatus.index && await emptyComposer())
6213
+ return id[1];
6214
+ }
6215
+ return undefined;
6216
+ };
6217
+ return { command, emptyComposer, statusId };
6218
+ };
6219
+ const verifyClearContext = async (target, clear) => {
6220
+ const a = byTarget.get(target);
6221
+ if (!a || !clear.nextSid || !clear.panePid || a.tmuxPane !== clear.pane || a.sessionId !== clear.nextSid || await panePidOf(clear.pane) !== clear.panePid)
6222
+ return false;
6223
+ const backend = backendForPath(a.jsonlPath);
6224
+ if (backend.name === "claude") {
6225
+ const actual = sessionOnPane(backend.homeDir, clear.pane);
6226
+ return actual?.pid === clear.pid && actual.sessionId === clear.nextSid;
6227
+ }
6228
+ if (backend.name !== "codex" || codexSessionForPane(clear.pane)?.sessionId !== clear.nextSid)
6229
+ return false;
6230
+ const ps = await psSnap();
6231
+ const pids = new Set([...ps.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid));
6232
+ return registeredPidUnder(clear.panePid, pids, ps.kids) === clear.pid;
6233
+ };
6234
+ const verifyClearInput = async (target, clear, phase, allowed) => {
6235
+ if (!allowed() || !(await verifyClearContext(target, clear)))
6236
+ return false;
6237
+ const a = byTarget.get(target);
6238
+ if (phase === "paste" && backendForPath(a.jsonlPath).name === "codex") {
6239
+ // 本机 Codex 不触发 SessionStart; 同 PID 原生 /new 也会让旧映射失真。
6240
+ // brief 尚未粘贴时用 /status 重取当前 ID; submit 阶段由原进程 + 原框内容核验保护。
6241
+ const { statusId } = nativeCommands(target, clear.pane, true, () => verifyClearContext(target, clear), () => true, allowed);
6242
+ return await statusId() === clear.nextSid && await verifyClearContext(target, clear) && allowed();
6243
+ }
6244
+ return true;
6245
+ };
6246
+ const clearContext = async (target, attempt, submitted, guard = () => true) => {
6247
+ if (!guard())
6248
+ return { ok: false, reason: "handoff continuation authority withdrawn; no input" };
6249
+ const a = byTarget.get(target);
6250
+ if (!a?.tmuxPane)
6251
+ return { ok: false, reason: "handoff requires the original live pane; no respawn" };
6252
+ const backend = backendForPath(a.jsonlPath);
6253
+ // 只开放本机真实探针验证过的后端; 不能因家族相近就假定 slash/注册表语义相同。
6254
+ if (!["claude", "codex"].includes(backend.name))
6255
+ return { ok: false, reason: `${backend.name} native /clear completion is not verified; pane unchanged` };
6256
+ if (a.pendingCwd && a.pendingCwd !== a.runningCwd)
6257
+ return { ok: false, reason: "pending workspace switch requires an explicit /new; /clear keeps the original process cwd" };
6258
+ const pane = a.tmuxPane;
6259
+ const valid = () => guard() && byTarget.get(target) === a && a.tmuxPane === pane;
6260
+ try {
6261
+ return await withPaneInput(pane, async (live) => {
6262
+ const codex = backend.name === "codex";
6263
+ const nativePid = async () => {
6264
+ if (!codex)
6265
+ return sessionOnPane(backend.homeDir, pane)?.pid;
6266
+ const root = await panePidOf(pane);
6267
+ const ps = await psSnap();
6268
+ return registeredPidUnder(root, new Set([...ps.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid)), ps.kids);
6269
+ };
6270
+ const pid = await nativePid();
6271
+ const panePid = await panePidOf(pane);
6272
+ if (!pid || !panePid || !(await tmuxPaneAlive(pane)))
6273
+ return { ok: false, reason: "original CLI process identity is unavailable" };
6274
+ const original = sessionOnPane(backend.homeDir, pane);
6275
+ let transaction = attempt ?? { pane, pid, panePid, sid: a.sessionId, at: Date.now() };
6276
+ if (transaction.pane !== pane || transaction.pid !== pid || (transaction.panePid && transaction.panePid !== panePid))
6277
+ return { ok: false, reason: "original pane/CLI process changed; refusing /clear" };
6278
+ const sameProcess = async () => live() && valid() && await nativePid() === transaction.pid && await panePidOf(pane) === panePid;
6279
+ const { command, emptyComposer, statusId } = nativeCommands(target, pane, codex, sameProcess, () => codex || (sessionOnPane(backend.homeDir, pane)?.sessionId === transaction.sid && sessionOnPane(backend.homeDir, pane)?.status === "idle"), guard);
6280
+ if (!attempt) {
6281
+ const actualSid = codex ? await statusId() : original?.sessionId;
6282
+ if (actualSid !== transaction.sid)
6283
+ return { ok: false, reason: "native pane session changed/unconfirmed before /clear; refusing to clear an unbound context" };
6284
+ // 凭证先于第一下 /clear 按键落盘; reload 只核验原生状态, 绝不再次清空。
6285
+ if (!(await command("/clear", () => submitted(transaction))))
6286
+ return { ok: false, reason: "native /clear submission unconfirmed; no retry" };
6287
+ }
6288
+ if (codex) {
6289
+ let empty = false;
6290
+ for (const end = Date.now() + 5000; Date.now() < end; await sleep(100)) {
6291
+ if (!(await sameProcess()))
6292
+ return { ok: false, reason: "Codex CLI process changed during /clear" };
6293
+ if (await emptyComposer()) {
6294
+ empty = true;
6295
+ break;
6296
+ }
6297
+ }
6298
+ const nextSid = empty ? await statusId() : undefined;
6299
+ if (!nextSid || nextSid === transaction.sid || (transaction.nextSid && transaction.nextSid !== nextSid))
6300
+ return { ok: false, reason: "Codex native /status did not confirm the cleared thread; no retry" };
6301
+ if (sidsClaimedByOthers(target).has(nextSid))
6302
+ return { ok: false, reason: "native Codex thread belongs to another wizard" };
6303
+ transaction = { ...transaction, nextSid };
6304
+ submitted(transaction);
6305
+ const actualRollout = () => codexSessions(expandHome(backend.projectsDir), expandHome(a.runningCwd)).find((s) => s.sessionId === nextSid);
6306
+ // 空 thread 不会先写 rollout; /rename 原生持久化当前 thread, 不发模型消息、不另起 thread。
6307
+ if (!actualRollout() && !(await command(`/rename ${displayName(target).replace(/[\r\n]/gu, " ") || "wezard"}`)))
6308
+ return { ok: false, reason: "Codex /rename persistence unconfirmed; thread preserved" };
6309
+ let rollout = actualRollout();
6310
+ for (const end = Date.now() + 5000; !rollout && Date.now() < end; await sleep(100))
6311
+ rollout = actualRollout();
6312
+ // rename 和 metadata 扫描之间仍可能有人原生切 thread; 最后再读当前 ID, 不绑错现场。
6313
+ if (!rollout || await statusId() !== nextSid || !(await sameProcess()))
6314
+ return { ok: false, reason: "Codex current thread/actual rollout metadata not confirmed; refusing binding" };
6315
+ recordCodexPaneSession({ sessionId: nextSid, jsonlPath: rollout.jsonlPath, cwd: a.runningCwd, pane, startedAt: transaction.at }, cfg.daemon.stateDir);
6316
+ if (a.sessionId !== nextSid)
6317
+ migrateAttachment(a, nextSid, rollout.jsonlPath, 0, true);
6318
+ log.info({ target, pane, pid, panePid, oldSessionId: transaction.sid, sessionId: nextSid, jsonlPath: rollout.jsonlPath }, "handoff: Codex native clear/status/rename confirmed in original CLI process");
6319
+ return { ok: true };
6320
+ }
6321
+ for (const end = Date.now() + 20_000; Date.now() < end; await sleep(200)) {
6322
+ if (!(await sameProcess()))
6323
+ return { ok: false, reason: "CLI process changed during /clear" };
6324
+ const current = sessionOnPane(backend.homeDir, pane);
6325
+ if (!current || current.sessionId === transaction.sid || current.status !== "idle" || !(await emptyComposer()))
6326
+ continue;
6327
+ if (sidsClaimedByOthers(target).has(current.sessionId))
6328
+ return { ok: false, reason: "native session identity belongs to another wizard" };
6329
+ if (transaction.nextSid && transaction.nextSid !== current.sessionId)
6330
+ return { ok: false, reason: "Claude native session changed after clear confirmation" };
6331
+ transaction = { ...transaction, nextSid: current.sessionId };
6332
+ submitted(transaction);
6333
+ if (a.sessionId !== current.sessionId)
6334
+ migrateAttachment(a, current.sessionId, join(dirname(a.jsonlPath), `${current.sessionId}.jsonl`), 0, true);
6335
+ log.info({ target, pane, pid, panePid, oldSessionId: transaction.sid, sessionId: current.sessionId }, "handoff: native /clear confirmed in original CLI process");
6336
+ return { ok: true };
6337
+ }
6338
+ return { ok: false, reason: "native /clear completion unconfirmed; original pane preserved, brief retained, no retry" };
6339
+ }, { guard: valid, timeoutMs: 60_000 });
6340
+ }
6341
+ catch (e) {
6342
+ return { ok: false, reason: String(e) };
6343
+ }
6344
+ };
5914
6345
  return {
5915
6346
  attach,
6347
+ clearContext,
6348
+ verifyClearContext,
5916
6349
  chatTargets,
5917
6350
  peers,
5918
6351
  foreignPeers,
@@ -6040,9 +6473,11 @@ export const startMirror = (deps) => {
6040
6473
  // the first flushStream will reject, mark s.dead=true, and subsequent items
6041
6474
  // route to standalone via the existing :1206 fall-through. Self-healing.
6042
6475
  if (a.outbound?.kind === "awaiting_appr") {
6043
- const { frame, streamId } = a.outbound;
6476
+ if (discardUnownedOutbound(a, a.outbound))
6477
+ return;
6478
+ const { frame, streamId, eventId } = a.outbound;
6044
6479
  a.outbound = undefined;
6045
- const s = openStream(a, frame, streamId);
6480
+ const s = openStream(a, frame, streamId, eventId);
6046
6481
  a.liveStream = s;
6047
6482
  log.info({ sessionId, turnId: s.turnId }, "outbound: AWAITING_APPR → STREAMING (approved)");
6048
6483
  }
@@ -6143,11 +6578,21 @@ export const startMirror = (deps) => {
6143
6578
  const a = byTarget.get(target) ?? (await restoreFromStore(target, undefined, permission));
6144
6579
  if (!permission())
6145
6580
  return denied;
6581
+ // A missing binding cannot recover on retry; let receipts escalate.
6146
6582
  if (!a)
6147
- return { ok: false, retryable: true, reason: "no mirror attached for target" };
6583
+ return { ok: false, retryable: true, reason: "no mirror attached for target", ...(deps.store.get(target) ? {} : { gone: true }) };
6584
+ if (opts?.handoff && !(await verifyClearContext(target, opts.handoff)))
6585
+ return { ok: false, retryable: false, reason: "original cleared CLI process/session changed; handoff carry withheld" };
6148
6586
  const sid = a.sessionId;
6149
6587
  const generation = a.injectGen ?? 0;
6150
6588
  const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
6589
+ if (!a.tmuxPane) {
6590
+ const recovered = await codexPaneForTranscript(a.jsonlPath);
6591
+ if (!permitted())
6592
+ return denied;
6593
+ if (!a.tmuxPane)
6594
+ a.tmuxPane = recovered;
6595
+ }
6151
6596
  const probedPane = a.tmuxPane;
6152
6597
  const probe = a.tmuxPane ? await probePane(a.tmuxPane, a.jsonlPath) : { state: "gone" };
6153
6598
  if (!permitted() || a.tmuxPane !== probedPane)
@@ -6163,6 +6608,8 @@ export const startMirror = (deps) => {
6163
6608
  // 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
6164
6609
  let fresh = a.justSpawned === true;
6165
6610
  if (probe.state !== "session") {
6611
+ if (opts?.noRespawn)
6612
+ return { ok: false, retryable: false, reason: "original CLI is unavailable; handoff never respawns" };
6166
6613
  log.warn({ target, sessionId: sid, oldPane: a.tmuxPane, why: probe.state }, "injectText: pane not alive, respawning");
6167
6614
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
6168
6615
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
@@ -6211,13 +6658,18 @@ export const startMirror = (deps) => {
6211
6658
  const clearing = isClearCommand(text);
6212
6659
  const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
6213
6660
  const injectPane = a.tmuxPane;
6661
+ let pinRejected = false;
6214
6662
  const r = await inject({
6215
6663
  text: full, images: [], cfg, guard: (phase, identity) => permitted() && a.tmuxPane === injectPane && (!opts?.guard || opts.guard(phase, identity)), log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
6216
6664
  sessionId: sid, eventId: opts?.eventId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
6665
+ physicalGuard: opts?.handoff ? async (phase) => { const ok = permission() && await verifyClearInput(target, opts.handoff, phase, permission) && permission(); if (!ok)
6666
+ pinRejected = true; return ok; } : undefined,
6217
6667
  requireIdle: opts?.requireIdle ? () => idleNow(target) : undefined,
6218
6668
  prepareText: () => (full = text + renderTail(envelope, noticeSuffixFor(target, text))),
6219
6669
  beforeInput,
6220
6670
  });
6671
+ if (pinRejected)
6672
+ return { ...r, ok: false, retryable: false, reason: "handoff process/session or continuation authority changed at physical input; carry withheld" };
6221
6673
  if (r.ok && a.tmuxPane && permitted()) {
6222
6674
  if (preClearBaseline) {
6223
6675
  a.clearRebind = { baseline: preClearBaseline };
@@ -6575,7 +7027,10 @@ export const startMirror = (deps) => {
6575
7027
  },
6576
7028
  formatFinal,
6577
7029
  recoverEventFinal,
7030
+ deliverTransferredFinal,
7031
+ transferReply,
6578
7032
  setEventFinalGate: (gate) => { eventFinalGate = gate; },
7033
+ setEventOwnerGate: (gate) => { eventOwnerGate = gate; },
6579
7034
  settleFinalProgress,
6580
7035
  dispatch: async ({ principal, text, images, frame, streamId, channel, speaker, eventId, guard, onSubmitted }) => {
6581
7036
  // Route by inbound principal — the WeCom chat the user just messaged us
@@ -6698,10 +7153,12 @@ export const startMirror = (deps) => {
6698
7153
  // • outboundDeferMs === 0 → eager openStream + "…" ack (legacy behavior)
6699
7154
  const brief = cfg.wrc.mirror.brief && !armMigration;
6700
7155
  const eagerOpen = !armMigration && !brief && cfg.wrc.mirror.outboundDeferMs <= 0;
6701
- const s = eagerOpen ? openStream(a, frame, streamId) : undefined;
7156
+ const s = eagerOpen ? openStream(a, frame, streamId, eventId) : undefined;
6702
7157
  if (s) {
6703
7158
  a.liveStream = s;
6704
7159
  try {
7160
+ if (!eventOwned(s.eventId) || s.closed)
7161
+ return;
6705
7162
  await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
6706
7163
  }
6707
7164
  catch (e) {
@@ -6734,6 +7191,15 @@ export const startMirror = (deps) => {
6734
7191
  // First step of the job and the historical hang point — log BEFORE the
6735
7192
  // probe so a stall is attributable to it next time, not invisible.
6736
7193
  log.info({ target: a.target, sessionId: sid, pane: a.tmuxPane, gen: myGen }, "inject job start");
7194
+ if (!a.tmuxPane) {
7195
+ const recovered = await codexPaneForTranscript(a.jsonlPath);
7196
+ if (byTarget.get(a.target) !== a || (a.injectGen ?? 0) !== myGen || (guard && !guard())) {
7197
+ onSubmitted?.({ ok: false, retryable: true, reason: "pane recovery permission withdrawn" });
7198
+ return;
7199
+ }
7200
+ if (!a.tmuxPane)
7201
+ a.tmuxPane = recovered;
7202
+ }
6737
7203
  const probe = a.tmuxPane ? await probePane(a.tmuxPane, a.jsonlPath) : { state: "gone" };
6738
7204
  let freshSpawn = a.justSpawned === true;
6739
7205
  a.justSpawned = false;
@@ -6787,7 +7253,16 @@ export const startMirror = (deps) => {
6787
7253
  else {
6788
7254
  // Drop only the stale pane id; keep tmuxSession (if any) so the
6789
7255
  // store still reflects "user wanted tmux" — next turn retries.
6790
- log.error({ reason: r.reason }, "mirror: tmux respawn failed, this turn falls back to spawn-mode");
7256
+ log.error({ reason: r.reason }, "mirror: tmux respawn failed");
7257
+ if (eventId) {
7258
+ onSubmitted?.({ ok: false, retryable: true, reason: `respawn failed: ${r.reason ?? "unknown"}` });
7259
+ if (s)
7260
+ await finalizeStream(a, s);
7261
+ if (a.outbound?.kind === "deferred")
7262
+ clearTimeout(a.outbound.timer);
7263
+ a.outbound = undefined;
7264
+ return;
7265
+ }
6791
7266
  a.tmuxPane = "";
6792
7267
  }
6793
7268
  }
@@ -6833,9 +7308,15 @@ export const startMirror = (deps) => {
6833
7308
  if (!r.ok) {
6834
7309
  // retryable = 一个字都没进输入框, 消息事件层会稍后重投 —— 不是失败, 别报 ✗
6835
7310
  // (每次退避重试都会走到这里, 报 ✗ 就是一串假警报)。
6836
- const note = r.retryable && eventId
6837
- ? `[mirror] ⏳ 暂未送达, 稍后自动重投 (${r.reason ?? "deferred"})`
6838
- : `[mirror] ✗ ${r.reason ?? "failed"}`;
7311
+ if (r.retryable && eventId) {
7312
+ if (s)
7313
+ await finalizeStream(a, s);
7314
+ if (a.outbound?.kind === "deferred")
7315
+ clearTimeout(a.outbound.timer);
7316
+ a.outbound = undefined;
7317
+ return;
7318
+ }
7319
+ const note = `[mirror] ✗ ${r.reason ?? "failed"}`;
6839
7320
  if (s) {
6840
7321
  s.acc = s.acc ? `${s.acc}\n\n${note}` : note;
6841
7322
  await finalizeStream(a, s);