wezard 2.6.2 → 2.6.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-plugin/plugin.json +1 -1
- package/config.example.jsonc +3 -1
- package/dist/daemon/answer-transfer.js +56 -0
- package/dist/daemon/answer-transfer.js.map +1 -0
- package/dist/daemon/approval.js +1 -1
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/cfg-sync.js +1 -1
- package/dist/daemon/cfg-sync.js.map +1 -1
- package/dist/daemon/chat-name.js +4 -4
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/config-api.js +2 -2
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/image-input.js +34 -0
- package/dist/daemon/image-input.js.map +1 -0
- package/dist/daemon/inbound.js +4 -11
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +880 -280
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +32 -13
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +16 -6
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/message-format.js +40 -0
- package/dist/daemon/message-format.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +673 -295
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/pane-input.js +48 -0
- package/dist/daemon/pane-input.js.map +1 -0
- package/dist/daemon/peers.js +15 -14
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/pending-items.js +8 -0
- package/dist/daemon/pending-items.js.map +1 -1
- package/dist/daemon/receipts.js +346 -120
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/scheduled-delivery.js +52 -0
- package/dist/daemon/scheduled-delivery.js.map +1 -0
- package/dist/daemon/steward-model.js +10 -10
- package/dist/daemon/steward-model.js.map +1 -1
- package/dist/daemon/tasks.js +83 -27
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/tmux-buffer.js +16 -6
- package/dist/daemon/tmux-buffer.js.map +1 -1
- package/dist/daemon/weixin-cmd.js +2 -4
- package/dist/daemon/weixin-cmd.js.map +1 -1
- package/dist/daemon/wizard.js +2 -1
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/daemon/workflow-nodes.js +217 -0
- package/dist/daemon/workflow-nodes.js.map +1 -0
- package/dist/daemon/workflow-plan-http.js +83 -0
- package/dist/daemon/workflow-plan-http.js.map +1 -0
- package/dist/daemon/workflow-plans.js +274 -0
- package/dist/daemon/workflow-plans.js.map +1 -0
- package/dist/mcp/server.js +27 -1
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/config.js +12 -10
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/json-map-store.js +46 -9
- package/dist/shared/json-map-store.js.map +1 -1
- package/dist/shared/keyed-scheduler.js +51 -0
- package/dist/shared/keyed-scheduler.js.map +1 -0
- package/dist/shared/message-footer.js +34 -0
- package/dist/shared/message-footer.js.map +1 -0
- package/dist/shared/model-tiers.js +1 -2
- package/dist/shared/model-tiers.js.map +1 -1
- package/dist/shared/reminder.js +3 -0
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/session-label.js +19 -1
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/workflow-plan.js +36 -0
- package/dist/shared/workflow-plan.js.map +1 -0
- package/package.json +1 -1
|
@@ -24,6 +24,9 @@ import { newStewardSpec, resumedStewardSpec } from "./steward-model.js";
|
|
|
24
24
|
import { expandHome, sanitizeId } from "../shared/paths.js";
|
|
25
25
|
import { augmentedPath } from "../shared/exec-path.js";
|
|
26
26
|
import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
|
|
27
|
+
import { withImageClipboard, pasteVerifiedImage } from "./image-input.js";
|
|
28
|
+
import { createKeyedScheduler } from "../shared/keyed-scheduler.js";
|
|
29
|
+
import { withPaneInput } from "./pane-input.js";
|
|
27
30
|
import { activeBackends, resolveCliBackend, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
|
|
28
31
|
import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, modalFingerprint, looksLikePicker } from "../shared/modal-pane.js";
|
|
29
32
|
import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
|
|
@@ -42,7 +45,6 @@ import { errText } from "./last-response.js";
|
|
|
42
45
|
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey, sameBot } from "../shared/session-label.js";
|
|
43
46
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
44
47
|
import { isWxFrame } from "../shared/wx-text.js";
|
|
45
|
-
import { randomTip } from "./tips.js";
|
|
46
48
|
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
47
49
|
import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
|
|
48
50
|
import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
|
|
@@ -1159,10 +1161,10 @@ const detectModalPicker = async (target) => {
|
|
|
1159
1161
|
const MODAL_PRESS_SETTLE_MS = 400;
|
|
1160
1162
|
const MODAL_ARROW_SETTLE_MS = 150;
|
|
1161
1163
|
const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
|
|
1162
|
-
const pressModalIndex = async (pane, before, index) => {
|
|
1164
|
+
const pressModalIndex = async (pane, before, index, live = () => true) => {
|
|
1163
1165
|
const fingerprint = modalFingerprint(before.screen);
|
|
1164
1166
|
const current = await detectModalPicker(pane);
|
|
1165
|
-
if (!current.modal || modalFingerprint(current.screen) !== fingerprint)
|
|
1167
|
+
if (!live() || !current.modal || modalFingerprint(current.screen) !== fingerprint)
|
|
1166
1168
|
return current;
|
|
1167
1169
|
await runTmux(["send-keys", "-t", pane, String(index)]);
|
|
1168
1170
|
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
@@ -1171,6 +1173,8 @@ const pressModalIndex = async (pane, before, index) => {
|
|
|
1171
1173
|
return after;
|
|
1172
1174
|
// 数字键没关掉框: 把高亮挪到目标项。guard 上限盖过任何确认框的选项数。
|
|
1173
1175
|
for (let guard = 0; guard < 8; guard++) {
|
|
1176
|
+
if (!live())
|
|
1177
|
+
return after;
|
|
1174
1178
|
const cur = highlightedIndex(after.screen);
|
|
1175
1179
|
if (cur === undefined || cur === index)
|
|
1176
1180
|
break;
|
|
@@ -1180,7 +1184,7 @@ const pressModalIndex = async (pane, before, index) => {
|
|
|
1180
1184
|
if (!after.modal || modalFingerprint(after.screen) !== fingerprint)
|
|
1181
1185
|
return after;
|
|
1182
1186
|
}
|
|
1183
|
-
if (highlightedIndex(after.screen) !== index)
|
|
1187
|
+
if (!live() || highlightedIndex(after.screen) !== index)
|
|
1184
1188
|
return after; // 核不准, 绝不盲按
|
|
1185
1189
|
await runTmux(["send-keys", "-t", pane, "Enter"]);
|
|
1186
1190
|
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
@@ -1301,7 +1305,7 @@ export const parseInputBox = (cap, styledLayout = cap) => {
|
|
|
1301
1305
|
layout.pop();
|
|
1302
1306
|
const footer = layout.length - 1;
|
|
1303
1307
|
// Codex hides the shortcuts row while the composer contains text.
|
|
1304
|
-
const shortcuts =
|
|
1308
|
+
const shortcuts = /(?:^|\s)\? for shortcuts(?:\s|$)/u.test(layout[footer] ?? "");
|
|
1305
1309
|
if (shortcuts || (layout[footer] ?? "").includes("·")) {
|
|
1306
1310
|
let status = shortcuts ? footer - 1 : footer;
|
|
1307
1311
|
while (status >= 0 && !layout[status].trim())
|
|
@@ -1433,7 +1437,7 @@ const witnessFor = (target, homeDir, jsonlPath, live) => {
|
|
|
1433
1437
|
// but the trailing Enter is eaten while the TUI is still initializing, so
|
|
1434
1438
|
// the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
|
|
1435
1439
|
// 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
|
|
1436
|
-
const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false) => {
|
|
1440
|
+
const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard) => {
|
|
1437
1441
|
const backendName = backend.name;
|
|
1438
1442
|
log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
|
|
1439
1443
|
// 就绪门: pane 里得真有一个进入了交互态的 AI 会话, 才轮得到贴。先于 modal guard ——
|
|
@@ -1474,33 +1478,53 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
|
|
|
1474
1478
|
// dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
|
|
1475
1479
|
// withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
|
|
1476
1480
|
if (backendName === "codebuddy") {
|
|
1477
|
-
return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness);
|
|
1481
|
+
return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, guard);
|
|
1478
1482
|
}
|
|
1479
1483
|
// Pump images first via clipboard+C-v so each one is attached as a separate
|
|
1480
1484
|
// image content block. Each C-v needs a brief settle for Claude Code's TUI
|
|
1481
1485
|
// to read the clipboard before the next overwrite. Fresh spawn extends.
|
|
1482
1486
|
const IMG_SETTLE_MS = freshSpawn ? 700 : 350;
|
|
1483
|
-
|
|
1484
|
-
const
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1487
|
+
const pumped = images.length ? await withImageClipboard(async () => {
|
|
1488
|
+
for (const imgPath of images) {
|
|
1489
|
+
const paste = async () => {
|
|
1490
|
+
const cb = await setMacClipboardImage(imgPath);
|
|
1491
|
+
if (!cb.ok)
|
|
1492
|
+
return cb;
|
|
1493
|
+
if (guard && !guard("paste"))
|
|
1494
|
+
return { ok: false, reason: "image paste canceled by dispatch guard" };
|
|
1495
|
+
const cv = await runTmux(["send-keys", "-t", target, "C-v"]);
|
|
1496
|
+
return cv.ok ? { ok: true } : { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
|
|
1497
|
+
};
|
|
1498
|
+
// Cold Codex can ignore C-v before its composer exists. Do not submit
|
|
1499
|
+
// the text until the composer proves this attachment has landed.
|
|
1500
|
+
const result = backendName === "codex"
|
|
1501
|
+
? await pasteVerifiedImage({ read: () => readInputBox(target), paste, valid: () => !guard || guard("paste") })
|
|
1502
|
+
: await paste();
|
|
1503
|
+
if (!result.ok) {
|
|
1504
|
+
log.warn({ target, imgPath, reason: result.reason }, "mirror inject: image not confirmed, refusing text submission");
|
|
1505
|
+
return result;
|
|
1506
|
+
}
|
|
1507
|
+
// Attachment rendering can precede the TUI finishing its paste handler.
|
|
1508
|
+
// Preserve the key settle interval before another C-v or text paste.
|
|
1509
|
+
await sleep(IMG_SETTLE_MS);
|
|
1510
|
+
log.info({ target, imgPath, verified: backendName === "codex" }, "mirror inject: image attached");
|
|
1488
1511
|
}
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
}
|
|
1512
|
+
return { ok: true };
|
|
1513
|
+
}) : { ok: true };
|
|
1514
|
+
if (!pumped.ok)
|
|
1515
|
+
return { ...pumped, uncertain: true };
|
|
1494
1516
|
// Image-only message: TUI input box now holds the attached images; press
|
|
1495
1517
|
// Enter and we're done. No fingerprint to verify (the input itself is binary
|
|
1496
1518
|
// image refs, not text).
|
|
1497
1519
|
if (!text) {
|
|
1498
1520
|
if (images.length === 0)
|
|
1499
1521
|
return { ok: true };
|
|
1522
|
+
if (guard && !guard("submit"))
|
|
1523
|
+
return { ok: false, reason: "submit canceled by dispatch guard" };
|
|
1500
1524
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1501
1525
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
|
|
1502
1526
|
}
|
|
1503
|
-
return injectViaTmuxText(target, text, log, freshSpawn, witness);
|
|
1527
|
+
return injectViaTmuxText(target, text, log, freshSpawn, witness, guard);
|
|
1504
1528
|
};
|
|
1505
1529
|
// 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
|
|
1506
1530
|
// 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
|
|
@@ -1522,7 +1546,7 @@ const withImageRefs = (text, images) => {
|
|
|
1522
1546
|
// 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
|
|
1523
1547
|
// 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
|
|
1524
1548
|
// 每次补之前再问一遍证人, 已提交就不补。
|
|
1525
|
-
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
|
|
1549
|
+
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard) => {
|
|
1526
1550
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1527
1551
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
1528
1552
|
// end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
|
|
@@ -1559,7 +1583,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1559
1583
|
const baseBody = alreadyTyped ? "" : preBox.body;
|
|
1560
1584
|
/** 框里还是贴之前那样 (或彻底空了) = 我们的文本不在框里。 */
|
|
1561
1585
|
const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === baseBody);
|
|
1562
|
-
const loadAndPaste = () => pasteTmuxBuffer(target, text);
|
|
1586
|
+
const loadAndPaste = () => pasteTmuxBuffer(target, text, guard ? () => guard("paste") : undefined);
|
|
1563
1587
|
// 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
|
|
1564
1588
|
// 头部可能在框顶, 所以窗口要宽)。
|
|
1565
1589
|
const sawLanded = async (timeoutMs) => {
|
|
@@ -1583,6 +1607,9 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1583
1607
|
let pasteSeen = alreadyTyped;
|
|
1584
1608
|
let pastedAt = Date.now();
|
|
1585
1609
|
if (alreadyTyped) {
|
|
1610
|
+
// Existing text is already exposed, even if permission was just withdrawn.
|
|
1611
|
+
if (guard && !guard("paste"))
|
|
1612
|
+
return { ok: false, uncertain: true, reason: "existing input submission canceled by dispatch guard" };
|
|
1586
1613
|
log.warn({ target, headFp }, "mirror inject: 输入框里已有这段文本 (上次未提交) — 不重贴, 直接提交");
|
|
1587
1614
|
}
|
|
1588
1615
|
else {
|
|
@@ -1647,6 +1674,8 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1647
1674
|
await sleep(Math.max(0, POST_PASTE_SETTLE_FALLBACK_MS - (Date.now() - pastedAt)));
|
|
1648
1675
|
}
|
|
1649
1676
|
const sendEnter = async () => {
|
|
1677
|
+
if (guard && !guard("submit"))
|
|
1678
|
+
return { ok: false, reason: "submit canceled by dispatch guard" };
|
|
1650
1679
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1651
1680
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
|
|
1652
1681
|
};
|
|
@@ -1749,6 +1778,8 @@ const injectViaSpawn = async (args) => {
|
|
|
1749
1778
|
...cfg.wrc.extraArgs,
|
|
1750
1779
|
];
|
|
1751
1780
|
log.info({ sessionId, bin, len: text.length }, "mirror inject");
|
|
1781
|
+
if (args.guard && !args.guard("submit"))
|
|
1782
|
+
return { ok: false, reason: "spawn canceled by dispatch guard" };
|
|
1752
1783
|
return new Promise((resolve) => {
|
|
1753
1784
|
const proc = spawn(bin, cliArgs, {
|
|
1754
1785
|
cwd,
|
|
@@ -1778,11 +1809,57 @@ const injectViaSpawn = async (args) => {
|
|
|
1778
1809
|
});
|
|
1779
1810
|
});
|
|
1780
1811
|
};
|
|
1781
|
-
const inject = (args) => {
|
|
1812
|
+
const inject = async (args) => {
|
|
1782
1813
|
const target = (args.tmuxTarget ?? "").trim();
|
|
1783
|
-
if (!target)
|
|
1784
|
-
|
|
1785
|
-
|
|
1814
|
+
if (!target) {
|
|
1815
|
+
if (args.requireIdle)
|
|
1816
|
+
return { ok: false, retryable: true, reason: "idle delivery requires a live pane" };
|
|
1817
|
+
try {
|
|
1818
|
+
if (args.guard && !args.guard())
|
|
1819
|
+
return { ok: false, retryable: true, reason: "spawn permission withdrawn" };
|
|
1820
|
+
const text = args.prepareText?.() ?? args.text;
|
|
1821
|
+
let activated = false;
|
|
1822
|
+
const result = await injectViaSpawn({ ...args, text, guard: (phase) => {
|
|
1823
|
+
if (args.guard && !args.guard(phase))
|
|
1824
|
+
return false;
|
|
1825
|
+
if (phase && !activated) {
|
|
1826
|
+
activated = true;
|
|
1827
|
+
args.beforeInput?.();
|
|
1828
|
+
}
|
|
1829
|
+
return true;
|
|
1830
|
+
} });
|
|
1831
|
+
return result.ok ? result : { ...result, uncertain: true };
|
|
1832
|
+
}
|
|
1833
|
+
catch (error) {
|
|
1834
|
+
return { ok: false, uncertain: true, reason: String(error) };
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
let exposed = false;
|
|
1838
|
+
const classify = (result) => result.ok ? result
|
|
1839
|
+
: result.uncertain || exposed ? { ...result, uncertain: true, retryable: false }
|
|
1840
|
+
: { ...result, retryable: true };
|
|
1841
|
+
try {
|
|
1842
|
+
return classify(await withPaneInput(target, async (live) => {
|
|
1843
|
+
// Busy and unknown are both a deferred delivery. Never hold the input
|
|
1844
|
+
// lock while waiting for a model's whole turn to finish.
|
|
1845
|
+
if (args.requireIdle && !args.requireIdle())
|
|
1846
|
+
return { ok: false, reason: "target has no confirmed idle evidence" };
|
|
1847
|
+
const text = args.prepareText?.() ?? args.text;
|
|
1848
|
+
const guard = (phase) => {
|
|
1849
|
+
if (!live() || (!exposed && args.requireIdle && !args.requireIdle()) || (args.guard && !args.guard(phase)))
|
|
1850
|
+
return false;
|
|
1851
|
+
if (phase && !exposed) {
|
|
1852
|
+
exposed = true;
|
|
1853
|
+
args.beforeInput?.();
|
|
1854
|
+
}
|
|
1855
|
+
return true;
|
|
1856
|
+
};
|
|
1857
|
+
return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard);
|
|
1858
|
+
}, { guard: args.guard }));
|
|
1859
|
+
}
|
|
1860
|
+
catch (error) {
|
|
1861
|
+
return classify({ ok: false, reason: String(error) });
|
|
1862
|
+
}
|
|
1786
1863
|
};
|
|
1787
1864
|
const queues = new Map();
|
|
1788
1865
|
/** Ceiling for one whole inject job (respawn + paste + submit verification).
|
|
@@ -1989,7 +2066,11 @@ export const startMirror = (deps) => {
|
|
|
1989
2066
|
const content = s.acc;
|
|
1990
2067
|
s.lastSent = content;
|
|
1991
2068
|
try {
|
|
1992
|
-
await
|
|
2069
|
+
await (s.pending = (s.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2070
|
+
if (s.closed || s.dead)
|
|
2071
|
+
return;
|
|
2072
|
+
await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
|
|
2073
|
+
}));
|
|
1993
2074
|
log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
|
|
1994
2075
|
}
|
|
1995
2076
|
catch (e) {
|
|
@@ -2030,6 +2111,7 @@ export const startMirror = (deps) => {
|
|
|
2030
2111
|
if (!s.dead && body.trim()) {
|
|
2031
2112
|
const card = detailCardFor(s, a.target);
|
|
2032
2113
|
try {
|
|
2114
|
+
await s.pending?.catch(() => undefined);
|
|
2033
2115
|
if (card) {
|
|
2034
2116
|
s.cardSent = true;
|
|
2035
2117
|
await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
|
|
@@ -2079,7 +2161,7 @@ export const startMirror = (deps) => {
|
|
|
2079
2161
|
// 头挂 chat 详情页的票据: 本轮那条 turn。没有活 turn 可挂 → undefined, 头退回裸
|
|
2080
2162
|
// `emoji #tag` (withLinkedTagHeader 自己兜)。
|
|
2081
2163
|
const chatTurnUrl = (target, turnId) => turnId
|
|
2082
|
-
? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target))
|
|
2164
|
+
? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target), target)
|
|
2083
2165
|
: undefined;
|
|
2084
2166
|
// `[mirror]` / `[wezard]` 系统提示不是答给谁的话, 不标方向。
|
|
2085
2167
|
const withLinkedTag = (a, content, seq, turnId) => withLinkedTagHeader(a.target, content, chatTurnUrl(a.target, turnId ?? a.briefTurnId), seq, /^\[(mirror|wezard)\]/.test(content.trim()) ? undefined : a.replyTo, a.channel || undefined);
|
|
@@ -2094,15 +2176,21 @@ export const startMirror = (deps) => {
|
|
|
2094
2176
|
const sendStandalone = (a, content) => {
|
|
2095
2177
|
if (!hasVisibleBody(content))
|
|
2096
2178
|
return;
|
|
2179
|
+
if (answerSilent(a))
|
|
2180
|
+
return;
|
|
2097
2181
|
if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
|
|
2098
2182
|
return;
|
|
2099
2183
|
const chatId = replyChatId(a);
|
|
2100
|
-
const turnId = a.briefTurnId; // 推送是排队异步的, 到发出时 turn 早已收口
|
|
2184
|
+
const turnId = a.briefTurnId ?? a.liveStream?.turnId; // 推送是排队异步的, 到发出时 turn 早已收口
|
|
2101
2185
|
const pieces = splitMarkdown(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
2102
2186
|
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
2103
2187
|
a.standalonePending = a.standalonePending
|
|
2104
2188
|
.then(async () => {
|
|
2189
|
+
if (silenceAnswer(a.target, turnId ?? ""))
|
|
2190
|
+
return;
|
|
2105
2191
|
for (const c of chunks) {
|
|
2192
|
+
if (silenceAnswer(a.target, turnId ?? ""))
|
|
2193
|
+
return;
|
|
2106
2194
|
try {
|
|
2107
2195
|
const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
|
|
2108
2196
|
log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
|
|
@@ -2119,9 +2207,11 @@ export const startMirror = (deps) => {
|
|
|
2119
2207
|
if (!hasVisibleBody(content))
|
|
2120
2208
|
return;
|
|
2121
2209
|
const chatId = replyChatId(a);
|
|
2122
|
-
const turnId = a.briefTurnId;
|
|
2210
|
+
const turnId = a.briefTurnId ?? a.liveStream?.turnId;
|
|
2123
2211
|
a.standalonePending = a.standalonePending
|
|
2124
2212
|
.then(async () => {
|
|
2213
|
+
if (silenceAnswer(a.target, turnId ?? ""))
|
|
2214
|
+
return;
|
|
2125
2215
|
try {
|
|
2126
2216
|
await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
|
|
2127
2217
|
log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
|
|
@@ -2310,7 +2400,7 @@ export const startMirror = (deps) => {
|
|
|
2310
2400
|
// sees the bubble; subsequent items grow it as today.
|
|
2311
2401
|
void (async () => {
|
|
2312
2402
|
try {
|
|
2313
|
-
await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
|
|
2403
|
+
await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
|
|
2314
2404
|
}
|
|
2315
2405
|
catch (e) {
|
|
2316
2406
|
log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
|
|
@@ -2415,11 +2505,15 @@ export const startMirror = (deps) => {
|
|
|
2415
2505
|
const b = a.briefBubble;
|
|
2416
2506
|
const turnId = a.briefTurnId;
|
|
2417
2507
|
// 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
|
|
2418
|
-
if (!text || !b || b.done || !turnId || a.briefConcluded)
|
|
2508
|
+
if (!text || !b || b.done || !turnId || a.briefConcluded || answerSilent(a))
|
|
2419
2509
|
return;
|
|
2420
2510
|
a.cotLastSent = text;
|
|
2421
2511
|
try {
|
|
2422
|
-
await
|
|
2512
|
+
await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2513
|
+
if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || answerSilent(a))
|
|
2514
|
+
return;
|
|
2515
|
+
await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, `\`${text}\``), false);
|
|
2516
|
+
}));
|
|
2423
2517
|
}
|
|
2424
2518
|
catch (e) {
|
|
2425
2519
|
log.debug({ sessionId: a.sessionId, turnId, err: e.message }, "brief: cot refresh failed");
|
|
@@ -2450,7 +2544,172 @@ export const startMirror = (deps) => {
|
|
|
2450
2544
|
// `[mirror]` 系统提示 —— 它们都不走这道门。
|
|
2451
2545
|
// 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
|
|
2452
2546
|
// 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
|
|
2453
|
-
|
|
2547
|
+
let silenceAnswer = () => false;
|
|
2548
|
+
const answerSilent = (a) => silenceAnswer(a.target, a.briefTurnId ?? a.liveStream?.turnId ?? "");
|
|
2549
|
+
const turnSilent = (a) => answerSilent(a) || isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
|
|
2550
|
+
const answerBubbles = new Map();
|
|
2551
|
+
const answerLine = (held, text) => withLinkedTagHeader(held.receiver, text, chatTurnUrl(held.receiver, held.sourceTurn), undefined, undefined, held.channel);
|
|
2552
|
+
// Serialise the old owner's in-flight refresh, reset, progress and final.
|
|
2553
|
+
// A delayed ACK must never let an earlier writer overtake the new owner.
|
|
2554
|
+
const writeAnswerProgress = (held, text) => {
|
|
2555
|
+
held.pending = held.pending.catch(() => undefined).then(async () => {
|
|
2556
|
+
if (!held.active || held.stopped || !held.bubble || held.bubble.done)
|
|
2557
|
+
return;
|
|
2558
|
+
await client.replyStream(held.bubble.frame, held.bubble.streamId, answerLine(held, text), false);
|
|
2559
|
+
}).catch((e) => log.warn({ err: errText(e) }, "answer transfer progress refresh failed"));
|
|
2560
|
+
};
|
|
2561
|
+
const updateAnswerProgress = (a, item) => {
|
|
2562
|
+
// Queued metadata is not authority: only the input boundary activates a task.
|
|
2563
|
+
const held = a.activePeerTurn ? answerBubbles.get(a.activePeerTurn) : undefined;
|
|
2564
|
+
if (!held || held.receiver !== a.target || held.stopped || held.bubble?.done)
|
|
2565
|
+
return;
|
|
2566
|
+
const raw = item.kind === "tool_use" ? cotToolLabel(item.calls)
|
|
2567
|
+
: item.kind === "thinking" || (item.kind === "text" && item.final !== true) ? item.body : "";
|
|
2568
|
+
const line = cotLine(raw);
|
|
2569
|
+
if (!line)
|
|
2570
|
+
return;
|
|
2571
|
+
held.progress = line;
|
|
2572
|
+
if (!held.active)
|
|
2573
|
+
return;
|
|
2574
|
+
if (held.timer)
|
|
2575
|
+
return;
|
|
2576
|
+
held.timer = setTimeout(() => {
|
|
2577
|
+
held.timer = undefined;
|
|
2578
|
+
if (held.progress)
|
|
2579
|
+
writeAnswerProgress(held, `\`${held.progress}\``);
|
|
2580
|
+
}, COT_FLUSH_MS);
|
|
2581
|
+
};
|
|
2582
|
+
const answerContext = (target) => {
|
|
2583
|
+
const a = byTarget.get(target);
|
|
2584
|
+
if (!a || a.briefConcluded || a.briefIsSlash || isPrivate(a) || a.turnFromChat === false)
|
|
2585
|
+
return undefined;
|
|
2586
|
+
const turn = a.briefTurnId ?? a.liveStream?.turnId;
|
|
2587
|
+
return turn ? { turn, channel: a.channel ?? baseOfKey(target), prefix: "" } : undefined;
|
|
2588
|
+
};
|
|
2589
|
+
const prepareAnswer = (id, source, expectedTurn, receiver, channel) => {
|
|
2590
|
+
const a = byTarget.get(source);
|
|
2591
|
+
if (!a || (a.briefTurnId ?? a.liveStream?.turnId) !== expectedTurn || answerBubbles.has(id))
|
|
2592
|
+
return false;
|
|
2593
|
+
if ((!a.briefBubble || a.briefBubble.done) && (!a.liveStream || a.liveStream.closed || a.liveStream.dead))
|
|
2594
|
+
return false;
|
|
2595
|
+
answerBubbles.set(id, {
|
|
2596
|
+
source, receiver, sourceTurn: expectedTurn, channel, active: false, stopped: false,
|
|
2597
|
+
bubble: a.briefBubble, stream: a.liveStream, pending: Promise.resolve(),
|
|
2598
|
+
});
|
|
2599
|
+
return true;
|
|
2600
|
+
};
|
|
2601
|
+
const cancelAnswerPreparation = (id) => {
|
|
2602
|
+
const held = answerBubbles.get(id);
|
|
2603
|
+
if (held && !held.active)
|
|
2604
|
+
answerBubbles.delete(id);
|
|
2605
|
+
};
|
|
2606
|
+
const stopAnswerProgress = (id) => {
|
|
2607
|
+
const held = answerBubbles.get(id);
|
|
2608
|
+
if (!held)
|
|
2609
|
+
return;
|
|
2610
|
+
held.stopped = true;
|
|
2611
|
+
clearTimeout(held.timer);
|
|
2612
|
+
held.timer = undefined;
|
|
2613
|
+
};
|
|
2614
|
+
const takeAnswer = async (id, target, expectedTurn, receiver, channel) => {
|
|
2615
|
+
const a = byTarget.get(target);
|
|
2616
|
+
const held = answerBubbles.get(id);
|
|
2617
|
+
if (!held || held.active || held.source !== target || held.sourceTurn !== expectedTurn || held.receiver !== receiver || held.channel !== channel)
|
|
2618
|
+
return { taken: false, cleared: false };
|
|
2619
|
+
const sameTurn = a && (a.briefTurnId ?? a.liveStream?.turnId) === expectedTurn;
|
|
2620
|
+
if (sameTurn) {
|
|
2621
|
+
clearCot(a);
|
|
2622
|
+
a.cotLastSent = undefined;
|
|
2623
|
+
if (a.standaloneBuf) {
|
|
2624
|
+
clearTimeout(a.standaloneBuf.timer);
|
|
2625
|
+
a.standaloneBuf = undefined;
|
|
2626
|
+
}
|
|
2627
|
+
// Only this source turn becomes private; a concurrent new human turn is untouched.
|
|
2628
|
+
a.channel = "";
|
|
2629
|
+
}
|
|
2630
|
+
const s = held.stream;
|
|
2631
|
+
if (s && !s.closed && !s.dead) {
|
|
2632
|
+
s.closed = true;
|
|
2633
|
+
clearTimeout(s.flushTimer);
|
|
2634
|
+
clearTimeout(s.idleTimer);
|
|
2635
|
+
clearTimeout(s.hardTimer);
|
|
2636
|
+
// Read the current chain, not prepare's snapshot: source may have flushed during injection.
|
|
2637
|
+
held.bubble = { frame: s.frame, streamId: s.streamId, done: false, pending: s.pending };
|
|
2638
|
+
if (a?.liveStream === s)
|
|
2639
|
+
a.liveStream = undefined;
|
|
2640
|
+
}
|
|
2641
|
+
else if (s)
|
|
2642
|
+
held.bubble = undefined;
|
|
2643
|
+
const b = held.bubble;
|
|
2644
|
+
held.active = true;
|
|
2645
|
+
if (!b || b.done)
|
|
2646
|
+
return { taken: true, cleared: false };
|
|
2647
|
+
clearTimeout(b.hardTimer);
|
|
2648
|
+
if (a?.briefBubble === b)
|
|
2649
|
+
a.briefBubble = undefined;
|
|
2650
|
+
let cleared = false;
|
|
2651
|
+
held.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2652
|
+
if (b.done)
|
|
2653
|
+
return;
|
|
2654
|
+
await client.replyStream(b.frame, b.streamId, answerLine(held, "…"), false);
|
|
2655
|
+
cleared = true;
|
|
2656
|
+
});
|
|
2657
|
+
try {
|
|
2658
|
+
await held.pending;
|
|
2659
|
+
}
|
|
2660
|
+
catch (e) {
|
|
2661
|
+
cleared = false;
|
|
2662
|
+
log.warn({ id, err: errText(e) }, "answer transfer reset not acknowledged");
|
|
2663
|
+
}
|
|
2664
|
+
// Keep delivery possible after a reset error; reset never carries the final answer.
|
|
2665
|
+
held.pending = held.pending.catch(() => undefined);
|
|
2666
|
+
if (!held.stopped && held.progress)
|
|
2667
|
+
writeAnswerProgress(held, `\`${held.progress}\``);
|
|
2668
|
+
return { taken: true, cleared };
|
|
2669
|
+
};
|
|
2670
|
+
const deliverAnswer = async (id, target, turn, channel, _prefix, body, standalone) => {
|
|
2671
|
+
const held = answerBubbles.get(id);
|
|
2672
|
+
const text = body.trim();
|
|
2673
|
+
const content = withLinkedTagHeader(target, text, chatTurnUrl(target, turn), undefined, undefined, channel);
|
|
2674
|
+
stopAnswerProgress(id);
|
|
2675
|
+
if (held?.bubble && !held.bubble.done) {
|
|
2676
|
+
held.bubble.done = true;
|
|
2677
|
+
clearTimeout(held.timer);
|
|
2678
|
+
await held.pending;
|
|
2679
|
+
try {
|
|
2680
|
+
// Failure closes progress first; the terminal explanation is a standalone message.
|
|
2681
|
+
const progress = withLinkedTagHeader(target, "回答已转交", chatTurnUrl(target, turn), undefined, undefined, channel);
|
|
2682
|
+
await client.replyStream(held.bubble.frame, held.bubble.streamId, standalone ? progress : content, true);
|
|
2683
|
+
if (!standalone) {
|
|
2684
|
+
answerBubbles.delete(id);
|
|
2685
|
+
return;
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
catch (e) {
|
|
2689
|
+
const ack = e && typeof e === "object" ? e : undefined;
|
|
2690
|
+
// Only a structured non-zero server ACK proves the stream frame was
|
|
2691
|
+
// rejected. Timeout/disconnect errors are ambiguous: standalone retry
|
|
2692
|
+
// could publish the same final answer twice, so let the caller persist
|
|
2693
|
+
// `uncertain` and require a human resend instead.
|
|
2694
|
+
const rejected = !!ack && typeof ack.headers === "object" && !!ack.headers && typeof ack.errcode === "number" && ack.errcode !== 0 && typeof ack.errmsg === "string";
|
|
2695
|
+
if (!rejected && !standalone) {
|
|
2696
|
+
answerBubbles.delete(id);
|
|
2697
|
+
throw e;
|
|
2698
|
+
}
|
|
2699
|
+
if (!rejected) {
|
|
2700
|
+
log.warn({ id, err: errText(e) }, "answer transfer progress close acknowledgement uncertain; continuing with the one standalone terminal explanation");
|
|
2701
|
+
// In standalone mode this stream only closes progress; it never
|
|
2702
|
+
// carries the final body, so sending the terminal explanation once
|
|
2703
|
+
// remains necessary even when progress-close ACK is ambiguous.
|
|
2704
|
+
}
|
|
2705
|
+
else {
|
|
2706
|
+
log.warn({ id, err: errText(e), errcode: ack.errcode }, "answer transfer stream was explicitly rejected; standalone fallback");
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
}
|
|
2710
|
+
await client.sendMessage(channel.replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content } });
|
|
2711
|
+
answerBubbles.delete(id);
|
|
2712
|
+
};
|
|
2454
2713
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
2455
2714
|
// raw=true skips withTagHeader (used when content already contains the linked tag header).
|
|
2456
2715
|
// WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
|
|
@@ -2492,6 +2751,7 @@ export const startMirror = (deps) => {
|
|
|
2492
2751
|
a.briefHadTool = false;
|
|
2493
2752
|
a.briefConcluded = false;
|
|
2494
2753
|
a.briefLastText = undefined;
|
|
2754
|
+
a.cotLastSent = undefined;
|
|
2495
2755
|
clearCot(a); // turn 级状态 —— 进度行跟着气泡走, 不能跨 turn 残留
|
|
2496
2756
|
log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
|
|
2497
2757
|
};
|
|
@@ -2636,6 +2896,7 @@ export const startMirror = (deps) => {
|
|
|
2636
2896
|
const activateDispatch = (a, s, at = s.at) => {
|
|
2637
2897
|
const q = s.turn;
|
|
2638
2898
|
s.activated = true;
|
|
2899
|
+
a.activePeerTurn = undefined;
|
|
2639
2900
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
2640
2901
|
a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
|
|
2641
2902
|
a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
|
|
@@ -2681,12 +2942,18 @@ export const startMirror = (deps) => {
|
|
|
2681
2942
|
if (a.briefTurnId)
|
|
2682
2943
|
closeBriefTurn(a);
|
|
2683
2944
|
const now = Date.now();
|
|
2945
|
+
a.activePeerTurn = s.from?.kind === "peer" ? s.from.turn : undefined;
|
|
2684
2946
|
a.pendingFrom = s.from ? { from: s.from, at: now } : undefined;
|
|
2685
2947
|
a.pendingOrigin = s.origin ? { origin: s.origin, at: now } : undefined;
|
|
2686
2948
|
// 没写频道的注入 (graph 步骤 / 手动 inject) 等同在 home 里开口: 印章就是全部出处, 不沿用上一轮的。
|
|
2687
|
-
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), at: now };
|
|
2949
|
+
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: now };
|
|
2688
2950
|
a.pendingBriefQuery = s.query;
|
|
2689
2951
|
a.pendingBriefAt = ts;
|
|
2952
|
+
if (!cfg.wrc.mirror.brief) {
|
|
2953
|
+
consumeChannel(a, false, consumeFrom(a));
|
|
2954
|
+
if (a.liveStream && !a.liveStream.closed)
|
|
2955
|
+
a.liveStream.dest = s.channel ?? baseOfKey(a.target);
|
|
2956
|
+
}
|
|
2690
2957
|
return s;
|
|
2691
2958
|
};
|
|
2692
2959
|
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
|
|
@@ -2725,7 +2992,7 @@ export const startMirror = (deps) => {
|
|
|
2725
2992
|
// 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
|
|
2726
2993
|
// 尾随 `…` 表示"正文还没到", 正文到位时整条内容被 `链接 正文` 覆盖。
|
|
2727
2994
|
try {
|
|
2728
|
-
await client.replyStream(frame, streamId, briefLine(a, turnId, "…", channel), false);
|
|
2995
|
+
await (bubble.pending = client.replyStream(frame, streamId, briefLine(a, turnId, "…", channel), false));
|
|
2729
2996
|
}
|
|
2730
2997
|
catch (e) {
|
|
2731
2998
|
log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
|
|
@@ -2749,6 +3016,7 @@ export const startMirror = (deps) => {
|
|
|
2749
3016
|
a.channel = r.channel === undefined || r.channel === baseOfKey(a.target) ? undefined : r.channel;
|
|
2750
3017
|
a.speaker = r.speaker;
|
|
2751
3018
|
a.convFrom = r.from;
|
|
3019
|
+
a.activePeerTurn = r.from?.kind === "peer" ? r.from.turn : undefined;
|
|
2752
3020
|
a.replyTo = publicPeer(r.from);
|
|
2753
3021
|
a.turnFromChat = a.turnFromChat ?? true;
|
|
2754
3022
|
a.briefTurnId = r.id;
|
|
@@ -2835,7 +3103,10 @@ export const startMirror = (deps) => {
|
|
|
2835
3103
|
a.lastConcludedBody = body;
|
|
2836
3104
|
a.lastConcludedEpoch = a.queryEpoch ?? 0;
|
|
2837
3105
|
a.lastConcludedAt = Date.now();
|
|
2838
|
-
if (a
|
|
3106
|
+
if (answerSilent(a)) {
|
|
3107
|
+
log.info({ target: a.target, turnId }, "answer transferred; source conclusion suppressed");
|
|
3108
|
+
}
|
|
3109
|
+
else if (a.briefBubble && !a.briefBubble.done) {
|
|
2839
3110
|
void finishBriefBubble(a, briefLine(a, turnId, linkTags(a.target, body)), true);
|
|
2840
3111
|
}
|
|
2841
3112
|
else if (turnSilent(a)) {
|
|
@@ -2975,6 +3246,9 @@ export const startMirror = (deps) => {
|
|
|
2975
3246
|
: item.kind === "thinking" || item.kind === "text"
|
|
2976
3247
|
? item.body
|
|
2977
3248
|
: "";
|
|
3249
|
+
// A prompt (even an exact match) is not a child/parent-call identity. Late
|
|
3250
|
+
// children can reuse prompts after history eviction, so their content must
|
|
3251
|
+
// never enter a transferred answer bubble without a verifiable ID binding.
|
|
2978
3252
|
if (activity)
|
|
2979
3253
|
updateBriefProgress(a, `🤖 ${run.label} · ${activity}`);
|
|
2980
3254
|
};
|
|
@@ -3328,7 +3602,12 @@ export const startMirror = (deps) => {
|
|
|
3328
3602
|
// 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
|
|
3329
3603
|
a.pendingBriefQuery = item.body;
|
|
3330
3604
|
a.pendingBriefAt = item.ts;
|
|
3605
|
+
a.activePeerTurn = undefined;
|
|
3606
|
+
a.pendingFrom = undefined;
|
|
3607
|
+
a.pendingChannel = undefined;
|
|
3331
3608
|
a.pendingFromCli = true;
|
|
3609
|
+
if (!cfg.wrc.mirror.brief)
|
|
3610
|
+
consumeChannel(a, true, undefined);
|
|
3332
3611
|
}
|
|
3333
3612
|
if (item.kind === "user_inject") {
|
|
3334
3613
|
interject(a, item.said, item.ts);
|
|
@@ -3413,6 +3692,7 @@ export const startMirror = (deps) => {
|
|
|
3413
3692
|
// 的配置下, 人在 CLI 敲的那一行就这么原样回显进了群, 正好破了 chatOriginOnly。
|
|
3414
3693
|
if (item.kind === "user_text" && (item.quiet || cfg.wrc.mirror.brief))
|
|
3415
3694
|
return;
|
|
3695
|
+
updateAnswerProgress(a, item);
|
|
3416
3696
|
// thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
|
|
3417
3697
|
// 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
|
|
3418
3698
|
// 那就变回已下线的 thinkStyle 了。
|
|
@@ -3560,6 +3840,8 @@ export const startMirror = (deps) => {
|
|
|
3560
3840
|
}
|
|
3561
3841
|
}
|
|
3562
3842
|
}
|
|
3843
|
+
if (!cfg.wrc.mirror.brief && isPrivate(a))
|
|
3844
|
+
return;
|
|
3563
3845
|
// /goal mode overrides everything below: the session-scoped Stop hook
|
|
3564
3846
|
// self-drives the model with no terminal stop_reason, so turn_end never fires
|
|
3565
3847
|
// and brief-mode would swallow the entire run into the turn store (silent
|
|
@@ -3864,7 +4146,9 @@ export const startMirror = (deps) => {
|
|
|
3864
4146
|
};
|
|
3865
4147
|
// `panes` = boot 快照; 给了就不再为每个 principal 单独问 tmux。lazy 调用方
|
|
3866
4148
|
// 不传, 维持原来的逐个探活。
|
|
3867
|
-
const restoreFromStore = async (principal, panes) => {
|
|
4149
|
+
const restoreFromStore = async (principal, panes, permitted) => {
|
|
4150
|
+
if (permitted && !permitted())
|
|
4151
|
+
return undefined;
|
|
3868
4152
|
const rec = deps.store.get(principal);
|
|
3869
4153
|
if (!rec)
|
|
3870
4154
|
return undefined;
|
|
@@ -3882,6 +4166,8 @@ export const startMirror = (deps) => {
|
|
|
3882
4166
|
// unrelated newest-in-cwd file is exactly the cross-wire we must avoid.
|
|
3883
4167
|
const storedPane = (rec.tmuxPane ?? "").trim();
|
|
3884
4168
|
const livePane = storedPane && (panes ? panes.has(storedPane) : await tmuxPaneAlive(storedPane)) ? storedPane : "";
|
|
4169
|
+
if (permitted && !permitted())
|
|
4170
|
+
return undefined;
|
|
3885
4171
|
if (!existsSync(jsonlAbs)) {
|
|
3886
4172
|
// First: the SAME-sid transcript may have merely relocated to a sibling
|
|
3887
4173
|
// project dir (Claude Code EnterWorktree/ExitWorktree moved it while the
|
|
@@ -3937,6 +4223,8 @@ export const startMirror = (deps) => {
|
|
|
3937
4223
|
const paneCwd = panes
|
|
3938
4224
|
? (panes.get(livePane) ?? "")
|
|
3939
4225
|
: (await runTmux(["display-message", "-p", "-t", livePane, "#{pane_current_path}"])).stdout.trim();
|
|
4226
|
+
if (permitted && !permitted())
|
|
4227
|
+
return undefined;
|
|
3940
4228
|
if (paneCwd) {
|
|
3941
4229
|
// Encode under the backend that owns the bound transcript — comparing
|
|
3942
4230
|
// with the primary dialect would report a phantom drift for every
|
|
@@ -3956,6 +4244,8 @@ export const startMirror = (deps) => {
|
|
|
3956
4244
|
}
|
|
3957
4245
|
}
|
|
3958
4246
|
}
|
|
4247
|
+
if (permitted && !permitted())
|
|
4248
|
+
return undefined;
|
|
3959
4249
|
const r = attach({
|
|
3960
4250
|
sessionId: rec.sessionId,
|
|
3961
4251
|
jsonlPath: jsonlAbs,
|
|
@@ -4542,10 +4832,10 @@ export const startMirror = (deps) => {
|
|
|
4542
4832
|
// `header` folds the caller's ack ("created") into this same bubble — /new
|
|
4543
4833
|
// must land as exactly ONE WeCom message, not card + separate reply.
|
|
4544
4834
|
// 会话边界回执一律不携带 cwd 信息 (📂 项目行 / 下次切换), 需要时 `/pwd` 可查。
|
|
4545
|
-
// slashAckFirstLine 进一步省去 💡 tip, 只剩首行 ack。
|
|
4546
4835
|
const pushProjectInfo = (target, header) => {
|
|
4547
|
-
|
|
4548
|
-
|
|
4836
|
+
if (!header)
|
|
4837
|
+
return;
|
|
4838
|
+
const md = header;
|
|
4549
4839
|
const a = byTarget.get(target);
|
|
4550
4840
|
if (a) {
|
|
4551
4841
|
sendStandalone(a, md);
|
|
@@ -4606,12 +4896,13 @@ export const startMirror = (deps) => {
|
|
|
4606
4896
|
return undefined;
|
|
4607
4897
|
}
|
|
4608
4898
|
};
|
|
4609
|
-
/** 死 pane
|
|
4610
|
-
* 没记 (老绑定 / CLI 默认) 的管家补上管家档。 */
|
|
4899
|
+
/** 死 pane 重生: 管家重新取 router 档位, 普通 wizard 沿用绑定模型。 */
|
|
4611
4900
|
const respawnSpec = (a) => {
|
|
4612
4901
|
return resumedStewardSpec(cfg, a.target, backendForPath(a.jsonlPath).name, a);
|
|
4613
4902
|
};
|
|
4614
4903
|
const newSession = async (target, windowName, cli, opts) => {
|
|
4904
|
+
if (opts?.guard && !opts.guard())
|
|
4905
|
+
return { ok: false, reason: "spawn guard withdrew permission" };
|
|
4615
4906
|
const prev = byTarget.get(target);
|
|
4616
4907
|
// Resolution precedence (all chat-scoped except the running-cwd fallback):
|
|
4617
4908
|
// base.pending > caller.pending > target.running > base.running > default
|
|
@@ -4637,8 +4928,8 @@ export const startMirror = (deps) => {
|
|
|
4637
4928
|
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath;
|
|
4638
4929
|
const base = baseOfKey(target);
|
|
4639
4930
|
const effCli = resolveCliBackend(cfg.wrc).name;
|
|
4640
|
-
// 管家 (群的默认会话)
|
|
4641
|
-
//
|
|
4931
|
+
// 管家 (群的默认会话) 固定按 models.router 起, 压过显式模型及绑定里记下的 effort;
|
|
4932
|
+
// `/new`、换目录、交接重开都是让改过的档位生效的那一下。
|
|
4642
4933
|
// 已在跑的管家不动 (换模型会让缓存整份重读); 死 pane 重生见 respawnSpec。
|
|
4643
4934
|
const backendChanged = Boolean(boundPath && effCli && backendForPath(expandHome(boundPath)).name !== effCli);
|
|
4644
4935
|
const { model: effModel, effort: effEffort } = newStewardSpec(cfg, target, effCli, opts, backendChanged ? undefined : prev?.effort ?? deps.store.get(target)?.effort);
|
|
@@ -4666,6 +4957,12 @@ export const startMirror = (deps) => {
|
|
|
4666
4957
|
inFlightSids.delete(freshSid);
|
|
4667
4958
|
return { ok: false, reason: r.reason };
|
|
4668
4959
|
}
|
|
4960
|
+
if (opts?.guard && !opts.guard()) {
|
|
4961
|
+
inFlightSids.delete(freshSid);
|
|
4962
|
+
if (r.tmuxPane)
|
|
4963
|
+
await runTmux(["kill-pane", "-t", r.tmuxPane]);
|
|
4964
|
+
return { ok: false, reason: "spawn completed after permission withdrawal; binding withheld" };
|
|
4965
|
+
}
|
|
4669
4966
|
const att = attach({
|
|
4670
4967
|
sessionId: r.sessionId,
|
|
4671
4968
|
jsonlPath: r.jsonlPath,
|
|
@@ -4783,14 +5080,16 @@ export const startMirror = (deps) => {
|
|
|
4783
5080
|
return undefined;
|
|
4784
5081
|
};
|
|
4785
5082
|
const cloneSession = async (args) => {
|
|
4786
|
-
|
|
5083
|
+
if (args.guard && !args.guard("paste"))
|
|
5084
|
+
return { ok: false, reason: "spawn guard withdrew permission", inherited: false };
|
|
5085
|
+
const p = byTarget.get(args.parent) ?? (await restoreFromStore(args.parent, undefined, () => !args.guard || args.guard()));
|
|
4787
5086
|
// 继承上下文要求父亲有一个活着的 transcript, 且分身必须待在同一个项目目录 ——
|
|
4788
5087
|
// `--resume` 是按项目目录寻址 session 的。调用方点名换目录 = 明确放弃继承。
|
|
4789
5088
|
const sameCwd = !args.cwd?.trim() || expandHome(args.cwd.trim()) === (p?.runningCwd ?? "");
|
|
4790
5089
|
const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
|
|
4791
5090
|
if (!canInherit) {
|
|
4792
5091
|
const r = await newSession(args.target, args.windowName, args.cli, {
|
|
4793
|
-
model: args.model, effort: args.effort, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive,
|
|
5092
|
+
model: args.model, effort: args.effort, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive, guard: args.guard ? () => args.guard("paste") : undefined,
|
|
4794
5093
|
});
|
|
4795
5094
|
return { ...r, inherited: false };
|
|
4796
5095
|
}
|
|
@@ -4802,6 +5101,8 @@ export const startMirror = (deps) => {
|
|
|
4802
5101
|
const parentCli = backendForPath(parent.jsonlPath).name;
|
|
4803
5102
|
const model = args.model?.trim() || parent.model || lastModel(parent.jsonlPath);
|
|
4804
5103
|
const effort = args.effort ?? parseEffort(parent.effort);
|
|
5104
|
+
if (args.guard && !args.guard("paste"))
|
|
5105
|
+
return { ok: false, reason: "spawn guard withdrew permission", inherited: false };
|
|
4805
5106
|
const r = await spawnTmuxClaude({
|
|
4806
5107
|
cfg,
|
|
4807
5108
|
log: lg,
|
|
@@ -4825,7 +5126,7 @@ export const startMirror = (deps) => {
|
|
|
4825
5126
|
const boot = await inject({
|
|
4826
5127
|
text: hello, images: [], cfg, log: lg,
|
|
4827
5128
|
sessionId: parentCli === "codex" ? r.sessionId : parent.sessionId, jsonlPath: parentCli === "codex" ? r.jsonlPath : parent.jsonlPath,
|
|
4828
|
-
tmuxTarget: r.tmuxPane, freshSpawn: true,
|
|
5129
|
+
tmuxTarget: r.tmuxPane, freshSpawn: true, guard: args.guard,
|
|
4829
5130
|
});
|
|
4830
5131
|
if (!boot.ok) {
|
|
4831
5132
|
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
@@ -4841,6 +5142,12 @@ export const startMirror = (deps) => {
|
|
|
4841
5142
|
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4842
5143
|
return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
|
|
4843
5144
|
}
|
|
5145
|
+
if (args.guard && !args.guard("submit")) {
|
|
5146
|
+
inFlightSids.delete(fork.sessionId);
|
|
5147
|
+
// The bootstrap may already have executed; withhold binding, report the
|
|
5148
|
+
// unconfirmed birth to its caller instead of claiming cancellation success.
|
|
5149
|
+
return { ok: false, reason: "fork executed after permission withdrawal; reconcile pane manually", inherited: false };
|
|
5150
|
+
}
|
|
4844
5151
|
const att = attach({
|
|
4845
5152
|
sessionId: fork.sessionId,
|
|
4846
5153
|
jsonlPath: fork.jsonlPath,
|
|
@@ -5340,7 +5647,9 @@ export const startMirror = (deps) => {
|
|
|
5340
5647
|
catch { /* unreadable tail */ }
|
|
5341
5648
|
return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), cacheAt: cacheTouchMs(jsonlPath) };
|
|
5342
5649
|
};
|
|
5343
|
-
const fireKeepalive = async (a) => {
|
|
5650
|
+
const fireKeepalive = async (a, guard) => {
|
|
5651
|
+
if (!guard())
|
|
5652
|
+
return;
|
|
5344
5653
|
const kc = cfg.wrc.mirror.keepalive;
|
|
5345
5654
|
// The SAME full instruction every round: a bare "ping" used to be sent from round 2 on
|
|
5346
5655
|
// (the instruction was already in context), but the model reads a bare "ping"
|
|
@@ -5354,16 +5663,23 @@ export const startMirror = (deps) => {
|
|
|
5354
5663
|
// ping turn (reply included). The 60s fail-safe clears the quiet window if
|
|
5355
5664
|
// the turn somehow never emits turn_end, so a later real turn is never muted;
|
|
5356
5665
|
// it also closes the detail turn so it can't hang open in the chat timeline.
|
|
5357
|
-
rememberInject(text);
|
|
5358
|
-
a.keepaliveQuiet = setTimeout(() => {
|
|
5359
|
-
a.keepaliveQuiet = undefined;
|
|
5360
|
-
if (a.keepaliveTurnId) {
|
|
5361
|
-
recordTurnClose(a.keepaliveTurnId);
|
|
5362
|
-
a.keepaliveTurnId = undefined;
|
|
5363
|
-
}
|
|
5364
|
-
}, 60_000);
|
|
5365
5666
|
const r = await inject({
|
|
5366
5667
|
text, images: [], cfg,
|
|
5668
|
+
guard,
|
|
5669
|
+
requireIdle: () => idleVerdict(a.target) === true,
|
|
5670
|
+
beforeInput: () => {
|
|
5671
|
+
// A queued heartbeat must not mute the peer turn that won the lane.
|
|
5672
|
+
// Drain its tail first, then open swallow only at permitted physical input.
|
|
5673
|
+
a.tail.drain();
|
|
5674
|
+
rememberInject(text);
|
|
5675
|
+
a.keepaliveQuiet = setTimeout(() => {
|
|
5676
|
+
a.keepaliveQuiet = undefined;
|
|
5677
|
+
if (a.keepaliveTurnId) {
|
|
5678
|
+
recordTurnClose(a.keepaliveTurnId);
|
|
5679
|
+
a.keepaliveTurnId = undefined;
|
|
5680
|
+
}
|
|
5681
|
+
}, 60_000);
|
|
5682
|
+
},
|
|
5367
5683
|
log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
|
|
5368
5684
|
sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
|
|
5369
5685
|
});
|
|
@@ -5374,6 +5690,8 @@ export const startMirror = (deps) => {
|
|
|
5374
5690
|
log.warn({ target: a.target, reason: r.reason }, "keepalive: inject failed");
|
|
5375
5691
|
return;
|
|
5376
5692
|
}
|
|
5693
|
+
if (!guard())
|
|
5694
|
+
return;
|
|
5377
5695
|
const tokens = lastContextTokens(a.jsonlPath);
|
|
5378
5696
|
const round = a.keepalive ? (a.keepalive.round += 1) : 1;
|
|
5379
5697
|
log.info({ target: a.target, tokens, round, totalRounds: Math.max(1, kc.rounds) }, "keepalive: ping injected");
|
|
@@ -5389,116 +5707,132 @@ export const startMirror = (deps) => {
|
|
|
5389
5707
|
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
|
|
5390
5708
|
}
|
|
5391
5709
|
};
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5710
|
+
// One pool for both maintenance paths: slow panes do not stall other panes,
|
|
5711
|
+
// and a keepalive never overlaps a picker scan/automatic press on the same pane.
|
|
5712
|
+
const maintenance = createKeyedScheduler(4);
|
|
5713
|
+
const maintenanceValid = (a, pane, sid, gen, signal) => !signal.aborted && byTarget.get(a.target) === a && a.tmuxPane === pane && a.sessionId === sid
|
|
5714
|
+
&& (a.injectGen ?? 0) === gen && !a.migrationWatcher;
|
|
5715
|
+
const keepaliveOne = async (a, pane, sid, gen, signal) => {
|
|
5716
|
+
const valid = () => maintenanceValid(a, pane, sid, gen, signal) && cfg.wrc.mirror.keepalive.enabled;
|
|
5717
|
+
if (!valid())
|
|
5396
5718
|
return;
|
|
5397
|
-
|
|
5719
|
+
const kc = cfg.wrc.mirror.keepalive;
|
|
5720
|
+
const pingSigs = [normAssistant(kc.ping).slice(0, 40)].filter((s) => s.length > 0);
|
|
5721
|
+
const now = Date.now();
|
|
5722
|
+
if (a.keepaliveDisabled)
|
|
5723
|
+
return; // sticky spawn-time opt-out — never pings
|
|
5724
|
+
if (!a.tmuxPane)
|
|
5725
|
+
return; // spawn-mode: no live pane to warm
|
|
5726
|
+
if (a.migrationWatcher)
|
|
5727
|
+
return; // session rotating — skip
|
|
5728
|
+
if (a.liveStream && !a.liveStream.closed)
|
|
5729
|
+
return; // mid typewriter — don't inject
|
|
5730
|
+
if (!(await tmuxPaneAlive(pane)) || !valid())
|
|
5731
|
+
return; // dead pane — nothing to keep alive
|
|
5732
|
+
let mtime = 0;
|
|
5398
5733
|
try {
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
continue;
|
|
5418
|
-
}
|
|
5419
|
-
if (!mtime)
|
|
5420
|
-
continue;
|
|
5421
|
-
const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, pingSigs));
|
|
5422
|
-
// File mtime is only a cheap "did anything change → re-read the tail" gate.
|
|
5423
|
-
// The clocks themselves come from message turns, so metadata churn (which
|
|
5424
|
-
// bumps mtime but is not a turn) can never move them. `grewSinceLast` now
|
|
5425
|
-
// means a NEW message turn appeared — not that the file grew.
|
|
5426
|
-
const prevRealMs = k.lastRealMs;
|
|
5427
|
-
if (mtime > k.seenMtime + 1000) {
|
|
5428
|
-
const s = keepaliveStamps(a.jsonlPath, pingSigs);
|
|
5429
|
-
if (s.stamped) {
|
|
5430
|
-
k.lastMs = s.lastMs;
|
|
5431
|
-
k.lastRealMs = s.lastRealMs;
|
|
5432
|
-
}
|
|
5433
|
-
else {
|
|
5434
|
-
k.lastMs = mtime;
|
|
5435
|
-
k.lastRealMs = mtime;
|
|
5436
|
-
} // backend without timestamps → fall back to mtime
|
|
5437
|
-
k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
|
|
5438
|
-
k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
|
|
5439
|
-
}
|
|
5440
|
-
k.seenMtime = mtime;
|
|
5441
|
-
// Round reset / real-activity re-anchor keys off REAL turns only — a
|
|
5442
|
-
// keepalive ping+pong advances lastMs but NOT lastRealMs, so the heartbeat
|
|
5443
|
-
// no longer reads as activity to itself (which was pinning round at 1/N).
|
|
5444
|
-
const grewSinceLast = k.lastRealMs > prevRealMs + 1000;
|
|
5445
|
-
const paneTail = await capturePaneTail(a.tmuxPane, 16);
|
|
5446
|
-
const busy = paneIsBusy(paneTail);
|
|
5447
|
-
if (k.pinging) {
|
|
5448
|
-
// Waiting for our own ping to land and settle: until a NEWER message turn
|
|
5449
|
-
// (the ping's own user line) appears and the pane goes idle, keep waiting.
|
|
5450
|
-
if (busy || k.lastMs <= k.pingMtime)
|
|
5451
|
-
continue;
|
|
5452
|
-
k.pinging = false;
|
|
5453
|
-
k.settledAt = now;
|
|
5454
|
-
continue;
|
|
5455
|
-
}
|
|
5456
|
-
// A new real message turn (or a busy pane) re-anchors: reset the round
|
|
5457
|
-
// counter, and past the /stop grace lift a busy-resume. grewSinceLast is
|
|
5458
|
-
// message-based now, so `/stop`'s Esc settling writes (metadata, no new
|
|
5459
|
-
// turn) can no longer self-resume the pause — only a busy pane past grace,
|
|
5460
|
-
// or a WeCom inbound in dispatch, does.
|
|
5461
|
-
// Suppress the grewSinceLast reset for 30s after pinging settles — the
|
|
5462
|
-
// pong's tail flush can lag a tick and would otherwise fake real activity.
|
|
5463
|
-
const justSettled = k.settledAt > 0 && now - k.settledAt < 30_000;
|
|
5464
|
-
if (busy || (grewSinceLast && !justSettled)) {
|
|
5465
|
-
k.round = 0;
|
|
5466
|
-
k.settledAt = 0;
|
|
5467
|
-
if (a.keepaliveOff && busy && now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS) {
|
|
5468
|
-
a.keepaliveOff = false;
|
|
5469
|
-
persistPause(a);
|
|
5470
|
-
}
|
|
5471
|
-
if (busy)
|
|
5472
|
-
continue;
|
|
5473
|
-
}
|
|
5474
|
-
if (a.keepaliveOff)
|
|
5475
|
-
continue; // paused by /stop until real activity returns
|
|
5476
|
-
// Cache clock = start of the newest answered request (TTL runs from there),
|
|
5477
|
-
// not the last turn's write time; no request on record → the turn clock.
|
|
5478
|
-
const sinceCache = now - (k.cacheAt || k.lastMs || mtime);
|
|
5479
|
-
// The TTL the session's cache entries were actually written with (1h on a
|
|
5480
|
-
// Claude Code subscription), not a guess: pinging at the 5min cadence
|
|
5481
|
-
// against a 1h entry re-reads the whole context ~14x per hour for nothing.
|
|
5482
|
-
const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
|
|
5483
|
-
const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
|
|
5484
|
-
if (sinceCache < idleTriggerMs)
|
|
5485
|
-
continue; // cache still comfortably warm
|
|
5486
|
-
if (sinceCache >= ttlMs)
|
|
5487
|
-
continue; // cache already cold — a ping would cold-rewrite for nothing
|
|
5488
|
-
if (sinceCache > KEEPALIVE_MAX_IDLE_MS)
|
|
5489
|
-
continue; // idle past 10min (a long-TTL entry) — not worth warming
|
|
5490
|
-
if (k.round >= kc.rounds)
|
|
5491
|
-
continue; // budget spent — let it go cold
|
|
5492
|
-
k.pinging = true;
|
|
5493
|
-
k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
|
|
5494
|
-
await fireKeepalive(a);
|
|
5734
|
+
mtime = statSync(a.jsonlPath).mtimeMs;
|
|
5735
|
+
}
|
|
5736
|
+
catch {
|
|
5737
|
+
return;
|
|
5738
|
+
}
|
|
5739
|
+
if (!mtime)
|
|
5740
|
+
return;
|
|
5741
|
+
const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, pingSigs));
|
|
5742
|
+
// File mtime is only a cheap "did anything change → re-read the tail" gate.
|
|
5743
|
+
// The clocks themselves come from message turns, so metadata churn (which
|
|
5744
|
+
// bumps mtime but is not a turn) can never move them. `grewSinceLast` now
|
|
5745
|
+
// means a NEW message turn appeared — not that the file grew.
|
|
5746
|
+
const prevRealMs = k.lastRealMs;
|
|
5747
|
+
if (mtime > k.seenMtime + 1000) {
|
|
5748
|
+
const s = keepaliveStamps(a.jsonlPath, pingSigs);
|
|
5749
|
+
if (s.stamped) {
|
|
5750
|
+
k.lastMs = s.lastMs;
|
|
5751
|
+
k.lastRealMs = s.lastRealMs;
|
|
5495
5752
|
}
|
|
5753
|
+
else {
|
|
5754
|
+
k.lastMs = mtime;
|
|
5755
|
+
k.lastRealMs = mtime;
|
|
5756
|
+
} // backend without timestamps → fall back to mtime
|
|
5757
|
+
k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
|
|
5758
|
+
k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
|
|
5759
|
+
}
|
|
5760
|
+
k.seenMtime = mtime;
|
|
5761
|
+
// Round reset / real-activity re-anchor keys off REAL turns only — a
|
|
5762
|
+
// keepalive ping+pong advances lastMs but NOT lastRealMs, so the heartbeat
|
|
5763
|
+
// no longer reads as activity to itself (which was pinning round at 1/N).
|
|
5764
|
+
const grewSinceLast = k.lastRealMs > prevRealMs + 1000;
|
|
5765
|
+
const paneTail = await capturePaneTail(pane, 16);
|
|
5766
|
+
if (!valid())
|
|
5767
|
+
return;
|
|
5768
|
+
const busy = paneIsBusy(paneTail);
|
|
5769
|
+
if (k.pinging) {
|
|
5770
|
+
// Waiting for our own ping to land and settle: until a NEWER message turn
|
|
5771
|
+
// (the ping's own user line) appears and the pane goes idle, keep waiting.
|
|
5772
|
+
if (busy || k.lastMs <= k.pingMtime)
|
|
5773
|
+
return;
|
|
5774
|
+
k.pinging = false;
|
|
5775
|
+
k.settledAt = now;
|
|
5776
|
+
return;
|
|
5496
5777
|
}
|
|
5497
|
-
|
|
5498
|
-
|
|
5778
|
+
// A new real message turn (or a busy pane) re-anchors: reset the round
|
|
5779
|
+
// counter, and past the /stop grace lift a busy-resume. grewSinceLast is
|
|
5780
|
+
// message-based now, so `/stop`'s Esc settling writes (metadata, no new
|
|
5781
|
+
// turn) can no longer self-resume the pause — only a busy pane past grace,
|
|
5782
|
+
// or a WeCom inbound in dispatch, does.
|
|
5783
|
+
// Suppress the grewSinceLast reset for 30s after pinging settles — the
|
|
5784
|
+
// pong's tail flush can lag a tick and would otherwise fake real activity.
|
|
5785
|
+
const justSettled = k.settledAt > 0 && now - k.settledAt < 30_000;
|
|
5786
|
+
if (busy || (grewSinceLast && !justSettled)) {
|
|
5787
|
+
k.round = 0;
|
|
5788
|
+
k.settledAt = 0;
|
|
5789
|
+
if (a.keepaliveOff && busy && now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS) {
|
|
5790
|
+
a.keepaliveOff = false;
|
|
5791
|
+
persistPause(a);
|
|
5792
|
+
}
|
|
5793
|
+
if (busy)
|
|
5794
|
+
return;
|
|
5499
5795
|
}
|
|
5500
|
-
|
|
5501
|
-
|
|
5796
|
+
if (a.keepaliveOff)
|
|
5797
|
+
return; // paused by /stop until real activity returns
|
|
5798
|
+
// Cache clock = start of the newest answered request (TTL runs from there),
|
|
5799
|
+
// not the last turn's write time; no request on record → the turn clock.
|
|
5800
|
+
const sinceCache = now - (k.cacheAt || k.lastMs || mtime);
|
|
5801
|
+
// The TTL the session's cache entries were actually written with (1h on a
|
|
5802
|
+
// Claude Code subscription), not a guess: pinging at the 5min cadence
|
|
5803
|
+
// against a 1h entry re-reads the whole context ~14x per hour for nothing.
|
|
5804
|
+
const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
|
|
5805
|
+
const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
|
|
5806
|
+
if (sinceCache < idleTriggerMs)
|
|
5807
|
+
return; // cache still comfortably warm
|
|
5808
|
+
if (sinceCache >= ttlMs)
|
|
5809
|
+
return; // cache already cold — a ping would cold-rewrite for nothing
|
|
5810
|
+
if (sinceCache > KEEPALIVE_MAX_IDLE_MS)
|
|
5811
|
+
return; // idle past 10min (a long-TTL entry) — not worth warming
|
|
5812
|
+
if (k.round >= kc.rounds)
|
|
5813
|
+
return; // budget spent — let it go cold
|
|
5814
|
+
if (idleVerdict(a.target) !== true)
|
|
5815
|
+
return; // no confirmed idle → no heartbeat input
|
|
5816
|
+
k.pinging = true;
|
|
5817
|
+
k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
|
|
5818
|
+
await fireKeepalive(a, () => valid() && !a.keepaliveOff);
|
|
5819
|
+
};
|
|
5820
|
+
const keepaliveTick = () => {
|
|
5821
|
+
if (!cfg.wrc.mirror.keepalive.enabled)
|
|
5822
|
+
return;
|
|
5823
|
+
for (const a of byTarget.values()) {
|
|
5824
|
+
if (!a.tmuxPane || a.keepaliveDisabled)
|
|
5825
|
+
continue;
|
|
5826
|
+
const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
|
|
5827
|
+
void maintenance.schedule(`keepalive:${a.target}`, pane, (signal) => keepaliveOne(a, pane, sid, gen, signal))
|
|
5828
|
+
?.catch((e) => {
|
|
5829
|
+
if (maintenanceValid(a, pane, sid, gen, maintenance.signal)) {
|
|
5830
|
+
if (a.keepalive)
|
|
5831
|
+
a.keepalive.pinging = false;
|
|
5832
|
+
endKeepaliveSwallow(a);
|
|
5833
|
+
}
|
|
5834
|
+
log.warn({ target: a.target, err: e.message }, "keepalive tick failed");
|
|
5835
|
+
});
|
|
5502
5836
|
}
|
|
5503
5837
|
};
|
|
5504
5838
|
const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
|
|
@@ -5513,118 +5847,143 @@ export const startMirror = (deps) => {
|
|
|
5513
5847
|
// - 其它 → 把框转成标准审批卡发到本 chat (runMirrorPickerFlow), 点击后代按
|
|
5514
5848
|
// 对应选项; 本地先按掉则 moot 作废卡。代按/发卡都发 chat 回执留痕。
|
|
5515
5849
|
// WEZARD_PICKER_SENTINEL=0 整体关闭。
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
if (!cfg.approval.enabled)
|
|
5850
|
+
const pickerOne = async (a, pane, sid, gen, signal) => {
|
|
5851
|
+
const guard = () => maintenanceValid(a, pane, sid, gen, signal) && cfg.approval.enabled
|
|
5852
|
+
&& process.env.WEZARD_PICKER_SENTINEL !== "0";
|
|
5853
|
+
if (!guard())
|
|
5521
5854
|
return;
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5528
|
-
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5541
|
-
|
|
5542
|
-
|
|
5543
|
-
}
|
|
5544
|
-
}
|
|
5545
|
-
else {
|
|
5546
|
-
a.pickerSentinelFp = undefined;
|
|
5855
|
+
await withPaneInput(pane, async (live) => {
|
|
5856
|
+
const valid = () => guard() && live();
|
|
5857
|
+
if (!a.tmuxPane || a.migrationWatcher)
|
|
5858
|
+
return;
|
|
5859
|
+
// Codex PermissionRequest has its own independent native gate. Screen
|
|
5860
|
+
// options, tool windows and skipAll never authorize its sandbox.
|
|
5861
|
+
if (cliOf(a) === "codex")
|
|
5862
|
+
return;
|
|
5863
|
+
if (!(await tmuxPaneAlive(pane)) || !valid())
|
|
5864
|
+
return;
|
|
5865
|
+
const v = await detectModalPicker(pane);
|
|
5866
|
+
if (!valid())
|
|
5867
|
+
return;
|
|
5868
|
+
if (!v.modal) {
|
|
5869
|
+
// 审批卡在途而框已消失 = 本地先按掉了 → 作废卡等待 (无卡则 no-op)。
|
|
5870
|
+
mootMirrorPicker(a.sessionId);
|
|
5871
|
+
// 诊断: 屏上有高亮编号行却没判成 modal = 布局盲区候选 (askq/plan 面板
|
|
5872
|
+
// 也长这样, 属正常)。每屏记一次日志, 供远端排查"哨兵为何没反应"。
|
|
5873
|
+
if (looksLikePicker(v.screen)) {
|
|
5874
|
+
if (a.pickerSentinelFp !== v.screen) {
|
|
5875
|
+
a.pickerSentinelFp = v.screen;
|
|
5876
|
+
log.info({ target: a.target, tail: v.screen.split("\n").slice(-8).join("\\n").slice(-600) }, "picker sentinel: numbered picker shape on pane but not judged modal");
|
|
5547
5877
|
}
|
|
5548
|
-
continue;
|
|
5549
5878
|
}
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
if (a.pickerSentinelFp === v.screen)
|
|
5553
|
-
continue; // 同一屏已处理过, 等它变化
|
|
5554
|
-
a.pickerSentinelFp = v.screen;
|
|
5555
|
-
const pick = pickAutoAllowAnswer(parseModalOptions(v.screen));
|
|
5556
|
-
if (!pick) {
|
|
5557
|
-
log.info({ target: a.target, title: v.title }, "picker sentinel: modal on pane but not a trustworthy permission picker — leaving it");
|
|
5558
|
-
continue;
|
|
5559
|
-
}
|
|
5560
|
-
const ctx = parseConfirmContext(v.screen);
|
|
5561
|
-
const policy = pickerPolicy(cfg, cliOf(a), ctx, isAutoWindowActive(a.target));
|
|
5562
|
-
if (policy.decision === "leave") {
|
|
5563
|
-
log.info({ target: a.target, reason: policy.reason }, "picker sentinel: independent approval left on CLI");
|
|
5879
|
+
else {
|
|
5880
|
+
a.pickerSentinelFp = undefined;
|
|
5564
5881
|
}
|
|
5565
|
-
|
|
5566
|
-
deny: async () => {
|
|
5567
|
-
const result = await pressNativePicker(a, modalFingerprint(v.screen), "no");
|
|
5568
|
-
log.info({ target: a.target, reason: policy.reason, result }, "picker sentinel: deny rule");
|
|
5569
|
-
sendStandalone(a, `[mirror] 🚫 ${policy.reason},${result.ok ? "已拒绝 CLI 确认" : "确认框已变更,请回 CLI 处理"}`);
|
|
5570
|
-
},
|
|
5571
|
-
allow: async () => {
|
|
5572
|
-
const after = await pressModalIndex(a.tmuxPane, v, pick.index);
|
|
5573
|
-
if (after.modal) {
|
|
5574
|
-
// 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
|
|
5575
|
-
a.pickerSentinelFp = after.screen;
|
|
5576
|
-
log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
|
|
5577
|
-
return;
|
|
5578
|
-
}
|
|
5579
|
-
a.pickerSentinelFp = undefined;
|
|
5580
|
-
log.info({ target: a.target, title: v.title, via: policy.reason, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
|
|
5581
|
-
sendStandalone(a, `[mirror] 🔓 ${policy.reason} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
|
|
5582
|
-
},
|
|
5583
|
-
manual: () => {
|
|
5584
|
-
// 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
|
|
5585
|
-
// 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
|
|
5586
|
-
log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, reason: policy.reason }, "picker sentinel: forwarding native picker as approval card");
|
|
5587
|
-
runMirrorPickerFlow({
|
|
5588
|
-
log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
|
|
5589
|
-
client,
|
|
5590
|
-
cfg,
|
|
5591
|
-
sessionId: a.sessionId,
|
|
5592
|
-
chatKey: a.target,
|
|
5593
|
-
cwd: a.runningCwd,
|
|
5594
|
-
jsonlPath: a.jsonlPath,
|
|
5595
|
-
toolName: ctx.toolName,
|
|
5596
|
-
toolInput: ctx.toolInput,
|
|
5597
|
-
agent: ctx.agent,
|
|
5598
|
-
danger: policy.forceSingle,
|
|
5599
|
-
voteTimeoutMs: cfg.approval.longPollSec * 1000,
|
|
5600
|
-
press: (what) => pressNativePicker(a, modalFingerprint(v.screen), what),
|
|
5601
|
-
}).then((sent) => {
|
|
5602
|
-
// 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
|
|
5603
|
-
// 否则这只框会被指纹永久静默。
|
|
5604
|
-
if (!sent)
|
|
5605
|
-
a.pickerSentinelFp = undefined;
|
|
5606
|
-
}).catch((e) => log.warn({ err: e.message, target: a.target }, "mirror picker flow failed"));
|
|
5607
|
-
},
|
|
5608
|
-
});
|
|
5882
|
+
return;
|
|
5609
5883
|
}
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5884
|
+
if (hasMirrorPicker(a.sessionId))
|
|
5885
|
+
return; // 审批卡在途, 等点击 / moot
|
|
5886
|
+
if (a.pickerSentinelFp === v.screen)
|
|
5887
|
+
return; // 同一屏已处理过, 等它变化
|
|
5888
|
+
a.pickerSentinelFp = v.screen;
|
|
5889
|
+
const pick = pickAutoAllowAnswer(parseModalOptions(v.screen));
|
|
5890
|
+
if (!pick) {
|
|
5891
|
+
log.info({ target: a.target, title: v.title }, "picker sentinel: modal on pane but not a trustworthy permission picker — leaving it");
|
|
5892
|
+
return;
|
|
5893
|
+
}
|
|
5894
|
+
const ctx = parseConfirmContext(v.screen);
|
|
5895
|
+
const policy = pickerPolicy(cfg, cliOf(a), ctx, isAutoWindowActive(a.target));
|
|
5896
|
+
if (policy.decision === "leave") {
|
|
5897
|
+
log.info({ target: a.target, reason: policy.reason }, "picker sentinel: independent approval left on CLI");
|
|
5898
|
+
}
|
|
5899
|
+
await dispatchPickerPolicy(policy, {
|
|
5900
|
+
deny: async () => {
|
|
5901
|
+
if (!valid())
|
|
5902
|
+
return;
|
|
5903
|
+
const result = await pressNativePickerUnlocked(a, modalFingerprint(v.screen), "no", valid);
|
|
5904
|
+
if (!valid())
|
|
5905
|
+
return;
|
|
5906
|
+
log.info({ target: a.target, reason: policy.reason, result }, "picker sentinel: deny rule");
|
|
5907
|
+
sendStandalone(a, `[mirror] 🚫 ${policy.reason},${result.ok ? "已拒绝 CLI 确认" : "确认框已变更,请回 CLI 处理"}`);
|
|
5908
|
+
},
|
|
5909
|
+
allow: async () => {
|
|
5910
|
+
if (!valid())
|
|
5911
|
+
return;
|
|
5912
|
+
const after = await pressModalIndex(pane, v, pick.index, valid);
|
|
5913
|
+
if (!valid())
|
|
5914
|
+
return;
|
|
5915
|
+
if (after.modal) {
|
|
5916
|
+
// 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
|
|
5917
|
+
a.pickerSentinelFp = after.screen;
|
|
5918
|
+
log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
|
|
5919
|
+
return;
|
|
5920
|
+
}
|
|
5921
|
+
a.pickerSentinelFp = undefined;
|
|
5922
|
+
log.info({ target: a.target, title: v.title, via: policy.reason, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
|
|
5923
|
+
sendStandalone(a, `[mirror] 🔓 ${policy.reason} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
|
|
5924
|
+
},
|
|
5925
|
+
manual: () => {
|
|
5926
|
+
if (!valid())
|
|
5927
|
+
return;
|
|
5928
|
+
// 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
|
|
5929
|
+
// 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
|
|
5930
|
+
log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, reason: policy.reason }, "picker sentinel: forwarding native picker as approval card");
|
|
5931
|
+
runMirrorPickerFlow({
|
|
5932
|
+
log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
|
|
5933
|
+
client,
|
|
5934
|
+
cfg,
|
|
5935
|
+
sessionId: a.sessionId,
|
|
5936
|
+
chatKey: a.target,
|
|
5937
|
+
cwd: a.runningCwd,
|
|
5938
|
+
jsonlPath: a.jsonlPath,
|
|
5939
|
+
toolName: ctx.toolName,
|
|
5940
|
+
toolInput: ctx.toolInput,
|
|
5941
|
+
agent: ctx.agent,
|
|
5942
|
+
danger: policy.forceSingle,
|
|
5943
|
+
voteTimeoutMs: cfg.approval.longPollSec * 1000,
|
|
5944
|
+
// Human waiting is outside the lock; the eventual press obtains a new transaction.
|
|
5945
|
+
press: (what) => pressNativePicker(a, modalFingerprint(v.screen), what, guard),
|
|
5946
|
+
}).then((sent) => {
|
|
5947
|
+
// 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
|
|
5948
|
+
// 否则这只框会被指纹永久静默。
|
|
5949
|
+
if (!sent && guard())
|
|
5950
|
+
a.pickerSentinelFp = undefined;
|
|
5951
|
+
}).catch((e) => {
|
|
5952
|
+
if (guard())
|
|
5953
|
+
a.pickerSentinelFp = undefined;
|
|
5954
|
+
log.warn({ err: e.message, target: a.target }, "mirror picker flow failed");
|
|
5955
|
+
});
|
|
5956
|
+
},
|
|
5957
|
+
});
|
|
5958
|
+
}, { guard });
|
|
5959
|
+
};
|
|
5960
|
+
const pickerTick = () => {
|
|
5961
|
+
if (!cfg.approval.enabled || process.env.WEZARD_PICKER_SENTINEL === "0")
|
|
5962
|
+
return;
|
|
5963
|
+
for (const a of byTarget.values()) {
|
|
5964
|
+
if (!a.tmuxPane || cliOf(a) === "codex")
|
|
5965
|
+
continue;
|
|
5966
|
+
const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
|
|
5967
|
+
void maintenance.schedule(`picker:${a.target}`, pane, (signal) => pickerOne(a, pane, sid, gen, signal))
|
|
5968
|
+
?.catch((e) => {
|
|
5969
|
+
if (maintenanceValid(a, pane, sid, gen, maintenance.signal))
|
|
5970
|
+
a.pickerSentinelFp = undefined;
|
|
5971
|
+
log.warn({ target: a.target, err: e.message }, "picker sentinel tick failed");
|
|
5972
|
+
});
|
|
5616
5973
|
}
|
|
5617
5974
|
};
|
|
5618
5975
|
const pickerTimer = setInterval(() => void pickerTick(), 5_000);
|
|
5619
5976
|
// 审批卡点击后的代按回调。点击与本地按键之间隔着人的反应时间, 必须重读屏并
|
|
5620
5977
|
// 校验仍是**同一个调用**的确认框 —— 期间本地按掉旧框又弹了新框的话, 盲按就是
|
|
5621
5978
|
// 替用户批准了他没看过的东西。
|
|
5622
|
-
const
|
|
5623
|
-
if (!a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
5979
|
+
const pressNativePickerUnlocked = async (a, expectFingerprint, what, live) => {
|
|
5980
|
+
if (!live() || !a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
5624
5981
|
return { ok: false, reason: "pane 已不在" };
|
|
5625
5982
|
if (cliOf(a) === "codex")
|
|
5626
5983
|
return { ok: false, reason: "Codex 原生沙箱审批必须单独处理" };
|
|
5627
5984
|
const v = await detectModalPicker(a.tmuxPane);
|
|
5985
|
+
if (!live())
|
|
5986
|
+
return { ok: false, reason: "pane 事务已撤回" };
|
|
5628
5987
|
if (!v.modal)
|
|
5629
5988
|
return { ok: false, reason: "确认框已不在(可能已在本地处理)" };
|
|
5630
5989
|
if (pickerPolicy(cfg, cliOf(a), parseConfirmContext(v.screen), false).decision === "leave") {
|
|
@@ -5645,11 +6004,12 @@ export const startMirror = (deps) => {
|
|
|
5645
6004
|
const pick = sess ?? bare;
|
|
5646
6005
|
if (!pick)
|
|
5647
6006
|
return { ok: false, reason: "确认框上找不到可按的 Yes 选项" };
|
|
5648
|
-
const after = await pressModalIndex(a.tmuxPane, v, pick.index);
|
|
6007
|
+
const after = await pressModalIndex(a.tmuxPane, v, pick.index, live);
|
|
5649
6008
|
return after.modal
|
|
5650
6009
|
? { ok: false, reason: `按下 ${pick.index} 后确认框仍在` }
|
|
5651
6010
|
: { ok: true, label: `${pick.index}. ${pick.label}` };
|
|
5652
6011
|
};
|
|
6012
|
+
const pressNativePicker = async (a, fingerprint, what, guard) => withPaneInput(a.tmuxPane, (live) => pressNativePickerUnlocked(a, fingerprint, what, live), { guard });
|
|
5653
6013
|
return {
|
|
5654
6014
|
attach,
|
|
5655
6015
|
chatTargets,
|
|
@@ -5873,35 +6233,24 @@ export const startMirror = (deps) => {
|
|
|
5873
6233
|
// to in this process lifetime (or any session after a reload) has an empty
|
|
5874
6234
|
// in-memory slot but a perfectly good persisted binding. Without this, an
|
|
5875
6235
|
// agent driving a cold sibling gets "not attached" for a live pane.
|
|
5876
|
-
const
|
|
5877
|
-
|
|
5878
|
-
return { ok: false, reason: "no mirror attached for target" };
|
|
6236
|
+
const permission = () => !opts?.guard || opts.guard();
|
|
6237
|
+
const denied = { ok: false, retryable: true, reason: "inject permission withdrawn before respawn/binding" };
|
|
5879
6238
|
if (!text.trim())
|
|
5880
|
-
return { ok: false, reason: "empty text" };
|
|
5881
|
-
|
|
5882
|
-
|
|
5883
|
-
|
|
5884
|
-
|
|
5885
|
-
|
|
5886
|
-
a
|
|
5887
|
-
|
|
5888
|
-
// 名册增量搭这趟车进去 —— 同伴派活 / 定时任务 / graph 步骤都走这里, 所以
|
|
5889
|
-
// 一个从不被人直接说话的分身也能知道群里多了谁、少了谁。人说的话那一条
|
|
5890
|
-
// 路径在 inbound.send 上挂 (dispatch 有自己的 inject)。
|
|
5891
|
-
// 信封 (谁说的、私聊还是公开、回执怎么交) 同理挂在尾巴上; slash 命令按行解析,
|
|
5892
|
-
// 多挂一段就不再是命令, 跳过。`pendingBriefQuery` 记的仍是不带信封的原话。
|
|
5893
|
-
const envelope = text.trimStart().startsWith("/") ? "" : opts?.envelope ?? "";
|
|
5894
|
-
const full = text + envelope + noticeSuffixFor(target, text);
|
|
5895
|
-
// Pre-record so the tail's user-line emission is suppressed by the
|
|
5896
|
-
// recentInjects dedupe (otherwise the user sees their own demo prompt
|
|
5897
|
-
// echoed back as a quoted bubble).
|
|
5898
|
-
rememberInject(full);
|
|
5899
|
-
// 印章要早于 inject 落地 —— tail 是独立轮询的, 它可能在 inject 的 promise
|
|
5900
|
-
// resolve 之前就看到 user 行并开出 turn, 那时归因必须已经在位。
|
|
5901
|
-
// 印章随这一行走 (见 Seal): 行露面时才落成那一轮的出处 / 频道 / 问话。
|
|
5902
|
-
pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
|
|
6239
|
+
return { ok: false, retryable: true, reason: "empty text" };
|
|
6240
|
+
if (!permission())
|
|
6241
|
+
return denied;
|
|
6242
|
+
const a = byTarget.get(target) ?? (await restoreFromStore(target, undefined, permission));
|
|
6243
|
+
if (!permission())
|
|
6244
|
+
return denied;
|
|
6245
|
+
if (!a)
|
|
6246
|
+
return { ok: false, retryable: true, reason: "no mirror attached for target" };
|
|
5903
6247
|
const sid = a.sessionId;
|
|
6248
|
+
const generation = a.injectGen ?? 0;
|
|
6249
|
+
const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
|
|
6250
|
+
const probedPane = a.tmuxPane;
|
|
5904
6251
|
const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
|
|
6252
|
+
if (!permitted() || a.tmuxPane !== probedPane)
|
|
6253
|
+
return denied;
|
|
5905
6254
|
// 同 dispatch: 只有刚生 / 刚重生的 pane 才要冷启动的宽限 —— 对一个热着、正忙的 pane 贴 `now`
|
|
5906
6255
|
// 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
|
|
5907
6256
|
let fresh = a.justSpawned === true;
|
|
@@ -5912,7 +6261,16 @@ export const startMirror = (deps) => {
|
|
|
5912
6261
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
5913
6262
|
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: displayName(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, ...respawnSpec(a), systemPrompt: charterFor(target, { cwd: a.runningCwd, cli: backendForPath(a.jsonlPath).name }) });
|
|
5914
6263
|
if (!r.ok || !r.tmuxPane)
|
|
5915
|
-
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
6264
|
+
return { ok: false, retryable: true, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
6265
|
+
if (!permitted() || a.tmuxPane !== probedPane) {
|
|
6266
|
+
// This pane was born for an unsubmitted injection. Never kill the old
|
|
6267
|
+
// executor or bind the withdrawn birth. A failed cleanup is explicitly
|
|
6268
|
+
// uncertain and must be reconciled; it is not proof execution stopped.
|
|
6269
|
+
const cleanup = await runTmux(["kill-pane", "-t", r.tmuxPane]).catch(() => undefined);
|
|
6270
|
+
return { ...denied, ...(cleanup?.ok ? {} : { uncertain: true, retryable: false }), reason: cleanup?.ok
|
|
6271
|
+
? "respawn permission withdrawn; new pane removed before injection, binding withheld"
|
|
6272
|
+
: `respawn permission withdrawn; binding withheld, reconcile new pane ${r.tmuxPane}` };
|
|
6273
|
+
}
|
|
5916
6274
|
a.tmuxPane = r.tmuxPane;
|
|
5917
6275
|
a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
|
|
5918
6276
|
if (r.cwd)
|
|
@@ -5925,18 +6283,34 @@ export const startMirror = (deps) => {
|
|
|
5925
6283
|
startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
|
|
5926
6284
|
fresh = true;
|
|
5927
6285
|
}
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
a
|
|
6286
|
+
if (!permitted())
|
|
6287
|
+
return denied;
|
|
6288
|
+
// Busy/queued retries must not consume notices or stamp a future turn.
|
|
6289
|
+
// Render only after the pane lane grants delivery, then stamp exactly at
|
|
6290
|
+
// the first permitted input. activePeerTurn still changes in bindLine.
|
|
6291
|
+
const envelope = text.trimStart().startsWith("/") ? "" : opts?.envelope ?? "";
|
|
6292
|
+
let full = text + envelope;
|
|
6293
|
+
const beforeInput = () => {
|
|
6294
|
+
a.pendingFromCli = false;
|
|
6295
|
+
a.turnFromChat = true;
|
|
6296
|
+
rememberInject(full);
|
|
6297
|
+
pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
|
|
6298
|
+
a.muteUntilInject = false;
|
|
6299
|
+
a.justSpawned = false;
|
|
6300
|
+
};
|
|
5931
6301
|
// 同 dispatch 的 /clear 与静默分叉跟随: self-handoff / 同伴 / 定时任务也走这里注入,
|
|
5932
6302
|
// 漏了它 tail 就钉在旧 jsonl 上, 直到下一条人话经 dispatch 才把它拽回来。
|
|
5933
6303
|
const clearing = isClearCommand(text);
|
|
5934
6304
|
const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
6305
|
+
const injectPane = a.tmuxPane;
|
|
5935
6306
|
const r = await inject({
|
|
5936
|
-
text: full, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
6307
|
+
text: full, images: [], cfg, guard: (phase) => permitted() && a.tmuxPane === injectPane && (!opts?.guard || opts.guard(phase)), log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
5937
6308
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
|
|
6309
|
+
requireIdle: opts?.requireIdle ? () => idleVerdict(target) === true : undefined,
|
|
6310
|
+
prepareText: () => (full = text + envelope + noticeSuffixFor(target, text)),
|
|
6311
|
+
beforeInput,
|
|
5938
6312
|
});
|
|
5939
|
-
if (r.ok && a.tmuxPane) {
|
|
6313
|
+
if (r.ok && a.tmuxPane && permitted()) {
|
|
5940
6314
|
if (preClearBaseline) {
|
|
5941
6315
|
a.clearRebind = { baseline: preClearBaseline };
|
|
5942
6316
|
startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
|
|
@@ -6252,6 +6626,7 @@ export const startMirror = (deps) => {
|
|
|
6252
6626
|
clearInterval(paneDriftTimer);
|
|
6253
6627
|
clearInterval(keepaliveTimer);
|
|
6254
6628
|
clearInterval(pickerTimer);
|
|
6629
|
+
maintenance.stop();
|
|
6255
6630
|
for (const a of bySessionId.values()) {
|
|
6256
6631
|
if (a.outbound?.kind === "deferred")
|
|
6257
6632
|
clearTimeout(a.outbound.timer);
|
|
@@ -6269,6 +6644,8 @@ export const startMirror = (deps) => {
|
|
|
6269
6644
|
return a.convFrom.asker;
|
|
6270
6645
|
return a?.speaker ? { who: a.speaker, chat: a.channel ?? baseOfKey(target) } : undefined;
|
|
6271
6646
|
},
|
|
6647
|
+
answerContext, prepareAnswer, takeAnswer, cancelAnswerPreparation, stopAnswerProgress, deliverAnswer,
|
|
6648
|
+
setAnswerSilencer: (fn) => { silenceAnswer = fn; },
|
|
6272
6649
|
currentChannel: (target) => {
|
|
6273
6650
|
const a = byTarget.get(target);
|
|
6274
6651
|
return a?.channel ?? baseOfKey(target);
|
|
@@ -6406,7 +6783,7 @@ export const startMirror = (deps) => {
|
|
|
6406
6783
|
if (s) {
|
|
6407
6784
|
a.liveStream = s;
|
|
6408
6785
|
try {
|
|
6409
|
-
await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
|
|
6786
|
+
await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
|
|
6410
6787
|
}
|
|
6411
6788
|
catch (e) {
|
|
6412
6789
|
log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
|
|
@@ -6503,6 +6880,8 @@ export const startMirror = (deps) => {
|
|
|
6503
6880
|
log.warn({ target: a.target, sessionId: sid, myGen, gen: a.injectGen }, "inject aborted — superseded by /stop or watchdog");
|
|
6504
6881
|
return;
|
|
6505
6882
|
}
|
|
6883
|
+
if (!brief)
|
|
6884
|
+
pushSeal(a, { keys: sealKeys(effText), at: Date.now(), query: effText, channel: channel ?? baseOfKey(principal), speaker, fresh: true });
|
|
6506
6885
|
rememberInject(effText);
|
|
6507
6886
|
// 解除静默必须在 inject 之前: inject 内部的 freshSpawn 验证循环可达 10+s
|
|
6508
6887
|
// (paste-verify + settle + waitForCleared + retry), 而 LLM 的回复可能在 Enter
|
|
@@ -6511,9 +6890,11 @@ export const startMirror = (deps) => {
|
|
|
6511
6890
|
// 安全性: newSession 的 fresh pane 在 inject 前只写过 system/init (→ 无 item),
|
|
6512
6891
|
// 用户行有 isOwnInject 过滤 —— 提前解除不会泄漏脏数据。
|
|
6513
6892
|
a.muteUntilInject = false;
|
|
6893
|
+
const injectPane = a.tmuxPane;
|
|
6514
6894
|
const r = await inject({
|
|
6515
6895
|
text: effText, images: effImages, cfg, log: log.child({ principal, sessionId: sid }),
|
|
6516
6896
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
|
|
6897
|
+
guard: () => byTarget.get(a.target) === a && a.tmuxPane === injectPane && (a.injectGen ?? 0) === myGen,
|
|
6517
6898
|
});
|
|
6518
6899
|
if (!r.ok) {
|
|
6519
6900
|
if (s) {
|
|
@@ -6577,10 +6958,7 @@ export const startMirror = (deps) => {
|
|
|
6577
6958
|
// and surface a standalone "cleared" so the user gets explicit
|
|
6578
6959
|
// feedback (the skip-stream path otherwise leaves WeCom silent).
|
|
6579
6960
|
if (armMigration) {
|
|
6580
|
-
|
|
6581
|
-
sendStandalone(a, cfg.wrc.mirror.slashAckFirstLine
|
|
6582
|
-
? "cleared"
|
|
6583
|
-
: `cleared\n\n${randomTip()}`);
|
|
6961
|
+
sendStandalone(a, "cleared");
|
|
6584
6962
|
a.clearRebind = { baseline: preClearBaseline };
|
|
6585
6963
|
startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
|
|
6586
6964
|
}
|