wezard 1.2.12 → 1.2.18

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.
Files changed (54) hide show
  1. package/README.md +2 -3
  2. package/config.example.jsonc +32 -0
  3. package/dist/cli/init.js +53 -2
  4. package/dist/cli/init.js.map +1 -1
  5. package/dist/daemon/approval.js +479 -106
  6. package/dist/daemon/approval.js.map +1 -1
  7. package/dist/daemon/danger.js +23 -0
  8. package/dist/daemon/danger.js.map +1 -1
  9. package/dist/daemon/graph.js +22 -4
  10. package/dist/daemon/graph.js.map +1 -1
  11. package/dist/daemon/inbound.js +112 -71
  12. package/dist/daemon/inbound.js.map +1 -1
  13. package/dist/daemon/index.js +67 -17
  14. package/dist/daemon/index.js.map +1 -1
  15. package/dist/daemon/mirror-bridge.js +564 -133
  16. package/dist/daemon/mirror-bridge.js.map +1 -1
  17. package/dist/daemon/peers.js +69 -7
  18. package/dist/daemon/peers.js.map +1 -1
  19. package/dist/daemon/pending.js +4 -3
  20. package/dist/daemon/pending.js.map +1 -1
  21. package/dist/daemon/session-name.js +133 -0
  22. package/dist/daemon/session-name.js.map +1 -0
  23. package/dist/daemon/session-scan.js +18 -2
  24. package/dist/daemon/session-scan.js.map +1 -1
  25. package/dist/daemon/spawn-tmux.js +103 -16
  26. package/dist/daemon/spawn-tmux.js.map +1 -1
  27. package/dist/daemon/ws.js +19 -1
  28. package/dist/daemon/ws.js.map +1 -1
  29. package/dist/mcp/server.js +4 -4
  30. package/dist/mcp/server.js.map +1 -1
  31. package/dist/shared/allow-rules.js +391 -0
  32. package/dist/shared/allow-rules.js.map +1 -0
  33. package/dist/shared/chat-render.js +2 -0
  34. package/dist/shared/chat-render.js.map +1 -1
  35. package/dist/shared/chat-view.js +37 -2
  36. package/dist/shared/chat-view.js.map +1 -1
  37. package/dist/shared/claude-config-path.js +132 -0
  38. package/dist/shared/claude-config-path.js.map +1 -0
  39. package/dist/shared/claude-permissions.js +63 -0
  40. package/dist/shared/claude-permissions.js.map +1 -0
  41. package/dist/shared/cli-backends.js +15 -2
  42. package/dist/shared/cli-backends.js.map +1 -1
  43. package/dist/shared/config.js +42 -2
  44. package/dist/shared/config.js.map +1 -1
  45. package/dist/shared/detail-render.js +22 -1
  46. package/dist/shared/detail-render.js.map +1 -1
  47. package/dist/shared/detail-store.js.map +1 -1
  48. package/dist/shared/modal-pane.js +101 -0
  49. package/dist/shared/modal-pane.js.map +1 -0
  50. package/dist/shared/session-label.js +26 -3
  51. package/dist/shared/session-label.js.map +1 -1
  52. package/package.json +1 -1
  53. package/web/chat.css +30 -0
  54. package/web/chat.js +60 -6
@@ -17,13 +17,14 @@ import { existsSync, readdirSync, statSync, watch, openSync, readSync, closeSync
17
17
  import { join, dirname } from "node:path";
18
18
  import { expandHome, sanitizeId } from "../shared/paths.js";
19
19
  import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
20
+ import { isModalPane, parseModalOptions, pickModalAnswer } from "../shared/modal-pane.js";
20
21
  import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow } from "./approval.js";
21
- import { spawnTmuxClaude } from "./spawn-tmux.js";
22
+ import { runTmux as runTmuxCmd, spawnTmuxClaude } from "./spawn-tmux.js";
22
23
  import { recordTool, recordToolResult, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
23
24
  import { labelFor, tagOfKey, baseOfKey, withTagHeader } from "../shared/session-label.js";
24
25
  import { splitMarkdown } from "../shared/md-chunk.js";
25
26
  import { randomTip } from "./tips.js";
26
- import { stripAnsi, compactPane, paneIsBusy, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps } from "./peers.js";
27
+ import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
27
28
  // Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
28
29
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
29
30
  const NODE_BIN_DIR = dirname(process.execPath);
@@ -763,16 +764,12 @@ export const startMirrorTail = (deps) => {
763
764
  drain,
764
765
  };
765
766
  };
766
- // Run a tmux subcommand, capturing stdout/stderr. Local helper to avoid
767
- // pulling spawn-tmux's runTmux into the bridge module graph.
768
- const tmuxRun = (args) => new Promise((resolve) => {
769
- const p = spawn("tmux", args, { stdio: ["ignore", "pipe", "pipe"] });
770
- let out = "", err = "";
771
- p.stdout?.on("data", (c) => (out += c.toString("utf8")));
772
- p.stderr?.on("data", (c) => (err += c.toString("utf8")));
773
- p.on("error", (e) => resolve({ ok: false, stdout: "", stderr: e.message }));
774
- p.on("close", (code) => resolve({ ok: code === 0, stdout: out, stderr: err }));
775
- });
767
+ // Run a tmux subcommand, capturing stdout/stderr. Delegates to spawn-tmux's
768
+ // runTmux so there is exactly ONE tmux exec path in the daemon — and therefore
769
+ // exactly one place where the hard timeout lives. (Two hand-rolled copies used
770
+ // to exist here; both could hang forever, which is how a wedged tmux server
771
+ // silently killed a whole chat. See TMUX_TIMEOUT_MS.)
772
+ const tmuxRun = (args, opts) => runTmuxCmd(args, opts);
776
773
  const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
777
774
  // Stable JSON: sort object keys recursively. Used to fingerprint a tool_use's
778
775
  // `input` so the hook-side and the jsonl-side compute the same signature even
@@ -821,12 +818,29 @@ const PB_CLASS_BY_EXT = {
821
818
  tif: "«class TIFF»",
822
819
  tiff: "«class TIFF»",
823
820
  };
821
+ // Same no-unbounded-await rule as runTmux: this sits on the inject path (image
822
+ // pump), and `osascript` in particular can block indefinitely if macOS decides
823
+ // to raise an automation-permission prompt nobody is there to answer. 30s is
824
+ // generous for a sips transcode of a large screenshot.
825
+ const PROC_TIMEOUT_MS = 30_000;
824
826
  const runProc = (cmd, args) => new Promise((resolve) => {
825
827
  const p = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
826
828
  let err = "";
829
+ let settled = false;
830
+ const finish = (r) => {
831
+ if (settled)
832
+ return;
833
+ settled = true;
834
+ clearTimeout(timer);
835
+ resolve(r);
836
+ };
837
+ const timer = setTimeout(() => {
838
+ p.kill("SIGKILL");
839
+ finish({ ok: false, stderr: `${cmd} timeout after ${PROC_TIMEOUT_MS}ms` });
840
+ }, PROC_TIMEOUT_MS);
827
841
  p.stderr?.on("data", (c) => (err += c.toString("utf8")));
828
- p.on("error", (e) => resolve({ ok: false, stderr: e.message }));
829
- p.on("close", (code) => resolve({ ok: code === 0, stderr: err }));
842
+ p.on("error", (e) => finish({ ok: false, stderr: e.message }));
843
+ p.on("close", (code) => finish({ ok: code === 0, stderr: err }));
830
844
  });
