wezard 2.6.3 → 2.7.0

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 (106) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CLAUDE.md +6 -6
  3. package/README.md +1 -15
  4. package/dist/cli/codex-hook.js +5 -1
  5. package/dist/cli/codex-hook.js.map +1 -1
  6. package/dist/cli/sync.js +5 -4
  7. package/dist/cli/sync.js.map +1 -1
  8. package/dist/daemon/approval.js +12 -5
  9. package/dist/daemon/approval.js.map +1 -1
  10. package/dist/daemon/bot-mux.js +4 -0
  11. package/dist/daemon/bot-mux.js.map +1 -1
  12. package/dist/daemon/chat-log.js +5 -5
  13. package/dist/daemon/chat-log.js.map +1 -1
  14. package/dist/daemon/chat-name.js +5 -2
  15. package/dist/daemon/chat-name.js.map +1 -1
  16. package/dist/daemon/detail.js +28 -5
  17. package/dist/daemon/detail.js.map +1 -1
  18. package/dist/daemon/direction.js +3 -2
  19. package/dist/daemon/direction.js.map +1 -1
  20. package/dist/daemon/dispatch.js +6 -0
  21. package/dist/daemon/dispatch.js.map +1 -0
  22. package/dist/daemon/handoff.js +66 -15
  23. package/dist/daemon/handoff.js.map +1 -1
  24. package/dist/daemon/image-input.js +25 -5
  25. package/dist/daemon/image-input.js.map +1 -1
  26. package/dist/daemon/inbound.js +5 -6
  27. package/dist/daemon/inbound.js.map +1 -1
  28. package/dist/daemon/index.js +1232 -671
  29. package/dist/daemon/index.js.map +1 -1
  30. package/dist/daemon/jobs.js +13 -3
  31. package/dist/daemon/jobs.js.map +1 -1
  32. package/dist/daemon/last-response.js +29 -4
  33. package/dist/daemon/last-response.js.map +1 -1
  34. package/dist/daemon/message-events.js +534 -0
  35. package/dist/daemon/message-events.js.map +1 -0
  36. package/dist/daemon/mirror-bridge.js +702 -790
  37. package/dist/daemon/mirror-bridge.js.map +1 -1
  38. package/dist/daemon/mirror-store.js.map +1 -1
  39. package/dist/daemon/notices.js +9 -2
  40. package/dist/daemon/notices.js.map +1 -1
  41. package/dist/daemon/peers.js +124 -145
  42. package/dist/daemon/peers.js.map +1 -1
  43. package/dist/daemon/pending-items.js +33 -34
  44. package/dist/daemon/pending-items.js.map +1 -1
  45. package/dist/daemon/route.js +5 -2
  46. package/dist/daemon/route.js.map +1 -1
  47. package/dist/daemon/router.js +643 -0
  48. package/dist/daemon/router.js.map +1 -0
  49. package/dist/daemon/session-name.js +1 -1
  50. package/dist/daemon/session-name.js.map +1 -1
  51. package/dist/daemon/spawn-tmux.js +32 -13
  52. package/dist/daemon/spawn-tmux.js.map +1 -1
  53. package/dist/daemon/subagent-tail.js +3 -3
  54. package/dist/daemon/subagent-tail.js.map +1 -1
  55. package/dist/daemon/weixin.js +111 -7
  56. package/dist/daemon/weixin.js.map +1 -1
  57. package/dist/daemon/{cc-session.js → wezard-session.js} +32 -2
  58. package/dist/daemon/wezard-session.js.map +1 -0
  59. package/dist/daemon/wizard.js +5 -5
  60. package/dist/daemon/wizard.js.map +1 -1
  61. package/dist/daemon/workflow-nodes.js +3 -3
  62. package/dist/daemon/workflow-nodes.js.map +1 -1
  63. package/dist/daemon/workflow-plans.js +9 -6
  64. package/dist/daemon/workflow-plans.js.map +1 -1
  65. package/dist/mcp/server.js +38 -47
  66. package/dist/mcp/server.js.map +1 -1
  67. package/dist/shared/chat-http.js +2 -2
  68. package/dist/shared/chat-http.js.map +1 -1
  69. package/dist/shared/chat-view.js +0 -4
  70. package/dist/shared/chat-view.js.map +1 -1
  71. package/dist/shared/cli-backends.js +11 -1
  72. package/dist/shared/cli-backends.js.map +1 -1
  73. package/dist/shared/codex.js +1 -1
  74. package/dist/shared/codex.js.map +1 -1
  75. package/dist/shared/config.js +3 -44
  76. package/dist/shared/config.js.map +1 -1
  77. package/dist/shared/detail-render.js +36 -11
  78. package/dist/shared/detail-render.js.map +1 -1
  79. package/dist/shared/detail-store.js.map +1 -1
  80. package/dist/shared/message-footer.js +8 -8
  81. package/dist/shared/message-footer.js.map +1 -1
  82. package/dist/shared/reminder.js +109 -37
  83. package/dist/shared/reminder.js.map +1 -1
  84. package/dist/shared/role-render.js +28 -20
  85. package/dist/shared/role-render.js.map +1 -1
  86. package/dist/shared/role-search.js +3 -3
  87. package/dist/shared/role-search.js.map +1 -1
  88. package/dist/shared/role-view.js +50 -30
  89. package/dist/shared/role-view.js.map +1 -1
  90. package/dist/shared/session-label.js +25 -6
  91. package/dist/shared/session-label.js.map +1 -1
  92. package/dist/shared/turn-state.js +7 -7
  93. package/dist/shared/turn-state.js.map +1 -1
  94. package/dist/shared/world.js +1 -3
  95. package/dist/shared/world.js.map +1 -1
  96. package/hooks/pre-tool-use.sh +5 -25
  97. package/package.json +1 -1
  98. package/web/chat.css +3 -33
  99. package/web/chat.js +11 -62
  100. package/dist/daemon/answer-transfer.js +0 -56
  101. package/dist/daemon/answer-transfer.js.map +0 -1
  102. package/dist/daemon/cc-session.js.map +0 -1
  103. package/dist/daemon/receipts.js +0 -906
  104. package/dist/daemon/receipts.js.map +0 -1
  105. package/dist/shared/keepalive.js +0 -46
  106. package/dist/shared/keepalive.js.map +0 -1
@@ -1,3 +1,5 @@
1
+ import { finalId } from "./message-events.js";
2
+ import { parseEnvelope, renderTail } from "../shared/reminder.js";
1
3
  import { codexSessions, codexSessionForPane } from "../shared/codex.js";
2
4
  import { codexHookLaunchArgs, codexHookCommand } from "../shared/codex-hooks.js";
3
5
  import { fileURLToPath } from "node:url";
@@ -19,7 +21,7 @@ import { spawn } from "node:child_process";
19
21
  import { randomUUID } from "node:crypto";
20
22
  import { pasteTmuxBuffer } from "./tmux-buffer.js";
21
23
  import { existsSync, readdirSync, readFileSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
22
- import { join, dirname } from "node:path";
24
+ import { basename, join, dirname } from "node:path";
23
25
  import { newStewardSpec, resumedStewardSpec } from "./steward-model.js";
24
26
  import { expandHome, sanitizeId } from "../shared/paths.js";
25
27
  import { augmentedPath } from "../shared/exec-path.js";
@@ -36,18 +38,17 @@ import { dispatchPickerPolicy, pickerPolicy } from "./picker-policy.js";
36
38
  import { renameWindow, runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
37
39
  import { selectEffort, selectModel } from "./model-select.js";
38
40
  import { parseEffort } from "../shared/effort.js";
39
- import { hasRegistry, liveSessionPids, markTranscript, probeOf, sessionOnPane, submittedSince, watchRegistry } from "./cc-session.js";
41
+ import { hasRegistry, liveSessionPids, markTranscript, probeOf, sessionOfPid, sessionOnPane, submittedSince, submittedEventSince, watchRegistry } from "./wezard-session.js";
40
42
  import { waitForIdle } from "./graph.js";
41
43
  import { wizardStore } from "./wizard.js";
42
44
  import { startSubagentWatch } from "./subagent-tail.js";
43
- import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
45
+ import { getDetail, knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq, markTurnTextFinal } from "./detail.js";
44
46
  import { errText } from "./last-response.js";
45
- import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey, sameBot } from "../shared/session-label.js";
47
+ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, windowLabel, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, parseTagHeader, MAX_BODY_LINKS, isInternalKey, sameBot } from "../shared/session-label.js";
46
48
  import { splitMarkdown } from "../shared/md-chunk.js";
47
49
  import { isWxFrame } from "../shared/wx-text.js";
48
50
  import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
49
- import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
50
- import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
51
+ import { stripAnsi, paneIsBusy, codexTurnBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, lastUserTurn, talkRounds, openToolUses, turnClosed, renderDialog, stripForChat } from "./peers.js";
51
52
  // PATH augmentation: launchd / systemd start the daemon
52
53
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
53
54
  // Backend resolution is per-transcript, not global: `backendForPath` recovers
@@ -526,7 +527,7 @@ const renderLine = (raw, deps) => {
526
527
  if (line.type === "attachment" && line.attachment?.type === "queued_command" && typeof line.attachment.prompt === "string") {
527
528
  const said = cleanUserText(line.attachment.prompt);
528
529
  const ts = Date.parse(line.timestamp ?? "");
529
- return said ? [{ kind: "user_inject", said, ts: Number.isNaN(ts) ? Date.now() : ts }] : [];
530
+ return said ? [{ kind: "user_inject", said, eventId: parseEnvelope(line.attachment.prompt)?.eventId, ts: Number.isNaN(ts) ? Date.now() : ts }] : [];
530
531
  }
531
532
  const out = [];
532
533
  if (line.type === "user") {
@@ -569,15 +570,11 @@ const renderLine = (raw, deps) => {
569
570
  const text = cleanUserText(c);
570
571
  if (!text)
571
572
  return []; // pure slash-command meta / stdout — drop
572
- // Keepalive ping user line: judged by CONTENT, before every other gate
573
- // (includeUser / isOwnInject's 60s TTL) — the ping never echoes, and
574
- // the reply turn is swallowed by content in onItem. Timer windows can't
575
- // cover reloads or replays; the user message itself always can.
576
- if (deps.isKeepalivePing?.(text))
577
- return [{ kind: "keepalive_start", body: text }];
578
573
  // 自己注入的那一行不回显, 但要报个到: 它的印章 (谁说的、回复去哪) 在这一刻认领。
579
- if (deps.isOwnInject(text))
580
- return [{ kind: "user_query", body: unwrapPasted(c).trim(), said: text, ts: lineTs(line) }];
574
+ // 注入事件 id: 信封的 `msg` (老形状是信封外的 message-event 块) —— 认得出就是守护进程投的。
575
+ const eventId = parseEnvelope(c)?.eventId;
576
+ if (eventId || deps.isOwnInject(text))
577
+ return [{ kind: "user_query", eventId, body: unwrapPasted(c).trim(), said: text, ts: lineTs(line) }];
581
578
  const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
582
579
  // includeUser 只决定"渲不渲染", 不决定"算不算边界" —— quiet 的这一条照样发出,
583
580
  // onItem 消费完边界语义后自己丢掉。
@@ -588,11 +585,11 @@ const renderLine = (raw, deps) => {
588
585
  // 回显去重对得上; 记进详情页的则是带占位符的原样文本, 图本身不画。
589
586
  const raw = c.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("\n");
590
587
  const text = cleanUserText(raw.replace(IMAGE_REF_RE, ""));
591
- if (text && !deps.isOwnInject(text)) {
588
+ if (text && !parseEnvelope(raw)?.eventId && !deps.isOwnInject(text)) {
592
589
  out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser, ts: lineTs(line) });
593
590
  }
594
591
  else {
595
- out.push({ kind: "user_query", body: unwrapPasted(raw).trim(), said: text, ts: lineTs(line) });
592
+ out.push({ kind: "user_query", eventId: parseEnvelope(raw)?.eventId, body: unwrapPasted(raw).trim(), said: text, ts: lineTs(line) });
596
593
  }
597
594
  }
598
595
  else if (Array.isArray(c)) {
@@ -1336,6 +1333,22 @@ export const parseInputBox = (cap, styledLayout = cap) => {
1336
1333
  const body = [rows[0].replace(BOX_PROMPT_RE, ""), ...rows.slice(1)].join("\n").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
1337
1334
  return { known: true, body };
1338
1335
  };
1336
+ // CodeBuddy 忙时回车不落盘: 那句挂进输入框上方的待发队列, 一条一行
1337
+ // Messages to be submitted after next tool call (…)
1338
+ // ↳ <整段压成一行, 过长以 … 截断>
1339
+ // 直到出队才写进 transcript —— 这期间「框清空了、transcript 里没有」是正常的排队态, 不是丢了。
1340
+ const QUEUED_ROW_RE = /^\s*↳\s?(.+)$/u;
1341
+ const QUEUED_HEAD_CHARS = 24;
1342
+ /** 队列里有几行是这段文本 (按去空白后的开头比对; 截断的行只比它露出的那截)。 */
1343
+ export const queuedCopies = (cap, text) => {
1344
+ const head = text.replace(/\s+/gu, "").slice(0, QUEUED_HEAD_CHARS);
1345
+ if (!head)
1346
+ return 0;
1347
+ return cap.split("\n")
1348
+ .map((l) => QUEUED_ROW_RE.exec(l)?.[1]?.replace(/\s+/gu, "").replace(/…$/u, "") ?? "")
1349
+ .filter((row) => row.length >= Math.min(8, head.length) && (row.startsWith(head) || head.startsWith(row)))
1350
+ .length;
1351
+ };
1339
1352
  // 空框里的灰字不是输入: 幽灵提示 (`Try "…"`) 和 CC 预测的下一句 (prompt suggestion,
1340
1353
  // 回车不采纳) 都以 SGR 2 (dim) 渲染。纯文本 capture 看不出区别 —— 预测句
1341
1354
  // `commit 这次改动…` 曾让一条 `commit` 被判成「已在框里」, 于是没贴, 只按了三下
@@ -1372,7 +1385,7 @@ const readPaneInput = async (target) => {
1372
1385
  return { cap, box: parseInputBox(cap, raw) };
1373
1386
  };
1374
1387
  const readInputBox = async (target) => (await readPaneInput(target)).box;
1375
- // ── 注入门控的会话证人 (cc-session) ─────────────────────────────────
1388
+ // ── 注入门控的会话证人 (wezard-session) ──────────────────────────────
1376
1389
  // 能从会话自己写的 json 读到的判据, 就不读 pane: 就绪看注册表的 status, 提交看
1377
1390
  // 注册表的 busy 翻转或 jsonl 里新落的那一行。输入框里「贴进去没有」是唯一只有
1378
1391
  // pane 知道的事 (未提交的输入不落任何盘), 那一段仍旧 capture。
@@ -1382,9 +1395,89 @@ const paneRunsShell = async (target) => {
1382
1395
  const r = await runTmux(["display-message", "-p", "-t", target, "#{pane_current_command}"]);
1383
1396
  return r.ok && SHELL_RE.test(r.stdout.trim());
1384
1397
  };
1385
- /** pane 活着且里面住着 AI 会话。注册表认得它就是; 不认得时只在前台确是 shell 才判否 ——
1386
- * 注册表缺席 (codebuddy / 旧版 CC / 进程不在 tmux 里记的行) 不能被当成会话不在。 */
1387
- const paneHostsSession = async (target, homeDir) => !hasRegistry(homeDir) || sessionOnPane(homeDir, target) !== undefined || !(await paneRunsShell(target));
1398
+ const paneLiveness = async (paneId) => {
1399
+ if (!paneId)
1400
+ return "gone";
1401
+ const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
1402
+ if (r.timedOut || r.code === null)
1403
+ return "unknown";
1404
+ if (r.code === 0)
1405
+ return r.stdout.trim() === paneId ? "alive" : "unknown";
1406
+ return "gone"; // "can't find pane: %N" / "no server running" —— 两条都等于它没了
1407
+ };
1408
+ /** 沿 ps 快照从某个 pid 往下找「会话注册表登记过、进程还活着」的那个 pid。注册表没有
1409
+ * `tmux` 字段的后端 (codebuddy) 只能这样把 pane 和进程对上: pane_pid 是 shell, CLI 是
1410
+ * 它的子孙。认不出返回 undefined。 */
1411
+ const registeredPidUnder = (root, livePids, kids) => {
1412
+ const stack = [root];
1413
+ const seen = new Set();
1414
+ while (stack.length) {
1415
+ const p = stack.pop() ?? 0;
1416
+ if (!p || seen.has(p))
1417
+ continue;
1418
+ if (livePids.has(p))
1419
+ return p;
1420
+ seen.add(p);
1421
+ for (const k of kids.get(p) ?? [])
1422
+ stack.push(k);
1423
+ }
1424
+ return undefined;
1425
+ };
1426
+ const panePidOf = async (paneId) => {
1427
+ const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_pid}"]);
1428
+ return r.ok ? Number(r.stdout.trim()) || 0 : 0;
1429
+ };
1430
+ /** pane 里有没有活着的 CLI 会话进程。注册表带 `tmux` 字段的后端按 pane 直接认; codebuddy
1431
+ * 不写那个字段, 只能沿 pane 的进程树找到注册表登记过的 pid。
1432
+ * **它只证明「里面有活的 CLI」, 不证明「里面是哪份转录」** —— codebuddy 的注册行里
1433
+ * sessionId 是续接链的根, 会话换过 sid 之后不更新 (2026-10-14 实测: pid 84679 报
1434
+ * 3d51b76b, 而那个进程实际在写 01a13ae5)。要认会话得另找权威来源, 别拿这个 sid 用。 */
1435
+ const paneHasLiveSession = async (paneId, homeDir, panePid, ps) => {
1436
+ if (sessionOnPane(homeDir, paneId))
1437
+ return true;
1438
+ const root = panePid ?? (await panePidOf(paneId));
1439
+ if (!root)
1440
+ return false;
1441
+ const { kids } = ps ?? (await psSnap());
1442
+ return registeredPidUnder(root, liveSessionPids(homeDir), kids) !== undefined;
1443
+ };
1444
+ /** 注入前问 pane 一句话: 还在吗 / 里面还有 AI 会话吗。
1445
+ * `unknown` 必须由调用方按「不知道」处理 —— 不许当 pane 死了去重生。 */
1446
+ const probePane = async (paneId, jsonlPath) => {
1447
+ const live = await paneLiveness(paneId);
1448
+ if (live !== "alive")
1449
+ return { state: live };
1450
+ if (await paneHasLiveSession(paneId, backendForPath(jsonlPath).homeDir))
1451
+ return { state: "session" };
1452
+ // 认不出会话进程 (注册表缺席 / 进程不在 tmux 里记的行) 不能被当成会话不在: 只有前台确实
1453
+ // 退回 shell 才判否。而这一次探问自己也可能是超时 —— 那仍旧只能算 unknown。
1454
+ const cmd = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_current_command}"]);
1455
+ if (cmd.timedOut || !cmd.ok)
1456
+ return { state: "unknown" };
1457
+ return SHELL_RE.test(cmd.stdout.trim()) ? { state: "shell" } : { state: "session" };
1458
+ };
1459
+ /** 注册表没有 tmux 字段的后端 (codebuddy): pane 里住着谁只能沿 pane 的进程树往下找
1460
+ * 注册表登记过、还活着的 pid。冷 pane 的注册行晚于进程出生, `waitMs` 内轮询。 */
1461
+ const hostPidOnPane = async (target, homeDir, waitMs) => {
1462
+ const deadline = Date.now() + waitMs;
1463
+ const probe = async () => {
1464
+ const r = await runTmux(["display-message", "-p", "-t", target, "#{pane_pid}"]);
1465
+ const root = r.ok ? Number(r.stdout.trim()) : 0;
1466
+ if (!root)
1467
+ return undefined;
1468
+ const reg = liveSessionPids(homeDir);
1469
+ const { kids } = await psSnap();
1470
+ return registeredPidUnder(root, reg, kids);
1471
+ };
1472
+ const poll = async () => {
1473
+ const pid = await probe();
1474
+ if (pid || Date.now() >= deadline)
1475
+ return pid;
1476
+ await sleep(300);
1477
+ return poll();
1478
+ };
1479
+ return poll();
1480
+ };
1388
1481
  const SESSION_READY_TIMEOUT_MS = 15_000;
1389
1482
  /** 冷 pane 等注册表报 idle (会话建好、输入框等人); 热 pane 只核一次会话还在 —— 忙也照贴,
1390
1483
  * CC 会把它排进队列。等不到 idle 不算失败 (贴后校验会兜), 前台退回 shell 才算:
@@ -1412,32 +1505,32 @@ const awaitSession = async (target, homeDir, fresh, log) => {
1412
1505
  }
1413
1506
  return { ok: true };
1414
1507
  };
1415
- const NO_WITNESS = { status: "", submitted: () => false };
1508
+ const NO_WITNESS = { status: "", submitted: () => false, submittedEvent: () => false };
1416
1509
  /** 贴之前给会话可能落盘的 jsonl 打书签: 绑定的那份, 加上注册表报的当前 sid 那份
1417
1510
  * (resume / fork 会换 sid)。提交后 sid 若又变了, 新文件从 0 读 —— 时间门挡掉 fork 里的旧话。 */
1418
- const witnessFor = (target, homeDir, jsonlPath, live) => {
1419
- if (!live)
1420
- return NO_WITNESS;
1511
+ const witnessFor = (target, homeDir, jsonlPath, live, actualTranscript) => {
1421
1512
  const dir = dirname(jsonlPath);
1422
- const marks = new Map([jsonlPath, join(dir, `${live.sessionId}.jsonl`)].map((p) => [p, markTranscript(p)]));
1513
+ const marks = new Map([...new Set([jsonlPath, actualTranscript, ...(live ? [join(dir, `${live.sessionId}.jsonl`)] : [])].filter((p) => !!p))]
1514
+ .map((p) => [p, markTranscript(p)]));
1423
1515
  return {
1424
- status: live.status,
1516
+ status: live?.status ?? "",
1425
1517
  submitted: (probe, since, enterAt) => {
1426
1518
  const now = sessionOnPane(homeDir, target);
1427
- if (now && live.status !== "busy" && now.status === "busy" && now.statusUpdatedAt >= enterAt)
1519
+ if (live && now && live.status !== "busy" && now.status === "busy" && now.statusUpdatedAt >= enterAt)
1428
1520
  return true;
1429
1521
  const p = now && join(dir, `${now.sessionId}.jsonl`);
1430
1522
  if (p && !marks.has(p))
1431
1523
  marks.set(p, { path: p, offset: 0 });
1432
1524
  return submittedSince([...marks.values()], probe, since);
1433
1525
  },
1526
+ submittedEvent: (eventId, since) => submittedEventSince([...marks.values()], eventId, since),
1434
1527
  };
1435
1528
  };
1436
1529
  // Self-verifying inject. The cold-spawn race we guard against: paste lands
1437
1530
  // but the trailing Enter is eaten while the TUI is still initializing, so
1438
1531
  // the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
1439
1532
  // 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
1440
- const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard) => {
1533
+ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard, eventId, expectedSessionId) => {
1441
1534
  const backendName = backend.name;
1442
1535
  log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
1443
1536
  // 就绪门: pane 里得真有一个进入了交互态的 AI 会话, 才轮得到贴。先于 modal guard ——
@@ -1445,7 +1538,74 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1445
1538
  const gate = await awaitSession(target, backend.homeDir, freshSpawn, log);
1446
1539
  if (!gate.ok)
1447
1540
  return gate;
1448
- const witness = witnessFor(target, backend.homeDir, jsonlPath, gate.live);
1541
+ const tmuxRegistry = hasRegistry(backend.homeDir);
1542
+ const eventNeedsIdentity = !!eventId && !text.trimStart().startsWith("/");
1543
+ // codebuddy 的注册行有 pid/sessionId 却没有 tmux, 而且 sessionId 在会话内换 sid
1544
+ // (handoff / 续接) 后不更新 —— 只能证明「pane 里住着一个活的 CLI 进程」, 收件的
1545
+ // 会话以 mirror 自己绑定的那份为准 (expectedSessionId / jsonlPath)。
1546
+ const hostPid = !tmuxRegistry && backendName !== "codex" && eventNeedsIdentity
1547
+ ? await hostPidOnPane(target, backend.homeDir, freshSpawn ? SESSION_READY_TIMEOUT_MS : 0)
1548
+ : undefined;
1549
+ const paneIdentity = () => {
1550
+ // A readable live-session registry is the physical pane authority. Codex's
1551
+ // SessionStart map is the fallback on installations without that registry.
1552
+ if (tmuxRegistry) {
1553
+ const session = sessionOnPane(backend.homeDir, target);
1554
+ return session ? { sessionId: session.sessionId, transcript: join(dirname(jsonlPath), `${session.sessionId}.jsonl`) } : undefined;
1555
+ }
1556
+ if (backendName === "codex") {
1557
+ const session = codexSessionForPane(target);
1558
+ return session ? { sessionId: session.sessionId, transcript: session.jsonlPath } : undefined;
1559
+ }
1560
+ if (hostPid && sessionOfPid(backend.homeDir, hostPid)) {
1561
+ const sessionId = expectedSessionId || basename(jsonlPath, ".jsonl");
1562
+ return { sessionId, transcript: jsonlPath };
1563
+ }
1564
+ return undefined;
1565
+ };
1566
+ const observedIdentity = paneIdentity();
1567
+ if (eventNeedsIdentity && (!observedIdentity?.sessionId || !observedIdentity.transcript)) {
1568
+ const reason = backendName === "codebuddy"
1569
+ ? "CodeBuddy pane hosts no live registered CLI process; refusing event injection before input exposure"
1570
+ : backendName === "codex"
1571
+ ? "Codex SessionStart pane identity is unavailable; refusing event injection before input exposure"
1572
+ : "Claude pane session identity is unavailable; refusing event injection before input exposure because the receiving session/transcript cannot be verified";
1573
+ return { ok: false, retryable: true, reason };
1574
+ }
1575
+ const initialIdentity = observedIdentity ?? (gate.live
1576
+ ? { sessionId: gate.live.sessionId, transcript: join(dirname(jsonlPath), `${gate.live.sessionId}.jsonl`) }
1577
+ : { transcript: jsonlPath });
1578
+ const witness = witnessFor(target, backend.homeDir, jsonlPath, gate.live, initialIdentity.transcript);
1579
+ let guardFailureReason = "dispatch guard rejected input";
1580
+ let inputGuardRejected = false;
1581
+ const boundGuard = (phase) => {
1582
+ // Resolve again at each physical paste/submit boundary. The mapping can
1583
+ // change after awaitSession or while an image is being staged.
1584
+ const current = paneIdentity();
1585
+ if (eventNeedsIdentity && (!current?.sessionId || !current.transcript)) {
1586
+ guardFailureReason = "receiving pane session identity became unavailable before input";
1587
+ inputGuardRejected = true;
1588
+ return false;
1589
+ }
1590
+ if (!current)
1591
+ return !eventNeedsIdentity && (!guard || guard(phase, { transcript: jsonlPath }));
1592
+ if (current.sessionId !== initialIdentity.sessionId || current.transcript !== initialIdentity.transcript) {
1593
+ guardFailureReason = "receiving pane session changed before input; refusing to attach event to a different session";
1594
+ inputGuardRejected = true;
1595
+ return false;
1596
+ }
1597
+ if (expectedSessionId && current.sessionId && current.sessionId !== expectedSessionId) {
1598
+ guardFailureReason = "receiving pane session no longer matches the queued event target";
1599
+ inputGuardRejected = true;
1600
+ return false;
1601
+ }
1602
+ const allowed = !guard || guard(phase, current);
1603
+ if (!allowed) {
1604
+ guardFailureReason = "dispatch guard withdrew permission before input";
1605
+ inputGuardRejected = true;
1606
+ }
1607
+ return allowed;
1608
+ };
1449
1609
  // Never type into a modal picker (see detectModalPicker). Checked before the
1450
1610
  // image pump too — a C-v into a picker is just as destructive as a paste.
1451
1611
  // 必须在按后端分流之前: 每个后端都有自己的原生确认框, 往任何一个里打字都会
@@ -1478,27 +1638,31 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1478
1638
  // dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
1479
1639
  // withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
1480
1640
  if (backendName === "codebuddy") {
1481
- return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, guard);
1641
+ return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, boundGuard, eventId);
1482
1642
  }
