wezard 2.7.1 → 2.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,7 +38,7 @@ import { dispatchPickerPolicy, pickerPolicy } from "./picker-policy.js";
38
38
  import { renameWindow, runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
39
39
  import { selectEffort, selectModel } from "./model-select.js";
40
40
  import { parseEffort } from "../shared/effort.js";
41
- import { hasRegistry, liveSessionPids, markTranscript, probeOf, sessionOfPid, sessionOnPane, submittedSince, submittedEventSince, watchRegistry } from "./wezard-session.js";
41
+ import { hasRegistry, liveSessionPids, markTranscript, probeOf, registrySnapshot, sessionOfPid, sessionOnPane, submittedSince, submittedEventSince, watchRegistry } from "./wezard-session.js";
42
42
  import { waitForIdle } from "./graph.js";
43
43
  import { wizardStore } from "./wizard.js";
44
44
  import { startSubagentWatch } from "./subagent-tail.js";
@@ -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,47 @@ 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
+ if (inputHint || warnings || (layout[footer] ?? "").includes("·")) {
1309
+ let status = inputHint || warnings ? footer - 1 : footer;
1308
1310
  while (status >= 0 && !layout[status].trim())
1309
1311
  status--;
1310
1312
  if (status >= 0 && footer - status <= BOX_FOOTER_ROWS && layout[status].includes("·") && status > 0 && !layout[status - 1].trim()) {
1311
1313
  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]))
1314
+ // Only contiguous, indented continuation rows belong to this composer.
1315
+ // Crossing a blank/output row can otherwise select a historical user
1316
+ // prompt (which may carry the same background SGR as the live box).
1317
+ while (prompt >= 0 && /^\s{2,}\S/u.test(layout[prompt]))
1315
1318
  prompt--;
1316
- if (prompt >= 0 && (shortcuts || /\x1b\[[0-9;:]*48[;:]/u.test(styledLines[prompt]))) {
1319
+ if (prompt >= 0 && /^›(?:\s|$)/u.test(layout[prompt])
1320
+ && (prompt === 0 || !layout[prompt - 1].trim())
1321
+ && (inputHint || /\x1b\[[0-9;:]*48[;:]/u.test(styledLines[prompt]))) {
1317
1322
  const body = dropDim(styledLines.slice(prompt, status).join("\n"))
1318
1323
  .replace(/^\s*›\s?/u, "").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
1319
1324
  return { known: true, body };
1320
1325
  }
1321
1326
  }
1322
1327
  }
1323
- const rulers = lines.reduce((acc, l, i) => (BOX_RULER_RE.test(l) ? [...acc, i] : acc), []);
1328
+ // Dim is presentation, not layout: removing it before locating rulers also
1329
+ // removes dim borders/prompts, leaving collapsed pastes unverifiable.
1330
+ const rulers = layout.reduce((acc, l, i) => (BOX_RULER_RE.test(l) ? [...acc, i] : acc), []);
1324
1331
  const bottom = rulers[rulers.length - 1];
1325
1332
  const top = rulers[rulers.length - 2];
1326
1333
  if (top === undefined || bottom === undefined || bottom - top < 1)
1327
1334
  return UNKNOWN_BOX;
1328
- if (lines.length - bottom > BOX_FOOTER_ROWS)
1335
+ if (layout.length - bottom > BOX_FOOTER_ROWS)
1329
1336
  return UNKNOWN_BOX;
1330
- const rows = lines.slice(top + 1, bottom);
1337
+ const rows = layout.slice(top + 1, bottom);
1331
1338
  if (!rows.length || !BOX_PROMPT_RE.test(rows[0]))
1332
1339
  return UNKNOWN_BOX;
1333
- const body = [rows[0].replace(BOX_PROMPT_RE, ""), ...rows.slice(1)].join("\n").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
1340
+ const body = dropDim(styledLines.slice(top + 1, bottom).join("\n"))
1341
+ .replace(BOX_PROMPT_RE, "").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
1334
1342
  return { known: true, body };
1335
1343
  };
1336
1344
  // CodeBuddy 忙时回车不落盘: 那句挂进输入框上方的待发队列, 一条一行
@@ -1438,8 +1446,12 @@ const paneHasLiveSession = async (paneId, homeDir, panePid, ps) => {
1438
1446
  const root = panePid ?? (await panePidOf(paneId));
1439
1447
  if (!root)
1440
1448
  return false;
1441
- const { kids } = ps ?? (await psSnap());
1442
- return registeredPidUnder(root, liveSessionPids(homeDir), kids) !== undefined;
1449
+ const snapshot = ps ?? (await psSnap());
1450
+ // Codex runs under a foreground bash wrapper and has no Claude registry.
1451
+ const codexPids = codexSessionForPane(paneId)
1452
+ ? [...snapshot.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid)
1453
+ : [];
1454
+ return registeredPidUnder(root, new Set([...liveSessionPids(homeDir), ...codexPids]), snapshot.kids) !== undefined;
1443
1455
  };
