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.
- package/CLAUDE.md +1 -1
- package/README.md +4 -4
- package/cli/wezard.sh +9 -2
- package/dist/daemon/chat-name.js +6 -4
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/handoff.js +34 -6
- package/dist/daemon/handoff.js.map +1 -1
- package/dist/daemon/index.js +124 -47
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +3 -2
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/message-events.js +263 -178
- package/dist/daemon/message-events.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +253 -55
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +9 -3
- 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/wezard-session.js +24 -2
- package/dist/daemon/wezard-session.js.map +1 -1
- package/dist/daemon/wizard.js +4 -3
- 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 +4 -4
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/config.js +2 -12
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/modal-pane.js +6 -0
- package/dist/shared/modal-pane.js.map +1 -1
- package/package.json +1 -1
- package/dist/daemon/top-only-nudge.js +0 -29
- package/dist/daemon/top-only-nudge.js.map +0 -1
|
@@ -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
|
|
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
|
+
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
|
-
//
|
|
1313
|
-
//
|
|
1314
|
-
|
|
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 && (
|
|
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
|
-
|
|
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 (
|
|
1335
|
+
if (layout.length - bottom > BOX_FOOTER_ROWS)
|
|
1329
1336
|
return UNKNOWN_BOX;
|
|
1330
|
-
const rows =
|
|
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 =
|
|
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
|
|
1442
|
-
|
|
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
|
-
|
|
1744
|
-
|
|
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 =
|
|
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 (
|
|
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 (
|
|
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
|
|
1919
|
-
const
|
|
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
|
-
|
|
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
|
-
//
|
|
1946
|
-
log.warn({ target, tailFp, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject:
|
|
1947
|
-
return { ok:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
6810
|
-
|
|
6811
|
-
|
|
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);
|