1483
1643
  // Pump images first via clipboard+C-v so each one is attached as a separate
1484
1644
  // image content block. Each C-v needs a brief settle for Claude Code's TUI
1485
1645
  // to read the clipboard before the next overwrite. Fresh spawn extends.
1486
1646
  const IMG_SETTLE_MS = freshSpawn ? 700 : 350;
1647
+ let imagePasteMayHaveExposed = false;
1487
1648
  const pumped = images.length ? await withImageClipboard(async () => {
1488
1649
  for (const imgPath of images) {
1489
1650
  const paste = async () => {
1490
1651
  const cb = await setMacClipboardImage(imgPath);
1491
1652
  if (!cb.ok)
1492
1653
  return cb;
1493
- if (guard && !guard("paste"))
1494
- return { ok: false, reason: "image paste canceled by dispatch guard" };
1654
+ if (!boundGuard("paste"))
1655
+ return { ok: false, reason: guardFailureReason };
1656
+ // Once send-keys starts, a failed client result cannot prove C-v was
1657
+ // not delivered; preserve uncertainty to prevent duplicate attachments.
1658
+ imagePasteMayHaveExposed = true;
1495
1659
  const cv = await runTmux(["send-keys", "-t", target, "C-v"]);
1496
1660
  return cv.ok ? { ok: true } : { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
1497
1661
  };
1498
1662
  // Cold Codex can ignore C-v before its composer exists. Do not submit
1499
1663
  // the text until the composer proves this attachment has landed.
1500
1664
  const result = backendName === "codex"
1501
- ? await pasteVerifiedImage({ read: () => readInputBox(target), paste, valid: () => !guard || guard("paste") })
1665
+ ? await pasteVerifiedImage({ read: () => readInputBox(target), paste, valid: () => boundGuard("paste") })
1502
1666
  : await paste();
1503
1667
  if (!result.ok) {
1504
1668
  log.warn({ target, imgPath, reason: result.reason }, "mirror inject: image not confirmed, refusing text submission");
@@ -1512,19 +1676,21 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1512
1676
  return { ok: true };
1513
1677
  }) : { ok: true };
1514
1678
  if (!pumped.ok)
1515
- return { ...pumped, uncertain: true };
1679
+ return imagePasteMayHaveExposed
1680
+ ? { ...pumped, ...(inputGuardRejected ? { reason: guardFailureReason } : {}), uncertain: true, retryable: false }
1681
+ : { ...pumped, ...(inputGuardRejected ? { reason: guardFailureReason } : {}), uncertain: false, retryable: true };
1516
1682
  // Image-only message: TUI input box now holds the attached images; press
1517
1683
  // Enter and we're done. No fingerprint to verify (the input itself is binary
1518
1684
  // image refs, not text).
1519
1685
  if (!text) {
1520
1686
  if (images.length === 0)
1521
1687
  return { ok: true };
1522
- if (guard && !guard("submit"))
1688
+ if (!boundGuard("submit"))
1523
1689
  return { ok: false, reason: "submit canceled by dispatch guard" };
1524
1690
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1525
1691
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
1526
1692
  }
1527
- return injectViaTmuxText(target, text, log, freshSpawn, witness, guard);
1693
+ return injectViaTmuxText(target, text, log, freshSpawn, witness, boundGuard, eventId);
1528
1694
  };
1529
1695
  // 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
1530
1696
  // 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
@@ -1546,7 +1712,7 @@ const withImageRefs = (text, images) => {
1546
1712
  // 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
1547
1713
  // 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
1548
1714
  // 每次补之前再问一遍证人, 已提交就不补。
1549
- const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard) => {
1715
+ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard, eventId) => {
1550
1716
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1551
1717
  // started, transcript still loading): extended timings — bracketed-paste
1552
1718
  // end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
@@ -1563,6 +1729,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1563
1729
  // (会话正忙 → 只是排队; slash 命令 → 不一定落 user 行) 不空等, 直接看框。
1564
1730
  const witnessQuiet = witness.status === "" || witness.status === "busy" || text.trimStart().startsWith("/");
1565
1731
  const WITNESS_ONLY_MS = witnessQuiet ? 0 : freshSpawn ? 2500 : 1000;
1732
+ const requireEventEvidence = !!eventId && !text.trimStart().startsWith("/");
1733
+ // 粘贴迟到的追加观察窗: 只走在「本来就要判 uncertain」的那条路上, 正常注入不付这笔。
1734
+ const LATE_LAND_MS = 15_000;
1735
+ const LATE_POLL_MS = 250;
1566
1736
  const { headFp, tailFp } = fingerprints(text);
1567
1737
  const probe = probeOf(text);
1568
1738
  const POLL_MS = 100;
@@ -1570,10 +1740,14 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1570
1740
  // 时间门往前放 1s: jsonl 的 timestamp 与本机时钟同源, 只差写盘的毫秒。
1571
1741
  const since = Date.now() - 1000;
1572
1742
  let enterAt = Number.POSITIVE_INFINITY;
1573
- const submitted = () => witness.submitted(probe, since, enterAt);
1743
+ const submitted = () => requireEventEvidence
1744
+ ? witness.submittedEvent(eventId, since) : witness.submitted(probe, since, enterAt);
1574
1745
  // 贴之前框里是什么: 空框、幽灵提示 (`Try "how do I log an error?"`)、甚至用户
1575
1746
  // 自己敲了一半的字 —— 一律作为基线, 免得去维护一张「哪些文案算空」的清单。
1576
- const preBox = await readInputBox(target);
1747
+ const pre = await readPaneInput(target);
1748
+ const preBox = pre.box;
1749
+ // 贴之前队列里已有几份同样开头的 (同一段文本上次排进去了 / 兄弟回执同开头): 只认新增的。
1750
+ const preQueued = queuedCopies(pre.cap, text);
1577
1751
  // 基线读不出来 (框被模态顶掉 / 正在重绘) 就整条退回指纹法: 没有基线,「框变了」
1578
1752
  // 无从谈起, 而把幽灵提示 (`Try "…"`) 误当成「贴进去了」会让这条消息静静消失。
1579
1753
  const boxVerify = preBox.known;
@@ -1598,6 +1772,21 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1598
1772
  }
1599
1773
  return false;
1600
1774
  };
1775
+ /** 粘贴过了 PASTE_VERIFY_MS 还没露面, 再盯一阵, 直到四者之一: 会话已收下 / 框变了 (迟到落地) /
1776
+ * 到点时框认得出且原样 (确实没进框) / 到点时框仍认不出 (无从判断)。 */
1777
+ const lateLanding = async (timeoutMs, t0 = Date.now()) => {
1778
+ if (submitted())
1779
+ return "submitted";
1780
+ const { cap, box } = await readPaneInput(target);
1781
+ if (box.known ? (boxVerify ? !boxIntact(box) : box.body !== "") : Boolean(headFp) && stripWs(cap).includes(headFp))
1782
+ return "landed";
1783
+ if (Date.now() - t0 >= timeoutMs)
1784
+ return box.known ? "intact" : "unknown";
1785
+ await sleep(LATE_POLL_MS);
1786
+ return lateLanding(timeoutMs, t0);
1787
+ };
1788
+ /** 回车后它进了 CodeBuddy 的待发队列 (见 queuedCopies) —— 提交已确凿, 只是还没落盘。 */
1789
+ const queued = async () => queuedCopies((await readPaneInput(target)).cap, text) > preQueued;
1601
1790
  // 窄窗口 = 只有输入框本身。tailFp 紧挨光标, 提交前必在窗内、提交后消失。
1602
1791
  // 仅用于 parseInputBox 认不出布局的后端。
1603
1792
  const inputBoxStillHasTail = async () => {
@@ -1622,7 +1811,22 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1622
1811
  // and then sleeping another 2.5s before the first Enter.
1623
1812
  pasteSeen = await sawLanded(boxVerify ? PASTE_VERIFY_MS : POST_PASTE_SETTLE_FALLBACK_MS);
1624
1813
  }
1625
- if (!pasteSeen) {
1814
+ if (!pasteSeen && requireEventEvidence) {
1815
+ // 事件注入不重贴, 但也绝不能把一份可能已进框的粘贴撂下不交: CodeBuddy 在轮次交接 (提交 /
1816
+ // 收尾的 hook、摘要) 时会把粘贴的渲染拖过 PASTE_VERIFY_MS, 撂下的那份等 TUI 缓过来才冒出,
1817
+ // 被下一次注入接在后面一起回车 —— 两件事并成一行提交 (2026-10-14 两起: 人话 + 回执)。
1818
+ // 所以先多等它一阵; 认不出框 ≠ 框是空的, 那种情况照样回车, 交给证人判。
1819
+ const late = await lateLanding(LATE_LAND_MS);
1820
+ if (late === "submitted")
1821
+ return { ok: true };
1822
+ if (late === "intact") {
1823
+ log.warn({ target, headFp, waitedMs: Date.now() - pastedAt }, "mirror inject: 粘贴始终没进框, 事件不重贴");
1824
+ return { ok: false, uncertain: true, reason: "composer is empty but this event is absent from the transcript; delivery remains uncertain and will not be pasted again" };
1825
+ }
1826
+ pasteSeen = late === "landed";
1827
+ log.warn({ target, headFp, late, waitedMs: Date.now() - pastedAt }, "mirror inject: 粘贴迟到, 按迟到结果继续提交");
1828
+ }
1829
+ else if (!pasteSeen) {
1626
1830
  const box = await readInputBox(target);
1627
1831
  const empty = boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
1628
1832
  if (submitted()) {
@@ -1691,13 +1895,19 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1691
1895
  await sleep(POLL_MS);
1692
1896
  if (submitted())
1693
1897
  return true;
1694
- if (Date.now() - t0 >= WITNESS_ONLY_MS && (await cleared()))
1898
+ if (Date.now() - t0 < WITNESS_ONLY_MS)
1899
+ continue;
1900
+ if (await queued())
1901
+ return true;
1902
+ // An unchanged empty composer proves submission only after we actually
1903
+ // saw this paste land. Otherwise a swallowed paste produces a false ACK.
1904
+ if (!requireEventEvidence && pasteSeen && (await cleared()))
1695
1905
  return true;
1696
1906
  }
1697
1907
  return false;
1698
1908
  };
1699
1909
  if (!pasteSeen)
1700
- log.warn({ target }, "mirror inject: submitted without paste verification (capture-pane lag)");
1910
+ log.warn({ target }, "mirror inject: paste unverified, requiring session submission evidence");
1701
1911
  if (!tailFp) {
1702
1912
  // empty/whitespace text — nothing to verify, fire once and go.
1703
1913
  const e = await sendEnter();
@@ -1706,7 +1916,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1706
1916
  // 回车可能被冷启动中的 TUI 吞掉 (框里躺着没提交的提示)。框认得出来时判据可信,
1707
1917
  // 多补一次; 认不出来时维持原来的「只补一次」, 免得判据出错往真实对话里灌回车。
1708
1918
  const boxTrusted = boxVerify && (await readInputBox(target)).known;
1709
- const attempts = boxTrusted ? 3 : 2;
1919
+ const attempts = requireEventEvidence ? 1 : boxTrusted ? 3 : 2;
1710
1920
  for (let i = 0; i < attempts; i++) {
1711
1921
  if (i > 0) {
1712
1922
  await sleep(RETRY_SETTLE_MS);
@@ -1723,9 +1933,13 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1723
1933
  return e;
1724
1934
  if (await waitForSubmit(CLEARED_TIMEOUT_MS))
1725
1935
  return { ok: true };
1936
+ if (requireEventEvidence && await cleared())
1937
+ return await queued() ? { ok: true } : { ok: false, uncertain: true, reason: "composer cleared without this event's transcript marker; delivery remains uncertain and will not be resubmitted" };
1726
1938
  }
1727
- if (submitted())
1939
+ if (submitted() || await queued())
1728
1940
  return { ok: true };
1941
+ if (requireEventEvidence && await cleared())
1942
+ return { ok: false, uncertain: true, reason: "composer cleared without this event's transcript marker; delivery remains uncertain and will not be resubmitted" };
1729
1943
  // 补到头还没清空。通常是渲染滞后 (回车其实落了), 但也可能是目标会话正忙 /
1730
1944
  // 上下文满了不消费输入 —— 那样用户这条消息会悄无声息地消失。不报硬失败 (用户
1731
1945
  // 多半已经被服务到了), 标 uncertain 让调用方按需提示。
@@ -1765,6 +1979,8 @@ const injectViaSpawn = async (args) => {
1765
1979
  // which Claude parses at submit time and inlines as image content blocks
1766
1980
  // without a model-decided Read tool turn.
1767
1981
  const finalText = withImageRefs(text, images);
1982
+ const eventMark = args.eventId && !text.trimStart().startsWith("/") ? markTranscript(jsonlPath) : undefined;
1983
+ const eventSince = Date.now() - 1000;
1768
1984
  const cliArgs = backend.name === "codex" ? ["exec", "resume", "--json", ...codexArgs,
1769
1985
  ...cfg.wrc.extraArgs.filter((arg) => !arg.startsWith("--disallowedTools")),
1770
1986
  ...images.flatMap((path) => ["--image", path]), sessionId, text] : [
@@ -1778,7 +1994,7 @@ const injectViaSpawn = async (args) => {
1778
1994
  ...cfg.wrc.extraArgs,
1779
1995
  ];
1780
1996
  log.info({ sessionId, bin, len: text.length }, "mirror inject");
1781
- if (args.guard && !args.guard("submit"))
1997
+ if (args.guard && !args.guard("submit", { sessionId, transcript: jsonlPath }))
1782
1998
  return { ok: false, reason: "spawn canceled by dispatch guard" };
1783
1999
  return new Promise((resolve) => {
1784
2000
  const proc = spawn(bin, cliArgs, {
@@ -1805,6 +2021,10 @@ const injectViaSpawn = async (args) => {
1805
2021
  resolve({ ok: false, reason: `${bin} exited ${code}: ${stderrTail.slice(-300)}` });
1806
2022
  return;
1807
2023
  }
2024
+ if (eventMark && args.eventId && !submittedEventSince([eventMark], args.eventId, eventSince)) {
2025
+ resolve({ ok: false, uncertain: true, reason: "CLI exited successfully but this event's transcript marker is absent; delivery remains uncertain" });
2026
+ return;
2027
+ }
1808
2028
  resolve({ ok: true });
1809
2029
  });
1810
2030
  });
@@ -1819,8 +2039,8 @@ const inject = async (args) => {
1819
2039
  return { ok: false, retryable: true, reason: "spawn permission withdrawn" };
1820
2040
  const text = args.prepareText?.() ?? args.text;
1821
2041
  let activated = false;
1822
- const result = await injectViaSpawn({ ...args, text, guard: (phase) => {
1823
- if (args.guard && !args.guard(phase))
2042
+ const result = await injectViaSpawn({ ...args, text, guard: (phase, identity) => {
2043
+ if (args.guard && !args.guard(phase, identity))
1824
2044
  return false;
1825
2045
  if (phase && !activated) {
1826
2046
  activated = true;
@@ -1842,11 +2062,12 @@ const inject = async (args) => {
1842
2062
  return classify(await withPaneInput(target, async (live) => {
1843
2063
  // Busy and unknown are both a deferred delivery. Never hold the input
1844
2064
  // lock while waiting for a model's whole turn to finish.
1845
- if (args.requireIdle && !args.requireIdle())
2065
+ const idleApproved = !args.requireIdle || await args.requireIdle();
2066
+ if (!idleApproved)
1846
2067
  return { ok: false, reason: "target has no confirmed idle evidence" };
1847
2068
  const text = args.prepareText?.() ?? args.text;
1848
- const guard = (phase) => {
1849
- if (!live() || (!exposed && args.requireIdle && !args.requireIdle()) || (args.guard && !args.guard(phase)))
2069
+ const guard = (phase, identity) => {
2070
+ if (!live() || (!exposed && !idleApproved) || (args.guard && !args.guard(phase, identity)))
1850
2071
  return false;
1851
2072
  if (phase && !exposed) {
1852
2073
  exposed = true;
@@ -1854,7 +2075,7 @@ const inject = async (args) => {
1854
2075
  }
1855
2076
  return true;
1856
2077
  };
1857
- return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard);
2078
+ return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard, args.eventId, args.sessionId);
1858
2079
  }, { guard: args.guard }));
1859
2080
  }
1860
2081
  catch (error) {
@@ -1873,15 +2094,24 @@ const INJECT_JOB_TIMEOUT_MS = 90_000;
1873
2094
  /** 没有绑定的 pane 静默多久才算孤儿。刚 spawn、还没 attach 的 pane (并发 fan-out 时
1874
2095
  * 别人的 sweep 会撞见它) 在这段时间里一直有输出, 远大于一次 spawn → attach 的耗时。 */
1875
2096
  const ORPHAN_GRACE_MS = 10 * 60_000;
2097
+ /** ps 的 `etime` ([[dd-]hh:]mm:ss) → 秒; 认不出 = NaN。 */
2098
+ const etimeSec = (s) => {
2099
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(s);
2100
+ if (!m)
2101
+ return Number.NaN;
2102
+ const [d = 0, h = 0, mi = 0, se = 0] = m.slice(1).map((x) => Number(x ?? 0));
2103
+ return ((d * 24 + h) * 60 + mi) * 60 + se;
2104
+ };
1876
2105
  const psSnap = () => new Promise((resolve) => {
1877
- const p = spawn("ps", ["-axo", "pid=,ppid=,etimes="], { env: { ...process.env, PATH: augmentedPath(process.env.PATH) }, stdio: ["ignore", "pipe", "ignore"] });
2106
+ const p = spawn("ps", ["-axo", "pid=,ppid=,etime="], { env: { ...process.env, PATH: augmentedPath(process.env.PATH) }, stdio: ["ignore", "pipe", "ignore"] });
1878
2107
  let out = "";
1879
2108
  p.stdout?.on("data", (c) => (out += c.toString("utf8")));
1880
2109
  const done = () => {
1881
2110
  const kids = new Map();
1882
2111
  const etimes = new Map();
1883
2112
  for (const line of out.split("\n")) {
1884
- const [pid, ppid, et] = line.trim().split(/\s+/).map(Number);
2113
+ const [pidS = "", ppidS = "", etS = ""] = line.trim().split(/\s+/);
2114
+ const pid = Number(pidS), ppid = Number(ppidS), et = etimeSec(etS);
1885
2115
  if (!pid || !ppid)
1886
2116
  continue;
1887
2117
  const arr = kids.get(ppid);
@@ -1889,7 +2119,7 @@ const psSnap = () => new Promise((resolve) => {
1889
2119
  arr.push(pid);
1890
2120
  else
1891
2121
  kids.set(ppid, [pid]);
1892
- if (et !== undefined && !Number.isNaN(et))
2122
+ if (!Number.isNaN(et))
1893
2123
  etimes.set(pid, et);
1894
2124
  }
1895
2125
  resolve({ kids, etimes });
@@ -1992,14 +2222,6 @@ export const startMirror = (deps) => {
1992
2222
  recentAssistantSends.splice(i, 1); // one-shot: a genuine later re-say still streams
1993
2223
  return true;
1994
2224
  };
1995
- // Keepalive ping detector for the tail's user-line judgment — the SAME
1996
- // normalized-prefix signature set keepaliveTick feeds keepaliveStamps (the
1997
- // configured ping + the bare "ping" legacy streak form), so renderLine, the
1998
- // tick, the peer readers (peek / wait / roster summary) and the transcript
1999
- // parser all agree on what counts as a ping. Content is the source of truth
2000
- // for the swallow: timers and echo-TTLs lose it across reloads/replays.
2001
- const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
2002
- const isKeepalivePing = (text) => isKeepalivePingText(text, pingSigs);
2003
2225
  // ── Typewriter stream lifecycle ────────────────────────────────────
2004
2226
  // WeCom spec: server polls us for stream refreshes for up to 6 min from the
2005
2227
  // original inbound. SDK queues replyStream calls per req_id, sends serially
@@ -2044,6 +2266,7 @@ export const startMirror = (deps) => {
2044
2266
  turnRegistry.delete(k);
2045
2267
  };
2046
2268
  const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
2269
+ let eventFinalGate = () => true;
2047
2270
  const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2048
2271
  const newMarkId = () => `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
2049
2272
  const detailCardFor = (s, target) => {
@@ -2176,8 +2399,6 @@ export const startMirror = (deps) => {
2176
2399
  const sendStandalone = (a, content) => {
2177
2400
  if (!hasVisibleBody(content))
2178
2401
  return;
2179
- if (answerSilent(a))
2180
- return;
2181
2402
  if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
2182
2403
  return;
2183
2404
  const chatId = replyChatId(a);
@@ -2186,11 +2407,7 @@ export const startMirror = (deps) => {
2186
2407
  const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
2187
2408
  a.standalonePending = a.standalonePending
2188
2409
  .then(async () => {
2189
- if (silenceAnswer(a.target, turnId ?? ""))
2190
- return;
2191
2410
  for (const c of chunks) {
2192
- if (silenceAnswer(a.target, turnId ?? ""))
2193
- return;
2194
2411
  try {
2195
2412
  const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
2196
2413
  log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
@@ -2210,8 +2427,6 @@ export const startMirror = (deps) => {
2210
2427
  const turnId = a.briefTurnId ?? a.liveStream?.turnId;
2211
2428
  a.standalonePending = a.standalonePending
2212
2429
  .then(async () => {
2213
- if (silenceAnswer(a.target, turnId ?? ""))
2214
- return;
2215
2430
  try {
2216
2431
  await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
2217
2432
  log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
@@ -2483,9 +2698,9 @@ export const startMirror = (deps) => {
2483
2698
  // • 节流 —— 气泡刷新是整条内容重发, 高频 thinking 不能 1:1 打到 stream 上。
2484
2699
  const COT_MAX_CHARS = 100;
2485
2700
  const COT_FLUSH_MS = 1500;
2486
- // 压成一行的进度片段。safeForMarkdown 必须先于截断: thinking 里一个裸反引号就能提前
2487
- // 闭合代码段, 让后面的正文裸奔在链接旁边。尾随的省略号 = "还在进行中"; 截断与进行中
2488
- // 共用同一个 …, 不叠加两个。
2701
+ // 压成一行的进度片段, 纯文本上气泡 (不包代码段)。safeForMarkdown 必须先于截断:
2702
+ // 进度行与链接头同行, thinking 里的 `[]`<>` 会戳破链接与排版。尾随的省略号 =
2703
+ // "还在进行中"; 截断与进行中共用同一个 …, 不叠加两个。
2489
2704
  const cotLine = (s) => {
2490
2705
  const flat = safeForMarkdown(s.replace(/\s+/g, " ").trim());
2491
2706
  return flat ? `${flat.slice(0, COT_MAX_CHARS)}…` : "";
@@ -2497,22 +2712,25 @@ export const startMirror = (deps) => {
2497
2712
  a.cotTimer = undefined;
2498
2713
  }
2499
2714
  a.cotText = undefined;
2715
+ a.cotTool = undefined;
2500
2716
  };
2501
2717
  const flushCot = async (a) => {
2502
2718
  a.cotTimer = undefined;
2503
- const text = a.cotText;
2719
+ // 展示选取: assistant text (含 thinking) 优先; 没有新 text 时才落最新 tool_use。
2720
+ const text = a.cotText ?? a.cotTool;
2504
2721
  a.cotText = undefined;
2722
+ a.cotTool = undefined;
2505
2723
  const b = a.briefBubble;
2506
2724
  const turnId = a.briefTurnId;
2507
2725
  // 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
2508
- if (!text || !b || b.done || !turnId || a.briefConcluded || answerSilent(a))
2726
+ if (!text || !b || b.done || !turnId || a.briefConcluded)
2509
2727
  return;
2510
2728
  a.cotLastSent = text;
2511
2729
  try {
2512
2730
  await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2513
- if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || answerSilent(a))
2731
+ if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId)
2514
2732
  return;
2515
- await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, `\`${text}\``), false);
2733
+ await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, text), false);
2516
2734
  }));
2517
2735
  }
2518
2736
  catch (e) {
@@ -2532,11 +2750,25 @@ export const startMirror = (deps) => {
2532
2750
  return;
2533
2751
  a.cotTimer = setTimeout(() => void flushCot(a), COT_FLUSH_MS);
2534
2752
  };
2753
+ /** tool_use 进度进 tool 槽 (不顶掉待发的 assistant text): flush 时 text 优先,
2754
+ * 只有没有新 text 的窗口才把最新 tool 行刷上气泡。节流与 text 共用一个计时器。 */
2755
+ const updateBriefToolProgress = (a, raw) => {
2756
+ const b = a.briefBubble;
2757
+ if (!b || b.done || a.briefConcluded)
2758
+ return;
2759
+ const line = cotLine(raw);
2760
+ if (!line)
2761
+ return;
2762
+ a.cotTool = line;
2763
+ if (a.cotTimer)
2764
+ return;
2765
+ a.cotTimer = setTimeout(() => void flushCot(a), COT_FLUSH_MS);
2766
+ };
2535
2767
  // 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
2536
2768
  // 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
2537
2769
  // wizard 名字作为 ww_uniq 传下去, 让同一个 wizard 的所有 turn 详情都复用一个 WeCom 窗口。
2538
2770
  // 头怎么写归 chatHead (默认 wizard 在 home 群: 企微只留名字链接, 微信不写头); 头与正文都空时留 `…`, 不发空气泡。
2539
- const briefLine = (a, turnId, rest, dest = a.channel) => headed(chatHead(a.target, buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(a.target)), undefined, dest || undefined), rest) || "…";
2771
+ const briefLine = (a, turnId, rest, dest = a.channel) => headed(chatHead(a.target, chatTurnUrl(a.target, turnId), undefined, dest || undefined), rest) || "…";
2540
2772
  // ── 出处门 (chatOriginOnly) ───────────────────────────────────────────
2541
2773
  // 人在 CLI 里手敲的一轮, 镜像只发生在两处: 他眼前的终端, 和 chat 详情页 (turn
2542
2774
  // store 全量记录 + SSE 实时刷新)。聊天里不再下发它的气泡 —— 那份内容他本来就
@@ -2544,171 +2776,64 @@ export const startMirror = (deps) => {
2544
2776
  // `[mirror]` 系统提示 —— 它们都不走这道门。
2545
2777
  // 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
2546
2778
  // 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
2547
- let silenceAnswer = () => false;
2548
- const answerSilent = (a) => silenceAnswer(a.target, a.briefTurnId ?? a.liveStream?.turnId ?? "");
2549
- const turnSilent = (a) => answerSilent(a) || isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
2550
- const answerBubbles = new Map();
2551
- const answerLine = (held, text) => withLinkedTagHeader(held.receiver, text, chatTurnUrl(held.receiver, held.sourceTurn), undefined, undefined, held.channel);
2552
- // Serialise the old owner's in-flight refresh, reset, progress and final.
2553
- // A delayed ACK must never let an earlier writer overtake the new owner.
2554
- const writeAnswerProgress = (held, text) => {
2555
- held.pending = held.pending.catch(() => undefined).then(async () => {
2556
- if (!held.active || held.stopped || !held.bubble || held.bubble.done)
2557
- return;
2558
- await client.replyStream(held.bubble.frame, held.bubble.streamId, answerLine(held, text), false);
2559
- }).catch((e) => log.warn({ err: errText(e) }, "answer transfer progress refresh failed"));
2560
- };
2561
- const updateAnswerProgress = (a, item) => {
2562
- // Queued metadata is not authority: only the input boundary activates a task.
2563
- const held = a.activePeerTurn ? answerBubbles.get(a.activePeerTurn) : undefined;
2564
- if (!held || held.receiver !== a.target || held.stopped || held.bubble?.done)
2565
- return;
2566
- const raw = item.kind === "tool_use" ? cotToolLabel(item.calls)
2567
- : item.kind === "thinking" || (item.kind === "text" && item.final !== true) ? item.body : "";
2568
- const line = cotLine(raw);
2569
- if (!line)
2570
- return;
2571
- held.progress = line;
2572
- if (!held.active)
2573
- return;
2574
- if (held.timer)
2575
- return;
2576
- held.timer = setTimeout(() => {
2577
- held.timer = undefined;
2578
- if (held.progress)
2579
- writeAnswerProgress(held, `\`${held.progress}\``);
2580
- }, COT_FLUSH_MS);
2581
- };
2582
- const answerContext = (target) => {
2583
- const a = byTarget.get(target);
2584
- if (!a || a.briefConcluded || a.briefIsSlash || isPrivate(a) || a.turnFromChat === false)
2779
+ const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
2780
+ const formatFinal = (target, turn, channel, body) => {
2781
+ const pieces = splitMarkdown(body, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
2782
+ return pieces.map((text, n) => withLinkedTagHeader(target, text, chatTurnUrl(target, turn), pieces.length > 1 ? `${n + 1}/${pieces.length}` : undefined, undefined, channel));
2783
+ };
2784
+ const finalProgress = new Map();
2785
+ const settleFinalProgress = async (id) => {
2786
+ await finalProgress.get(id)?.catch(() => undefined);
2787
+ finalProgress.delete(id);
2788
+ };
2789
+ const queueFinal = (target, turn, channel, body, eventId, bubble) => {
2790
+ // 投递出口剥掉行首收口标记与 ARTIFACT 行 (peers.stripForChat): 判定读原文, 群里只看结论。
2791
+ const text = stripForChat(body);
2792
+ if (!deps.events || !channel || !text.trim())
2585
2793
  return undefined;
2586
- const turn = a.briefTurnId ?? a.liveStream?.turnId;
2587
- return turn ? { turn, channel: a.channel ?? baseOfKey(target), prefix: "" } : undefined;
2588
- };
2589
- const prepareAnswer = (id, source, expectedTurn, receiver, channel) => {
2590
- const a = byTarget.get(source);
2591
- if (!a || (a.briefTurnId ?? a.liveStream?.turnId) !== expectedTurn || answerBubbles.has(id))
2592
- return false;
2593
- if ((!a.briefBubble || a.briefBubble.done) && (!a.liveStream || a.liveStream.closed || a.liveStream.dead))
2594
- return false;
2595
- answerBubbles.set(id, {
2596
- source, receiver, sourceTurn: expectedTurn, channel, active: false, stopped: false,
2597
- bubble: a.briefBubble, stream: a.liveStream, pending: Promise.resolve(),
2598
- });
2599
- return true;
2600
- };
2601
- const cancelAnswerPreparation = (id) => {
2602
- const held = answerBubbles.get(id);
2603
- if (held && !held.active)
2604
- answerBubbles.delete(id);
2605
- };
2606
- const stopAnswerProgress = (id) => {
2607
- const held = answerBubbles.get(id);
2608
- if (!held)
2794
+ const revision = finalId(eventId || turn, channel, text);
2795
+ const id = finalId(eventId || turn, channel, revision);
2796
+ const chunks = formatFinal(target, turn, channel, text);
2797
+ if (bubble?.pending)
2798
+ finalProgress.set(id, bubble.pending);
2799
+ deps.events.delivery({ id, eventId, target, turn, channel, body: text, revision, chunks,
2800
+ ...(bubble && !bubble.done ? { stream: { frame: bubble.frame, id: bubble.streamId, expires: bubble.expires ?? Date.now() } } : {}) });
2801
+ if (bubble) {
2802
+ bubble.done = true;
2803
+ clearTimeout(bubble.hardTimer);
2804
+ }
2805
+ return id;
2806
+ };
2807
+ const recoverEventFinal = (event, bubble) => {
2808
+ if (!event.result || event.interjected || event.canceled)
2609
2809
  return;
2610
- held.stopped = true;
2611
- clearTimeout(held.timer);
2612
- held.timer = undefined;
2613
- };
2614
- const takeAnswer = async (id, target, expectedTurn, receiver, channel) => {
2615
- const a = byTarget.get(target);
2616
- const held = answerBubbles.get(id);
2617
- if (!held || held.active || held.source !== target || held.sourceTurn !== expectedTurn || held.receiver !== receiver || held.channel !== channel)
2618
- return { taken: false, cleared: false };
2619
- const sameTurn = a && (a.briefTurnId ?? a.liveStream?.turnId) === expectedTurn;
2620
- if (sameTurn) {
2621
- clearCot(a);
2622
- a.cotLastSent = undefined;
2623
- if (a.standaloneBuf) {
2624
- clearTimeout(a.standaloneBuf.timer);
2625
- a.standaloneBuf = undefined;
2626
- }
2627
- // Only this source turn becomes private; a concurrent new human turn is untouched.
2628
- a.channel = "";
2629
- }
2630
- const s = held.stream;
2631
- if (s && !s.closed && !s.dead) {
2632
- s.closed = true;
2633
- clearTimeout(s.flushTimer);
2634
- clearTimeout(s.idleTimer);
2635
- clearTimeout(s.hardTimer);
2636
- // Read the current chain, not prepare's snapshot: source may have flushed during injection.
2637
- held.bubble = { frame: s.frame, streamId: s.streamId, done: false, pending: s.pending };
2638
- if (a?.liveStream === s)
2639
- a.liveStream = undefined;
2640
- }
2641
- else if (s)
2642
- held.bubble = undefined;
2643
- const b = held.bubble;
2644
- held.active = true;
2645
- if (!b || b.done)
2646
- return { taken: true, cleared: false };
2647
- clearTimeout(b.hardTimer);
2648
- if (a?.briefBubble === b)
2649
- a.briefBubble = undefined;
2650
- let cleared = false;
2651
- held.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2652
- if (b.done)
2653
- return;
2654
- await client.replyStream(b.frame, b.streamId, answerLine(held, "…"), false);
2655
- cleared = true;
2656
- });
2657
- try {
2658
- await held.pending;
2659
- }
2660
- catch (e) {
2661
- cleared = false;
2662
- log.warn({ id, err: errText(e) }, "answer transfer reset not acknowledged");
2663
- }
2664
- // Keep delivery possible after a reset error; reset never carries the final answer.
2665
- held.pending = held.pending.catch(() => undefined);
2666
- if (!held.stopped && held.progress)
2667
- writeAnswerProgress(held, `\`${held.progress}\``);
2668
- return { taken: true, cleared };
2669
- };
2670
- const deliverAnswer = async (id, target, turn, channel, _prefix, body, standalone) => {
2671
- const held = answerBubbles.get(id);
2672
- const text = body.trim();
2673
- const content = withLinkedTagHeader(target, text, chatTurnUrl(target, turn), undefined, undefined, channel);
2674
- stopAnswerProgress(id);
2675
- if (held?.bubble && !held.bubble.done) {
2676
- held.bubble.done = true;
2677
- clearTimeout(held.timer);
2678
- await held.pending;
2679
- try {
2680
- // Failure closes progress first; the terminal explanation is a standalone message.
2681
- const progress = withLinkedTagHeader(target, "回答已转交", chatTurnUrl(target, turn), undefined, undefined, channel);
2682
- await client.replyStream(held.bubble.frame, held.bubble.streamId, standalone ? progress : content, true);
2683
- if (!standalone) {
2684
- answerBubbles.delete(id);
2685
- return;
2686
- }
2687
- }
2688
- catch (e) {
2689
- const ack = e && typeof e === "object" ? e : undefined;
2690
- // Only a structured non-zero server ACK proves the stream frame was
2691
- // rejected. Timeout/disconnect errors are ambiguous: standalone retry
2692
- // could publish the same final answer twice, so let the caller persist
2693
- // `uncertain` and require a human resend instead.
2694
- const rejected = !!ack && typeof ack.headers === "object" && !!ack.headers && typeof ack.errcode === "number" && ack.errcode !== 0 && typeof ack.errmsg === "string";
2695
- if (!rejected && !standalone) {
2696
- answerBubbles.delete(id);
2697
- throw e;
2698
- }
2699
- if (!rejected) {
2700
- log.warn({ id, err: errText(e) }, "answer transfer progress close acknowledgement uncertain; continuing with the one standalone terminal explanation");
2701
- // In standalone mode this stream only closes progress; it never
2702
- // carries the final body, so sending the terminal explanation once
2703
- // remains necessary even when progress-close ACK is ambiguous.
2704
- }
2705
- else {
2706
- log.warn({ id, err: errText(e), errcode: ack.errcode }, "answer transfer stream was explicitly rejected; standalone fallback");
2707
- }
2708
- }
2709
- }
2710
- await client.sendMessage(channel.replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content } });
2711
- answerBubbles.delete(id);
2810
+ // 事件泵的 evidence 探针 (observe → io.recovered) 与 brief 软收口赛跑: 两者同为
2811
+ // ~1s 周期, 探针常先跑到, 而它调这里不带气泡 —— queueFinal 拿不到 stream, 终句
2812
+ // 全部走 standalone, 气泡定格在最后一行 CoT 进度。没传气泡时按 target/turn 找回
2813
+ // 活着的那只 (turn 已换人/气泡已收则不挂, 维持 standalone)。
2814
+ bubble ??= (() => {
2815
+ const a = byTarget.get(event.target);
2816
+ return a && (!event.turn || a.briefTurnId === event.turn) && a.briefBubble && !a.briefBubble.done ? a.briefBubble : undefined;
2817
+ })();
2818
+ const turn = event.turn || event.id;
2819
+ const record = getDetail(turn);
2820
+ if (!record)
2821
+ recordTurnStart({ id: turn, eventId: event.id, target: event.target,
2822
+ userQuery: event.payload.text, createdAt: event.originalAt ?? event.at, channel: event.channel,
2823
+ speaker: event.payload.human ? event.speaker : undefined, from: event.payload.peer?.from });
2824
+ // tail 已按软收口记过这句话 (final:false —— 它不带终句语义, doneAt 不认): 就地升级,
2825
+ // 不追加同文第二条, 否则 rolepage 上草稿与终稿各画一颗气泡。没有同文的才补 —— 但也
2826
+ // 只补「这轮还没有任何终句」的: 已有终句而 body 不同, 是续答链把子事件的终句写进了
2827
+ // 同一段话只落一次 (子轮 / 父轮不各画一颗气泡)。
2828
+ if (!(record?.kind === "turn" && markTurnTextFinal(turn, event.result.body)) &&
2829
+ (record ? record.kind === "turn" && !record.items.some((x) => x.t === "text" && x.final) : true))
2830
+ recordTurnItem(turn, { t: "text", final: true, body: event.result.body, ts: event.result.at });
2831
+ recordTurnClose(turn);
2832
+ if (!event.turn)
2833
+ deps.events?.bind(event.id, turn);
2834
+ // 终句按事件自己的 `serves` 放行 (§9.4/§9.5): 收口与否由路由表判 (eventFinalGate)。
2835
+ if (event.channel && eventFinalGate(event))
2836
+ queueFinal(event.target, turn, event.channel, event.result.body, event.id, bubble);
2712
2837
  };
2713
2838
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
2714
2839
  // raw=true skips withTagHeader (used when content already contains the linked tag header).
@@ -2782,13 +2907,18 @@ export const startMirror = (deps) => {
2782
2907
  const publicPeer = (from) => from?.kind === "peer" && from.public ? from.from : undefined;
2783
2908
  /** reload 后 tail 从 EOF 接上: 正在跑的这一轮的开头那句已在身后, 它的印章随旧进程没了。
2784
2909
  * 那句话自己挂着信封 (谁说的、私聊还是哪个群) —— 读回来就是这一轮的出处。没挂信封 = undefined。 */
2910
+ /** 这一轮挂在哪一笔上: 回执 (与老派活信封) 写在信封上; 新派活信封不带, 问注入事件的 `serves`。 */
2911
+ const rowOf = (env) => env.event ?? (env.eventId ? deps.events?.get(env.eventId)?.serves : undefined);
2785
2912
  const openingOf = (a) => {
2786
2913
  const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
2787
2914
  const env = t?.env;
2915
+ a.activeEventId = env?.eventId;
2788
2916
  if (!env)
2789
2917
  return undefined;
2790
2918
  const home = baseOfKey(a.target);
2791
- const from = env.kind === "peer" ? { kind: "peer", from: wizardStore()?.byName(stripSigil(env.from))?.target, ...(env.private ? {} : { public: true }), ...(env.receipt ? { receipt: true } : {}), ...(env.status ? { status: env.status } : {}), ...(env.turn ? { turn: env.turn } : {}), ...(env.asker ? { asker: env.asker } : {}) }
2919
+ // transfer 过来的那一笔信封上写着群 (不是 private), 但它不是公开派活: 回复不标给发话方。
2920
+ const handed = !!(env.eventId && deps.events?.get(env.eventId)?.payload.peer?.from?.transferred);
2921
+ const from = env.kind === "peer" ? { kind: "peer", from: wizardStore()?.byName(stripSigil(env.from))?.target, ...(handed ? { transferred: true } : env.private ? {} : { public: true }), ...(env.receipt ? { receipt: true } : {}), ...(env.status ? { status: env.status } : {}), ...((turn) => (turn ? { turn } : {}))(rowOf(env)), ...(env.asker ? { asker: env.asker } : {}) }
2792
2922
  : env.kind === "task" ? { kind: "task", taskId: env.from.replace(/^定时 /, "") }
2793
2923
  : undefined;
2794
2924
  return {
@@ -2838,7 +2968,7 @@ export const startMirror = (deps) => {
2838
2968
  }
2839
2969
  // 私聊派来的活 (不是回执) 这一轮必是私聊, 不论印章带没带频道: 进群的只能是人问的事的
2840
2970
  // 交代与显式公开。带 task 的 fork 分身就只有出处、没有频道印章, 退回 home 就把私聊派的活答进了群。
2841
- if (from?.kind === "peer" && !from.public && !from.receipt) {
2971
+ if (from?.kind === "peer" && !from.public && !from.receipt && !from.transferred) {
2842
2972
  a.channel = "";
2843
2973
  a.speaker = undefined;
2844
2974
  a.replyTo = undefined;
@@ -2846,7 +2976,7 @@ export const startMirror = (deps) => {
2846
2976
  return undefined;
2847
2977
  };
2848
2978
  /** 本轮落 turn 记录的频道字段。 */
2849
- const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker });
2979
+ const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker, eventId: a.activeEventId });
2850
2980
  /** 私聊轮: 回复只落 turn 记录。 */
2851
2981
  const isPrivate = (a) => a.channel === "";
2852
2982
  /** 本轮回复该推去的 chatId。 */
@@ -2872,9 +3002,9 @@ export const startMirror = (deps) => {
2872
3002
  };
2873
3003
  /** 露面的这一行认领它的印章, 先注入的先认。CLI 把排队的几句并成一行时, 包含在里面的都算
2874
3004
  * 露过面, 出处取第一枚。认不出 = undefined。 */
2875
- const takeSeal = (a, said, pick = () => true) => {
3005
+ const takeSeal = (a, said, pick = () => true, eventId) => {
2876
3006
  const t = said.trim();
2877
- const hit = (x) => pick(x) && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k)));
3007
+ const hit = (x) => pick(x) && (eventId ? x.eventId === eventId : !x.eventId && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k))));
2878
3008
  const seals = a.seals ?? [];
2879
3009
  const hits = seals.filter(hit);
2880
3010
  // CLI 把排队的几句并成一行, 这一轮只能有一个去向: 里面有私聊的那句就是私聊 —— 它的答复不能
@@ -2895,8 +3025,10 @@ export const startMirror = (deps) => {
2895
3025
  * `at` = 那一行落盘的时刻: 排队的那句从这一刻才开始被处理, 轮次的起点以它为准; 闲时当场接上就是投递那一刻。 */
2896
3026
  const activateDispatch = (a, s, at = s.at) => {
2897
3027
  const q = s.turn;
3028
+ if (a.briefTurnId && a.briefTurnId !== q.turnId)
3029
+ closeBriefTurn(a);
3030
+ a.activeEventId = s.eventId;
2898
3031
  s.activated = true;
2899
- a.activePeerTurn = undefined;
2900
3032
  a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
2901
3033
  a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
2902
3034
  a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
@@ -2906,9 +3038,7 @@ export const startMirror = (deps) => {
2906
3038
  a.pendingBriefQuery = undefined;
2907
3039
  a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
2908
3040
  consumeChannel(a, true, undefined);
2909
- recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: at, ...channelFields(a) });
2910
- if (a.briefTurnId)
2911
- closeBriefTurn(a);
3041
+ recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: (s.eventId ? deps.events?.get(s.eventId)?.originalAt : undefined) ?? at, ...channelFields(a) });
2912
3042
  openBriefTurn(a, q);
2913
3043
  };
2914
3044
  /** 一句插话记进它插进的那一轮: 出处、频道、发话人都取它自己的印章 (没写频道 = 在 home 里开口)。 */
@@ -2916,16 +3046,21 @@ export const startMirror = (deps) => {
2916
3046
  /** `attachment.queued_command` 露面: 那句被正在跑的这一轮中途吃进去了。认领它的印章记成插话。
2917
3047
  * 人从聊天里说的那句 (印章带预建的轮) 不在这里认: 它已有自己的轮次记录。认不出 (人在 CLI 里
2918
3048
  * 忙时敲的) 不记 —— 说不清是谁、在哪个频道说的。 */
2919
- const interject = (a, said, ts) => {
3049
+ const interject = (a, said, ts, eventId) => {
2920
3050
  if (!a.briefTurnId)
2921
3051
  return;
2922
- const s = takeSeal(a, said, (x) => !x.turn);
3052
+ const s = takeSeal(a, said, (x) => !x.turn, eventId);
2923
3053
  if (s)
2924
3054
  injectInto(a.briefTurnId, a, s, ts);
2925
3055
  };
2926
3056
  /** 一行注入露面了: 把它的印章落成下一轮的出处 / 频道。返回认领到的印章。`ts` = 那一行落盘的时刻。 */
2927
- const bindLine = (a, said, ts) => {
2928
- const s = takeSeal(a, said);
3057
+ const bindLine = (a, said, ts, eventId) => {
3058
+ const recovered = eventId ? deps.events?.get(eventId) : undefined;
3059
+ const s = takeSeal(a, said, undefined, eventId) ?? (recovered?.target === a.target ? {
3060
+ eventId, keys: [], at: recovered.at, query: recovered.payload.text, channel: recovered.channel,
3061
+ speaker: recovered.payload.human ? recovered.speaker : undefined, from: recovered.payload.peer?.from,
3062
+ fresh: recovered.priority === "normal" || idleVerdict(a.target) === true,
3063
+ } : undefined);
2929
3064
  if (!s)
2930
3065
  return undefined;
2931
3066
  if (s.turn) {
@@ -2941,8 +3076,8 @@ export const startMirror = (deps) => {
2941
3076
  }
2942
3077
  if (a.briefTurnId)
2943
3078
  closeBriefTurn(a);
3079
+ a.activeEventId = s.eventId;
2944
3080
  const now = Date.now();
2945
- a.activePeerTurn = s.from?.kind === "peer" ? s.from.turn : undefined;
2946
3081
  a.pendingFrom = s.from ? { from: s.from, at: now } : undefined;
2947
3082
  a.pendingOrigin = s.origin ? { origin: s.origin, at: now } : undefined;
2948
3083
  // 没写频道的注入 (graph 步骤 / 手动 inject) 等同在 home 里开口: 印章就是全部出处, 不沿用上一轮的。
@@ -2956,10 +3091,12 @@ export const startMirror = (deps) => {
2956
3091
  }
2957
3092
  return s;
2958
3093
  };
2959
- const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
3094
+ const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker, eventId) => {
2960
3095
  const turnId = newTurnId();
2961
3096
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
2962
- const bubble = { frame, streamId, done: false };
3097
+ const bubble = { frame, streamId, done: false, expires: (eventId ? deps.events?.get(eventId)?.at ?? Date.now() : Date.now()) + HARD_TIMEOUT_MS };
3098
+ if (eventId)
3099
+ deps.events?.bind(eventId, turnId);
2963
3100
  const q = { turnId, bubble, isSlash };
2964
3101
  // 微信帧不挂: 没有 stream 窗口要赶, 气泡等终句来了再一次发出 (见 openStream 同一条)。
2965
3102
  if (!isWxFrame(frame))
@@ -2970,23 +3107,17 @@ export const startMirror = (deps) => {
2970
3107
  const live = a.briefBubble === bubble && !a.briefConcluded;
2971
3108
  void finishBubble(a, bubble, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
2972
3109
  }, HARD_TIMEOUT_MS);
2973
- const seal = { keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
3110
+ const seal = { eventId, keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
2974
3111
  // 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
2975
3112
  // 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
2976
3113
  // 先建好, ack 里的链接点得开。斜杠命令的行未必露面, 当场接。
2977
3114
  const queued = !isSlash && idleVerdict(a.target) === false;
2978
3115
  pushSeal(a, seal);
2979
3116
  if (queued)
2980
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, channel: channel ?? baseOfKey(a.target), speaker });
3117
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, eventId, createdAt: eventId ? deps.events?.get(eventId)?.originalAt : undefined, channel: channel ?? baseOfKey(a.target), speaker });
2981
3118
  else
2982
3119
  activateDispatch(a, seal);
2983
- // 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
2984
- // closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
2985
- // 覆盖消息)。旧 turn 在 CLI 侧的剩余产出自然流入新气泡 —— "新回复走最新气泡"
2986
- // 正是这个语义。代价是旧 turn 页提前标完、尾部 item 记到新 turn 名下, 但远小于
2987
- // 排队的代价: 排队 turn 的 frame 在前一个长 turn 期间 (可达数分钟) 过期, 激活后
2988
- // replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。所以只在前面真有别人的行排着时
2989
- // 才排 (见上): 接上时气泡若已过期, 正文走 standalone (concludeBriefTurn)。
3120
+ // Durable normal inputs arrive only after the preceding full turn; expired bubbles use the final outbox standalone path.
2990
3121
  // ack 即详情链接。URL 的三个入参 (turnId / target / host) 在收消息这一刻全部已知 ——
2991
3122
  // turnId 是本地生成的, 详情页记录也已在 recordTurnStart 建好, 所以不必等 CLI 产出,
2992
3123
  // 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
@@ -3005,8 +3136,11 @@ export const startMirror = (deps) => {
3005
3136
  if (a.channelSeeded)
3006
3137
  return false;
3007
3138
  const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
3008
- const said = head(a.jsonlPath ? lastUserTurn(a.jsonlPath)?.text : undefined);
3009
- const r = said ? openTurnsOf(a.target, a.sessionId).find((t) => head(t.userQuery) === said) : undefined;
3139
+ const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
3140
+ const said = head(opening?.text);
3141
+ const eventId = opening?.env?.eventId;
3142
+ const r = openTurnsOf(a.target, a.sessionId).find((t) => eventId ? t.eventId === eventId : !!said && head(t.userQuery) === said);
3143
+ a.activeEventId = eventId;
3010
3144
  if (!r)
3011
3145
  return false;
3012
3146
  a.channelSeeded = true;
@@ -3016,7 +3150,6 @@ export const startMirror = (deps) => {
3016
3150
  a.channel = r.channel === undefined || r.channel === baseOfKey(a.target) ? undefined : r.channel;
3017
3151
  a.speaker = r.speaker;
3018
3152
  a.convFrom = r.from;
3019
- a.activePeerTurn = r.from?.kind === "peer" ? r.from.turn : undefined;
3020
3153
  a.replyTo = publicPeer(r.from);
3021
3154
  a.turnFromChat = a.turnFromChat ?? true;
3022
3155
  a.briefTurnId = r.id;
@@ -3052,6 +3185,8 @@ export const startMirror = (deps) => {
3052
3185
  const from = fresh ?? a.convFrom;
3053
3186
  recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: query || opened || undefined, origin: consumeOrigin(a), from, ...channelFields(a), ...(query && at ? { createdAt: at } : {}) });
3054
3187
  a.briefTurnId = turnId;
3188
+ if (a.activeEventId)
3189
+ deps.events?.bind(a.activeEventId, turnId);
3055
3190
  a.briefBubble = undefined;
3056
3191
  a.briefIsSlash = false;
3057
3192
  a.briefHadTool = false;
@@ -3094,28 +3229,41 @@ export const startMirror = (deps) => {
3094
3229
  return;
3095
3230
  if (!body.trim())
3096
3231
  return;
3097
- if (isDuplicateConclusion(a, body)) {
3232
+ if (!a.activeEventId && isDuplicateConclusion(a, body)) {
3098
3233
  a.briefConcluded = true; // 本轮已"定稿", 只是不投递
3099
3234
  log.info({ sessionId: a.sessionId, turnId, epoch: a.queryEpoch ?? 0 }, "brief: duplicate conclusion suppressed");
3100
3235
  return;
3101
3236
  }
3237
+ const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
3238
+ if (event)
3239
+ deps.events.result(event.id, body, finalId(event.id, event.channel, body), turnId);
3240
+ const gated = !!event && !eventFinalGate(event);
3241
+ if (!turnSilent(a) && !gated) {
3242
+ if (event)
3243
+ recoverEventFinal(deps.events.get(event.id), a.briefBubble);
3244
+ else
3245
+ queueFinal(a.target, turnId, a.channel ?? baseOfKey(a.target), body, undefined, a.briefBubble);
3246
+ }
3247
+ else if (gated && !turnSilent(a)) {
3248
+ // 链式收口 (eventFinalGate: 子活未落定) 延后的是 closing 的释放, 不该扣住气泡:
3249
+ // queueFinal 没跑 = 没人收气泡, 它会定格在最后一行 CoT 进度上 (看着像卡在 tool
3250
+ // use), hardTimer 到点的空 finish 又被 chat-gate 当 empty push 丢掉。气泡还在
3251
+ // stream 窗口内就把终句刷进气泡 (`链接 正文` 覆盖, 与正常收口同形); 气泡已收 /
3252
+ // 过期 / 终句超单条上限则 standalone 展示终句。
3253
+ const b = a.briefBubble;
3254
+ const text = stripForChat(body); // 同 queueFinal: 群气泡不带收口标记 / ARTIFACT 行
3255
+ const fitsOneChunk = splitMarkdown(text, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET)).length === 1;
3256
+ if (b && !b.done && fitsOneChunk && Date.now() < (b.expires ?? 0))
3257
+ void finishBriefBubble(a, text);
3258
+ else if (text.trim())
3259
+ sendStandalone(a, text);
3260
+ }
3261
+ // The durable result/outbox must exist before this in-memory dedup bit advances.
3102
3262
  a.briefConcluded = true;
3103
3263
  a.lastConcludedBody = body;
3104
3264
  a.lastConcludedEpoch = a.queryEpoch ?? 0;
3105
3265
  a.lastConcludedAt = Date.now();
3106
- if (answerSilent(a)) {
3107
- log.info({ target: a.target, turnId }, "answer transferred; source conclusion suppressed");
3108
- }
3109
- else if (a.briefBubble && !a.briefBubble.done) {
3110
- void finishBriefBubble(a, briefLine(a, turnId, linkTags(a.target, body)), true);
3111
- }
3112
- else if (turnSilent(a)) {
3113
- // CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
3114
- log.info({ sessionId: a.sessionId, turnId }, "brief: CLI-origin conclusion kept out of chat");
3115
- }
3116
- else {
3117
- sendStandalone(a, body); // 详情链接由 withLinkedTag 统一前缀, 不在这里拼
3118
- }
3266
+ clearCot(a);
3119
3267
  // 只保留当前 turn: 其余仍开着的 turn 记录是漏收的 close, 一并扫掉。
3120
3268
  recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: [turnId] });
3121
3269
  };
@@ -3148,6 +3296,8 @@ export const startMirror = (deps) => {
3148
3296
  voidTurnAgents(a);
3149
3297
  closeSubagentTurns(a); // 父 turn 收口 = 它派出的 subagent 都已结束
3150
3298
  clearCot(a);
3299
+ // 它闲下来了: 收件箱里等它闲的回执 / 派活此刻就该贴, 不等下一拍定时泵。
3300
+ void deps.events?.pump();
3151
3301
  };
3152
3302
  // ── Subagent turns ─────────────────────────────────────────────────
3153
3303
  // Task/Agent 工具派出的子 agent 在自己的转录文件里跑 (主 jsonl 只有派发与最终
@@ -3236,10 +3386,10 @@ export const startMirror = (deps) => {
3236
3386
  closeSubagentRun(a, agentId);
3237
3387
  return;
3238
3388
  }
3239
- // brief 气泡的实时进度: 静默窗 (keepalive 吞没 / 新 pane 未注入) 不推;
3389
+ // brief 气泡的实时进度: 静默窗 (新 pane 未注入) 不推;
3240
3390
  // updateBriefProgress 自身会检查气泡是否仍开。final text 之后的 end 会很快
3241
3391
  // 收口气泡, 进度行不会残留。
3242
- if (a.muteUntilInject || a.keepaliveQuiet || a.keepaliveByContent)
3392
+ if (a.muteUntilInject)
3243
3393
  return;
3244
3394
  const activity = item.kind === "tool_use"
3245
3395
  ? cotToolLabel(item.calls)
@@ -3341,7 +3491,7 @@ export const startMirror = (deps) => {
3341
3491
  const now = Date.now();
3342
3492
  if (item.kind === "tool_use") {
3343
3493
  markBriefTool(a);
3344
- updateBriefProgress(a, cotToolLabel(item.calls));
3494
+ updateBriefToolProgress(a, cotToolLabel(item.calls));
3345
3495
  for (const c of item.calls) {
3346
3496
  // 也走单卡 detail record — turn 页里 tool_use 段可点开链到独立详情 (以后有需要时)。
3347
3497
  if (c.toolUseId) {
@@ -3382,6 +3532,8 @@ export const startMirror = (deps) => {
3382
3532
  return;
3383
3533
  }
3384
3534
  if (item.kind === "turn_end") {
3535
+ if (a.activeEventId && !item.soft)
3536
+ deps.events?.finished(a.activeEventId);
3385
3537
  closeBriefTurn(a);
3386
3538
  return;
3387
3539
  }
@@ -3529,70 +3681,15 @@ export const startMirror = (deps) => {
3529
3681
  if (a.liveStream && !a.liveStream.closed)
3530
3682
  void finalizeStream(a, a.liveStream);
3531
3683
  };
3532
- // End the keepalive swallow — the ping turn is over (turn_end / genuine
3533
- // user line / inject failure), so real items flow through onItem again.
3534
- // Clears BOTH the timer window and the content flag.
3535
- const endKeepaliveSwallow = (a) => {
3536
- if (a.keepaliveQuiet) {
3537
- clearTimeout(a.keepaliveQuiet);
3538
- a.keepaliveQuiet = undefined;
3539
- }
3540
- if (a.keepaliveContentTimer) {
3541
- clearTimeout(a.keepaliveContentTimer);
3542
- a.keepaliveContentTimer = undefined;
3543
- }
3544
- a.keepaliveByContent = undefined;
3545
- };
3546
- // Content-based swallow entry: the tailed user line IS a configured ping.
3547
- // Belt to the inject-time timer window — this one keys on the user message
3548
- // itself, so it holds across daemon reloads, tail replays and replies
3549
- // slower than the 60s fail-safe. Reuses the detail turn the timer flow may
3550
- // have opened (fireKeepalive skips opening when this got there first).
3551
- const KEEPALIVE_CONTENT_FAILSAFE_MS = 5 * 60_000;
3552
- const PONG_RE = /pong/i;
3553
- const beginKeepaliveByContent = (a, text) => {
3554
- a.keepaliveByContent = true;
3555
- if (a.keepaliveContentTimer)
3556
- clearTimeout(a.keepaliveContentTimer);
3557
- // Fail-safe: a ping turn whose reply never emits turn_end (crash/hang)
3558
- // must not mute a later real turn forever.
3559
- a.keepaliveContentTimer = setTimeout(() => {
3560
- a.keepaliveContentTimer = undefined;
3561
- a.keepaliveByContent = undefined;
3562
- }, KEEPALIVE_CONTENT_FAILSAFE_MS);
3563
- if (!a.keepaliveTurnId) {
3564
- const turnId = newTurnId();
3565
- a.keepaliveTurnId = turnId;
3566
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
3567
- }
3568
- };
3569
- // A keepalive ping turn is swallowed wholesale — the reply content doesn't
3570
- // matter (model may add extra commentary beyond "pong", that's fine).
3571
3684
  const onItem = (a, item) => {
3572
3685
  // 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
3573
3686
  if (a.muteUntilInject)
3574
3687
  return;
3575
- // Keepalive ping turns are cache-warmers: swallow every item from every
3576
- // WeCom path so the ping/pong never reaches chat. Two triggers, either
3577
- // suffices — the timer window opened at inject (keepaliveQuiet) OR the
3578
- // user line itself matching a configured ping (keepaliveByContent; the
3579
- // reload/replay/slow-reply-proof one). The REAL exchange is recorded
3580
- // into its chat-detail turn — the actual assistant reply, the tool calls
3581
- // if any, and the usage — so the detail page shows the genuine heartbeat.
3582
- if (item.kind === "keepalive_start") {
3583
- beginKeepaliveByContent(a, item.body);
3584
- return;
3585
- }
3586
- // A genuine user line (not our inject, not a ping) is a hard turn
3587
- // boundary — any keepalive swallow still holding never got its turn_end
3588
- // (crashed reply). Release BEFORE the swallow gate so this real line (and
3589
- // its reply) mirror normally instead of being eaten by the stale swallow.
3590
3688
  if (item.kind === "user_text") {
3591
3689
  // 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
3592
3690
  // 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
3593
- if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts))
3691
+ if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts, item.eventId))
3594
3692
  return;
3595
- endKeepaliveSwallow(a);
3596
3693
  // 对话边界 = 旧 turn 清算。上一 turn 派发而未返回的前台 Agent/Task 到此作废,
3597
3694
  // 清掉防残留阻塞新 turn 的软收口 (guard 的 defer 判断依据)。
3598
3695
  voidTurnAgents(a);
@@ -3602,22 +3699,22 @@ export const startMirror = (deps) => {
3602
3699
  // 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
3603
3700
  a.pendingBriefQuery = item.body;
3604
3701
  a.pendingBriefAt = item.ts;
3605
- a.activePeerTurn = undefined;
3606
3702
  a.pendingFrom = undefined;
3607
3703
  a.pendingChannel = undefined;
3704
+ a.activeEventId = item.eventId;
3608
3705
  a.pendingFromCli = true;
3609
3706
  if (!cfg.wrc.mirror.brief)
3610
3707
  consumeChannel(a, true, undefined);
3611
3708
  }
3612
3709
  if (item.kind === "user_inject") {
3613
- interject(a, item.said, item.ts);
3710
+ interject(a, item.said, item.ts, item.eventId);
3614
3711
  return;
3615
3712
  }
3616
3713
  // 还有一轮等着懒建 (peer / graph 注入先记了 pendingBriefQuery) 就换掉它; 否则这句话
3617
3714
  // 就是已开好的那一轮 (IM 侧说的, startBriefTurn 在注入前建好)。
3618
3715
  if (item.kind === "user_query") {
3619
3716
  // 同伴 / 定时 / graph 注入的那一行: 问话就是印章里的原话 (不带信封)。
3620
- const sealed = bindLine(a, item.said, item.ts);
3717
+ const sealed = bindLine(a, item.said, item.ts, item.eventId);
3621
3718
  if (sealed && !sealed.turn)
3622
3719
  return;
3623
3720
  if (a.pendingBriefQuery !== undefined || !a.briefTurnId) {
@@ -3629,46 +3726,9 @@ export const startMirror = (deps) => {
3629
3726
  recordTurnQuery(a.briefTurnId, item.body, (prev) => cleanUserText(prev ?? "") === item.said);
3630
3727
  return;
3631
3728
  }
3632
- // 后台派发的结束信号 —— 它不是 function_call_result (那只是 spawn 句柄)。放在
3633
- // keepalive 门之前: 吞没窗口里到达也必须销账, 否则账本永久悬空。
3729
+ // 后台派发的结束信号 —— 它不是 function_call_result (那只是 spawn 句柄)。
3634
3730
  if (item.kind === "skill_output" && item.agentDone)
3635
3731
  clearBackgroundAgent(a, item.agentDone);
3636
- // Reply-side belt: a "pong" arriving with NO swallow holding. Both triggers
3637
- // above live in memory, and the tail re-attaches at EOF — a reload between
3638
- // the ping and its reply forgets the ping was ever sent, and the pong opens
3639
- // a chat turn of its own. So ask the transcript: if the last thing said to
3640
- // the model is a keepalive ping, this text is its pong — swallow from here
3641
- // to the turn_end, recorded as the same marked detail turn. Gated on the
3642
- // transcript, not on the word alone: a real answer that mentions pong has a
3643
- // real question in front of it.
3644
- if (item.kind === "text" && !a.keepaliveQuiet && !a.keepaliveByContent && PONG_RE.test(item.body)) {
3645
- const ping = openKeepalivePing(a.jsonlPath, pingSigs);
3646
- if (ping)
3647
- beginKeepaliveByContent(a, ping);
3648
- }
3649
- if (a.keepaliveQuiet || a.keepaliveByContent) {
3650
- const id = a.keepaliveTurnId;
3651
- if (id) {
3652
- const now = Date.now();
3653
- if (item.kind === "text") {
3654
- recordTurnItem(id, { t: "text", body: item.body, ts: now, final: item.final === true });
3655
- }
3656
- else if (item.kind === "tool_result") {
3657
- recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
3658
- }
3659
- else if (item.kind === "turn_usage") {
3660
- recordTurnUsage(id, { model: item.model, effort: item.effort, messageId: item.messageId, usage: item.usage });
3661
- }
3662
- else if (item.kind === "turn_end") {
3663
- recordTurnClose(id);
3664
- a.keepaliveTurnId = undefined;
3665
- }
3666
- }
3667
- if (item.kind === "turn_end") {
3668
- endKeepaliveSwallow(a);
3669
- }
3670
- return;
3671
- }
3672
3732
  // 父 agent 自己的新产出到达 = 上一条"消息写完了"并不代表这一轮结束 → 撤销待确认
3673
3733
  // 的软收口。硬信号 (end_turn / turn_duration) 的后端永远不会走到这里。
3674
3734
  //
@@ -3692,7 +3752,6 @@ export const startMirror = (deps) => {
3692
3752
  // 的配置下, 人在 CLI 敲的那一行就这么原样回显进了群, 正好破了 chatOriginOnly。
3693
3753
  if (item.kind === "user_text" && (item.quiet || cfg.wrc.mirror.brief))
3694
3754
  return;
3695
- updateAnswerProgress(a, item);
3696
3755
  // thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
3697
3756
  // 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
3698
3757
  // 那就变回已下线的 thinkStyle 了。
@@ -3840,6 +3899,19 @@ export const startMirror = (deps) => {
3840
3899
  }
3841
3900
  }
3842
3901
  }
3902
+ if (!cfg.wrc.mirror.brief && item.kind === "text" && item.final !== false && a.activeEventId && deps.events) {
3903
+ const e = deps.events.get(a.activeEventId);
3904
+ if (e) {
3905
+ // Soft final text waits for transcript/idle evidence; it must not escape through the legacy stream.
3906
+ if (item.final !== true)
3907
+ return;
3908
+ const turn = a.liveStream?.turnId ?? e.id;
3909
+ deps.events.result(e.id, item.body, finalId(e.id, e.channel, item.body), turn);
3910
+ if (eventFinalGate(e))
3911
+ recoverEventFinal(deps.events.get(e.id));
3912
+ return;
3913
+ }
3914
+ }
3843
3915
  if (!cfg.wrc.mirror.brief && isPrivate(a))
3844
3916
  return;
3845
3917
  // /goal mode overrides everything below: the session-scoped Stop hook
@@ -4003,7 +4075,7 @@ export const startMirror = (deps) => {
4003
4075
  const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
4004
4076
  ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
4005
4077
  : "";
4006
- const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, effort, pendingCwd, keepaliveDisabled }) => {
4078
+ const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, effort, pendingCwd }) => {
4007
4079
  const target = resolveTarget(targetOverride);
4008
4080
  if (!target)
4009
4081
  return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
@@ -4034,9 +4106,6 @@ export const startMirror = (deps) => {
4034
4106
  // onto an already-running pane doesn't, so it keeps what was recorded.
4035
4107
  const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
4036
4108
  const carryEffort = effort !== undefined ? effort.trim() : (prevByTarget?.effort ?? deps.store.get(target)?.effort ?? "");
4037
- // Same carry rule again: an explicit spawn-time choice wins, otherwise this
4038
- // wizard's existing policy (in-memory, or on disk across a reload) sticks.
4039
- const carryKeepaliveDisabled = keepaliveDisabled !== undefined ? keepaliveDisabled : (prevByTarget?.keepaliveDisabled ?? deps.store.get(target)?.keepaliveDisabled ?? false);
4040
4109
  if (prevByTarget)
4041
4110
  detach(prevByTarget, "target reassigned");
4042
4111
  // Build the attachment first so the tail's onItem closure can capture it.
@@ -4053,7 +4122,6 @@ export const startMirror = (deps) => {
4053
4122
  pendingCwd: carryPending,
4054
4123
  model: carryModel,
4055
4124
  effort: carryEffort,
4056
- keepaliveDisabled: carryKeepaliveDisabled,
4057
4125
  tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
4058
4126
  standalonePending: Promise.resolve(),
4059
4127
  recentToolSigs: new Map(),
@@ -4068,7 +4136,6 @@ export const startMirror = (deps) => {
4068
4136
  toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
4069
4137
  isOwnInject,
4070
4138
  isOwnAssistantSend,
4071
- isKeepalivePing,
4072
4139
  onItem: (item) => onItem(a, item),
4073
4140
  onCompact: () => contextCut(a.target, "compact"),
4074
4141
  detailUrlFor,
@@ -4081,9 +4148,6 @@ export const startMirror = (deps) => {
4081
4148
  startSubagentsFor(a);
4082
4149
  bySessionId.set(sessionId, a);
4083
4150
  byTarget.set(target, a);
4084
- // Preserve a persisted `/stop` pause across the re-attach: restore rebuilds the
4085
- // record here, and dropping keepaliveOff would resurrect a session the user
4086
- // explicitly quieted. restoreFromStore re-hydrates the in-memory flags below.
4087
4151
  const prevRec = deps.store.get(target);
4088
4152
  deps.store.set(target, {
4089
4153
  sessionId,
@@ -4095,9 +4159,6 @@ export const startMirror = (deps) => {
4095
4159
  effort: a.effort || undefined,
4096
4160
  pendingCwd: a.pendingCwd || undefined,
4097
4161
  cwdConfirmed: prevRec?.cwdConfirmed,
4098
- keepaliveOff: prevRec?.keepaliveOff,
4099
- keepaliveOffAt: prevRec?.keepaliveOffAt,
4100
- keepaliveDisabled: carryKeepaliveDisabled || undefined,
4101
4162
  });
4102
4163
  log.info({ sessionId, jsonlPath, target, tmuxSession: a.tmuxSession, runningCwd: a.runningCwd, pendingCwd: a.pendingCwd, mirrors: bySessionId.size }, "mirror attached");
4103
4164
  return { ok: true, sessionId, jsonlPath, target };
@@ -4110,16 +4171,14 @@ export const startMirror = (deps) => {
4110
4171
  // the pane is alive — and tmux pane ids monotonically increment within a
4111
4172
  // server lifetime, so a freed id will not be silently reused. We don't need
4112
4173
  // the session name, which matters because some attaches carry only a pane id.
4113
- const tmuxPaneAlive = async (paneId) => {
4114
- if (!paneId)
4115
- return false;
4116
- const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
4117
- return r.code === 0 && r.stdout.trim() === paneId;
4118
- };
4119
- // 注入前的存活判定要多问一句「里面还有 AI 会话吗」: pane 在、claude 却退回了 shell,
4174
+ // 布尔形式留给只问「这个 pane 还在不在」的调用点 (submitPane / 忙闲判定): 三态里的
4175
+ // `unknown` 仍旧是 false —— 它们不动会话, 判错没有副作用。决定要不要重生 pane 的地方
4176
+ // 必须自己看 `probePane` 的三态, 见 injectText / inject job。
4177
+ const tmuxPaneAlive = async (paneId) => (await paneLiveness(paneId)) === "alive";
4178
+ // 注入前的存活判定要多问一句「里面还有 AI 会话吗」: pane 在、CLI 却退回了 shell,
4120
4179
  // 照旧往里贴就是把用户的话当 shell 命令跑。判否即走 respawn (旧 pane 不杀 —— 它可能是
4121
4180
  // 用户自己的终端, 经 switch 绑进来的)。
4122
- const paneUsable = async (paneId, jsonlPath) => (await tmuxPaneAlive(paneId)) && (await paneHostsSession(paneId, backendForPath(jsonlPath).homeDir));
4181
+ const paneUsable = async (paneId, jsonlPath) => (await probePane(paneId, jsonlPath)).state === "session";
4123
4182
  // Re-attach a stored binding for `principal`. Returns the resulting state, or
4124
4183
  // undefined if the on-disk transcript is gone (in which case the entry is
4125
4184
  // dropped so the next inbound flows through /new auto-spawn).
@@ -4134,6 +4193,9 @@ export const startMirror = (deps) => {
4134
4193
  // 文件。同一个 cwd 下别的 target 的迁移 / 自愈 / 分叉跟随也在扫这个目录里「新出现的
4135
4194
  // jsonl」, 不挡住的话它会先一步把别人的新会话认走, 正主 attach 时反撞 "already bound"。
4136
4195
  const inFlightSids = new Set();
4196
+ // 已 spawn、还没 attach 的 pane: 分身要先说开场白、等分叉文件才能绑定, 这段里它在
4197
+ // tmux 里活着却不在任何绑定上 —— 孤儿清扫必须认得它, 不能只靠进程年龄宽限。
4198
+ const spawningPanes = new Set();
4137
4199
  const sidsClaimedByOthers = (principal) => {
4138
4200
  const s = new Set(inFlightSids);
4139
4201
  for (const [k, v] of Object.entries(deps.store.all()))
@@ -4261,25 +4323,10 @@ export const startMirror = (deps) => {
4261
4323
  log.warn({ principal, reason: r.reason }, "mirror restore: re-attach failed");
4262
4324
  return undefined;
4263
4325
  }
4264
- // Re-hydrate the `/stop` pause so a reload doesn't resume pinging a quieted
4265
- // session (attach preserved it on disk; this puts it back in memory).
4266
- // keepaliveDisabled is carried by attach() itself (same rule as `model`);
4267
- // only the transient `/stop` pause needs manual re-hydration here.
4268
4326
  const restored = byTarget.get(principal);
4269
- if (restored && rec.keepaliveOff) {
4270
- restored.keepaliveOff = true;
4271
- restored.keepaliveOffAt = rec.keepaliveOffAt;
4272
- }
4273
4327
  log.info({ principal, sessionId: rec.sessionId, livePane: livePane || "(spawn-mode)" }, "mirror restored from store");
4274
4328
  return restored;
4275
4329
  };
4276
- // Write the live `/stop` pause state through to the store (merging, not
4277
- // clobbering, the rest of the record) so it survives a daemon reload.
4278
- const persistPause = (a) => {
4279
- const rec = deps.store.get(a.target);
4280
- if (rec)
4281
- deps.store.set(a.target, { ...rec, keepaliveOff: a.keepaliveOff, keepaliveOffAt: a.keepaliveOffAt, keepaliveDisabled: a.keepaliveDisabled });
4282
- };
4283
4330
  // We re-attach lazily on demand too (see restoreFromStore in dispatch /
4284
4331
  // hasMirrorTarget), but eager boot-restore makes outbound (tail → push) work
4285
4332
  // even before any inbound arrives — e.g. claude finishes a long-running
@@ -4488,15 +4535,6 @@ export const startMirror = (deps) => {
4488
4535
  markCut(a.target, newSessionId, cleared ? "clear" : "switch");
4489
4536
  if (cleared)
4490
4537
  contextCut(a.target, "clear");
4491
- if (cleared) {
4492
- // 真清空 = 缓存里已无任何值得保温的内容。像 /stop 一样暂停保活 —— 与
4493
- // dispatch 里 WeCom 注入 /clear 的暂停对齐;TUI 手打 /clear 只走这个漏斗,
4494
- // 不在这里停下的话旧时钟会继续 ping 一个空上下文。busy-resume(过 grace)
4495
- // 或真实 inbound 解除。下方 store.set 一并落盘。
4496
- a.keepaliveOff = true;
4497
- a.keepaliveOffAt = Date.now();
4498
- a.keepalive = undefined;
4499
- }
4500
4538
  a.tail.stop();
4501
4539
  bySessionId.delete(oldSessionId);
4502
4540
  a.sessionId = newSessionId;
@@ -4512,7 +4550,6 @@ export const startMirror = (deps) => {
4512
4550
  toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
4513
4551
  isOwnInject,
4514
4552
  isOwnAssistantSend,
4515
- isKeepalivePing,
4516
4553
  onItem: (item) => onItem(a, item),
4517
4554
  onCompact: () => contextCut(a.target, "compact"),
4518
4555
  detailUrlFor,
@@ -4532,10 +4569,6 @@ export const startMirror = (deps) => {
4532
4569
  tmuxPane: a.tmuxPane || undefined,
4533
4570
  cwd: a.runningCwd || undefined,
4534
4571
  pendingCwd: a.pendingCwd || undefined,
4535
- // store.set 是整记录替换 —— 必须带上暂停态,否则迁移会把 dispatch 刚落盘
4536
- // 的 /clear 暂停(或之前的 /stop 暂停)从盘上抹掉,reload 后保活复活。
4537
- keepaliveOff: a.keepaliveOff || undefined,
4538
- keepaliveOffAt: a.keepaliveOffAt || undefined,
4539
4572
  });
4540
4573
  log.info({ target: a.target, oldSessionId, newSessionId, oldJsonlPath, newJsonlPath, startOffset }, "mirror migrated session");
4541
4574
  };
@@ -4769,6 +4802,10 @@ export const startMirror = (deps) => {
4769
4802
  // different project dir whose live session has a different sid, migrate onto
4770
4803
  // it. Tail from EOF (undefined startOffset): the worktree session already
4771
4804
  // carries full history we don't want to re-dump to WeCom.
4805
+ //
4806
+ // 同目录的轮换 (人在 TUI 里敲 `/clear`、或 CLI 自己换了会话) 走下面同一张表的 sameDir
4807
+ // 分支 —— 目录没变, 但「旧 jsonl 从此没人写」的后果一模一样, 而它比 worktree 常见得多
4808
+ // (人就在 pane 里敲命令)。
4772
4809
  let paneDriftTicking = false;
4773
4810
  const followPaneDrift = async () => {
4774
4811
  if (paneDriftTicking)
@@ -4804,8 +4841,26 @@ export const startMirror = (deps) => {
4804
4841
  continue;
4805
4842
  }
4806
4843
  const paneDir = projectDirFor(a.jsonlPath, expandHome(cwd));
4807
- if (backendForPath(a.jsonlPath).name === "codex" ? expandHome(cwd) === a.runningCwd : paneDir === dirname(a.jsonlPath))
4808
- continue; // same project dir — no drift
4844
+ const sameDir = backendForPath(a.jsonlPath).name === "codex" ? expandHome(cwd) === a.runningCwd : paneDir === dirname(a.jsonlPath);
4845
+ if (sameDir) {
4846
+ // 同一个项目目录里的会话轮换: 人在 TUI 里直接敲了 `/clear` (或 CLI 自己换了会话)。
4847
+ // 目录没变, 下面那条按目录判的漂移看不见它, 结果一样是「旧 jsonl 再没人写, 气泡
4848
+ // 永远停在最后一格」, 而每次 reload 的 boot 恢复都会把这份死绑定再恢复一遍。
4849
+ // 判据只认**注册表按 pane 报的会话** —— 只有写 `tmux` 字段的后端有这条路。
4850
+ // codebuddy 没有: 它的注册行里 sessionId 是续接链的根, 换过 sid 后不更新 (实测
4851
+ // pid 84679 报 3d51b76b 而进程实际在写 01a13ae5), 沿进程树只能认出「有活的 CLI」
4852
+ // 而不是「哪份转录」; 而「目录里最新的是谁」在共用 cwd 下会把别人的转录认到自己
4853
+ // 身上。两条都不能用, 所以这里对 codebuddy 什么都不做 —— 认不出就不动。
4854
+ const liveOnPane = sessionOnPane(backendForPath(a.jsonlPath).homeDir, a.tmuxPane);
4855
+ if (!liveOnPane || liveOnPane.sessionId === a.sessionId || sidsClaimedByOthers(a.target).has(liveOnPane.sessionId))
4856
+ continue;
4857
+ // 从 EOF 起读 (不给 startOffset): 轮换之后新文件已经自己长了一段 (轮换那一刻到这一
4858
+ // tick 之间人敲进去的话), 从 0 重读等于把整段会话倒进聊天窗口。补不上接缝,
4859
+ // 但从此往后是通的 —— 比一直不通强。
4860
+ log.info({ target: a.target, pane: a.tmuxPane, from: a.sessionId, to: liveOnPane.sessionId }, "mirror: pane 里的会话轮换 (同目录), 跟随它");
4861
+ migrateAttachment(a, liveOnPane.sessionId, join(dirname(a.jsonlPath), `${liveOnPane.sessionId}.jsonl`));
4862
+ continue;
4863
+ }
4809
4864
  const live = liveSessionForCwd(cwd, backendForPath(a.jsonlPath), sidsClaimedByOthers(a.target));
4810
4865
  if (!live || live.sessionId === a.sessionId)
4811
4866
  continue; // empty dir, or same-sid rename (tail follows it)
@@ -4946,7 +5001,7 @@ export const startMirror = (deps) => {
4946
5001
  cfg,
4947
5002
  log: log.child({ sub: "new-session", target }),
4948
5003
  sessionId: freshSid,
4949
- windowName: windowName ?? target,
5004
+ windowName: windowLabel(target, windowName),
4950
5005
  cwdOverride: eff,
4951
5006
  cli: effCli,
4952
5007
  model: effModel,
@@ -4978,7 +5033,6 @@ export const startMirror = (deps) => {
4978
5033
  // raw request — a later respawn's fast path then matches on try one.
4979
5034
  model: r.model ?? effModel ?? "",
4980
5035
  effort: r.effort ?? "",
4981
- keepaliveDisabled: opts?.keepalive === undefined ? undefined : !opts.keepalive,
4982
5036
  });
4983
5037
  inFlightSids.delete(freshSid);
4984
5038
  if (!att.ok)
@@ -4992,14 +5046,6 @@ export const startMirror = (deps) => {
4992
5046
  // 只有换过 pane 才是断点 —— 首次建会话时 prev 为空, 那不是"不连续", 是开局。
4993
5047
  if (prev)
4994
5048
  markCut(target, spawned.sessionId, "new");
4995
- // A freshly spawned session is empty — nothing in the cache to keep warm.
4996
- // Pause keepalive like /stop; the first real turn (WeCom inbound, or the
4997
- // pane going busy after the resume grace) re-earns the budget. Mirrors
4998
- // interruptPane so /new can't strand a ping loop on an idle blank session.
4999
- spawned.keepaliveOff = !opts?.warm;
5000
- spawned.keepaliveOffAt = Date.now();
5001
- spawned.keepalive = undefined; // re-anchors cleanly on resume
5002
- persistPause(spawned);
5003
5049
  }
5004
5050
  // Clear the chat's pendingCwd on the BASE record too — the queued switch
5005
5051
  // has just been consumed by this respawn. Without this, a subsequent /new
@@ -5089,7 +5135,7 @@ export const startMirror = (deps) => {
5089
5135
  const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
5090
5136
  if (!canInherit) {
5091
5137
  const r = await newSession(args.target, args.windowName, args.cli, {
5092
- model: args.model, effort: args.effort, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive, guard: args.guard ? () => args.guard("paste") : undefined,
5138
+ model: args.model, effort: args.effort, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, guard: args.guard ? () => args.guard("paste") : undefined,
5093
5139
  });
5094
5140
  return { ...r, inherited: false };
5095
5141
  }
@@ -5108,7 +5154,7 @@ export const startMirror = (deps) => {
5108
5154
  log: lg,
5109
5155
  resumeSessionId: parent.sessionId,
5110
5156
  forkSession: true,
5111
- windowName: args.windowName ?? args.target,
5157
+ windowName: windowLabel(args.target, args.windowName),
5112
5158
  cwdOverride: parent.runningCwd,
5113
5159
  cli: parentCli,
5114
5160
  model,
@@ -5117,51 +5163,61 @@ export const startMirror = (deps) => {
5117
5163
  });
5118
5164
  if (!r.ok)
5119
5165
  return { ok: false, reason: r.reason, inherited: false };
5120
- // 分叉文件不是开机就有的: CLI 在这个 pane **收到第一条消息**的那一刻才把父亲的
5121
- // transcript 复制成新文件。所以必须先说一句话, 分身才会有自己的 sid —— 这句话
5122
- // 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」)。
5123
- const hello = args.bootstrap?.trim()
5124
- || "(wezard 分身初始化)用一句话说明: 你是谁、从谁那里继承了哪些上下文、准备接什么活。不要复述这条指令。";
5125
- rememberInject(hello);
5126
- const boot = await inject({
5127
- text: hello, images: [], cfg, log: lg,
5128
- sessionId: parentCli === "codex" ? r.sessionId : parent.sessionId, jsonlPath: parentCli === "codex" ? r.jsonlPath : parent.jsonlPath,
5129
- tmuxTarget: r.tmuxPane, freshSpawn: true, guard: args.guard,
5130
- });
5131
- if (!boot.ok) {
5132
- await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
5133
- return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
5134
- }
5135
- const fork = parentCli === "codex" ? { sessionId: r.sessionId, jsonlPath: r.jsonlPath } : await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
5136
- homeDir: backendForPath(parent.jsonlPath).homeDir, pane: r.tmuxPane ?? "", parentSid: parent.sessionId,
5137
- });
5138
- // 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
5139
- // 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
5140
- // 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
5141
- if (!fork) {
5142
- await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
5143
- return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
5144
- }
5145
- if (args.guard && !args.guard("submit")) {
5166
+ // 开场白 + 等分叉期间 pane 活着却没绑定: 挂上 spawningPanes, 别被孤儿清扫当尸体收掉。
5167
+ const pane = r.tmuxPane ?? "";
5168
+ spawningPanes.add(pane);
5169
+ let att;
5170
+ let fork;
5171
+ try {
5172
+ // 分叉文件不是开机就有的: CLI 在这个 pane **收到第一条消息**的那一刻才把父亲的
5173
+ // transcript 复制成新文件。所以必须先说一句话, 分身才会有自己的 sid —— 这句话
5174
+ // 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」)。
5175
+ const hello = args.bootstrap?.trim()
5176
+ || "(wezard 分身初始化)用一句话说明: 你是谁、从谁那里继承了哪些上下文、准备接什么活。不要复述这条指令。";
5177
+ rememberInject(hello);
5178
+ const boot = await inject({
5179
+ text: hello, images: [], cfg, log: lg,
5180
+ sessionId: parentCli === "codex" ? r.sessionId : parent.sessionId, jsonlPath: parentCli === "codex" ? r.jsonlPath : parent.jsonlPath,
5181
+ tmuxTarget: r.tmuxPane, freshSpawn: true, guard: args.guard,
5182
+ });
5183
+ if (!boot.ok) {
5184
+ await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
5185
+ return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
5186
+ }
5187
+ const forked = parentCli === "codex" ? { sessionId: r.sessionId, jsonlPath: r.jsonlPath } : await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
5188
+ homeDir: backendForPath(parent.jsonlPath).homeDir, pane: r.tmuxPane ?? "", parentSid: parent.sessionId,
5189
+ });
5190
+ // 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
5191
+ // 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
5192
+ // 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
5193
+ if (!forked) {
5194
+ await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
5195
+ return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
5196
+ }
5197
+ fork = forked;
5198
+ if (args.guard && !args.guard("submit")) {
5199
+ inFlightSids.delete(fork.sessionId);
5200
+ // The bootstrap may already have executed; withhold binding, report the
5201
+ // unconfirmed birth to its caller instead of claiming cancellation success.
5202
+ return { ok: false, reason: "fork executed after permission withdrawal; reconcile pane manually", inherited: false };
5203
+ }
5204
+ att = attach({
5205
+ sessionId: fork.sessionId,
5206
+ jsonlPath: fork.jsonlPath,
5207
+ target: args.target,
5208
+ tmuxPane: r.tmuxPane,
5209
+ tmuxSession: r.tmuxSession,
5210
+ cwd: r.cwd,
5211
+ pendingCwd: "",
5212
+ model: r.model ?? model,
5213
+ effort: r.effort ?? "",
5214
+ });
5215
+ // 绑上 (或没绑上) 之后预留就没用了: 绑上的 sid 由 sidsClaimedByOthers 挡住别人。
5146
5216
  inFlightSids.delete(fork.sessionId);
5147
- // The bootstrap may already have executed; withhold binding, report the
5148
- // unconfirmed birth to its caller instead of claiming cancellation success.
5149
- return { ok: false, reason: "fork executed after permission withdrawal; reconcile pane manually", inherited: false };
5150
5217
  }
5151
- const att = attach({
5152
- sessionId: fork.sessionId,
5153
- jsonlPath: fork.jsonlPath,
5154
- target: args.target,
5155
- tmuxPane: r.tmuxPane,
5156
- tmuxSession: r.tmuxSession,
5157
- cwd: r.cwd,
5158
- pendingCwd: "",
5159
- model: r.model ?? model,
5160
- effort: r.effort ?? "",
5161
- keepaliveDisabled: args.keepalive === undefined ? undefined : !args.keepalive,
5162
- });
5163
- // 绑上 (或没绑上) 之后预留就没用了: 绑上的 sid 由 sidsClaimedByOthers 挡住别人。
5164
- inFlightSids.delete(fork.sessionId);
5218
+ finally {
5219
+ spawningPanes.delete(pane);
5220
+ }
5165
5221
  if (!att.ok)
5166
5222
  return { ok: false, reason: att.reason, inherited: false };
5167
5223
  const spawned = byTarget.get(args.target);
@@ -5169,10 +5225,6 @@ export const startMirror = (deps) => {
5169
5225
  // muteUntilInject 是给"全新 pane 的 greeting 噪音"准备的; 分身的开场白是正经
5170
5226
  // 内容 (rolepage 上它的第一轮), 所以这里不静音 —— 进不进群由下面的频道印章定。
5171
5227
  spawned.justSpawned = true;
5172
- spawned.keepaliveOff = true;
5173
- spawned.keepaliveOffAt = Date.now();
5174
- spawned.keepalive = undefined;
5175
- persistPause(spawned);
5176
5228
  // 开场白早在 attach 之前就落进分叉文件了 (tail 从 EOF 起, 读不到那一行): 第一轮懒建时
5177
5229
  // 才认得出它是谁派的活。印章赶在 tail 第一次 drain 之前盖上。
5178
5230
  if (args.bootstrapFrom) {
@@ -5402,18 +5454,40 @@ export const startMirror = (deps) => {
5402
5454
  };
5403
5455
  const paneOf = (target) => byTarget.get(target)?.tmuxPane || deps.store.get(target)?.tmuxPane || "";
5404
5456
  const jsonlOf = (target) => expandHome(byTarget.get(target)?.jsonlPath || deps.store.get(target)?.jsonlPath || "");
5405
- // Busy ≡ the pane is showing an interrupt hint. A dead or unbound pane is
5457
+ const inputSessionInfo = (target) => {
5458
+ const pane = paneOf(target);
5459
+ const transcript = jsonlOf(target);
5460
+ if (!pane || !transcript)
5461
+ return undefined;
5462
+ if (backendForPath(transcript).name === "codex") {
5463
+ const session = codexSessionForPane(pane);
5464
+ return session && { sessionId: session.sessionId, jsonlPath: session.jsonlPath };
5465
+ }
5466
+ const live = activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find((s) => s?.pane === pane);
5467
+ return live && { sessionId: live.sessionId, jsonlPath: join(dirname(transcript), `${live.sessionId}.jsonl`) };
5468
+ };
5469
+ // Busy comes from the native lifecycle or an interrupt hint. A dead/unbound pane is
5406
5470
  // reported idle, not busy: "nothing is running there" is the truthful answer
5407
5471
  // and it keeps a graph step from blocking forever on a session that vanished.
5408
5472
  const paneBusy = async (pane) => !!pane && (await tmuxPaneAlive(pane)) && paneIsBusy(await capturePaneTail(pane, 12));
5409
- const isBusy = (target) => paneBusy(paneOf(target));
5473
+ const isBusy = async (target) => {
5474
+ const pane = paneOf(target);
5475
+ return !!pane && (await tmuxPaneAlive(pane)) && (idleVerdict(target) === false || await paneBusy(pane));
5476
+ };
5410
5477
  // 「它这一轮结束了、可以投了」。注册表认得这个 pane 时以它为准: status 在整轮里
5411
5478
  // (工具调用之间、PreToolUse 审批 hook 挂着时) 一直是 busy, 只在轮结束那一刻翻成
5412
5479
  // idle —— 所以不必抽样 spinner, 也不必连数几次安静来排除工具间隙。idle 却悬着工具
5413
5480
  // 调用 = 停在本地审批框上等人点, 不算闲。注册表不认得 (codebuddy / 旧版 CC /
5414
5481
  // 会话已退出) 返回 undefined, 交给 pane 裁决。
5415
5482
  const idleVerdict = (target) => {
5483
+ const path = jsonlOf(target);
5416
5484
  const pane = paneOf(target);
5485
+ if (path && backendForPath(path).name === "codex") {
5486
+ const session = codexSessionForPane(pane);
5487
+ const busy = codexTurnBusy(path, session?.jsonlPath === path ? session.startedAt : 0);
5488
+ if (busy !== undefined)
5489
+ return !busy;
5490
+ }
5417
5491
  const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
5418
5492
  if (!live)
5419
5493
  return undefined;
@@ -5435,13 +5509,35 @@ export const startMirror = (deps) => {
5435
5509
  const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
5436
5510
  return live?.status === "idle" && hanging(target, live.sessionId);
5437
5511
  };
5438
- const idleNow = async (target) => idleVerdict(target) ?? !(await isBusy(target));
5512
+ const idleNow = async (target) => {
5513
+ // An interrupt footer vetoes stale idle records; never strip its dim text.
5514
+ if (await isBusy(target))
5515
+ return false;
5516
+ const registry = idleVerdict(target);
5517
+ if (registry !== undefined)
5518
+ return registry;
5519
+ const pane = paneOf(target);
5520
+ if (!pane || !(await tmuxPaneAlive(pane)))
5521
+ return false;
5522
+ const { cap, box } = await readPaneInput(pane);
5523
+ if (paneIsBusy(cap) || isModalPane(cap).modal)
5524
+ return false;
5525
+ // 注册表说不出 (codebuddy 的注册表没有 status) 时, 转录里这一轮紧跟正文落了轮终记录
5526
+ // (turn-metrics) 就是确定闲了, 不必再认出输入框 —— 只读屏曾把闲了 5 分钟的 owner 一直
5527
+ // 判成不闲, 回执卡在收件箱里直到它下一轮结束。CLI 退回 shell 的 pane 照旧不算。
5528
+ const path = jsonlOf(target);
5529
+ if (path && turnClosed(path))
5530
+ return !(await paneRunsShell(pane));
5531
+ return !!cap.trim() && box.known;
5532
+ };
5439
5533
  // 等到它闲下来, 事件驱动: 注册表目录一有写入就重判, 翻成 idle 的那一刻即返回。
5440
5534
  // 兜底轮询只防 watch 漏事件 (以及悬着的审批被点掉后 status 没再动的边角)。
5441
5535
  const IDLE_SAFETY_POLL_MS = 1000;
5442
5536
  const untilIdle = (target, timeoutMs, { aborted = () => false, confirm = 2 } = {}) => {
5537
+ // A vanished pane ends a wait, but must not become eligible for injection.
5538
+ const waiting = async (t) => !!paneOf(t) && (await tmuxPaneAlive(paneOf(t))) && !(await idleNow(t));
5443
5539
  if (idleVerdict(target) === undefined)
5444
- return waitForIdle(target, isBusy, timeoutMs, aborted, { rampMs: 0, confirm });
5540
+ return waitForIdle(target, waiting, timeoutMs, aborted, { rampMs: 0, confirm });
5445
5541
  return new Promise((resolve) => {
5446
5542
  let done = false;
5447
5543
  const finish = (r) => {
@@ -5461,8 +5557,7 @@ export const startMirror = (deps) => {
5461
5557
  finish({ idle: false, reason: "stopped" });
5462
5558
  return;
5463
5559
  }
5464
- const v = idleVerdict(target);
5465
- if (v ?? !(await isBusy(target)))
5560
+ if (!(await waiting(target)))
5466
5561
  finish({ idle: true });
5467
5562
  };
5468
5563
  const unwatch = activeBackends().map((b) => watchRegistry(b.homeDir, () => void check()));
@@ -5473,15 +5568,15 @@ export const startMirror = (deps) => {
5473
5568
  };
5474
5569
  const lastText = (target) => {
5475
5570
  const p = jsonlOf(target);
5476
- return p ? lastAssistantText(p, 4000, pingSigs) : "";
5571
+ return p ? lastAssistantText(p, 4000) : "";
5477
5572
  };
5478
5573
  const replySince = (target, sinceMs = 0) => {
5479
5574
  const p = jsonlOf(target);
5480
- return p ? lastReply(p, sinceMs, pingSigs) : "";
5575
+ return p ? lastReply(p, sinceMs) : "";
5481
5576
  };
5482
5577
  const replyToPeerSince = (target, fromName, sinceMs = 0, turn) => {
5483
5578
  const p = jsonlOf(target);
5484
- return p ? replyToPeerIn(p, fromName, sinceMs, pingSigs, turn) : undefined;
5579
+ return p ? replyToPeerIn(p, fromName, sinceMs, turn) : undefined;
5485
5580
  };
5486
5581
  /** 绑着的 pane 还能用 (存在、且不是别人的窗口) —— 不重生、不猜。 */
5487
5582
  const paneLiveOf = async (target) => {
@@ -5498,7 +5593,7 @@ export const startMirror = (deps) => {
5498
5593
  const quoteCanon = (s) => s.replace(OPEN_QUOTE_URL_RE, " ").replace(/[^\p{L}\p{N}]/gu, "");
5499
5594
  const openBubbleBodies = (a) => {
5500
5595
  if (a.briefBubble && !a.briefBubble.done && !a.briefConcluded) {
5501
- return [a.cotLastSent, a.cotText].filter((s) => !!s);
5596
+ return [a.cotLastSent, a.cotText, a.cotTool].filter((s) => !!s);
5502
5597
  }
5503
5598
  const s = a.liveStream;
5504
5599
  return s && !s.closed && !s.dead ? [s.acc, s.lastSent] : [];
@@ -5546,9 +5641,9 @@ export const startMirror = (deps) => {
5546
5641
  tmuxPane: pane,
5547
5642
  attached: !!a,
5548
5643
  paneAlive,
5549
- busy: paneAlive ? paneIsBusy(await capturePaneTail(pane, 12)) : false,
5644
+ busy: paneAlive ? await isBusy(t) : false,
5550
5645
  lastActivity,
5551
- summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80, pingSigs) : "(未绑定会话)",
5646
+ summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80) : "(未绑定会话)",
5552
5647
  self: t === self,
5553
5648
  };
5554
5649
  };
@@ -5594,7 +5689,7 @@ export const startMirror = (deps) => {
5594
5689
  };
5595
5690
  // Reading a peer's conversation is a transcript job, not a terminal job: the
5596
5691
  // jsonl holds whole role-tagged messages, while a pane capture is ANSI-laden
5597
- // and clipped at the viewport edge. Text turns only, keepalive stripped. Only
5692
+ // and clipped at the viewport edge. Text turns only. Only
5598
5693
  // `busy` still comes from the pane (the spinner is the one thing a transcript
5599
5694
  // cannot say during a long tool call); "where is it stuck" is answered by the
5600
5695
  // transcript too — `waiting` = tool calls still hanging on an idle session.
@@ -5605,237 +5700,16 @@ export const startMirror = (deps) => {
5605
5700
  return { ok: false, reason: "它还没有 transcript (没接过第一句话) —— send_peer 跟它说一句就有了", busy };
5606
5701
  // `n` 数的是来回: 每个来回给问话和它最新的一句 (跑完的是终句, 还在跑的是途中
5607
5702
  // 最近说的那句) —— 途中的其余叙述不给, 那是过程不是结论。
5608
- const rounds = talkRounds(jsonl, n, pingSigs).slice(-n);
5703
+ const rounds = talkRounds(jsonl, n).slice(-n);
5609
5704
  const dialog = renderDialog(rounds.flatMap((r) => (r.length > 1 ? [r[0], r.at(-1)] : r)), 800, 4000);
5610
5705
  const waiting = busy ? [] : openToolUses(jsonl);
5611
5706
  return dialog ? { ok: true, dialog, busy, waiting } : { ok: false, reason: "transcript 里还没有对话", busy, waiting };
5612
5707
  };
5613
- // ── Prompt-cache keepalive ──────────────────────────────────────────
5614
- // Anthropic's prompt cache expires a TTL (5min API default, 1h on a Claude Code
5615
- // subscription) after the start of the last request. A pane that goes idle
5616
- // (agent parked on a peer, background task running) lets the whole context fall
5617
- // out of cache — the next real turn then re-writes it. We inject a tiny ping just
5618
- // before expiry so the model makes one cheap request (cache-read) that slides the
5619
- // TTL forward. The budget is anchored to the last REAL (non-ping) activity, NOT to
5620
- // our own pings — so a dead session is never kept warm forever.
5621
- // After `/stop`, ignore pane-busy as a resume signal for this long — long
5622
- // enough for an in-flight ping (interrupted by the same /stop's Esc) to settle,
5623
- // so it can't self-resume the pause. A genuine new turn after this window does.
5624
- const RESUME_GRACE_MS = 30_000;
5625
- // A cache untouched this long is not kept warm: on a 1h entry the ping would come
5626
- // ~59min into the idle stretch, re-reading the whole context to cover a gap the
5627
- // TTL already covered — warming only pays on short (5min) entries.
5628
- const KEEPALIVE_MAX_IDLE_MS = 10 * 60_000;
5629
- // Seed the keepalive clocks from message-turn timestamps (keepaliveStamps), never
5630
- // from file mtime — non-message lines (file-history-snapshot / ai-title / mode)
5631
- // bump mtime without being a model turn, which would fake a long-dead session
5632
- // back to "just active" and pin it in an endless ping loop. lastRealMs stays
5633
- // anchored on genuine work, so the tick's realIdle guard can finally let it die.
5634
- const initKeepalive = (jsonlPath, mtime, pingSigs) => {
5635
- let lastMs = 0;
5636
- let lastRealMs = 0;
5637
- // round is seeded from the transcript too: starting at 0 would hand every
5638
- // daemon reload a fresh budget, and a session idle across N reloads gets
5639
- // N × rounds pings instead of rounds.
5640
- let round = 0;
5641
- try {
5642
- const s = keepaliveStamps(jsonlPath, pingSigs);
5643
- lastMs = s.lastMs;
5644
- lastRealMs = s.lastRealMs;
5645
- round = s.streak;
5646
- }
5647
- catch { /* unreadable tail */ }
5648
- return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), cacheAt: cacheTouchMs(jsonlPath) };
5649
- };
5650
- const fireKeepalive = async (a, guard) => {
5651
- if (!guard())
5652
- return;
5653
- const kc = cfg.wrc.mirror.keepalive;
5654
- // The SAME full instruction every round: a bare "ping" used to be sent from round 2 on
5655
- // (the instruction was already in context), but the model reads a bare "ping"
5656
- // as an open turn and answers it however it likes — anything other than
5657
- // "pong" un-swallows the heartbeat into chat and re-anchors the clocks as
5658
- // real activity. Restating the instruction is a few tokens of cache-write and
5659
- // buys a deterministic reply. keepaliveStamps matches every form, so none
5660
- // reads as real activity. (round is incremented below, after the inject lands.)
5661
- const text = kc.ping;
5662
- // Suppress the pane→WeCom echo of the ping user line, then swallow the whole
5663
- // ping turn (reply included). The 60s fail-safe clears the quiet window if
5664
- // the turn somehow never emits turn_end, so a later real turn is never muted;
5665
- // it also closes the detail turn so it can't hang open in the chat timeline.
5666
- const r = await inject({
5667
- text, images: [], cfg,
5668
- guard,
5669
- requireIdle: () => idleVerdict(a.target) === true,
5670
- beforeInput: () => {
5671
- // A queued heartbeat must not mute the peer turn that won the lane.
5672
- // Drain its tail first, then open swallow only at permitted physical input.
5673
- a.tail.drain();
5674
- rememberInject(text);
5675
- a.keepaliveQuiet = setTimeout(() => {
5676
- a.keepaliveQuiet = undefined;
5677
- if (a.keepaliveTurnId) {
5678
- recordTurnClose(a.keepaliveTurnId);
5679
- a.keepaliveTurnId = undefined;
5680
- }
5681
- }, 60_000);
5682
- },
5683
- log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
5684
- sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
5685
- });
5686
- if (!r.ok) {
5687
- endKeepaliveSwallow(a);
5688
- if (a.keepalive)
5689
- a.keepalive.pinging = false; // roll back so the next tick can retry
5690
- log.warn({ target: a.target, reason: r.reason }, "keepalive: inject failed");
5691
- return;
5692
- }
5693
- if (!guard())
5694
- return;
5695
- const tokens = lastContextTokens(a.jsonlPath);
5696
- const round = a.keepalive ? (a.keepalive.round += 1) : 1;
5697
- log.info({ target: a.target, tokens, round, totalRounds: Math.max(1, kc.rounds) }, "keepalive: ping injected");
5698
- // Open a chat-detail turn for the real heartbeat exchange: userQuery is the
5699
- // actual ping we injected; the assistant reply (expected: just "pong"), any
5700
- // tool calls, and usage are grafted on from the swallowed items in onItem;
5701
- // closed on the ping's turn_end (or the fail-safe). Kept out of chat.
5702
- // The tail's keepalive_start (content match on the user line) may have
5703
- // opened this turn already — only open when it hasn't.
5704
- if (!a.keepaliveTurnId) {
5705
- const turnId = newTurnId();
5706
- a.keepaliveTurnId = turnId;
5707
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
5708
- }
5709
- };
5710
- // One pool for both maintenance paths: slow panes do not stall other panes,
5711
- // and a keepalive never overlaps a picker scan/automatic press on the same pane.
5708
+ // One pool for the picker sentinel's pane probes: slow panes do not stall
5709
+ // other panes, and scans on the same pane never overlap.
5712
5710
  const maintenance = createKeyedScheduler(4);
5713
5711
  const maintenanceValid = (a, pane, sid, gen, signal) => !signal.aborted && byTarget.get(a.target) === a && a.tmuxPane === pane && a.sessionId === sid
5714
5712
  && (a.injectGen ?? 0) === gen && !a.migrationWatcher;
5715
- const keepaliveOne = async (a, pane, sid, gen, signal) => {
5716
- const valid = () => maintenanceValid(a, pane, sid, gen, signal) && cfg.wrc.mirror.keepalive.enabled;
5717
- if (!valid())
5718
- return;
5719
- const kc = cfg.wrc.mirror.keepalive;
5720
- const pingSigs = [normAssistant(kc.ping).slice(0, 40)].filter((s) => s.length > 0);
5721
- const now = Date.now();
5722
- if (a.keepaliveDisabled)
5723
- return; // sticky spawn-time opt-out — never pings
5724
- if (!a.tmuxPane)
5725
- return; // spawn-mode: no live pane to warm
5726
- if (a.migrationWatcher)
5727
- return; // session rotating — skip
5728
- if (a.liveStream && !a.liveStream.closed)
5729
- return; // mid typewriter — don't inject
5730
- if (!(await tmuxPaneAlive(pane)) || !valid())
5731
- return; // dead pane — nothing to keep alive
5732
- let mtime = 0;
5733
- try {
5734
- mtime = statSync(a.jsonlPath).mtimeMs;
5735
- }
5736
- catch {
5737
- return;
5738
- }
5739
- if (!mtime)
5740
- return;
5741
- const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, pingSigs));
5742
- // File mtime is only a cheap "did anything change → re-read the tail" gate.
5743
- // The clocks themselves come from message turns, so metadata churn (which
5744
- // bumps mtime but is not a turn) can never move them. `grewSinceLast` now
5745
- // means a NEW message turn appeared — not that the file grew.
5746
- const prevRealMs = k.lastRealMs;
5747
- if (mtime > k.seenMtime + 1000) {
5748
- const s = keepaliveStamps(a.jsonlPath, pingSigs);
5749
- if (s.stamped) {
5750
- k.lastMs = s.lastMs;
5751
- k.lastRealMs = s.lastRealMs;
5752
- }
5753
- else {
5754
- k.lastMs = mtime;
5755
- k.lastRealMs = mtime;
5756
- } // backend without timestamps → fall back to mtime
5757
- k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
5758
- k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
5759
- }
5760
- k.seenMtime = mtime;
5761
- // Round reset / real-activity re-anchor keys off REAL turns only — a
5762
- // keepalive ping+pong advances lastMs but NOT lastRealMs, so the heartbeat
5763
- // no longer reads as activity to itself (which was pinning round at 1/N).
5764
- const grewSinceLast = k.lastRealMs > prevRealMs + 1000;
5765
- const paneTail = await capturePaneTail(pane, 16);
5766
- if (!valid())
5767
- return;
5768
- const busy = paneIsBusy(paneTail);
5769
- if (k.pinging) {
5770
- // Waiting for our own ping to land and settle: until a NEWER message turn
5771
- // (the ping's own user line) appears and the pane goes idle, keep waiting.
5772
- if (busy || k.lastMs <= k.pingMtime)
5773
- return;
5774
- k.pinging = false;
5775
- k.settledAt = now;
5776
- return;
5777
- }
5778
- // A new real message turn (or a busy pane) re-anchors: reset the round
5779
- // counter, and past the /stop grace lift a busy-resume. grewSinceLast is
5780
- // message-based now, so `/stop`'s Esc settling writes (metadata, no new
5781
- // turn) can no longer self-resume the pause — only a busy pane past grace,
5782
- // or a WeCom inbound in dispatch, does.
5783
- // Suppress the grewSinceLast reset for 30s after pinging settles — the
5784
- // pong's tail flush can lag a tick and would otherwise fake real activity.
5785
- const justSettled = k.settledAt > 0 && now - k.settledAt < 30_000;
5786
- if (busy || (grewSinceLast && !justSettled)) {
5787
- k.round = 0;
5788
- k.settledAt = 0;
5789
- if (a.keepaliveOff && busy && now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS) {
5790
- a.keepaliveOff = false;
5791
- persistPause(a);
5792
- }
5793
- if (busy)
5794
- return;
5795
- }
5796
- if (a.keepaliveOff)
5797
- return; // paused by /stop until real activity returns
5798
- // Cache clock = start of the newest answered request (TTL runs from there),
5799
- // not the last turn's write time; no request on record → the turn clock.
5800
- const sinceCache = now - (k.cacheAt || k.lastMs || mtime);
5801
- // The TTL the session's cache entries were actually written with (1h on a
5802
- // Claude Code subscription), not a guess: pinging at the 5min cadence
5803
- // against a 1h entry re-reads the whole context ~14x per hour for nothing.
5804
- const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
5805
- const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
5806
- if (sinceCache < idleTriggerMs)
5807
- return; // cache still comfortably warm
5808
- if (sinceCache >= ttlMs)
5809
- return; // cache already cold — a ping would cold-rewrite for nothing
5810
- if (sinceCache > KEEPALIVE_MAX_IDLE_MS)
5811
- return; // idle past 10min (a long-TTL entry) — not worth warming
5812
- if (k.round >= kc.rounds)
5813
- return; // budget spent — let it go cold
5814
- if (idleVerdict(a.target) !== true)
5815
- return; // no confirmed idle → no heartbeat input
5816
- k.pinging = true;
5817
- k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
5818
- await fireKeepalive(a, () => valid() && !a.keepaliveOff);
5819
- };
5820
- const keepaliveTick = () => {
5821
- if (!cfg.wrc.mirror.keepalive.enabled)
5822
- return;
5823
- for (const a of byTarget.values()) {
5824
- if (!a.tmuxPane || a.keepaliveDisabled)
5825
- continue;
5826
- const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
5827
- void maintenance.schedule(`keepalive:${a.target}`, pane, (signal) => keepaliveOne(a, pane, sid, gen, signal))
5828
- ?.catch((e) => {
5829
- if (maintenanceValid(a, pane, sid, gen, maintenance.signal)) {
5830
- if (a.keepalive)
5831
- a.keepalive.pinging = false;
5832
- endKeepaliveSwallow(a);
5833
- }
5834
- log.warn({ target: a.target, err: e.message }, "keepalive tick failed");
5835
- });
5836
- }
5837
- };
5838
- const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
5839
5713
  // ── 原生权限 picker 哨兵 ──────────────────────────────────────────────
5840
5714
  // CodeBuddy 对一部分调用先弹自家权限 picker、PreToolUse hook 在人点掉之后才
5841
5715
  // 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
@@ -6248,14 +6122,21 @@ export const startMirror = (deps) => {
6248
6122
  const generation = a.injectGen ?? 0;
6249
6123
  const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
6250
6124
  const probedPane = a.tmuxPane;
6251
- const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
6125
+ const probe = a.tmuxPane ? await probePane(a.tmuxPane, a.jsonlPath) : { state: "gone" };
6252
6126
  if (!permitted() || a.tmuxPane !== probedPane)
6253
6127
  return denied;
6128
+ // tmux 没给出定论 (server 卡住时 display-message 会超时): 既不能贴 (不知道往哪贴),
6129
+ // 也不能重生 —— pane 很可能好端端的。按「稍后重发」交回去: 超时是分钟级的偶发, 重发
6130
+ // 几乎总能落地, 而重生的代价是这个会话被整个换掉。
6131
+ if (probe.state === "unknown") {
6132
+ log.warn({ target, sessionId: sid, pane: a.tmuxPane }, "injectText: pane 探活无定论 (tmux 无响应), 不重生");
6133
+ return { ok: false, retryable: true, reason: "tmux 探活无定论 (server 可能卡住), 未重生 pane, 稍后重发即可" };
6134
+ }
6254
6135
  // 同 dispatch: 只有刚生 / 刚重生的 pane 才要冷启动的宽限 —— 对一个热着、正忙的 pane 贴 `now`
6255
6136
  // 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
6256
6137
  let fresh = a.justSpawned === true;
6257
- if (!paneAlive) {
6258
- log.warn({ target, sessionId: sid, oldPane: a.tmuxPane }, "injectText: pane not alive, respawning");
6138
+ if (probe.state !== "session") {
6139
+ log.warn({ target, sessionId: sid, oldPane: a.tmuxPane, why: probe.state }, "injectText: pane not alive, respawning");
6259
6140
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
6260
6141
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
6261
6142
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
@@ -6287,14 +6168,14 @@ export const startMirror = (deps) => {
6287
6168
  return denied;
6288
6169
  // Busy/queued retries must not consume notices or stamp a future turn.
6289
6170
  // Render only after the pane lane grants delivery, then stamp exactly at
6290
- // the first permitted input. activePeerTurn still changes in bindLine.
6171
+ // the first permitted input.
6291
6172
  const envelope = text.trimStart().startsWith("/") ? "" : opts?.envelope ?? "";
6292
- let full = text + envelope;
6173
+ let full = text + renderTail(envelope);
6293
6174
  const beforeInput = () => {
6294
6175
  a.pendingFromCli = false;
6295
6176
  a.turnFromChat = true;
6296
6177
  rememberInject(full);
6297
- pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
6178
+ pushSeal(a, { eventId: parseEnvelope(full)?.eventId, keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
6298
6179
  a.muteUntilInject = false;
6299
6180
  a.justSpawned = false;
6300
6181
  };
@@ -6304,10 +6185,10 @@ export const startMirror = (deps) => {
6304
6185
  const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
6305
6186
  const injectPane = a.tmuxPane;
6306
6187
  const r = await inject({
6307
- text: full, images: [], cfg, guard: (phase) => permitted() && a.tmuxPane === injectPane && (!opts?.guard || opts.guard(phase)), log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
6308
- sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
6309
- requireIdle: opts?.requireIdle ? () => idleVerdict(target) === true : undefined,
6310
- prepareText: () => (full = text + envelope + noticeSuffixFor(target, text)),
6188
+ 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" }),
6189
+ sessionId: sid, eventId: opts?.eventId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
6190
+ requireIdle: opts?.requireIdle ? () => idleNow(target) : undefined,
6191
+ prepareText: () => (full = text + renderTail(envelope, noticeSuffixFor(target, text))),
6311
6192
  beforeInput,
6312
6193
  });
6313
6194
  if (r.ok && a.tmuxPane && permitted()) {
@@ -6321,9 +6202,15 @@ export const startMirror = (deps) => {
6321
6202
  return r;
6322
6203
  },
6323
6204
  interruptPane: async (target, opts) => {
6205
+ if (opts?.guard && !opts.guard())
6206
+ return { ok: false, reason: "interrupt authority changed" };
6324
6207
  const a = byTarget.get(target);
6325
6208
  if (!a)
6326
6209
  return { ok: false, reason: "no mirror attached for target" };
6210
+ const interruptedEvent = a.activeEventId;
6211
+ const interruptedPane = a.tmuxPane;
6212
+ if (opts?.teardown && interruptedEvent)
6213
+ deps.events?.cancel((e) => e.id === interruptedEvent);
6327
6214
  // `/stop` (teardown: true) must work when tmux itself is the problem, so
6328
6215
  // the order is: local state first, pane second. The old implementation
6329
6216
  // probed the pane FIRST and returned early on failure — meaning a wedged
@@ -6340,15 +6227,6 @@ export const startMirror = (deps) => {
6340
6227
  else if (!a.tmuxPane) {
6341
6228
  return { ok: false, reason: "no live tmux pane (spawn-mode attachment)" };
6342
6229
  }
6343
- // /stop also pauses keepalive: the user is deliberately quieting this
6344
- // session, so stop poking it too. A real turn (WeCom inbound, or the pane
6345
- // going busy AFTER the resume grace) lifts the pause and re-earns the
6346
- // budget. Stamping keepaliveOffAt gates the busy-resume so the ping that
6347
- // may be mid-flight right now can't immediately self-resume.
6348
- a.keepaliveOff = true;
6349
- a.keepaliveOffAt = Date.now();
6350
- a.keepalive = undefined; // re-anchors cleanly on resume
6351
- persistPause(a); // survive a reload — don't resurrect a quieted session
6352
6230
  // Best-effort Esc. Every failure mode here is reported, never fatal: the
6353
6231
  // teardown above already gave the user their chat back.
6354
6232
  let escOk = false;
@@ -6360,7 +6238,13 @@ export const startMirror = (deps) => {
6360
6238
  escReason = "tmux pane no longer alive";
6361
6239
  }
6362
6240
  else {
6363
- const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
6241
+ if (byTarget.get(target) !== a || a.tmuxPane !== interruptedPane || a.activeEventId !== interruptedEvent || (opts?.guard && !opts.guard()))
6242
+ return { ok: false, reason: "turn changed during interrupt probe; Escape withheld" };
6243
+ const r = await withPaneInput(interruptedPane, async (live) => {
6244
+ if (!live() || a.activeEventId !== interruptedEvent || a.tmuxPane !== interruptedPane || (opts?.guard && !opts.guard()))
6245
+ return { ok: false, stderr: "turn changed before Escape", stdout: "", code: 1 };
6246
+ return runTmux(["send-keys", "-t", interruptedPane, "Escape"]);
6247
+ });
6364
6248
  escOk = r.ok;
6365
6249
  if (!r.ok)
6366
6250
  escReason = `send-keys Escape failed: ${(r.stderr || r.stdout).slice(-200) || r.code}`;
@@ -6375,7 +6259,13 @@ export const startMirror = (deps) => {
6375
6259
  return { ok: false, reason: escReason };
6376
6260
  return { ok: true, torndown, escOk, escReason: escReason || undefined };
6377
6261
  },
6378
- killPane: (target) => downPane(target, true),
6262
+ killPane: async (target) => {
6263
+ deps.events?.cancel((e) => e.target === target);
6264
+ const r = await downPane(target, true);
6265
+ if (r.ok)
6266
+ deps.events?.stopped(target, "pane kill acknowledged; queued input revoked");
6267
+ return r;
6268
+ },
6379
6269
  sleepPane: (target) => downPane(target, false),
6380
6270
  lastActivity: (target) => {
6381
6271
  const jsonl = jsonlOf(target);
@@ -6390,15 +6280,22 @@ export const startMirror = (deps) => {
6390
6280
  const want = new Set(names.flatMap((n) => [n, `.${n}`]));
6391
6281
  if (!want.size)
6392
6282
  return [];
6393
- const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{session_name}\t#{window_name}"]);
6283
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{session_name}\t#{window_name}\t#{pane_pid}"]);
6394
6284
  if (snap.code !== 0)
6395
6285
  return [];
6396
- const bound = new Set(allTargets().map(paneOf).filter(Boolean));
6286
+ // 「生到一半」只可能是上一个进程留下的: 本进程自己 spawn 的 pane (名字被 sweepUnborn
6287
+ // 腾出后同名重生、或 spawnTmux 等 TUI 就绪那十几秒里还没回 pane id 的) 一律不碰 ——
6288
+ // pane 的 shell 比本进程年轻就是本进程生的。拿不到年龄 = 认不出, 不收。
6289
+ const ps = await psSnap();
6290
+ const bornBeforeUs = (pid) => (ps.etimes.get(Number(pid)) ?? 0) > process.uptime();
6397
6291
  const doomed = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"))
6398
- .filter(([pane = "", session, win = ""]) => session === cfg.wrc.tmuxPrefix && want.has(win) && !bound.has(pane))
6292
+ .filter(([, session, win = "", pid = ""]) => session === cfg.wrc.tmuxPrefix && want.has(win) && bornBeforeUs(pid))
6399
6293
  .map(([pane = ""]) => pane);
6400
6294
  const killed = [];
6401
6295
  for (const pane of doomed) {
6296
+ // 绑定现查: 逐个收的 await 期间别人可能刚 attach 上。
6297
+ if (spawningPanes.has(pane) || allTargets().some((t) => paneOf(t) === pane))
6298
+ continue;
6402
6299
  // 与 killPane 同款前戏: Esc 给可能已起来的 CLI 一个收束落盘的机会。
6403
6300
  await runTmux(["send-keys", "-t", pane, "Escape"]);
6404
6301
  await sleep(250);
@@ -6446,7 +6343,6 @@ export const startMirror = (deps) => {
6446
6343
  // 找)。不看注册表的 `tmux` 字段 —— codebuddy 的注册表没有它, 那条路对 codebuddy
6447
6344
  // 全盲。普通 pane (人自己开的 shell / ssh / 编辑器, 会话已退回 shell 的) 树里
6448
6345
  // 没有注册表 pid, 一律跳过 —— 认不出就不动。
6449
- const bound = new Set(targets.map(paneOf).filter(Boolean));
6450
6346
  const regPids = new Set(activeBackends().flatMap((b) => [...liveSessionPids(b.homeDir)]));
6451
6347
  const ps = await psSnap();
6452
6348
  // 沿进程树找注册表 pid; 找到返回那个 pid (它的 etimes 供宽限判), 找不到返回 0。
@@ -6466,13 +6362,15 @@ export const startMirror = (deps) => {
6466
6362
  return 0;
6467
6363
  };
6468
6364
  for (const [pane = "", , session, pid] of rows) {
6469
- if (session !== cfg.wrc.tmuxPrefix || bound.has(pane))
6365
+ // 绑定现查而非用开头的快照: 前面几轮 putDown 的 await 期间别人可能刚 attach 上。
6366
+ if (session !== cfg.wrc.tmuxPrefix || spawningPanes.has(pane) || allTargets().some((t) => paneOf(t) === pane))
6470
6367
  continue;
6471
6368
  const cliPid = hostedBy(Number(pid) || 0);
6472
6369
  if (!cliPid)
6473
6370
  continue;
6474
6371
  // 宽限按 CLI 进程年龄, 不按 window_activity: TUI 重绘会让它永远「刚动过」。
6475
- if ((ps.etimes.get(cliPid) ?? Number.POSITIVE_INFINITY) * 1000 < ORPHAN_GRACE_MS)
6372
+ // 拿不到年龄按刚生算 (跳过): 认不出就不收。
6373
+ if ((ps.etimes.get(cliPid) ?? 0) * 1000 < ORPHAN_GRACE_MS)
6476
6374
  continue;
6477
6375
  const why = await putDown(pane);
6478
6376
  if (why) {
@@ -6619,12 +6517,12 @@ export const startMirror = (deps) => {
6619
6517
  newSession,
6620
6518
  cloneSession,
6621
6519
  sessionInfo,
6520
+ inputSessionInfo,
6622
6521
  setCharterProvider,
6623
6522
  onSpawn,
6624
6523
  onContextCut,
6625
6524
  shutdown: () => {
6626
6525
  clearInterval(paneDriftTimer);
6627
- clearInterval(keepaliveTimer);
6628
6526
  clearInterval(pickerTimer);
6629
6527
  maintenance.stop();
6630
6528
  for (const a of bySessionId.values()) {
@@ -6644,13 +6542,15 @@ export const startMirror = (deps) => {
6644
6542
  return a.convFrom.asker;
6645
6543
  return a?.speaker ? { who: a.speaker, chat: a.channel ?? baseOfKey(target) } : undefined;
6646
6544
  },
6647
- answerContext, prepareAnswer, takeAnswer, cancelAnswerPreparation, stopAnswerProgress, deliverAnswer,
6648
- setAnswerSilencer: (fn) => { silenceAnswer = fn; },
6649
6545
  currentChannel: (target) => {
6650
6546
  const a = byTarget.get(target);
6651
6547
  return a?.channel ?? baseOfKey(target);
6652
6548
  },
6653
- dispatch: async ({ principal, text, images, frame, streamId, channel, speaker }) => {
6549
+ formatFinal,
6550
+ recoverEventFinal,
6551
+ setEventFinalGate: (gate) => { eventFinalGate = gate; },
6552
+ settleFinalProgress,
6553
+ dispatch: async ({ principal, text, images, frame, streamId, channel, speaker, eventId, guard, onSubmitted }) => {
6654
6554
  // Route by inbound principal — the WeCom chat the user just messaged us
6655
6555
  // from is the same string we registered as `target` on attach. After a
6656
6556
  // daemon reload the in-memory map is empty; restore from the persisted
@@ -6668,24 +6568,16 @@ export const startMirror = (deps) => {
6668
6568
  }
6669
6569
  return;
6670
6570
  }
6671
- // `/clear` resets to an empty session — nothing in the cache to keep warm,
6672
- // so pause keepalive like `/stop` instead of lifting it. Every other inbound
6673
- // is real work: lift any prior pause and re-anchor both clocks to now.
6674
- // Pane-side new turns re-anchor via the tick's stamps; this covers the
6675
- // WeCom-driven path.
6676
- if (isClearCommand(text)) {
6677
- a.keepaliveOff = true;
6678
- a.keepaliveOffAt = Date.now();
6679
- a.keepalive = undefined; // re-anchors cleanly on resume
6680
- }
6681
- else {
6682
- a.keepaliveOff = false;
6683
- if (a.keepalive) {
6684
- a.keepalive.lastMs = Date.now();
6685
- a.keepalive.lastRealMs = Date.now();
6686
- }
6571
+ // 人说的普通消息不再问「你现在忙不忙」: 忙就贴进去, 顺序交给 CLI 自己的输入队列 ——
6572
+ // 贴进运行中的一轮会由 startBriefTurn 记成 queued 轮, 等它在 transcript 里露面再 bindLine,
6573
+ // 终句照样归到这条 event。只有 slash 命令仍旧等它闲: /clear、/model 这些要改会话状态,
6574
+ // 落在运行中的一轮上会跟 armMigration / 原生 picker 抢 pane。(modal 那一层守卫在
6575
+ // injectViaTmux 里照旧管着: 停在确认框上时既不贴也不吞。)
6576
+ const slashCmd = /^\/[a-z]/i.test(text.trim());
6577
+ if (eventId && slashCmd && a.tmuxPane && await tmuxPaneAlive(a.tmuxPane) && !(await idleNow(principal))) {
6578
+ onSubmitted?.({ ok: false, retryable: true, reason: "target became busy before opening the slash command turn" });
6579
+ return;
6687
6580
  }
6688
- persistPause(a); // persist the pause/resume too, else a reload would revert it
6689
6581
  // 频道印章要早于任何开 turn 的路径 (startBriefTurn 同步消费)。
6690
6582
  // brief 模式下频道随这一句的印章走 (startBriefTurn); 其余路径仍是开 turn 时消费的格子。
6691
6583
  if (!cfg.wrc.mirror.brief || isClearCommand(text))
@@ -6700,7 +6592,7 @@ export const startMirror = (deps) => {
6700
6592
  const isModelSwitch = /^\/model\s+\S/.test(text.trim());
6701
6593
  // 任意 slash 命令 (/context, /cost, /status…): 产物走 skill_output standalone,
6702
6594
  // 详情链接是纯噪声 —— brief 起 turn 时不推链接。
6703
- const isSlash = /^\/[a-z]/i.test(text.trim());
6595
+ const isSlash = slashCmd;
6704
6596
  // codebuddy 没有可用的剪贴板图片通道 (tmux 里 C-v 会被它自己的
6705
6597
  // syncTmuxToSystemClipboard 冲掉, 见 injectViaTmux 分流注释) —— 图片只能以
6706
6598
  // @<path> 文本进会话。既然如此, 问话 / 印章 / 回显去重 / 静默分叉跟随都按这个
@@ -6789,11 +6681,13 @@ export const startMirror = (deps) => {
6789
6681
  log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
6790
6682
  }
6791
6683
  }
6684
+ if (!brief)
6685
+ a.activeEventId = eventId;
6792
6686
  if (brief) {
6793
6687
  // 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
6794
6688
  // 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
6795
6689
  // 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
6796
- await startBriefTurn(a, frame, streamId, isSlash, effText, channel ?? baseOfKey(principal), speaker);
6690
+ await startBriefTurn(a, frame, streamId, isSlash, effText, channel ?? baseOfKey(principal), speaker, eventId);
6797
6691
  }
6798
6692
  else if (!armMigration && !eagerOpen) {
6799
6693
  enterDeferred(a, frame, streamId);
@@ -6813,11 +6707,18 @@ export const startMirror = (deps) => {
6813
6707
  // First step of the job and the historical hang point — log BEFORE the
6814
6708
  // probe so a stall is attributable to it next time, not invisible.
6815
6709
  log.info({ target: a.target, sessionId: sid, pane: a.tmuxPane, gen: myGen }, "inject job start");
6816
- const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
6710
+ const probe = a.tmuxPane ? await probePane(a.tmuxPane, a.jsonlPath) : { state: "gone" };
6817
6711
  let freshSpawn = a.justSpawned === true;
6818
6712
  a.justSpawned = false;
6819
- if (!paneAlive) {
6820
- log.warn({ target: a.target, sessionId: sid, oldPane: a.tmuxPane, oldSession: a.tmuxSession }, "mirror: no live tmux pane, respawning");
6713
+ // tmux 没给出定论: 不重生 (同 injectText)。turn 已经开了头 (气泡可能已挂), 所以
6714
+ // 报一个可重试的失败让调用方收口 —— 比换掉整个会话温和得多。
6715
+ if (probe.state === "unknown") {
6716
+ log.warn({ target: a.target, sessionId: sid, pane: a.tmuxPane }, "mirror: pane 探活无定论 (tmux 无响应), 不重生");
6717
+ onSubmitted?.({ ok: false, retryable: true, reason: "tmux 探活无定论 (server 可能卡住), 未重生 pane" });
6718
+ return;
6719
+ }
6720
+ if (probe.state !== "session") {
6721
+ log.warn({ target: a.target, sessionId: sid, oldPane: a.tmuxPane, oldSession: a.tmuxSession, why: probe.state }, "mirror: no live tmux pane, respawning");
6821
6722
  // Interactive `claude --resume <sid>` does NOT keep appending to the
6822
6723
  // same <sid>.jsonl — it FORKS to a fresh <newSid>.jsonl seeded with a
6823
6724
  // copy of the transcript. Our tail stays bound to the OLD, now-frozen
@@ -6881,7 +6782,7 @@ export const startMirror = (deps) => {
6881
6782
  return;
6882
6783
  }
6883
6784
  if (!brief)
6884
- pushSeal(a, { keys: sealKeys(effText), at: Date.now(), query: effText, channel: channel ?? baseOfKey(principal), speaker, fresh: true });
6785
+ pushSeal(a, { eventId: a.activeEventId, keys: sealKeys(effText), at: Date.now(), query: effText, channel: channel ?? baseOfKey(principal), speaker, fresh: true });
6885
6786
  rememberInject(effText);
6886
6787
  // 解除静默必须在 inject 之前: inject 内部的 freshSpawn 验证循环可达 10+s
6887
6788
  // (paste-verify + settle + waitForCleared + retry), 而 LLM 的回复可能在 Enter
@@ -6891,14 +6792,25 @@ export const startMirror = (deps) => {
6891
6792
  // 用户行有 isOwnInject 过滤 —— 提前解除不会泄漏脏数据。
6892
6793
  a.muteUntilInject = false;
6893
6794
  const injectPane = a.tmuxPane;
6795
+ // 人话不再等 pane 闲 (§人话直入): `requireIdle` 在这一层只剩一个作用 —— 必须有 pane,
6796
+ // 不许退化成 spawn-mode (`--resume -p`) 的无窗口注入。忙闲交给 CLI 自己的输入队列:
6797
+ // 贴进运行中的一轮会被记成 queued 轮, 等它在 transcript 露面再接上这条 event。
6798
+ // slash 命令要等闲, 那一道在上面 (dispatch 开头) 已经拦过了。
6894
6799
  const r = await inject({
6895
6800
  text: effText, images: effImages, cfg, log: log.child({ principal, sessionId: sid }),
6896
- sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
6897
- guard: () => byTarget.get(a.target) === a && a.tmuxPane === injectPane && (a.injectGen ?? 0) === myGen,
6801
+ sessionId: sid, eventId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
6802
+ requireIdle: eventId ? () => true : undefined,
6803
+ guard: (phase, identity) => byTarget.get(a.target) === a && a.tmuxPane === injectPane && (a.injectGen ?? 0) === myGen && (!guard || guard(phase, identity)),
6898
6804
  });
6805
+ onSubmitted?.(r);
6899
6806
  if (!r.ok) {
6807
+ // retryable = 一个字都没进输入框, 消息事件层会稍后重投 —— 不是失败, 别报 ✗
6808
+ // (每次退避重试都会走到这里, 报 ✗ 就是一串假警报)。
6809
+ const note = r.retryable && eventId
6810
+ ? `[mirror] ⏳ 暂未送达, 稍后自动重投 (${r.reason ?? "deferred"})`
6811
+ : `[mirror] ✗ ${r.reason ?? "failed"}`;
6900
6812
  if (s) {
6901
- s.acc = s.acc ? `${s.acc}\n\n[mirror] ✗ ${r.reason ?? "failed"}` : `[mirror] ✗ ${r.reason ?? "failed"}`;
6813
+ s.acc = s.acc ? `${s.acc}\n\n${note}` : note;
6902
6814
  await finalizeStream(a, s);
6903
6815
  }
6904
6816
  else if (armMigration) {
@@ -6916,7 +6828,7 @@ export const startMirror = (deps) => {
6916
6828
  if (a.outbound?.kind === "deferred")
6917
6829
  clearTimeout(a.outbound.timer);
6918
6830
  a.outbound = undefined;
6919
- sendStandalone(a, `[mirror] ✗ ${r.reason ?? "failed"}`);
6831
+ sendStandalone(a, note);
6920
6832
  }
6921
6833
  return;
6922
6834
  }