831
845
  const setMacClipboardImage = async (imgPath) => {
832
846
  const ext = (imgPath.split(".").pop() ?? "").toLowerCase();
@@ -951,6 +965,28 @@ const extractPaneAssistantTail = (pane) => {
951
965
  const text = out.join("\n").trim();
952
966
  return text.length >= 12 ? text : "";
953
967
  };
968
+ // Is the pane sitting on a modal picker that would eat an injection?
969
+ //
970
+ // Deliberately NOT capturePaneTail: that one passes `-S -N`, which reaches
971
+ // into the scrollback. A confirm the user already answered lives in the
972
+ // scrollback forever, so a scrollback-inclusive capture would keep matching it
973
+ // long after the pane went back to accepting input — wedging the mirror shut
974
+ // permanently. Bare `-p` captures only what is currently on screen.
975
+ //
976
+ // Trailing blank rows are trimmed first so the 15-row window lands on the
977
+ // bottom-most *content* (a picker's footer is its last line); 15 rows spans
978
+ // "title → options → footer" on every confirm layout we've seen.
979
+ const MODAL_SCAN_ROWS = 15;
980
+ // `screen` = 实际参与判定的那一屏文本, 一并返回给调用方做选项解析 —— 再 capture
981
+ // 一次会拿到"下一瞬间"的屏幕, 与 verdict 不同源(框可能已被本地按掉), 按键就按错了。
982
+ const detectModalPicker = async (target) => {
983
+ const r = await tmuxRun(["capture-pane", "-t", target, "-p"]);
984
+ if (!r.ok)
985
+ return { modal: false, screen: "" };
986
+ const lines = r.stdout.replace(/\s+$/u, "").split("\n");
987
+ const screen = lines.slice(-MODAL_SCAN_ROWS).join("\n");
988
+ return { ...isModalPane(screen), screen };
989
+ };
954
990
  // Two fingerprints, derived from different ends of `text`:
955
991
  // headFp — first 8 non-ws chars; used for "did paste land" against a wide
956
992
  // window because long pastes wrap and the head sits near the top of the
@@ -974,6 +1010,24 @@ const fingerprints = (text) => {
974
1010
  // 4. on stuck-after-Enter, retry Enter once with extra settle.
975
1011
  const injectViaTmux = async (target, text, images, log, freshSpawn, backendName) => {
976
1012
  log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
1013
+ // Never type into a modal picker (see detectModalPicker). Checked before the
1014
+ // image pump too — a C-v into a picker is just as destructive as a paste.
1015
+ // 必须在按后端分流之前: 每个后端都有自己的原生确认框, 往任何一个里打字都会
1016
+ // 替用户点掉框并吞掉这条消息。放到 codebuddy 早退之后就等于只保护了 claude。
1017
+ // Escape hatch: WEZARD_MODAL_GUARD=0, in case a future TUI layout trips it.
1018
+ if (process.env.WEZARD_MODAL_GUARD !== "0") {
1019
+ const modal = await detectModalPicker(target);
1020
+ if (modal.modal) {
1021
+ log.warn({ target, title: modal.title, backendName }, "mirror inject: modal picker on pane, refusing to inject");
1022
+ const what = modal.title ? `「${modal.title}」` : "原生确认框";
1023
+ return {
1024
+ ok: false,
1025
+ reason: `目标会话停在 CLI ${what} 等待确认,消息未送达。`
1026
+ + `(强行注入会替你点掉确认框并吞掉这条消息)`
1027
+ + `请到 tmux 里处理该确认框,或发 /stop 取消当前操作后重发。`,
1028
+ };
1029
+ }
1030
+ }
977
1031
  // 图片注入策略按后端分流:
978
1032
  // claude / claude-internal —— 走 macOS 剪贴板 + Ctrl+V,TUI 收到 \x16 后
979
1033
  // 直接读系统剪贴板的 PNGf flavor,附加为 image content block。
@@ -1028,21 +1082,11 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
1028
1082
  const CLEARED_TIMEOUT_MS = freshSpawn ? 4000 : 1500;
1029
1083
  const RETRY_SETTLE_MS = freshSpawn ? 1500 : 800;
1030
1084
  const loadAndPaste = async () => {
1031
- const loaded = await new Promise((resolve) => {
1032
- const loader = spawn("tmux", ["load-buffer", "-"], { stdio: ["pipe", "ignore", "pipe"] });
1033
- let lerr = "";
1034
- loader.stderr?.on("data", (c) => (lerr += c.toString("utf8")));
1035
- loader.on("error", (e) => resolve({ ok: false, reason: `tmux not found: ${e.message}` }));
1036
- loader.on("close", (code) => {
1037
- if (code !== 0)
1038
- resolve({ ok: false, reason: `tmux load-buffer exit ${code}: ${lerr.slice(-200)}` });
1039
- else
1040
- resolve({ ok: true });
1041
- });
1042
- loader.stdin?.end(text);
1043
- });
1085
+ // stdin variant of the shared exec path — it carries the same hard timeout,
1086
+ // which a hand-rolled spawn here did not.
1087
+ const loaded = await tmuxRun(["load-buffer", "-"], { stdin: text });
1044
1088
  if (!loaded.ok)
1045
- return loaded;
1089
+ return { ok: false, reason: `tmux load-buffer failed: ${loaded.stderr.slice(-200) || loaded.code}` };
1046
1090
  const pasted = await tmuxRun(["paste-buffer", "-p", "-d", "-t", target]);
1047
1091
  if (!pasted.ok)
1048
1092
  return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
@@ -1192,6 +1236,21 @@ const inject = (args) => {
1192
1236
  return injectViaTmux(target, args.text, args.images ?? [], args.log, args.freshSpawn ?? false, backendName);
1193
1237
  };
1194
1238
  const queues = new Map();
1239
+ /** Ceiling for one whole inject job (respawn + paste + submit verification).
1240
+ * Worst legitimate case is a cold `--resume` respawn (~8s) plus a fresh-spawn
1241
+ * inject with both paste re-try and both cleared-polls (~30s), so 90s is ~2.5x
1242
+ * headroom. Past that the job is wedged, and holding the queue is strictly
1243
+ * worse than dropping the turn: every later message in that chat would queue
1244
+ * behind it forever. Well under brief's 350s bubble ceiling so the user still
1245
+ * gets an error bubble rather than a silent stream timeout. */
1246
+ const INJECT_JOB_TIMEOUT_MS = 90_000;
1247
+ /** Drop a session's queue chain. A hung job can't be cancelled (it's parked in a
1248
+ * tmux read), but detaching the chain means the NEXT message doesn't inherit
1249
+ * its deadlock. Pair with an injectGen bump so the zombie can't act when it
1250
+ * eventually returns. */
1251
+ const abortInjectQueue = (key) => {
1252
+ queues.delete(key);
1253
+ };
1195
1254
  const enqueue = (key, job) => {
1196
1255
  const prev = queues.get(key) ?? Promise.resolve();
1197
1256
  const next = prev.then(job, job).catch(() => undefined);
@@ -1359,10 +1418,10 @@ export const startMirror = (deps) => {
1359
1418
  try {
1360
1419
  if (card) {
1361
1420
  s.cardSent = true;
1362
- await client.replyStreamWithCard(s.frame, s.streamId, withSessionTag(a.target, s.acc || " "), true, { templateCard: card });
1421
+ await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, s.acc || " ", undefined, s.turnId), true, { templateCard: card });
1363
1422
  }
1364
1423
  else {
1365
- await client.replyStream(s.frame, s.streamId, withSessionTag(a.target, s.acc || " "), true);
1424
+ await client.replyStream(s.frame, s.streamId, withLinkedTag(a, s.acc || " ", undefined, s.turnId), true);
1366
1425
  }
1367
1426
  log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, withCard: !!card }, "stream finalize");
1368
1427
  }
@@ -1400,12 +1459,27 @@ export const startMirror = (deps) => {
1400
1459
  };
1401
1460
  // Standalone fallback (no live stream / stream dead). Per-attachment FIFO so
1402
1461
  // pushes from a single mirror stay ordered; different mirrors run in parallel.
1462
+ // Linked tag prefix: emoji+tag becomes a chat-detail link. Falls back to
1463
+ // plain withSessionTag when no turnId is available (no active turn to link).
1464
+ const linkedTagPrefix = (target, turnId) => {
1465
+ if (!turnId)
1466
+ return "";
1467
+ const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
1468
+ const tag = tagOfKey(target);
1469
+ return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
1470
+ };
1471
+ const withLinkedTag = (a, content, seq, turnId) => {
1472
+ const prefix = linkedTagPrefix(a.target, turnId ?? a.briefTurnId);
1473
+ if (prefix) {
1474
+ const seqBit = seq ? ` ${seq}` : "";
1475
+ return `${prefix}${seqBit} ${content}`;
1476
+ }
1477
+ return withSessionTag(a.target, content, seq);
1478
+ };
1403
1479
  const sendStandalone = (a, content) => {
1404
1480
  const chatId = stripPrincipalPrefix(a.target);
1405
- // Tag AFTER splitting — one header per bubble, so chunk 2..N stay
1406
- // attributable to the session instead of arriving anonymous.
1407
1481
  const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.chunkChars - TAG_HEADER_BUDGET));
1408
- const chunks = pieces.map((p, i) => withSessionTag(a.target, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
1482
+ const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
1409
1483
  a.standalonePending = a.standalonePending
1410
1484
  .then(async () => {
1411
1485
  for (const c of chunks) {
@@ -1419,6 +1493,20 @@ export const startMirror = (deps) => {
1419
1493
  })
1420
1494
  .catch(() => undefined);
1421
1495
  };
1496
+ // Like sendStandalone but skips withSessionTag — content already contains the tag header (e.g. as a link).
1497
+ const sendRaw = (a, content) => {
1498
+ const chatId = stripPrincipalPrefix(a.target);
1499
+ a.standalonePending = a.standalonePending
1500
+ .then(async () => {
1501
+ try {
1502
+ await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
1503
+ }
1504
+ catch (e) {
1505
+ log.warn({ sessionId: a.sessionId, err: e.message }, "raw push failed");
1506
+ }
1507
+ })
1508
+ .catch(() => undefined);
1509
+ };
1422
1510
  // Pre-card preamble recovery. Called right before an approval card when the
1423
1511
  // gating tool_use is NOT yet in the jsonl (mirror mode: CC defers the whole
1424
1512
  // tool-terminated turn's flush until the tool resolves — after the card is
@@ -1553,7 +1641,7 @@ export const startMirror = (deps) => {
1553
1641
  flushPendingStandalone(a);
1554
1642
  void (async () => {
1555
1643
  try {
1556
- await client.replyStream(out.frame, out.streamId, withSessionTag(a.target, bubbleMd || " "), true);
1644
+ await client.replyStream(out.frame, out.streamId, withLinkedTag(a, bubbleMd || " "), true);
1557
1645
  }
1558
1646
  catch (e) {
1559
1647
  log.warn({ sessionId: a.sessionId, err: e.message }, "exit-deferred finalize failed; falling back to standalone");
@@ -1649,10 +1737,17 @@ export const startMirror = (deps) => {
1649
1737
  // 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
1650
1738
  // 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
1651
1739
  // target 作为 ww_uniq 传下去, 让同 chat 的所有 turn 详情都复用一个 WeCom 窗口。
1652
- const briefDetailLink = (turnId, target) => `🧙 [View chat details](${buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target))})`;
1740
+ const briefDetailLink = (turnId, target) => {
1741
+ const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
1742
+ const tag = tagOfKey(target);
1743
+ return tag
1744
+ ? `[${labelFor(tag)} #${tag}](${url}) ← View chat details`
1745
+ : `[🧙](${url}) ← View chat details`;
1746
+ };
1653
1747
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
1654
1748
  // 排队中的 turn 也持有气泡, 所以气泡是显式入参而不是从 a 上取。
1655
- const finishBubble = async (a, b, content) => {
1749
+ // raw=true skips withSessionTag (used when content already contains the linked tag header).
1750
+ const finishBubble = async (a, b, content, raw = false) => {
1656
1751
  if (!b || b.done)
1657
1752
  return;
1658
1753
  b.done = true;
@@ -1664,15 +1759,15 @@ export const startMirror = (deps) => {
1664
1759
  if (a.briefBubble === b)
1665
1760
  a.briefBubble = undefined;
1666
1761
  try {
1667
- await client.replyStream(b.frame, b.streamId, withSessionTag(a.target, content || " "), true);
1762
+ await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
1668
1763
  }
1669
1764
  catch (e) {
1670
1765
  log.warn({ sessionId: a.sessionId, err: e.message }, "brief: bubble finish failed; standalone fallback");
1671
1766
  if (content.trim())
1672
- sendStandalone(a, content);
1767
+ raw ? sendRaw(a, content) : sendStandalone(a, content);
1673
1768
  }
1674
1769
  };
1675
- const finishBriefBubble = (a, content) => finishBubble(a, a.briefBubble, content);
1770
+ const finishBriefBubble = (a, content, raw = false) => finishBubble(a, a.briefBubble, content, raw);
1676
1771
  // 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
1677
1772
  const openBriefTurn = (a, q) => {
1678
1773
  a.briefTurnId = q.turnId;
@@ -1692,9 +1787,18 @@ export const startMirror = (deps) => {
1692
1787
  a.ctxCut = undefined;
1693
1788
  return cut;
1694
1789
  };
1790
+ // graph 归因同样一次性: 一次注入只解释它开出的那一轮。超过 ORIGIN_TTL_MS 还没被
1791
+ // 认领 = 那次注入没能开出 turn, 印章作废 —— 宁可少标一轮, 也不能把人打的字冒认成
1792
+ // graph 派的 (归因错了比没有更糟)。
1793
+ const ORIGIN_TTL_MS = 5 * 60_000;
1794
+ const consumeOrigin = (a) => {
1795
+ const p = a.pendingOrigin;
1796
+ a.pendingOrigin = undefined;
1797
+ return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.origin : undefined;
1798
+ };
1695
1799
  const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "") => {
1696
1800
  const turnId = newTurnId();
1697
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: userQuery.trim() || undefined, cut: consumeCut(a) });
1801
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, cut: consumeCut(a), origin: consumeOrigin(a) });
1698
1802
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
1699
1803
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
1700
1804
  const q = { turnId, bubble, isSlash };
@@ -1706,7 +1810,7 @@ export const startMirror = (deps) => {
1706
1810
  log.warn({ sessionId: a.sessionId, turnId }, "brief: queued turn timed out — force-closing the stuck one");
1707
1811
  closeBriefTurn(a);
1708
1812
  }
1709
- void finishBubble(a, bubble, briefDetailLink(turnId, a.target));
1813
+ void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
1710
1814
  }, HARD_TIMEOUT_MS);
1711
1815
  // earlyTimer: 3s 无 CLI 产出 → 先把 loading 气泡收成详情链接, 用户可即时点入。
1712
1816
  // 首条 item 到达时清除 (handleBriefItem 路径)。用显式 bubble ref 而非 a.briefBubble,
@@ -1714,7 +1818,7 @@ export const startMirror = (deps) => {
1714
1818
  bubble.earlyTimer = setTimeout(() => {
1715
1819
  if (bubble.done)
1716
1820
  return;
1717
- void finishBubble(a, bubble, briefDetailLink(turnId, a.target));
1821
+ void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
1718
1822
  }, EARLY_LINK_MS);
1719
1823
  if (a.briefTurnId) {
1720
1824
  (a.briefQueue ??= []).push(q);
@@ -1740,14 +1844,14 @@ export const startMirror = (deps) => {
1740
1844
  const turnId = newTurnId();
1741
1845
  const query = a.pendingBriefQuery?.replace(/^> ?/gm, "").trim();
1742
1846
  a.pendingBriefQuery = undefined;
1743
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: query || undefined, cut: consumeCut(a) });
1847
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: query || undefined, cut: consumeCut(a), origin: consumeOrigin(a) });
1744
1848
  a.briefTurnId = turnId;
1745
1849
  a.briefBubble = undefined;
1746
1850
  a.briefIsSlash = false;
1747
1851
  a.briefHadTool = false;
1748
1852
  a.briefConcluded = false;
1749
1853
  a.briefLastText = undefined;
1750
- enqueueStandalone(a, briefDetailLink(turnId, a.target));
1854
+ sendRaw(a, briefDetailLink(turnId, a.target));
1751
1855
  log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
1752
1856
  };
1753
1857
  // 本轮一旦出现工具调用 / tool_result / 非 final 文本, 就说明这不是"一句话答完"的
@@ -1758,7 +1862,7 @@ export const startMirror = (deps) => {
1758
1862
  if (!a.briefTurnId || !a.briefBubble || a.briefBubble.done)
1759
1863
  return;
1760
1864
  a.briefHadTool = true;
1761
- void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target));
1865
+ void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
1762
1866
  };
1763
1867
  // 本轮结论落地, 只生效一次:
1764
1868
  // • 无工具 → 结论 + 详情链接一并写进 loading 气泡 (仍是一条消息);
@@ -1787,7 +1891,7 @@ export const startMirror = (deps) => {
1787
1891
  // 气泡还开着 (有工具的 turn: 结论只发了 standalone, 气泡留到这里收链接; 或空 turn
1788
1892
  // 没有结论) —— 收口: 统一写详情链接, 保证每个 turn 都有可点入口。
1789
1893
  if (a.briefBubble && !a.briefBubble.done) {
1790
- void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target));
1894
+ void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
1791
1895
  }
1792
1896
  recordTurnClose(a.briefTurnId);
1793
1897
  log.info({ sessionId: a.sessionId, turnId: a.briefTurnId }, "brief: turn closed");
@@ -1802,6 +1906,78 @@ export const startMirror = (deps) => {
1802
1906
  if (next)
1803
1907
  openBriefTurn(a, next);
1804
1908
  };
1909
+ // 强制收口一个 attachment 的全部出站通道, 并返回收掉的气泡/流条数。
1910
+ //
1911
+ // 关键约束: **一个 tmux 调用都不许有**。它的两个调用方 (`/stop` 和 inject
1912
+ // watchdog) 恰恰是在 tmux 已经卡死时才最需要生效 —— 历史故障里 tmux 无响应
1913
+ // 导致 dispatch 静默挂死, 用户在手机上看到一条永不结束的 "…" 气泡, 发 /stop
1914
+ // 也没用 (旧 /stop 第一件事就是 tmuxPaneAlive, 一起卡死)。所以这里只做纯内存
1915
+ // 状态的清算, Esc 之类要碰 pane 的动作留给调用方在这之后自己尽力去做。
1916
+ const teardownOutbound = (a, note) => {
1917
+ let closed = 0;
1918
+ // 排队中的 turn 先清空再动当前 turn —— 反序会让 closeBriefTurn 顺手 shift
1919
+ // 一个僵尸 turn 上来当活跃 turn, 白收一遍。
1920
+ for (const q of a.briefQueue ?? []) {
1921
+ closed++;
1922
+ void finishBubble(a, q.bubble, briefDetailLink(q.turnId, a.target));
1923
+ recordTurnClose(q.turnId);
1924
+ }
1925
+ a.briefQueue = [];
1926
+ if (a.briefTurnId) {
1927
+ closed++;
1928
+ closeBriefTurn(a);
1929
+ }
1930
+ if (a.softEnd) {
1931
+ clearTimeout(a.softEnd);
1932
+ a.softEnd = undefined;
1933
+ }
1934
+ if (a.outbound) {
1935
+ if (a.outbound.kind === "deferred")
1936
+ clearTimeout(a.outbound.timer);
1937
+ a.outbound = undefined;
1938
+ }
1939
+ if (a.standaloneBuf) {
1940
+ clearTimeout(a.standaloneBuf.timer);
1941
+ flushStandalone(a);
1942
+ }
1943
+ if (a.liveStream && !a.liveStream.closed) {
1944
+ closed++;
1945
+ void finalizeStream(a, a.liveStream);
1946
+ }
1947
+ if (note)
1948
+ sendStandalone(a, note);
1949
+ return closed;
1950
+ };
1951
+ // Watchdog around one inject job. The failure this exists for: every await in
1952
+ // the job chain used to be unbounded, so a wedged tmux server parked the job
1953
+ // forever — no log line, no error bubble, and the per-session queue stayed
1954
+ // locked behind it, muting that chat until the daemon restarted. Now the queue
1955
+ // is released on a deadline and the user gets told.
1956
+ const withInjectWatchdog = async (a, gen, job) => {
1957
+ let timer;
1958
+ const timedOut = new Promise((resolve) => {
1959
+ timer = setTimeout(() => resolve("timeout"), INJECT_JOB_TIMEOUT_MS);
1960
+ });
1961
+ const outcome = await Promise.race([
1962
+ job().then(() => "done", (e) => {
1963
+ log.error({ target: a.target, sessionId: a.sessionId, err: e?.message }, "inject job threw");
1964
+ return "done";
1965
+ }),
1966
+ timedOut,
1967
+ ]);
1968
+ if (timer)
1969
+ clearTimeout(timer);
1970
+ if (outcome !== "timeout")
1971
+ return;
1972
+ // Racing already released the queue for the next message. The zombie is
1973
+ // still parked in tmux, so bump the generation to disarm its paste, and
1974
+ // detach the queue chain it thinks it owns.
1975
+ if ((a.injectGen ?? 0) === gen)
1976
+ a.injectGen = gen + 1;
1977
+ abortInjectQueue(a.sessionId);
1978
+ log.error({ target: a.target, sessionId: a.sessionId, gen: a.injectGen, timeoutMs: INJECT_JOB_TIMEOUT_MS }, "inject job watchdog fired — queue released, turn abandoned");
1979
+ teardownOutbound(a, `[mirror] ✗ 注入超时 ${Math.round(INJECT_JOB_TIMEOUT_MS / 1000)}s — tmux 无响应, 本轮已放弃。再发一条消息即可重试, 或 \`/new\` 换个 pane。`);
1980
+ };
1805
1981
  // Route one RenderItem into the turn store. final text 会额外作为 standalone 发群。
1806
1982
  const handleBriefItem = (a, item) => {
1807
1983
  const turnId = a.briefTurnId;
@@ -1940,6 +2116,29 @@ export const startMirror = (deps) => {
1940
2116
  clearTimeout(a.keepaliveQuiet);
1941
2117
  a.keepaliveQuiet = undefined;
1942
2118
  };
2119
+ // A reply is still a heartbeat while its letters remain a prefix of "pong"
2120
+ // (covers streamed partials "p"/"po"/"pon" and trailing punctuation). Anything
2121
+ // else means the model ignored the ping and resumed real work.
2122
+ const pongPrefix = (s) => "pong".startsWith(s.replace(/[^a-zA-Z]/g, "").toLowerCase());
2123
+ // Stop swallowing the in-flight ping turn: close its heartbeat detail turn,
2124
+ // lift the quiet window, and re-anchor the clocks to "real activity now" so the
2125
+ // resumed work resets the round budget. The caller then falls through to the
2126
+ // normal onItem path, letting this and the rest of the turn reach chat.
2127
+ const releaseKeepalive = (a) => {
2128
+ if (a.keepaliveTurnId) {
2129
+ recordTurnClose(a.keepaliveTurnId);
2130
+ a.keepaliveTurnId = undefined;
2131
+ }
2132
+ endKeepaliveQuiet(a);
2133
+ a.keepalivePongBuf = undefined;
2134
+ if (a.keepalive) {
2135
+ a.keepalive.round = 0;
2136
+ a.keepalive.pinging = false;
2137
+ a.keepalive.settledAt = 0;
2138
+ a.keepalive.lastRealMs = Date.now();
2139
+ }
2140
+ log.info({ target: a.target, sessionId: a.sessionId }, "keepalive: reply diverged from pong — un-swallowing resumed turn");
2141
+ };
1943
2142
  const onItem = (a, item) => {
1944
2143
  // 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
1945
2144
  if (a.muteUntilInject)
@@ -1951,30 +2150,39 @@ export const startMirror = (deps) => {
1951
2150
  // cache-read) — so the detail page shows the genuine heartbeat, not a
1952
2151
  // synthetic summary. Closed on the terminal signal (hard or soft turn_end).
1953
2152
  if (a.keepaliveQuiet) {
1954
- const id = a.keepaliveTurnId;
1955
- if (id) {
1956
- const now = Date.now();
1957
- if (item.kind === "text") {
1958
- recordTurnItem(id, { t: "text", body: item.body, ts: now, final: item.final === true });
1959
- }
1960
- else if (item.kind === "tool_use") {
1961
- for (const c of item.calls)
1962
- recordTurnItem(id, { t: "tool_use", toolUseId: c.toolUseId, toolName: c.name, toolInput: c.input, ts: now });
1963
- }
1964
- else if (item.kind === "tool_result") {
1965
- recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
1966
- }
1967
- else if (item.kind === "turn_usage") {
1968
- recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
2153
+ // Swallow only while the reply is consistent with a bare "pong". A text
2154
+ // whose accumulated letters leave the "pong" prefix, or ANY tool_use (a
2155
+ // heartbeat never calls tools), means the model resumed real work → release
2156
+ // and fall through so the turn reaches chat instead of being hidden.
2157
+ const nextBuf = item.kind === "text" ? (a.keepalivePongBuf ?? "") + item.body : (a.keepalivePongBuf ?? "");
2158
+ const divergent = item.kind === "tool_use" || (item.kind === "text" && !pongPrefix(nextBuf));
2159
+ if (!divergent) {
2160
+ if (item.kind === "text")
2161
+ a.keepalivePongBuf = nextBuf;
2162
+ const id = a.keepaliveTurnId;
2163
+ if (id) {
2164
+ const now = Date.now();
2165
+ if (item.kind === "text") {
2166
+ recordTurnItem(id, { t: "text", body: item.body, ts: now, final: item.final === true });
2167
+ }
2168
+ else if (item.kind === "tool_result") {
2169
+ recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
2170
+ }
2171
+ else if (item.kind === "turn_usage") {
2172
+ recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
2173
+ }
2174
+ else if (item.kind === "turn_end") {
2175
+ recordTurnClose(id);
2176
+ a.keepaliveTurnId = undefined;
2177
+ }
1969
2178
  }
1970
- else if (item.kind === "turn_end") {
1971
- recordTurnClose(id);
1972
- a.keepaliveTurnId = undefined;
2179
+ if (item.kind === "turn_end") {
2180
+ endKeepaliveQuiet(a);
2181
+ a.keepalivePongBuf = undefined;
1973
2182
  }
2183
+ return;
1974
2184
  }
1975
- if (item.kind === "turn_end")
1976
- endKeepaliveQuiet(a);
1977
- return;
2185
+ releaseKeepalive(a); // fall through to normal handling for this item
1978
2186
  }
1979
2187
  // 任何新 item 到达 = 上一条"消息写完了"并不代表这一轮结束 → 撤销待确认的软收口。
1980
2188
  // 硬信号 (end_turn / turn_duration) 的后端永远不会走到这里。
@@ -2214,6 +2422,7 @@ export const startMirror = (deps) => {
2214
2422
  a.migrationWatcher.cancel();
2215
2423
  a.migrationWatcher = undefined;
2216
2424
  }
2425
+ a.clearRebind = undefined;
2217
2426
  if (a.outbound?.kind === "deferred")
2218
2427
  clearTimeout(a.outbound.timer);
2219
2428
  a.outbound = undefined;
@@ -2254,7 +2463,14 @@ export const startMirror = (deps) => {
2254
2463
  // Replace any existing attach with the same sessionId or same target. The
2255
2464
  // sessionId clash is the "/wrc again from same window" case; the target
2256
2465
  // clash is "different window steals my WeCom chat" — both end the previous.
2466
+ // Guard: if a DIFFERENT principal already owns this sessionId, refuse rather
2467
+ // than detaching it — prevents cross-wire when concurrent restoreFromStore
2468
+ // calls race or heal logic picks the same newest jsonl.
2257
2469
  const prevBySid = bySessionId.get(sessionId);
2470
+ if (prevBySid && prevBySid.target !== target) {
2471
+ log.warn({ sessionId, existingTarget: prevBySid.target, incomingTarget: target }, "mirror attach: sessionId already bound to another principal, refusing");
2472
+ return { ok: false, reason: `sessionId ${sessionId} already bound to ${prevBySid.target}` };
2473
+ }
2258
2474
  if (prevBySid)
2259
2475
  detach(prevBySid, "sessionId reattach");
2260
2476
  const prevByTarget = byTarget.get(target);
@@ -2318,16 +2534,9 @@ export const startMirror = (deps) => {
2318
2534
  return { ok: true, sessionId, jsonlPath, target };
2319
2535
  };
2320
2536
  // ── Persistence: restore-from-store (lazy + boot) ────────────────────
2321
- const tmuxRun = (args) => new Promise((resolve) => {
2322
- const p = spawn("tmux", args, {
2323
- env: { ...process.env, PATH: augmentedPath(process.env.PATH) },
2324
- stdio: ["ignore", "pipe", "ignore"],
2325
- });
2326
- let out = "";
2327
- p.stdout?.on("data", (c) => (out += c.toString("utf8")));
2328
- p.on("error", () => resolve({ code: null, stdout: "" }));
2329
- p.on("close", (code) => resolve({ code, stdout: out }));
2330
- });
2537
+ // NOTE: this used to be a second hand-rolled `spawn("tmux", …)` that shadowed
2538
+ // the module-level helper — and had no timeout, so `/stop` itself could hang
2539
+ // on a wedged server. It now resolves to the same single exec path.
2331
2540
  // Verify a paneId still exists. `display-message -t <paneId>` succeeds iff
2332
2541
  // the pane is alive — and tmux pane ids monotonically increment within a
2333
2542
  // server lifetime, so a freed id will not be silently reused. We don't need
@@ -2549,6 +2758,26 @@ export const startMirror = (deps) => {
2549
2758
  return new Set();
2550
2759
  }
2551
2760
  };
2761
+ // Every `/clear` the daemon injects, keyed by the project dir it rotates in.
2762
+ // Two chats mirroring panes in the SAME dir (the norm — one repo, many chats)
2763
+ // both produce an indistinguishable post-clear transcript; whichever watcher
2764
+ // ticks first would otherwise claim whichever file landed first and the two
2765
+ // chats stay cross-wired forever (persisted to the store). Recorded BEFORE
2766
+ // the inject so a sibling armed earlier still sees the overlap.
2767
+ const CLEAR_WINDOW_MS = 5 * 60_000; // == migration watcher TIMEOUT_MS
2768
+ const clearInjects = [];
2769
+ const noteClearInject = (a) => {
2770
+ const cut = Date.now() - CLEAR_WINDOW_MS;
2771
+ for (let i = clearInjects.length - 1; i >= 0; i--)
2772
+ if (clearInjects[i].at < cut)
2773
+ clearInjects.splice(i, 1);
2774
+ clearInjects.push({ dir: dirname(a.jsonlPath), target: a.target, at: Date.now() });
2775
+ };
2776
+ const siblingClearedSameDir = (a) => {
2777
+ const cut = Date.now() - CLEAR_WINDOW_MS;
2778
+ const dir = dirname(a.jsonlPath);
2779
+ return clearInjects.some((r) => r.at >= cut && r.dir === dir && r.target !== a.target);
2780
+ };
2552
2781
  // Post-/clear identification: claude rotates the session IMMEDIATELY on
2553
2782
  // /clear (not on next user input as the original design assumed) and writes
2554
2783
  // the `/clear` command itself as the first non-meta user line of the brand-
@@ -2620,6 +2849,14 @@ export const startMirror = (deps) => {
2620
2849
  // migration omits it (→ tail from EOF) because the fork file is seeded with
2621
2850
  // the FULL prior transcript and replaying from 0 would re-dump it to WeCom.
2622
2851
  const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset) => {
2852
+ // Guard: if newSessionId is already bound to a DIFFERENT principal, abort —
2853
+ // prevents silent cross-wire (path B: /clear rotation or drift landing on
2854
+ // a sibling's live session).
2855
+ const incumbent = bySessionId.get(newSessionId);
2856
+ if (incumbent && incumbent.target !== a.target) {
2857
+ log.warn({ target: a.target, newSessionId, incumbentTarget: incumbent.target }, "migrateAttachment: target sid owned by another principal, aborting migration");
2858
+ return;
2859
+ }
2623
2860
  const oldSessionId = a.sessionId;
2624
2861
  const oldJsonlPath = a.jsonlPath;
2625
2862
  // 迁移是所有会话轮换的唯一漏斗 (注入的 /clear、TUI 里手打的 /clear、resume fork、
@@ -2678,7 +2915,9 @@ export const startMirror = (deps) => {
2678
2915
  // (predicate = first user line is /clear; replay from 0) and dead-pane resume
2679
2916
  // (predicate = file has real user content; tail from EOF — the fork is seeded
2680
2917
  // with full history). `startOffset` flows through to migrateAttachment.
2681
- const startMigrationWatcher = (a, baseline, isChild, startOffset) => {
2918
+ // `exclusive` (the /clear flavor) refuses to claim on ambiguous evidence —
2919
+ // see claimIsAttributable.
2920
+ const startMigrationWatcher = (a, baseline, isChild, startOffset, exclusive) => {
2682
2921
  if (a.migrationWatcher)
2683
2922
  a.migrationWatcher.cancel(); // re-armed (e.g. /clear twice, or respawn during a pending watch)
2684
2923
  const projectDir = dirname(a.jsonlPath);
@@ -2720,16 +2959,28 @@ export const startMirror = (deps) => {
2720
2959
  return 0;
2721
2960
  } })() }))
2722
2961
  .sort((x, y) => y.mtime - x.mtime);
2723
- for (const c of ranked) {
2724
- const p = join(projectDir, c.n);
2725
- if (isChild(p)) {
2726
- stopped = true;
2727
- a.migrationWatcher = undefined;
2728
- const newSid = c.n.replace(/\.jsonl$/, "");
2729
- if (newSid !== a.sessionId)
2730
- migrateAttachment(a, newSid, p, startOffset);
2731
- return;
2732
- }
2962
+ const matches = ranked.filter((c) => isChild(join(projectDir, c.n)));
2963
+ // A `/clear` rotation is identified only by "first user line is /clear",
2964
+ // which every chat's /clear produces — the file carries nothing tying it
2965
+ // to a pane. So claim only on unambiguous evidence: exactly one match,
2966
+ // and no sibling attachment in this dir cleared inside the same window.
2967
+ // Otherwise stand down and let the next inject's text fingerprint decide
2968
+ // (see armSilentForkRebind + a.clearRebind) — a wrong pick here would
2969
+ // permanently wire this chat onto another chat's pane.
2970
+ if (exclusive && matches.length > 0 && (matches.length > 1 || siblingClearedSameDir(a))) {
2971
+ log.warn({ target: a.target, candidates: matches.map((c) => c.n) }, "mirror migration: ambiguous /clear rotation, deferring to inject fingerprint");
2972
+ timer = setTimeout(tick, POLL_MS);
2973
+ return;
2974
+ }
2975
+ const hit = matches[0];
2976
+ if (hit) {
2977
+ stopped = true;
2978
+ a.migrationWatcher = undefined;
2979
+ a.clearRebind = undefined;
2980
+ const newSid = hit.n.replace(/\.jsonl$/, "");
2981
+ if (newSid !== a.sessionId)
2982
+ migrateAttachment(a, newSid, join(projectDir, hit.n), startOffset);
2983
+ return;
2733
2984
  }
2734
2985
  timer = setTimeout(tick, POLL_MS);
2735
2986
  };
@@ -2760,13 +3011,26 @@ export const startMirror = (deps) => {
2760
3011
  // not our fork. A genuine TUI fork is a brand-new, unowned session.
2761
3012
  // The fingerprint itself is lengthened (and its min-length raised) so short /
2762
3013
  // common messages can't collide onto a stranger's transcript in the first place.
3014
+ //
3015
+ // It doubles as the resolver for a `/clear` rotation the dir-scan watcher
3016
+ // refused to attribute (a.clearRebind): same mechanism, but the candidate set
3017
+ // narrows to transcripts born after the clear, which makes a short
3018
+ // fingerprint safe enough to accept.
2763
3019
  const armSilentForkRebind = (a, text) => {
2764
- if (a.migrationWatcher)
3020
+ const pendingClear = a.clearRebind;
3021
+ if (pendingClear) {
3022
+ // Hand over: the fingerprint is pane-certain, the dir-scan is not.
3023
+ a.migrationWatcher?.cancel();
3024
+ a.migrationWatcher = undefined;
3025
+ a.clearRebind = undefined; // one-shot — this inject is the evidence
3026
+ }
3027
+ else if (a.migrationWatcher) {
2765
3028
  return; // don't race an in-flight watcher
3029
+ }
2766
3030
  if (!a.tmuxPane)
2767
3031
  return; // spawn-mode: no pane to fork under
2768
3032
  const stripped = text.replace(/\s+/gu, "");
2769
- if (stripped.length < 16)
3033
+ if (stripped.length < (pendingClear ? 4 : 16))
2770
3034
  return; // too short to fingerprint safely
2771
3035
  const fp = stripped.slice(0, 120); // long contiguous run → collision-resistant
2772
3036
  const dir = dirname(a.jsonlPath);
@@ -2821,6 +3085,9 @@ export const startMirror = (deps) => {
2821
3085
  const boundName = boundPath.slice(dir.length + 1);
2822
3086
  const ranked = names
2823
3087
  .filter((n) => n !== boundName)
3088
+ // Post-/clear: only transcripts that didn't exist before the rotation
3089
+ // can be ours, which is what licenses the shorter fingerprint above.
3090
+ .filter((n) => !pendingClear || !pendingClear.baseline.has(n))
2824
3091
  .map((n) => ({ n, m: (() => { try {
2825
3092
  return statSync(join(dir, n)).mtimeMs;
2826
3093
  }
@@ -2918,6 +3185,9 @@ export const startMirror = (deps) => {
2918
3185
  // from the running cwd). Decoupling means /pwd can show truth even after
2919
3186
  // the AI sets a new path but the user hasn't /new'd yet.
2920
3187
  const expandedDefaultCwd = expandHome(cfg.wrc.cwd);
3188
+ // Long absolute paths wrap in the WeCom bubble — show only the trailing
3189
+ // three segments, which is enough to identify the project.
3190
+ const shortCwd = (p) => p.split("/").filter(Boolean).slice(-3).join("/");
2921
3191
  const renderProjectInfo = (target) => {
2922
3192
  const a = byTarget.get(target);
2923
3193
  const rec = a ? undefined : deps.store.get(target);
@@ -2926,17 +3196,20 @@ export const startMirror = (deps) => {
2926
3196
  // in this chat, so read it from the shared base slot rather than the
2927
3197
  // caller's own (now-empty) attachment record.
2928
3198
  const pending = chatCwdFallback(target).pending;
2929
- const lines = [`📂 当前项目: \`${running}\``];
3199
+ const lines = [`📂 cwd: \`${shortCwd(running)}\``];
2930
3200
  if (pending && pending !== running) {
2931
- lines.push(`下次切换: \`${pending}\` (使用 /new 或 /clear 生效)`);
3201
+ lines.push(`下次切换: \`${shortCwd(pending)}\` (使用 /new 或 /clear 生效)`);
2932
3202
  }
2933
3203
  // Session-boundary footer — `/new` and `/clear` are the only two callers,
2934
3204
  // so the tip lands exactly once per fresh context, never mid-conversation.
2935
3205
  lines.push(randomTip());
2936
3206
  return lines.join("\n");
2937
3207
  };
2938
- const pushProjectInfo = (target) => {
2939
- const md = renderProjectInfo(target);
3208
+ // `header` folds the caller's ack ("created") into this same bubble — /new
3209
+ // must land as exactly ONE WeCom message, not card + separate reply.
3210
+ const pushProjectInfo = (target, header) => {
3211
+ const info = renderProjectInfo(target);
3212
+ const md = header ? `${header}\n\n${info}` : info;
2940
3213
  const a = byTarget.get(target);
2941
3214
  if (a) {
2942
3215
  sendStandalone(a, md);
@@ -3066,7 +3339,7 @@ export const startMirror = (deps) => {
3066
3339
  }
3067
3340
  }
3068
3341
  if (!opts?.silent)
3069
- pushProjectInfo(target);
3342
+ pushProjectInfo(target, "created");
3070
3343
  return { ok: true, sessionId: r.sessionId, cwd: r.cwd };
3071
3344
  };
3072
3345
  const getCwd = (target) => {
@@ -3180,7 +3453,7 @@ export const startMirror = (deps) => {
3180
3453
  return {
3181
3454
  target: t,
3182
3455
  tag,
3183
- label: tag ? labelFor(tag) : "▫️",
3456
+ label: tag ? labelFor(tag) : "🧙",
3184
3457
  sessionId: a?.sessionId || rec?.sessionId || "",
3185
3458
  jsonlPath,
3186
3459
  cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
@@ -3207,6 +3480,17 @@ export const startMirror = (deps) => {
3207
3480
  const raw = await capturePaneTail(pane, Math.max(8, rows) + 12);
3208
3481
  return { ok: true, pane: compactPane(raw, rows), busy: paneIsBusy(raw) };
3209
3482
  };
3483
+ // Reading a peer's conversation is a transcript job, not a terminal job: the
3484
+ // jsonl holds whole role-tagged messages, while a pane capture is ANSI-laden
3485
+ // and clipped at the viewport edge. Only `busy` still comes from the pane.
3486
+ const peekTurns = async (target, n = 6) => {
3487
+ const jsonl = jsonlOf(target);
3488
+ const busy = await isBusy(target);
3489
+ if (!jsonl || !existsSync(jsonl))
3490
+ return { ok: false, reason: "no transcript bound for target", busy };
3491
+ const dialog = renderDialog(tailTurns(jsonl, n));
3492
+ return dialog ? { ok: true, dialog, busy } : { ok: false, reason: "transcript has no turns yet", busy };
3493
+ };
3210
3494
  // ── Prompt-cache keepalive ──────────────────────────────────────────
3211
3495
  // Anthropic's prompt cache expires ~5min after the last request. A pane that
3212
3496
  // goes idle (agent parked on a peer, background task running) lets the whole
@@ -3216,7 +3500,6 @@ export const startMirror = (deps) => {
3216
3500
  // anchored to the last REAL (non-ping) activity, NOT to our own pings — so a
3217
3501
  // dead session is never kept warm forever, and once real work is older than
3218
3502
  // `maxIdleSec` we stop and let the cache die.
3219
- const fmtTokens = (n) => n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n);
3220
3503
  // After `/stop`, ignore pane-busy as a resume signal for this long — long
3221
3504
  // enough for an in-flight ping (interrupted by the same /stop's Esc) to settle,
3222
3505
  // so it can't self-resume the pause. A genuine new turn after this window does.
@@ -3226,25 +3509,31 @@ export const startMirror = (deps) => {
3226
3509
  // bump mtime without being a model turn, which would fake a long-dead session
3227
3510
  // back to "just active" and pin it in an endless ping loop. lastRealMs stays
3228
3511
  // anchored on genuine work, so the tick's realIdle guard can finally let it die.
3229
- const initKeepalive = (jsonlPath, mtime, pingSig) => {
3512
+ const initKeepalive = (jsonlPath, mtime, pingSigs) => {
3230
3513
  let lastMs = 0;
3231
3514
  let lastRealMs = 0;
3232
3515
  try {
3233
- const s = keepaliveStamps(jsonlPath, pingSig);
3516
+ const s = keepaliveStamps(jsonlPath, pingSigs);
3234
3517
  lastMs = s.lastMs;
3235
3518
  lastRealMs = s.lastRealMs;
3236
3519
  }
3237
3520
  catch { /* unreadable tail */ }
3238
- return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round: 0 };
3521
+ return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round: 0, settledAt: 0 };
3239
3522
  };
3240
- const fireKeepalive = async (a) => {
3523
+ const fireKeepalive = async (a, stalled) => {
3241
3524
  const kc = cfg.wrc.mirror.keepalive;
3242
- // Only the streak's FIRST ping carries the full instruction; consecutive
3243
- // pings shrink to a bare "ping" — the instruction is already in context,
3244
- // and the smaller delta means a smaller cache-write and less pane clutter.
3245
- // keepaliveStamps matches both forms, so the bare one never reads as real
3246
- // activity. (round is incremented below, after the inject lands.)
3247
- const text = (a.keepalive?.round ?? 0) > 0 ? "ping" : kc.ping;
3525
+ // Stalled (error/limit banner + idle pane): send the full resume instruction
3526
+ // — inviting the model to finish genuinely-unfinished work, or to reply
3527
+ // "pong" if it was legitimately parked. Otherwise the plain warmer, the SAME
3528
+ // full instruction every round: a bare "ping" used to be sent from round 2 on
3529
+ // (the instruction was already in context), but the model reads a bare "ping"
3530
+ // as an open turn and answers it however it likes — anything other than
3531
+ // "pong" un-swallows the heartbeat into chat and re-anchors the clocks as
3532
+ // real activity. Restating the instruction is a few tokens of cache-write and
3533
+ // buys a deterministic reply. keepaliveStamps matches every form, so none
3534
+ // reads as real activity. (round is incremented below, after the inject lands.)
3535
+ const text = stalled ? kc.resumePing : kc.ping;
3536
+ a.keepalivePongBuf = ""; // fresh reply accumulator for the swallow/release gate
3248
3537
  // Suppress the pane→WeCom echo of the ping user line, then swallow the whole
3249
3538
  // ping turn (reply included). The 60s fail-safe clears the quiet window if
3250
3539
  // the turn somehow never emits turn_end, so a later real turn is never muted;
@@ -3270,10 +3559,8 @@ export const startMirror = (deps) => {
3270
3559
  return;
3271
3560
  }
3272
3561
  const tokens = lastContextTokens(a.jsonlPath);
3273
- const size = tokens > 0 ? `~${fmtTokens(tokens)} tokens` : "未知";
3274
- const totalRounds = Math.max(1, kc.rounds);
3275
3562
  const round = a.keepalive ? (a.keepalive.round += 1) : 1;
3276
- log.info({ target: a.target, tokens, round, totalRounds }, "keepalive: ping injected");
3563
+ log.info({ target: a.target, tokens, round, totalRounds: Math.max(1, kc.rounds) }, "keepalive: ping injected");
3277
3564
  // Open a chat-detail turn for the real heartbeat exchange: userQuery is the
3278
3565
  // actual ping we injected; the assistant reply (expected: just "pong"), any
3279
3566
  // tool calls, and usage are grafted on from the swallowed items in onItem;
@@ -3281,8 +3568,6 @@ export const startMirror = (deps) => {
3281
3568
  const turnId = newTurnId();
3282
3569
  a.keepaliveTurnId = turnId;
3283
3570
  recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
3284
- if (kc.notify)
3285
- sendStandalone(a, `KeepAlive ${round}/${totalRounds} · ${size}`);
3286
3571
  };
3287
3572
  let keepaliveTicking = false;
3288
3573
  const keepaliveTick = async () => {
@@ -3293,7 +3578,7 @@ export const startMirror = (deps) => {
3293
3578
  try {
3294
3579
  const idleTriggerMs = Math.max(30, kc.ttlSec - kc.marginSec) * 1000;
3295
3580
  const ttlMs = kc.ttlSec * 1000;
3296
- const pingSig = normAssistant(kc.ping).slice(0, 40);
3581
+ const pingSigs = [normAssistant(kc.ping).slice(0, 40), normAssistant(kc.resumePing).slice(0, 40)].filter((s) => s.length > 0);
3297
3582
  const now = Date.now();
3298
3583
  for (const a of byTarget.values()) {
3299
3584
  if (!a.tmuxPane)
@@ -3313,14 +3598,14 @@ export const startMirror = (deps) => {
3313
3598
  }
3314
3599
  if (!mtime)
3315
3600
  continue;
3316
- const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, pingSig));
3601
+ const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, pingSigs));
3317
3602
  // File mtime is only a cheap "did anything change → re-read the tail" gate.
3318
3603
  // The clocks themselves come from message turns, so metadata churn (which
3319
3604
  // bumps mtime but is not a turn) can never move them. `grewSinceLast` now
3320
3605
  // means a NEW message turn appeared — not that the file grew.
3321
3606
  const prevRealMs = k.lastRealMs;
3322
3607
  if (mtime > k.seenMtime + 1000) {
3323
- const s = keepaliveStamps(a.jsonlPath, pingSig);
3608
+ const s = keepaliveStamps(a.jsonlPath, pingSigs);
3324
3609
  if (s.stamped) {
3325
3610
  k.lastMs = s.lastMs;
3326
3611
  k.lastRealMs = s.lastRealMs;
@@ -3335,13 +3620,15 @@ export const startMirror = (deps) => {
3335
3620
  // keepalive ping+pong advances lastMs but NOT lastRealMs, so the heartbeat
3336
3621
  // no longer reads as activity to itself (which was pinning round at 1/N).
3337
3622
  const grewSinceLast = k.lastRealMs > prevRealMs + 1000;
3338
- const busy = paneIsBusy(await capturePaneTail(a.tmuxPane, 12));
3623
+ const paneTail = await capturePaneTail(a.tmuxPane, 16);
3624
+ const busy = paneIsBusy(paneTail);
3339
3625
  if (k.pinging) {
3340
3626
  // Waiting for our own ping to land and settle: until a NEWER message turn
3341
3627
  // (the ping's own user line) appears and the pane goes idle, keep waiting.
3342
3628
  if (busy || k.lastMs <= k.pingMtime)
3343
3629
  continue;
3344
3630
  k.pinging = false;
3631
+ k.settledAt = now;
3345
3632
  continue;
3346
3633
  }
3347
3634
  // A new real message turn (or a busy pane) re-anchors: reset the round
@@ -3349,8 +3636,12 @@ export const startMirror = (deps) => {
3349
3636
  // message-based now, so `/stop`'s Esc settling writes (metadata, no new
3350
3637
  // turn) can no longer self-resume the pause — only a busy pane past grace,
3351
3638
  // or a WeCom inbound in dispatch, does.
3352
- if (busy || grewSinceLast) {
3353
- k.round = 0; // real work resets the round counter — pings start from 1/N again
3639
+ // Suppress the grewSinceLast reset for 30s after pinging settles — the
3640
+ // pong's tail flush can lag a tick and would otherwise fake real activity.
3641
+ const justSettled = k.settledAt > 0 && now - k.settledAt < 30_000;
3642
+ if (busy || (grewSinceLast && !justSettled)) {
3643
+ k.round = 0;
3644
+ k.settledAt = 0;
3354
3645
  if (a.keepaliveOff && busy && now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS) {
3355
3646
  a.keepaliveOff = false;
3356
3647
  persistPause(a);
@@ -3367,9 +3658,14 @@ export const startMirror = (deps) => {
3367
3658
  continue; // cache already cold — a ping would cold-rewrite for nothing
3368
3659
  if (k.round >= kc.rounds)
3369
3660
  continue; // budget spent — let it go cold
3661
+ // Stall recovery, decided by RULE only (no model self-judgment): the last
3662
+ // transcript turn is a synthetic API-error/limit line, or the idle pane
3663
+ // still shows an error banner ⇒ a turn died mid-work. Send the resume
3664
+ // instruction instead of the plain warmer.
3665
+ const stalled = kc.resumeOnStall && (transcriptStalled(a.jsonlPath) || paneIsStalled(paneTail));
3370
3666
  k.pinging = true;
3371
3667
  k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
3372
- await fireKeepalive(a);
3668
+ await fireKeepalive(a, stalled);
3373
3669
  }
3374
3670
  }
3375
3671
  catch (e) {
@@ -3384,6 +3680,7 @@ export const startMirror = (deps) => {
3384
3680
  attach,
3385
3681
  peers,
3386
3682
  peekPane,
3683
+ peekTurns,
3387
3684
  isBusy,
3388
3685
  lastText,
3389
3686
  status: () => {
@@ -3543,7 +3840,7 @@ export const startMirror = (deps) => {
3543
3840
  // 队列内单条失败已在 sendStandalone 里 warn 过, 这里吞掉, 不阻塞发卡。
3544
3841
  }
3545
3842
  },
3546
- injectText: async (target, text) => {
3843
+ injectText: async (target, text, origin) => {
3547
3844
  // Lazy restore, same as dispatch: a peer session that hasn't been talked
3548
3845
  // to in this process lifetime (or any session after a reload) has an empty
3549
3846
  // in-memory slot but a perfectly good persisted binding. Without this, an
@@ -3557,6 +3854,10 @@ export const startMirror = (deps) => {
3557
3854
  // recentInjects dedupe (otherwise the user sees their own demo prompt
3558
3855
  // echoed back as a quoted bubble).
3559
3856
  rememberInject(text);
3857
+ // 印章要早于 inject 落地 —— tail 是独立轮询的, 它可能在 inject 的 promise
3858
+ // resolve 之前就看到 user 行并开出 turn, 那时归因必须已经在位。
3859
+ if (origin)
3860
+ a.pendingOrigin = { origin, at: Date.now() };
3560
3861
  const sid = a.sessionId;
3561
3862
  const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
3562
3863
  if (!paneAlive) {
@@ -3576,23 +3877,40 @@ export const startMirror = (deps) => {
3576
3877
  }
3577
3878
  // freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
3578
3879
  // is still warming up so the verifier in injectViaTmux needs the slack.
3579
- return await inject({
3880
+ const r = await inject({
3580
3881
  text, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
3581
3882
  sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: true,
3582
3883
  });
3884
+ // 与 dispatch 同一约定: 首条 inject 落地 = 解除新 pane 的初始输出静默。
3885
+ // 漏掉这一步的后果不只是少几条气泡 —— onItem 在 mute 分支整体早退, 连
3886
+ // recordTurnStart 都不会跑, 于是 graph/send_peer 拉起来的节点在 chat 列表
3887
+ // 和 chat 详情里彻底不存在(它从来没被 WeCom 侧 dispatch 过, 没有第二次机会)。
3888
+ if (r.ok) {
3889
+ a.muteUntilInject = false;
3890
+ a.justSpawned = false;
3891
+ }
3892
+ return r;
3583
3893
  },
3584
- interruptPane: async (target) => {
3894
+ interruptPane: async (target, opts) => {
3585
3895
  const a = byTarget.get(target);
3586
3896
  if (!a)
3587
3897
  return { ok: false, reason: "no mirror attached for target" };
3588
- if (!a.tmuxPane)
3898
+ // `/stop` (teardown: true) must work when tmux itself is the problem, so
3899
+ // the order is: local state first, pane second. The old implementation
3900
+ // probed the pane FIRST and returned early on failure — meaning a wedged
3901
+ // tmux server left the user with a "…" bubble that /stop could not close
3902
+ // and an inject queue no message could get past. Callers that only want
3903
+ // an Esc (the `.claude/**` guard's cancel) pass no opts and keep the old
3904
+ // pane-only behavior, bubbles untouched.
3905
+ let torndown = 0;
3906
+ if (opts?.teardown) {
3907
+ a.injectGen = (a.injectGen ?? 0) + 1; // disarm a zombie inject's paste
3908
+ abortInjectQueue(a.sessionId); // unblock the next message
3909
+ torndown = teardownOutbound(a); // close every hanging bubble/stream
3910
+ }
3911
+ else if (!a.tmuxPane) {
3589
3912
  return { ok: false, reason: "no live tmux pane (spawn-mode attachment)" };
3590
- const alive = await tmuxPaneAlive(a.tmuxPane);
3591
- if (!alive)
3592
- return { ok: false, reason: "tmux pane no longer alive" };
3593
- const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
3594
- if (r.code !== 0)
3595
- return { ok: false, reason: `send-keys Escape failed: ${r.stdout.slice(-200) || r.code}` };
3913
+ }
3596
3914
  // /stop also pauses keepalive: the user is deliberately quieting this
3597
3915
  // session, so stop poking it too. A real turn (WeCom inbound, or the pane
3598
3916
  // going busy AFTER the resume grace) lifts the pause and re-earns the
@@ -3602,9 +3920,56 @@ export const startMirror = (deps) => {
3602
3920
  a.keepaliveOffAt = Date.now();
3603
3921
  a.keepalive = undefined; // re-anchors cleanly on resume
3604
3922
  persistPause(a); // survive a reload — don't resurrect a quieted session
3923
+ // Best-effort Esc. Every failure mode here is reported, never fatal: the
3924
+ // teardown above already gave the user their chat back.
3925
+ let escOk = false;
3926
+ let escReason = "";
3927
+ if (!a.tmuxPane) {
3928
+ escReason = "spawn-mode attachment (no live tmux pane)";
3929
+ }
3930
+ else if (!(await tmuxPaneAlive(a.tmuxPane))) {
3931
+ escReason = "tmux pane no longer alive";
3932
+ }
3933
+ else {
3934
+ const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
3935
+ escOk = r.ok;
3936
+ if (!r.ok)
3937
+ escReason = `send-keys Escape failed: ${(r.stderr || r.stdout).slice(-200) || r.code}`;
3938
+ }
3605
3939
  // Any in-flight ping's quiet window + detail turn are left to close on their
3606
3940
  // own turn_end (or the 60s fail-safe), so the interrupted pong stays out of chat.
3607
- log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /stop — Esc sent to pane, keepalive paused");
3941
+ log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane, torndown, escOk, escReason, gen: a.injectGen }, "mirror /stop — teardown + Esc");
3942
+ // Esc failure is not an overall failure when we tore down: the user asked
3943
+ // for quiet and got it. Without teardown there's nothing else to report,
3944
+ // so keep the old strict contract.
3945
+ if (!opts?.teardown && !escOk)
3946
+ return { ok: false, reason: escReason };
3947
+ return { ok: true, torndown, escOk, escReason: escReason || undefined };
3948
+ },
3949
+ killPane: async (target) => {
3950
+ // paneOf (not byTarget.get) so a binding surviving only in the persisted
3951
+ // store — e.g. after a reload that couldn't re-attach — is still killable.
3952
+ const a = byTarget.get(target);
3953
+ const pane = a?.tmuxPane || paneOf(target);
3954
+ if (!a && !pane)
3955
+ return { ok: false, reason: "no session bound to target" };
3956
+ if (pane && (await tmuxPaneAlive(pane))) {
3957
+ // Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
3958
+ // flush its transcript before the TTY is yanked out from under it.
3959
+ await tmuxRun(["send-keys", "-t", pane, "Escape"]);
3960
+ await sleepMs(250);
3961
+ const r = await tmuxRun(["kill-pane", "-t", pane]);
3962
+ if (r.code !== 0)
3963
+ return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
3964
+ }
3965
+ if (a)
3966
+ detach(a, "/kill");
3967
+ // Drop the persisted record too — keeping it would let the next inbound
3968
+ // resurrect this very session via the dead-pane `--resume` self-heal,
3969
+ // which is the opposite of what /kill means. The chat then auto-spawns a
3970
+ // fresh session on its next message.
3971
+ deps.store.drop(target);
3972
+ log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
3608
3973
  return { ok: true };
3609
3974
  },
3610
3975
  submitPane: async (target) => {
@@ -3651,6 +4016,50 @@ export const startMirror = (deps) => {
3651
4016
  log.info({ target, pane, client: best }, "mirror /reveal — tmux client switched");
3652
4017
  return { ok: true };
3653
4018
  },
4019
+ hasLivePane: (sessionId) => Boolean(bySessionId.get(sessionId)?.tmuxPane),
4020
+ answerNativeModal: async (sessionId, opts) => {
4021
+ const a = bySessionId.get(sessionId);
4022
+ if (!a?.tmuxPane)
4023
+ return { status: "no_pane", reason: "no live tmux pane for session" };
4024
+ if (!(await tmuxPaneAlive(a.tmuxPane)))
4025
+ return { status: "no_pane", reason: "tmux pane no longer alive" };
4026
+ const pane = a.tmuxPane;
4027
+ const lg = log.child({ sessionId, pane, sub: "native-modal" });
4028
+ // CC 在 hook 返回**之后**才渲染这个框, 所以进来时它通常还没出现 —— 轮询等它。
4029
+ const POLL_MS = 200;
4030
+ const deadline = Date.now() + Math.max(0, opts.waitMs);
4031
+ let v = await detectModalPicker(pane);
4032
+ while (!v.modal && Date.now() < deadline) {
4033
+ await sleepMs(POLL_MS);
4034
+ v = await detectModalPicker(pane);
4035
+ }
4036
+ // 没等到: 这次 CC 没弹框(会话内已授权过 / 命中盲点判断失误)。无事可做。
4037
+ if (!v.modal)
4038
+ return { status: "no_modal" };
4039
+ const pick = pickModalAnswer(parseModalOptions(v.screen), v.title);
4040
+ if (!pick) {
4041
+ lg.warn({ title: v.title }, "native modal: no trustworthy one-shot option, not pressing");
4042
+ return { status: "unparsable", title: v.title, screen: v.screen };
4043
+ }
4044
+ // 数字键在 CC 的 picker 里即选即确认。按完等一拍回读: 框还在且**标题没变**才
4045
+ // 补一个 Enter(个别布局要显式确认); 标题变了说明弹的已是另一个框 —— 绝不盲按,
4046
+ // 交给兜底路径, 免得替用户确认了他没看过的东西。
4047
+ const SETTLE_MS = 400;
4048
+ await tmuxRun(["send-keys", "-t", pane, String(pick.index)]);
4049
+ await sleepMs(SETTLE_MS);
4050
+ let after = await detectModalPicker(pane);
4051
+ if (after.modal && after.title === v.title) {
4052
+ await tmuxRun(["send-keys", "-t", pane, "Enter"]);
4053
+ await sleepMs(SETTLE_MS);
4054
+ after = await detectModalPicker(pane);
4055
+ }
4056
+ if (after.modal) {
4057
+ lg.warn({ title: after.title, pressed: pick.index }, "native modal: still on pane after answer");
4058
+ return { status: "still_modal", title: after.title, index: pick.index, label: pick.label };
4059
+ }
4060
+ lg.info({ title: v.title, pressed: `${pick.index}. ${pick.label}` }, "native modal answered");
4061
+ return { status: "answered", title: v.title, index: pick.index, label: pick.label };
4062
+ },
3654
4063
  getCwd,
3655
4064
  setPendingCwd,
3656
4065
  newSession,
@@ -3742,6 +4151,11 @@ export const startMirror = (deps) => {
3742
4151
  // immediately — capturing baseline post-inject would already include it,
3743
4152
  // and the watcher would never see a "new" candidate (the bug this fixes).
3744
4153
  const preClearBaseline = armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
4154
+ // Registered here, not after the inject: the inject's paste verification
4155
+ // can take ~10s, and a sibling chat's watcher armed earlier must already
4156
+ // see this clear as an overlap by the time it evaluates a candidate.
4157
+ if (armMigration)
4158
+ noteClearInject(a);
3745
4159
  // 记下断点, 由下一轮 (第一轮读不到上文的轮次) 领走。/clear 自己不建 turn,
3746
4160
  // 否则这次清空在 chat 详情里毫无痕迹, 前后两轮看着还是连续的。
3747
4161
  if (armMigration)
@@ -3794,7 +4208,8 @@ export const startMirror = (deps) => {
3794
4208
  enterDeferred(a, frame, streamId);
3795
4209
  }
3796
4210
  const sid = a.sessionId;
3797
- await enqueue(sid, async () => {
4211
+ const myGen = a.injectGen ?? 0;
4212
+ await enqueue(sid, () => withInjectWatchdog(a, myGen, async () => {
3798
4213
  if (s && s.closed)
3799
4214
  return; // (eager path) superseded by a newer dispatch
3800
4215
  // Always reincarnate when no live pane: covers (a) pane closed between
@@ -3804,6 +4219,9 @@ export const startMirror = (deps) => {
3804
4219
  // If respawn fails, fall through to spawn-mode inject for THIS turn
3805
4220
  // but DON'T erase tmuxSession from store — next inbound will retry,
3806
4221
  // making the system self-healing instead of one-failure-permanent.
4222
+ // First step of the job and the historical hang point — log BEFORE the
4223
+ // probe so a stall is attributable to it next time, not invisible.
4224
+ log.info({ target: a.target, sessionId: sid, pane: a.tmuxPane, gen: myGen }, "inject job start");
3807
4225
  const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
3808
4226
  let freshSpawn = a.justSpawned === true;
3809
4227
  a.justSpawned = false;
@@ -3855,6 +4273,18 @@ export const startMirror = (deps) => {
3855
4273
  // surfaces the user's text, and without a pre-recorded entry the
3856
4274
  // dedupe filter wouldn't suppress it — user's chat msg gets echoed
3857
4275
  // back as a quoted bubble. Recording up front fixes that.
4276
+ // Last checkpoint before we touch the pane: a `/stop` (or a watchdog
4277
+ // fire) during the respawn above means this text is no longer wanted.
4278
+ // Without this, a tmux server that unwedges after the user gave up would
4279
+ // paste a minutes-old message into a session that has moved on.
4280
+ if ((a.injectGen ?? 0) !== myGen) {
4281
+ // Lift the spawn mute here too: this inject will never land, so the
4282
+ // pane's output (including whatever /stop interrupted) would otherwise
4283
+ // stay swallowed by onItem until some later message injects cleanly.
4284
+ a.muteUntilInject = false;
4285
+ log.warn({ target: a.target, sessionId: sid, myGen, gen: a.injectGen }, "inject aborted — superseded by /stop or watchdog");
4286
+ return;
4287
+ }
3858
4288
  rememberInject(text);
3859
4289
  const r = await inject({
3860
4290
  text, images, cfg, log: log.child({ principal, sessionId: sid }),
@@ -3926,13 +4356,14 @@ export const startMirror = (deps) => {
3926
4356
  // feedback (the skip-stream path otherwise leaves WeCom silent).
3927
4357
  if (armMigration) {
3928
4358
  sendStandalone(a, `cleared\n\n${renderProjectInfo(a.target)}`);
3929
- startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0);
4359
+ a.clearRebind = { baseline: preClearBaseline };
4360
+ startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
3930
4361
  }
3931
4362
  // Don't await the stream's lifetime — it stays open until next inbound
3932
4363
  // or hard timeout. Releasing the inject queue here lets the next
3933
4364
  // dispatch start its inject promptly while tail content keeps flowing
3934
4365
  // into the (still-open) stream until superseded.
3935
- });
4366
+ }));
3936
4367
  },
3937
4368
  };
3938
4369
  };