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.
@@ -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, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey, sameBot } from "../shared/session-label.js";
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
- if (deps.isOwnInject(text))
580
- return [{ kind: "user_query", body: unwrapPasted(c).trim(), said: text, ts: lineTs(line) }];
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
- if (args.requireIdle && !args.requireIdle())
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 && args.requireIdle && !args.requireIdle()) || (args.guard && !args.guard(phase)))
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
- await client.replyStream(held.bubble.frame, held.bubble.streamId, standalone ? progress : content, true);
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 client.sendMessage(channel.replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content } });
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 s = takeSeal(a, said);
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
- // 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
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 said = head(a.jsonlPath ? lastUserTurn(a.jsonlPath)?.text : undefined);
3009
- const r = said ? openTurnsOf(a.target, a.sessionId).find((t) => head(t.userQuery) === said) : undefined;
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
- if (answerSilent(a)) {
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) => idleVerdict(target) ?? !(await isBusy(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 ? () => idleVerdict(target) === true : undefined,
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
- const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
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) => downPane(target, true),
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
- dispatch: async ({ principal, text, images, frame, streamId, channel, speaker }) => {
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
- guard: () => byTarget.get(a.target) === a && a.tmuxPane === injectPane && (a.injectGen ?? 0) === myGen,
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"}`;