1444
1456
  /** 注入前问 pane 一句话: 还在吗 / 里面还有 AI 会话吗。
1445
1457
  * `unknown` 必须由调用方按「不知道」处理 —— 不许当 pane 死了去重生。 */
@@ -1456,6 +1468,19 @@ const probePane = async (paneId, jsonlPath) => {
1456
1468
  return { state: "unknown" };
1457
1469
  return SHELL_RE.test(cmd.stdout.trim()) ? { state: "shell" } : { state: "session" };
1458
1470
  };
1471
+ // Recover only an exact SessionStart transcript match; a shared cwd is not identity.
1472
+ const codexPaneForTranscript = async (jsonlPath) => {
1473
+ if (backendForPath(jsonlPath).name !== "codex")
1474
+ return "";
1475
+ const panes = await runTmux(["list-panes", "-a", "-F", "#{pane_id}"]);
1476
+ if (!panes.ok)
1477
+ return "";
1478
+ for (const pane of panes.stdout.trim().split("\n")) {
1479
+ if (codexSessionForPane(pane)?.jsonlPath === jsonlPath && (await probePane(pane, jsonlPath)).state === "session")
1480
+ return pane;
1481
+ }
1482
+ return "";
1483
+ };
1459
1484
  /** 注册表没有 tmux 字段的后端 (codebuddy): pane 里住着谁只能沿 pane 的进程树往下找
1460
1485
  * 注册表登记过、还活着的 pid。冷 pane 的注册行晚于进程出生, `waitMs` 内轮询。 */
1461
1486
  const hostPidOnPane = async (target, homeDir, waitMs) => {
@@ -1532,7 +1557,7 @@ const witnessFor = (target, homeDir, jsonlPath, live, actualTranscript) => {
1532
1557
  // 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
1533
1558
  const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard, eventId, expectedSessionId) => {
1534
1559
  const backendName = backend.name;
1535
- log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
1560
+ log.info({ target, eventId, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
1536
1561
  // 就绪门: pane 里得真有一个进入了交互态的 AI 会话, 才轮得到贴。先于 modal guard ——
1537
1562
  // 这一步只读注册表, 会话都不在时也就不必去 capture pane 找对话框了。
1538
1563
  const gate = await awaitSession(target, backend.homeDir, freshSpawn, log);
@@ -1638,7 +1663,7 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1638
1663
  // dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
1639
1664
  // withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
1640
1665
  if (backendName === "codebuddy") {
1641
- return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, boundGuard, eventId);
1666
+ return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, boundGuard, eventId, bypassModalGuard);
1642
1667
  }
1643
1668
  // Pump images first via clipboard+C-v so each one is attached as a separate
1644
1669
  // image content block. Each C-v needs a brief settle for Claude Code's TUI
@@ -1690,7 +1715,7 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1690
1715
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1691
1716
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
1692
1717
  }
1693
- return injectViaTmuxText(target, text, log, freshSpawn, witness, boundGuard, eventId);
1718
+ return injectViaTmuxText(target, text, log, freshSpawn, witness, boundGuard, eventId, bypassModalGuard);
1694
1719
  };
1695
1720
  // 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
1696
1721
  // 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
@@ -1712,7 +1737,7 @@ const withImageRefs = (text, images) => {
1712
1737
  // 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
1713
1738
  // 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
1714
1739
  // 每次补之前再问一遍证人, 已提交就不补。
1715
- const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard, eventId) => {
1740
+ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard, eventId, bypassModalGuard = false) => {
1716
1741
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1717
1742
  // started, transcript still loading): extended timings — bracketed-paste
1718
1743
  // end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
@@ -1740,8 +1765,16 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1740
1765
  // 时间门往前放 1s: jsonl 的 timestamp 与本机时钟同源, 只差写盘的毫秒。
1741
1766
  const since = Date.now() - 1000;
1742
1767
  let enterAt = Number.POSITIVE_INFINITY;
1743
- const submitted = () => requireEventEvidence
1744
- ? witness.submittedEvent(eventId, since) : witness.submitted(probe, since, enterAt);
1768
+ let confirmed = false;
1769
+ const submitted = () => {
1770
+ const yes = requireEventEvidence
1771
+ ? witness.submittedEvent(eventId, since) : witness.submitted(probe, since, enterAt);
1772
+ if (yes && !confirmed) {
1773
+ confirmed = true;
1774
+ log.info({ target, eventId, enterToEvidenceMs: Number.isFinite(enterAt) ? Date.now() - enterAt : undefined }, "mirror inject: submission confirmed");
1775
+ }
1776
+ return yes;
1777
+ };
1745
1778
  // 贴之前框里是什么: 空框、幽灵提示 (`Try "how do I log an error?"`)、甚至用户
1746
1779
  // 自己敲了一半的字 —— 一律作为基线, 免得去维护一张「哪些文案算空」的清单。
1747
1780
  const pre = await readPaneInput(target);
@@ -1755,9 +1788,23 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1755
1788
  const alreadyTyped = boxVerify && Boolean(probe) && stripWs(preBox.body).includes(probe);
1756
1789
  // 提交后框该回到的样子: 正常是贴之前的基线; 文本本就在框里时基线就是它, 只能以「空」为准。
1757
1790
  const baseBody = alreadyTyped ? "" : preBox.body;
1791
+ let landedBody = alreadyTyped ? preBox.body : undefined;
1758
1792
  /** 框里还是贴之前那样 (或彻底空了) = 我们的文本不在框里。 */
1759
1793
  const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === baseBody);
