wezard 1.2.25 → 1.2.27

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.
@@ -368,6 +368,17 @@ const stripTrailingBody = (think, body) => {
368
368
  }
369
369
  return think;
370
370
  };
371
+ // thinkStyle 兜底: turn 收口时无正式 body, 从累积的 thinking 里摘出最后一段
372
+ // 看起来像正文的内容当作 body 下发。工具调用行 (🔧) 和工具结果行 (↳) 跳过。
373
+ const rescueBodyFromThinking = (thinking) => {
374
+ const paragraphs = thinking.split(/\n\n/).map(s => s.trim()).filter((s) => s.length > 0);
375
+ for (let i = paragraphs.length - 1; i >= 0; i--) {
376
+ const p = paragraphs[i];
377
+ if (!p.startsWith("🔧 ") && !p.startsWith("↳ "))
378
+ return p;
379
+ }
380
+ return undefined;
381
+ };
371
382
  // WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
372
383
  // code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
373
384
  // `<noreply@x>` etc. silently swallows the rest of the line plus the closing
@@ -1448,6 +1459,22 @@ export const startMirror = (deps) => {
1448
1459
  const SOFT_TURN_END_MS = 4_000;
1449
1460
  const STREAM_SOFT_CAP = 18_000;
1450
1461
  const TOOL_DETAIL_TTL_MS = 24 * 60 * 60 * 1000;
1462
+ /** thinkStyle 兜底超时: turn 开始 2min 后仍未 conclude → 强制软收口。
1463
+ * 比 HARD_TIMEOUT_MS (350s) 短, 气泡还活着时走 finishBriefBubble 路径。 */
1464
+ const THINK_RESCUE_TIMEOUT_MS = 120_000;
1465
+ /** Standalone 发送前正文检查: 内容无正文 (纯 think 标记 / 工具行) 时,
1466
+ * 回队列等这么久。期间有新消息入队则重置; 到期且无新消息才发出。 */
1467
+ const STANDALONE_BODY_WAIT_MS = 8_000;
1468
+ // 判断 standalone 内容是否含有正文 (非纯 <think>…</think> / 工具行)。
1469
+ const standaloneHasBody = (content) => {
1470
+ // 剥掉 <think>…</think> 包裹 (含 tag header)
1471
+ const stripped = content.replace(/<think>[\s\S]*?<\/think>/g, "")
1472
+ // 工具行
1473
+ .replace(/^🔧 .*/gm, "")
1474
+ .replace(/^↳ .*/gm, "")
1475
+ .trim();
1476
+ return stripped.length > 0;
1477
+ };
1451
1478
  const turnRegistry = new Map();
1452
1479
  const evictTurns = () => {
1453
1480
  const now = Date.now();
@@ -1627,12 +1654,22 @@ export const startMirror = (deps) => {
1627
1654
  };
1628
1655
  // Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 → 合并成单条 markdown。
1629
1656
  // 0 关闭时退化为透传。flushStandalone 也用于 detach 时的 drain。
1630
- const flushStandalone = (a) => {
1657
+ // 正文检查: 合并内容无正文 (纯 think/工具) 时, 回队列等 STANDALONE_BODY_WAIT_MS;
1658
+ // 期间有新消息入队 (parts 增长) 则重置; 到期且无新消息才强制发出。
1659
+ // force=true 跳过正文检查, 用于 teardown / detach / pre-card 等强制 drain 场景。
1660
+ const flushStandalone = (a, force = false) => {
1631
1661
  const buf = a.standaloneBuf;
1632
1662
  if (!buf)
1633
1663
  return;
1664
+ const merged = buf.parts.join("\n\n");
1665
+ // 无正文 & 队列有新增 → 再等一轮 (force 跳过)
1666
+ if (!force && !standaloneHasBody(merged) && buf.bodyWaitLen !== buf.parts.length) {
1667
+ buf.bodyWaitLen = buf.parts.length;
1668
+ buf.timer = setTimeout(() => flushStandalone(a), STANDALONE_BODY_WAIT_MS);
1669
+ return;
1670
+ }
1634
1671
  a.standaloneBuf = undefined;
1635
- sendStandalone(a, buf.parts.join("\n\n"));
1672
+ sendStandalone(a, merged);
1636
1673
  };
1637
1674
  const enqueueStandalone = (a, content) => {
1638
1675
  const ms = cfg.wrc.mirror.standaloneDebounceMs;
@@ -1683,7 +1720,7 @@ export const startMirror = (deps) => {
1683
1720
  if (!a.standaloneBuf)
1684
1721
  return;
1685
1722
  clearTimeout(a.standaloneBuf.timer);
1686
- flushStandalone(a);
1723
+ flushStandalone(a, true);
1687
1724
  };
1688
1725
  // Path A: a needs-approval tool_use just landed. Aggregate the buffer as one
1689
1726
  // standalone, send it BEFORE the approval card race, transition to AWAITING_APPR.
@@ -1842,6 +1879,11 @@ export const startMirror = (deps) => {
1842
1879
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
1843
1880
  // 排队中的 turn 也持有气泡, 所以气泡是显式入参而不是从 a 上取。
1844
1881
  // raw=true skips withSessionTag (used when content already contains the linked tag header).
1882
+ // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
1883
+ // standalone (sendMessage), 用户会看到 standalone 抢在气泡动画结束之前出现。
1884
+ // 把 finishBubble 的 replyStream promise 链入 standalonePending, 让后续
1885
+ // standalone 自然排在 finish 之后; 额外加一小段延迟留给客户端渲染。
1886
+ const BUBBLE_FINISH_SETTLE_MS = 600;
1845
1887
  const finishBubble = async (a, b, content, raw = false) => {
1846
1888
  if (!b || b.done)
1847
1889
  return;
@@ -1851,16 +1893,28 @@ export const startMirror = (deps) => {
1851
1893
  clearTimeout(b.earlyTimer);
1852
1894
  b.earlyTimer = undefined;
1853
1895
  }
1896
+ // think-style: 清除待发的 flush 定时器, 避免 doStreamThink 在 done 后空跑。
1897
+ if (a.briefBubble === b && a.thinkFlushTimer) {
1898
+ clearTimeout(a.thinkFlushTimer);
1899
+ a.thinkFlushTimer = undefined;
1900
+ }
1854
1901
  if (a.briefBubble === b)
1855
1902
  a.briefBubble = undefined;
1856
- try {
1857
- await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
1858
- }
1859
- catch (e) {
1860
- log.warn({ sessionId: a.sessionId, err: e.message }, "brief: bubble finish failed; standalone fallback");
1861
- if (content.trim())
1862
- raw ? sendRaw(a, content) : sendStandalone(a, content);
1863
- }
1903
+ const p = (async () => {
1904
+ try {
1905
+ await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
1906
+ // WeCom 客户端留一点时间完成打字机动画, 再放行 standalone 队列。
1907
+ await new Promise((r) => setTimeout(r, BUBBLE_FINISH_SETTLE_MS));
1908
+ }
1909
+ catch (e) {
1910
+ log.warn({ sessionId: a.sessionId, err: e.message }, "brief: bubble finish failed; standalone fallback");
1911
+ if (content.trim())
1912
+ raw ? sendRaw(a, content) : sendStandalone(a, content);
1913
+ }
1914
+ })();
1915
+ // 链入 standalonePending, 后续 sendStandalone/sendRaw 自然等 finish 完成。
1916
+ a.standalonePending = a.standalonePending.then(() => p).catch(() => undefined);
1917
+ return p;
1864
1918
  };
1865
1919
  const finishBriefBubble = (a, content, raw = false) => finishBubble(a, a.briefBubble, content, raw);
1866
1920
  // think-style tag: 紧跟在 `<think>` 之后, 不在整条消息最前面 —— `<think>🧙 …`。
@@ -1872,17 +1926,36 @@ export const startMirror = (deps) => {
1872
1926
  // think-style: 把当前累积的 thinking 作为 `<think>🧙 …` 流进活着的 loading 气泡
1873
1927
  // (finish=false, 气泡不关)。收口时再补 `</think>\n\n<final>` 并 finish。
1874
1928
  // raw 发送: tag 已在 <think> 内, 不再经 withSessionTag 外包。
1875
- const streamThink = (a) => {
1929
+ // streamPending 首次调用时才真正开流 (fix: off-by-one race)。
1930
+ // fix: 加去抖 (FLUSH_MS) + 背压 (await 上一次完成), 消除 SDK 串行队列积压。
1931
+ const doStreamThink = async (a) => {
1876
1932
  const b = a.briefBubble;
1877
1933
  const think = a.briefThinking?.trim();
1878
1934
  if (!b || b.done || !think)
1879
1935
  return;
1880
- client
1881
- .replyStream(b.frame, b.streamId, openThink(a.target, think), false)
1882
- .catch((e) => log.warn({ sessionId: a.sessionId, err: e.message }, "brief: think stream failed"));
1936
+ // 首条 replyStream 延迟到这里发出 (streamPending), 携带真实内容, 避免空开流竞态。
1937
+ b.streamPending = false;
1938
+ try {
1939
+ await client.replyStream(b.frame, b.streamId, openThink(a.target, think), false);
1940
+ log.debug({ sessionId: a.sessionId, streamId: b.streamId, thinkLen: think.length }, "brief: think stream ok");
1941
+ }
1942
+ catch (e) {
1943
+ log.warn({ sessionId: a.sessionId, err: e.message }, "brief: think stream failed");
1944
+ }
1945
+ };
1946
+ const scheduleThinkFlush = (a) => {
1947
+ if (!a.briefBubble || a.briefBubble.done || a.thinkFlushTimer || a.thinkFlushing)
1948
+ return;
1949
+ a.thinkFlushTimer = setTimeout(() => {
1950
+ a.thinkFlushTimer = undefined;
1951
+ if (a.thinkFlushing)
1952
+ return; // 上一次还在飞 → 下次 pushThink 再排
1953
+ a.thinkFlushing = true;
1954
+ void doStreamThink(a).finally(() => { a.thinkFlushing = false; });
1955
+ }, FLUSH_MS);
1883
1956
  };
1884
1957
  // think-style 单一入口: 一段中间过程 (reasoning / 中途文本 / 工具调用) 进 think。
1885
- // 气泡还活 → 累积进 briefThinking 并全量刷进气泡; 否则 (无气泡 turn / 气泡已收口)
1958
+ // 气泡还活 → 累积进 briefThinking 并排一次去抖 flush; 否则 (无气泡 turn / 气泡已收口)
1886
1959
  // 只累积, 绝不单独 standalone —— 中间纯思考过程不下发消息, 等正式文本回复出现时
1887
1960
  // 与正文拼成一条 `<think>…</think>\n\n正文` 整段下发 (见 concludeBriefTurn)。
1888
1961
  const pushThink = (a, chunk) => {
@@ -1891,7 +1964,7 @@ export const startMirror = (deps) => {
1891
1964
  return;
1892
1965
  a.briefThinking = a.briefThinking ? `${a.briefThinking}\n\n${c}` : c;
1893
1966
  if (a.briefBubble && !a.briefBubble.done)
1894
- streamThink(a);
1967
+ scheduleThinkFlush(a);
1895
1968
  };
1896
1969
  // 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
1897
1970
  const openBriefTurn = (a, q) => {
@@ -1902,6 +1975,32 @@ export const startMirror = (deps) => {
1902
1975
  a.briefConcluded = false;
1903
1976
  a.briefLastText = undefined;
1904
1977
  a.briefThinking = undefined;
1978
+ if (a.thinkFlushTimer) {
1979
+ clearTimeout(a.thinkFlushTimer);
1980
+ a.thinkFlushTimer = undefined;
1981
+ }
1982
+ a.thinkFlushing = false;
1983
+ if (a.thinkRescueTimer) {
1984
+ clearTimeout(a.thinkRescueTimer);
1985
+ a.thinkRescueTimer = undefined;
1986
+ }
1987
+ if (thinkStyle) {
1988
+ a.thinkRescueTimer = setTimeout(() => {
1989
+ if (a.briefTurnId === q.turnId && !a.briefConcluded) {
1990
+ log.warn({ sessionId: a.sessionId, turnId: q.turnId, hasThinking: !!a.briefThinking }, "brief: thinkStyle rescue timeout — forcing close");
1991
+ // 立即刷一次最新 thinking 到气泡 (跳过去抖), 让用户看到完整积累内容,
1992
+ // 而不是停在上一次 250ms 去抖窗口的快照。
1993
+ if (a.thinkFlushTimer) {
1994
+ clearTimeout(a.thinkFlushTimer);
1995
+ a.thinkFlushTimer = undefined;
1996
+ }
1997
+ if (a.briefBubble && !a.briefBubble.done && a.briefThinking?.trim()) {
1998
+ void doStreamThink(a);
1999
+ }
2000
+ closeBriefTurn(a, true);
2001
+ }
2002
+ }, THINK_RESCUE_TIMEOUT_MS);
2003
+ }
1905
2004
  log.info({ sessionId: a.sessionId, turnId: q.turnId, isSlash: q.isSlash }, "brief: turn started");
1906
2005
  };
1907
2006
  // WeCom 侧发起一个 turn: 立刻挂 loading 气泡当 ack, 再决定是马上激活还是排队。
@@ -1936,10 +2035,18 @@ export const startMirror = (deps) => {
1936
2035
  log.warn({ sessionId: a.sessionId, turnId }, "brief: queued turn timed out — force-closing the stuck one");
1937
2036
  closeBriefTurn(a);
1938
2037
  }
1939
- // think-style: 到点也不发链接或纯 think 孤消息(a.briefThinking 属于当前活跃
1940
- // turn, 不是本排队 turn 的)。超时只收空气泡防止悬挂。
2038
+ // think-style: WeCom ~6min stream 窗口快到, 必须 finish=true 收口。
2039
+ // 保留当前已流进气泡的 thinking 内容作为最终快照 (用户看到的内容不变),
2040
+ // 后续正文走 standalone。如果 turn 尚未结论, 标记 done 让 concludeBriefTurn
2041
+ // 知道气泡已收口、走 else 分支 (sendRaw / sendStandalone)。
1941
2042
  if (thinkStyle) {
1942
- void finishBubble(a, bubble, withSessionTag(a.target, " "), true);
2043
+ // 活跃 turn thinking 才是本气泡的内容; 排队 turn 没有 thinking, 空收即可。
2044
+ const think = (a.briefBubble === bubble) ? a.briefThinking?.trim() : undefined;
2045
+ const content = think ? openThink(a.target, think) : withSessionTag(a.target, " ");
2046
+ // 标记: thinking 快照已写进气泡, 后续 concludeBriefTurn 走 else 分支时不再重复发 thinking。
2047
+ if (think)
2048
+ bubble.thinkSnapshotFlushed = true;
2049
+ void finishBubble(a, bubble, content, true);
1943
2050
  }
1944
2051
  else {
1945
2052
  void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
@@ -1949,7 +2056,11 @@ export const startMirror = (deps) => {
1949
2056
  // 首条 item 到达时清除 (handleBriefItem 路径)。用显式 bubble ref 而非 a.briefBubble,
1950
2057
  // 因为排队中的 turn 还没成为活跃 turn, a.briefBubble 指向的是前一个。
1951
2058
  // think-style: 不设 earlyTimer —— 不发链接, 气泡留着等 thinking / 答复流进来。
1952
- if (!thinkStyle)
2059
+ // codebuddy: jsonl 按完整消息落盘 (status:"completed"), 不像 Claude Code 增量写,
2060
+ // 首条 item 常在 3s 之后才到达 → earlyTimer 提前收口气泡 → 结论被迫走 standalone。
2061
+ // 跳过 earlyTimer, 让气泡等到真正的内容写入 (hardTimer 兜底 6min 窗口)。
2062
+ const skipEarly = thinkStyle || backendForPath(a.jsonlPath).name === "codebuddy";
2063
+ if (!skipEarly)
1953
2064
  bubble.earlyTimer = setTimeout(() => {
1954
2065
  if (bubble.done)
1955
2066
  return;
@@ -1963,10 +2074,12 @@ export const startMirror = (deps) => {
1963
2074
  openBriefTurn(a, q);
1964
2075
  }
1965
2076
  try {
1966
- // think-style: 用户一发消息立刻回 `<think>🧙 ` 开头 (tag 在 <think> 内), 不再先占位 `…`。
1967
- // 后续 thinking / 中途文本经 streamThink 全量替换续写, 收口时补 `</think>\n\n<final>`。
2077
+ // think-style: 延迟首条 replyStream 到首个内容到达 (streamPending=true),
2078
+ // 避免空 `<think>` 与紧随的 pushThink 产生两次快速 replyStream 触发 WeCom
2079
+ // 服务端合并/丢弃 (off-by-one)。flushStreamPending 在 pushThink/doStreamThink 首次
2080
+ // 调用时开流, 保证只有一次 replyStream 携带真实内容。
1968
2081
  if (thinkStyle) {
1969
- await client.replyStream(frame, streamId, openThink(a.target, ""), false);
2082
+ bubble.streamPending = true;
1970
2083
  }
1971
2084
  else {
1972
2085
  await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false); // 挂住 loading 气泡, 不关闭
@@ -2038,7 +2151,10 @@ export const startMirror = (deps) => {
2038
2151
  else {
2039
2152
  // 无气泡 turn / 气泡已收口: 中间 thinking 不单独 standalone, 累积的整段思考与
2040
2153
  // 正式正文拼成一条下发 (think 取自 stripTrailingBody, 已摘掉尾部等于正文的重复段)。
2041
- const t = think;
2154
+ // fix #1: 若 hardTimer 已把 thinking 快照写进气泡 (thinkSnapshotFlushed), 只发 body,
2155
+ // 不再重复发 thinking —— 用户已经在气泡里看到了完整 thinking。
2156
+ const snapshotFlushed = a.briefBubble?.thinkSnapshotFlushed;
2157
+ const t = snapshotFlushed ? undefined : think;
2042
2158
  if (t)
2043
2159
  sendRaw(a, `${openThink(a.target, t)}</think>\n\n${body}`);
2044
2160
  else
@@ -2062,19 +2178,35 @@ export const startMirror = (deps) => {
2062
2178
  return;
2063
2179
  if (soft)
2064
2180
  concludeBriefTurn(a, a.briefLastText ?? "");
2181
+ // ──── thinkStyle 兜底: turn 将收口但 concludeBriefTurn 未生效 ────
2182
+ // 正常路径 (final===true / 软收口 + briefLastText) 上面已经 conclude 过了;
2183
+ // 这里只处理"有 thinking 但正文为空"的漏网之鱼: 从 briefThinking 尾部摘出最后
2184
+ // 一段非工具行当作正文, 再试一次 conclude。保证 thinking 内容不被静默丢弃。
2185
+ if (thinkStyle && !a.briefConcluded && a.briefThinking) {
2186
+ const rescued = rescueBodyFromThinking(a.briefThinking);
2187
+ if (rescued) {
2188
+ log.info({ sessionId: a.sessionId, turnId: a.briefTurnId }, "brief: thinkStyle rescue — concluding with last text from thinking");
2189
+ concludeBriefTurn(a, rescued);
2190
+ }
2191
+ }
2065
2192
  // 气泡还开着 (有工具的 turn: 结论只发了 standalone, 气泡留到这里收链接; 或空 turn
2066
2193
  // 没有结论) —— 收口: 统一写详情链接, 保证每个 turn 都有可点入口。
2067
2194
  if (a.briefBubble && !a.briefBubble.done) {
2068
2195
  if (thinkStyle) {
2069
2196
  // 已结论 → 气泡已在 concludeBriefTurn 的 else 分支通过 sendRaw 发出
2070
2197
  // think+body 整条消息,这里只需收掉气泡(避免重复下发)。
2071
- // 未结论 → 不发纯 think 孤消息。streamThink 已提供实时预览,
2072
- // 标记 done 阻止后续 streamThink,等 concludeBriefTurn 合并下发。
2198
+ // 未结论 → doStreamThink 可能已把半成品 thinking 流进气泡 (无 </think> 闭合),
2199
+ // 用闭合的 thinking 快照收口, 避免 WeCom 渲染标签未闭合的乱排版。
2200
+ // 如果连 thinking 都没有 (streamPending 还在), 空收。
2073
2201
  if (a.briefConcluded) {
2074
2202
  void finishBriefBubble(a, withSessionTag(a.target, " "), true);
2075
2203
  }
2076
2204
  else {
2077
- a.briefBubble.done = true;
2205
+ const think = a.briefThinking?.trim();
2206
+ const content = think
2207
+ ? `${openThink(a.target, think)}</think>`
2208
+ : withSessionTag(a.target, " ");
2209
+ void finishBriefBubble(a, content, true);
2078
2210
  }
2079
2211
  }
2080
2212
  else {
@@ -2090,6 +2222,15 @@ export const startMirror = (deps) => {
2090
2222
  a.briefConcluded = false;
2091
2223
  a.briefLastText = undefined;
2092
2224
  a.briefThinking = undefined;
2225
+ if (a.thinkFlushTimer) {
2226
+ clearTimeout(a.thinkFlushTimer);
2227
+ a.thinkFlushTimer = undefined;
2228
+ }
2229
+ a.thinkFlushing = false;
2230
+ if (a.thinkRescueTimer) {
2231
+ clearTimeout(a.thinkRescueTimer);
2232
+ a.thinkRescueTimer = undefined;
2233
+ }
2093
2234
  // 放行下一个排队的 turn —— 它的气泡早在 ack 时就挂出去了, 这里只是激活。
2094
2235
  const next = a.briefQueue?.shift();
2095
2236
  if (next)
@@ -2108,8 +2249,8 @@ export const startMirror = (deps) => {
2108
2249
  // 一个僵尸 turn 上来当活跃 turn, 白收一遍。
2109
2250
  for (const q of a.briefQueue ?? []) {
2110
2251
  closed++;
2111
- // think-style: 不发链接, 排队气泡直接空收。
2112
- void finishBubble(a, q.bubble, thinkStyle ? " " : briefDetailLink(q.turnId, a.target), thinkStyle);
2252
+ // think-style: 不发链接, 排队气泡收为"已取消"提示 (避免空白气泡闪过)。
2253
+ void finishBubble(a, q.bubble, thinkStyle ? withSessionTag(a.target, " (queued, cancelled)") : briefDetailLink(q.turnId, a.target), thinkStyle);
2113
2254
  recordTurnClose(q.turnId);
2114
2255
  }
2115
2256
  a.briefQueue = [];
@@ -2128,7 +2269,7 @@ export const startMirror = (deps) => {
2128
2269
  }
2129
2270
  if (a.standaloneBuf) {
2130
2271
  clearTimeout(a.standaloneBuf.timer);
2131
- flushStandalone(a);
2272
+ flushStandalone(a, true);
2132
2273
  }
2133
2274
  if (a.liveStream && !a.liveStream.closed) {
2134
2275
  closed++;
@@ -2270,10 +2411,15 @@ export const startMirror = (deps) => {
2270
2411
  if (item.quiet)
2271
2412
  return;
2272
2413
  // slash 命令 (/context…) 的 skill_output 即本轮答案 (无 final text 收口) —— 写进
2273
- // loading 气泡。非 slash 场景的 skill_output 是中间反馈, 照旧 standalone。
2414
+ // loading 气泡。非 slash 场景的 skill_output 是中间反馈:
2415
+ // think-style → 进 think 流 (与工具调用等中间过程一致);
2416
+ // 非 think-style → standalone。
2274
2417
  if (a.briefIsSlash && !a.briefHadTool && a.briefBubble && !a.briefBubble.done) {
2275
2418
  void finishBriefBubble(a, item.body);
2276
2419
  }
2420
+ else if (thinkStyle) {
2421
+ pushThink(a, item.body);
2422
+ }
2277
2423
  else {
2278
2424
  sendStandalone(a, item.body);
2279
2425
  }
@@ -2636,7 +2782,7 @@ export const startMirror = (deps) => {
2636
2782
  void finalizeStream(a, a.liveStream);
2637
2783
  if (a.standaloneBuf) {
2638
2784
  clearTimeout(a.standaloneBuf.timer);
2639
- flushStandalone(a);
2785
+ flushStandalone(a, true);
2640
2786
  }
2641
2787
  a.tail.stop();
2642
2788
  bySessionId.delete(a.sessionId);
@@ -4123,7 +4269,7 @@ export const startMirror = (deps) => {
4123
4269
  // 3) standalone 防抖里 (3s 默认) 还压着的合并文案 — 强制立刻发。
4124
4270
  if (a.standaloneBuf) {
4125
4271
  clearTimeout(a.standaloneBuf.timer);
4126
- flushStandalone(a);
4272
+ flushStandalone(a, true);
4127
4273
  }
4128
4274
  // 4) liveStream 半成品: 当前 acc 里有内容但还没 flush 出去 — 同步刷一刀,
4129
4275
  // 保留 stream 不 finalize (后续 tool_result 还要继续 append 同一气泡)。
@@ -4181,18 +4327,13 @@ export const startMirror = (deps) => {
4181
4327
  }
4182
4328
  // freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
4183
4329
  // is still warming up so the verifier in injectViaTmux needs the slack.
4330
+ // 同 dispatch: 在 inject 之前解除静默, 防止验证循环中 LLM 回复被永久吞掉。
4331
+ a.muteUntilInject = false;
4332
+ a.justSpawned = false;
4184
4333
  const r = await inject({
4185
4334
  text, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
4186
4335
  sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: true,
4187
4336
  });
4188
- // 与 dispatch 同一约定: 首条 inject 落地 = 解除新 pane 的初始输出静默。
4189
- // 漏掉这一步的后果不只是少几条气泡 —— onItem 在 mute 分支整体早退, 连
4190
- // recordTurnStart 都不会跑, 于是 graph/send_peer 拉起来的节点在 chat 列表
4191
- // 和 chat 详情里彻底不存在(它从来没被 WeCom 侧 dispatch 过, 没有第二次机会)。
4192
- if (r.ok) {
4193
- a.muteUntilInject = false;
4194
- a.justSpawned = false;
4195
- }
4196
4337
  return r;
4197
4338
  },
4198
4339
  interruptPane: async (target, opts) => {
@@ -4479,7 +4620,7 @@ export const startMirror = (deps) => {
4479
4620
  // race against the new stream's first content (Path B).
4480
4621
  if (a.standaloneBuf) {
4481
4622
  clearTimeout(a.standaloneBuf.timer);
4482
- flushStandalone(a);
4623
+ flushStandalone(a, true);
4483
4624
  }
4484
4625
  // /clear produces no assistant output; opening a stream would leave a
4485
4626
  // stale "…" + quoted-user bubble in WeCom. Skip the stream entirely on
@@ -4590,12 +4731,18 @@ export const startMirror = (deps) => {
4590
4731
  return;
4591
4732
  }
4592
4733
  rememberInject(text);
4734
+ // 解除静默必须在 inject 之前: inject 内部的 freshSpawn 验证循环可达 10+s
4735
+ // (paste-verify + settle + waitForCleared + retry), 而 LLM 的回复可能在 Enter
4736
+ // 发出后 2-3s 就落盘 —— tail 在 mute 期间读到的 item 会被 onItem 永久丢弃,
4737
+ // 导致 turn_end 丢失, brief turn 无法收口, 后续消息全部错位 (off-by-one)。
4738
+ // 安全性: newSession 的 fresh pane 在 inject 前只写过 system/init (→ 无 item),
4739
+ // 用户行有 isOwnInject 过滤 —— 提前解除不会泄漏脏数据。
4740
+ a.muteUntilInject = false;
4593
4741
  const r = await inject({
4594
4742
  text, images, cfg, log: log.child({ principal, sessionId: sid }),
4595
4743
  sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
4596
4744
  });
4597
4745
  if (!r.ok) {
4598
- a.muteUntilInject = false;
4599
4746
  if (s) {
4600
4747
  s.acc = s.acc ? `${s.acc}\n\n[mirror] ✗ ${r.reason ?? "failed"}` : `[mirror] ✗ ${r.reason ?? "failed"}`;
4601
4748
  await finalizeStream(a, s);
@@ -4619,8 +4766,6 @@ export const startMirror = (deps) => {
4619
4766
  }
4620
4767
  return;
4621
4768
  }
4622
- // inject 成功 → 解除初始输出静默, 后续 onItem 正常流转。
4623
- a.muteUntilInject = false;
4624
4769
  // Inject "succeeded" but the input box never cleared — the target
4625
4770
  // session may be busy / not consuming input (long task, full context).
4626
4771
  // Hint the user once so a silently-dropped message isn't mistaken for a