wezard 2.6.3 → 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/dist/daemon/bot-mux.js +4 -0
- package/dist/daemon/bot-mux.js.map +1 -1
- package/dist/daemon/image-input.js +25 -5
- package/dist/daemon/image-input.js.map +1 -1
- package/dist/daemon/inbound.js +3 -2
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +312 -109
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +8 -3
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +22 -2
- 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/mirror-bridge.js +189 -63
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +2 -2
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/receipts.js +87 -25
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/weixin.js +111 -7
- package/dist/daemon/weixin.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/detail-store.js.map +1 -1
- package/dist/shared/reminder.js +6 -1
- package/dist/shared/reminder.js.map +1 -1
- 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";
|
|
@@ -40,9 +42,9 @@ import { hasRegistry, liveSessionPids, markTranscript, probeOf, sessionOnPane, s
|
|
|
40
42
|
import { waitForIdle } from "./graph.js";
|
|
41
43
|
import { wizardStore } from "./wizard.js";
|
|
42
44
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
43
|
-
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";
|
|
44
46
|
import { errText } from "./last-response.js";
|
|
45
|
-
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";
|
|
46
48
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
47
49
|
import { isWxFrame } from "../shared/wx-text.js";
|
|
48
50
|
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
@@ -526,7 +528,7 @@ const renderLine = (raw, deps) => {
|
|
|
526
528
|
if (line.type === "attachment" && line.attachment?.type === "queued_command" && typeof line.attachment.prompt === "string") {
|
|
527
529
|
const said = cleanUserText(line.attachment.prompt);
|
|
528
530
|
const ts = Date.parse(line.timestamp ?? "");
|
|
529
|
-
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 }] : [];
|
|
530
532
|
}
|
|
531
533
|
const out = [];
|
|
532
534
|
if (line.type === "user") {
|
|
@@ -576,8 +578,9 @@ const renderLine = (raw, deps) => {
|
|
|
576
578
|
if (deps.isKeepalivePing?.(text))
|
|
577
579
|
return [{ kind: "keepalive_start", body: text }];
|
|
578
580
|
// 自己注入的那一行不回显, 但要报个到: 它的印章 (谁说的、回复去哪) 在这一刻认领。
|
|
579
|
-
|
|
580
|
-
|
|
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) }];
|
|
581
584
|
const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
|
|
582
585
|
// includeUser 只决定"渲不渲染", 不决定"算不算边界" —— quiet 的这一条照样发出,
|
|
583
586
|
// onItem 消费完边界语义后自己丢掉。
|
|
@@ -588,11 +591,11 @@ const renderLine = (raw, deps) => {
|
|
|
588
591
|
// 回显去重对得上; 记进详情页的则是带占位符的原样文本, 图本身不画。
|
|
589
592
|
const raw = c.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("\n");
|
|
590
593
|
const text = cleanUserText(raw.replace(IMAGE_REF_RE, ""));
|
|
591
|
-
if (text && !deps.isOwnInject(text)) {
|
|
594
|
+
if (text && !parseEnvelope(raw)?.eventId && !deps.isOwnInject(text)) {
|
|
592
595
|
out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser, ts: lineTs(line) });
|
|
593
596
|
}
|
|
594
597
|
else {
|
|
595
|
-
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) });
|
|
596
599
|
}
|
|
597
600
|
}
|
|
598
601
|
else if (Array.isArray(c)) {
|
|
@@ -1842,11 +1845,12 @@ const inject = async (args) => {
|
|
|
1842
1845
|
return classify(await withPaneInput(target, async (live) => {
|
|
1843
1846
|
// Busy and unknown are both a deferred delivery. Never hold the input
|
|
1844
1847
|
// lock while waiting for a model's whole turn to finish.
|
|
1845
|
-
|
|
1848
|
+
const idleApproved = !args.requireIdle || await args.requireIdle();
|
|
1849
|
+
if (!idleApproved)
|
|
1846
1850
|
return { ok: false, reason: "target has no confirmed idle evidence" };
|
|
1847
1851
|
const text = args.prepareText?.() ?? args.text;
|
|
1848
1852
|
const guard = (phase) => {
|
|
1849
|
-
if (!live() || (!exposed &&
|
|
1853
|
+
if (!live() || (!exposed && !idleApproved) || (args.guard && !args.guard(phase)))
|
|
1850
1854
|
return false;
|
|
1851
1855
|
if (phase && !exposed) {
|
|
1852
1856
|
exposed = true;
|
|
@@ -2044,6 +2048,7 @@ export const startMirror = (deps) => {
|
|
|
2044
2048
|
turnRegistry.delete(k);
|
|
2045
2049
|
};
|
|
2046
2050
|
const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
|
|
2051
|
+
let eventFinalGate = () => true;
|
|
2047
2052
|
const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
2048
2053
|
const newMarkId = () => `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
2049
2054
|
const detailCardFor = (s, target) => {
|
|
@@ -2547,6 +2552,61 @@ export const startMirror = (deps) => {
|
|
|
2547
2552
|
let silenceAnswer = () => false;
|
|
2548
2553
|
const answerSilent = (a) => silenceAnswer(a.target, a.briefTurnId ?? a.liveStream?.turnId ?? "");
|
|
2549
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
|
+
};
|
|
2550
2610
|
const answerBubbles = new Map();
|
|
2551
2611
|
const answerLine = (held, text) => withLinkedTagHeader(held.receiver, text, chatTurnUrl(held.receiver, held.sourceTurn), undefined, undefined, held.channel);
|
|
2552
2612
|
// Serialise the old owner's in-flight refresh, reset, progress and final.
|
|
@@ -2667,10 +2727,10 @@ export const startMirror = (deps) => {
|
|
|
2667
2727
|
writeAnswerProgress(held, `\`${held.progress}\``);
|
|
2668
2728
|
return { taken: true, cleared };
|
|
2669
2729
|
};
|
|
2670
|
-
const deliverAnswer = async (id, target, turn, channel, _prefix, body, standalone) => {
|
|
2730
|
+
const deliverAnswer = async (id, target, turn, channel, _prefix, body, standalone, frozen = false) => {
|
|
2671
2731
|
const held = answerBubbles.get(id);
|
|
2672
2732
|
const text = body.trim();
|
|
2673
|
-
const content = withLinkedTagHeader(target, text, chatTurnUrl(target, turn), undefined, undefined, channel);
|
|
2733
|
+
const content = frozen ? body : withLinkedTagHeader(target, text, chatTurnUrl(target, turn), undefined, undefined, channel);
|
|
2674
2734
|
stopAnswerProgress(id);
|
|
2675
2735
|
if (held?.bubble && !held.bubble.done) {
|
|
2676
2736
|
held.bubble.done = true;
|
|
@@ -2679,10 +2739,12 @@ export const startMirror = (deps) => {
|
|
|
2679
2739
|
try {
|
|
2680
2740
|
// Failure closes progress first; the terminal explanation is a standalone message.
|
|
2681
2741
|
const progress = withLinkedTagHeader(target, "回答已转交", chatTurnUrl(target, turn), undefined, undefined, channel);
|
|
2682
|
-
|
|
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);
|
|
2683
2745
|
if (!standalone) {
|
|
2684
2746
|
answerBubbles.delete(id);
|
|
2685
|
-
return;
|
|
2747
|
+
return ack;
|
|
2686
2748
|
}
|
|
2687
2749
|
}
|
|
2688
2750
|
catch (e) {
|
|
@@ -2707,8 +2769,10 @@ export const startMirror = (deps) => {
|
|
|
2707
2769
|
}
|
|
2708
2770
|
}
|
|
2709
2771
|
}
|
|
2710
|
-
await
|
|
2772
|
+
const ack = frozen ? await deps.sendFrozen(channel, content)
|
|
2773
|
+
: await client.sendMessage(channel.replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content } });
|
|
2711
2774
|
answerBubbles.delete(id);
|
|
2775
|
+
return ack;
|
|
2712
2776
|
};
|
|
2713
2777
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
2714
2778
|
// raw=true skips withTagHeader (used when content already contains the linked tag header).
|
|
@@ -2785,6 +2849,7 @@ export const startMirror = (deps) => {
|
|
|
2785
2849
|
const openingOf = (a) => {
|
|
2786
2850
|
const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
|
|
2787
2851
|
const env = t?.env;
|
|
2852
|
+
a.activeEventId = env?.eventId;
|
|
2788
2853
|
if (!env)
|
|
2789
2854
|
return undefined;
|
|
2790
2855
|
const home = baseOfKey(a.target);
|
|
@@ -2846,7 +2911,7 @@ export const startMirror = (deps) => {
|
|
|
2846
2911
|
return undefined;
|
|
2847
2912
|
};
|
|
2848
2913
|
/** 本轮落 turn 记录的频道字段。 */
|
|
2849
|
-
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 });
|
|
2850
2915
|
/** 私聊轮: 回复只落 turn 记录。 */
|
|
2851
2916
|
const isPrivate = (a) => a.channel === "";
|
|
2852
2917
|
/** 本轮回复该推去的 chatId。 */
|
|
@@ -2872,9 +2937,9 @@ export const startMirror = (deps) => {
|
|
|
2872
2937
|
};
|
|
2873
2938
|
/** 露面的这一行认领它的印章, 先注入的先认。CLI 把排队的几句并成一行时, 包含在里面的都算
|
|
2874
2939
|
* 露过面, 出处取第一枚。认不出 = undefined。 */
|
|
2875
|
-
const takeSeal = (a, said, pick = () => true) => {
|
|
2940
|
+
const takeSeal = (a, said, pick = () => true, eventId) => {
|
|
2876
2941
|
const t = said.trim();
|
|
2877
|
-
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))));
|
|
2878
2943
|
const seals = a.seals ?? [];
|
|
2879
2944
|
const hits = seals.filter(hit);
|
|
2880
2945
|
// CLI 把排队的几句并成一行, 这一轮只能有一个去向: 里面有私聊的那句就是私聊 —— 它的答复不能
|
|
@@ -2895,6 +2960,9 @@ export const startMirror = (deps) => {
|
|
|
2895
2960
|
* `at` = 那一行落盘的时刻: 排队的那句从这一刻才开始被处理, 轮次的起点以它为准; 闲时当场接上就是投递那一刻。 */
|
|
2896
2961
|
const activateDispatch = (a, s, at = s.at) => {
|
|
2897
2962
|
const q = s.turn;
|
|
2963
|
+
if (a.briefTurnId && a.briefTurnId !== q.turnId)
|
|
2964
|
+
closeBriefTurn(a);
|
|
2965
|
+
a.activeEventId = s.eventId;
|
|
2898
2966
|
s.activated = true;
|
|
2899
2967
|
a.activePeerTurn = undefined;
|
|
2900
2968
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
@@ -2906,9 +2974,7 @@ export const startMirror = (deps) => {
|
|
|
2906
2974
|
a.pendingBriefQuery = undefined;
|
|
2907
2975
|
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
|
|
2908
2976
|
consumeChannel(a, true, undefined);
|
|
2909
|
-
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) });
|
|
2910
|
-
if (a.briefTurnId)
|
|
2911
|
-
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) });
|
|
2912
2978
|
openBriefTurn(a, q);
|
|
2913
2979
|
};
|
|
2914
2980
|
/** 一句插话记进它插进的那一轮: 出处、频道、发话人都取它自己的印章 (没写频道 = 在 home 里开口)。 */
|
|
@@ -2916,16 +2982,21 @@ export const startMirror = (deps) => {
|
|
|
2916
2982
|
/** `attachment.queued_command` 露面: 那句被正在跑的这一轮中途吃进去了。认领它的印章记成插话。
|
|
2917
2983
|
* 人从聊天里说的那句 (印章带预建的轮) 不在这里认: 它已有自己的轮次记录。认不出 (人在 CLI 里
|
|
2918
2984
|
* 忙时敲的) 不记 —— 说不清是谁、在哪个频道说的。 */
|
|
2919
|
-
const interject = (a, said, ts) => {
|
|
2985
|
+
const interject = (a, said, ts, eventId) => {
|
|
2920
2986
|
if (!a.briefTurnId)
|
|
2921
2987
|
return;
|
|
2922
|
-
const s = takeSeal(a, said, (x) => !x.turn);
|
|
2988
|
+
const s = takeSeal(a, said, (x) => !x.turn, eventId);
|
|
2923
2989
|
if (s)
|
|
2924
2990
|
injectInto(a.briefTurnId, a, s, ts);
|
|
2925
2991
|
};
|
|
2926
2992
|
/** 一行注入露面了: 把它的印章落成下一轮的出处 / 频道。返回认领到的印章。`ts` = 那一行落盘的时刻。 */
|
|
2927
|
-
const bindLine = (a, said, ts) => {
|
|
2928
|
-
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);
|
|
2929
3000
|
if (!s)
|
|
2930
3001
|
return undefined;
|
|
2931
3002
|
if (s.turn) {
|
|
@@ -2941,6 +3012,7 @@ export const startMirror = (deps) => {
|
|
|
2941
3012
|
}
|
|
2942
3013
|
if (a.briefTurnId)
|
|
2943
3014
|
closeBriefTurn(a);
|
|
3015
|
+
a.activeEventId = s.eventId;
|
|
2944
3016
|
const now = Date.now();
|
|
2945
3017
|
a.activePeerTurn = s.from?.kind === "peer" ? s.from.turn : undefined;
|
|
2946
3018
|
a.pendingFrom = s.from ? { from: s.from, at: now } : undefined;
|
|
@@ -2956,10 +3028,12 @@ export const startMirror = (deps) => {
|
|
|
2956
3028
|
}
|
|
2957
3029
|
return s;
|
|
2958
3030
|
};
|
|
2959
|
-
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
|
|
3031
|
+
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker, eventId) => {
|
|
2960
3032
|
const turnId = newTurnId();
|
|
2961
3033
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2962
|
-
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);
|
|
2963
3037
|
const q = { turnId, bubble, isSlash };
|
|
2964
3038
|
// 微信帧不挂: 没有 stream 窗口要赶, 气泡等终句来了再一次发出 (见 openStream 同一条)。
|
|
2965
3039
|
if (!isWxFrame(frame))
|
|
@@ -2970,23 +3044,17 @@ export const startMirror = (deps) => {
|
|
|
2970
3044
|
const live = a.briefBubble === bubble && !a.briefConcluded;
|
|
2971
3045
|
void finishBubble(a, bubble, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
|
|
2972
3046
|
}, HARD_TIMEOUT_MS);
|
|
2973
|
-
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 };
|
|
2974
3048
|
// 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
|
|
2975
3049
|
// 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
|
|
2976
3050
|
// 先建好, ack 里的链接点得开。斜杠命令的行未必露面, 当场接。
|
|
2977
3051
|
const queued = !isSlash && idleVerdict(a.target) === false;
|
|
2978
3052
|
pushSeal(a, seal);
|
|
2979
3053
|
if (queued)
|
|
2980
|
-
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 });
|
|
2981
3055
|
else
|
|
2982
3056
|
activateDispatch(a, seal);
|
|
2983
|
-
//
|
|
2984
|
-
// closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
|
|
2985
|
-
// 覆盖消息)。旧 turn 在 CLI 侧的剩余产出自然流入新气泡 —— "新回复走最新气泡"
|
|
2986
|
-
// 正是这个语义。代价是旧 turn 页提前标完、尾部 item 记到新 turn 名下, 但远小于
|
|
2987
|
-
// 排队的代价: 排队 turn 的 frame 在前一个长 turn 期间 (可达数分钟) 过期, 激活后
|
|
2988
|
-
// replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。所以只在前面真有别人的行排着时
|
|
2989
|
-
// 才排 (见上): 接上时气泡若已过期, 正文走 standalone (concludeBriefTurn)。
|
|
3057
|
+
// Durable normal inputs arrive only after the preceding full turn; expired bubbles use the final outbox standalone path.
|
|
2990
3058
|
// ack 即详情链接。URL 的三个入参 (turnId / target / host) 在收消息这一刻全部已知 ——
|
|
2991
3059
|
// turnId 是本地生成的, 详情页记录也已在 recordTurnStart 建好, 所以不必等 CLI 产出,
|
|
2992
3060
|
// 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
|
|
@@ -3005,8 +3073,11 @@ export const startMirror = (deps) => {
|
|
|
3005
3073
|
if (a.channelSeeded)
|
|
3006
3074
|
return false;
|
|
3007
3075
|
const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
3008
|
-
const
|
|
3009
|
-
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;
|
|
3010
3081
|
if (!r)
|
|
3011
3082
|
return false;
|
|
3012
3083
|
a.channelSeeded = true;
|
|
@@ -3052,6 +3123,8 @@ export const startMirror = (deps) => {
|
|
|
3052
3123
|
const from = fresh ?? a.convFrom;
|
|
3053
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 } : {}) });
|
|
3054
3125
|
a.briefTurnId = turnId;
|
|
3126
|
+
if (a.activeEventId)
|
|
3127
|
+
deps.events?.bind(a.activeEventId, turnId);
|
|
3055
3128
|
a.briefBubble = undefined;
|
|
3056
3129
|
a.briefIsSlash = false;
|
|
3057
3130
|
a.briefHadTool = false;
|
|
@@ -3094,28 +3167,26 @@ export const startMirror = (deps) => {
|
|
|
3094
3167
|
return;
|
|
3095
3168
|
if (!body.trim())
|
|
3096
3169
|
return;
|
|
3097
|
-
if (isDuplicateConclusion(a, body)) {
|
|
3170
|
+
if (!a.activeEventId && isDuplicateConclusion(a, body)) {
|
|
3098
3171
|
a.briefConcluded = true; // 本轮已"定稿", 只是不投递
|
|
3099
3172
|
log.info({ sessionId: a.sessionId, turnId, epoch: a.queryEpoch ?? 0 }, "brief: duplicate conclusion suppressed");
|
|
3100
3173
|
return;
|
|
3101
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.
|
|
3102
3185
|
a.briefConcluded = true;
|
|
3103
3186
|
a.lastConcludedBody = body;
|
|
3104
3187
|
a.lastConcludedEpoch = a.queryEpoch ?? 0;
|
|
3105
3188
|
a.lastConcludedAt = Date.now();
|
|
3106
|
-
|
|
3107
|
-
log.info({ target: a.target, turnId }, "answer transferred; source conclusion suppressed");
|
|
3108
|
-
}
|
|
3109
|
-
else if (a.briefBubble && !a.briefBubble.done) {
|
|
3110
|
-
void finishBriefBubble(a, briefLine(a, turnId, linkTags(a.target, body)), true);
|
|
3111
|
-
}
|
|
3112
|
-
else if (turnSilent(a)) {
|
|
3113
|
-
// CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
|
|
3114
|
-
log.info({ sessionId: a.sessionId, turnId }, "brief: CLI-origin conclusion kept out of chat");
|
|
3115
|
-
}
|
|
3116
|
-
else {
|
|
3117
|
-
sendStandalone(a, body); // 详情链接由 withLinkedTag 统一前缀, 不在这里拼
|
|
3118
|
-
}
|
|
3189
|
+
clearCot(a);
|
|
3119
3190
|
// 只保留当前 turn: 其余仍开着的 turn 记录是漏收的 close, 一并扫掉。
|
|
3120
3191
|
recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: [turnId] });
|
|
3121
3192
|
};
|
|
@@ -3382,6 +3453,8 @@ export const startMirror = (deps) => {
|
|
|
3382
3453
|
return;
|
|
3383
3454
|
}
|
|
3384
3455
|
if (item.kind === "turn_end") {
|
|
3456
|
+
if (a.activeEventId && !item.soft)
|
|
3457
|
+
deps.events?.finished(a.activeEventId);
|
|
3385
3458
|
closeBriefTurn(a);
|
|
3386
3459
|
return;
|
|
3387
3460
|
}
|
|
@@ -3590,7 +3663,7 @@ export const startMirror = (deps) => {
|
|
|
3590
3663
|
if (item.kind === "user_text") {
|
|
3591
3664
|
// 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
|
|
3592
3665
|
// 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
|
|
3593
|
-
if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts))
|
|
3666
|
+
if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts, item.eventId))
|
|
3594
3667
|
return;
|
|
3595
3668
|
endKeepaliveSwallow(a);
|
|
3596
3669
|
// 对话边界 = 旧 turn 清算。上一 turn 派发而未返回的前台 Agent/Task 到此作废,
|
|
@@ -3605,19 +3678,20 @@ export const startMirror = (deps) => {
|
|
|
3605
3678
|
a.activePeerTurn = undefined;
|
|
3606
3679
|
a.pendingFrom = undefined;
|
|
3607
3680
|
a.pendingChannel = undefined;
|
|
3681
|
+
a.activeEventId = item.eventId;
|
|
3608
3682
|
a.pendingFromCli = true;
|
|
3609
3683
|
if (!cfg.wrc.mirror.brief)
|
|
3610
3684
|
consumeChannel(a, true, undefined);
|
|
3611
3685
|
}
|
|
3612
3686
|
if (item.kind === "user_inject") {
|
|
3613
|
-
interject(a, item.said, item.ts);
|
|
3687
|
+
interject(a, item.said, item.ts, item.eventId);
|
|
3614
3688
|
return;
|
|
3615
3689
|
}
|
|
3616
3690
|
// 还有一轮等着懒建 (peer / graph 注入先记了 pendingBriefQuery) 就换掉它; 否则这句话
|
|
3617
3691
|
// 就是已开好的那一轮 (IM 侧说的, startBriefTurn 在注入前建好)。
|
|
3618
3692
|
if (item.kind === "user_query") {
|
|
3619
3693
|
// 同伴 / 定时 / graph 注入的那一行: 问话就是印章里的原话 (不带信封)。
|
|
3620
|
-
const sealed = bindLine(a, item.said, item.ts);
|
|
3694
|
+
const sealed = bindLine(a, item.said, item.ts, item.eventId);
|
|
3621
3695
|
if (sealed && !sealed.turn)
|
|
3622
3696
|
return;
|
|
3623
3697
|
if (a.pendingBriefQuery !== undefined || !a.briefTurnId) {
|
|
@@ -3840,6 +3914,19 @@ export const startMirror = (deps) => {
|
|
|
3840
3914
|
}
|
|
3841
3915
|
}
|
|
3842
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
|
+
}
|
|
3843
3930
|
if (!cfg.wrc.mirror.brief && isPrivate(a))
|
|
3844
3931
|
return;
|
|
3845
3932
|
// /goal mode overrides everything below: the session-scoped Stop hook
|
|
@@ -5435,7 +5522,16 @@ export const startMirror = (deps) => {
|
|
|
5435
5522
|
const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
|
|
5436
5523
|
return live?.status === "idle" && hanging(target, live.sessionId);
|
|
5437
5524
|
};
|
|
5438
|
-
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
|
+
};
|
|
5439
5535
|
// 等到它闲下来, 事件驱动: 注册表目录一有写入就重判, 翻成 idle 的那一刻即返回。
|
|
5440
5536
|
// 兜底轮询只防 watch 漏事件 (以及悬着的审批被点掉后 status 没再动的边角)。
|
|
5441
5537
|
const IDLE_SAFETY_POLL_MS = 1000;
|
|
@@ -6294,7 +6390,7 @@ export const startMirror = (deps) => {
|
|
|
6294
6390
|
a.pendingFromCli = false;
|
|
6295
6391
|
a.turnFromChat = true;
|
|
6296
6392
|
rememberInject(full);
|
|
6297
|
-
pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
|
|
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 });
|
|
6298
6394
|
a.muteUntilInject = false;
|
|
6299
6395
|
a.justSpawned = false;
|
|
6300
6396
|
};
|
|
@@ -6306,7 +6402,7 @@ export const startMirror = (deps) => {
|
|
|
6306
6402
|
const r = await inject({
|
|
6307
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" }),
|
|
6308
6404
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
|
|
6309
|
-
requireIdle: opts?.requireIdle ? () =>
|
|
6405
|
+
requireIdle: opts?.requireIdle ? () => idleNow(target) : undefined,
|
|
6310
6406
|
prepareText: () => (full = text + envelope + noticeSuffixFor(target, text)),
|
|
6311
6407
|
beforeInput,
|
|
6312
6408
|
});
|
|
@@ -6321,9 +6417,15 @@ export const startMirror = (deps) => {
|
|
|
6321
6417
|
return r;
|
|
6322
6418
|
},
|
|
6323
6419
|
interruptPane: async (target, opts) => {
|
|
6420
|
+
if (opts?.guard && !opts.guard())
|
|
6421
|
+
return { ok: false, reason: "interrupt authority changed" };
|
|
6324
6422
|
const a = byTarget.get(target);
|
|
6325
6423
|
if (!a)
|
|
6326
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);
|
|
6327
6429
|
// `/stop` (teardown: true) must work when tmux itself is the problem, so
|
|
6328
6430
|
// the order is: local state first, pane second. The old implementation
|
|
6329
6431
|
// probed the pane FIRST and returned early on failure — meaning a wedged
|
|
@@ -6360,7 +6462,13 @@ export const startMirror = (deps) => {
|
|
|
6360
6462
|
escReason = "tmux pane no longer alive";
|
|
6361
6463
|
}
|
|
6362
6464
|
else {
|
|
6363
|
-
|
|
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
|
+
});
|
|
6364
6472
|
escOk = r.ok;
|
|
6365
6473
|
if (!r.ok)
|
|
6366
6474
|
escReason = `send-keys Escape failed: ${(r.stderr || r.stdout).slice(-200) || r.code}`;
|
|
@@ -6375,7 +6483,13 @@ export const startMirror = (deps) => {
|
|
|
6375
6483
|
return { ok: false, reason: escReason };
|
|
6376
6484
|
return { ok: true, torndown, escOk, escReason: escReason || undefined };
|
|
6377
6485
|
},
|
|
6378
|
-
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
|
+
},
|
|
6379
6493
|
sleepPane: (target) => downPane(target, false),
|
|
6380
6494
|
lastActivity: (target) => {
|
|
6381
6495
|
const jsonl = jsonlOf(target);
|
|
@@ -6650,7 +6764,11 @@ export const startMirror = (deps) => {
|
|
|
6650
6764
|
const a = byTarget.get(target);
|
|
6651
6765
|
return a?.channel ?? baseOfKey(target);
|
|
6652
6766
|
},
|
|
6653
|
-
|
|
6767
|
+
formatFinal,
|
|
6768
|
+
recoverEventFinal,
|
|
6769
|
+
setEventFinalGate: (gate) => { eventFinalGate = gate; },
|
|
6770
|
+
settleFinalProgress,
|
|
6771
|
+
dispatch: async ({ principal, text, images, frame, streamId, channel, speaker, eventId, guard, onSubmitted }) => {
|
|
6654
6772
|
// Route by inbound principal — the WeCom chat the user just messaged us
|
|
6655
6773
|
// from is the same string we registered as `target` on attach. After a
|
|
6656
6774
|
// daemon reload the in-memory map is empty; restore from the persisted
|
|
@@ -6668,6 +6786,10 @@ export const startMirror = (deps) => {
|
|
|
6668
6786
|
}
|
|
6669
6787
|
return;
|
|
6670
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
|
+
}
|
|
6671
6793
|
// `/clear` resets to an empty session — nothing in the cache to keep warm,
|
|
6672
6794
|
// so pause keepalive like `/stop` instead of lifting it. Every other inbound
|
|
6673
6795
|
// is real work: lift any prior pause and re-anchor both clocks to now.
|
|
@@ -6789,11 +6911,13 @@ export const startMirror = (deps) => {
|
|
|
6789
6911
|
log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
|
|
6790
6912
|
}
|
|
6791
6913
|
}
|
|
6914
|
+
if (!brief)
|
|
6915
|
+
a.activeEventId = eventId;
|
|
6792
6916
|
if (brief) {
|
|
6793
6917
|
// 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
|
|
6794
6918
|
// 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
|
|
6795
6919
|
// 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
|
|
6796
|
-
await startBriefTurn(a, frame, streamId, isSlash, effText, channel ?? baseOfKey(principal), speaker);
|
|
6920
|
+
await startBriefTurn(a, frame, streamId, isSlash, effText, channel ?? baseOfKey(principal), speaker, eventId);
|
|
6797
6921
|
}
|
|
6798
6922
|
else if (!armMigration && !eagerOpen) {
|
|
6799
6923
|
enterDeferred(a, frame, streamId);
|
|
@@ -6881,7 +7005,7 @@ export const startMirror = (deps) => {
|
|
|
6881
7005
|
return;
|
|
6882
7006
|
}
|
|
6883
7007
|
if (!brief)
|
|
6884
|
-
pushSeal(a, { keys: sealKeys(effText), at: Date.now(), query: effText, channel: channel ?? baseOfKey(principal), speaker, fresh: true });
|
|
7008
|
+
pushSeal(a, { eventId: a.activeEventId, keys: sealKeys(effText), at: Date.now(), query: effText, channel: channel ?? baseOfKey(principal), speaker, fresh: true });
|
|
6885
7009
|
rememberInject(effText);
|
|
6886
7010
|
// 解除静默必须在 inject 之前: inject 内部的 freshSpawn 验证循环可达 10+s
|
|
6887
7011
|
// (paste-verify + settle + waitForCleared + retry), 而 LLM 的回复可能在 Enter
|
|
@@ -6894,8 +7018,10 @@ export const startMirror = (deps) => {
|
|
|
6894
7018
|
const r = await inject({
|
|
6895
7019
|
text: effText, images: effImages, cfg, log: log.child({ principal, sessionId: sid }),
|
|
6896
7020
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
|
|
6897
|
-
|
|
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)),
|
|
6898
7023
|
});
|
|
7024
|
+
onSubmitted?.(r);
|
|
6899
7025
|
if (!r.ok) {
|
|
6900
7026
|
if (s) {
|
|
6901
7027
|
s.acc = s.acc ? `${s.acc}\n\n[mirror] ✗ ${r.reason ?? "failed"}` : `[mirror] ✗ ${r.reason ?? "failed"}`;
|