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.
- package/dist/daemon/handoff.js +68 -33
- package/dist/daemon/handoff.js.map +1 -1
- package/dist/daemon/inbound.js +1 -1
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +410 -205
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/message-events.js +132 -52
- package/dist/daemon/message-events.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +586 -105
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +15 -9
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/router.js +3 -2
- package/dist/daemon/router.js.map +1 -1
- package/dist/daemon/task-registry.js +47 -21
- package/dist/daemon/task-registry.js.map +1 -1
- package/dist/daemon/tasks.js +68 -26
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/tmux-buffer.js +1 -1
- package/dist/daemon/tmux-buffer.js.map +1 -1
- package/dist/daemon/wizard-names.js +63 -0
- package/dist/daemon/wizard-names.js.map +1 -0
- package/dist/daemon/wizard.js +16 -5
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/daemon/workflow-nodes.js +19 -0
- package/dist/daemon/workflow-nodes.js.map +1 -1
- package/dist/mcp/server.js +10 -18
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/modal-pane.js +6 -0
- package/dist/shared/modal-pane.js.map +1 -1
- package/dist/shared/task-file.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
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
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
|
|
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 && (
|
|
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
|
-
|
|
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 (
|
|
1338
|
+
if (layout.length - bottom > BOX_FOOTER_ROWS)
|
|
1329
1339
|
return UNKNOWN_BOX;
|
|
1330
|
-
const rows =
|
|
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 =
|
|
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
|
|
1442
|
-
|
|
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
|
-
|
|
1744
|
-
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
|
1934
|
-
const
|
|
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
|
-
|
|
1971
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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 (
|
|
3128
|
-
bubble
|
|
3129
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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 (!
|
|
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 ||
|
|
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
|
-
|
|
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 (!
|
|
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 (!
|
|
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 (
|
|
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" && !(
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
6837
|
-
|
|
6838
|
-
|
|
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);
|