1760
- const loadAndPaste = () => pasteTmuxBuffer(target, text, guard ? () => guard("paste") : undefined);
1794
+ const loadAndPaste = async () => {
1795
+ const start = Date.now();
1796
+ const result = await pasteTmuxBuffer(target, text, guard ? () => guard("paste") : undefined);
1797
+ log.info({ target, eventId, tmuxPasteMs: Date.now() - start, boxVerify, ok: result.ok }, "mirror inject: paste completed");
1798
+ return result;
1799
+ };
1800
+ const landed = (cap, box) => {
1801
+ const seen = box.known ? (boxVerify ? !boxIntact(box) : box.body !== "") : Boolean(headFp) && stripWs(cap).includes(headFp);
1802
+ // Bind recovery to the first observed paste, not arbitrary contents found
1803
+ // after settling: another writer may have replaced the draft meanwhile.
1804
+ if (seen && box.known && landedBody === undefined)
1805
+ landedBody = box.body;
1806
+ return seen;
1807
+ };
1761
1808
  // 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
1762
1809
  // 头部可能在框顶, 所以窗口要宽)。
1763
1810
  const sawLanded = async (timeoutMs) => {
@@ -1766,7 +1813,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1766
1813
  const { cap, box } = await readPaneInput(target);
1767
1814
  // A redraw can hide the baseline. Once the composer reappears, its
1768
1815
  // nonempty body also proves landing of a collapsed paste.
1769
- if (box.known ? (boxVerify ? !boxIntact(box) : box.body !== "") : Boolean(headFp) && stripWs(cap).includes(headFp))
1816
+ if (landed(cap, box))
1770
1817
  return true;
1771
1818
  await sleep(POLL_MS);
1772
1819
  }
@@ -1778,7 +1825,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1778
1825
  if (submitted())
1779
1826
  return "submitted";
1780
1827
  const { cap, box } = await readPaneInput(target);
1781
- if (box.known ? (boxVerify ? !boxIntact(box) : box.body !== "") : Boolean(headFp) && stripWs(cap).includes(headFp))
1828
+ if (landed(cap, box))
1782
1829
  return "landed";
1783
1830
  if (Date.now() - t0 >= timeoutMs)
1784
1831
  return box.known ? "intact" : "unknown";
@@ -1878,8 +1925,26 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1878
1925
  await sleep(Math.max(0, POST_PASTE_SETTLE_FALLBACK_MS - (Date.now() - pastedAt)));
1879
1926
  }
1880
1927
  const sendEnter = async () => {
1928
+ // A picker can open after the pre-paste gate, especially during a delayed
1929
+ // render. Check again before every Enter; askq text entry keeps its bypass.
1930
+ if (process.env.WEZARD_MODAL_GUARD !== "0" && !bypassModalGuard) {
1931
+ const modal = await detectModalPicker(target);
1932
+ if (modal.modal || !modal.screen.trim())
1933
+ return { ok: false, reason: "native picker or unreadable pane before submission; refusing Enter" };
1934
+ }
1935
+ if (submitted())
1936
+ return { ok: true };
1937
+ if (landedBody !== undefined) {
1938
+ const box = await readInputBox(target);
1939
+ if (!box.known || box.body !== landedBody)
1940
+ return { ok: false, reason: "composer changed after paste verification; refusing Enter" };
1941
+ }
1881
1942
  if (guard && !guard("submit"))
1882
1943
  return { ok: false, reason: "submit canceled by dispatch guard" };
1944
+ if (tailFp && !Number.isFinite(enterAt)) {
1945
+ enterAt = Date.now();
1946
+ log.info({ target, eventId, pasteToEnterMs: enterAt - pastedAt, pasteSeen, boxTrusted, freshSpawn }, "mirror inject: first Enter");
1947
+ }
1883
1948
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1884
1949
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
1885
1950
  };
