wezard 2.7.3 → 2.7.7

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,6 +1,6 @@
1
- import { finalId } from "./message-events.js";
1
+ import { finalId, receiptCoverage, receiptQuiet } from "./message-events.js";
2
2
  import { parseEnvelope, renderTail } from "../shared/reminder.js";
3
- import { codexSessions, codexSessionForPane } from "../shared/codex.js";
3
+ import { codexSessions, codexSessionForPane, recordCodexPaneSession } from "../shared/codex.js";
4
4
  import { codexHookLaunchArgs, codexHookCommand } from "../shared/codex-hooks.js";
5
5
  import { fileURLToPath } from "node:url";
6
6
  // Mirror-mode bridge.
@@ -48,7 +48,7 @@ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, windowLa
48
48
  import { splitMarkdown } from "../shared/md-chunk.js";
49
49
  import { isWxFrame } from "../shared/wx-text.js";
50
50
  import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
51
- import { stripAnsi, paneIsBusy, codexTurnBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, lastUserTurn, talkRounds, openToolUses, turnClosed, renderDialog, stripForChat } from "./peers.js";
51
+ import { stripAnsi, paneIsBusy, codexTurnBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, lastUserTurn, talkRounds, openToolUses, turnClosed, renderDialog, stripForChat, parseClosing } from "./peers.js";
52
52
  // PATH augmentation: launchd / systemd start the daemon
53
53
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
54
54
  // Backend resolution is per-transcript, not global: `backendForPath` recovers
@@ -1305,6 +1305,9 @@ export const parseInputBox = (cap, styledLayout = cap) => {
1305
1305
  // Idle Codex hides the left hint after a paste but keeps a right-aligned
1306
1306
  // warnings footer. Its middle dot is not the status row's separator.
1307
1307
  const warnings = /^\s*⚠\s*\d+\s+warnings?\s*·\s*f2 to view\s*$/u.test(layout[footer] ?? "");
1308
+ // --no-alt-screen 的 composer 没有背景 SGR; 原生 Context + cwd 行才是状态锚, warning 的 · 不能充当它。
1309
+ const inlineStatus = /\bContext\s+\d+%\s+used\b/u.test(layout[footer] ?? "")
1310
+ && /·\s+(?:\/|~\/)/u.test(layout[footer] ?? "");
1308
1311
  if (inputHint || warnings || (layout[footer] ?? "").includes("·")) {
1309
1312
  let status = inputHint || warnings ? footer - 1 : footer;
1310
1313
  while (status >= 0 && !layout[status].trim())
@@ -1318,7 +1321,7 @@ export const parseInputBox = (cap, styledLayout = cap) => {
1318
1321
  prompt--;
1319
1322
  if (prompt >= 0 && /^›(?:\s|$)/u.test(layout[prompt])
1320
1323
  && (prompt === 0 || !layout[prompt - 1].trim())
1321
- && (inputHint || /\x1b\[[0-9;:]*48[;:]/u.test(styledLines[prompt]))) {
1324
+ && (inputHint || inlineStatus || /\x1b\[[0-9;:]*48[;:]/u.test(styledLines[prompt]))) {
1322
1325
  const body = dropDim(styledLines.slice(prompt, status).join("\n"))
1323
1326
  .replace(/^\s*›\s?/u, "").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
1324
1327
  return { known: true, body };
@@ -1555,7 +1558,7 @@ const witnessFor = (target, homeDir, jsonlPath, live, actualTranscript) => {
1555
1558
  // but the trailing Enter is eaten while the TUI is still initializing, so
1556
1559
  // the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
1557
1560
  // 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
1558
- const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard, eventId, expectedSessionId) => {
1561
+ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard, eventId, expectedSessionId, physicalGuard) => {
1559
1562
  const backendName = backend.name;
1560
1563
  log.info({ target, eventId, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
1561
1564
  // 就绪门: pane 里得真有一个进入了交互态的 AI 会话, 才轮得到贴。先于 modal guard ——
@@ -1663,7 +1666,7 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1663
1666
  // dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
1664
1667
  // withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
1665
1668
  if (backendName === "codebuddy") {
1666
- return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, boundGuard, eventId, bypassModalGuard);
1669
+ return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, boundGuard, eventId, bypassModalGuard, physicalGuard);
1667
1670
  }
1668
1671
  // Pump images first via clipboard+C-v so each one is attached as a separate
1669
1672
  // image content block. Each C-v needs a brief settle for Claude Code's TUI
@@ -1715,7 +1718,7 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1715
1718
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1716
1719
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
1717
1720
  }
1718
- return injectViaTmuxText(target, text, log, freshSpawn, witness, boundGuard, eventId, bypassModalGuard);
1721
+ return injectViaTmuxText(target, text, log, freshSpawn, witness, boundGuard, eventId, bypassModalGuard, physicalGuard);
1719
1722
  };
1720
1723
  // 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
1721
1724
  // 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
@@ -1737,7 +1740,7 @@ const withImageRefs = (text, images) => {
1737
1740
  // 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
1738
1741
  // 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
1739
1742
  // 每次补之前再问一遍证人, 已提交就不补。
1740
- const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard, eventId, bypassModalGuard = false) => {
1743
+ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard, eventId, bypassModalGuard = false, physicalGuard) => {
1741
1744
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1742
1745
  // started, transcript still loading): extended timings — bracketed-paste
1743
1746
  // end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
@@ -1793,7 +1796,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1793
1796
  const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === baseBody);
1794
1797
  const loadAndPaste = async () => {
1795
1798
  const start = Date.now();
1796
- const result = await pasteTmuxBuffer(target, text, guard ? () => guard("paste") : undefined);
1799
+ const result = await pasteTmuxBuffer(target, text, async () => (!physicalGuard || await physicalGuard("paste")) && (!guard || guard("paste")));
1797
1800
  log.info({ target, eventId, tmuxPasteMs: Date.now() - start, boxVerify, ok: result.ok }, "mirror inject: paste completed");
1798
1801
  return result;
1799
1802
  };
@@ -1844,6 +1847,8 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1844
1847
  let pastedAt = Date.now();
1845
1848
  if (alreadyTyped) {
1846
1849
  // Existing text is already exposed, even if permission was just withdrawn.
1850
+ if (physicalGuard && !(await physicalGuard("submit")))
1851
+ return { ok: false, uncertain: true, reason: "original handoff CLI identity changed before existing input submission" };
1847
1852
  if (guard && !guard("paste"))
1848
1853
  return { ok: false, uncertain: true, reason: "existing input submission canceled by dispatch guard" };
1849
1854
  log.warn({ target, headFp }, "mirror inject: 输入框里已有这段文本 (上次未提交) — 不重贴, 直接提交");
@@ -1939,6 +1944,8 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1939
1944
  if (!box.known || box.body !== landedBody)
1940
1945
  return { ok: false, reason: "composer changed after paste verification; refusing Enter" };
1941
1946
  }
1947
+ if (physicalGuard && !(await physicalGuard("submit")))
1948
+ return { ok: false, reason: "original handoff CLI identity changed before submit" };
1942
1949
  if (guard && !guard("submit"))
1943
1950
  return { ok: false, reason: "submit canceled by dispatch guard" };
1944
1951
  if (tailFp && !Number.isFinite(enterAt)) {
@@ -2171,7 +2178,7 @@ const inject = async (args) => {
2171
2178
  }
2172
2179
  return true;
2173
2180
  };
2174
- return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard, args.eventId, args.sessionId);
2181
+ return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard, args.eventId, args.sessionId, args.physicalGuard);
2175
2182
  }, { guard: args.guard }));
2176
2183
  }
