wezard 2.6.2 → 2.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.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/bot-mux.js +4 -0
- package/dist/daemon/bot-mux.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 +54 -0
- package/dist/daemon/image-input.js.map +1 -0
- package/dist/daemon/inbound.js +7 -13
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +1134 -331
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +39 -15
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +37 -7
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/message-events.js +361 -0
- package/dist/daemon/message-events.js.map +1 -0
- package/dist/daemon/message-format.js +40 -0
- package/dist/daemon/message-format.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +847 -343
- 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 +17 -16
- 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 +427 -139
- 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/weixin.js +111 -7
- package/dist/daemon/weixin.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/cli-backends.js.map +1 -1
- package/dist/shared/codex.js +1 -1
- package/dist/shared/codex.js.map +1 -1
- package/dist/shared/config.js +12 -10
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-store.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 +9 -1
- 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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { finalId } from "./message-events.js";
|
|
2
|
+
import { parseEnvelope } from "../shared/reminder.js";
|
|
1
3
|
import { codexSessions, codexSessionForPane } from "../shared/codex.js";
|
|
2
4
|
import { codexHookLaunchArgs, codexHookCommand } from "../shared/codex-hooks.js";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -24,6 +26,9 @@ import { newStewardSpec, resumedStewardSpec } from "./steward-model.js";
|
|
|
24
26
|
import { expandHome, sanitizeId } from "../shared/paths.js";
|
|
25
27
|
import { augmentedPath } from "../shared/exec-path.js";
|
|
26
28
|
import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
|
|
29
|
+
import { withImageClipboard, pasteVerifiedImage } from "./image-input.js";
|
|
30
|
+
import { createKeyedScheduler } from "../shared/keyed-scheduler.js";
|
|
31
|
+
import { withPaneInput } from "./pane-input.js";
|
|
27
32
|
import { activeBackends, resolveCliBackend, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
|
|
28
33
|
import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, modalFingerprint, looksLikePicker } from "../shared/modal-pane.js";
|
|
29
34
|
import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
|
|
@@ -37,12 +42,11 @@ import { hasRegistry, liveSessionPids, markTranscript, probeOf, sessionOnPane, s
|
|
|
37
42
|
import { waitForIdle } from "./graph.js";
|
|
38
43
|
import { wizardStore } from "./wizard.js";
|
|
39
44
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
40
|
-
import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
|
|
45
|
+
import { getDetail, knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
|
|
41
46
|
import { errText } from "./last-response.js";
|
|
42
|
-
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge,
|
|
47
|
+
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, parseTagHeader, MAX_BODY_LINKS, isInternalKey, sameBot } from "../shared/session-label.js";
|
|
43
48
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
44
49
|
import { isWxFrame } from "../shared/wx-text.js";
|
|
45
|
-
import { randomTip } from "./tips.js";
|
|
46
50
|
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
47
51
|
import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
|
|
48
52
|
import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
|
|
@@ -524,7 +528,7 @@ const renderLine = (raw, deps) => {
|
|
|
524
528
|
if (line.type === "attachment" && line.attachment?.type === "queued_command" && typeof line.attachment.prompt === "string") {
|
|
525
529
|
const said = cleanUserText(line.attachment.prompt);
|
|
526
530
|
const ts = Date.parse(line.timestamp ?? "");
|
|
527
|
-
return said ? [{ kind: "user_inject", said, ts: Number.isNaN(ts) ? Date.now() : ts }] : [];
|
|
531
|
+
return said ? [{ kind: "user_inject", said, eventId: parseEnvelope(line.attachment.prompt)?.eventId, ts: Number.isNaN(ts) ? Date.now() : ts }] : [];
|
|
528
532
|
}
|
|
529
533
|
const out = [];
|
|
530
534
|
if (line.type === "user") {
|
|
@@ -574,8 +578,9 @@ const renderLine = (raw, deps) => {
|
|
|
574
578
|
if (deps.isKeepalivePing?.(text))
|
|
575
579
|
return [{ kind: "keepalive_start", body: text }];
|
|
576
580
|
// 自己注入的那一行不回显, 但要报个到: 它的印章 (谁说的、回复去哪) 在这一刻认领。
|
|
577
|
-
|
|
578
|
-
|
|
581
|
+
const eventId = c.includes('wezard="message-event"') ? parseEnvelope(c)?.eventId : undefined;
|
|
582
|
+
if (eventId || deps.isOwnInject(text))
|
|
583
|
+
return [{ kind: "user_query", eventId, body: unwrapPasted(c).trim(), said: text, ts: lineTs(line) }];
|
|
579
584
|
const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
|
|
580
585
|
// includeUser 只决定"渲不渲染", 不决定"算不算边界" —— quiet 的这一条照样发出,
|
|
581
586
|
// onItem 消费完边界语义后自己丢掉。
|
|
@@ -586,11 +591,11 @@ const renderLine = (raw, deps) => {
|
|
|
586
591
|
// 回显去重对得上; 记进详情页的则是带占位符的原样文本, 图本身不画。
|
|
587
592
|
const raw = c.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("\n");
|
|
588
593
|
const text = cleanUserText(raw.replace(IMAGE_REF_RE, ""));
|
|
589
|
-
if (text && !deps.isOwnInject(text)) {
|
|
594
|
+
if (text && !parseEnvelope(raw)?.eventId && !deps.isOwnInject(text)) {
|
|
590
595
|
out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser, ts: lineTs(line) });
|
|
591
596
|
}
|
|
592
597
|
else {
|
|
593
|
-
out.push({ kind: "user_query", body: unwrapPasted(raw).trim(), said: text, ts: lineTs(line) });
|
|
598
|
+
out.push({ kind: "user_query", eventId: parseEnvelope(raw)?.eventId, body: unwrapPasted(raw).trim(), said: text, ts: lineTs(line) });
|
|
594
599
|
}
|
|
595
600
|
}
|
|
596
601
|
else if (Array.isArray(c)) {
|
|
@@ -1159,10 +1164,10 @@ const detectModalPicker = async (target) => {
|
|
|
1159
1164
|
const MODAL_PRESS_SETTLE_MS = 400;
|
|
1160
1165
|
const MODAL_ARROW_SETTLE_MS = 150;
|
|
1161
1166
|
const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
|
|
1162
|
-
const pressModalIndex = async (pane, before, index) => {
|
|
1167
|
+
const pressModalIndex = async (pane, before, index, live = () => true) => {
|
|
1163
1168
|
const fingerprint = modalFingerprint(before.screen);
|
|
1164
1169
|
const current = await detectModalPicker(pane);
|
|
1165
|
-
if (!current.modal || modalFingerprint(current.screen) !== fingerprint)
|
|
1170
|
+
if (!live() || !current.modal || modalFingerprint(current.screen) !== fingerprint)
|
|
1166
1171
|
return current;
|
|
1167
1172
|
await runTmux(["send-keys", "-t", pane, String(index)]);
|
|
1168
1173
|
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
@@ -1171,6 +1176,8 @@ const pressModalIndex = async (pane, before, index) => {
|
|
|
1171
1176
|
return after;
|
|
1172
1177
|
// 数字键没关掉框: 把高亮挪到目标项。guard 上限盖过任何确认框的选项数。
|
|
1173
1178
|
for (let guard = 0; guard < 8; guard++) {
|
|
1179
|
+
if (!live())
|
|
1180
|
+
return after;
|
|
1174
1181
|
const cur = highlightedIndex(after.screen);
|
|
1175
1182
|
if (cur === undefined || cur === index)
|
|
1176
1183
|
break;
|
|
@@ -1180,7 +1187,7 @@ const pressModalIndex = async (pane, before, index) => {
|
|
|
1180
1187
|
if (!after.modal || modalFingerprint(after.screen) !== fingerprint)
|
|
1181
1188
|
return after;
|
|
1182
1189
|
}
|
|
1183
|
-
if (highlightedIndex(after.screen) !== index)
|
|
1190
|
+
if (!live() || highlightedIndex(after.screen) !== index)
|
|
1184
1191
|
return after; // 核不准, 绝不盲按
|
|
1185
1192
|
await runTmux(["send-keys", "-t", pane, "Enter"]);
|
|
1186
1193
|
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
@@ -1301,7 +1308,7 @@ export const parseInputBox = (cap, styledLayout = cap) => {
|
|
|
1301
1308
|
layout.pop();
|
|
1302
1309
|
const footer = layout.length - 1;
|
|
1303
1310
|
// Codex hides the shortcuts row while the composer contains text.
|
|
1304
|
-
const shortcuts =
|
|
1311
|
+
const shortcuts = /(?:^|\s)\? for shortcuts(?:\s|$)/u.test(layout[footer] ?? "");
|
|
1305
1312
|
if (shortcuts || (layout[footer] ?? "").includes("·")) {
|
|
1306
1313
|
let status = shortcuts ? footer - 1 : footer;
|
|
1307
1314
|
while (status >= 0 && !layout[status].trim())
|
|
@@ -1433,7 +1440,7 @@ const witnessFor = (target, homeDir, jsonlPath, live) => {
|
|
|
1433
1440
|
// but the trailing Enter is eaten while the TUI is still initializing, so
|
|
1434
1441
|
// the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
|
|
1435
1442
|
// 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
|
|
1436
|
-
const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false) => {
|
|
1443
|
+
const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard) => {
|
|
1437
1444
|
const backendName = backend.name;
|
|
1438
1445
|
log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
|
|
1439
1446
|
// 就绪门: pane 里得真有一个进入了交互态的 AI 会话, 才轮得到贴。先于 modal guard ——
|
|
@@ -1474,33 +1481,53 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
|
|
|
1474
1481
|
// dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
|
|
1475
1482
|
// withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
|
|
1476
1483
|
if (backendName === "codebuddy") {
|
|
1477
|
-
return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness);
|
|
1484
|
+
return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, guard);
|
|
1478
1485
|
}
|
|
1479
1486
|
// Pump images first via clipboard+C-v so each one is attached as a separate
|
|
1480
1487
|
// image content block. Each C-v needs a brief settle for Claude Code's TUI
|
|
1481
1488
|
// to read the clipboard before the next overwrite. Fresh spawn extends.
|
|
1482
1489
|
const IMG_SETTLE_MS = freshSpawn ? 700 : 350;
|
|
1483
|
-
|
|
1484
|
-
const
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1490
|
+
const pumped = images.length ? await withImageClipboard(async () => {
|
|
1491
|
+
for (const imgPath of images) {
|
|
1492
|
+
const paste = async () => {
|
|
1493
|
+
const cb = await setMacClipboardImage(imgPath);
|
|
1494
|
+
if (!cb.ok)
|
|
1495
|
+
return cb;
|
|
1496
|
+
if (guard && !guard("paste"))
|
|
1497
|
+
return { ok: false, reason: "image paste canceled by dispatch guard" };
|
|
1498
|
+
const cv = await runTmux(["send-keys", "-t", target, "C-v"]);
|
|
1499
|
+
return cv.ok ? { ok: true } : { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
|
|
1500
|
+
};
|
|
1501
|
+
// Cold Codex can ignore C-v before its composer exists. Do not submit
|
|
1502
|
+
// the text until the composer proves this attachment has landed.
|
|
1503
|
+
const result = backendName === "codex"
|
|
1504
|
+
? await pasteVerifiedImage({ read: () => readInputBox(target), paste, valid: () => !guard || guard("paste") })
|
|
1505
|
+
: await paste();
|
|
1506
|
+
if (!result.ok) {
|
|
1507
|
+
log.warn({ target, imgPath, reason: result.reason }, "mirror inject: image not confirmed, refusing text submission");
|
|
1508
|
+
return result;
|
|
1509
|
+
}
|
|
1510
|
+
// Attachment rendering can precede the TUI finishing its paste handler.
|
|
1511
|
+
// Preserve the key settle interval before another C-v or text paste.
|
|
1512
|
+
await sleep(IMG_SETTLE_MS);
|
|
1513
|
+
log.info({ target, imgPath, verified: backendName === "codex" }, "mirror inject: image attached");
|
|
1488
1514
|
}
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
}
|
|
1515
|
+
return { ok: true };
|
|
1516
|
+
}) : { ok: true };
|
|
1517
|
+
if (!pumped.ok)
|
|
1518
|
+
return { ...pumped, uncertain: true };
|
|
1494
1519
|
// Image-only message: TUI input box now holds the attached images; press
|
|
1495
1520
|
// Enter and we're done. No fingerprint to verify (the input itself is binary
|
|
1496
1521
|
// image refs, not text).
|
|
1497
1522
|
if (!text) {
|
|
1498
1523
|
if (images.length === 0)
|
|
1499
1524
|
return { ok: true };
|
|
1525
|
+
if (guard && !guard("submit"))
|
|
1526
|
+
return { ok: false, reason: "submit canceled by dispatch guard" };
|
|
1500
1527
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1501
1528
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
|
|
1502
1529
|
}
|
|
1503
|
-
return injectViaTmuxText(target, text, log, freshSpawn, witness);
|
|
1530
|
+
return injectViaTmuxText(target, text, log, freshSpawn, witness, guard);
|
|
1504
1531
|
};
|
|
1505
1532
|
// 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
|
|
1506
1533
|
// 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
|
|
@@ -1522,7 +1549,7 @@ const withImageRefs = (text, images) => {
|
|
|
1522
1549
|
// 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
|
|
1523
1550
|
// 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
|
|
1524
1551
|
// 每次补之前再问一遍证人, 已提交就不补。
|
|
1525
|
-
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
|
|
1552
|
+
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard) => {
|
|
1526
1553
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1527
1554
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
1528
1555
|
// end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
|
|
@@ -1559,7 +1586,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1559
1586
|
const baseBody = alreadyTyped ? "" : preBox.body;
|
|
1560
1587
|
/** 框里还是贴之前那样 (或彻底空了) = 我们的文本不在框里。 */
|
|
1561
1588
|
const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === baseBody);
|
|
1562
|
-
const loadAndPaste = () => pasteTmuxBuffer(target, text);
|
|
1589
|
+
const loadAndPaste = () => pasteTmuxBuffer(target, text, guard ? () => guard("paste") : undefined);
|
|
1563
1590
|
// 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
|
|
1564
1591
|
// 头部可能在框顶, 所以窗口要宽)。
|
|
1565
1592
|
const sawLanded = async (timeoutMs) => {
|
|
@@ -1583,6 +1610,9 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1583
1610
|
let pasteSeen = alreadyTyped;
|
|
1584
1611
|
let pastedAt = Date.now();
|
|
1585
1612
|
if (alreadyTyped) {
|
|
1613
|
+
// Existing text is already exposed, even if permission was just withdrawn.
|
|
1614
|
+
if (guard && !guard("paste"))
|
|
1615
|
+
return { ok: false, uncertain: true, reason: "existing input submission canceled by dispatch guard" };
|
|
1586
1616
|
log.warn({ target, headFp }, "mirror inject: 输入框里已有这段文本 (上次未提交) — 不重贴, 直接提交");
|
|
1587
1617
|
}
|
|
1588
1618
|
else {
|
|
@@ -1647,6 +1677,8 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1647
1677
|
await sleep(Math.max(0, POST_PASTE_SETTLE_FALLBACK_MS - (Date.now() - pastedAt)));
|
|
1648
1678
|
}
|
|
1649
1679
|
const sendEnter = async () => {
|
|
1680
|
+
if (guard && !guard("submit"))
|
|
1681
|
+
return { ok: false, reason: "submit canceled by dispatch guard" };
|
|
1650
1682
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1651
1683
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
|
|
1652
1684
|
};
|
|
@@ -1749,6 +1781,8 @@ const injectViaSpawn = async (args) => {
|
|
|
1749
1781
|
...cfg.wrc.extraArgs,
|
|
1750
1782
|
];
|
|
1751
1783
|
log.info({ sessionId, bin, len: text.length }, "mirror inject");
|
|
1784
|
+
if (args.guard && !args.guard("submit"))
|
|
1785
|
+
return { ok: false, reason: "spawn canceled by dispatch guard" };
|
|
1752
1786
|
return new Promise((resolve) => {
|
|
1753
1787
|
const proc = spawn(bin, cliArgs, {
|
|
1754
1788
|
cwd,
|
|
@@ -1778,11 +1812,58 @@ const injectViaSpawn = async (args) => {
|
|
|
1778
1812
|
});
|
|
1779
1813
|
});
|
|
1780
1814
|
};
|
|
1781
|
-
const inject = (args) => {
|
|
1815
|
+
const inject = async (args) => {
|
|
1782
1816
|
const target = (args.tmuxTarget ?? "").trim();
|
|
1783
|
-
if (!target)
|
|
1784
|
-
|
|
1785
|
-
|
|
1817
|
+
if (!target) {
|
|
1818
|
+
if (args.requireIdle)
|
|
1819
|
+
return { ok: false, retryable: true, reason: "idle delivery requires a live pane" };
|
|
1820
|
+
try {
|
|
1821
|
+
if (args.guard && !args.guard())
|
|
1822
|
+
return { ok: false, retryable: true, reason: "spawn permission withdrawn" };
|
|
1823
|
+
const text = args.prepareText?.() ?? args.text;
|
|
1824
|
+
let activated = false;
|
|
1825
|
+
const result = await injectViaSpawn({ ...args, text, guard: (phase) => {
|
|
1826
|
+
if (args.guard && !args.guard(phase))
|
|
1827
|
+
return false;
|
|
1828
|
+
if (phase && !activated) {
|
|
1829
|
+
activated = true;
|
|
1830
|
+
args.beforeInput?.();
|
|
1831
|
+
}
|
|
1832
|
+
return true;
|
|
1833
|
+
} });
|
|
1834
|
+
return result.ok ? result : { ...result, uncertain: true };
|
|
1835
|
+
}
|
|
1836
|
+
catch (error) {
|
|
1837
|
+
return { ok: false, uncertain: true, reason: String(error) };
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
let exposed = false;
|
|
1841
|
+
const classify = (result) => result.ok ? result
|
|
1842
|
+
: result.uncertain || exposed ? { ...result, uncertain: true, retryable: false }
|
|
1843
|
+
: { ...result, retryable: true };
|
|
1844
|
+
try {
|
|
1845
|
+
return classify(await withPaneInput(target, async (live) => {
|
|
1846
|
+
// Busy and unknown are both a deferred delivery. Never hold the input
|
|
1847
|
+
// lock while waiting for a model's whole turn to finish.
|
|
1848
|
+
const idleApproved = !args.requireIdle || await args.requireIdle();
|
|
1849
|
+
if (!idleApproved)
|
|
1850
|
+
return { ok: false, reason: "target has no confirmed idle evidence" };
|
|
1851
|
+
const text = args.prepareText?.() ?? args.text;
|
|
1852
|
+
const guard = (phase) => {
|
|
1853
|
+
if (!live() || (!exposed && !idleApproved) || (args.guard && !args.guard(phase)))
|
|
1854
|
+
return false;
|
|
1855
|
+
if (phase && !exposed) {
|
|
1856
|
+
exposed = true;
|
|
1857
|
+
args.beforeInput?.();
|
|
1858
|
+
}
|
|
1859
|
+
return true;
|
|
1860
|
+
};
|
|
1861
|
+
return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard);
|
|
1862
|
+
}, { guard: args.guard }));
|
|
1863
|
+
}
|
|
1864
|
+
catch (error) {
|
|
1865
|
+
return classify({ ok: false, reason: String(error) });
|
|
1866
|
+
}
|
|
1786
1867
|
};
|
|
1787
1868
|
const queues = new Map();
|
|
1788
1869
|
/** Ceiling for one whole inject job (respawn + paste + submit verification).
|
|
@@ -1967,6 +2048,7 @@ export const startMirror = (deps) => {
|
|
|
1967
2048
|
turnRegistry.delete(k);
|
|
1968
2049
|
};
|
|
1969
2050
|
const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
|
|
2051
|
+
let eventFinalGate = () => true;
|
|
1970
2052
|
const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
1971
2053
|
const newMarkId = () => `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
1972
2054
|
const detailCardFor = (s, target) => {
|
|
@@ -1989,7 +2071,11 @@ export const startMirror = (deps) => {
|
|
|
1989
2071
|
const content = s.acc;
|
|
1990
2072
|
s.lastSent = content;
|
|
1991
2073
|
try {
|
|
1992
|
-
await
|
|
2074
|
+
await (s.pending = (s.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2075
|
+
if (s.closed || s.dead)
|
|
2076
|
+
return;
|
|
2077
|
+
await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
|
|
2078
|
+
}));
|
|
1993
2079
|
log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
|
|
1994
2080
|
}
|
|
1995
2081
|
catch (e) {
|
|
@@ -2030,6 +2116,7 @@ export const startMirror = (deps) => {
|
|
|
2030
2116
|
if (!s.dead && body.trim()) {
|
|
2031
2117
|
const card = detailCardFor(s, a.target);
|
|
2032
2118
|
try {
|
|
2119
|
+
await s.pending?.catch(() => undefined);
|
|
2033
2120
|
if (card) {
|
|
2034
2121
|
s.cardSent = true;
|
|
2035
2122
|
await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
|
|
@@ -2079,7 +2166,7 @@ export const startMirror = (deps) => {
|
|
|
2079
2166
|
// 头挂 chat 详情页的票据: 本轮那条 turn。没有活 turn 可挂 → undefined, 头退回裸
|
|
2080
2167
|
// `emoji #tag` (withLinkedTagHeader 自己兜)。
|
|
2081
2168
|
const chatTurnUrl = (target, turnId) => turnId
|
|
2082
|
-
? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target))
|
|
2169
|
+
? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target), target)
|
|
2083
2170
|
: undefined;
|
|
2084
2171
|
// `[mirror]` / `[wezard]` 系统提示不是答给谁的话, 不标方向。
|
|
2085
2172
|
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 +2181,21 @@ export const startMirror = (deps) => {
|
|
|
2094
2181
|
const sendStandalone = (a, content) => {
|
|
2095
2182
|
if (!hasVisibleBody(content))
|
|
2096
2183
|
return;
|
|
2184
|
+
if (answerSilent(a))
|
|
2185
|
+
return;
|
|
2097
2186
|
if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
|
|
2098
2187
|
return;
|
|
2099
2188
|
const chatId = replyChatId(a);
|
|
2100
|
-
const turnId = a.briefTurnId; // 推送是排队异步的, 到发出时 turn 早已收口
|
|
2189
|
+
const turnId = a.briefTurnId ?? a.liveStream?.turnId; // 推送是排队异步的, 到发出时 turn 早已收口
|
|
2101
2190
|
const pieces = splitMarkdown(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
2102
2191
|
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
2103
2192
|
a.standalonePending = a.standalonePending
|
|
2104
2193
|
.then(async () => {
|
|
2194
|
+
if (silenceAnswer(a.target, turnId ?? ""))
|
|
2195
|
+
return;
|
|
2105
2196
|
for (const c of chunks) {
|
|
2197
|
+
if (silenceAnswer(a.target, turnId ?? ""))
|
|
2198
|
+
return;
|
|
2106
2199
|
try {
|
|
2107
2200
|
const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
|
|
2108
2201
|
log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
|
|
@@ -2119,9 +2212,11 @@ export const startMirror = (deps) => {
|
|
|
2119
2212
|
if (!hasVisibleBody(content))
|
|
2120
2213
|
return;
|
|
2121
2214
|
const chatId = replyChatId(a);
|
|
2122
|
-
const turnId = a.briefTurnId;
|
|
2215
|
+
const turnId = a.briefTurnId ?? a.liveStream?.turnId;
|
|
2123
2216
|
a.standalonePending = a.standalonePending
|
|
2124
2217
|
.then(async () => {
|
|
2218
|
+
if (silenceAnswer(a.target, turnId ?? ""))
|
|
2219
|
+
return;
|
|
2125
2220
|
try {
|
|
2126
2221
|
await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
|
|
2127
2222
|
log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
|
|
@@ -2310,7 +2405,7 @@ export const startMirror = (deps) => {
|
|
|
2310
2405
|
// sees the bubble; subsequent items grow it as today.
|
|
2311
2406
|
void (async () => {
|
|
2312
2407
|
try {
|
|
2313
|
-
await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
|
|
2408
|
+
await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
|
|
2314
2409
|
}
|
|
2315
2410
|
catch (e) {
|
|
2316
2411
|
log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
|
|
@@ -2415,11 +2510,15 @@ export const startMirror = (deps) => {
|
|
|
2415
2510
|
const b = a.briefBubble;
|
|
2416
2511
|
const turnId = a.briefTurnId;
|
|
2417
2512
|
// 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
|
|
2418
|
-
if (!text || !b || b.done || !turnId || a.briefConcluded)
|
|
2513
|
+
if (!text || !b || b.done || !turnId || a.briefConcluded || answerSilent(a))
|
|
2419
2514
|
return;
|
|
2420
2515
|
a.cotLastSent = text;
|
|
2421
2516
|
try {
|
|
2422
|
-
await
|
|
2517
|
+
await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2518
|
+
if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || answerSilent(a))
|
|
2519
|
+
return;
|
|
2520
|
+
await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, `\`${text}\``), false);
|
|
2521
|
+
}));
|
|
2423
2522
|
}
|
|
2424
2523
|
catch (e) {
|
|
2425
2524
|
log.debug({ sessionId: a.sessionId, turnId, err: e.message }, "brief: cot refresh failed");
|
|
@@ -2450,7 +2549,231 @@ export const startMirror = (deps) => {
|
|
|
2450
2549
|
// `[mirror]` 系统提示 —— 它们都不走这道门。
|
|
2451
2550
|
// 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
|
|
2452
2551
|
// 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
|
|
2453
|
-
|
|
2552
|
+
let silenceAnswer = () => false;
|
|
2553
|
+
const answerSilent = (a) => silenceAnswer(a.target, a.briefTurnId ?? a.liveStream?.turnId ?? "");
|
|
2554
|
+
const turnSilent = (a) => answerSilent(a) || isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
|
|
2555
|
+
const formatFinal = (target, turn, channel, body) => {
|
|
2556
|
+
const pieces = splitMarkdown(body, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
2557
|
+
return pieces.map((text, n) => withLinkedTagHeader(target, text, chatTurnUrl(target, turn), pieces.length > 1 ? `${n + 1}/${pieces.length}` : undefined, undefined, channel));
|
|
2558
|
+
};
|
|
2559
|
+
const finalProgress = new Map();
|
|
2560
|
+
const settleFinalProgress = async (id) => {
|
|
2561
|
+
await finalProgress.get(id)?.catch(() => undefined);
|
|
2562
|
+
finalProgress.delete(id);
|
|
2563
|
+
};
|
|
2564
|
+
const queueFinal = (target, turn, channel, body, eventId, bubble) => {
|
|
2565
|
+
if (!deps.events || !channel || !body.trim())
|
|
2566
|
+
return undefined;
|
|
2567
|
+
const revision = finalId(eventId || turn, channel, body);
|
|
2568
|
+
const id = finalId(eventId || turn, channel, revision);
|
|
2569
|
+
const chunks = formatFinal(target, turn, channel, body);
|
|
2570
|
+
if (bubble?.pending)
|
|
2571
|
+
finalProgress.set(id, bubble.pending);
|
|
2572
|
+
deps.events.delivery({ id, eventId, target, turn, channel, body, revision, chunks,
|
|
2573
|
+
...(bubble && !bubble.done ? { stream: { frame: bubble.frame, id: bubble.streamId, expires: bubble.expires ?? Date.now() } } : {}) });
|
|
2574
|
+
if (bubble) {
|
|
2575
|
+
bubble.done = true;
|
|
2576
|
+
clearTimeout(bubble.hardTimer);
|
|
2577
|
+
}
|
|
2578
|
+
return id;
|
|
2579
|
+
};
|
|
2580
|
+
const recoverEventFinal = (event, bubble) => {
|
|
2581
|
+
if (!event.result || event.interjected || event.canceled)
|
|
2582
|
+
return;
|
|
2583
|
+
const turn = event.turn || event.id;
|
|
2584
|
+
const record = getDetail(turn);
|
|
2585
|
+
if (!record)
|
|
2586
|
+
recordTurnStart({ id: turn, eventId: event.id, target: event.target,
|
|
2587
|
+
userQuery: event.payload.text, createdAt: event.originalAt ?? event.at, channel: event.channel,
|
|
2588
|
+
speaker: event.payload.human ? event.speaker : undefined, from: event.payload.peer?.from });
|
|
2589
|
+
if (record?.kind !== "turn" || !record.items.some((x) => x.t === "text" && x.final && x.body === event.result.body))
|
|
2590
|
+
recordTurnItem(turn, { t: "text", final: true, body: event.result.body, ts: event.result.at });
|
|
2591
|
+
recordTurnClose(turn);
|
|
2592
|
+
if (!event.turn)
|
|
2593
|
+
deps.events?.bind(event.id, turn);
|
|
2594
|
+
const parent = event.replyTo?.kind === "chat" ? deps.events?.get(event.replyTo.turn) : undefined;
|
|
2595
|
+
if (parent && event.channel === parent.channel) {
|
|
2596
|
+
const original = deps.events.continueResult(event.id);
|
|
2597
|
+
if (!original)
|
|
2598
|
+
return;
|
|
2599
|
+
if (eventFinalGate({ ...original, awaitingContinuation: false }))
|
|
2600
|
+
deps.events.defer(original.id, false);
|
|
2601
|
+
const fresh = deps.events.get(original.id);
|
|
2602
|
+
if (fresh.replyTo?.kind === "chat")
|
|
2603
|
+
recoverEventFinal(fresh, bubble);
|
|
2604
|
+
else if (eventFinalGate(fresh))
|
|
2605
|
+
queueFinal(fresh.target, fresh.turn || fresh.id, fresh.channel, event.result.body, fresh.id, bubble);
|
|
2606
|
+
}
|
|
2607
|
+
else if (event.channel && eventFinalGate(event))
|
|
2608
|
+
queueFinal(event.target, turn, event.channel, event.result.body, event.id, bubble);
|
|
2609
|
+
};
|
|
2610
|
+
const answerBubbles = new Map();
|
|
2611
|
+
const answerLine = (held, text) => withLinkedTagHeader(held.receiver, text, chatTurnUrl(held.receiver, held.sourceTurn), undefined, undefined, held.channel);
|
|
2612
|
+
// Serialise the old owner's in-flight refresh, reset, progress and final.
|
|
2613
|
+
// A delayed ACK must never let an earlier writer overtake the new owner.
|
|
2614
|
+
const writeAnswerProgress = (held, text) => {
|
|
2615
|
+
held.pending = held.pending.catch(() => undefined).then(async () => {
|
|
2616
|
+
if (!held.active || held.stopped || !held.bubble || held.bubble.done)
|
|
2617
|
+
return;
|
|
2618
|
+
await client.replyStream(held.bubble.frame, held.bubble.streamId, answerLine(held, text), false);
|
|
2619
|
+
}).catch((e) => log.warn({ err: errText(e) }, "answer transfer progress refresh failed"));
|
|
2620
|
+
};
|
|
2621
|
+
const updateAnswerProgress = (a, item) => {
|
|
2622
|
+
// Queued metadata is not authority: only the input boundary activates a task.
|
|
2623
|
+
const held = a.activePeerTurn ? answerBubbles.get(a.activePeerTurn) : undefined;
|
|
2624
|
+
if (!held || held.receiver !== a.target || held.stopped || held.bubble?.done)
|
|
2625
|
+
return;
|
|
2626
|
+
const raw = item.kind === "tool_use" ? cotToolLabel(item.calls)
|
|
2627
|
+
: item.kind === "thinking" || (item.kind === "text" && item.final !== true) ? item.body : "";
|
|
2628
|
+
const line = cotLine(raw);
|
|
2629
|
+
if (!line)
|
|
2630
|
+
return;
|
|
2631
|
+
held.progress = line;
|
|
2632
|
+
if (!held.active)
|
|
2633
|
+
return;
|
|
2634
|
+
if (held.timer)
|
|
2635
|
+
return;
|
|
2636
|
+
held.timer = setTimeout(() => {
|
|
2637
|
+
held.timer = undefined;
|
|
2638
|
+
if (held.progress)
|
|
2639
|
+
writeAnswerProgress(held, `\`${held.progress}\``);
|
|
2640
|
+
}, COT_FLUSH_MS);
|
|
2641
|
+
};
|
|
2642
|
+
const answerContext = (target) => {
|
|
2643
|
+
const a = byTarget.get(target);
|
|
2644
|
+
if (!a || a.briefConcluded || a.briefIsSlash || isPrivate(a) || a.turnFromChat === false)
|
|
2645
|
+
return undefined;
|
|
2646
|
+
const turn = a.briefTurnId ?? a.liveStream?.turnId;
|
|
2647
|
+
return turn ? { turn, channel: a.channel ?? baseOfKey(target), prefix: "" } : undefined;
|
|
2648
|
+
};
|
|
2649
|
+
const prepareAnswer = (id, source, expectedTurn, receiver, channel) => {
|
|
2650
|
+
const a = byTarget.get(source);
|
|
2651
|
+
if (!a || (a.briefTurnId ?? a.liveStream?.turnId) !== expectedTurn || answerBubbles.has(id))
|
|
2652
|
+
return false;
|
|
2653
|
+
if ((!a.briefBubble || a.briefBubble.done) && (!a.liveStream || a.liveStream.closed || a.liveStream.dead))
|
|
2654
|
+
return false;
|
|
2655
|
+
answerBubbles.set(id, {
|
|
2656
|
+
source, receiver, sourceTurn: expectedTurn, channel, active: false, stopped: false,
|
|
2657
|
+
bubble: a.briefBubble, stream: a.liveStream, pending: Promise.resolve(),
|
|
2658
|
+
});
|
|
2659
|
+
return true;
|
|
2660
|
+
};
|
|
2661
|
+
const cancelAnswerPreparation = (id) => {
|
|
2662
|
+
const held = answerBubbles.get(id);
|
|
2663
|
+
if (held && !held.active)
|
|
2664
|
+
answerBubbles.delete(id);
|
|
2665
|
+
};
|
|
2666
|
+
const stopAnswerProgress = (id) => {
|
|
2667
|
+
const held = answerBubbles.get(id);
|
|
2668
|
+
if (!held)
|
|
2669
|
+
return;
|
|
2670
|
+
held.stopped = true;
|
|
2671
|
+
clearTimeout(held.timer);
|
|
2672
|
+
held.timer = undefined;
|
|
2673
|
+
};
|
|
2674
|
+
const takeAnswer = async (id, target, expectedTurn, receiver, channel) => {
|
|
2675
|
+
const a = byTarget.get(target);
|
|
2676
|
+
const held = answerBubbles.get(id);
|
|
2677
|
+
if (!held || held.active || held.source !== target || held.sourceTurn !== expectedTurn || held.receiver !== receiver || held.channel !== channel)
|
|
2678
|
+
return { taken: false, cleared: false };
|
|
2679
|
+
const sameTurn = a && (a.briefTurnId ?? a.liveStream?.turnId) === expectedTurn;
|
|
2680
|
+
if (sameTurn) {
|
|
2681
|
+
clearCot(a);
|
|
2682
|
+
a.cotLastSent = undefined;
|
|
2683
|
+
if (a.standaloneBuf) {
|
|
2684
|
+
clearTimeout(a.standaloneBuf.timer);
|
|
2685
|
+
a.standaloneBuf = undefined;
|
|
2686
|
+
}
|
|
2687
|
+
// Only this source turn becomes private; a concurrent new human turn is untouched.
|
|
2688
|
+
a.channel = "";
|
|
2689
|
+
}
|
|
2690
|
+
const s = held.stream;
|
|
2691
|
+
if (s && !s.closed && !s.dead) {
|
|
2692
|
+
s.closed = true;
|
|
2693
|
+
clearTimeout(s.flushTimer);
|
|
2694
|
+
clearTimeout(s.idleTimer);
|
|
2695
|
+
clearTimeout(s.hardTimer);
|
|
2696
|
+
// Read the current chain, not prepare's snapshot: source may have flushed during injection.
|
|
2697
|
+
held.bubble = { frame: s.frame, streamId: s.streamId, done: false, pending: s.pending };
|
|
2698
|
+
if (a?.liveStream === s)
|
|
2699
|
+
a.liveStream = undefined;
|
|
2700
|
+
}
|
|
2701
|
+
else if (s)
|
|
2702
|
+
held.bubble = undefined;
|
|
2703
|
+
const b = held.bubble;
|
|
2704
|
+
held.active = true;
|
|
2705
|
+
if (!b || b.done)
|
|
2706
|
+
return { taken: true, cleared: false };
|
|
2707
|
+
clearTimeout(b.hardTimer);
|
|
2708
|
+
if (a?.briefBubble === b)
|
|
2709
|
+
a.briefBubble = undefined;
|
|
2710
|
+
let cleared = false;
|
|
2711
|
+
held.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2712
|
+
if (b.done)
|
|
2713
|
+
return;
|
|
2714
|
+
await client.replyStream(b.frame, b.streamId, answerLine(held, "…"), false);
|
|
2715
|
+
cleared = true;
|
|
2716
|
+
});
|
|
2717
|
+
try {
|
|
2718
|
+
await held.pending;
|
|
2719
|
+
}
|
|
2720
|
+
catch (e) {
|
|
2721
|
+
cleared = false;
|
|
2722
|
+
log.warn({ id, err: errText(e) }, "answer transfer reset not acknowledged");
|
|
2723
|
+
}
|
|
2724
|
+
// Keep delivery possible after a reset error; reset never carries the final answer.
|
|
2725
|
+
held.pending = held.pending.catch(() => undefined);
|
|
2726
|
+
if (!held.stopped && held.progress)
|
|
2727
|
+
writeAnswerProgress(held, `\`${held.progress}\``);
|
|
2728
|
+
return { taken: true, cleared };
|
|
2729
|
+
};
|
|
2730
|
+
const deliverAnswer = async (id, target, turn, channel, _prefix, body, standalone, frozen = false) => {
|
|
2731
|
+
const held = answerBubbles.get(id);
|
|
2732
|
+
const text = body.trim();
|
|
2733
|
+
const content = frozen ? body : withLinkedTagHeader(target, text, chatTurnUrl(target, turn), undefined, undefined, channel);
|
|
2734
|
+
stopAnswerProgress(id);
|
|
2735
|
+
if (held?.bubble && !held.bubble.done) {
|
|
2736
|
+
held.bubble.done = true;
|
|
2737
|
+
clearTimeout(held.timer);
|
|
2738
|
+
await held.pending;
|
|
2739
|
+
try {
|
|
2740
|
+
// Failure closes progress first; the terminal explanation is a standalone message.
|
|
2741
|
+
const progress = withLinkedTagHeader(target, "回答已转交", chatTurnUrl(target, turn), undefined, undefined, channel);
|
|
2742
|
+
const ack = frozen && !standalone
|
|
2743
|
+
? await deps.sendFrozen(channel, content, { frame: held.bubble.frame, id: held.bubble.streamId })
|
|
2744
|
+
: await client.replyStream(held.bubble.frame, held.bubble.streamId, standalone ? progress : content, true);
|
|
2745
|
+
if (!standalone) {
|
|
2746
|
+
answerBubbles.delete(id);
|
|
2747
|
+
return ack;
|
|
2748
|
+
}
|
|
2749
|
+
}
|
|
2750
|
+
catch (e) {
|
|
2751
|
+
const ack = e && typeof e === "object" ? e : undefined;
|
|
2752
|
+
// Only a structured non-zero server ACK proves the stream frame was
|
|
2753
|
+
// rejected. Timeout/disconnect errors are ambiguous: standalone retry
|
|
2754
|
+
// could publish the same final answer twice, so let the caller persist
|
|
2755
|
+
// `uncertain` and require a human resend instead.
|
|
2756
|
+
const rejected = !!ack && typeof ack.headers === "object" && !!ack.headers && typeof ack.errcode === "number" && ack.errcode !== 0 && typeof ack.errmsg === "string";
|
|
2757
|
+
if (!rejected && !standalone) {
|
|
2758
|
+
answerBubbles.delete(id);
|
|
2759
|
+
throw e;
|
|
2760
|
+
}
|
|
2761
|
+
if (!rejected) {
|
|
2762
|
+
log.warn({ id, err: errText(e) }, "answer transfer progress close acknowledgement uncertain; continuing with the one standalone terminal explanation");
|
|
2763
|
+
// In standalone mode this stream only closes progress; it never
|
|
2764
|
+
// carries the final body, so sending the terminal explanation once
|
|
2765
|
+
// remains necessary even when progress-close ACK is ambiguous.
|
|
2766
|
+
}
|
|
2767
|
+
else {
|
|
2768
|
+
log.warn({ id, err: errText(e), errcode: ack.errcode }, "answer transfer stream was explicitly rejected; standalone fallback");
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
}
|
|
2772
|
+
const ack = frozen ? await deps.sendFrozen(channel, content)
|
|
2773
|
+
: await client.sendMessage(channel.replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content } });
|
|
2774
|
+
answerBubbles.delete(id);
|
|
2775
|
+
return ack;
|
|
2776
|
+
};
|
|
2454
2777
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
2455
2778
|
// raw=true skips withTagHeader (used when content already contains the linked tag header).
|
|
2456
2779
|
// WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
|
|
@@ -2492,6 +2815,7 @@ export const startMirror = (deps) => {
|
|
|
2492
2815
|
a.briefHadTool = false;
|
|
2493
2816
|
a.briefConcluded = false;
|
|
2494
2817
|
a.briefLastText = undefined;
|
|
2818
|
+
a.cotLastSent = undefined;
|
|
2495
2819
|
clearCot(a); // turn 级状态 —— 进度行跟着气泡走, 不能跨 turn 残留
|
|
2496
2820
|
log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
|
|
2497
2821
|
};
|
|
@@ -2525,6 +2849,7 @@ export const startMirror = (deps) => {
|
|
|
2525
2849
|
const openingOf = (a) => {
|
|
2526
2850
|
const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
|
|
2527
2851
|
const env = t?.env;
|
|
2852
|
+
a.activeEventId = env?.eventId;
|
|
2528
2853
|
if (!env)
|
|
2529
2854
|
return undefined;
|
|
2530
2855
|
const home = baseOfKey(a.target);
|
|
@@ -2586,7 +2911,7 @@ export const startMirror = (deps) => {
|
|
|
2586
2911
|
return undefined;
|
|
2587
2912
|
};
|
|
2588
2913
|
/** 本轮落 turn 记录的频道字段。 */
|
|
2589
|
-
const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker });
|
|
2914
|
+
const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker, eventId: a.activeEventId });
|
|
2590
2915
|
/** 私聊轮: 回复只落 turn 记录。 */
|
|
2591
2916
|
const isPrivate = (a) => a.channel === "";
|
|
2592
2917
|
/** 本轮回复该推去的 chatId。 */
|
|
@@ -2612,9 +2937,9 @@ export const startMirror = (deps) => {
|
|
|
2612
2937
|
};
|
|
2613
2938
|
/** 露面的这一行认领它的印章, 先注入的先认。CLI 把排队的几句并成一行时, 包含在里面的都算
|
|
2614
2939
|
* 露过面, 出处取第一枚。认不出 = undefined。 */
|
|
2615
|
-
const takeSeal = (a, said, pick = () => true) => {
|
|
2940
|
+
const takeSeal = (a, said, pick = () => true, eventId) => {
|
|
2616
2941
|
const t = said.trim();
|
|
2617
|
-
const hit = (x) => pick(x) && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k)));
|
|
2942
|
+
const hit = (x) => pick(x) && (eventId ? x.eventId === eventId : !x.eventId && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k))));
|
|
2618
2943
|
const seals = a.seals ?? [];
|
|
2619
2944
|
const hits = seals.filter(hit);
|
|
2620
2945
|
// CLI 把排队的几句并成一行, 这一轮只能有一个去向: 里面有私聊的那句就是私聊 —— 它的答复不能
|
|
@@ -2635,7 +2960,11 @@ export const startMirror = (deps) => {
|
|
|
2635
2960
|
* `at` = 那一行落盘的时刻: 排队的那句从这一刻才开始被处理, 轮次的起点以它为准; 闲时当场接上就是投递那一刻。 */
|
|
2636
2961
|
const activateDispatch = (a, s, at = s.at) => {
|
|
2637
2962
|
const q = s.turn;
|
|
2963
|
+
if (a.briefTurnId && a.briefTurnId !== q.turnId)
|
|
2964
|
+
closeBriefTurn(a);
|
|
2965
|
+
a.activeEventId = s.eventId;
|
|
2638
2966
|
s.activated = true;
|
|
2967
|
+
a.activePeerTurn = undefined;
|
|
2639
2968
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
2640
2969
|
a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
|
|
2641
2970
|
a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
|
|
@@ -2645,9 +2974,7 @@ export const startMirror = (deps) => {
|
|
|
2645
2974
|
a.pendingBriefQuery = undefined;
|
|
2646
2975
|
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
|
|
2647
2976
|
consumeChannel(a, true, undefined);
|
|
2648
|
-
recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: at, ...channelFields(a) });
|
|
2649
|
-
if (a.briefTurnId)
|
|
2650
|
-
closeBriefTurn(a);
|
|
2977
|
+
recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: (s.eventId ? deps.events?.get(s.eventId)?.originalAt : undefined) ?? at, ...channelFields(a) });
|
|
2651
2978
|
openBriefTurn(a, q);
|
|
2652
2979
|
};
|
|
2653
2980
|
/** 一句插话记进它插进的那一轮: 出处、频道、发话人都取它自己的印章 (没写频道 = 在 home 里开口)。 */
|
|
@@ -2655,16 +2982,21 @@ export const startMirror = (deps) => {
|
|
|
2655
2982
|
/** `attachment.queued_command` 露面: 那句被正在跑的这一轮中途吃进去了。认领它的印章记成插话。
|
|
2656
2983
|
* 人从聊天里说的那句 (印章带预建的轮) 不在这里认: 它已有自己的轮次记录。认不出 (人在 CLI 里
|
|
2657
2984
|
* 忙时敲的) 不记 —— 说不清是谁、在哪个频道说的。 */
|
|
2658
|
-
const interject = (a, said, ts) => {
|
|
2985
|
+
const interject = (a, said, ts, eventId) => {
|
|
2659
2986
|
if (!a.briefTurnId)
|
|
2660
2987
|
return;
|
|
2661
|
-
const s = takeSeal(a, said, (x) => !x.turn);
|
|
2988
|
+
const s = takeSeal(a, said, (x) => !x.turn, eventId);
|
|
2662
2989
|
if (s)
|
|
2663
2990
|
injectInto(a.briefTurnId, a, s, ts);
|
|
2664
2991
|
};
|
|
2665
2992
|
/** 一行注入露面了: 把它的印章落成下一轮的出处 / 频道。返回认领到的印章。`ts` = 那一行落盘的时刻。 */
|
|
2666
|
-
const bindLine = (a, said, ts) => {
|
|
2667
|
-
const
|
|
2993
|
+
const bindLine = (a, said, ts, eventId) => {
|
|
2994
|
+
const recovered = eventId ? deps.events?.get(eventId) : undefined;
|
|
2995
|
+
const s = takeSeal(a, said, undefined, eventId) ?? (recovered?.target === a.target ? {
|
|
2996
|
+
eventId, keys: [], at: recovered.at, query: recovered.payload.text, channel: recovered.channel,
|
|
2997
|
+
speaker: recovered.payload.human ? recovered.speaker : undefined, from: recovered.payload.peer?.from,
|
|
2998
|
+
fresh: recovered.priority === "normal" || idleVerdict(a.target) === true,
|
|
2999
|
+
} : undefined);
|
|
2668
3000
|
if (!s)
|
|
2669
3001
|
return undefined;
|
|
2670
3002
|
if (s.turn) {
|
|
@@ -2680,19 +3012,28 @@ export const startMirror = (deps) => {
|
|
|
2680
3012
|
}
|
|
2681
3013
|
if (a.briefTurnId)
|
|
2682
3014
|
closeBriefTurn(a);
|
|
3015
|
+
a.activeEventId = s.eventId;
|
|
2683
3016
|
const now = Date.now();
|
|
3017
|
+
a.activePeerTurn = s.from?.kind === "peer" ? s.from.turn : undefined;
|
|
2684
3018
|
a.pendingFrom = s.from ? { from: s.from, at: now } : undefined;
|
|
2685
3019
|
a.pendingOrigin = s.origin ? { origin: s.origin, at: now } : undefined;
|
|
2686
3020
|
// 没写频道的注入 (graph 步骤 / 手动 inject) 等同在 home 里开口: 印章就是全部出处, 不沿用上一轮的。
|
|
2687
|
-
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), at: now };
|
|
3021
|
+
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: now };
|
|
2688
3022
|
a.pendingBriefQuery = s.query;
|
|
2689
3023
|
a.pendingBriefAt = ts;
|
|
3024
|
+
if (!cfg.wrc.mirror.brief) {
|
|
3025
|
+
consumeChannel(a, false, consumeFrom(a));
|
|
3026
|
+
if (a.liveStream && !a.liveStream.closed)
|
|
3027
|
+
a.liveStream.dest = s.channel ?? baseOfKey(a.target);
|
|
3028
|
+
}
|
|
2690
3029
|
return s;
|
|
2691
3030
|
};
|
|
2692
|
-
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
|
|
3031
|
+
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker, eventId) => {
|
|
2693
3032
|
const turnId = newTurnId();
|
|
2694
3033
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2695
|
-
const bubble = { frame, streamId, done: false };
|
|
3034
|
+
const bubble = { frame, streamId, done: false, expires: (eventId ? deps.events?.get(eventId)?.at ?? Date.now() : Date.now()) + HARD_TIMEOUT_MS };
|
|
3035
|
+
if (eventId)
|
|
3036
|
+
deps.events?.bind(eventId, turnId);
|
|
2696
3037
|
const q = { turnId, bubble, isSlash };
|
|
2697
3038
|
// 微信帧不挂: 没有 stream 窗口要赶, 气泡等终句来了再一次发出 (见 openStream 同一条)。
|
|
2698
3039
|
if (!isWxFrame(frame))
|
|
@@ -2703,29 +3044,23 @@ export const startMirror = (deps) => {
|
|
|
2703
3044
|
const live = a.briefBubble === bubble && !a.briefConcluded;
|
|
2704
3045
|
void finishBubble(a, bubble, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
|
|
2705
3046
|
}, HARD_TIMEOUT_MS);
|
|
2706
|
-
const seal = { keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
|
|
3047
|
+
const seal = { eventId, keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
|
|
2707
3048
|
// 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
|
|
2708
3049
|
// 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
|
|
2709
3050
|
// 先建好, ack 里的链接点得开。斜杠命令的行未必露面, 当场接。
|
|
2710
3051
|
const queued = !isSlash && idleVerdict(a.target) === false;
|
|
2711
3052
|
pushSeal(a, seal);
|
|
2712
3053
|
if (queued)
|
|
2713
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, channel: channel ?? baseOfKey(a.target), speaker });
|
|
3054
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, eventId, createdAt: eventId ? deps.events?.get(eventId)?.originalAt : undefined, channel: channel ?? baseOfKey(a.target), speaker });
|
|
2714
3055
|
else
|
|
2715
3056
|
activateDispatch(a, seal);
|
|
2716
|
-
//
|
|
2717
|
-
// closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
|
|
2718
|
-
// 覆盖消息)。旧 turn 在 CLI 侧的剩余产出自然流入新气泡 —— "新回复走最新气泡"
|
|
2719
|
-
// 正是这个语义。代价是旧 turn 页提前标完、尾部 item 记到新 turn 名下, 但远小于
|
|
2720
|
-
// 排队的代价: 排队 turn 的 frame 在前一个长 turn 期间 (可达数分钟) 过期, 激活后
|
|
2721
|
-
// replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。所以只在前面真有别人的行排着时
|
|
2722
|
-
// 才排 (见上): 接上时气泡若已过期, 正文走 standalone (concludeBriefTurn)。
|
|
3057
|
+
// Durable normal inputs arrive only after the preceding full turn; expired bubbles use the final outbox standalone path.
|
|
2723
3058
|
// ack 即详情链接。URL 的三个入参 (turnId / target / host) 在收消息这一刻全部已知 ——
|
|
2724
3059
|
// turnId 是本地生成的, 详情页记录也已在 recordTurnStart 建好, 所以不必等 CLI 产出,
|
|
2725
3060
|
// 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
|
|
2726
3061
|
// 尾随 `…` 表示"正文还没到", 正文到位时整条内容被 `链接 正文` 覆盖。
|
|
2727
3062
|
try {
|
|
2728
|
-
await client.replyStream(frame, streamId, briefLine(a, turnId, "…", channel), false);
|
|
3063
|
+
await (bubble.pending = client.replyStream(frame, streamId, briefLine(a, turnId, "…", channel), false));
|
|
2729
3064
|
}
|
|
2730
3065
|
catch (e) {
|
|
2731
3066
|
log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
|
|
@@ -2738,8 +3073,11 @@ export const startMirror = (deps) => {
|
|
|
2738
3073
|
if (a.channelSeeded)
|
|
2739
3074
|
return false;
|
|
2740
3075
|
const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
2741
|
-
const
|
|
2742
|
-
const
|
|
3076
|
+
const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
|
|
3077
|
+
const said = head(opening?.text);
|
|
3078
|
+
const eventId = opening?.env?.eventId;
|
|
3079
|
+
const r = openTurnsOf(a.target, a.sessionId).find((t) => eventId ? t.eventId === eventId : !!said && head(t.userQuery) === said);
|
|
3080
|
+
a.activeEventId = eventId;
|
|
2743
3081
|
if (!r)
|
|
2744
3082
|
return false;
|
|
2745
3083
|
a.channelSeeded = true;
|
|
@@ -2749,6 +3087,7 @@ export const startMirror = (deps) => {
|
|
|
2749
3087
|
a.channel = r.channel === undefined || r.channel === baseOfKey(a.target) ? undefined : r.channel;
|
|
2750
3088
|
a.speaker = r.speaker;
|
|
2751
3089
|
a.convFrom = r.from;
|
|
3090
|
+
a.activePeerTurn = r.from?.kind === "peer" ? r.from.turn : undefined;
|
|
2752
3091
|
a.replyTo = publicPeer(r.from);
|
|
2753
3092
|
a.turnFromChat = a.turnFromChat ?? true;
|
|
2754
3093
|
a.briefTurnId = r.id;
|
|
@@ -2784,6 +3123,8 @@ export const startMirror = (deps) => {
|
|
|
2784
3123
|
const from = fresh ?? a.convFrom;
|
|
2785
3124
|
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: query || opened || undefined, origin: consumeOrigin(a), from, ...channelFields(a), ...(query && at ? { createdAt: at } : {}) });
|
|
2786
3125
|
a.briefTurnId = turnId;
|
|
3126
|
+
if (a.activeEventId)
|
|
3127
|
+
deps.events?.bind(a.activeEventId, turnId);
|
|
2787
3128
|
a.briefBubble = undefined;
|
|
2788
3129
|
a.briefIsSlash = false;
|
|
2789
3130
|
a.briefHadTool = false;
|
|
@@ -2826,25 +3167,26 @@ export const startMirror = (deps) => {
|
|
|
2826
3167
|
return;
|
|
2827
3168
|
if (!body.trim())
|
|
2828
3169
|
return;
|
|
2829
|
-
if (isDuplicateConclusion(a, body)) {
|
|
3170
|
+
if (!a.activeEventId && isDuplicateConclusion(a, body)) {
|
|
2830
3171
|
a.briefConcluded = true; // 本轮已"定稿", 只是不投递
|
|
2831
3172
|
log.info({ sessionId: a.sessionId, turnId, epoch: a.queryEpoch ?? 0 }, "brief: duplicate conclusion suppressed");
|
|
2832
3173
|
return;
|
|
2833
3174
|
}
|
|
3175
|
+
const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
|
|
3176
|
+
if (event)
|
|
3177
|
+
deps.events.result(event.id, body, finalId(event.id, event.channel, body), turnId);
|
|
3178
|
+
if (!answerSilent(a) && !turnSilent(a) && (!event || eventFinalGate(event))) {
|
|
3179
|
+
if (event)
|
|
3180
|
+
recoverEventFinal(deps.events.get(event.id), a.briefBubble);
|
|
3181
|
+
else
|
|
3182
|
+
queueFinal(a.target, turnId, a.channel ?? baseOfKey(a.target), body, undefined, a.briefBubble);
|
|
3183
|
+
}
|
|
3184
|
+
// The durable result/outbox must exist before this in-memory dedup bit advances.
|
|
2834
3185
|
a.briefConcluded = true;
|
|
2835
3186
|
a.lastConcludedBody = body;
|
|
2836
3187
|
a.lastConcludedEpoch = a.queryEpoch ?? 0;
|
|
2837
3188
|
a.lastConcludedAt = Date.now();
|
|
2838
|
-
|
|
2839
|
-
void finishBriefBubble(a, briefLine(a, turnId, linkTags(a.target, body)), true);
|
|
2840
|
-
}
|
|
2841
|
-
else if (turnSilent(a)) {
|
|
2842
|
-
// CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
|
|
2843
|
-
log.info({ sessionId: a.sessionId, turnId }, "brief: CLI-origin conclusion kept out of chat");
|
|
2844
|
-
}
|
|
2845
|
-
else {
|
|
2846
|
-
sendStandalone(a, body); // 详情链接由 withLinkedTag 统一前缀, 不在这里拼
|
|
2847
|
-
}
|
|
3189
|
+
clearCot(a);
|
|
2848
3190
|
// 只保留当前 turn: 其余仍开着的 turn 记录是漏收的 close, 一并扫掉。
|
|
2849
3191
|
recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: [turnId] });
|
|
2850
3192
|
};
|
|
@@ -2975,6 +3317,9 @@ export const startMirror = (deps) => {
|
|
|
2975
3317
|
: item.kind === "thinking" || item.kind === "text"
|
|
2976
3318
|
? item.body
|
|
2977
3319
|
: "";
|
|
3320
|
+
// A prompt (even an exact match) is not a child/parent-call identity. Late
|
|
3321
|
+
// children can reuse prompts after history eviction, so their content must
|
|
3322
|
+
// never enter a transferred answer bubble without a verifiable ID binding.
|
|
2978
3323
|
if (activity)
|
|
2979
3324
|
updateBriefProgress(a, `🤖 ${run.label} · ${activity}`);
|
|
2980
3325
|
};
|
|
@@ -3108,6 +3453,8 @@ export const startMirror = (deps) => {
|
|
|
3108
3453
|
return;
|
|
3109
3454
|
}
|
|
3110
3455
|
if (item.kind === "turn_end") {
|
|
3456
|
+
if (a.activeEventId && !item.soft)
|
|
3457
|
+
deps.events?.finished(a.activeEventId);
|
|
3111
3458
|
closeBriefTurn(a);
|
|
3112
3459
|
return;
|
|
3113
3460
|
}
|
|
@@ -3316,7 +3663,7 @@ export const startMirror = (deps) => {
|
|
|
3316
3663
|
if (item.kind === "user_text") {
|
|
3317
3664
|
// 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
|
|
3318
3665
|
// 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
|
|
3319
|
-
if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts))
|
|
3666
|
+
if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts, item.eventId))
|
|
3320
3667
|
return;
|
|
3321
3668
|
endKeepaliveSwallow(a);
|
|
3322
3669
|
// 对话边界 = 旧 turn 清算。上一 turn 派发而未返回的前台 Agent/Task 到此作废,
|
|
@@ -3328,17 +3675,23 @@ export const startMirror = (deps) => {
|
|
|
3328
3675
|
// 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
|
|
3329
3676
|
a.pendingBriefQuery = item.body;
|
|
3330
3677
|
a.pendingBriefAt = item.ts;
|
|
3678
|
+
a.activePeerTurn = undefined;
|
|
3679
|
+
a.pendingFrom = undefined;
|
|
3680
|
+
a.pendingChannel = undefined;
|
|
3681
|
+
a.activeEventId = item.eventId;
|
|
3331
3682
|
a.pendingFromCli = true;
|
|
3683
|
+
if (!cfg.wrc.mirror.brief)
|
|
3684
|
+
consumeChannel(a, true, undefined);
|
|
3332
3685
|
}
|
|
3333
3686
|
if (item.kind === "user_inject") {
|
|
3334
|
-
interject(a, item.said, item.ts);
|
|
3687
|
+
interject(a, item.said, item.ts, item.eventId);
|
|
3335
3688
|
return;
|
|
3336
3689
|
}
|
|
3337
3690
|
// 还有一轮等着懒建 (peer / graph 注入先记了 pendingBriefQuery) 就换掉它; 否则这句话
|
|
3338
3691
|
// 就是已开好的那一轮 (IM 侧说的, startBriefTurn 在注入前建好)。
|
|
3339
3692
|
if (item.kind === "user_query") {
|
|
3340
3693
|
// 同伴 / 定时 / graph 注入的那一行: 问话就是印章里的原话 (不带信封)。
|
|
3341
|
-
const sealed = bindLine(a, item.said, item.ts);
|
|
3694
|
+
const sealed = bindLine(a, item.said, item.ts, item.eventId);
|
|
3342
3695
|
if (sealed && !sealed.turn)
|
|
3343
3696
|
return;
|
|
3344
3697
|
if (a.pendingBriefQuery !== undefined || !a.briefTurnId) {
|
|
@@ -3413,6 +3766,7 @@ export const startMirror = (deps) => {
|
|
|
3413
3766
|
// 的配置下, 人在 CLI 敲的那一行就这么原样回显进了群, 正好破了 chatOriginOnly。
|
|
3414
3767
|
if (item.kind === "user_text" && (item.quiet || cfg.wrc.mirror.brief))
|
|
3415
3768
|
return;
|
|
3769
|
+
updateAnswerProgress(a, item);
|
|
3416
3770
|
// thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
|
|
3417
3771
|
// 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
|
|
3418
3772
|
// 那就变回已下线的 thinkStyle 了。
|
|
@@ -3560,6 +3914,21 @@ export const startMirror = (deps) => {
|
|
|
3560
3914
|
}
|
|
3561
3915
|
}
|
|
3562
3916
|
}
|
|
3917
|
+
if (!cfg.wrc.mirror.brief && item.kind === "text" && item.final !== false && a.activeEventId && deps.events) {
|
|
3918
|
+
const e = deps.events.get(a.activeEventId);
|
|
3919
|
+
if (e) {
|
|
3920
|
+
// Soft final text waits for transcript/idle evidence; it must not escape through the legacy stream.
|
|
3921
|
+
if (item.final !== true)
|
|
3922
|
+
return;
|
|
3923
|
+
const turn = a.liveStream?.turnId ?? e.id;
|
|
3924
|
+
deps.events.result(e.id, item.body, finalId(e.id, e.channel, item.body), turn);
|
|
3925
|
+
if (eventFinalGate(e))
|
|
3926
|
+
recoverEventFinal(deps.events.get(e.id));
|
|
3927
|
+
return;
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
if (!cfg.wrc.mirror.brief && isPrivate(a))
|
|
3931
|
+
return;
|
|
3563
3932
|
// /goal mode overrides everything below: the session-scoped Stop hook
|
|
3564
3933
|
// self-drives the model with no terminal stop_reason, so turn_end never fires
|
|
3565
3934
|
// and brief-mode would swallow the entire run into the turn store (silent
|
|
@@ -3864,7 +4233,9 @@ export const startMirror = (deps) => {
|
|
|
3864
4233
|
};
|
|
3865
4234
|
// `panes` = boot 快照; 给了就不再为每个 principal 单独问 tmux。lazy 调用方
|
|
3866
4235
|
// 不传, 维持原来的逐个探活。
|
|
3867
|
-
const restoreFromStore = async (principal, panes) => {
|
|
4236
|
+
const restoreFromStore = async (principal, panes, permitted) => {
|
|
4237
|
+
if (permitted && !permitted())
|
|
4238
|
+
return undefined;
|
|
3868
4239
|
const rec = deps.store.get(principal);
|
|
3869
4240
|
if (!rec)
|
|
3870
4241
|
return undefined;
|
|
@@ -3882,6 +4253,8 @@ export const startMirror = (deps) => {
|
|
|
3882
4253
|
// unrelated newest-in-cwd file is exactly the cross-wire we must avoid.
|
|
3883
4254
|
const storedPane = (rec.tmuxPane ?? "").trim();
|
|
3884
4255
|
const livePane = storedPane && (panes ? panes.has(storedPane) : await tmuxPaneAlive(storedPane)) ? storedPane : "";
|
|
4256
|
+
if (permitted && !permitted())
|
|
4257
|
+
return undefined;
|
|
3885
4258
|
if (!existsSync(jsonlAbs)) {
|
|
3886
4259
|
// First: the SAME-sid transcript may have merely relocated to a sibling
|
|
3887
4260
|
// project dir (Claude Code EnterWorktree/ExitWorktree moved it while the
|
|
@@ -3937,6 +4310,8 @@ export const startMirror = (deps) => {
|
|
|
3937
4310
|
const paneCwd = panes
|
|
3938
4311
|
? (panes.get(livePane) ?? "")
|
|
3939
4312
|
: (await runTmux(["display-message", "-p", "-t", livePane, "#{pane_current_path}"])).stdout.trim();
|
|
4313
|
+
if (permitted && !permitted())
|
|
4314
|
+
return undefined;
|
|
3940
4315
|
if (paneCwd) {
|
|
3941
4316
|
// Encode under the backend that owns the bound transcript — comparing
|
|
3942
4317
|
// with the primary dialect would report a phantom drift for every
|
|
@@ -3956,6 +4331,8 @@ export const startMirror = (deps) => {
|
|
|
3956
4331
|
}
|
|
3957
4332
|
}
|
|
3958
4333
|
}
|
|
4334
|
+
if (permitted && !permitted())
|
|
4335
|
+
return undefined;
|
|
3959
4336
|
const r = attach({
|
|
3960
4337
|
sessionId: rec.sessionId,
|
|
3961
4338
|
jsonlPath: jsonlAbs,
|
|
@@ -4542,10 +4919,10 @@ export const startMirror = (deps) => {
|
|
|
4542
4919
|
// `header` folds the caller's ack ("created") into this same bubble — /new
|
|
4543
4920
|
// must land as exactly ONE WeCom message, not card + separate reply.
|
|
4544
4921
|
// 会话边界回执一律不携带 cwd 信息 (📂 项目行 / 下次切换), 需要时 `/pwd` 可查。
|
|
4545
|
-
// slashAckFirstLine 进一步省去 💡 tip, 只剩首行 ack。
|
|
4546
4922
|
const pushProjectInfo = (target, header) => {
|
|
4547
|
-
|
|
4548
|
-
|
|
4923
|
+
if (!header)
|
|
4924
|
+
return;
|
|
4925
|
+
const md = header;
|
|
4549
4926
|
const a = byTarget.get(target);
|
|
4550
4927
|
if (a) {
|
|
4551
4928
|
sendStandalone(a, md);
|
|
@@ -4606,12 +4983,13 @@ export const startMirror = (deps) => {
|
|
|
4606
4983
|
return undefined;
|
|
4607
4984
|
}
|
|
4608
4985
|
};
|
|
4609
|
-
/** 死 pane
|
|
4610
|
-
* 没记 (老绑定 / CLI 默认) 的管家补上管家档。 */
|
|
4986
|
+
/** 死 pane 重生: 管家重新取 router 档位, 普通 wizard 沿用绑定模型。 */
|
|
4611
4987
|
const respawnSpec = (a) => {
|
|
4612
4988
|
return resumedStewardSpec(cfg, a.target, backendForPath(a.jsonlPath).name, a);
|
|
4613
4989
|
};
|
|
4614
4990
|
const newSession = async (target, windowName, cli, opts) => {
|
|
4991
|
+
if (opts?.guard && !opts.guard())
|
|
4992
|
+
return { ok: false, reason: "spawn guard withdrew permission" };
|
|
4615
4993
|
const prev = byTarget.get(target);
|
|
4616
4994
|
// Resolution precedence (all chat-scoped except the running-cwd fallback):
|
|
4617
4995
|
// base.pending > caller.pending > target.running > base.running > default
|
|
@@ -4637,8 +5015,8 @@ export const startMirror = (deps) => {
|
|
|
4637
5015
|
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath;
|
|
4638
5016
|
const base = baseOfKey(target);
|
|
4639
5017
|
const effCli = resolveCliBackend(cfg.wrc).name;
|
|
4640
|
-
// 管家 (群的默认会话)
|
|
4641
|
-
//
|
|
5018
|
+
// 管家 (群的默认会话) 固定按 models.router 起, 压过显式模型及绑定里记下的 effort;
|
|
5019
|
+
// `/new`、换目录、交接重开都是让改过的档位生效的那一下。
|
|
4642
5020
|
// 已在跑的管家不动 (换模型会让缓存整份重读); 死 pane 重生见 respawnSpec。
|
|
4643
5021
|
const backendChanged = Boolean(boundPath && effCli && backendForPath(expandHome(boundPath)).name !== effCli);
|
|
4644
5022
|
const { model: effModel, effort: effEffort } = newStewardSpec(cfg, target, effCli, opts, backendChanged ? undefined : prev?.effort ?? deps.store.get(target)?.effort);
|
|
@@ -4666,6 +5044,12 @@ export const startMirror = (deps) => {
|
|
|
4666
5044
|
inFlightSids.delete(freshSid);
|
|
4667
5045
|
return { ok: false, reason: r.reason };
|
|
4668
5046
|
}
|
|
5047
|
+
if (opts?.guard && !opts.guard()) {
|
|
5048
|
+
inFlightSids.delete(freshSid);
|
|
5049
|
+
if (r.tmuxPane)
|
|
5050
|
+
await runTmux(["kill-pane", "-t", r.tmuxPane]);
|
|
5051
|
+
return { ok: false, reason: "spawn completed after permission withdrawal; binding withheld" };
|
|
5052
|
+
}
|
|
4669
5053
|
const att = attach({
|
|
4670
5054
|
sessionId: r.sessionId,
|
|
4671
5055
|
jsonlPath: r.jsonlPath,
|
|
@@ -4783,14 +5167,16 @@ export const startMirror = (deps) => {
|
|
|
4783
5167
|
return undefined;
|
|
4784
5168
|
};
|
|
4785
5169
|
const cloneSession = async (args) => {
|
|
4786
|
-
|
|
5170
|
+
if (args.guard && !args.guard("paste"))
|
|
5171
|
+
return { ok: false, reason: "spawn guard withdrew permission", inherited: false };
|
|
5172
|
+
const p = byTarget.get(args.parent) ?? (await restoreFromStore(args.parent, undefined, () => !args.guard || args.guard()));
|
|
4787
5173
|
// 继承上下文要求父亲有一个活着的 transcript, 且分身必须待在同一个项目目录 ——
|
|
4788
5174
|
// `--resume` 是按项目目录寻址 session 的。调用方点名换目录 = 明确放弃继承。
|
|
4789
5175
|
const sameCwd = !args.cwd?.trim() || expandHome(args.cwd.trim()) === (p?.runningCwd ?? "");
|
|
4790
5176
|
const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
|
|
4791
5177
|
if (!canInherit) {
|
|
4792
5178
|
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,
|
|
5179
|
+
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
5180
|
});
|
|
4795
5181
|
return { ...r, inherited: false };
|
|
4796
5182
|
}
|
|
@@ -4802,6 +5188,8 @@ export const startMirror = (deps) => {
|
|
|
4802
5188
|
const parentCli = backendForPath(parent.jsonlPath).name;
|
|
4803
5189
|
const model = args.model?.trim() || parent.model || lastModel(parent.jsonlPath);
|
|
4804
5190
|
const effort = args.effort ?? parseEffort(parent.effort);
|
|
5191
|
+
if (args.guard && !args.guard("paste"))
|
|
5192
|
+
return { ok: false, reason: "spawn guard withdrew permission", inherited: false };
|
|
4805
5193
|
const r = await spawnTmuxClaude({
|
|
4806
5194
|
cfg,
|
|
4807
5195
|
log: lg,
|
|
@@ -4825,7 +5213,7 @@ export const startMirror = (deps) => {
|
|
|
4825
5213
|
const boot = await inject({
|
|
4826
5214
|
text: hello, images: [], cfg, log: lg,
|
|
4827
5215
|
sessionId: parentCli === "codex" ? r.sessionId : parent.sessionId, jsonlPath: parentCli === "codex" ? r.jsonlPath : parent.jsonlPath,
|
|
4828
|
-
tmuxTarget: r.tmuxPane, freshSpawn: true,
|
|
5216
|
+
tmuxTarget: r.tmuxPane, freshSpawn: true, guard: args.guard,
|
|
4829
5217
|
});
|
|
4830
5218
|
if (!boot.ok) {
|
|
4831
5219
|
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
@@ -4841,6 +5229,12 @@ export const startMirror = (deps) => {
|
|
|
4841
5229
|
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4842
5230
|
return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
|
|
4843
5231
|
}
|
|
5232
|
+
if (args.guard && !args.guard("submit")) {
|
|
5233
|
+
inFlightSids.delete(fork.sessionId);
|
|
5234
|
+
// The bootstrap may already have executed; withhold binding, report the
|
|
5235
|
+
// unconfirmed birth to its caller instead of claiming cancellation success.
|
|
5236
|
+
return { ok: false, reason: "fork executed after permission withdrawal; reconcile pane manually", inherited: false };
|
|
5237
|
+
}
|
|
4844
5238
|
const att = attach({
|
|
4845
5239
|
sessionId: fork.sessionId,
|
|
4846
5240
|
jsonlPath: fork.jsonlPath,
|
|
@@ -5128,7 +5522,16 @@ export const startMirror = (deps) => {
|
|
|
5128
5522
|
const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
|
|
5129
5523
|
return live?.status === "idle" && hanging(target, live.sessionId);
|
|
5130
5524
|
};
|
|
5131
|
-
const idleNow = async (target) =>
|
|
5525
|
+
const idleNow = async (target) => {
|
|
5526
|
+
const registry = idleVerdict(target);
|
|
5527
|
+
if (registry !== undefined)
|
|
5528
|
+
return registry;
|
|
5529
|
+
const pane = paneOf(target);
|
|
5530
|
+
if (!pane || !(await tmuxPaneAlive(pane)))
|
|
5531
|
+
return false;
|
|
5532
|
+
const { cap, box } = await readPaneInput(pane);
|
|
5533
|
+
return !!cap.trim() && box.known && !paneIsBusy(cap) && !isModalPane(cap).modal;
|
|
5534
|
+
};
|
|
5132
5535
|
// 等到它闲下来, 事件驱动: 注册表目录一有写入就重判, 翻成 idle 的那一刻即返回。
|
|
5133
5536
|
// 兜底轮询只防 watch 漏事件 (以及悬着的审批被点掉后 status 没再动的边角)。
|
|
5134
5537
|
const IDLE_SAFETY_POLL_MS = 1000;
|
|
@@ -5340,7 +5743,9 @@ export const startMirror = (deps) => {
|
|
|
5340
5743
|
catch { /* unreadable tail */ }
|
|
5341
5744
|
return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), cacheAt: cacheTouchMs(jsonlPath) };
|
|
5342
5745
|
};
|
|
5343
|
-
const fireKeepalive = async (a) => {
|
|
5746
|
+
const fireKeepalive = async (a, guard) => {
|
|
5747
|
+
if (!guard())
|
|
5748
|
+
return;
|
|
5344
5749
|
const kc = cfg.wrc.mirror.keepalive;
|
|
5345
5750
|
// The SAME full instruction every round: a bare "ping" used to be sent from round 2 on
|
|
5346
5751
|
// (the instruction was already in context), but the model reads a bare "ping"
|
|
@@ -5354,16 +5759,23 @@ export const startMirror = (deps) => {
|
|
|
5354
5759
|
// ping turn (reply included). The 60s fail-safe clears the quiet window if
|
|
5355
5760
|
// the turn somehow never emits turn_end, so a later real turn is never muted;
|
|
5356
5761
|
// 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
5762
|
const r = await inject({
|
|
5366
5763
|
text, images: [], cfg,
|
|
5764
|
+
guard,
|
|
5765
|
+
requireIdle: () => idleVerdict(a.target) === true,
|
|
5766
|
+
beforeInput: () => {
|
|
5767
|
+
// A queued heartbeat must not mute the peer turn that won the lane.
|
|
5768
|
+
// Drain its tail first, then open swallow only at permitted physical input.
|
|
5769
|
+
a.tail.drain();
|
|
5770
|
+
rememberInject(text);
|
|
5771
|
+
a.keepaliveQuiet = setTimeout(() => {
|
|
5772
|
+
a.keepaliveQuiet = undefined;
|
|
5773
|
+
if (a.keepaliveTurnId) {
|
|
5774
|
+
recordTurnClose(a.keepaliveTurnId);
|
|
5775
|
+
a.keepaliveTurnId = undefined;
|
|
5776
|
+
}
|
|
5777
|
+
}, 60_000);
|
|
5778
|
+
},
|
|
5367
5779
|
log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
|
|
5368
5780
|
sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
|
|
5369
5781
|
});
|
|
@@ -5374,6 +5786,8 @@ export const startMirror = (deps) => {
|
|
|
5374
5786
|
log.warn({ target: a.target, reason: r.reason }, "keepalive: inject failed");
|
|
5375
5787
|
return;
|
|
5376
5788
|
}
|
|
5789
|
+
if (!guard())
|
|
5790
|
+
return;
|
|
5377
5791
|
const tokens = lastContextTokens(a.jsonlPath);
|
|
5378
5792
|
const round = a.keepalive ? (a.keepalive.round += 1) : 1;
|
|
5379
5793
|
log.info({ target: a.target, tokens, round, totalRounds: Math.max(1, kc.rounds) }, "keepalive: ping injected");
|
|
@@ -5389,116 +5803,132 @@ export const startMirror = (deps) => {
|
|
|
5389
5803
|
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
|
|
5390
5804
|
}
|
|
5391
5805
|
};
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5806
|
+
// One pool for both maintenance paths: slow panes do not stall other panes,
|
|
5807
|
+
// and a keepalive never overlaps a picker scan/automatic press on the same pane.
|
|
5808
|
+
const maintenance = createKeyedScheduler(4);
|
|
5809
|
+
const maintenanceValid = (a, pane, sid, gen, signal) => !signal.aborted && byTarget.get(a.target) === a && a.tmuxPane === pane && a.sessionId === sid
|
|
5810
|
+
&& (a.injectGen ?? 0) === gen && !a.migrationWatcher;
|
|
5811
|
+
const keepaliveOne = async (a, pane, sid, gen, signal) => {
|
|
5812
|
+
const valid = () => maintenanceValid(a, pane, sid, gen, signal) && cfg.wrc.mirror.keepalive.enabled;
|
|
5813
|
+
if (!valid())
|
|
5396
5814
|
return;
|
|
5397
|
-
|
|
5815
|
+
const kc = cfg.wrc.mirror.keepalive;
|
|
5816
|
+
const pingSigs = [normAssistant(kc.ping).slice(0, 40)].filter((s) => s.length > 0);
|
|
5817
|
+
const now = Date.now();
|
|
5818
|
+
if (a.keepaliveDisabled)
|
|
5819
|
+
return; // sticky spawn-time opt-out — never pings
|
|
5820
|
+
if (!a.tmuxPane)
|
|
5821
|
+
return; // spawn-mode: no live pane to warm
|
|
5822
|
+
if (a.migrationWatcher)
|
|
5823
|
+
return; // session rotating — skip
|
|
5824
|
+
if (a.liveStream && !a.liveStream.closed)
|
|
5825
|
+
return; // mid typewriter — don't inject
|
|
5826
|
+
if (!(await tmuxPaneAlive(pane)) || !valid())
|
|
5827
|
+
return; // dead pane — nothing to keep alive
|
|
5828
|
+
let mtime = 0;
|
|
5398
5829
|
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);
|
|
5830
|
+
mtime = statSync(a.jsonlPath).mtimeMs;
|
|
5831
|
+
}
|
|
5832
|
+
catch {
|
|
5833
|
+
return;
|
|
5834
|
+
}
|
|
5835
|
+
if (!mtime)
|
|
5836
|
+
return;
|
|
5837
|
+
const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, pingSigs));
|
|
5838
|
+
// File mtime is only a cheap "did anything change → re-read the tail" gate.
|
|
5839
|
+
// The clocks themselves come from message turns, so metadata churn (which
|
|
5840
|
+
// bumps mtime but is not a turn) can never move them. `grewSinceLast` now
|
|
5841
|
+
// means a NEW message turn appeared — not that the file grew.
|
|
5842
|
+
const prevRealMs = k.lastRealMs;
|
|
5843
|
+
if (mtime > k.seenMtime + 1000) {
|
|
5844
|
+
const s = keepaliveStamps(a.jsonlPath, pingSigs);
|
|
5845
|
+
if (s.stamped) {
|
|
5846
|
+
k.lastMs = s.lastMs;
|
|
5847
|
+
k.lastRealMs = s.lastRealMs;
|
|
5495
5848
|
}
|
|
5849
|
+
else {
|
|
5850
|
+
k.lastMs = mtime;
|
|
5851
|
+
k.lastRealMs = mtime;
|
|
5852
|
+
} // backend without timestamps → fall back to mtime
|
|
5853
|
+
k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
|
|
5854
|
+
k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
|
|
5855
|
+
}
|
|
5856
|
+
k.seenMtime = mtime;
|
|
5857
|
+
// Round reset / real-activity re-anchor keys off REAL turns only — a
|
|
5858
|
+
// keepalive ping+pong advances lastMs but NOT lastRealMs, so the heartbeat
|
|
5859
|
+
// no longer reads as activity to itself (which was pinning round at 1/N).
|
|
5860
|
+
const grewSinceLast = k.lastRealMs > prevRealMs + 1000;
|
|
5861
|
+
const paneTail = await capturePaneTail(pane, 16);
|
|
5862
|
+
if (!valid())
|
|
5863
|
+
return;
|
|
5864
|
+
const busy = paneIsBusy(paneTail);
|
|
5865
|
+
if (k.pinging) {
|
|
5866
|
+
// Waiting for our own ping to land and settle: until a NEWER message turn
|
|
5867
|
+
// (the ping's own user line) appears and the pane goes idle, keep waiting.
|
|
5868
|
+
if (busy || k.lastMs <= k.pingMtime)
|
|
5869
|
+
return;
|
|
5870
|
+
k.pinging = false;
|
|
5871
|
+
k.settledAt = now;
|
|
5872
|
+
return;
|
|
5496
5873
|
}
|
|
5497
|
-
|
|
5498
|
-
|
|
5874
|
+
// A new real message turn (or a busy pane) re-anchors: reset the round
|
|
5875
|
+
// counter, and past the /stop grace lift a busy-resume. grewSinceLast is
|
|
5876
|
+
// message-based now, so `/stop`'s Esc settling writes (metadata, no new
|
|
5877
|
+
// turn) can no longer self-resume the pause — only a busy pane past grace,
|
|
5878
|
+
// or a WeCom inbound in dispatch, does.
|
|
5879
|
+
// Suppress the grewSinceLast reset for 30s after pinging settles — the
|
|
5880
|
+
// pong's tail flush can lag a tick and would otherwise fake real activity.
|
|
5881
|
+
const justSettled = k.settledAt > 0 && now - k.settledAt < 30_000;
|
|
5882
|
+
if (busy || (grewSinceLast && !justSettled)) {
|
|
5883
|
+
k.round = 0;
|
|
5884
|
+
k.settledAt = 0;
|
|
5885
|
+
if (a.keepaliveOff && busy && now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS) {
|
|
5886
|
+
a.keepaliveOff = false;
|
|
5887
|
+
persistPause(a);
|
|
5888
|
+
}
|
|
5889
|
+
if (busy)
|
|
5890
|
+
return;
|
|
5499
5891
|
}
|
|
5500
|
-
|
|
5501
|
-
|
|
5892
|
+
if (a.keepaliveOff)
|
|
5893
|
+
return; // paused by /stop until real activity returns
|
|
5894
|
+
// Cache clock = start of the newest answered request (TTL runs from there),
|
|
5895
|
+
// not the last turn's write time; no request on record → the turn clock.
|
|
5896
|
+
const sinceCache = now - (k.cacheAt || k.lastMs || mtime);
|
|
5897
|
+
// The TTL the session's cache entries were actually written with (1h on a
|
|
5898
|
+
// Claude Code subscription), not a guess: pinging at the 5min cadence
|
|
5899
|
+
// against a 1h entry re-reads the whole context ~14x per hour for nothing.
|
|
5900
|
+
const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
|
|
5901
|
+
const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
|
|
5902
|
+
if (sinceCache < idleTriggerMs)
|
|
5903
|
+
return; // cache still comfortably warm
|
|
5904
|
+
if (sinceCache >= ttlMs)
|
|
5905
|
+
return; // cache already cold — a ping would cold-rewrite for nothing
|
|
5906
|
+
if (sinceCache > KEEPALIVE_MAX_IDLE_MS)
|
|
5907
|
+
return; // idle past 10min (a long-TTL entry) — not worth warming
|
|
5908
|
+
if (k.round >= kc.rounds)
|
|
5909
|
+
return; // budget spent — let it go cold
|
|
5910
|
+
if (idleVerdict(a.target) !== true)
|
|
5911
|
+
return; // no confirmed idle → no heartbeat input
|
|
5912
|
+
k.pinging = true;
|
|
5913
|
+
k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
|
|
5914
|
+
await fireKeepalive(a, () => valid() && !a.keepaliveOff);
|
|
5915
|
+
};
|
|
5916
|
+
const keepaliveTick = () => {
|
|
5917
|
+
if (!cfg.wrc.mirror.keepalive.enabled)
|
|
5918
|
+
return;
|
|
5919
|
+
for (const a of byTarget.values()) {
|
|
5920
|
+
if (!a.tmuxPane || a.keepaliveDisabled)
|
|
5921
|
+
continue;
|
|
5922
|
+
const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
|
|
5923
|
+
void maintenance.schedule(`keepalive:${a.target}`, pane, (signal) => keepaliveOne(a, pane, sid, gen, signal))
|
|
5924
|
+
?.catch((e) => {
|
|
5925
|
+
if (maintenanceValid(a, pane, sid, gen, maintenance.signal)) {
|
|
5926
|
+
if (a.keepalive)
|
|
5927
|
+
a.keepalive.pinging = false;
|
|
5928
|
+
endKeepaliveSwallow(a);
|
|
5929
|
+
}
|
|
5930
|
+
log.warn({ target: a.target, err: e.message }, "keepalive tick failed");
|
|
5931
|
+
});
|
|
5502
5932
|
}
|
|
5503
5933
|
};
|
|
5504
5934
|
const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
|
|
@@ -5513,118 +5943,143 @@ export const startMirror = (deps) => {
|
|
|
5513
5943
|
// - 其它 → 把框转成标准审批卡发到本 chat (runMirrorPickerFlow), 点击后代按
|
|
5514
5944
|
// 对应选项; 本地先按掉则 moot 作废卡。代按/发卡都发 chat 回执留痕。
|
|
5515
5945
|
// WEZARD_PICKER_SENTINEL=0 整体关闭。
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
if (!cfg.approval.enabled)
|
|
5946
|
+
const pickerOne = async (a, pane, sid, gen, signal) => {
|
|
5947
|
+
const guard = () => maintenanceValid(a, pane, sid, gen, signal) && cfg.approval.enabled
|
|
5948
|
+
&& process.env.WEZARD_PICKER_SENTINEL !== "0";
|
|
5949
|
+
if (!guard())
|
|
5521
5950
|
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;
|
|
5951
|
+
await withPaneInput(pane, async (live) => {
|
|
5952
|
+
const valid = () => guard() && live();
|
|
5953
|
+
if (!a.tmuxPane || a.migrationWatcher)
|
|
5954
|
+
return;
|
|
5955
|
+
// Codex PermissionRequest has its own independent native gate. Screen
|
|
5956
|
+
// options, tool windows and skipAll never authorize its sandbox.
|
|
5957
|
+
if (cliOf(a) === "codex")
|
|
5958
|
+
return;
|
|
5959
|
+
if (!(await tmuxPaneAlive(pane)) || !valid())
|
|
5960
|
+
return;
|
|
5961
|
+
const v = await detectModalPicker(pane);
|
|
5962
|
+
if (!valid())
|
|
5963
|
+
return;
|
|
5964
|
+
if (!v.modal) {
|
|
5965
|
+
// 审批卡在途而框已消失 = 本地先按掉了 → 作废卡等待 (无卡则 no-op)。
|
|
5966
|
+
mootMirrorPicker(a.sessionId);
|
|
5967
|
+
// 诊断: 屏上有高亮编号行却没判成 modal = 布局盲区候选 (askq/plan 面板
|
|
5968
|
+
// 也长这样, 属正常)。每屏记一次日志, 供远端排查"哨兵为何没反应"。
|
|
5969
|
+
if (looksLikePicker(v.screen)) {
|
|
5970
|
+
if (a.pickerSentinelFp !== v.screen) {
|
|
5971
|
+
a.pickerSentinelFp = v.screen;
|
|
5972
|
+
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
5973
|
}
|
|
5548
|
-
continue;
|
|
5549
5974
|
}
|
|
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");
|
|
5975
|
+
else {
|
|
5976
|
+
a.pickerSentinelFp = undefined;
|
|
5564
5977
|
}
|
|
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
|
-
});
|
|
5978
|
+
return;
|
|
5609
5979
|
}
|
|
5610
|
-
|
|
5611
|
-
|
|
5612
|
-
|
|
5613
|
-
|
|
5614
|
-
|
|
5615
|
-
|
|
5980
|
+
if (hasMirrorPicker(a.sessionId))
|
|
5981
|
+
return; // 审批卡在途, 等点击 / moot
|
|
5982
|
+
if (a.pickerSentinelFp === v.screen)
|
|
5983
|
+
return; // 同一屏已处理过, 等它变化
|
|
5984
|
+
a.pickerSentinelFp = v.screen;
|
|
5985
|
+
const pick = pickAutoAllowAnswer(parseModalOptions(v.screen));
|
|
5986
|
+
if (!pick) {
|
|
5987
|
+
log.info({ target: a.target, title: v.title }, "picker sentinel: modal on pane but not a trustworthy permission picker — leaving it");
|
|
5988
|
+
return;
|
|
5989
|
+
}
|
|
5990
|
+
const ctx = parseConfirmContext(v.screen);
|
|
5991
|
+
const policy = pickerPolicy(cfg, cliOf(a), ctx, isAutoWindowActive(a.target));
|
|
5992
|
+
if (policy.decision === "leave") {
|
|
5993
|
+
log.info({ target: a.target, reason: policy.reason }, "picker sentinel: independent approval left on CLI");
|
|
5994
|
+
}
|
|
5995
|
+
await dispatchPickerPolicy(policy, {
|
|
5996
|
+
deny: async () => {
|
|
5997
|
+
if (!valid())
|
|
5998
|
+
return;
|
|
5999
|
+
const result = await pressNativePickerUnlocked(a, modalFingerprint(v.screen), "no", valid);
|
|
6000
|
+
if (!valid())
|
|
6001
|
+
return;
|
|
6002
|
+
log.info({ target: a.target, reason: policy.reason, result }, "picker sentinel: deny rule");
|
|
6003
|
+
sendStandalone(a, `[mirror] 🚫 ${policy.reason},${result.ok ? "已拒绝 CLI 确认" : "确认框已变更,请回 CLI 处理"}`);
|
|
6004
|
+
},
|
|
6005
|
+
allow: async () => {
|
|
6006
|
+
if (!valid())
|
|
6007
|
+
return;
|
|
6008
|
+
const after = await pressModalIndex(pane, v, pick.index, valid);
|
|
6009
|
+
if (!valid())
|
|
6010
|
+
return;
|
|
6011
|
+
if (after.modal) {
|
|
6012
|
+
// 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
|
|
6013
|
+
a.pickerSentinelFp = after.screen;
|
|
6014
|
+
log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
|
|
6015
|
+
return;
|
|
6016
|
+
}
|
|
6017
|
+
a.pickerSentinelFp = undefined;
|
|
6018
|
+
log.info({ target: a.target, title: v.title, via: policy.reason, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
|
|
6019
|
+
sendStandalone(a, `[mirror] 🔓 ${policy.reason} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
|
|
6020
|
+
},
|
|
6021
|
+
manual: () => {
|
|
6022
|
+
if (!valid())
|
|
6023
|
+
return;
|
|
6024
|
+
// 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
|
|
6025
|
+
// 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
|
|
6026
|
+
log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, reason: policy.reason }, "picker sentinel: forwarding native picker as approval card");
|
|
6027
|
+
runMirrorPickerFlow({
|
|
6028
|
+
log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
|
|
6029
|
+
client,
|
|
6030
|
+
cfg,
|
|
6031
|
+
sessionId: a.sessionId,
|
|
6032
|
+
chatKey: a.target,
|
|
6033
|
+
cwd: a.runningCwd,
|
|
6034
|
+
jsonlPath: a.jsonlPath,
|
|
6035
|
+
toolName: ctx.toolName,
|
|
6036
|
+
toolInput: ctx.toolInput,
|
|
6037
|
+
agent: ctx.agent,
|
|
6038
|
+
danger: policy.forceSingle,
|
|
6039
|
+
voteTimeoutMs: cfg.approval.longPollSec * 1000,
|
|
6040
|
+
// Human waiting is outside the lock; the eventual press obtains a new transaction.
|
|
6041
|
+
press: (what) => pressNativePicker(a, modalFingerprint(v.screen), what, guard),
|
|
6042
|
+
}).then((sent) => {
|
|
6043
|
+
// 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
|
|
6044
|
+
// 否则这只框会被指纹永久静默。
|
|
6045
|
+
if (!sent && guard())
|
|
6046
|
+
a.pickerSentinelFp = undefined;
|
|
6047
|
+
}).catch((e) => {
|
|
6048
|
+
if (guard())
|
|
6049
|
+
a.pickerSentinelFp = undefined;
|
|
6050
|
+
log.warn({ err: e.message, target: a.target }, "mirror picker flow failed");
|
|
6051
|
+
});
|
|
6052
|
+
},
|
|
6053
|
+
});
|
|
6054
|
+
}, { guard });
|
|
6055
|
+
};
|
|
6056
|
+
const pickerTick = () => {
|
|
6057
|
+
if (!cfg.approval.enabled || process.env.WEZARD_PICKER_SENTINEL === "0")
|
|
6058
|
+
return;
|
|
6059
|
+
for (const a of byTarget.values()) {
|
|
6060
|
+
if (!a.tmuxPane || cliOf(a) === "codex")
|
|
6061
|
+
continue;
|
|
6062
|
+
const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
|
|
6063
|
+
void maintenance.schedule(`picker:${a.target}`, pane, (signal) => pickerOne(a, pane, sid, gen, signal))
|
|
6064
|
+
?.catch((e) => {
|
|
6065
|
+
if (maintenanceValid(a, pane, sid, gen, maintenance.signal))
|
|
6066
|
+
a.pickerSentinelFp = undefined;
|
|
6067
|
+
log.warn({ target: a.target, err: e.message }, "picker sentinel tick failed");
|
|
6068
|
+
});
|
|
5616
6069
|
}
|
|
5617
6070
|
};
|
|
5618
6071
|
const pickerTimer = setInterval(() => void pickerTick(), 5_000);
|
|
5619
6072
|
// 审批卡点击后的代按回调。点击与本地按键之间隔着人的反应时间, 必须重读屏并
|
|
5620
6073
|
// 校验仍是**同一个调用**的确认框 —— 期间本地按掉旧框又弹了新框的话, 盲按就是
|
|
5621
6074
|
// 替用户批准了他没看过的东西。
|
|
5622
|
-
const
|
|
5623
|
-
if (!a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
6075
|
+
const pressNativePickerUnlocked = async (a, expectFingerprint, what, live) => {
|
|
6076
|
+
if (!live() || !a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
5624
6077
|
return { ok: false, reason: "pane 已不在" };
|
|
5625
6078
|
if (cliOf(a) === "codex")
|
|
5626
6079
|
return { ok: false, reason: "Codex 原生沙箱审批必须单独处理" };
|
|
5627
6080
|
const v = await detectModalPicker(a.tmuxPane);
|
|
6081
|
+
if (!live())
|
|
6082
|
+
return { ok: false, reason: "pane 事务已撤回" };
|
|
5628
6083
|
if (!v.modal)
|
|
5629
6084
|
return { ok: false, reason: "确认框已不在(可能已在本地处理)" };
|
|
5630
6085
|
if (pickerPolicy(cfg, cliOf(a), parseConfirmContext(v.screen), false).decision === "leave") {
|
|
@@ -5645,11 +6100,12 @@ export const startMirror = (deps) => {
|
|
|
5645
6100
|
const pick = sess ?? bare;
|
|
5646
6101
|
if (!pick)
|
|
5647
6102
|
return { ok: false, reason: "确认框上找不到可按的 Yes 选项" };
|
|
5648
|
-
const after = await pressModalIndex(a.tmuxPane, v, pick.index);
|
|
6103
|
+
const after = await pressModalIndex(a.tmuxPane, v, pick.index, live);
|
|
5649
6104
|
return after.modal
|
|
5650
6105
|
? { ok: false, reason: `按下 ${pick.index} 后确认框仍在` }
|
|
5651
6106
|
: { ok: true, label: `${pick.index}. ${pick.label}` };
|
|
5652
6107
|
};
|
|
6108
|
+
const pressNativePicker = async (a, fingerprint, what, guard) => withPaneInput(a.tmuxPane, (live) => pressNativePickerUnlocked(a, fingerprint, what, live), { guard });
|
|
5653
6109
|
return {
|
|
5654
6110
|
attach,
|
|
5655
6111
|
chatTargets,
|
|
@@ -5873,35 +6329,24 @@ export const startMirror = (deps) => {
|
|
|
5873
6329
|
// to in this process lifetime (or any session after a reload) has an empty
|
|
5874
6330
|
// in-memory slot but a perfectly good persisted binding. Without this, an
|
|
5875
6331
|
// 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" };
|
|
6332
|
+
const permission = () => !opts?.guard || opts.guard();
|
|
6333
|
+
const denied = { ok: false, retryable: true, reason: "inject permission withdrawn before respawn/binding" };
|
|
5879
6334
|
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 });
|
|
6335
|
+
return { ok: false, retryable: true, reason: "empty text" };
|
|
6336
|
+
if (!permission())
|
|
6337
|
+
return denied;
|
|
6338
|
+
const a = byTarget.get(target) ?? (await restoreFromStore(target, undefined, permission));
|
|
6339
|
+
if (!permission())
|
|
6340
|
+
return denied;
|
|
6341
|
+
if (!a)
|
|
6342
|
+
return { ok: false, retryable: true, reason: "no mirror attached for target" };
|
|
5903
6343
|
const sid = a.sessionId;
|
|
6344
|
+
const generation = a.injectGen ?? 0;
|
|
6345
|
+
const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
|
|
6346
|
+
const probedPane = a.tmuxPane;
|
|
5904
6347
|
const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
|
|
6348
|
+
if (!permitted() || a.tmuxPane !== probedPane)
|
|
6349
|
+
return denied;
|
|
5905
6350
|
// 同 dispatch: 只有刚生 / 刚重生的 pane 才要冷启动的宽限 —— 对一个热着、正忙的 pane 贴 `now`
|
|
5906
6351
|
// 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
|
|
5907
6352
|
let fresh = a.justSpawned === true;
|
|
@@ -5912,7 +6357,16 @@ export const startMirror = (deps) => {
|
|
|
5912
6357
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
5913
6358
|
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
6359
|
if (!r.ok || !r.tmuxPane)
|
|
5915
|
-
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
6360
|
+
return { ok: false, retryable: true, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
6361
|
+
if (!permitted() || a.tmuxPane !== probedPane) {
|
|
6362
|
+
// This pane was born for an unsubmitted injection. Never kill the old
|
|
6363
|
+
// executor or bind the withdrawn birth. A failed cleanup is explicitly
|
|
6364
|
+
// uncertain and must be reconciled; it is not proof execution stopped.
|
|
6365
|
+
const cleanup = await runTmux(["kill-pane", "-t", r.tmuxPane]).catch(() => undefined);
|
|
6366
|
+
return { ...denied, ...(cleanup?.ok ? {} : { uncertain: true, retryable: false }), reason: cleanup?.ok
|
|
6367
|
+
? "respawn permission withdrawn; new pane removed before injection, binding withheld"
|
|
6368
|
+
: `respawn permission withdrawn; binding withheld, reconcile new pane ${r.tmuxPane}` };
|
|
6369
|
+
}
|
|
5916
6370
|
a.tmuxPane = r.tmuxPane;
|
|
5917
6371
|
a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
|
|
5918
6372
|
if (r.cwd)
|
|
@@ -5925,18 +6379,34 @@ export const startMirror = (deps) => {
|
|
|
5925
6379
|
startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
|
|
5926
6380
|
fresh = true;
|
|
5927
6381
|
}
|
|
5928
|
-
|
|
5929
|
-
|
|
5930
|
-
a
|
|
6382
|
+
if (!permitted())
|
|
6383
|
+
return denied;
|
|
6384
|
+
// Busy/queued retries must not consume notices or stamp a future turn.
|
|
6385
|
+
// Render only after the pane lane grants delivery, then stamp exactly at
|
|
6386
|
+
// the first permitted input. activePeerTurn still changes in bindLine.
|
|
6387
|
+
const envelope = text.trimStart().startsWith("/") ? "" : opts?.envelope ?? "";
|
|
6388
|
+
let full = text + envelope;
|
|
6389
|
+
const beforeInput = () => {
|
|
6390
|
+
a.pendingFromCli = false;
|
|
6391
|
+
a.turnFromChat = true;
|
|
6392
|
+
rememberInject(full);
|
|
6393
|
+
pushSeal(a, { eventId: parseEnvelope(full)?.eventId, keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
|
|
6394
|
+
a.muteUntilInject = false;
|
|
6395
|
+
a.justSpawned = false;
|
|
6396
|
+
};
|
|
5931
6397
|
// 同 dispatch 的 /clear 与静默分叉跟随: self-handoff / 同伴 / 定时任务也走这里注入,
|
|
5932
6398
|
// 漏了它 tail 就钉在旧 jsonl 上, 直到下一条人话经 dispatch 才把它拽回来。
|
|
5933
6399
|
const clearing = isClearCommand(text);
|
|
5934
6400
|
const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
6401
|
+
const injectPane = a.tmuxPane;
|
|
5935
6402
|
const r = await inject({
|
|
5936
|
-
text: full, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
6403
|
+
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
6404
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
|
|
6405
|
+
requireIdle: opts?.requireIdle ? () => idleNow(target) : undefined,
|
|
6406
|
+
prepareText: () => (full = text + envelope + noticeSuffixFor(target, text)),
|
|
6407
|
+
beforeInput,
|
|
5938
6408
|
});
|
|
5939
|
-
if (r.ok && a.tmuxPane) {
|
|
6409
|
+
if (r.ok && a.tmuxPane && permitted()) {
|
|
5940
6410
|
if (preClearBaseline) {
|
|
5941
6411
|
a.clearRebind = { baseline: preClearBaseline };
|
|
5942
6412
|
startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
|
|
@@ -5947,9 +6417,15 @@ export const startMirror = (deps) => {
|
|
|
5947
6417
|
return r;
|
|
5948
6418
|
},
|
|
5949
6419
|
interruptPane: async (target, opts) => {
|
|
6420
|
+
if (opts?.guard && !opts.guard())
|
|
6421
|
+
return { ok: false, reason: "interrupt authority changed" };
|
|
5950
6422
|
const a = byTarget.get(target);
|
|
5951
6423
|
if (!a)
|
|
5952
6424
|
return { ok: false, reason: "no mirror attached for target" };
|
|
6425
|
+
const interruptedEvent = a.activeEventId;
|
|
6426
|
+
const interruptedPane = a.tmuxPane;
|
|
6427
|
+
if (opts?.teardown && interruptedEvent)
|
|
6428
|
+
deps.events?.cancel((e) => e.id === interruptedEvent);
|
|
5953
6429
|
// `/stop` (teardown: true) must work when tmux itself is the problem, so
|
|
5954
6430
|
// the order is: local state first, pane second. The old implementation
|
|
5955
6431
|
// probed the pane FIRST and returned early on failure — meaning a wedged
|
|
@@ -5986,7 +6462,13 @@ export const startMirror = (deps) => {
|
|
|
5986
6462
|
escReason = "tmux pane no longer alive";
|
|
5987
6463
|
}
|
|
5988
6464
|
else {
|
|
5989
|
-
|
|
6465
|
+
if (byTarget.get(target) !== a || a.tmuxPane !== interruptedPane || a.activeEventId !== interruptedEvent || (opts?.guard && !opts.guard()))
|
|
6466
|
+
return { ok: false, reason: "turn changed during interrupt probe; Escape withheld" };
|
|
6467
|
+
const r = await withPaneInput(interruptedPane, async (live) => {
|
|
6468
|
+
if (!live() || a.activeEventId !== interruptedEvent || a.tmuxPane !== interruptedPane || (opts?.guard && !opts.guard()))
|
|
6469
|
+
return { ok: false, stderr: "turn changed before Escape", stdout: "", code: 1 };
|
|
6470
|
+
return runTmux(["send-keys", "-t", interruptedPane, "Escape"]);
|
|
6471
|
+
});
|
|
5990
6472
|
escOk = r.ok;
|
|
5991
6473
|
if (!r.ok)
|
|
5992
6474
|
escReason = `send-keys Escape failed: ${(r.stderr || r.stdout).slice(-200) || r.code}`;
|
|
@@ -6001,7 +6483,13 @@ export const startMirror = (deps) => {
|
|
|
6001
6483
|
return { ok: false, reason: escReason };
|
|
6002
6484
|
return { ok: true, torndown, escOk, escReason: escReason || undefined };
|
|
6003
6485
|
},
|
|
6004
|
-
killPane: (target) =>
|
|
6486
|
+
killPane: async (target) => {
|
|
6487
|
+
deps.events?.cancel((e) => e.target === target);
|
|
6488
|
+
const r = await downPane(target, true);
|
|
6489
|
+
if (r.ok)
|
|
6490
|
+
deps.events?.stopped(target, "pane kill acknowledged; queued input revoked");
|
|
6491
|
+
return r;
|
|
6492
|
+
},
|
|
6005
6493
|
sleepPane: (target) => downPane(target, false),
|
|
6006
6494
|
lastActivity: (target) => {
|
|
6007
6495
|
const jsonl = jsonlOf(target);
|
|
@@ -6252,6 +6740,7 @@ export const startMirror = (deps) => {
|
|
|
6252
6740
|
clearInterval(paneDriftTimer);
|
|
6253
6741
|
clearInterval(keepaliveTimer);
|
|
6254
6742
|
clearInterval(pickerTimer);
|
|
6743
|
+
maintenance.stop();
|
|
6255
6744
|
for (const a of bySessionId.values()) {
|
|
6256
6745
|
if (a.outbound?.kind === "deferred")
|
|
6257
6746
|
clearTimeout(a.outbound.timer);
|
|
@@ -6269,11 +6758,17 @@ export const startMirror = (deps) => {
|
|
|
6269
6758
|
return a.convFrom.asker;
|
|
6270
6759
|
return a?.speaker ? { who: a.speaker, chat: a.channel ?? baseOfKey(target) } : undefined;
|
|
6271
6760
|
},
|
|
6761
|
+
answerContext, prepareAnswer, takeAnswer, cancelAnswerPreparation, stopAnswerProgress, deliverAnswer,
|
|
6762
|
+
setAnswerSilencer: (fn) => { silenceAnswer = fn; },
|
|
6272
6763
|
currentChannel: (target) => {
|
|
6273
6764
|
const a = byTarget.get(target);
|
|
6274
6765
|
return a?.channel ?? baseOfKey(target);
|
|
6275
6766
|
},
|
|
6276
|
-
|
|
6767
|
+
formatFinal,
|
|
6768
|
+
recoverEventFinal,
|
|
6769
|
+
setEventFinalGate: (gate) => { eventFinalGate = gate; },
|
|
6770
|
+
settleFinalProgress,
|
|
6771
|
+
dispatch: async ({ principal, text, images, frame, streamId, channel, speaker, eventId, guard, onSubmitted }) => {
|
|
6277
6772
|
// Route by inbound principal — the WeCom chat the user just messaged us
|
|
6278
6773
|
// from is the same string we registered as `target` on attach. After a
|
|
6279
6774
|
// daemon reload the in-memory map is empty; restore from the persisted
|
|
@@ -6291,6 +6786,10 @@ export const startMirror = (deps) => {
|
|
|
6291
6786
|
}
|
|
6292
6787
|
return;
|
|
6293
6788
|
}
|
|
6789
|
+
if (eventId && a.tmuxPane && await tmuxPaneAlive(a.tmuxPane) && !(await idleNow(principal))) {
|
|
6790
|
+
onSubmitted?.({ ok: false, retryable: true, reason: "target became busy before opening the human turn" });
|
|
6791
|
+
return;
|
|
6792
|
+
}
|
|
6294
6793
|
// `/clear` resets to an empty session — nothing in the cache to keep warm,
|
|
6295
6794
|
// so pause keepalive like `/stop` instead of lifting it. Every other inbound
|
|
6296
6795
|
// is real work: lift any prior pause and re-anchor both clocks to now.
|
|
@@ -6406,17 +6905,19 @@ export const startMirror = (deps) => {
|
|
|
6406
6905
|
if (s) {
|
|
6407
6906
|
a.liveStream = s;
|
|
6408
6907
|
try {
|
|
6409
|
-
await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
|
|
6908
|
+
await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
|
|
6410
6909
|
}
|
|
6411
6910
|
catch (e) {
|
|
6412
6911
|
log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
|
|
6413
6912
|
}
|
|
6414
6913
|
}
|
|
6914
|
+
if (!brief)
|
|
6915
|
+
a.activeEventId = eventId;
|
|
6415
6916
|
if (brief) {
|
|
6416
6917
|
// 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
|
|
6417
6918
|
// 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
|
|
6418
6919
|
// 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
|
|
6419
|
-
await startBriefTurn(a, frame, streamId, isSlash, effText, channel ?? baseOfKey(principal), speaker);
|
|
6920
|
+
await startBriefTurn(a, frame, streamId, isSlash, effText, channel ?? baseOfKey(principal), speaker, eventId);
|
|
6420
6921
|
}
|
|
6421
6922
|
else if (!armMigration && !eagerOpen) {
|
|
6422
6923
|
enterDeferred(a, frame, streamId);
|
|
@@ -6503,6 +7004,8 @@ export const startMirror = (deps) => {
|
|
|
6503
7004
|
log.warn({ target: a.target, sessionId: sid, myGen, gen: a.injectGen }, "inject aborted — superseded by /stop or watchdog");
|
|
6504
7005
|
return;
|
|
6505
7006
|
}
|
|
7007
|
+
if (!brief)
|
|
7008
|
+
pushSeal(a, { eventId: a.activeEventId, keys: sealKeys(effText), at: Date.now(), query: effText, channel: channel ?? baseOfKey(principal), speaker, fresh: true });
|
|
6506
7009
|
rememberInject(effText);
|
|
6507
7010
|
// 解除静默必须在 inject 之前: inject 内部的 freshSpawn 验证循环可达 10+s
|
|
6508
7011
|
// (paste-verify + settle + waitForCleared + retry), 而 LLM 的回复可能在 Enter
|
|
@@ -6511,10 +7014,14 @@ export const startMirror = (deps) => {
|
|
|
6511
7014
|
// 安全性: newSession 的 fresh pane 在 inject 前只写过 system/init (→ 无 item),
|
|
6512
7015
|
// 用户行有 isOwnInject 过滤 —— 提前解除不会泄漏脏数据。
|
|
6513
7016
|
a.muteUntilInject = false;
|
|
7017
|
+
const injectPane = a.tmuxPane;
|
|
6514
7018
|
const r = await inject({
|
|
6515
7019
|
text: effText, images: effImages, cfg, log: log.child({ principal, sessionId: sid }),
|
|
6516
7020
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
|
|
7021
|
+
requireIdle: eventId ? () => idleNow(a.target) : undefined,
|
|
7022
|
+
guard: (phase) => byTarget.get(a.target) === a && a.tmuxPane === injectPane && (a.injectGen ?? 0) === myGen && (!guard || guard(phase)),
|
|
6517
7023
|
});
|
|
7024
|
+
onSubmitted?.(r);
|
|
6518
7025
|
if (!r.ok) {
|
|
6519
7026
|
if (s) {
|
|
6520
7027
|
s.acc = s.acc ? `${s.acc}\n\n[mirror] ✗ ${r.reason ?? "failed"}` : `[mirror] ✗ ${r.reason ?? "failed"}`;
|
|
@@ -6577,10 +7084,7 @@ export const startMirror = (deps) => {
|
|
|
6577
7084
|
// and surface a standalone "cleared" so the user gets explicit
|
|
6578
7085
|
// feedback (the skip-stream path otherwise leaves WeCom silent).
|
|
6579
7086
|
if (armMigration) {
|
|
6580
|
-
|
|
6581
|
-
sendStandalone(a, cfg.wrc.mirror.slashAckFirstLine
|
|
6582
|
-
? "cleared"
|
|
6583
|
-
: `cleared\n\n${randomTip()}`);
|
|
7087
|
+
sendStandalone(a, "cleared");
|
|
6584
7088
|
a.clearRebind = { baseline: preClearBaseline };
|
|
6585
7089
|
startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
|
|
6586
7090
|
}
|