@@ -1906,6 +1971,21 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1906
1971
  }
1907
1972
  return false;
1908
1973
  };
1974
+ /** 回车后的证据也是会迟到的 —— 粘贴有迟到窗, 提交同样要有。
1975
+ * CodeBuddy 的 user 行按「接受输入的时刻」打时间戳、却要等 TUI 把这一轮消化完才落盘: 忙时
1976
+ * 能晚好几秒 (2026-10-14 h687c3a5 实测: 标记 23:06:50.834 写进转录, 但 CLEARED_TIMEOUT_MS 的
1977
+ * 1.5s 窗内读不到, 于是判成「框空了却没有这一笔」, 往群里发了一条 ✗ —— 消息其实到了, 还被并进
1978
+ * 同一秒另一条注入的提交里)。判据是「宽限到 LATE_LAND_MS 仍没有」, 不是「1.5s 内还没看到」。 */
1979
+ const lateEvidence = async (timeoutMs, t0 = Date.now()) => {
1980
+ if (submitted())
1981
+ return true;
1982
+ if (await queued())
1983
+ return true;
1984
+ if (Date.now() - t0 >= timeoutMs)
1985
+ return false;
1986
+ await sleep(LATE_POLL_MS);
1987
+ return lateEvidence(timeoutMs, t0);
1988
+ };
1909
1989
  if (!pasteSeen)
1910
1990
  log.warn({ target }, "mirror inject: paste unverified, requiring session submission evidence");
1911
1991
  if (!tailFp) {
@@ -1915,36 +1995,52 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1915
1995
  }
1916
1996
  // 回车可能被冷启动中的 TUI 吞掉 (框里躺着没提交的提示)。框认得出来时判据可信,
1917
1997
  // 多补一次; 认不出来时维持原来的「只补一次」, 免得判据出错往真实对话里灌回车。
