wezard 2.7.3 → 2.7.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,6 +1,6 @@
1
1
  import { finalId } 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.
@@ -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,97 @@ 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 eventOwnerGate = () => true;
2376
+ const eventOwned = (id) => {
2377
+ const event = id ? deps.events?.get(id) : undefined;
2378
+ return !event || eventOwnerGate(event);
2379
+ };
2380
+ const briefMode = (a) => cfg.wrc.mirror.brief
2381
+ || !!(a.activeEventId && deps.events?.get(a.activeEventId)?.payload.peer?.transferred);
2382
+ const bubbleOwned = (a, b) => {
2383
+ const event = b.owner ? deps.events?.get(b.owner) : undefined;
2384
+ return !event || event.target === a.target && eventOwnerGate(event);
2385
+ };
2386
+ const replyBubbles = new Map();
2387
+ const rememberReplyBubble = (id, bubble) => {
2388
+ if (replyBubbles.has(id))
2389
+ return;
2390
+ replyBubbles.set(id, bubble);
2391
+ setTimeout(() => { if (replyBubbles.get(id) === bubble)
2392
+ replyBubbles.delete(id); }, Math.max(0, (bubble.expires ?? Date.now()) - Date.now()) + 60_000).unref?.();
2393
+ };
2394
+ const claimReplyBubble = (event) => {
2395
+ if (!deps.events || !eventOwnerGate(event) || !event.channel)
2396
+ return undefined;
2397
+ const origin = deps.events.replyOrigin(event);
2398
+ const human = origin?.payload.human;
2399
+ if (!origin || !human)
2400
+ return undefined;
2401
+ const source = byTarget.get(origin.target);
2402
+ const out = source?.outbound;
2403
+ if (source && out?.streamId === human.streamId) {
2404
+ if (out.kind === "deferred")
2405
+ clearTimeout(out.timer);
2406
+ source.outbound = undefined;
2407
+ }
2408
+ let bubble = replyBubbles.get(origin.id);
2409
+ if (!bubble) {
2410
+ const expires = origin.at + HARD_TIMEOUT_MS;
2411
+ if (expires <= Date.now() || deps.events.hasReplyDelivery(human.streamId))
2412
+ return undefined;
2413
+ bubble = { frame: human.frame, streamId: human.streamId, expires, done: false, owner: event.id };
2414
+ rememberReplyBubble(origin.id, bubble);
2415
+ }
2416
+ if (bubble.done || Date.now() >= (bubble.expires ?? 0))
2417
+ return undefined;
2418
+ if (bubble.source) {
2419
+ const stream = bubble.source;
2420
+ stream.closed = true;
2421
+ clearTimeout(stream.flushTimer);
2422
+ clearTimeout(stream.idleTimer);
2423
+ clearTimeout(stream.hardTimer);
2424
+ bubble.pending = stream.pending;
2425
+ const source = byTarget.get(stream.target);
2426
+ if (source?.liveStream === stream)
2427
+ source.liveStream = undefined;
2428
+ bubble.source = undefined;
2429
+ }
2430
+ if (bubble.owner !== event.id) {
2431
+ // The same object carries the last in-flight write into the receiver's FIFO.
2432
+ // Old final/cleanup/timer paths lose authority before this object is reused.
2433
+ byTarget.forEach((a) => {
2434
+ if (a.briefBubble === bubble) {
2435
+ clearCot(a);
2436
+ a.briefBubble = undefined;
2437
+ }
2438
+ });
2439
+ clearTimeout(bubble.hardTimer);
2440
+ bubble.owner = event.id;
2441
+ }
2442
+ return bubble;
2443
+ };
2444
+ const transferReply = (event) => {
2445
+ const bubble = claimReplyBubble(event);
2446
+ if (!bubble)
2447
+ return;
2448
+ const origin = deps.events?.replyOrigin(event);
2449
+ bubble.pending = (bubble.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2450
+ if (bubble.done || bubble.owner !== event.id || !eventOwnerGate(event))
2451
+ return;
2452
+ await client.replyStream(bubble.frame, bubble.streamId, withLinkedTagHeader(event.target, "…", chatTurnUrl(event.target, event.turn || origin?.turn), undefined, undefined, event.channel), false);
2453
+ }).catch((e) => { log.debug({ event: event.id, err: errText(e) }, "transfer reply refresh failed"); });
2454
+ };
2455
+ const armBriefBubble = (a, b, turnId, channel) => {
2456
+ clearTimeout(b.hardTimer);
2457
+ const owner = b.owner;
2458
+ if (!isWxFrame(b.frame))
2459
+ b.hardTimer = setTimeout(() => {
2460
+ if (b.owner !== owner || !bubbleOwned(a, b))
2461
+ return;
2462
+ const live = a.briefBubble === b && !a.briefConcluded;
2463
+ void finishBubble(a, b, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
2464
+ }, Math.max(0, (b.expires ?? Date.now()) - Date.now()));
2465
+ };
2369
2466
  const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2370
2467
  const newMarkId = () => `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2371
2468
  const detailCardFor = (s, target) => {
@@ -2383,13 +2480,13 @@ export const startMirror = (deps) => {
2383
2480
  };
2384
2481
  const flushStream = async (s) => {
2385
2482
  s.flushTimer = undefined;
2386
- if (s.closed || s.dead || s.acc === s.lastSent)
2483
+ if (s.closed || s.dead || s.acc === s.lastSent || !eventOwned(s.eventId))
2387
2484
  return;
2388
2485
  const content = s.acc;
2389
2486
  s.lastSent = content;
2390
2487
  try {
2391
2488
  await (s.pending = (s.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2392
- if (s.closed || s.dead)
2489
+ if (s.closed || s.dead || !eventOwned(s.eventId))
2393
2490
  return;
2394
2491
  await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
2395
2492
  }));
@@ -2430,10 +2527,12 @@ export const startMirror = (deps) => {
2430
2527
  // 例外是 hard cap 超时: turn 还在跑, 收口时缀上中间结束语 —— 这时即使没正文
2431
2528
  // 也有话可说, 不算空覆盖。
2432
2529
  const body = timedOut ? [s.acc, STILL_WORKING].filter((x) => x.trim()).join("\n\n") : s.acc;
2433
- if (!s.dead && body.trim()) {
2530
+ if (!s.dead && body.trim() && eventOwned(s.eventId)) {
2434
2531
  const card = detailCardFor(s, a.target);
2435
2532
  try {
2436
2533
  await s.pending?.catch(() => undefined);
2534
+ if (!eventOwned(s.eventId))
2535
+ return;
2437
2536
  if (card) {
2438
2537
  s.cardSent = true;
2439
2538
  await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
@@ -2462,8 +2561,9 @@ export const startMirror = (deps) => {
2462
2561
  if (a.liveStream === s)
2463
2562
  a.liveStream = undefined;
2464
2563
  };
2465
- const openStream = (a, frame, streamId) => {
2564
+ const openStream = (a, frame, streamId, eventId = a.activeEventId) => {
2466
2565
  const s = {
2566
+ eventId,
2467
2567
  turnId: newTurnId(),
2468
2568
  frame, streamId,
2469
2569
  target: a.target,
@@ -2472,6 +2572,11 @@ export const startMirror = (deps) => {
2472
2572
  capped: false, closed: false, dead: false, cardSent: false,
2473
2573
  tools: [], sawTool: false,
2474
2574
  };
2575
+ const event = eventId ? deps.events?.get(eventId) : undefined;
2576
+ if (event?.payload.human)
2577
+ rememberReplyBubble(event.id, {
2578
+ frame, streamId, done: false, owner: event.id, source: s, expires: event.at + HARD_TIMEOUT_MS,
2579
+ });
2475
2580
  // 微信帧没有 stream 窗口: 到点收口只会多发一条「仍在处理中」, 白占它那几格出站额度。
2476
2581
  if (!isWxFrame(frame))
2477
2582
  s.hardTimer = setTimeout(() => void finalizeStream(a, s, true), HARD_TIMEOUT_MS);
@@ -2496,7 +2601,8 @@ export const startMirror = (deps) => {
2496
2601
  // turnSilent 已被拦下; 非 brief 的逐条推送在这里兜一道 —— 只放 `[mirror]` /
2497
2602
  // `[wezard]` 系统提示 (它们回 home)。
2498
2603
  const sendStandalone = (a, content) => {
2499
- if (!hasVisibleBody(content))
2604
+ const eventId = a.activeEventId;
2605
+ if (!hasVisibleBody(content) || !eventOwned(eventId))
2500
2606
  return;
2501
2607
  if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
2502
2608
  return;
@@ -2507,6 +2613,8 @@ export const startMirror = (deps) => {
2507
2613
  a.standalonePending = a.standalonePending
2508
2614
  .then(async () => {
2509
2615
  for (const c of chunks) {
2616
+ if (!eventOwned(eventId))
2617
+ return;
2510
2618
  try {
2511
2619
  const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
2512
2620
  log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
@@ -2520,12 +2628,15 @@ export const startMirror = (deps) => {
2520
2628
  };
2521
2629
  // Like sendStandalone but skips withTagHeader — content already contains the tag header (e.g. as a link).
2522
2630
  const sendRaw = (a, content) => {
2523
- if (!hasVisibleBody(content))
2631
+ const eventId = a.activeEventId;
2632
+ if (!hasVisibleBody(content) || !eventOwned(eventId))
2524
2633
  return;
2525
2634
  const chatId = replyChatId(a);
2526
2635
  const turnId = a.briefTurnId ?? a.liveStream?.turnId;
2527
2636
  a.standalonePending = a.standalonePending
2528
2637
  .then(async () => {
2638
+ if (!eventOwned(eventId))
2639
+ return;
2529
2640
  try {
2530
2641
  await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
2531
2642
  log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
@@ -2634,13 +2745,22 @@ export const startMirror = (deps) => {
2634
2745
  };
2635
2746
  // Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
2636
2747
  // standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
2748
+ const discardUnownedOutbound = (a, out) => {
2749
+ if (eventOwned(out.eventId))
2750
+ return false;
2751
+ if (out.kind === "deferred")
2752
+ clearTimeout(out.timer);
2753
+ if (a.outbound === out)
2754
+ a.outbound = undefined;
2755
+ return true;
2756
+ };
2637
2757
  const promoteToStandalone = (a) => {
2638
2758
  const out = a.outbound;
2639
- if (out?.kind !== "deferred")
2759
+ if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
2640
2760
  return;
2641
2761
  clearTimeout(out.timer);
2642
2762
  const md = renderBuf(out.buf);
2643
- a.outbound = { kind: "awaiting_appr", frame: out.frame, streamId: out.streamId };
2763
+ a.outbound = { kind: "awaiting_appr", eventId: out.eventId, frame: out.frame, streamId: out.streamId };
2644
2764
  flushPendingStandalone(a);
2645
2765
  if (md)
2646
2766
  sendStandalone(a, md);
@@ -2660,7 +2780,7 @@ export const startMirror = (deps) => {
2660
2780
  // Pure-text turn or no trailing final text → no split, all into streamId.
2661
2781
  const exitDeferredAsFinalStream = (a) => {
2662
2782
  const out = a.outbound;
2663
- if (out?.kind !== "deferred")
2783
+ if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
2664
2784
  return;
2665
2785
  clearTimeout(out.timer);
2666
2786
  const buf = out.buf;
@@ -2685,15 +2805,15 @@ export const startMirror = (deps) => {
2685
2805
  try {
2686
2806
  // 有正文才写旧 streamId (收入 stream); 空缓冲一个字都不写 —— 否则
2687
2807
  // loading 气泡被 " " 覆盖, 结束处理自己凭空制造一条空消息。
2688
- if (bubbleMd)
2808
+ if (bubbleMd && eventOwned(out.eventId))
2689
2809
  await client.replyStream(out.frame, out.streamId, withLinkedTag(a, bubbleMd), true);
2690
2810
  }
2691
2811
  catch (e) {
2692
2812
  log.warn({ sessionId: a.sessionId, err: e.message }, "exit-deferred finalize failed; falling back to standalone");
2693
- if (bubbleMd)
2813
+ if (bubbleMd && eventOwned(out.eventId))
2694
2814
  sendStandalone(a, bubbleMd);
2695
2815
  }
2696
- if (trailingMd)
2816
+ if (trailingMd && eventOwned(out.eventId))
2697
2817
  sendStandalone(a, trailingMd);
2698
2818
  })();
2699
2819
  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 +2823,19 @@ export const startMirror = (deps) => {
2703
2823
  // STREAMING branch since outbound is now undefined.
2704
2824
  const promoteToStream = (a) => {
2705
2825
  const out = a.outbound;
2706
- if (out?.kind !== "deferred")
2826
+ if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
2707
2827
  return;
2708
2828
  const { buf, frame, streamId } = out;
2709
2829
  a.outbound = undefined;
2710
- const s = openStream(a, frame, streamId);
2830
+ const s = openStream(a, frame, streamId, out.eventId);
2711
2831
  a.liveStream = s;
2712
2832
  if (buf.length === 0) {
2713
2833
  // Empty buffer — claude still thinking. Send the "…" ack so the user
2714
2834
  // sees the bubble; subsequent items grow it as today.
2715
2835
  void (async () => {
2716
2836
  try {
2837
+ if (!eventOwned(s.eventId) || s.closed)
2838
+ return;
2717
2839
  await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
2718
2840
  }
2719
2841
  catch (e) {
@@ -2736,7 +2858,7 @@ export const startMirror = (deps) => {
2736
2858
  // an empty buffer before any tool_use can be evaluated for approval-needs.
2737
2859
  const SAFETY_MS = 5 * 60_000;
2738
2860
  const timer = setTimeout(() => promoteToStream(a), SAFETY_MS);
2739
- a.outbound = { kind: "deferred", buf: [], frame, streamId, timer };
2861
+ a.outbound = { kind: "deferred", eventId: a.activeEventId, buf: [], frame, streamId, timer };
2740
2862
  log.info({ sessionId: a.sessionId, safetyMs: SAFETY_MS }, "outbound: IDLE → DEFERRED (safety net)");
2741
2863
  };
2742
2864
  // Buffer / decide while in DEFERRED. user_text is CLI-side typing (not the
@@ -2745,7 +2867,7 @@ export const startMirror = (deps) => {
2745
2867
  // subsequent items don't reset (bounded promote delay).
2746
2868
  const handleDeferredItem = (a, item) => {
2747
2869
  const out = a.outbound;
2748
- if (out?.kind !== "deferred")
2870
+ if (out?.kind !== "deferred" || discardUnownedOutbound(a, out))
2749
2871
  return;
2750
2872
  if (item.kind === "turn_end") {
2751
2873
  exitDeferredAsFinalStream(a);
@@ -2822,12 +2944,12 @@ export const startMirror = (deps) => {
2822
2944
  const b = a.briefBubble;
2823
2945
  const turnId = a.briefTurnId;
2824
2946
  // 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
2825
- if (!text || !b || b.done || !turnId || a.briefConcluded)
2947
+ if (!text || !b || b.done || !turnId || a.briefConcluded || !bubbleOwned(a, b))
2826
2948
  return;
2827
2949
  a.cotLastSent = text;
2828
2950
  try {
2829
2951
  await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2830
- if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId)
2952
+ if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || !bubbleOwned(a, b))
2831
2953
  return;
2832
2954
  await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, text), false);
2833
2955
  }));
@@ -2839,7 +2961,7 @@ export const startMirror = (deps) => {
2839
2961
  /** 推进气泡里的 CoT 进度。节流窗口内到达的多条只留最后一条 —— 进度永远只展示"最新"。 */
2840
2962
  const updateBriefProgress = (a, raw) => {
2841
2963
  const b = a.briefBubble;
2842
- if (!b || b.done || a.briefConcluded)
2964
+ if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b))
2843
2965
  return;
2844
2966
  const line = cotLine(raw);
2845
2967
  if (!line)
@@ -2853,7 +2975,7 @@ export const startMirror = (deps) => {
2853
2975
  * 只有没有新 text 的窗口才把最新 tool 行刷上气泡。节流与 text 共用一个计时器。 */
2854
2976
  const updateBriefToolProgress = (a, raw) => {
2855
2977
  const b = a.briefBubble;
2856
- if (!b || b.done || a.briefConcluded)
2978
+ if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b))
2857
2979
  return;
2858
2980
  const line = cotLine(raw);
2859
2981
  if (!line)
@@ -2875,7 +2997,7 @@ export const startMirror = (deps) => {
2875
2997
  // `[mirror]` 系统提示 —— 它们都不走这道门。
2876
2998
  // 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
2877
2999
  // 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
2878
- const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
3000
+ const turnSilent = (a) => !eventOwned(a.activeEventId) || isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
2879
3001
  const formatFinal = (target, turn, channel, body) => {
2880
3002
  const pieces = splitMarkdown(body, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
2881
3003
  return pieces.map((text, n) => withLinkedTagHeader(target, text, chatTurnUrl(target, turn), pieces.length > 1 ? `${n + 1}/${pieces.length}` : undefined, undefined, channel));
@@ -2904,12 +3026,13 @@ export const startMirror = (deps) => {
2904
3026
  return id;
2905
3027
  };
2906
3028
  const recoverEventFinal = (event, bubble) => {
2907
- if (!event.result || event.interjected || event.canceled)
3029
+ if ((deps.events?.get(event.id) ?? event).handoffCut || !event.result || event.interjected || event.canceled || !eventOwnerGate(event))
2908
3030
  return;
2909
3031
  // 事件泵的 evidence 探针 (observe → io.recovered) 与 brief 软收口赛跑: 两者同为
2910
3032
  // ~1s 周期, 探针常先跑到, 而它调这里不带气泡 —— queueFinal 拿不到 stream, 终句
2911
3033
  // 全部走 standalone, 气泡定格在最后一行 CoT 进度。没传气泡时按 target/turn 找回
2912
3034
  // 活着的那只 (turn 已换人/气泡已收则不挂, 维持 standalone)。
3035
+ bubble ??= claimReplyBubble(event);
2913
3036
  bubble ??= (() => {
2914
3037
  const a = byTarget.get(event.target);
2915
3038
  return a && (!event.turn || a.briefTurnId === event.turn) && a.briefBubble && !a.briefBubble.done ? a.briefBubble : undefined;
@@ -2934,6 +3057,15 @@ export const startMirror = (deps) => {
2934
3057
  if (event.channel && eventFinalGate(event))
2935
3058
  queueFinal(event.target, turn, event.channel, event.result.body, event.id, bubble);
2936
3059
  };
3060
+ const deliverTransferredFinal = (event, body) => {
3061
+ if (!eventOwnerGate(event) || !event.channel)
3062
+ return;
3063
+ const turn = event.turn || event.id;
3064
+ if (!getDetail(turn))
3065
+ recordTurnStart({ id: turn, eventId: event.id, target: event.target,
3066
+ userQuery: event.payload.text, createdAt: event.at, channel: event.channel, from: event.payload.peer?.from });
3067
+ queueFinal(event.target, turn, event.channel, body, event.id, claimReplyBubble(event));
3068
+ };
2937
3069
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
2938
3070
  // raw=true skips withTagHeader (used when content already contains the linked tag header).
2939
3071
  // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
@@ -2942,8 +3074,9 @@ export const startMirror = (deps) => {
2942
3074
  // standalone 自然排在 finish 之后; 额外加一小段延迟留给客户端渲染。
2943
3075
  const BUBBLE_FINISH_SETTLE_MS = 600;
2944
3076
  const finishBubble = async (a, b, content, raw = false) => {
2945
- if (!b || b.done)
3077
+ if (!b || b.done || !bubbleOwned(a, b))
2946
3078
  return;
3079
+ const owner = b.owner;
2947
3080
  b.done = true;
2948
3081
  clearTimeout(b.hardTimer);
2949
3082
  if (a.briefBubble === b)
@@ -2952,6 +3085,9 @@ export const startMirror = (deps) => {
2952
3085
  clearCot(a);
2953
3086
  const p = (async () => {
2954
3087
  try {
3088
+ await b.pending?.catch(() => undefined);
3089
+ if (b.owner !== owner || !bubbleOwned(a, b))
3090
+ return;
2955
3091
  await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
2956
3092
  // 给 WeCom 客户端留一点时间完成打字机动画, 再放行 standalone 队列。
2957
3093
  await new Promise((r) => setTimeout(r, BUBBLE_FINISH_SETTLE_MS));
@@ -2967,62 +3103,6 @@ export const startMirror = (deps) => {
2967
3103
  return p;
2968
3104
  };
2969
3105
  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
3106
  // 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
3027
3107
  const openBriefTurn = (a, q) => {
3028
3108
  a.briefTurnId = q.turnId;
@@ -3065,7 +3145,7 @@ export const startMirror = (deps) => {
3065
3145
  /** 这一轮挂在哪一笔上: 回执 (与老派活信封) 写在信封上; 新派活信封不带, 问注入事件的 `serves`。 */
3066
3146
  const rowOf = (env) => env.event ?? (env.eventId ? deps.events?.get(env.eventId)?.serves : undefined);
3067
3147
  const openingOf = (a) => {
3068
- const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
3148
+ const t = a.jsonlPath ? lastUserTurn(a.jsonlPath, a.openingOffset) : undefined;
3069
3149
  const env = t?.env;
3070
3150
  a.activeEventId = env?.eventId;
3071
3151
  if (!env)
@@ -3239,7 +3319,7 @@ export const startMirror = (deps) => {
3239
3319
  a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: now };
3240
3320
  a.pendingBriefQuery = s.query;
3241
3321
  a.pendingBriefAt = ts;
3242
- if (!cfg.wrc.mirror.brief) {
3322
+ if (!briefMode(a)) {
3243
3323
  consumeChannel(a, false, consumeFrom(a));
3244
3324
  if (a.liveStream && !a.liveStream.closed)
3245
3325
  a.liveStream.dest = s.channel ?? baseOfKey(a.target);
@@ -3249,19 +3329,14 @@ export const startMirror = (deps) => {
3249
3329
  const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker, eventId) => {
3250
3330
  const turnId = newTurnId();
3251
3331
  // 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 };
3332
+ const bubble = { frame, streamId, done: false, owner: eventId, expires: (eventId ? deps.events?.get(eventId)?.at ?? Date.now() : Date.now()) + HARD_TIMEOUT_MS };
3253
3333
  if (eventId)
3254
3334
  deps.events?.bind(eventId, turnId);
3255
3335
  const q = { turnId, bubble, isSlash };
3256
3336
  // 微信帧不挂: 没有 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);
3337
+ if (eventId)
3338
+ rememberReplyBubble(eventId, bubble);
3339
+ armBriefBubble(a, bubble, turnId, channel);
3265
3340
  const seal = { eventId, keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
3266
3341
  // 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
3267
3342
  // 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
@@ -3291,7 +3366,7 @@ export const startMirror = (deps) => {
3291
3366
  if (a.channelSeeded)
3292
3367
  return false;
3293
3368
  const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
3294
- const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
3369
+ const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath, a.openingOffset) : undefined;
3295
3370
  const said = head(opening?.text);
3296
3371
  const eventId = opening?.env?.eventId;
3297
3372
  const r = openTurnsOf(a.target, a.sessionId).find((t) => eventId ? t.eventId === eventId : !!said && head(t.userQuery) === said);
@@ -3308,7 +3383,10 @@ export const startMirror = (deps) => {
3308
3383
  a.replyTo = publicPeer(r.from);
3309
3384
  a.turnFromChat = a.turnFromChat ?? true;
3310
3385
  a.briefTurnId = r.id;
3311
- a.briefBubble = undefined;
3386
+ const event = eventId ? deps.events?.get(eventId) : undefined;
3387
+ a.briefBubble = event ? claimReplyBubble(event) : undefined;
3388
+ if (a.briefBubble)
3389
+ armBriefBubble(a, a.briefBubble, r.id, a.channel);
3312
3390
  a.briefIsSlash = false;
3313
3391
  a.briefHadTool = r.items.some((it) => it.t === "tool_use");
3314
3392
  a.briefConcluded = false;
@@ -3342,15 +3420,15 @@ export const startMirror = (deps) => {
3342
3420
  a.briefTurnId = turnId;
3343
3421
  if (a.activeEventId)
3344
3422
  deps.events?.bind(a.activeEventId, turnId);
3423
+ const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
3424
+ a.briefBubble = event ? claimReplyBubble(event) : undefined;
3425
+ if (a.briefBubble)
3426
+ armBriefBubble(a, a.briefBubble, turnId, a.channel);
3345
3427
  a.briefIsSlash = false;
3346
3428
  a.briefHadTool = false;
3347
3429
  a.briefConcluded = false;
3348
3430
  a.briefLastText = undefined;
3349
3431
  clearCot(a);
3350
- // transfer 接过来的那一笔: 续写人那句话的气泡 —— 这一轮就是在答群里的人。
3351
- a.briefBubble = adoptBubble(a, turnId);
3352
- if (a.briefBubble)
3353
- a.turnFromChat = true;
3354
3432
  // 详情链接不在这里单独发 — 本轮的每条 standalone 都由 withLinkedTag 自带
3355
3433
  // (chatTurnUrl 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
3356
3434
  // 一条都不发: 群里只该出现有正文的消息, 纯入口提示是噪音。
@@ -3396,16 +3474,13 @@ export const startMirror = (deps) => {
3396
3474
  if (event)
3397
3475
  deps.events.result(event.id, body, finalId(event.id, event.channel, body), turnId);
3398
3476
  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) {
3477
+ if (!turnSilent(a) && !gated) {
3403
3478
  if (event)
3404
3479
  recoverEventFinal(deps.events.get(event.id), a.briefBubble);
3405
3480
  else
3406
3481
  queueFinal(a.target, turnId, a.channel ?? baseOfKey(a.target), body, undefined, a.briefBubble);
3407
3482
  }
3408
- else if (gated && !turnSilent(a)) {
3483
+ else if (gated && eventOwned(a.activeEventId) && !turnSilent(a)) {
3409
3484
  // 链式收口 (eventFinalGate: 子活未落定) 延后的是 closing 的释放, 不该扣住气泡:
3410
3485
  // queueFinal 没跑 = 没人收气泡, 它会定格在最后一行 CoT 进度上 (看着像卡在 tool
3411
3486
  // use), hardTimer 到点的空 finish 又被 chat-gate 当 empty push 丢掉。气泡还在
@@ -3835,7 +3910,7 @@ export const startMirror = (deps) => {
3835
3910
  handleGoalItem(a, { kind: "turn_end" });
3836
3911
  return;
3837
3912
  }
3838
- if (cfg.wrc.mirror.brief && a.briefTurnId) {
3913
+ if (briefMode(a) && a.briefTurnId) {
3839
3914
  closeBriefTurn(a);
3840
3915
  return;
3841
3916
  }
@@ -3846,6 +3921,8 @@ export const startMirror = (deps) => {
3846
3921
  // 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
3847
3922
  if (a.muteUntilInject)
3848
3923
  return;
3924
+ if (item.kind === "user_text" || item.kind === "user_query" || item.kind === "user_inject")
3925
+ a.openingOffset = undefined;
3849
3926
  if (item.kind === "user_text") {
3850
3927
  // 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
3851
3928
  // 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
@@ -3864,7 +3941,7 @@ export const startMirror = (deps) => {
3864
3941
  a.pendingChannel = undefined;
3865
3942
  a.activeEventId = item.eventId;
3866
3943
  a.pendingFromCli = true;
3867
- if (!cfg.wrc.mirror.brief)
3944
+ if (!briefMode(a))
3868
3945
  consumeChannel(a, true, undefined);
3869
3946
  }
3870
3947
  if (item.kind === "user_inject") {
@@ -3911,13 +3988,18 @@ export const startMirror = (deps) => {
3911
3988
  // 有活跃 turn 时 handleBriefItem 本就丢它, 没有 turn 时 (user_text 不是
3912
3989
  // BRIEF_TURN_OPENERS) 它会一路穿到底部的 standalone fallback —— includeUser=true
3913
3990
  // 的配置下, 人在 CLI 敲的那一行就这么原样回显进了群, 正好破了 chatOriginOnly。
3914
- if (item.kind === "user_text" && (item.quiet || cfg.wrc.mirror.brief))
3991
+ if (item.kind === "user_text" && (item.quiet || briefMode(a)))
3915
3992
  return;
3916
3993
  // thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
3917
3994
  // 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
3918
3995
  // 那就变回已下线的 thinkStyle 了。
3919
3996
  if (item.kind === "thinking") {
3920
- if (cfg.wrc.mirror.brief && a.briefTurnId)
3997
+ const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
3998
+ // A receiver can begin with thinking, before any text/tool opener. Bind its
3999
+ // turn and inherited bubble now; pending input alone never authorizes output.
4000
+ if (!a.briefTurnId && event?.payload.peer?.transferred && eventOwnerGate(event))
4001
+ ensureBriefTurn(a);
4002
+ if (briefMode(a) && a.briefTurnId)
3921
4003
  updateBriefProgress(a, item.body);
3922
4004
  return;
3923
4005
  }
@@ -4060,7 +4142,7 @@ export const startMirror = (deps) => {
4060
4142
  }
4061
4143
  }
4062
4144
  }
4063
- if (!cfg.wrc.mirror.brief && item.kind === "text" && item.final !== false && a.activeEventId && deps.events) {
4145
+ if (!briefMode(a) && item.kind === "text" && item.final !== false && a.activeEventId && deps.events) {
4064
4146
  const e = deps.events.get(a.activeEventId);
4065
4147
  if (e) {
4066
4148
  // Soft final text waits for transcript/idle evidence; it must not escape through the legacy stream.
@@ -4073,7 +4155,7 @@ export const startMirror = (deps) => {
4073
4155
  return;
4074
4156
  }
4075
4157
  }
4076
- if (!cfg.wrc.mirror.brief && isPrivate(a))
4158
+ if (!briefMode(a) && (isPrivate(a) || !eventOwned(a.activeEventId)))
4077
4159
  return;
4078
4160
  // /goal mode overrides everything below: the session-scoped Stop hook
4079
4161
  // self-drives the model with no terminal stop_reason, so turn_end never fires
@@ -4092,17 +4174,17 @@ export const startMirror = (deps) => {
4092
4174
  // Brief 模式下没有活跃 turn 时, 任何 assistant 侧产出都补开一个无气泡 turn ——
4093
4175
  // 覆盖 CLI 侧直接开的新一轮 (WeCom 没参与, 拿不到 frame) 与收口后的零星补写。
4094
4176
  // user 行不开 turn (CLI 敲字 ≠ 一定有回复), 它的 query/出处已在上面记下。
4095
- if (cfg.wrc.mirror.brief && !a.briefTurnId && BRIEF_TURN_OPENERS.has(item.kind))
4177
+ if (briefMode(a) && !a.briefTurnId && BRIEF_TURN_OPENERS.has(item.kind))
4096
4178
  ensureBriefTurn(a);
4097
4179
  // Usage snapshots never flow into WeCom bubbles — brief 分支下 handleBriefItem
4098
4180
  // 会把它写进 turn store, 非 brief 下直接吞掉, 保持 onItem 主流程只处理有渲染
4099
4181
  // 输出的 item 类型。
4100
- if (item.kind === "turn_usage" && !(cfg.wrc.mirror.brief && a.briefTurnId)) {
4182
+ if (item.kind === "turn_usage" && !(briefMode(a) && a.briefTurnId)) {
4101
4183
  return;
4102
4184
  }
4103
4185
  // Brief mode 短路: 所有正常流/deferral/awaiting 全跳过, 只写入 turn store,
4104
4186
  // 唯一发到群里的是 turn 结束时的 finish text (下文 handleBriefItem 处理)。
4105
- if (cfg.wrc.mirror.brief && a.briefTurnId) {
4187
+ if (briefMode(a) && a.briefTurnId) {
4106
4188
  handleBriefItem(a, item);
4107
4189
  return;
4108
4190
  }
@@ -4118,7 +4200,7 @@ export const startMirror = (deps) => {
4118
4200
  return;
4119
4201
  // Brief mode 短路: 所有正常流/deferral/awaiting 全跳过, 只写入 turn store,
4120
4202
  // 唯一发到群里的是 turn 结束时的 finish text (下文 handleBriefItem 处理)。
4121
- if (cfg.wrc.mirror.brief && a.briefTurnId) {
4203
+ if (briefMode(a) && a.briefTurnId) {
4122
4204
  handleBriefItem(a, item);
4123
4205
  return;
4124
4206
  }
@@ -4133,9 +4215,11 @@ export const startMirror = (deps) => {
4133
4215
  // 说明 claude 已继续执行 (hook 必然已返回 / 根本没拦), 把状态提升回 STREAMING,
4134
4216
  // 复用原 frame/streamId 让本轮回到打字机气泡。
4135
4217
  if (a.outbound?.kind === "awaiting_appr") {
4136
- const { frame, streamId } = a.outbound;
4218
+ if (discardUnownedOutbound(a, a.outbound))
4219
+ return;
4220
+ const { frame, streamId, eventId } = a.outbound;
4137
4221
  a.outbound = undefined;
4138
- const s = openStream(a, frame, streamId);
4222
+ const s = openStream(a, frame, streamId, eventId);
4139
4223
  a.liveStream = s;
4140
4224
  log.info({ sessionId: a.sessionId, kind: item.kind, turnId: s.turnId }, "outbound: AWAITING_APPR → STREAMING (hook bypassed, item arrived)");
4141
4225
  }
@@ -4269,11 +4353,21 @@ export const startMirror = (deps) => {
4269
4353
  const carryEffort = effort !== undefined ? effort.trim() : (prevByTarget?.effort ?? deps.store.get(target)?.effort ?? "");
4270
4354
  if (prevByTarget)
4271
4355
  detach(prevByTarget, "target reassigned");
4356
+ const startOffset = (() => { try {
4357
+ return statSync(jsonlPath).size;
4358
+ }
4359
+ catch {
4360
+ return 0;
4361
+ } })();
4272
4362
  // Build the attachment first so the tail's onItem closure can capture it.
4273
4363
  const a = {
4274
4364
  sessionId,
4275
4365
  jsonlPath,
4276
4366
  target,
4367
+ // Tail starts at EOF after reload; recover ownership before its first item,
4368
+ // including non-brief output that never enters ensureBriefTurn.
4369
+ activeEventId: lastUserTurn(jsonlPath, startOffset)?.env?.eventId,
4370
+ openingOffset: startOffset,
4277
4371
  tmuxPane: (tmuxPane ?? "").trim(),
4278
4372
  tmuxSession: (tmuxSession ?? "").trim(),
4279
4373
  // Explicit cwd (spawn path) wins; otherwise derive from jsonl head so
@@ -4289,6 +4383,7 @@ export const startMirror = (deps) => {
4289
4383
  };
4290
4384
  a.tail = startMirrorTail({
4291
4385
  jsonlPath,
4386
+ startOffset,
4292
4387
  log: log.child({ sub: "tail", sessionId }),
4293
4388
  includeUser: cfg.wrc.mirror.includeUser,
4294
4389
  includeTools: cfg.wrc.mirror.includeTools,
@@ -4678,7 +4773,7 @@ export const startMirror = (deps) => {
4678
4773
  // freshly-rotated jsonl (only holds the /clear line + new content); resume
4679
4774
  // migration omits it (→ tail from EOF) because the fork file is seeded with
4680
4775
  // the FULL prior transcript and replaying from 0 would re-dump it to WeCom.
4681
- const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset) => {
4776
+ const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset, knownClear = false) => {
4682
4777
  // Guard: if newSessionId is already bound to a DIFFERENT principal, abort —
4683
4778
  // prevents silent cross-wire (path B: /clear rotation or drift landing on
4684
4779
  // a sibling's live session).
@@ -4692,7 +4787,7 @@ export const startMirror = (deps) => {
4692
4787
  // 迁移是所有会话轮换的唯一漏斗 (注入的 /clear、TUI 里手打的 /clear、resume fork、
4693
4788
  // worktree drift)。只有目标 jsonl 首条用户行是 /clear 的那种才是真清空 —— fork /
4694
4789
  // drift 上下文是延续的, 画中性的 "switch"。
4695
- const cleared = jsonlIsPostClearChild(newJsonlPath);
4790
+ const cleared = knownClear || jsonlIsPostClearChild(newJsonlPath);
4696
4791
  markCut(a.target, newSessionId, cleared ? "clear" : "switch");
4697
4792
  if (cleared)
4698
4793
  contextCut(a.target, "clear");
@@ -4729,6 +4824,8 @@ export const startMirror = (deps) => {
4729
4824
  tmuxSession: a.tmuxSession || undefined,
4730
4825
  tmuxPane: a.tmuxPane || undefined,
4731
4826
  cwd: a.runningCwd || undefined,
4827
+ model: a.model || undefined,
4828
+ effort: a.effort || undefined,
4732
4829
  pendingCwd: a.pendingCwd || undefined,
4733
4830
  });
4734
4831
  log.info({ target: a.target, oldSessionId, newSessionId, oldJsonlPath, newJsonlPath, startOffset }, "mirror migrated session");
@@ -5527,17 +5624,29 @@ export const startMirror = (deps) => {
5527
5624
  // store — e.g. after a reload that couldn't re-attach — is still killable.
5528
5625
  const a = byTarget.get(target);
5529
5626
  const pane = a?.tmuxPane || paneOf(target);
5627
+ const sid = a?.sessionId ?? deps.store.get(target)?.sessionId;
5628
+ const birth = wizardStore()?.get(target)?.birthToken;
5629
+ const same = () => byTarget.get(target) === a && (a?.tmuxPane || paneOf(target)) === pane
5630
+ && (a?.sessionId ?? deps.store.get(target)?.sessionId) === sid && wizardStore()?.get(target)?.birthToken === birth;
5631
+ const replaced = { ok: false, reason: "target identity changed during pane cleanup; cleanup withheld" };
5530
5632
  if (!a && !pane)
5531
5633
  return { ok: false, reason: "no session bound to target" };
5532
5634
  if (pane && (await tmuxPaneAlive(pane))) {
5635
+ if (!same())
5636
+ return replaced;
5533
5637
  // Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
5534
5638
  // flush its transcript before the TTY is yanked out from under it.
5535
5639
  await runTmux(["send-keys", "-t", pane, "Escape"]);
5536
5640
  await sleep(250);
5641
+ if (!same())
5642
+ return replaced;
5537
5643
  const r = await runTmux(["kill-pane", "-t", pane]);
5538
5644
  if (r.code !== 0)
5539
5645
  return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
5540
5646
  }
5647
+ // A late stop must not detach a new wizard that reused this target/name.
5648
+ if (!same())
5649
+ return replaced;
5541
5650
  if (a)
5542
5651
  detach(a, drop ? "/kill" : "sleep");
5543
5652
  if (!drop) {
@@ -6047,8 +6156,196 @@ export const startMirror = (deps) => {
6047
6156
  : { ok: true, label: `${pick.index}. ${pick.label}` };
6048
6157
  };
6049
6158
  const pressNativePicker = async (a, fingerprint, what, guard) => withPaneInput(a.tmuxPane, (live) => pressNativePickerUnlocked(a, fingerprint, what, live), { guard });
6159
+ // 调用方持有 pane 输入锁; clear 与 carry 的原生身份探测共用同一条 slash 提交流程。
6160
+ const nativeCommands = (target, pane, codex, sameProcess, sameSession, allowed = () => true) => {
6161
+ // 每次 slash 都在同一个物理输入锁里核对空框、idle 与进程; 不借用有补 Enter 的通用注入器。
6162
+ const command = async (text, before) => {
6163
+ if (!allowed() || !(await sameProcess()) || !(await idleNow(target)))
6164
+ return false;
6165
+ const input = await readPaneInput(pane);
6166
+ if (!input.box.known || input.box.body || paneIsBusy(input.cap) || isModalPane(input.cap).modal)
6167
+ return false;
6168
+ if (!(await sameSession()))
6169
+ return false;
6170
+ if (!(await sameProcess()))
6171
+ return false;
6172
+ if (!allowed())
6173
+ return false;
6174
+ before?.();
6175
+ if (!allowed())
6176
+ return false;
6177
+ if (!(await runTmux(["send-keys", "-t", pane, "-l", text])).ok)
6178
+ return false;
6179
+ for (const end = Date.now() + 3000; Date.now() < end; await sleep(50)) {
6180
+ const typed = await readPaneInput(pane);
6181
+ if (!(await sameProcess()) || await isBusy(target))
6182
+ return false;
6183
+ if (!(await sameSession()))
6184
+ return false;
6185
+ // Codex 的 slash 菜单会暂时藏掉 model/cwd 状态行, 只留精确命令框与 queue footer。
6186
+ const rows = typed.cap.trimEnd().split("\n");
6187
+ const end = rows.length - 1;
6188
+ const slashBox = codex && /^\s*tab to queue message(?:\s|$)/u.test(rows[end] ?? "")
6189
+ && !rows[end - 1]?.trim() && !rows[end - 3]?.trim() && rows[end - 2]?.trim() === `› ${text}`;
6190
+ if (((typed.box.known && typed.box.body === text) || slashBox) && !paneIsBusy(typed.cap)) {
6191
+ if (!allowed())
6192
+ return false;
6193
+ return (await runTmux(["send-keys", "-t", pane, "Enter"])).ok;
6194
+ }
6195
+ }
6196
+ return false;
6197
+ };
6198
+ const emptyComposer = async () => {
6199
+ const input = await readPaneInput(pane);
6200
+ return input.box.known && !input.box.body && !paneIsBusy(input.cap) && !isModalPane(input.cap).modal;
6201
+ };
6202
+ const statusId = async () => {
6203
+ const before = await runTmux(["capture-pane", "-t", pane, "-p", "-J", "-S", "-200"]);
6204
+ if (!before.ok || !(await command("/status")))
6205
+ return undefined;
6206
+ for (const end = Date.now() + 5000; Date.now() < end; await sleep(100)) {
6207
+ const screen = await runTmux(["capture-pane", "-t", pane, "-p", "-J", "-S", "-200"]);
6208
+ if (!screen.ok || !(await sameProcess()))
6209
+ return undefined;
6210
+ const lastStatus = [...screen.stdout.matchAll(/(?:^|\n)(?:›\s*)?\/status\s*\n/gu)].at(-1);
6211
+ 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);
6212
+ if (screen.stdout !== before.stdout && lastStatus && id && id.index > lastStatus.index && await emptyComposer())
6213
+ return id[1];
6214
+ }
6215
+ return undefined;
6216
+ };
6217
+ return { command, emptyComposer, statusId };
6218
+ };
6219
+ const verifyClearContext = async (target, clear) => {
6220
+ const a = byTarget.get(target);
6221
+ if (!a || !clear.nextSid || !clear.panePid || a.tmuxPane !== clear.pane || a.sessionId !== clear.nextSid || await panePidOf(clear.pane) !== clear.panePid)
6222
+ return false;
6223
+ const backend = backendForPath(a.jsonlPath);
6224
+ if (backend.name === "claude") {
6225
+ const actual = sessionOnPane(backend.homeDir, clear.pane);
6226
+ return actual?.pid === clear.pid && actual.sessionId === clear.nextSid;
6227
+ }
6228
+ if (backend.name !== "codex" || codexSessionForPane(clear.pane)?.sessionId !== clear.nextSid)
6229
+ return false;
6230
+ const ps = await psSnap();
6231
+ const pids = new Set([...ps.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid));
6232
+ return registeredPidUnder(clear.panePid, pids, ps.kids) === clear.pid;
6233
+ };
6234
+ const verifyClearInput = async (target, clear, phase, allowed) => {
6235
+ if (!allowed() || !(await verifyClearContext(target, clear)))
6236
+ return false;
6237
+ const a = byTarget.get(target);
6238
+ if (phase === "paste" && backendForPath(a.jsonlPath).name === "codex") {
6239
+ // 本机 Codex 不触发 SessionStart; 同 PID 原生 /new 也会让旧映射失真。
6240
+ // brief 尚未粘贴时用 /status 重取当前 ID; submit 阶段由原进程 + 原框内容核验保护。
6241
+ const { statusId } = nativeCommands(target, clear.pane, true, () => verifyClearContext(target, clear), () => true, allowed);
6242
+ return await statusId() === clear.nextSid && await verifyClearContext(target, clear) && allowed();
6243
+ }
6244
+ return true;
6245
+ };
6246
+ const clearContext = async (target, attempt, submitted, guard = () => true) => {
6247
+ if (!guard())
6248
+ return { ok: false, reason: "handoff continuation authority withdrawn; no input" };
6249
+ const a = byTarget.get(target);
6250
+ if (!a?.tmuxPane)
6251
+ return { ok: false, reason: "handoff requires the original live pane; no respawn" };
6252
+ const backend = backendForPath(a.jsonlPath);
6253
+ // 只开放本机真实探针验证过的后端; 不能因家族相近就假定 slash/注册表语义相同。
6254
+ if (!["claude", "codex"].includes(backend.name))
6255
+ return { ok: false, reason: `${backend.name} native /clear completion is not verified; pane unchanged` };
6256
+ if (a.pendingCwd && a.pendingCwd !== a.runningCwd)
6257
+ return { ok: false, reason: "pending workspace switch requires an explicit /new; /clear keeps the original process cwd" };
6258
+ const pane = a.tmuxPane;
6259
+ const valid = () => guard() && byTarget.get(target) === a && a.tmuxPane === pane;
6260
+ try {
6261
+ return await withPaneInput(pane, async (live) => {
6262
+ const codex = backend.name === "codex";
6263
+ const nativePid = async () => {
6264
+ if (!codex)
6265
+ return sessionOnPane(backend.homeDir, pane)?.pid;
6266
+ const root = await panePidOf(pane);
6267
+ const ps = await psSnap();
6268
+ return registeredPidUnder(root, new Set([...ps.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid)), ps.kids);
6269
+ };
6270
+ const pid = await nativePid();
6271
+ const panePid = await panePidOf(pane);
6272
+ if (!pid || !panePid || !(await tmuxPaneAlive(pane)))
6273
+ return { ok: false, reason: "original CLI process identity is unavailable" };
6274
+ const original = sessionOnPane(backend.homeDir, pane);
6275
+ let transaction = attempt ?? { pane, pid, panePid, sid: a.sessionId, at: Date.now() };
6276
+ if (transaction.pane !== pane || transaction.pid !== pid || (transaction.panePid && transaction.panePid !== panePid))
6277
+ return { ok: false, reason: "original pane/CLI process changed; refusing /clear" };
6278
+ const sameProcess = async () => live() && valid() && await nativePid() === transaction.pid && await panePidOf(pane) === panePid;
6279
+ const { command, emptyComposer, statusId } = nativeCommands(target, pane, codex, sameProcess, () => codex || (sessionOnPane(backend.homeDir, pane)?.sessionId === transaction.sid && sessionOnPane(backend.homeDir, pane)?.status === "idle"), guard);
6280
+ if (!attempt) {
6281
+ const actualSid = codex ? await statusId() : original?.sessionId;
6282
+ if (actualSid !== transaction.sid)
6283
+ return { ok: false, reason: "native pane session changed/unconfirmed before /clear; refusing to clear an unbound context" };
6284
+ // 凭证先于第一下 /clear 按键落盘; reload 只核验原生状态, 绝不再次清空。
6285
+ if (!(await command("/clear", () => submitted(transaction))))
6286
+ return { ok: false, reason: "native /clear submission unconfirmed; no retry" };
6287
+ }
6288
+ if (codex) {
6289
+ let empty = false;
6290
+ for (const end = Date.now() + 5000; Date.now() < end; await sleep(100)) {
6291
+ if (!(await sameProcess()))
6292
+ return { ok: false, reason: "Codex CLI process changed during /clear" };
6293
+ if (await emptyComposer()) {
6294
+ empty = true;
6295
+ break;
6296
+ }
6297
+ }
6298
+ const nextSid = empty ? await statusId() : undefined;
6299
+ if (!nextSid || nextSid === transaction.sid || (transaction.nextSid && transaction.nextSid !== nextSid))
6300
+ return { ok: false, reason: "Codex native /status did not confirm the cleared thread; no retry" };
6301
+ if (sidsClaimedByOthers(target).has(nextSid))
6302
+ return { ok: false, reason: "native Codex thread belongs to another wizard" };
6303
+ transaction = { ...transaction, nextSid };
6304
+ submitted(transaction);
6305
+ const actualRollout = () => codexSessions(expandHome(backend.projectsDir), expandHome(a.runningCwd)).find((s) => s.sessionId === nextSid);
6306
+ // 空 thread 不会先写 rollout; /rename 原生持久化当前 thread, 不发模型消息、不另起 thread。
6307
+ if (!actualRollout() && !(await command(`/rename ${displayName(target).replace(/[\r\n]/gu, " ") || "wezard"}`)))
6308
+ return { ok: false, reason: "Codex /rename persistence unconfirmed; thread preserved" };
6309
+ let rollout = actualRollout();
6310
+ for (const end = Date.now() + 5000; !rollout && Date.now() < end; await sleep(100))
6311
+ rollout = actualRollout();
6312
+ // rename 和 metadata 扫描之间仍可能有人原生切 thread; 最后再读当前 ID, 不绑错现场。
6313
+ if (!rollout || await statusId() !== nextSid || !(await sameProcess()))
6314
+ return { ok: false, reason: "Codex current thread/actual rollout metadata not confirmed; refusing binding" };
6315
+ recordCodexPaneSession({ sessionId: nextSid, jsonlPath: rollout.jsonlPath, cwd: a.runningCwd, pane, startedAt: transaction.at }, cfg.daemon.stateDir);
6316
+ if (a.sessionId !== nextSid)
6317
+ migrateAttachment(a, nextSid, rollout.jsonlPath, 0, true);
6318
+ 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");
6319
+ return { ok: true };
6320
+ }
6321
+ for (const end = Date.now() + 20_000; Date.now() < end; await sleep(200)) {
6322
+ if (!(await sameProcess()))
6323
+ return { ok: false, reason: "CLI process changed during /clear" };
6324
+ const current = sessionOnPane(backend.homeDir, pane);
6325
+ if (!current || current.sessionId === transaction.sid || current.status !== "idle" || !(await emptyComposer()))
6326
+ continue;
6327
+ if (sidsClaimedByOthers(target).has(current.sessionId))
6328
+ return { ok: false, reason: "native session identity belongs to another wizard" };
6329
+ if (transaction.nextSid && transaction.nextSid !== current.sessionId)
6330
+ return { ok: false, reason: "Claude native session changed after clear confirmation" };
6331
+ transaction = { ...transaction, nextSid: current.sessionId };
6332
+ submitted(transaction);
6333
+ if (a.sessionId !== current.sessionId)
6334
+ migrateAttachment(a, current.sessionId, join(dirname(a.jsonlPath), `${current.sessionId}.jsonl`), 0, true);
6335
+ log.info({ target, pane, pid, panePid, oldSessionId: transaction.sid, sessionId: current.sessionId }, "handoff: native /clear confirmed in original CLI process");
6336
+ return { ok: true };
6337
+ }
6338
+ return { ok: false, reason: "native /clear completion unconfirmed; original pane preserved, brief retained, no retry" };
6339
+ }, { guard: valid, timeoutMs: 60_000 });
6340
+ }
6341
+ catch (e) {
6342
+ return { ok: false, reason: String(e) };
6343
+ }
6344
+ };
6050
6345
  return {
6051
6346
  attach,
6347
+ clearContext,
6348
+ verifyClearContext,
6052
6349
  chatTargets,
6053
6350
  peers,
6054
6351
  foreignPeers,
@@ -6176,9 +6473,11 @@ export const startMirror = (deps) => {
6176
6473
  // the first flushStream will reject, mark s.dead=true, and subsequent items
6177
6474
  // route to standalone via the existing :1206 fall-through. Self-healing.
6178
6475
  if (a.outbound?.kind === "awaiting_appr") {
6179
- const { frame, streamId } = a.outbound;
6476
+ if (discardUnownedOutbound(a, a.outbound))
6477
+ return;
6478
+ const { frame, streamId, eventId } = a.outbound;
6180
6479
  a.outbound = undefined;
6181
- const s = openStream(a, frame, streamId);
6480
+ const s = openStream(a, frame, streamId, eventId);
6182
6481
  a.liveStream = s;
6183
6482
  log.info({ sessionId, turnId: s.turnId }, "outbound: AWAITING_APPR → STREAMING (approved)");
6184
6483
  }
@@ -6279,10 +6578,11 @@ export const startMirror = (deps) => {
6279
6578
  const a = byTarget.get(target) ?? (await restoreFromStore(target, undefined, permission));
6280
6579
  if (!permission())
6281
6580
  return denied;
6282
- // 恢复已经试过了: store 里连绑定都没有 (被 stop / kill 收掉, 或 transcript 没了被 drop) =
6283
- // 收方真不在了, 报 gone 让上层改投; 还有绑定只是这次 attach 没成, 照旧稍后重试。
6581
+ // A missing binding cannot recover on retry; let receipts escalate.
6284
6582
  if (!a)
6285
6583
  return { ok: false, retryable: true, reason: "no mirror attached for target", ...(deps.store.get(target) ? {} : { gone: true }) };
6584
+ if (opts?.handoff && !(await verifyClearContext(target, opts.handoff)))
6585
+ return { ok: false, retryable: false, reason: "original cleared CLI process/session changed; handoff carry withheld" };
6286
6586
  const sid = a.sessionId;
6287
6587
  const generation = a.injectGen ?? 0;
6288
6588
  const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
@@ -6308,6 +6608,8 @@ export const startMirror = (deps) => {
6308
6608
  // 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
6309
6609
  let fresh = a.justSpawned === true;
6310
6610
  if (probe.state !== "session") {
6611
+ if (opts?.noRespawn)
6612
+ return { ok: false, retryable: false, reason: "original CLI is unavailable; handoff never respawns" };
6311
6613
  log.warn({ target, sessionId: sid, oldPane: a.tmuxPane, why: probe.state }, "injectText: pane not alive, respawning");
6312
6614
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
6313
6615
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
@@ -6356,13 +6658,18 @@ export const startMirror = (deps) => {
6356
6658
  const clearing = isClearCommand(text);
6357
6659
  const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
6358
6660
  const injectPane = a.tmuxPane;
6661
+ let pinRejected = false;
6359
6662
  const r = await inject({
6360
6663
  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
6664
  sessionId: sid, eventId: opts?.eventId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
6665
+ physicalGuard: opts?.handoff ? async (phase) => { const ok = permission() && await verifyClearInput(target, opts.handoff, phase, permission) && permission(); if (!ok)
6666
+ pinRejected = true; return ok; } : undefined,
6362
6667
  requireIdle: opts?.requireIdle ? () => idleNow(target) : undefined,
6363
6668
  prepareText: () => (full = text + renderTail(envelope, noticeSuffixFor(target, text))),
6364
6669
  beforeInput,
6365
6670
  });
6671
+ if (pinRejected)
6672
+ return { ...r, ok: false, retryable: false, reason: "handoff process/session or continuation authority changed at physical input; carry withheld" };
6366
6673
  if (r.ok && a.tmuxPane && permitted()) {
6367
6674
  if (preClearBaseline) {
6368
6675
  a.clearRebind = { baseline: preClearBaseline };
@@ -6720,9 +7027,10 @@ export const startMirror = (deps) => {
6720
7027
  },
6721
7028
  formatFinal,
6722
7029
  recoverEventFinal,
7030
+ deliverTransferredFinal,
7031
+ transferReply,
6723
7032
  setEventFinalGate: (gate) => { eventFinalGate = gate; },
6724
- setEventHandedAway: (away) => { eventHandedAway = away; },
6725
- handBubble,
7033
+ setEventOwnerGate: (gate) => { eventOwnerGate = gate; },
6726
7034
  settleFinalProgress,
6727
7035
  dispatch: async ({ principal, text, images, frame, streamId, channel, speaker, eventId, guard, onSubmitted }) => {
6728
7036
  // Route by inbound principal — the WeCom chat the user just messaged us
@@ -6845,10 +7153,12 @@ export const startMirror = (deps) => {
6845
7153
  // • outboundDeferMs === 0 → eager openStream + "…" ack (legacy behavior)
6846
7154
  const brief = cfg.wrc.mirror.brief && !armMigration;
6847
7155
  const eagerOpen = !armMigration && !brief && cfg.wrc.mirror.outboundDeferMs <= 0;
6848
- const s = eagerOpen ? openStream(a, frame, streamId) : undefined;
7156
+ const s = eagerOpen ? openStream(a, frame, streamId, eventId) : undefined;
6849
7157
  if (s) {
6850
7158
  a.liveStream = s;
6851
7159
  try {
7160
+ if (!eventOwned(s.eventId) || s.closed)
7161
+ return;
6852
7162
  await (s.pending = client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false));
6853
7163
  }
6854
7164
  catch (e) {