2177
2184
  catch (error) {
@@ -2365,7 +2372,98 @@ export const startMirror = (deps) => {
2365
2372
  };
2366
2373
  const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
2367
2374
  let eventFinalGate = () => true;
2368
- let eventHandedAway = () => false;
2375
+ let eventProgressGate = () => false;
2376
+ let eventOwnerGate = () => true;
2377
+ const eventOwned = (id) => {
2378
+ const event = id ? deps.events?.get(id) : undefined;
2379
+ return !event || eventOwnerGate(event);
2380
+ };
2381
+ const briefMode = (a) => cfg.wrc.mirror.brief
2382
+ || !!(a.activeEventId && (deps.events?.get(a.activeEventId)?.payload.peer?.transferred || deps.events?.get(a.activeEventId)?.payload.peer?.from.receipt));
2383
+ const bubbleOwned = (a, b) => {
2384
+ const event = b.owner ? deps.events?.get(b.owner) : undefined;
2385
+ return !event || event.target === a.target && eventOwnerGate(event);
2386
+ };
2387
+ const replyBubbles = new Map();
2388
+ const rememberReplyBubble = (id, bubble) => {
2389
+ if (replyBubbles.has(id))
2390
+ return;
2391
+ replyBubbles.set(id, bubble);
2392
+ setTimeout(() => { if (replyBubbles.get(id) === bubble)
2393
+ replyBubbles.delete(id); }, Math.max(0, (bubble.expires ?? Date.now()) - Date.now()) + 60_000).unref?.();
2394
+ };
2395
+ const claimReplyBubble = (event) => {
2396
+ if (!deps.events || !eventOwnerGate(event) || !event.channel)
2397
+ return undefined;
2398
+ const origin = deps.events.replyOrigin(event);
2399
+ const human = origin?.payload.human;
2400
+ if (!origin || !human)
2401
+ return undefined;
2402
+ const source = byTarget.get(origin.target);
2403
+ const out = source?.outbound;
2404
+ if (source && out?.streamId === human.streamId) {
2405
+ if (out.kind === "deferred")
2406
+ clearTimeout(out.timer);
2407
+ source.outbound = undefined;
2408
+ }
2409
+ let bubble = replyBubbles.get(origin.id);
2410
+ if (!bubble) {
2411
+ const expires = origin.at + HARD_TIMEOUT_MS;
2412
+ if (expires <= Date.now() || deps.events.hasReplyDelivery(human.streamId))
2413
+ return undefined;
2414
+ bubble = { frame: human.frame, streamId: human.streamId, expires, done: false, owner: event.id };
2415
+ rememberReplyBubble(origin.id, bubble);
2416
+ }
2417
+ if (bubble.done || Date.now() >= (bubble.expires ?? 0))
2418
+ return undefined;
2419
+ if (bubble.source) {
2420
+ const stream = bubble.source;
2421
+ stream.closed = true;
2422
+ clearTimeout(stream.flushTimer);
2423
+ clearTimeout(stream.idleTimer);
2424
+ clearTimeout(stream.hardTimer);
2425
+ bubble.pending = stream.pending;
2426
+ const source = byTarget.get(stream.target);
2427
+ if (source?.liveStream === stream)
2428
+ source.liveStream = undefined;
2429
+ bubble.source = undefined;
2430
+ }
2431
+ if (bubble.owner !== event.id) {
2432
+ // The same object carries the last in-flight write into the receiver's FIFO.
2433
+ // Old final/cleanup/timer paths lose authority before this object is reused.
2434
+ byTarget.forEach((a) => {
2435
+ if (a.briefBubble === bubble) {
2436
+ clearCot(a);
2437
+ a.briefBubble = undefined;
2438
+ }
2439
+ });
2440
+ clearTimeout(bubble.hardTimer);
2441
+ bubble.owner = event.id;
2442
+ }
2443
+ return bubble;
2444
+ };
2445
+ const transferReply = (event) => {
2446
+ const bubble = claimReplyBubble(event);
2447
+ if (!bubble)
2448
+ return;
2449
+ const origin = deps.events?.replyOrigin(event);
2450
+ bubble.pending = (bubble.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2451
+ if (bubble.done || bubble.owner !== event.id || !eventOwnerGate(event))
2452
+ return;
2453
+ await client.replyStream(bubble.frame, bubble.streamId, withLinkedTagHeader(event.target, "…", chatTurnUrl(event.target, event.turn || origin?.turn), undefined, undefined, event.channel), false);
2454
+ }).catch((e) => { log.debug({ event: event.id, err: errText(e) }, "transfer reply refresh failed"); });
2455
+ };
2456
+ const armBriefBubble = (a, b, turnId, channel) => {
2457
+ clearTimeout(b.hardTimer);
2458
+ const owner = b.owner;
2459
+ if (!isWxFrame(b.frame))
2460
+ b.hardTimer = setTimeout(() => {
2461
+ if (b.owner !== owner || !bubbleOwned(a, b))
2462
+ return;
2463
+ const live = a.briefBubble === b && !a.briefConcluded;
2464
+ void finishBubble(a, b, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
2465
+ }, Math.max(0, (b.expires ?? Date.now()) - Date.now()));
2466
+ };
2369
2467
  const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2370
2468
  const newMarkId = () => `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2371
2469
  const detailCardFor = (s, target) => {
@@ -2383,13 +2481,13 @@ export const startMirror = (deps) => {
2383
2481
  };
2384
2482
  const flushStream = async (s) => {
2385
2483
  s.flushTimer = undefined;
2386
- if (s.closed || s.dead || s.acc === s.lastSent)
2484
+ if (s.closed || s.dead || s.acc === s.lastSent || !eventOwned(s.eventId))
2387
2485
  return;
2388
2486
  const content = s.acc;
2389
2487
  s.lastSent = content;
2390
2488
  try {
2391
2489
  await (s.pending = (s.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2392
- if (s.closed || s.dead)
2490
+ if (s.closed || s.dead || !eventOwned(s.eventId))
2393
2491
  return;
2394
2492
  await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
2395
2493
  }));
@@ -2430,10 +2528,12 @@ export const startMirror = (deps) => {
2430
2528
  // 例外是 hard cap 超时: turn 还在跑, 收口时缀上中间结束语 —— 这时即使没正文
2431
2529
  // 也有话可说, 不算空覆盖。
2432
2530
  const body = timedOut ? [s.acc, STILL_WORKING].filter((x) => x.trim()).join("\n\n") : s.acc;
2433
- if (!s.dead && body.trim()) {
2531
+ if (!s.dead && body.trim() && eventOwned(s.eventId)) {
2434
2532
  const card = detailCardFor(s, a.target);
2435
2533
  try {
2436
2534
  await s.pending?.catch(() => undefined);
2535
+ if (!eventOwned(s.eventId))
2536
+ return;
2437
2537
  if (card) {
2438
2538
  s.cardSent = true;
2439
2539
  await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
@@ -2462,8 +2562,9 @@ export const startMirror = (deps) => {
2462
2562
  if (a.liveStream === s)
2463
2563
  a.liveStream = undefined;
2464
2564
  };
2465
- const openStream = (a, frame, streamId) => {
2565
+ const openStream = (a, frame, streamId, eventId = a.activeEventId) => {
2466
2566
  const s = {
2567
+ eventId,
2467
2568
  turnId: newTurnId(),
2468
2569
  frame, streamId,
2469
2570
  target: a.target,
@@ -2472,6 +2573,11 @@ export const startMirror = (deps) => {
2472
2573
  capped: false, closed: false, dead: false, cardSent: false,
2473
2574
  tools: [], sawTool: false,
2474
2575
  };
2576
+ const event = eventId ? deps.events?.get(eventId) : undefined;
2577
+ if (event?.payload.human)
2578
+ rememberReplyBubble(event.id, {
2579
+ frame, streamId, done: false, owner: event.id, source: s, expires: event.at + HARD_TIMEOUT_MS,
2580
+ });
2475
2581
  // 微信帧没有 stream 窗口: 到点收口只会多发一条「仍在处理中」, 白占它那几格出站额度。
2476
2582
  if (!isWxFrame(frame))
2477
2583
  s.hardTimer = setTimeout(() => void finalizeStream(a, s, true), HARD_TIMEOUT_MS);
@@ -2496,7 +2602,8 @@ export const startMirror = (deps) => {
2496
2602
  // turnSilent 已被拦下; 非 brief 的逐条推送在这里兜一道 —— 只放 `[mirror]` /
2497
2603
  // `[wezard]` 系统提示 (它们回 home)。
2498
2604
  const sendStandalone = (a, content) => {
2499
- if (!hasVisibleBody(content))
2605
+ const eventId = a.activeEventId;
2606
+ if (!hasVisibleBody(content) || !eventOwned(eventId))
2500
2607
  return;
2501
2608
  if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
2502
2609
  return;
@@ -2507,6 +2614,8 @@ export const startMirror = (deps) => {
2507
2614
  a.standalonePending = a.standalonePending
2508
2615
  .then(async () => {
2509
2616
  for (const c of chunks) {
2617
+ if (!eventOwned(eventId))
2618
+ return;
2510
2619
  try {
2511
2620
  const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
2512
2621
  log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
@@ -2520,12 +2629,15 @@ export const startMirror = (deps) => {
2520
2629
  };
2521
2630
  // Like sendStandalone but skips withTagHeader — content already contains the tag header (e.g. as a link).
2522
2631
  const sendRaw = (a, content) => {
2523
- if (!hasVisibleBody(content))
2632
+ const eventId = a.activeEventId;
2633
+ if (!hasVisibleBody(content) || !eventOwned(eventId))
2524
2634
  return;
2525
2635
  const chatId = replyChatId(a);
2526
2636
  const turnId = a.briefTurnId ?? a.liveStream?.turnId;
2527
2637
  a.standalonePending = a.standalonePending
2528
2638
  .then(async () => {
2639
+ if (!eventOwned(eventId))
2640
+ return;
2529
2641
  try {
2530
2642
  await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
2531
2643
  log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
@@ -2634,13 +2746,22 @@ export const startMirror = (deps) => {
2634
2746
  };
2635
2747
  // Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
2636
2748
  // standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
2749
+ const discardUnownedOutbound = (a, out) => {
2750
+ if (eventOwned(out.eventId))
2751
+ return false;
2752
+ if (out.kind === "deferred")
2753
+ clearTimeout(out.timer);
2754
+ if (a.outbound === out)
2755
+ a.outbound = undefined;
2756
+ return true;
2757
+ };
2637
2758
  const promoteToStandalone = (a) => {
2638
2759
  const out = a.outbound;
2639
- if (out?.kind !== "deferred")
2760
+ if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
2640
2761
  return;
2641
2762
  clearTimeout(out.timer);
2642
2763
  const md = renderBuf(out.buf);
2643
- a.outbound = { kind: "awaiting_appr", frame: out.frame, streamId: out.streamId };
2764
+ a.outbound = { kind: "awaiting_appr", eventId: out.eventId, frame: out.frame, streamId: out.streamId };
2644
2765
  flushPendingStandalone(a);
2645
2766
  if (md)
2646
2767
  sendStandalone(a, md);
@@ -2660,7 +2781,7 @@ export const startMirror = (deps) => {
2660
2781
  // Pure-text turn or no trailing final text → no split, all into streamId.
2661
2782
  const exitDeferredAsFinalStream = (a) => {
2662
2783
  const out = a.outbound;
2663
- if (out?.kind !== "deferred")
2784
+ if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
2664
2785
  return;
2665
2786
  clearTimeout(out.timer);
2666
2787
  const buf = out.buf;
@@ -2685,15 +2806,15 @@ export const startMirror = (deps) => {
2685
2806
  try {
2686
2807
  // 有正文才写旧 streamId (收入 stream); 空缓冲一个字都不写 —— 否则
2687
2808
  // loading 气泡被 " " 覆盖, 结束处理自己凭空制造一条空消息。
2688
- if (bubbleMd)
2809
+ if (bubbleMd && eventOwned(out.eventId))
2689
2810
  await client.replyStream(out.frame, out.streamId, withLinkedTag(a, bubbleMd), true);
2690
2811
  }
2691
2812
  catch (e) {
2692
2813
  log.warn({ sessionId: a.sessionId, err: e.message }, "exit-deferred finalize failed; falling back to standalone");
2693
- if (bubbleMd)
2814
+ if (bubbleMd && eventOwned(out.eventId))
2694
2815
  sendStandalone(a, bubbleMd);
2695
2816
  }
2696
- if (trailingMd)
2817
+ if (trailingMd && eventOwned(out.eventId))
2697
2818
  sendStandalone(a, trailingMd);
2698
2819
  })();
2699
2820
  log.info({ sessionId: a.sessionId, items: buf.length, bubbleLen: bubbleMd.length, trailingLen: trailingMd.length, sawTool }, "outbound: DEFERRED → IDLE (turn_end, finalized to streamId)");
@@ -2703,17 +2824,19 @@ export const startMirror = (deps) => {
2703
2824
  // STREAMING branch since outbound is now undefined.
2704
2825
  const promoteToStream = (a) => {
2705
2826
  const out = a.outbound;
2706
- if (out?.kind !== "deferred")
2827
+ if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
2707
2828
  return;
2708
2829
  const { buf, frame, streamId } = out;
2709
2830
  a.outbound = undefined;
2710
- const s = openStream(a, frame, streamId);
2831
+ const s = openStream(a, frame, streamId, out.eventId);
2711
2832
  a.liveStream = s;
2712
2833
  if (buf.length === 0) {
2713
2834
  // Empty buffer — claude still thinking. Send the "…" ack so the user
2714
2835
  // sees the bubble; subsequent items grow it as today.
2715
2836
  void (async () => {
2716
2837
  try {
2838
+ if (!eventOwned(s.eventId) || s.closed)
2839
+ return;
2717
2840
  await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
2718
2841
  }
2719
2842
  catch (e) {
@@ -2736,7 +2859,7 @@ export const startMirror = (deps) => {
2736
2859
  // an empty buffer before any tool_use can be evaluated for approval-needs.
2737
2860
  const SAFETY_MS = 5 * 60_000;
2738
2861
  const timer = setTimeout(() => promoteToStream(a), SAFETY_MS);
2739
- a.outbound = { kind: "deferred", buf: [], frame, streamId, timer };
2862
+ a.outbound = { kind: "deferred", eventId: a.activeEventId, buf: [], frame, streamId, timer };
2740
2863
  log.info({ sessionId: a.sessionId, safetyMs: SAFETY_MS }, "outbound: IDLE → DEFERRED (safety net)");
2741
2864
  };
2742
2865
  // Buffer / decide while in DEFERRED. user_text is CLI-side typing (not the
@@ -2745,7 +2868,7 @@ export const startMirror = (deps) => {
2745
2868
  // subsequent items don't reset (bounded promote delay).
2746
2869
  const handleDeferredItem = (a, item) => {
2747
2870
  const out = a.outbound;
2748
- if (out?.kind !== "deferred")
2871
+ if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
2749
2872
  return;
2750
2873
  if (item.kind === "turn_end") {
2751
2874
  exitDeferredAsFinalStream(a);
@@ -2813,6 +2936,10 @@ export const startMirror = (deps) => {
2813
2936
  a.cotText = undefined;
2814
2937
  a.cotTool = undefined;
2815
2938
  };
2939
+ const receiptProgress = (a, text) => {
2940
+ const e = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
2941
+ return !e?.payload.peer?.from.receipt || (!e.quiet && !(text && receiptQuiet(e, text)) && eventProgressGate(e));
2942
+ };
2816
2943
  const flushCot = async (a) => {
2817
2944
  a.cotTimer = undefined;
2818
2945
  // 展示选取: assistant text (含 thinking) 优先; 没有新 text 时才落最新 tool_use。
@@ -2822,12 +2949,12 @@ export const startMirror = (deps) => {
2822
2949
  const b = a.briefBubble;
2823
2950
  const turnId = a.briefTurnId;
2824
2951
  // 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
2825
- if (!text || !b || b.done || !turnId || a.briefConcluded)
2952
+ if (!text || !b || b.done || !turnId || a.briefConcluded || !bubbleOwned(a, b) || !receiptProgress(a, text))
2826
2953
  return;
2827
2954
  a.cotLastSent = text;
2828
2955
  try {
2829
2956
  await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2830
- if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId)
2957
+ if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || !bubbleOwned(a, b) || !receiptProgress(a, text))
2831
2958
  return;
2832
2959
  await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, text), false);
2833
2960
  }));
@@ -2839,7 +2966,7 @@ export const startMirror = (deps) => {
2839
2966
  /** 推进气泡里的 CoT 进度。节流窗口内到达的多条只留最后一条 —— 进度永远只展示"最新"。 */
2840
2967
  const updateBriefProgress = (a, raw) => {
2841
2968
  const b = a.briefBubble;
2842
- if (!b || b.done || a.briefConcluded)
2969
+ if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b) || !receiptProgress(a, raw))
2843
2970
  return;
2844
2971
  const line = cotLine(raw);
2845
2972
  if (!line)
@@ -2853,7 +2980,7 @@ export const startMirror = (deps) => {
2853
2980
  * 只有没有新 text 的窗口才把最新 tool 行刷上气泡。节流与 text 共用一个计时器。 */
2854
2981
  const updateBriefToolProgress = (a, raw) => {
2855
2982
  const b = a.briefBubble;
2856
- if (!b || b.done || a.briefConcluded)
2983
+ if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b) || !receiptProgress(a, raw))
2857
2984
  return;
2858
2985
  const line = cotLine(raw);
2859
2986
  if (!line)
@@ -2875,7 +3002,7 @@ export const startMirror = (deps) => {
2875
3002
  // `[mirror]` 系统提示 —— 它们都不走这道门。
2876
3003
  // 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
2877
3004
  // 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
2878
- const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
3005
+ const turnSilent = (a) => !eventOwned(a.activeEventId) || isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
2879
3006
  const formatFinal = (target, turn, channel, body) => {
2880
3007
  const pieces = splitMarkdown(body, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
2881
3008
  return pieces.map((text, n) => withLinkedTagHeader(target, text, chatTurnUrl(target, turn), pieces.length > 1 ? `${n + 1}/${pieces.length}` : undefined, undefined, channel));
@@ -2885,7 +3012,7 @@ export const startMirror = (deps) => {
2885
3012
  await finalProgress.get(id)?.catch(() => undefined);
2886
3013
  finalProgress.delete(id);
2887
3014
  };
2888
- const queueFinal = (target, turn, channel, body, eventId, bubble) => {
3015
+ const queueFinal = (target, turn, channel, body, eventId, bubble, reportKind = parseClosing(body).kind === "need" ? "need" : "done") => {
2889
3016
  // 投递出口剥掉行首收口标记与 ARTIFACT 行 (peers.stripForChat): 判定读原文, 群里只看结论。
2890
3017
  const text = stripForChat(body);
2891
3018
  if (!deps.events || !channel || !text.trim())
@@ -2895,21 +3022,25 @@ export const startMirror = (deps) => {
2895
3022
  const chunks = formatFinal(target, turn, channel, text);
2896
3023
  if (bubble?.pending)
2897
3024
  finalProgress.set(id, bubble.pending);
2898
- deps.events.delivery({ id, eventId, target, turn, channel, body: text, revision, chunks,
3025
+ const claimed = deps.events.delivery({ id, eventId, target, turn, channel, body: text, revision, chunks, reportKind,
3026
+ ...(eventId && deps.events.get(eventId) ? { coverage: receiptCoverage(deps.events.get(eventId), channel, parseClosing(body).kind === "need", body) } : {}),
2899
3027
  ...(bubble && !bubble.done ? { stream: { frame: bubble.frame, id: bubble.streamId, expires: bubble.expires ?? Date.now() } } : {}) });
3028
+ if (claimed !== id || deps.events.deliveryStatus(id)?.every((s) => s === "delivered"))
3029
+ finalProgress.delete(id);
2900
3030
  if (bubble) {
2901
3031
  bubble.done = true;
2902
3032
  clearTimeout(bubble.hardTimer);
2903
3033
  }
2904
- return id;
3034
+ return claimed;
2905
3035
  };
2906
3036
  const recoverEventFinal = (event, bubble) => {
2907
- if (!event.result || event.interjected || event.canceled)
3037
+ if ((deps.events?.get(event.id) ?? event).handoffCut || event.quiet || !event.result || event.interjected || event.canceled || !eventOwnerGate(event))
2908
3038
  return;
2909
3039
  // 事件泵的 evidence 探针 (observe → io.recovered) 与 brief 软收口赛跑: 两者同为
2910
3040
  // ~1s 周期, 探针常先跑到, 而它调这里不带气泡 —— queueFinal 拿不到 stream, 终句
2911
3041
  // 全部走 standalone, 气泡定格在最后一行 CoT 进度。没传气泡时按 target/turn 找回
2912
3042
  // 活着的那只 (turn 已换人/气泡已收则不挂, 维持 standalone)。
3043
+ bubble ??= claimReplyBubble(event);
2913
3044
  bubble ??= (() => {
2914
3045
  const a = byTarget.get(event.target);
2915
3046
  return a && (!event.turn || a.briefTurnId === event.turn) && a.briefBubble && !a.briefBubble.done ? a.briefBubble : undefined;
@@ -2934,6 +3065,15 @@ export const startMirror = (deps) => {
2934
3065
  if (event.channel && eventFinalGate(event))
2935
3066
  queueFinal(event.target, turn, event.channel, event.result.body, event.id, bubble);
2936
3067
  };
3068
+ const deliverTransferredFinal = (event, body, status = "error") => {
3069
+ if (!eventOwnerGate(event) || !event.channel)
3070
+ return;
3071
+ const turn = event.turn || event.id;
3072
+ if (!getDetail(turn))
3073
+ recordTurnStart({ id: turn, eventId: event.id, target: event.target,
3074
+ userQuery: event.payload.text, createdAt: event.at, channel: event.channel, from: event.payload.peer?.from });
3075
+ queueFinal(event.target, turn, event.channel, body, event.id, claimReplyBubble(event), status);
3076
+ };
2937
3077
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
2938
3078
  // raw=true skips withTagHeader (used when content already contains the linked tag header).
2939
3079
  // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
@@ -2942,8 +3082,9 @@ export const startMirror = (deps) => {
2942
3082
  // standalone 自然排在 finish 之后; 额外加一小段延迟留给客户端渲染。
2943
3083
  const BUBBLE_FINISH_SETTLE_MS = 600;
2944
3084
  const finishBubble = async (a, b, content, raw = false) => {
2945
- if (!b || b.done)
3085
+ if (!b || b.done || !bubbleOwned(a, b))
2946
3086
  return;
3087
+ const owner = b.owner;
2947
3088
  b.done = true;
2948
3089
  clearTimeout(b.hardTimer);
2949
3090
  if (a.briefBubble === b)
@@ -2952,6 +3093,9 @@ export const startMirror = (deps) => {
2952
3093
  clearCot(a);
2953
3094
  const p = (async () => {
2954
3095
  try {
3096
+ await b.pending?.catch(() => undefined);
3097
+ if (b.owner !== owner || !bubbleOwned(a, b))
3098
+ return;
2955
3099
  await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
2956
3100
  // 给 WeCom 客户端留一点时间完成打字机动画, 再放行 standalone 队列。
2957
3101
  await new Promise((r) => setTimeout(r, BUBBLE_FINISH_SETTLE_MS));
@@ -2967,62 +3111,6 @@ export const startMirror = (deps) => {
2967
3111
  return p;
2968
3112
  };
2969
3113
  const finishBriefBubble = (a, content, raw = false) => finishBubble(a, a.briefBubble, content, raw);
2970
- // ── transfer 的气泡移交 ───────────────────────────────────────────────
2971
- // 人问的那一笔 transfer 出去, 气泡 (那句人话的 frame/streamId) 跟着行走: 从交出方这一轮摘下,
2972
- // 按接手方的注入事件 id 寄存, 接手方开出那一轮时 (ensureBriefTurn) 接过去 —— CoT 进度与终句
2973
- // 续写同一颗气泡, 不另开新的。寄存只在内存: reload 后丢了就退回 standalone, 与过期同。
2974
- const handedBubbles = new Map();
2975
- const writeBubble = (b, content, finish) => (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined)
2976
- .then(() => client.replyStream(b.frame, b.streamId, content, finish))
2977
- .catch((e) => log.warn({ err: e.message, finish }, "transfer: handed bubble write failed")));
2978
- /** 6min 窗口的兜底收口按气泡自己的到期点算 (它从人那句话起算, 不因转手重置)。 */
2979
- const untilExpiry = (b) => Math.max(0, (b.expires ?? Date.now()) - Date.now());
2980
- const handBubble = (source, serves, eventId, target) => {
2981
- const a = byTarget.get(source);
2982
- const b = a?.briefBubble;
2983
- const turnId = a?.briefTurnId;
2984
- // 只交「这一轮正服务着那一笔」的气泡: 别的轮的气泡与这次移交无关。
2985
- if (!a || !b || b.done || !turnId || !a.activeEventId || deps.events?.get(a.activeEventId)?.serves !== serves)
2986
- return false;
2987
- clearTimeout(b.hardTimer);
2988
- a.briefBubble = undefined;
2989
- clearCot(a);
2990
- const channel = a.channel || undefined;
2991
- const line = (rest) => headed(chatHead(target, chatTurnUrl(target, turnId), undefined, channel), rest) || "…";
2992
- void writeBubble(b, line("…"), false);
2993
- const held = { bubble: b };
2994
- // 接手方迟迟不开轮 (在忙 / 被并进别的轮): 到期前收口成「仍在处理中」, 终句届时走 standalone。
2995
- if (!isWxFrame(b.frame))
2996
- held.timer = setTimeout(() => {
2997
- if (handedBubbles.get(eventId) !== held)
2998
- return;
2999
- handedBubbles.delete(eventId);
3000
- b.done = true;
3001
- void writeBubble(b, line(STILL_WORKING), true);
3002
- }, untilExpiry(b));
3003
- handedBubbles.set(eventId, held);
3004
- log.info({ source, target, serves, eventId, turnId }, "transfer: bubble handed to new owner");
3005
- return true;
3006
- };
3007
- /** 接手方这一轮 (`a.activeEventId`) 若有寄存的气泡: 接成本轮气泡, 头换成它自己的链接。 */
3008
- const adoptBubble = (a, turnId) => {
3009
- const held = a.activeEventId ? handedBubbles.get(a.activeEventId) : undefined;
3010
- if (!held)
3011
- return undefined;
3012
- handedBubbles.delete(a.activeEventId);
3013
- clearTimeout(held.timer);
3014
- const b = held.bubble;
3015
- if (b.done || untilExpiry(b) === 0)
3016
- return undefined;
3017
- if (!isWxFrame(b.frame))
3018
- b.hardTimer = setTimeout(() => {
3019
- const live = a.briefBubble === b && !a.briefConcluded;
3020
- void finishBubble(a, b, briefLine(a, turnId, live ? STILL_WORKING : ""), true);
3021
- }, untilExpiry(b));
3022
- void writeBubble(b, briefLine(a, turnId, "…"), false);
3023
- log.info({ sessionId: a.sessionId, turnId, eventId: a.activeEventId }, "transfer: handed bubble adopted");
3024
- return b;
3025
- };
3026
3114
  // 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
3027
3115
  const openBriefTurn = (a, q) => {
3028
3116
  a.briefTurnId = q.turnId;
@@ -3065,7 +3153,7 @@ export const startMirror = (deps) => {
3065
3153
  /** 这一轮挂在哪一笔上: 回执 (与老派活信封) 写在信封上; 新派活信封不带, 问注入事件的 `serves`。 */
3066
3154
  const rowOf = (env) => env.event ?? (env.eventId ? deps.events?.get(env.eventId)?.serves : undefined);
3067
3155
  const openingOf = (a) => {
3068
- const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
3156
+ const t = a.jsonlPath ? lastUserTurn(a.jsonlPath, a.openingOffset) : undefined;
3069
3157
  const env = t?.env;
3070
3158
  a.activeEventId = env?.eventId;
3071
3159
  if (!env)
@@ -3239,7 +3327,7 @@ export const startMirror = (deps) => {
3239
3327
  a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: now };
3240
3328
  a.pendingBriefQuery = s.query;
3241
3329
  a.pendingBriefAt = ts;
3242
- if (!cfg.wrc.mirror.brief) {
3330
+ if (!briefMode(a)) {
3243
3331
  consumeChannel(a, false, consumeFrom(a));
3244
3332
  if (a.liveStream && !a.liveStream.closed)
3245
3333
  a.liveStream.dest = s.channel ?? baseOfKey(a.target);
@@ -3249,19 +3337,14 @@ export const startMirror = (deps) => {
3249
3337
  const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker, eventId) => {
3250
3338
  const turnId = newTurnId();
3251
3339
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
3252
- const bubble = { frame, streamId, done: false, expires: (eventId ? deps.events?.get(eventId)?.at ?? Date.now() : Date.now()) + HARD_TIMEOUT_MS };
3340
+ const bubble = { frame, streamId, done: false, owner: eventId, expires: (eventId ? deps.events?.get(eventId)?.at ?? Date.now() : Date.now()) + HARD_TIMEOUT_MS };
3253
3341
  if (eventId)
3254
3342
  deps.events?.bind(eventId, turnId);
3255
3343
  const q = { turnId, bubble, isSlash };
3256
3344
  // 微信帧不挂: 没有 stream 窗口要赶, 气泡等终句来了再一次发出 (见 openStream 同一条)。
3257
- if (!isWxFrame(frame))
3258
- bubble.hardTimer = setTimeout(() => {
3259
- // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。finish 是整条替换 ——
3260
- // 定格在一行过期的 CoT 进度上看着像卡死, 光链接又像说完了; 气泡仍是本轮活跃
3261
- // 气泡 (turn 还在跑) 就明说「仍在处理中」, 已被顶替的旧轮只留链接。
3262
- const live = a.briefBubble === bubble && !a.briefConcluded;
3263
- void finishBubble(a, bubble, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
3264
- }, HARD_TIMEOUT_MS);
3345
+ if (eventId)
3346
+ rememberReplyBubble(eventId, bubble);
3347
+ armBriefBubble(a, bubble, turnId, channel);
3265
3348
  const seal = { eventId, keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
3266
3349
  // 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
3267
3350
  // 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
@@ -3291,7 +3374,7 @@ export const startMirror = (deps) => {
3291
3374
  if (a.channelSeeded)
3292
3375
  return false;
3293
3376
  const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
3294
- const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
3377
+ const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath, a.openingOffset) : undefined;
3295
3378
  const said = head(opening?.text);
3296
3379
  const eventId = opening?.env?.eventId;
3297
3380
  const r = openTurnsOf(a.target, a.sessionId).find((t) => eventId ? t.eventId === eventId : !!said && head(t.userQuery) === said);
@@ -3308,7 +3391,10 @@ export const startMirror = (deps) => {
3308
3391
  a.replyTo = publicPeer(r.from);
3309
3392
  a.turnFromChat = a.turnFromChat ?? true;
3310
3393
  a.briefTurnId = r.id;
3311
- a.briefBubble = undefined;
3394
+ const event = eventId ? deps.events?.get(eventId) : undefined;
3395
+ a.briefBubble = event ? claimReplyBubble(event) : undefined;
3396
+ if (a.briefBubble)
3397
+ armBriefBubble(a, a.briefBubble, r.id, a.channel);
3312
3398
  a.briefIsSlash = false;
3313
3399
  a.briefHadTool = r.items.some((it) => it.t === "tool_use");
3314
3400
  a.briefConcluded = false;
@@ -3342,15 +3428,15 @@ export const startMirror = (deps) => {
3342
3428
  a.briefTurnId = turnId;
3343
3429
  if (a.activeEventId)
3344
3430
  deps.events?.bind(a.activeEventId, turnId);
3431
+ const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
3432
+ a.briefBubble = event ? claimReplyBubble(event) : undefined;
3433
+ if (a.briefBubble)
3434
+ armBriefBubble(a, a.briefBubble, turnId, a.channel);
3345
3435
  a.briefIsSlash = false;
3346
3436
  a.briefHadTool = false;
3347
3437
  a.briefConcluded = false;
3348
3438
  a.briefLastText = undefined;
3349
3439
  clearCot(a);
3350
- // transfer 接过来的那一笔: 续写人那句话的气泡 —— 这一轮就是在答群里的人。
3351
- a.briefBubble = adoptBubble(a, turnId);
3352
- if (a.briefBubble)
3353
- a.turnFromChat = true;
3354
3440
  // 详情链接不在这里单独发 — 本轮的每条 standalone 都由 withLinkedTag 自带
3355
3441
  // (chatTurnUrl 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
3356
3442
  // 一条都不发: 群里只该出现有正文的消息, 纯入口提示是噪音。
@@ -3396,28 +3482,36 @@ export const startMirror = (deps) => {
3396
3482
  if (event)
3397
3483
  deps.events.result(event.id, body, finalId(event.id, event.channel, body), turnId);
3398
3484
  const gated = !!event && !eventFinalGate(event);
3399
- // 那一笔已交出: 交代归接手方, 这句只留 rolepage —— 不进气泡 (已移交) 也不 standalone。
3400
- if (event && eventHandedAway(event))
3401
- log.info({ sessionId: a.sessionId, turnId, event: event.id }, "brief: final of a transferred-away row kept off chat");
3402
- else if (!turnSilent(a) && !gated) {
3485
+ if (!turnSilent(a) && !gated) {
3403
3486
  if (event)
3404
3487
  recoverEventFinal(deps.events.get(event.id), a.briefBubble);
3405
3488
  else
3406
3489
  queueFinal(a.target, turnId, a.channel ?? baseOfKey(a.target), body, undefined, a.briefBubble);
3407
3490
  }
3408
- else if (gated && !turnSilent(a)) {
3409
- // 链式收口 (eventFinalGate: 子活未落定) 延后的是 closing 的释放, 不该扣住气泡:
3410
- // queueFinal 没跑 = 没人收气泡, 它会定格在最后一行 CoT 进度上 (看着像卡在 tool
3411
- // use), hardTimer 到点的空 finish 又被 chat-gate 当 empty push 丢掉。气泡还在
3412
- // stream 窗口内就把终句刷进气泡 (`链接 正文` 覆盖, 与正常收口同形); 气泡已收 /
3413
- // 过期 / 终句超单条上限则 standalone 展示终句。
3491
+ else if (event && gated && !turnSilent(a) && eventProgressGate(deps.events.get(event.id))) {
3492
+ // A live parent may report progress; settled/canceled rows never use this outlet.
3493
+ const text = stripForChat(body);
3494
+ const channel = event.channel;
3495
+ const id = finalId(event.id, channel, `progress:${text}`);
3496
+ const b = a.briefBubble;
3497
+ if (b?.pending)
3498
+ finalProgress.set(id, b.pending);
3499
+ if (text && channel)
3500
+ deps.events.delivery({ id, eventId: event.id, target: event.target, turn: turnId,
3501
+ channel, body: text, revision: id, purpose: "progress", chunks: formatFinal(event.target, turnId, channel, text),
3502
+ ...(b && !b.done ? { stream: { frame: b.frame, id: b.streamId, expires: b.expires ?? Date.now() } } : {}) });
3503
+ if (b) {
3504
+ b.done = true;
3505
+ clearTimeout(b.hardTimer);
3506
+ }
3507
+ }
3508
+ const current = event ? deps.events.get(event.id) : undefined;
3509
+ if (current && (current.quiet || gated && !eventProgressGate(current))) {
3414
3510
  const b = a.briefBubble;
3415
- const text = stripForChat(body); // 同 queueFinal: 群气泡不带收口标记 / ARTIFACT 行
3416
- const fitsOneChunk = splitMarkdown(text, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET)).length === 1;
3417
- if (b && !b.done && fitsOneChunk && Date.now() < (b.expires ?? 0))
3418
- void finishBriefBubble(a, text);
3419
- else if (text.trim())
3420
- sendStandalone(a, text);
3511
+ if (b) {
3512
+ b.done = true;
3513
+ clearTimeout(b.hardTimer);
3514
+ }
3421
3515
  }
3422
3516
  // The durable result/outbox must exist before this in-memory dedup bit advances.
3423
3517
  a.briefConcluded = true;
@@ -3835,7 +3929,7 @@ export const startMirror = (deps) => {
3835
3929
  handleGoalItem(a, { kind: "turn_end" });
3836
3930
  return;
3837
3931
  }
3838
- if (cfg.wrc.mirror.brief && a.briefTurnId) {
3932
+ if (briefMode(a) && a.briefTurnId) {
3839
3933
  closeBriefTurn(a);
3840
3934
  return;
3841
3935
  }
@@ -3846,6 +3940,8 @@ export const startMirror = (deps) => {
3846
3940
  // 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
3847
3941
  if (a.muteUntilInject)
3848
3942
  return;
3943
+ if (item.kind === "user_text" || item.kind === "user_query" || item.kind === "user_inject")
3944
+ a.openingOffset = undefined;
3849
3945
  if (item.kind === "user_text") {
3850
3946
  // 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
3851
3947
  // 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
@@ -3864,7 +3960,7 @@ export const startMirror = (deps) => {
3864
3960
  a.pendingChannel = undefined;
3865
3961
  a.activeEventId = item.eventId;
3866
3962
  a.pendingFromCli = true;
3867
- if (!cfg.wrc.mirror.brief)
3963
+ if (!briefMode(a))
3868
3964
  consumeChannel(a, true, undefined);
3869
3965
  }
3870
3966
  if (item.kind === "user_inject") {
@@ -3911,13 +4007,18 @@ export const startMirror = (deps) => {
3911
4007
  // 有活跃 turn 时 handleBriefItem 本就丢它, 没有 turn 时 (user_text 不是
3912
4008
  // BRIEF_TURN_OPENERS) 它会一路穿到底部的 standalone fallback —— includeUser=true
3913
4009
  // 的配置下, 人在 CLI 敲的那一行就这么原样回显进了群, 正好破了 chatOriginOnly。
3914
- if (item.kind === "user_text" && (item.quiet || cfg.wrc.mirror.brief))
4010
+ if (item.kind === "user_text" && (item.quiet || briefMode(a)))
3915
4011
  return;
3916
4012
  // thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
3917
4013
  // 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
3918
4014
  // 那就变回已下线的 thinkStyle 了。
3919
4015
  if (item.kind === "thinking") {
3920
- if (cfg.wrc.mirror.brief && a.briefTurnId)
4016
+ const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
4017
+ // A receiver can begin with thinking, before any text/tool opener. Bind its
4018
+ // turn and inherited bubble now; pending input alone never authorizes output.
4019
+ if (!a.briefTurnId && event?.payload.peer?.transferred && eventOwnerGate(event))
4020
+ ensureBriefTurn(a);
4021
+ if (briefMode(a) && a.briefTurnId)
3921
4022
  updateBriefProgress(a, item.body);
3922
4023
  return;
3923
4024
  }
@@ -4060,7 +4161,7 @@ export const startMirror = (deps) => {
4060
4161
  }
4061
4162
  }
4062
4163
  }
4063
- if (!cfg.wrc.mirror.brief && item.kind === "text" && item.final !== false && a.activeEventId && deps.events) {
4164
+ if (!briefMode(a) && item.kind === "text" && item.final !== false && a.activeEventId && deps.events) {
4064
4165
  const e = deps.events.get(a.activeEventId);
4065
4166
  if (e) {
4066
4167
  // Soft final text waits for transcript/idle evidence; it must not escape through the legacy stream.
@@ -4073,7 +4174,7 @@ export const startMirror = (deps) => {
4073
4174
  return;
4074
4175
  }
4075
4176
  }
4076
- if (!cfg.wrc.mirror.brief && isPrivate(a))
4177
+ if (!briefMode(a) && (isPrivate(a) || !eventOwned(a.activeEventId)))
4077
4178
  return;
4078
4179
  // /goal mode overrides everything below: the session-scoped Stop hook
4079
4180
  // self-drives the model with no terminal stop_reason, so turn_end never fires
@@ -4092,17 +4193,17 @@ export const startMirror = (deps) => {
4092
4193
  // Brief 模式下没有活跃 turn 时, 任何 assistant 侧产出都补开一个无气泡 turn ——
4093
4194
  // 覆盖 CLI 侧直接开的新一轮 (WeCom 没参与, 拿不到 frame) 与收口后的零星补写。
4094
4195
  // user 行不开 turn (CLI 敲字 ≠ 一定有回复), 它的 query/出处已在上面记下。
4095
- if (cfg.wrc.mirror.brief && !a.briefTurnId && BRIEF_TURN_OPENERS.has(item.kind))
4196
+ if (briefMode(a) && !a.briefTurnId && BRIEF_TURN_OPENERS.has(item.kind))
4096
4197
  ensureBriefTurn(a);
4097
4198
  // Usage snapshots never flow into WeCom bubbles — brief 分支下 handleBriefItem
4098
4199
  // 会把它写进 turn store, 非 brief 下直接吞掉, 保持 onItem 主流程只处理有渲染
4099
4200
  // 输出的 item 类型。
4100
- if (item.kind === "turn_usage" && !(cfg.wrc.mirror.brief && a.briefTurnId)) {
4201
+ if (item.kind === "turn_usage" && !(briefMode(a) && a.briefTurnId)) {
4101
4202
  return;
4102
4203
  }
4103
4204
  // Brief mode 短路: 所有正常流/deferral/awaiting 全跳过, 只写入 turn store,
4104
4205
  // 唯一发到群里的是 turn 结束时的 finish text (下文 handleBriefItem 处理)。
4105
- if (cfg.wrc.mirror.brief && a.briefTurnId) {
4206
+ if (briefMode(a) && a.briefTurnId) {
4106
4207
  handleBriefItem(a, item);
4107
4208
  return;
4108
4209
  }
@@ -4118,7 +4219,7 @@ export const startMirror = (deps) => {
4118
4219
  return;
4119
4220
  // Brief mode 短路: 所有正常流/deferral/awaiting 全跳过, 只写入 turn store,
4120
4221
  // 唯一发到群里的是 turn 结束时的 finish text (下文 handleBriefItem 处理)。
4121
- if (cfg.wrc.mirror.brief && a.briefTurnId) {
4222
+ if (briefMode(a) && a.briefTurnId) {
4122
4223
  handleBriefItem(a, item);
4123
4224
  return;
4124
4225
  }
@@ -4133,9 +4234,11 @@ export const startMirror = (deps) => {
4133
4234
  // 说明 claude 已继续执行 (hook 必然已返回 / 根本没拦), 把状态提升回 STREAMING,
4134
4235
  // 复用原 frame/streamId 让本轮回到打字机气泡。
4135
4236
  if (a.outbound?.kind === "awaiting_appr") {
4136
- const { frame, streamId } = a.outbound;
4237
+ if (discardUnownedOutbound(a, a.outbound))
4238
+ return;
4239
+ const { frame, streamId, eventId } = a.outbound;
4137
4240
  a.outbound = undefined;
4138
- const s = openStream(a, frame, streamId);
4241
+ const s = openStream(a, frame, streamId, eventId);
4139
4242
  a.liveStream = s;
4140
4243
  log.info({ sessionId: a.sessionId, kind: item.kind, turnId: s.turnId }, "outbound: AWAITING_APPR → STREAMING (hook bypassed, item arrived)");
4141
4244
  }
@@ -4269,11 +4372,21 @@ export const startMirror = (deps) => {
4269
4372
  const carryEffort = effort !== undefined ? effort.trim() : (prevByTarget?.effort ?? deps.store.get(target)?.effort ?? "");
4270
4373
  if (prevByTarget)
4271
4374
  detach(prevByTarget, "target reassigned");
4375
+ const startOffset = (() => { try {
4376
+ return statSync(jsonlPath).size;
4377
+ }
4378
+ catch {
4379
+ return 0;
4380
+ } })();
4272
4381
  // Build the attachment first so the tail's onItem closure can capture it.
4273
4382
  const a = {
4274
4383
  sessionId,
4275
4384
  jsonlPath,
4276
4385
  target,
4386
+ // Tail starts at EOF after reload; recover ownership before its first item,
4387
+ // including non-brief output that never enters ensureBriefTurn.
4388
+ activeEventId: lastUserTurn(jsonlPath, startOffset)?.env?.eventId,
4389
+ openingOffset: startOffset,
4277
4390
  tmuxPane: (tmuxPane ?? "").trim(),
4278
4391
  tmuxSession: (tmuxSession ?? "").trim(),
4279
4392
  // Explicit cwd (spawn path) wins; otherwise derive from jsonl head so
@@ -4289,6 +4402,7 @@ export const startMirror = (deps) => {
4289
4402
  };
4290
4403
  a.tail = startMirrorTail({
4291
4404
  jsonlPath,
4405
+ startOffset,
4292
4406
  log: log.child({ sub: "tail", sessionId }),
4293
4407
  includeUser: cfg.wrc.mirror.includeUser,
4294
4408
  includeTools: cfg.wrc.mirror.includeTools,
@@ -4678,7 +4792,7 @@ export const startMirror = (deps) => {
4678
4792
  // freshly-rotated jsonl (only holds the /clear line + new content); resume
4679
4793
  // migration omits it (→ tail from EOF) because the fork file is seeded with
4680
4794
  // the FULL prior transcript and replaying from 0 would re-dump it to WeCom.
4681
- const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset) => {
4795
+ const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset, knownClear = false) => {
4682
4796
  // Guard: if newSessionId is already bound to a DIFFERENT principal, abort —
4683
4797
  // prevents silent cross-wire (path B: /clear rotation or drift landing on
4684
4798
  // a sibling's live session).
@@ -4692,7 +4806,7 @@ export const startMirror = (deps) => {
4692
4806
  // 迁移是所有会话轮换的唯一漏斗 (注入的 /clear、TUI 里手打的 /clear、resume fork、
4693
4807
  // worktree drift)。只有目标 jsonl 首条用户行是 /clear 的那种才是真清空 —— fork /
4694
4808
  // drift 上下文是延续的, 画中性的 "switch"。
4695
- const cleared = jsonlIsPostClearChild(newJsonlPath);
4809
+ const cleared = knownClear || jsonlIsPostClearChild(newJsonlPath);
4696
4810
  markCut(a.target, newSessionId, cleared ? "clear" : "switch");
4697
4811
  if (cleared)
4698
4812
  contextCut(a.target, "clear");
@@ -4729,6 +4843,8 @@ export const startMirror = (deps) => {
4729
4843
  tmuxSession: a.tmuxSession || undefined,
4730
4844
  tmuxPane: a.tmuxPane || undefined,
4731
4845
  cwd: a.runningCwd || undefined,
4846
+ model: a.model || undefined,
4847
+ effort: a.effort || undefined,
4732
4848
  pendingCwd: a.pendingCwd || undefined,
4733
4849
  });
4734
4850
  log.info({ target: a.target, oldSessionId, newSessionId, oldJsonlPath, newJsonlPath, startOffset }, "mirror migrated session");
@@ -5527,17 +5643,29 @@ export const startMirror = (deps) => {
5527
5643
  // store — e.g. after a reload that couldn't re-attach — is still killable.
5528
5644
  const a = byTarget.get(target);
5529
5645
  const pane = a?.tmuxPane || paneOf(target);
5646
+ const sid = a?.sessionId ?? deps.store.get(target)?.sessionId;
5647
+ const birth = wizardStore()?.get(target)?.birthToken;
5648
+ const same = () => byTarget.get(target) === a && (a?.tmuxPane || paneOf(target)) === pane
5649
+ && (a?.sessionId ?? deps.store.get(target)?.sessionId) === sid && wizardStore()?.get(target)?.birthToken === birth;
5650
+ const replaced = { ok: false, reason: "target identity changed during pane cleanup; cleanup withheld" };
5530
5651
  if (!a && !pane)
5531
5652
  return { ok: false, reason: "no session bound to target" };
5532
5653
  if (pane && (await tmuxPaneAlive(pane))) {
5654
+ if (!same())
5655
+ return replaced;
5533
5656
  // Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
5534
5657
  // flush its transcript before the TTY is yanked out from under it.
5535
5658
  await runTmux(["send-keys", "-t", pane, "Escape"]);
5536
5659
  await sleep(250);
5660
+ if (!same())
5661
+ return replaced;
5537
5662
  const r = await runTmux(["kill-pane", "-t", pane]);
5538
5663
  if (r.code !== 0)
5539
5664
  return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
5540
5665
  }
5666
+ // A late stop must not detach a new wizard that reused this target/name.
5667
+ if (!same())
5668
+ return replaced;
5541
5669
  if (a)
5542
5670
  detach(a, drop ? "/kill" : "sleep");
5543
5671
  if (!drop) {
@@ -6047,8 +6175,196 @@ export const startMirror = (deps) => {
6047
6175
  : { ok: true, label: `${pick.index}. ${pick.label}` };
6048
6176
  };
6049
6177
  const pressNativePicker = async (a, fingerprint, what, guard) => withPaneInput(a.tmuxPane, (live) => pressNativePickerUnlocked(a, fingerprint, what, live), { guard });
6178
+ // 调用方持有 pane 输入锁; clear 与 carry 的原生身份探测共用同一条 slash 提交流程。
6179
+ const nativeCommands = (target, pane, codex, sameProcess, sameSession, allowed = () => true) => {
6180
+ // 每次 slash 都在同一个物理输入锁里核对空框、idle 与进程; 不借用有补 Enter 的通用注入器。
6181
+ const command = async (text, before) => {
6182
+ if (!allowed() || !(await sameProcess()) || !(await idleNow(target)))
6183
+ return false;
6184
+ const input = await readPaneInput(pane);
6185
+ if (!input.box.known || input.box.body || paneIsBusy(input.cap) || isModalPane(input.cap).modal)
6186
+ return false;
6187
+ if (!(await sameSession()))
6188
+ return false;
6189
+ if (!(await sameProcess()))
6190
+ return false;
6191
+ if (!allowed())
6192
+ return false;
6193
+ before?.();
6194
+ if (!allowed())
6195
+ return false;
6196
+ if (!(await runTmux(["send-keys", "-t", pane, "-l", text])).ok)
6197
+ return false;
6198
+ for (const end = Date.now() + 3000; Date.now() < end; await sleep(50)) {
6199
+ const typed = await readPaneInput(pane);
6200
+ if (!(await sameProcess()) || await isBusy(target))
6201
+ return false;
6202
+ if (!(await sameSession()))
6203
+ return false;
6204
+ // Codex 的 slash 菜单会暂时藏掉 model/cwd 状态行, 只留精确命令框与 queue footer。
6205
+ const rows = typed.cap.trimEnd().split("\n");
6206
+ const end = rows.length - 1;
6207
+ const slashBox = codex && /^\s*tab to queue message(?:\s|$)/u.test(rows[end] ?? "")
6208
+ && !rows[end - 1]?.trim() && !rows[end - 3]?.trim() && rows[end - 2]?.trim() === `› ${text}`;
6209
+ if (((typed.box.known && typed.box.body === text) || slashBox) && !paneIsBusy(typed.cap)) {
6210
+ if (!allowed())
6211
+ return false;
6212
+ return (await runTmux(["send-keys", "-t", pane, "Enter"])).ok;
6213
+ }
6214
+ }
6215
+ return false;
6216
+ };
6217
+ const emptyComposer = async () => {
6218
+ const input = await readPaneInput(pane);
6219
+ return input.box.known && !input.box.body && !paneIsBusy(input.cap) && !isModalPane(input.cap).modal;
6220
+ };
6221
+ const statusId = async () => {
6222
+ const before = await runTmux(["capture-pane", "-t", pane, "-p", "-J", "-S", "-200"]);
6223
+ if (!before.ok || !(await command("/status")))
6224
+ return undefined;
6225
+ for (const end = Date.now() + 5000; Date.now() < end; await sleep(100)) {
6226
+ const screen = await runTmux(["capture-pane", "-t", pane, "-p", "-J", "-S", "-200"]);
6227
+ if (!screen.ok || !(await sameProcess()))
6228
+ return undefined;
6229
+ const lastStatus = [...screen.stdout.matchAll(/(?:^|\n)(?:›\s*)?\/status\s*\n/gu)].at(-1);
6230
+ const id = [...screen.stdout.matchAll(/\bSession:\s*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/gu)].at(-1);
6231
+ if (screen.stdout !== before.stdout && lastStatus && id && id.index > lastStatus.index && await emptyComposer())
6232
+ return id[1];
6233
+ }
6234
+ return undefined;
6235
+ };
6236
+ return { command, emptyComposer, statusId };
6237
+ };
6238
+ const verifyClearContext = async (target, clear) => {
6239
+ const a = byTarget.get(target);
6240
+ if (!a || !clear.nextSid || !clear.panePid || a.tmuxPane !== clear.pane || a.sessionId !== clear.nextSid || await panePidOf(clear.pane) !== clear.panePid)
6241
+ return false;
6242
+ const backend = backendForPath(a.jsonlPath);
6243
+ if (backend.name === "claude") {
6244
+ const actual = sessionOnPane(backend.homeDir, clear.pane);
6245
+ return actual?.pid === clear.pid && actual.sessionId === clear.nextSid;
6246
+ }
6247
+ if (backend.name !== "codex" || codexSessionForPane(clear.pane)?.sessionId !== clear.nextSid)
6248
+ return false;
6249
+ const ps = await psSnap();
6250
+ const pids = new Set([...ps.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid));
6251
+ return registeredPidUnder(clear.panePid, pids, ps.kids) === clear.pid;
6252
+ };
6253
+ const verifyClearInput = async (target, clear, phase, allowed) => {
6254
+ if (!allowed() || !(await verifyClearContext(target, clear)))
6255
+ return false;
6256
+ const a = byTarget.get(target);
6257
+ if (phase === "paste" && backendForPath(a.jsonlPath).name === "codex") {
6258
+ // 本机 Codex 不触发 SessionStart; 同 PID 原生 /new 也会让旧映射失真。
6259
+ // brief 尚未粘贴时用 /status 重取当前 ID; submit 阶段由原进程 + 原框内容核验保护。
6260
+ const { statusId } = nativeCommands(target, clear.pane, true, () => verifyClearContext(target, clear), () => true, allowed);
6261
+ return await statusId() === clear.nextSid && await verifyClearContext(target, clear) && allowed();
6262
+ }
6263
+ return true;
6264
+ };
6265
+ const clearContext = async (target, attempt, submitted, guard = () => true) => {
6266
+ if (!guard())
6267
+ return { ok: false, reason: "handoff continuation authority withdrawn; no input" };
6268
+ const a = byTarget.get(target);
6269
+ if (!a?.tmuxPane)
6270
+ return { ok: false, reason: "handoff requires the original live pane; no respawn" };
6271
+ const backend = backendForPath(a.jsonlPath);
6272
+ // 只开放本机真实探针验证过的后端; 不能因家族相近就假定 slash/注册表语义相同。
6273
+ if (!["claude", "codex"].includes(backend.name))
6274
+ return { ok: false, reason: `${backend.name} native /clear completion is not verified; pane unchanged` };
6275
+ if (a.pendingCwd && a.pendingCwd !== a.runningCwd)
6276
+ return { ok: false, reason: "pending workspace switch requires an explicit /new; /clear keeps the original process cwd" };
6277
+ const pane = a.tmuxPane;
6278
+ const valid = () => guard() && byTarget.get(target) === a && a.tmuxPane === pane;
6279
+ try {
6280
+ return await withPaneInput(pane, async (live) => {
6281
+ const codex = backend.name === "codex";
6282
+ const nativePid = async () => {
6283
+ if (!codex)
6284
+ return sessionOnPane(backend.homeDir, pane)?.pid;
6285
+ const root = await panePidOf(pane);
6286
+ const ps = await psSnap();
6287
+ return registeredPidUnder(root, new Set([...ps.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid)), ps.kids);
6288
+ };
6289
+ const pid = await nativePid();
6290
+ const panePid = await panePidOf(pane);
6291
+ if (!pid || !panePid || !(await tmuxPaneAlive(pane)))
6292
+ return { ok: false, reason: "original CLI process identity is unavailable" };
6293
+ const original = sessionOnPane(backend.homeDir, pane);
6294
+ let transaction = attempt ?? { pane, pid, panePid, sid: a.sessionId, at: Date.now() };
6295
+ if (transaction.pane !== pane || transaction.pid !== pid || (transaction.panePid && transaction.panePid !== panePid))
6296
+ return { ok: false, reason: "original pane/CLI process changed; refusing /clear" };
6297
+ const sameProcess = async () => live() && valid() && await nativePid() === transaction.pid && await panePidOf(pane) === panePid;
6298
+ const { command, emptyComposer, statusId } = nativeCommands(target, pane, codex, sameProcess, () => codex || (sessionOnPane(backend.homeDir, pane)?.sessionId === transaction.sid && sessionOnPane(backend.homeDir, pane)?.status === "idle"), guard);
6299
+ if (!attempt) {
6300
+ const actualSid = codex ? await statusId() : original?.sessionId;
6301
+ if (actualSid !== transaction.sid)
6302
+ return { ok: false, reason: "native pane session changed/unconfirmed before /clear; refusing to clear an unbound context" };
6303
+ // 凭证先于第一下 /clear 按键落盘; reload 只核验原生状态, 绝不再次清空。
6304
+ if (!(await command("/clear", () => submitted(transaction))))
6305
+ return { ok: false, reason: "native /clear submission unconfirmed; no retry" };
6306
+ }
6307
+ if (codex) {
6308
+ let empty = false;
6309
+ for (const end = Date.now() + 5000; Date.now() < end; await sleep(100)) {
6310
+ if (!(await sameProcess()))
6311
+ return { ok: false, reason: "Codex CLI process changed during /clear" };
6312
+ if (await emptyComposer()) {
6313
+ empty = true;
6314
+ break;
6315
+ }
6316
+ }
6317
+ const nextSid = empty ? await statusId() : undefined;
6318
+ if (!nextSid || nextSid === transaction.sid || (transaction.nextSid && transaction.nextSid !== nextSid))
6319
+ return { ok: false, reason: "Codex native /status did not confirm the cleared thread; no retry" };
6320
+ if (sidsClaimedByOthers(target).has(nextSid))
6321
+ return { ok: false, reason: "native Codex thread belongs to another wizard" };
6322
+ transaction = { ...transaction, nextSid };
6323
+ submitted(transaction);
6324
+ const actualRollout = () => codexSessions(expandHome(backend.projectsDir), expandHome(a.runningCwd)).find((s) => s.sessionId === nextSid);
6325
+ // 空 thread 不会先写 rollout; /rename 原生持久化当前 thread, 不发模型消息、不另起 thread。
6326
+ if (!actualRollout() && !(await command(`/rename ${displayName(target).replace(/[\r\n]/gu, " ") || "wezard"}`)))
6327
+ return { ok: false, reason: "Codex /rename persistence unconfirmed; thread preserved" };
6328
+ let rollout = actualRollout();
6329
+ for (const end = Date.now() + 5000; !rollout && Date.now() < end; await sleep(100))
6330
+ rollout = actualRollout();
6331
+ // rename 和 metadata 扫描之间仍可能有人原生切 thread; 最后再读当前 ID, 不绑错现场。
6332
+ if (!rollout || await statusId() !== nextSid || !(await sameProcess()))
6333
+ return { ok: false, reason: "Codex current thread/actual rollout metadata not confirmed; refusing binding" };
6334
+ recordCodexPaneSession({ sessionId: nextSid, jsonlPath: rollout.jsonlPath, cwd: a.runningCwd, pane, startedAt: transaction.at }, cfg.daemon.stateDir);
6335
+ if (a.sessionId !== nextSid)
6336
+ migrateAttachment(a, nextSid, rollout.jsonlPath, 0, true);
6337
+ log.info({ target, pane, pid, panePid, oldSessionId: transaction.sid, sessionId: nextSid, jsonlPath: rollout.jsonlPath }, "handoff: Codex native clear/status/rename confirmed in original CLI process");
6338
+ return { ok: true };
6339
+ }
6340
+ for (const end = Date.now() + 20_000; Date.now() < end; await sleep(200)) {
6341
+ if (!(await sameProcess()))
6342
+ return { ok: false, reason: "CLI process changed during /clear" };
6343
+ const current = sessionOnPane(backend.homeDir, pane);
6344
+ if (!current || current.sessionId === transaction.sid || current.status !== "idle" || !(await emptyComposer()))
6345
+ continue;
6346
+ if (sidsClaimedByOthers(target).has(current.sessionId))
6347
+ return { ok: false, reason: "native session identity belongs to another wizard" };
6348
+ if (transaction.nextSid && transaction.nextSid !== current.sessionId)
6349
+ return { ok: false, reason: "Claude native session changed after clear confirmation" };
6350
+ transaction = { ...transaction, nextSid: current.sessionId };
6351
+ submitted(transaction);
6352
+ if (a.sessionId !== current.sessionId)
6353
+ migrateAttachment(a, current.sessionId, join(dirname(a.jsonlPath), `${current.sessionId}.jsonl`), 0, true);
6354
+ log.info({ target, pane, pid, panePid, oldSessionId: transaction.sid, sessionId: current.sessionId }, "handoff: native /clear confirmed in original CLI process");
6355
+ return { ok: true };
6356
+ }
6357
+ return { ok: false, reason: "native /clear completion unconfirmed; original pane preserved, brief retained, no retry" };
6358
+ }, { guard: valid, timeoutMs: 60_000 });
6359
+ }
6360
+ catch (e) {
6361
+ return { ok: false, reason: String(e) };
6362
+ }
6363
+ };
6050
6364
  return {
6051
6365
  attach,
6366
+ clearContext,
6367
+ verifyClearContext,
6052
6368
  chatTargets,
6053
6369
  peers,
6054
6370
  foreignPeers,
@@ -6176,9 +6492,11 @@ export const startMirror = (deps) => {
6176
6492
  // the first flushStream will reject, mark s.dead=true, and subsequent items
6177
6493
  // route to standalone via the existing :1206 fall-through. Self-healing.
6178
6494
  if (a.outbound?.kind === "awaiting_appr") {
6179
- const { frame, streamId } = a.outbound;
6495
+ if (discardUnownedOutbound(a, a.outbound))
6496
+ return;
6497
+ const { frame, streamId, eventId } = a.outbound;
6180
6498
  a.outbound = undefined;
6181
- const s = openStream(a, frame, streamId);
6499
+ const s = openStream(a, frame, streamId, eventId);
6182
6500
  a.liveStream = s;
6183
6501
  log.info({ sessionId, turnId: s.turnId }, "outbound: AWAITING_APPR → STREAMING (approved)");
6184
6502
  }
@@ -6279,10 +6597,11 @@ export const startMirror = (deps) => {
6279
6597
  const a = byTarget.get(target) ?? (await restoreFromStore(target, undefined, permission));
6280
6598
  if (!permission())
6281
6599
  return denied;
6282
- // 恢复已经试过了: store 里连绑定都没有 (被 stop / kill 收掉, 或 transcript 没了被 drop) =
6283
- // 收方真不在了, 报 gone 让上层改投; 还有绑定只是这次 attach 没成, 照旧稍后重试。
6600
+ // A missing binding cannot recover on retry; let receipts escalate.
6284
6601
  if (!a)
6285
6602
  return { ok: false, retryable: true, reason: "no mirror attached for target", ...(deps.store.get(target) ? {} : { gone: true }) };
6603
+ if (opts?.handoff && !(await verifyClearContext(target, opts.handoff)))
6604
+ return { ok: false, retryable: false, reason: "original cleared CLI process/session changed; handoff carry withheld" };
6286
6605
  const sid = a.sessionId;
6287
6606
  const generation = a.injectGen ?? 0;
6288
6607
  const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
@@ -6308,6 +6627,8 @@ export const startMirror = (deps) => {
6308
6627
  // 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
6309
6628
  let fresh = a.justSpawned === true;
6310
6629
  if (probe.state !== "session") {
6630
+ if (opts?.noRespawn)
6631
+ return { ok: false, retryable: false, reason: "original CLI is unavailable; handoff never respawns" };
6311
6632
  log.warn({ target, sessionId: sid, oldPane: a.tmuxPane, why: probe.state }, "injectText: pane not alive, respawning");
6312
6633
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
6313
6634
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
@@ -6356,13 +6677,18 @@ export const startMirror = (deps) => {
6356
6677
  const clearing = isClearCommand(text);
6357
6678
  const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
6358
6679
  const injectPane = a.tmuxPane;
6680
+ let pinRejected = false;
6359
6681
  const r = await inject({
6360
6682
  text: full, images: [], cfg, guard: (phase, identity) => permitted() && a.tmuxPane === injectPane && (!opts?.guard || opts.guard(phase, identity)), log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
6361
6683
  sessionId: sid, eventId: opts?.eventId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
6684
+ physicalGuard: opts?.handoff ? async (phase) => { const ok = permission() && await verifyClearInput(target, opts.handoff, phase, permission) && permission(); if (!ok)
6685
+ pinRejected = true; return ok; } : undefined,
6362
6686
  requireIdle: opts?.requireIdle ? () => idleNow(target) : undefined,
6363
6687
  prepareText: () => (full = text + renderTail(envelope, noticeSuffixFor(target, text))),
6364
6688
  beforeInput,
6365
6689
  });
6690
+ if (pinRejected)
6691
+ return { ...r, ok: false, retryable: false, reason: "handoff process/session or continuation authority changed at physical input; carry withheld" };
6366
6692
  if (r.ok && a.tmuxPane && permitted()) {
6367
6693
  if (preClearBaseline) {
6368
6694
  a.clearRebind = { baseline: preClearBaseline };
@@ -6720,9 +7046,11 @@ export const startMirror = (deps) => {
6720
7046
  },
6721
7047
  formatFinal,
6722
7048
  recoverEventFinal,
7049
+ deliverTransferredFinal,
7050
+ transferReply,
6723
7051
  setEventFinalGate: (gate) => { eventFinalGate = gate; },
6724
- setEventHandedAway: (away) => { eventHandedAway = away; },
6725
- handBubble,
7052
+ setEventProgressGate: (gate) => { eventProgressGate = gate; },
7053
+ setEventOwnerGate: (gate) => { eventOwnerGate = gate; },
6726
7054
  settleFinalProgress,
6727
7055
  dispatch: async ({ principal, text, images, frame, streamId, channel, speaker, eventId, guard, onSubmitted }) => {
6728
7056
  // Route by inbound principal — the WeCom chat the user just messaged us
@@ -6845,10 +7173,12 @@ export const startMirror = (deps) => {
6845
7173
  // • outboundDeferMs === 0 → eager openStream + "…" ack (legacy behavior)
6846
7174
  const brief = cfg.wrc.mirror.brief && !armMigration;
6847
7175
  const eagerOpen = !armMigration && !brief && cfg.wrc.mirror.outboundDeferMs <= 0;
6848
- const s = eagerOpen ? openStream(a, frame, streamId) : undefined;
7176
+ const s = eagerOpen ? openStream(a, frame, streamId, eventId) : undefined;
6849
7177
  if (s) {
6850
7178
  a.liveStream = s;
6851
7179
  try {
7180
+ if (!eventOwned(s.eventId) || s.closed)
7181
+ return;
6852
7182
  await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
6853
7183
  }
6854
7184
  catch (e) {