1918
- const boxTrusted = boxVerify && (await readInputBox(target)).known;
1919
- const attempts = requireEventEvidence ? 1 : boxTrusted ? 3 : 2;
1998
+ const pendingBox = await readInputBox(target);
1999
+ const boxTrusted = boxVerify && pendingBox.known;
2000
+ // An event may retry Enter only while the very same landed composer remains.
2001
+ // Empty/unknown/changed boxes need transcript evidence, never another paste
2002
+ // or a blind Enter that could confirm a newly opened native picker.
2003
+ const pendingBody = pasteSeen && pendingBox.known && pendingBox.body === landedBody
2004
+ ? landedBody : undefined;
2005
+ const attempts = requireEventEvidence ? pendingBody ? 3 : 1 : boxTrusted ? 3 : 2;
1920
2006
  for (let i = 0; i < attempts; i++) {
1921
2007
  if (i > 0) {
1922
2008
  await sleep(RETRY_SETTLE_MS);
1923
2009
  if (submitted())
1924
2010
  return { ok: true }; // 迟到的确认 —— 补的这一下会落在空框上, 省了
2011
+ if (requireEventEvidence) {
2012
+ const box = await readInputBox(target);
2013
+ if (!box.known || box.body !== pendingBody)
2014
+ break;
2015
+ }
1925
2016
  log.warn({ target, tailFp, attempt: i + 1 }, "mirror inject: 回车后输入框未清空, 再补一次");
1926
2017
  }
1927
- if (i === 0) {
1928
- enterAt = Date.now();
1929
- log.info({ target, pasteToEnterMs: enterAt - pastedAt, pasteSeen, boxTrusted, freshSpawn }, "mirror inject: first Enter");
1930
- }
1931
2018
  const e = await sendEnter();
1932
2019
  if (!e.ok)
1933
2020
  return e;
1934
2021
  if (await waitForSubmit(CLEARED_TIMEOUT_MS))
1935
2022
  return { ok: true };
1936
- if (requireEventEvidence && await cleared())
1937
- return await queued() ? { ok: true } : { ok: false, uncertain: true, reason: "composer cleared without this event's transcript marker; delivery remains uncertain and will not be resubmitted" };
2023
+ if (requireEventEvidence && await cleared()) {
2024
+ if (await lateEvidence(LATE_LAND_MS))
2025
+ return { ok: true };
2026
+ // 宽限用完仍没有这一笔的证据才是真不确定 —— 这条会以 `[mirror] ✗` 的面目出现在
2027
+ // 问话的那个群里, 所以留一行日志, 免得事后只剩群里那句话可查。
2028
+ log.warn({ target, eventId, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: 框空了且转录里始终没有这一笔, 判不确定并停止重贴");
2029
+ return { ok: false, uncertain: true, reason: "composer cleared without this event's transcript marker; delivery remains uncertain and will not be resubmitted" };
2030
+ }
1938
2031
  }
1939
2032
  if (submitted() || await queued())
1940
2033
  return { ok: true };
1941
- if (requireEventEvidence && await cleared())
2034
+ if (requireEventEvidence && await cleared()) {
2035
+ if (await lateEvidence(LATE_LAND_MS))
2036
+ return { ok: true };
2037
+ log.warn({ target, eventId, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: 框空了且转录里始终没有这一笔, 判不确定并停止重贴");
1942
2038
  return { ok: false, uncertain: true, reason: "composer cleared without this event's transcript marker; delivery remains uncertain and will not be resubmitted" };
1943
- // 补到头还没清空。通常是渲染滞后 (回车其实落了), 但也可能是目标会话正忙 /
1944
- // 上下文满了不消费输入 —— 那样用户这条消息会悄无声息地消失。不报硬失败 (用户
1945
- // 多半已经被服务到了), 标 uncertain 让调用方按需提示。
1946
- log.warn({ target, tailFp, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: clear not observed, trusting submit");
1947
- return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
2039
+ }
2040
+ // 补到头仍没证据: 可能只是渲染/落盘迟到, 也可能根本没提交。
2041
+ // 保留 uncertain 阻止重贴, 但不能让只看 ok 的调用方把它当成已送达。
2042
+ log.warn({ target, tailFp, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: submission unconfirmed, stopping input");
2043
+ return { ok: false, uncertain: true, reason: "目标会话未确认收下消息,停止输入以避免误提交或重复投递" };
1948
2044
  };
1949
2045
  const injectViaSpawn = async (args) => {
1950
2046
  const { text, images = [], cfg, log, sessionId, jsonlPath } = args;
@@ -2103,17 +2199,19 @@ const etimeSec = (s) => {
2103
2199
  return ((d * 24 + h) * 60 + mi) * 60 + se;
2104
2200
  };
2105
2201
  const psSnap = () => new Promise((resolve) => {
2106
- const p = spawn("ps", ["-axo", "pid=,ppid=,etime="], { env: { ...process.env, PATH: augmentedPath(process.env.PATH) }, stdio: ["ignore", "pipe", "ignore"] });
2202
+ const p = spawn("ps", ["-axo", "pid=,ppid=,etime=,comm="], { env: { ...process.env, PATH: augmentedPath(process.env.PATH) }, stdio: ["ignore", "pipe", "ignore"] });
2107
2203
  let out = "";
2108
2204
  p.stdout?.on("data", (c) => (out += c.toString("utf8")));
2109
2205
  const done = () => {
2110
2206
  const kids = new Map();
2111
2207
  const etimes = new Map();
2208
+ const commands = new Map();
2112
2209
  for (const line of out.split("\n")) {
2113
- const [pidS = "", ppidS = "", etS = ""] = line.trim().split(/\s+/);
2210
+ const [pidS = "", ppidS = "", etS = "", ...command] = line.trim().split(/\s+/);
2114
2211
  const pid = Number(pidS), ppid = Number(ppidS), et = etimeSec(etS);
2115
2212
  if (!pid || !ppid)
2116
2213
  continue;
2214
+ commands.set(pid, command.join(" "));
2117
2215
  const arr = kids.get(ppid);
2118
2216
  if (arr)
2119
2217
  arr.push(pid);
@@ -2122,10 +2220,10 @@ const psSnap = () => new Promise((resolve) => {
2122
2220
  if (!Number.isNaN(et))
2123
2221
  etimes.set(pid, et);
2124
2222
  }
2125
- resolve({ kids, etimes });
2223
+ resolve({ kids, etimes, commands });
2126
2224
  };
2127
2225
  p.on("close", done);
2128
- p.on("error", () => resolve({ kids: new Map(), etimes: new Map() }));
2226
+ p.on("error", () => resolve({ kids: new Map(), etimes: new Map(), commands: new Map() }));
2129
2227
  });
2130
2228
  /** Drop a session's queue chain. A hung job can't be cancelled (it's parked in a
2131
2229
  * tmux read), but detaching the chain means the NEXT message doesn't inherit
@@ -2267,6 +2365,7 @@ export const startMirror = (deps) => {
2267
2365
  };
2268
2366
  const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
2269
2367
  let eventFinalGate = () => true;
2368
+ let eventHandedAway = () => false;
2270
2369
  const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2271
2370
  const newMarkId = () => `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2272
2371
  const detailCardFor = (s, target) => {
@@ -2868,6 +2967,62 @@ export const startMirror = (deps) => {
2868
2967
  return p;
2869
2968
  };
2870
2969
  const finishBriefBubble = (a, content, raw = false) => finishBubble(a, a.briefBubble, content, raw);
2970
+ // ── transfer 的气泡移交 ───────────────────────────────────────────────
2971
+ // 人问的那一笔 transfer 出去, 气泡 (那句人话的 frame/streamId) 跟着行走: 从交出方这一轮摘下,
2972
+ // 按接手方的注入事件 id 寄存, 接手方开出那一轮时 (ensureBriefTurn) 接过去 —— CoT 进度与终句
2973
+ // 续写同一颗气泡, 不另开新的。寄存只在内存: reload 后丢了就退回 standalone, 与过期同。
2974
+ const handedBubbles = new Map();
2975
+ const writeBubble = (b, content, finish) => (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined)
2976
+ .then(() => client.replyStream(b.frame, b.streamId, content, finish))
2977
+ .catch((e) => log.warn({ err: e.message, finish }, "transfer: handed bubble write failed")));
2978
+ /** 6min 窗口的兜底收口按气泡自己的到期点算 (它从人那句话起算, 不因转手重置)。 */
2979
+ const untilExpiry = (b) => Math.max(0, (b.expires ?? Date.now()) - Date.now());
2980
+ const handBubble = (source, serves, eventId, target) => {
2981
+ const a = byTarget.get(source);
2982
+ const b = a?.briefBubble;
2983
+ const turnId = a?.briefTurnId;
2984
+ // 只交「这一轮正服务着那一笔」的气泡: 别的轮的气泡与这次移交无关。
2985
+ if (!a || !b || b.done || !turnId || !a.activeEventId || deps.events?.get(a.activeEventId)?.serves !== serves)
2986
+ return false;
2987
+ clearTimeout(b.hardTimer);
2988
+ a.briefBubble = undefined;
2989
+ clearCot(a);
2990
+ const channel = a.channel || undefined;
2991
+ const line = (rest) => headed(chatHead(target, chatTurnUrl(target, turnId), undefined, channel), rest) || "…";
2992
+ void writeBubble(b, line("…"), false);
2993
+ const held = { bubble: b };
2994
+ // 接手方迟迟不开轮 (在忙 / 被并进别的轮): 到期前收口成「仍在处理中」, 终句届时走 standalone。
2995
+ if (!isWxFrame(b.frame))
2996
+ held.timer = setTimeout(() => {
2997
+ if (handedBubbles.get(eventId) !== held)
2998
+ return;
2999
+ handedBubbles.delete(eventId);
3000
+ b.done = true;
3001
+ void writeBubble(b, line(STILL_WORKING), true);
3002
+ }, untilExpiry(b));
3003
+ handedBubbles.set(eventId, held);
3004
+ log.info({ source, target, serves, eventId, turnId }, "transfer: bubble handed to new owner");
3005
+ return true;
3006
+ };
3007
+ /** 接手方这一轮 (`a.activeEventId`) 若有寄存的气泡: 接成本轮气泡, 头换成它自己的链接。 */
3008
+ const adoptBubble = (a, turnId) => {
3009
+ const held = a.activeEventId ? handedBubbles.get(a.activeEventId) : undefined;
3010
+ if (!held)
3011
+ return undefined;
3012
+ handedBubbles.delete(a.activeEventId);
3013
+ clearTimeout(held.timer);
3014
+ const b = held.bubble;
3015
+ if (b.done || untilExpiry(b) === 0)
3016
+ return undefined;
3017
+ if (!isWxFrame(b.frame))
3018
+ b.hardTimer = setTimeout(() => {
3019
+ const live = a.briefBubble === b && !a.briefConcluded;
3020
+ void finishBubble(a, b, briefLine(a, turnId, live ? STILL_WORKING : ""), true);
3021
+ }, untilExpiry(b));
3022
+ void writeBubble(b, briefLine(a, turnId, "…"), false);
3023
+ log.info({ sessionId: a.sessionId, turnId, eventId: a.activeEventId }, "transfer: handed bubble adopted");
3024
+ return b;
3025
+ };
2871
3026
  // 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
2872
3027
  const openBriefTurn = (a, q) => {
2873
3028
  a.briefTurnId = q.turnId;
@@ -3187,12 +3342,15 @@ export const startMirror = (deps) => {
3187
3342
  a.briefTurnId = turnId;
3188
3343
  if (a.activeEventId)
3189
3344
  deps.events?.bind(a.activeEventId, turnId);
3190
- a.briefBubble = undefined;
3191
3345
  a.briefIsSlash = false;
3192
3346
  a.briefHadTool = false;
3193
3347
  a.briefConcluded = false;
3194
3348
  a.briefLastText = undefined;
3195
3349
  clearCot(a);
3350
+ // transfer 接过来的那一笔: 续写人那句话的气泡 —— 这一轮就是在答群里的人。
3351
+ a.briefBubble = adoptBubble(a, turnId);
3352
+ if (a.briefBubble)
3353
+ a.turnFromChat = true;
3196
3354
  // 详情链接不在这里单独发 — 本轮的每条 standalone 都由 withLinkedTag 自带
3197
3355
  // (chatTurnUrl 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
3198
3356
  // 一条都不发: 群里只该出现有正文的消息, 纯入口提示是噪音。
@@ -3238,7 +3396,10 @@ export const startMirror = (deps) => {
3238
3396
  if (event)
3239
3397
  deps.events.result(event.id, body, finalId(event.id, event.channel, body), turnId);
3240
3398
  const gated = !!event && !eventFinalGate(event);
3241
- if (!turnSilent(a) && !gated) {
3399
+ // 那一笔已交出: 交代归接手方, 这句只留 rolepage —— 不进气泡 (已移交) 也不 standalone。
3400
+ if (event && eventHandedAway(event))
3401
+ log.info({ sessionId: a.sessionId, turnId, event: event.id }, "brief: final of a transferred-away row kept off chat");
3402
+ else if (!turnSilent(a) && !gated) {
3242
3403
  if (event)
3243
3404
  recoverEventFinal(deps.events.get(event.id), a.briefBubble);
3244
3405
  else
@@ -4227,7 +4388,7 @@ export const startMirror = (deps) => {
4227
4388
  // jsonl means "no input yet", NOT "rotated away" — healing it onto some
4228
4389
  // unrelated newest-in-cwd file is exactly the cross-wire we must avoid.
4229
4390
  const storedPane = (rec.tmuxPane ?? "").trim();
4230
- const livePane = storedPane && (panes ? panes.has(storedPane) : await tmuxPaneAlive(storedPane)) ? storedPane : "";
4391
+ const livePane = storedPane && (panes ? panes.has(storedPane) : await tmuxPaneAlive(storedPane)) ? storedPane : await codexPaneForTranscript(jsonlAbs);
4231
4392
  if (permitted && !permitted())
4232
4393
  return undefined;
4233
4394
  if (!existsSync(jsonlAbs)) {
@@ -5254,7 +5415,8 @@ export const startMirror = (deps) => {
5254
5415
  model: a?.model || rec?.model || "",
5255
5416
  effort: a?.effort || rec?.effort || "",
5256
5417
  tmuxPane: a?.tmuxPane ?? rec?.tmuxPane ?? "",
5257
- contextTokens: lastContextTokens(jsonlPath),
5418
+ // 惰性: 读 transcript 尾巴 (64K 起, 最多 2MB), 只要 pane / sid 的调用方 (调度器每秒逐行问) 不该替它付。
5419
+ get contextTokens() { return lastContextTokens(jsonlPath); },
5258
5420
  };
5259
5421
  };
5260
5422
  const getCwd = (target) => {
@@ -5463,7 +5625,8 @@ export const startMirror = (deps) => {
5463
5625
  const session = codexSessionForPane(pane);
5464
5626
  return session && { sessionId: session.sessionId, jsonlPath: session.jsonlPath };
5465
5627
  }
5466
- const live = activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find((s) => s?.pane === pane);
5628
+ // 只喂调度器 (message-events 的 transcript / sessionId 适配器): 每秒逐行问, 走目录快照。
5629
+ const live = activeBackends().map((b) => sessionOnPane(b.homeDir, pane, registrySnapshot)).find((s) => s?.pane === pane);
5467
5630
  return live && { sessionId: live.sessionId, jsonlPath: join(dirname(transcript), `${live.sessionId}.jsonl`) };
5468
5631
  };
5469
5632
  // Busy comes from the native lifecycle or an interrupt hint. A dead/unbound pane is
@@ -6116,11 +6279,20 @@ export const startMirror = (deps) => {
6116
6279
  const a = byTarget.get(target) ?? (await restoreFromStore(target, undefined, permission));
6117
6280
  if (!permission())
6118
6281
  return denied;
6282
+ // 恢复已经试过了: store 里连绑定都没有 (被 stop / kill 收掉, 或 transcript 没了被 drop) =
6283
+ // 收方真不在了, 报 gone 让上层改投; 还有绑定只是这次 attach 没成, 照旧稍后重试。
6119
6284
  if (!a)
6120
- return { ok: false, retryable: true, reason: "no mirror attached for target" };
6285
+ return { ok: false, retryable: true, reason: "no mirror attached for target", ...(deps.store.get(target) ? {} : { gone: true }) };
6121
6286
  const sid = a.sessionId;
6122
6287
  const generation = a.injectGen ?? 0;
6123
6288
  const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
6289
+ if (!a.tmuxPane) {
6290
+ const recovered = await codexPaneForTranscript(a.jsonlPath);
6291
+ if (!permitted())
6292
+ return denied;
6293
+ if (!a.tmuxPane)
6294
+ a.tmuxPane = recovered;
6295
+ }
6124
6296
  const probedPane = a.tmuxPane;
6125
6297
  const probe = a.tmuxPane ? await probePane(a.tmuxPane, a.jsonlPath) : { state: "gone" };
6126
6298
  if (!permitted() || a.tmuxPane !== probedPane)
@@ -6549,6 +6721,8 @@ export const startMirror = (deps) => {
6549
6721
  formatFinal,
6550
6722
  recoverEventFinal,
6551
6723
  setEventFinalGate: (gate) => { eventFinalGate = gate; },
6724
+ setEventHandedAway: (away) => { eventHandedAway = away; },
6725
+ handBubble,
6552
6726
  settleFinalProgress,
6553
6727
  dispatch: async ({ principal, text, images, frame, streamId, channel, speaker, eventId, guard, onSubmitted }) => {
6554
6728
  // Route by inbound principal — the WeCom chat the user just messaged us
@@ -6707,6 +6881,15 @@ export const startMirror = (deps) => {
6707
6881
  // First step of the job and the historical hang point — log BEFORE the
6708
6882
  // probe so a stall is attributable to it next time, not invisible.
6709
6883
  log.info({ target: a.target, sessionId: sid, pane: a.tmuxPane, gen: myGen }, "inject job start");
6884
+ if (!a.tmuxPane) {
6885
+ const recovered = await codexPaneForTranscript(a.jsonlPath);
6886
+ if (byTarget.get(a.target) !== a || (a.injectGen ?? 0) !== myGen || (guard && !guard())) {
6887
+ onSubmitted?.({ ok: false, retryable: true, reason: "pane recovery permission withdrawn" });
6888
+ return;
6889
+ }
6890
+ if (!a.tmuxPane)
6891
+ a.tmuxPane = recovered;
6892
+ }
6710
6893
  const probe = a.tmuxPane ? await probePane(a.tmuxPane, a.jsonlPath) : { state: "gone" };
6711
6894
  let freshSpawn = a.justSpawned === true;
6712
6895
  a.justSpawned = false;
@@ -6760,7 +6943,16 @@ export const startMirror = (deps) => {
6760
6943
  else {
6761
6944
  // Drop only the stale pane id; keep tmuxSession (if any) so the
6762
6945
  // store still reflects "user wanted tmux" — next turn retries.
6763
- log.error({ reason: r.reason }, "mirror: tmux respawn failed, this turn falls back to spawn-mode");
6946
+ log.error({ reason: r.reason }, "mirror: tmux respawn failed");
6947
+ if (eventId) {
6948
+ onSubmitted?.({ ok: false, retryable: true, reason: `respawn failed: ${r.reason ?? "unknown"}` });
6949
+ if (s)
6950
+ await finalizeStream(a, s);
6951
+ if (a.outbound?.kind === "deferred")
6952
+ clearTimeout(a.outbound.timer);
6953
+ a.outbound = undefined;
6954
+ return;
6955
+ }
6764
6956
  a.tmuxPane = "";
6765
6957
  }
6766
6958
  }
@@ -6806,9 +6998,15 @@ export const startMirror = (deps) => {
6806
6998
  if (!r.ok) {
6807
6999
  // retryable = 一个字都没进输入框, 消息事件层会稍后重投 —— 不是失败, 别报 ✗
6808
7000
  // (每次退避重试都会走到这里, 报 ✗ 就是一串假警报)。
6809
- const note = r.retryable && eventId
6810
- ? `[mirror] ⏳ 暂未送达, 稍后自动重投 (${r.reason ?? "deferred"})`
6811
- : `[mirror] ✗ ${r.reason ?? "failed"}`;
7001
+ if (r.retryable && eventId) {
7002
+ if (s)
7003
+ await finalizeStream(a, s);
7004
+ if (a.outbound?.kind === "deferred")
7005
+ clearTimeout(a.outbound.timer);
7006
+ a.outbound = undefined;
7007
+ return;
7008
+ }
7009
+ const note = `[mirror] ✗ ${r.reason ?? "failed"}`;
6812
7010
  if (s) {
6813
7011
  s.acc = s.acc ? `${s.acc}\n\n${note}` : note;
6814
7012
  await finalizeStream(a, s);