wezard 2.6.4 → 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 (97) hide show
  1. package/CLAUDE.md +6 -6
  2. package/README.md +1 -15
  3. package/dist/cli/codex-hook.js +5 -1
  4. package/dist/cli/codex-hook.js.map +1 -1
  5. package/dist/cli/sync.js +5 -4
  6. package/dist/cli/sync.js.map +1 -1
  7. package/dist/daemon/approval.js +12 -5
  8. package/dist/daemon/approval.js.map +1 -1
  9. package/dist/daemon/chat-log.js +5 -5
  10. package/dist/daemon/chat-log.js.map +1 -1
  11. package/dist/daemon/chat-name.js +5 -2
  12. package/dist/daemon/chat-name.js.map +1 -1
  13. package/dist/daemon/detail.js +28 -5
  14. package/dist/daemon/detail.js.map +1 -1
  15. package/dist/daemon/direction.js +3 -2
  16. package/dist/daemon/direction.js.map +1 -1
  17. package/dist/daemon/dispatch.js +6 -0
  18. package/dist/daemon/dispatch.js.map +1 -0
  19. package/dist/daemon/handoff.js +66 -15
  20. package/dist/daemon/handoff.js.map +1 -1
  21. package/dist/daemon/inbound.js +3 -5
  22. package/dist/daemon/inbound.js.map +1 -1
  23. package/dist/daemon/index.js +1072 -714
  24. package/dist/daemon/index.js.map +1 -1
  25. package/dist/daemon/jobs.js +7 -2
  26. package/dist/daemon/jobs.js.map +1 -1
  27. package/dist/daemon/last-response.js +8 -3
  28. package/dist/daemon/last-response.js.map +1 -1
  29. package/dist/daemon/message-events.js +232 -59
  30. package/dist/daemon/message-events.js.map +1 -1
  31. package/dist/daemon/mirror-bridge.js +556 -770
  32. package/dist/daemon/mirror-bridge.js.map +1 -1
  33. package/dist/daemon/mirror-store.js.map +1 -1
  34. package/dist/daemon/notices.js +9 -2
  35. package/dist/daemon/notices.js.map +1 -1
  36. package/dist/daemon/peers.js +122 -143
  37. package/dist/daemon/peers.js.map +1 -1
  38. package/dist/daemon/pending-items.js +33 -34
  39. package/dist/daemon/pending-items.js.map +1 -1
  40. package/dist/daemon/route.js +5 -2
  41. package/dist/daemon/route.js.map +1 -1
  42. package/dist/daemon/router.js +643 -0
  43. package/dist/daemon/router.js.map +1 -0
  44. package/dist/daemon/session-name.js +1 -1
  45. package/dist/daemon/session-name.js.map +1 -1
  46. package/dist/daemon/spawn-tmux.js +32 -13
  47. package/dist/daemon/spawn-tmux.js.map +1 -1
  48. package/dist/daemon/subagent-tail.js +3 -3
  49. package/dist/daemon/subagent-tail.js.map +1 -1
  50. package/dist/daemon/{cc-session.js → wezard-session.js} +32 -2
  51. package/dist/daemon/wezard-session.js.map +1 -0
  52. package/dist/daemon/wizard.js +5 -5
  53. package/dist/daemon/wizard.js.map +1 -1
  54. package/dist/daemon/workflow-nodes.js +3 -3
  55. package/dist/daemon/workflow-nodes.js.map +1 -1
  56. package/dist/daemon/workflow-plans.js +9 -6
  57. package/dist/daemon/workflow-plans.js.map +1 -1
  58. package/dist/mcp/server.js +38 -47
  59. package/dist/mcp/server.js.map +1 -1
  60. package/dist/shared/chat-http.js +2 -2
  61. package/dist/shared/chat-http.js.map +1 -1
  62. package/dist/shared/chat-view.js +0 -4
  63. package/dist/shared/chat-view.js.map +1 -1
  64. package/dist/shared/cli-backends.js +11 -1
  65. package/dist/shared/cli-backends.js.map +1 -1
  66. package/dist/shared/config.js +3 -44
  67. package/dist/shared/config.js.map +1 -1
  68. package/dist/shared/detail-render.js +36 -11
  69. package/dist/shared/detail-render.js.map +1 -1
  70. package/dist/shared/detail-store.js.map +1 -1
  71. package/dist/shared/message-footer.js +8 -8
  72. package/dist/shared/message-footer.js.map +1 -1
  73. package/dist/shared/reminder.js +104 -37
  74. package/dist/shared/reminder.js.map +1 -1
  75. package/dist/shared/role-render.js +28 -20
  76. package/dist/shared/role-render.js.map +1 -1
  77. package/dist/shared/role-search.js +3 -3
  78. package/dist/shared/role-search.js.map +1 -1
  79. package/dist/shared/role-view.js +50 -30
  80. package/dist/shared/role-view.js.map +1 -1
  81. package/dist/shared/session-label.js +25 -6
  82. package/dist/shared/session-label.js.map +1 -1
  83. package/dist/shared/turn-state.js +7 -7
  84. package/dist/shared/turn-state.js.map +1 -1
  85. package/dist/shared/world.js +1 -3
  86. package/dist/shared/world.js.map +1 -1
  87. package/hooks/pre-tool-use.sh +5 -25
  88. package/package.json +1 -1
  89. package/web/chat.css +3 -33
  90. package/web/chat.js +11 -62
  91. package/dist/daemon/answer-transfer.js +0 -56
  92. package/dist/daemon/answer-transfer.js.map +0 -1
  93. package/dist/daemon/cc-session.js.map +0 -1
  94. package/dist/daemon/receipts.js +0 -968
  95. package/dist/daemon/receipts.js.map +0 -1
  96. package/dist/shared/keepalive.js +0 -46
  97. package/dist/shared/keepalive.js.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { finalId } from "./message-events.js";
2
- import { parseEnvelope } from "../shared/reminder.js";
2
+ import { parseEnvelope, renderTail } from "../shared/reminder.js";
3
3
  import { codexSessions, codexSessionForPane } from "../shared/codex.js";
4
4
  import { codexHookLaunchArgs, codexHookCommand } from "../shared/codex-hooks.js";
5
5
  import { fileURLToPath } from "node:url";
@@ -21,7 +21,7 @@ import { spawn } from "node:child_process";
21
21
  import { randomUUID } from "node:crypto";
22
22
  import { pasteTmuxBuffer } from "./tmux-buffer.js";
23
23
  import { existsSync, readdirSync, readFileSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
24
- import { join, dirname } from "node:path";
24
+ import { basename, join, dirname } from "node:path";
25
25
  import { newStewardSpec, resumedStewardSpec } from "./steward-model.js";
26
26
  import { expandHome, sanitizeId } from "../shared/paths.js";
27
27
  import { augmentedPath } from "../shared/exec-path.js";
@@ -38,18 +38,17 @@ import { dispatchPickerPolicy, pickerPolicy } from "./picker-policy.js";
38
38
  import { renameWindow, runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
39
39
  import { selectEffort, selectModel } from "./model-select.js";
40
40
  import { parseEffort } from "../shared/effort.js";
41
- 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";
42
42
  import { waitForIdle } from "./graph.js";
43
43
  import { wizardStore } from "./wizard.js";
44
44
  import { startSubagentWatch } from "./subagent-tail.js";
45
- import { getDetail, 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";
46
46
  import { errText } from "./last-response.js";
47
- import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, 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";
48
48
  import { splitMarkdown } from "../shared/md-chunk.js";
49
49
  import { isWxFrame } from "../shared/wx-text.js";
50
50
  import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
51
- import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
52
- 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";
53
52
  // PATH augmentation: launchd / systemd start the daemon
54
53
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
55
54
  // Backend resolution is per-transcript, not global: `backendForPath` recovers
@@ -571,14 +570,9 @@ const renderLine = (raw, deps) => {
571
570
  const text = cleanUserText(c);
572
571
  if (!text)
573
572
  return []; // pure slash-command meta / stdout — drop
574
- // Keepalive ping user line: judged by CONTENT, before every other gate
575
- // (includeUser / isOwnInject's 60s TTL) — the ping never echoes, and
576
- // the reply turn is swallowed by content in onItem. Timer windows can't
577
- // cover reloads or replays; the user message itself always can.
578
- if (deps.isKeepalivePing?.(text))
579
- return [{ kind: "keepalive_start", body: text }];
580
573
  // 自己注入的那一行不回显, 但要报个到: 它的印章 (谁说的、回复去哪) 在这一刻认领。
581
- const eventId = c.includes('wezard="message-event"') ? parseEnvelope(c)?.eventId : undefined;
574
+ // 注入事件 id: 信封的 `msg` (老形状是信封外的 message-event 块) —— 认得出就是守护进程投的。
575
+ const eventId = parseEnvelope(c)?.eventId;
582
576
  if (eventId || deps.isOwnInject(text))
583
577
  return [{ kind: "user_query", eventId, body: unwrapPasted(c).trim(), said: text, ts: lineTs(line) }];
584
578
  const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
@@ -1339,6 +1333,22 @@ export const parseInputBox = (cap, styledLayout = cap) => {
1339
1333
  const body = [rows[0].replace(BOX_PROMPT_RE, ""), ...rows.slice(1)].join("\n").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
1340
1334
  return { known: true, body };
1341
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
+ };
1342
1352
  // 空框里的灰字不是输入: 幽灵提示 (`Try "…"`) 和 CC 预测的下一句 (prompt suggestion,
1343
1353
  // 回车不采纳) 都以 SGR 2 (dim) 渲染。纯文本 capture 看不出区别 —— 预测句
1344
1354
  // `commit 这次改动…` 曾让一条 `commit` 被判成「已在框里」, 于是没贴, 只按了三下
@@ -1375,7 +1385,7 @@ const readPaneInput = async (target) => {
1375
1385
  return { cap, box: parseInputBox(cap, raw) };
1376
1386
  };
1377
1387
  const readInputBox = async (target) => (await readPaneInput(target)).box;
1378
- // ── 注入门控的会话证人 (cc-session) ─────────────────────────────────
1388
+ // ── 注入门控的会话证人 (wezard-session) ──────────────────────────────
1379
1389
  // 能从会话自己写的 json 读到的判据, 就不读 pane: 就绪看注册表的 status, 提交看
1380
1390
  // 注册表的 busy 翻转或 jsonl 里新落的那一行。输入框里「贴进去没有」是唯一只有
1381
1391
  // pane 知道的事 (未提交的输入不落任何盘), 那一段仍旧 capture。
@@ -1385,9 +1395,89 @@ const paneRunsShell = async (target) => {
1385
1395
  const r = await runTmux(["display-message", "-p", "-t", target, "#{pane_current_command}"]);
1386
1396
  return r.ok && SHELL_RE.test(r.stdout.trim());
1387
1397
  };
1388
- /** pane 活着且里面住着 AI 会话。注册表认得它就是; 不认得时只在前台确是 shell 才判否 ——
1389
- * 注册表缺席 (codebuddy / 旧版 CC / 进程不在 tmux 里记的行) 不能被当成会话不在。 */
1390
- 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
+ };
1391
1481
  const SESSION_READY_TIMEOUT_MS = 15_000;
1392
1482
  /** 冷 pane 等注册表报 idle (会话建好、输入框等人); 热 pane 只核一次会话还在 —— 忙也照贴,
1393
1483
  * CC 会把它排进队列。等不到 idle 不算失败 (贴后校验会兜), 前台退回 shell 才算:
@@ -1415,32 +1505,32 @@ const awaitSession = async (target, homeDir, fresh, log) => {
1415
1505
  }
1416
1506
  return { ok: true };
1417
1507
  };
1418
- const NO_WITNESS = { status: "", submitted: () => false };
1508
+ const NO_WITNESS = { status: "", submitted: () => false, submittedEvent: () => false };
1419
1509
  /** 贴之前给会话可能落盘的 jsonl 打书签: 绑定的那份, 加上注册表报的当前 sid 那份
1420
1510
  * (resume / fork 会换 sid)。提交后 sid 若又变了, 新文件从 0 读 —— 时间门挡掉 fork 里的旧话。 */
1421
- const witnessFor = (target, homeDir, jsonlPath, live) => {
1422
- if (!live)
1423
- return NO_WITNESS;
1511
+ const witnessFor = (target, homeDir, jsonlPath, live, actualTranscript) => {
1424
1512
  const dir = dirname(jsonlPath);
1425
- 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)]));
1426
1515
  return {
1427
- status: live.status,
1516
+ status: live?.status ?? "",
1428
1517
  submitted: (probe, since, enterAt) => {
1429
1518
  const now = sessionOnPane(homeDir, target);
1430
- if (now && live.status !== "busy" && now.status === "busy" && now.statusUpdatedAt >= enterAt)
1519
+ if (live && now && live.status !== "busy" && now.status === "busy" && now.statusUpdatedAt >= enterAt)
1431
1520
  return true;
1432
1521
  const p = now && join(dir, `${now.sessionId}.jsonl`);
1433
1522
  if (p && !marks.has(p))
1434
1523
  marks.set(p, { path: p, offset: 0 });
1435
1524
  return submittedSince([...marks.values()], probe, since);
1436
1525
  },
1526
+ submittedEvent: (eventId, since) => submittedEventSince([...marks.values()], eventId, since),
1437
1527
  };
1438
1528
  };
1439
1529
  // Self-verifying inject. The cold-spawn race we guard against: paste lands
1440
1530
  // but the trailing Enter is eaten while the TUI is still initializing, so
1441
1531
  // the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
1442
1532
  // 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
1443
- 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) => {
1444
1534
  const backendName = backend.name;
1445
1535
  log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
1446
1536
  // 就绪门: pane 里得真有一个进入了交互态的 AI 会话, 才轮得到贴。先于 modal guard ——
@@ -1448,7 +1538,74 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1448
1538
  const gate = await awaitSession(target, backend.homeDir, freshSpawn, log);
1449
1539
  if (!gate.ok)
1450
1540
  return gate;
1451
- 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
+ };
1452
1609
  // Never type into a modal picker (see detectModalPicker). Checked before the
1453
1610
  // image pump too — a C-v into a picker is just as destructive as a paste.
1454
1611
  // 必须在按后端分流之前: 每个后端都有自己的原生确认框, 往任何一个里打字都会
@@ -1481,27 +1638,31 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1481
1638
  // dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
1482
1639
  // withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
1483
1640
  if (backendName === "codebuddy") {
1484
- return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, guard);
1641
+ return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, boundGuard, eventId);
1485
1642
  }
1486
1643
  // Pump images first via clipboard+C-v so each one is attached as a separate
1487
1644
  // image content block. Each C-v needs a brief settle for Claude Code's TUI
1488
1645
  // to read the clipboard before the next overwrite. Fresh spawn extends.
1489
1646
  const IMG_SETTLE_MS = freshSpawn ? 700 : 350;
1647
+ let imagePasteMayHaveExposed = false;
1490
1648
  const pumped = images.length ? await withImageClipboard(async () => {
1491
1649
  for (const imgPath of images) {
1492
1650
  const paste = async () => {
1493
1651
  const cb = await setMacClipboardImage(imgPath);
1494
1652
  if (!cb.ok)
1495
1653
  return cb;
1496
- if (guard && !guard("paste"))
1497
- 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;
1498
1659
  const cv = await runTmux(["send-keys", "-t", target, "C-v"]);
1499
1660
  return cv.ok ? { ok: true } : { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
1500
1661
  };
1501
1662
  // Cold Codex can ignore C-v before its composer exists. Do not submit
1502
1663
  // the text until the composer proves this attachment has landed.
1503
1664
  const result = backendName === "codex"
1504
- ? await pasteVerifiedImage({ read: () => readInputBox(target), paste, valid: () => !guard || guard("paste") })
1665
+ ? await pasteVerifiedImage({ read: () => readInputBox(target), paste, valid: () => boundGuard("paste") })
1505
1666
  : await paste();
1506
1667
  if (!result.ok) {
1507
1668
  log.warn({ target, imgPath, reason: result.reason }, "mirror inject: image not confirmed, refusing text submission");
@@ -1515,19 +1676,21 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1515
1676
  return { ok: true };
1516
1677
  }) : { ok: true };
1517
1678
  if (!pumped.ok)
1518
- 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 };
1519
1682
  // Image-only message: TUI input box now holds the attached images; press
1520
1683
  // Enter and we're done. No fingerprint to verify (the input itself is binary
1521
1684
  // image refs, not text).
1522
1685
  if (!text) {
1523
1686
  if (images.length === 0)
1524
1687
  return { ok: true };
1525
- if (guard && !guard("submit"))
1688
+ if (!boundGuard("submit"))
1526
1689
  return { ok: false, reason: "submit canceled by dispatch guard" };
1527
1690
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1528
1691
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
1529
1692
  }
1530
- return injectViaTmuxText(target, text, log, freshSpawn, witness, guard);
1693
+ return injectViaTmuxText(target, text, log, freshSpawn, witness, boundGuard, eventId);
1531
1694
  };
1532
1695
  // 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
1533
1696
  // 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
@@ -1549,7 +1712,7 @@ const withImageRefs = (text, images) => {
1549
1712
  // 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
1550
1713
  // 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
1551
1714
  // 每次补之前再问一遍证人, 已提交就不补。
1552
- const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard) => {
1715
+ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard, eventId) => {
1553
1716
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1554
1717
  // started, transcript still loading): extended timings — bracketed-paste
1555
1718
  // end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
@@ -1566,6 +1729,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1566
1729
  // (会话正忙 → 只是排队; slash 命令 → 不一定落 user 行) 不空等, 直接看框。
1567
1730
  const witnessQuiet = witness.status === "" || witness.status === "busy" || text.trimStart().startsWith("/");
1568
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;
1569
1736
  const { headFp, tailFp } = fingerprints(text);
1570
1737
  const probe = probeOf(text);
1571
1738
  const POLL_MS = 100;
@@ -1573,10 +1740,14 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1573
1740
  // 时间门往前放 1s: jsonl 的 timestamp 与本机时钟同源, 只差写盘的毫秒。
1574
1741
  const since = Date.now() - 1000;
1575
1742
  let enterAt = Number.POSITIVE_INFINITY;
1576
- const submitted = () => witness.submitted(probe, since, enterAt);
1743
+ const submitted = () => requireEventEvidence
1744
+ ? witness.submittedEvent(eventId, since) : witness.submitted(probe, since, enterAt);
1577
1745
  // 贴之前框里是什么: 空框、幽灵提示 (`Try "how do I log an error?"`)、甚至用户
1578
1746
  // 自己敲了一半的字 —— 一律作为基线, 免得去维护一张「哪些文案算空」的清单。
1579
- const preBox = await readInputBox(target);
1747
+ const pre = await readPaneInput(target);
1748
+ const preBox = pre.box;
1749
+ // 贴之前队列里已有几份同样开头的 (同一段文本上次排进去了 / 兄弟回执同开头): 只认新增的。
1750
+ const preQueued = queuedCopies(pre.cap, text);
1580
1751
  // 基线读不出来 (框被模态顶掉 / 正在重绘) 就整条退回指纹法: 没有基线,「框变了」
1581
1752
  // 无从谈起, 而把幽灵提示 (`Try "…"`) 误当成「贴进去了」会让这条消息静静消失。
1582
1753
  const boxVerify = preBox.known;
@@ -1601,6 +1772,21 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1601
1772
  }
1602
1773
  return false;
1603
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;
1604
1790
  // 窄窗口 = 只有输入框本身。tailFp 紧挨光标, 提交前必在窗内、提交后消失。
1605
1791
  // 仅用于 parseInputBox 认不出布局的后端。
1606
1792
  const inputBoxStillHasTail = async () => {
@@ -1625,7 +1811,22 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1625
1811
  // and then sleeping another 2.5s before the first Enter.
1626
1812
  pasteSeen = await sawLanded(boxVerify ? PASTE_VERIFY_MS : POST_PASTE_SETTLE_FALLBACK_MS);
1627
1813
  }
1628
- 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) {
1629
1830
  const box = await readInputBox(target);
1630
1831
  const empty = boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
1631
1832
  if (submitted()) {
@@ -1694,13 +1895,19 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1694
1895
  await sleep(POLL_MS);
1695
1896
  if (submitted())
1696
1897
  return true;
1697
- 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()))
1698
1905
  return true;
1699
1906
  }
1700
1907
  return false;
1701
1908
  };
1702
1909
  if (!pasteSeen)
1703
- log.warn({ target }, "mirror inject: submitted without paste verification (capture-pane lag)");
1910
+ log.warn({ target }, "mirror inject: paste unverified, requiring session submission evidence");
1704
1911
  if (!tailFp) {
1705
1912
  // empty/whitespace text — nothing to verify, fire once and go.
1706
1913
  const e = await sendEnter();
@@ -1709,7 +1916,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1709
1916
  // 回车可能被冷启动中的 TUI 吞掉 (框里躺着没提交的提示)。框认得出来时判据可信,
1710
1917
  // 多补一次; 认不出来时维持原来的「只补一次」, 免得判据出错往真实对话里灌回车。
1711
1918
  const boxTrusted = boxVerify && (await readInputBox(target)).known;
1712
- const attempts = boxTrusted ? 3 : 2;
1919
+ const attempts = requireEventEvidence ? 1 : boxTrusted ? 3 : 2;
1713
1920
  for (let i = 0; i < attempts; i++) {
1714
1921
  if (i > 0) {
1715
1922
  await sleep(RETRY_SETTLE_MS);
@@ -1726,9 +1933,13 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1726
1933
  return e;
1727
1934
  if (await waitForSubmit(CLEARED_TIMEOUT_MS))
1728
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" };
1729
1938
  }
1730
- if (submitted())
1939
+ if (submitted() || await queued())
1731
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" };
1732
1943
  // 补到头还没清空。通常是渲染滞后 (回车其实落了), 但也可能是目标会话正忙 /
1733
1944
  // 上下文满了不消费输入 —— 那样用户这条消息会悄无声息地消失。不报硬失败 (用户
1734
1945
  // 多半已经被服务到了), 标 uncertain 让调用方按需提示。
@@ -1768,6 +1979,8 @@ const injectViaSpawn = async (args) => {
1768
1979
  // which Claude parses at submit time and inlines as image content blocks
1769
1980
  // without a model-decided Read tool turn.
1770
1981
  const finalText = withImageRefs(text, images);
1982
+ const eventMark = args.eventId && !text.trimStart().startsWith("/") ? markTranscript(jsonlPath) : undefined;
1983
+ const eventSince = Date.now() - 1000;
1771
1984
  const cliArgs = backend.name === "codex" ? ["exec", "resume", "--json", ...codexArgs,
1772
1985
  ...cfg.wrc.extraArgs.filter((arg) => !arg.startsWith("--disallowedTools")),
1773
1986
  ...images.flatMap((path) => ["--image", path]), sessionId, text] : [
@@ -1781,7 +1994,7 @@ const injectViaSpawn = async (args) => {
1781
1994
  ...cfg.wrc.extraArgs,
1782
1995
  ];
1783
1996
  log.info({ sessionId, bin, len: text.length }, "mirror inject");
1784
- if (args.guard && !args.guard("submit"))
1997
+ if (args.guard && !args.guard("submit", { sessionId, transcript: jsonlPath }))
1785
1998
  return { ok: false, reason: "spawn canceled by dispatch guard" };
1786
1999
  return new Promise((resolve) => {
1787
2000
  const proc = spawn(bin, cliArgs, {
@@ -1808,6 +2021,10 @@ const injectViaSpawn = async (args) => {
1808
2021
  resolve({ ok: false, reason: `${bin} exited ${code}: ${stderrTail.slice(-300)}` });
1809
2022
  return;
1810
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
+ }
1811
2028
  resolve({ ok: true });
1812
2029
  });
1813
2030
  });
@@ -1822,8 +2039,8 @@ const inject = async (args) => {
1822
2039
  return { ok: false, retryable: true, reason: "spawn permission withdrawn" };
1823
2040
  const text = args.prepareText?.() ?? args.text;
1824
2041
  let activated = false;
1825
- const result = await injectViaSpawn({ ...args, text, guard: (phase) => {
1826
- if (args.guard && !args.guard(phase))
2042
+ const result = await injectViaSpawn({ ...args, text, guard: (phase, identity) => {
2043
+ if (args.guard && !args.guard(phase, identity))
1827
2044
  return false;
1828
2045
  if (phase && !activated) {
1829
2046
  activated = true;
@@ -1849,8 +2066,8 @@ const inject = async (args) => {
1849
2066
  if (!idleApproved)
1850
2067
  return { ok: false, reason: "target has no confirmed idle evidence" };
1851
2068
  const text = args.prepareText?.() ?? args.text;
1852
- const guard = (phase) => {
1853
- if (!live() || (!exposed && !idleApproved) || (args.guard && !args.guard(phase)))
2069
+ const guard = (phase, identity) => {
2070
+ if (!live() || (!exposed && !idleApproved) || (args.guard && !args.guard(phase, identity)))
1854
2071
  return false;
1855
2072
  if (phase && !exposed) {
1856
2073
  exposed = true;
@@ -1858,7 +2075,7 @@ const inject = async (args) => {
1858
2075
  }
1859
2076
  return true;
1860
2077
  };
1861
- 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);
1862
2079
  }, { guard: args.guard }));
1863
2080
  }
1864
2081
  catch (error) {
@@ -1877,15 +2094,24 @@ const INJECT_JOB_TIMEOUT_MS = 90_000;
1877
2094
  /** 没有绑定的 pane 静默多久才算孤儿。刚 spawn、还没 attach 的 pane (并发 fan-out 时
1878
2095
  * 别人的 sweep 会撞见它) 在这段时间里一直有输出, 远大于一次 spawn → attach 的耗时。 */
1879
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
+ };
1880
2105
  const psSnap = () => new Promise((resolve) => {
1881
- 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"] });
1882
2107
  let out = "";
1883
2108
  p.stdout?.on("data", (c) => (out += c.toString("utf8")));
1884
2109
  const done = () => {
1885
2110
  const kids = new Map();
1886
2111
  const etimes = new Map();
1887
2112
  for (const line of out.split("\n")) {
1888
- 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);
1889
2115
  if (!pid || !ppid)
1890
2116
  continue;
1891
2117
  const arr = kids.get(ppid);
@@ -1893,7 +2119,7 @@ const psSnap = () => new Promise((resolve) => {
1893
2119
  arr.push(pid);
1894
2120
  else
1895
2121
  kids.set(ppid, [pid]);
1896
- if (et !== undefined && !Number.isNaN(et))
2122
+ if (!Number.isNaN(et))
1897
2123
  etimes.set(pid, et);
1898
2124
  }
1899
2125
  resolve({ kids, etimes });
@@ -1996,14 +2222,6 @@ export const startMirror = (deps) => {
1996
2222
  recentAssistantSends.splice(i, 1); // one-shot: a genuine later re-say still streams
1997
2223
  return true;
1998
2224
  };
1999
- // Keepalive ping detector for the tail's user-line judgment — the SAME
2000
- // normalized-prefix signature set keepaliveTick feeds keepaliveStamps (the
2001
- // configured ping + the bare "ping" legacy streak form), so renderLine, the
2002
- // tick, the peer readers (peek / wait / roster summary) and the transcript
2003
- // parser all agree on what counts as a ping. Content is the source of truth
2004
- // for the swallow: timers and echo-TTLs lose it across reloads/replays.
2005
- const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
2006
- const isKeepalivePing = (text) => isKeepalivePingText(text, pingSigs);
2007
2225
  // ── Typewriter stream lifecycle ────────────────────────────────────
2008
2226
  // WeCom spec: server polls us for stream refreshes for up to 6 min from the
2009
2227
  // original inbound. SDK queues replyStream calls per req_id, sends serially
@@ -2181,8 +2399,6 @@ export const startMirror = (deps) => {
2181
2399
  const sendStandalone = (a, content) => {
2182
2400
  if (!hasVisibleBody(content))
2183
2401
  return;
2184
- if (answerSilent(a))
2185
- return;
2186
2402
  if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
2187
2403
  return;
2188
2404
  const chatId = replyChatId(a);
@@ -2191,11 +2407,7 @@ export const startMirror = (deps) => {
2191
2407
  const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
2192
2408
  a.standalonePending = a.standalonePending
2193
2409
  .then(async () => {
2194
- if (silenceAnswer(a.target, turnId ?? ""))
2195
- return;
2196
2410
  for (const c of chunks) {
2197
- if (silenceAnswer(a.target, turnId ?? ""))
2198
- return;
2199
2411
  try {
2200
2412
  const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
2201
2413
  log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
@@ -2215,8 +2427,6 @@ export const startMirror = (deps) => {
2215
2427
  const turnId = a.briefTurnId ?? a.liveStream?.turnId;
2216
2428
  a.standalonePending = a.standalonePending
2217
2429
  .then(async () => {
2218
- if (silenceAnswer(a.target, turnId ?? ""))
2219
- return;
2220
2430
  try {
2221
2431
  await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
2222
2432
  log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
@@ -2488,9 +2698,9 @@ export const startMirror = (deps) => {
2488
2698
  // • 节流 —— 气泡刷新是整条内容重发, 高频 thinking 不能 1:1 打到 stream 上。
2489
2699
  const COT_MAX_CHARS = 100;
2490
2700
  const COT_FLUSH_MS = 1500;
2491
- // 压成一行的进度片段。safeForMarkdown 必须先于截断: thinking 里一个裸反引号就能提前
2492
- // 闭合代码段, 让后面的正文裸奔在链接旁边。尾随的省略号 = "还在进行中"; 截断与进行中
2493
- // 共用同一个 …, 不叠加两个。
2701
+ // 压成一行的进度片段, 纯文本上气泡 (不包代码段)。safeForMarkdown 必须先于截断:
2702
+ // 进度行与链接头同行, thinking 里的 `[]`<>` 会戳破链接与排版。尾随的省略号 =
2703
+ // "还在进行中"; 截断与进行中共用同一个 …, 不叠加两个。
2494
2704
  const cotLine = (s) => {
2495
2705
  const flat = safeForMarkdown(s.replace(/\s+/g, " ").trim());
2496
2706
  return flat ? `${flat.slice(0, COT_MAX_CHARS)}…` : "";
@@ -2502,22 +2712,25 @@ export const startMirror = (deps) => {
2502
2712
  a.cotTimer = undefined;
2503
2713
  }
2504
2714
  a.cotText = undefined;
2715
+ a.cotTool = undefined;
2505
2716
  };
2506
2717
  const flushCot = async (a) => {
2507
2718
  a.cotTimer = undefined;
2508
- const text = a.cotText;
2719
+ // 展示选取: assistant text (含 thinking) 优先; 没有新 text 时才落最新 tool_use。
2720
+ const text = a.cotText ?? a.cotTool;
2509
2721
  a.cotText = undefined;
2722
+ a.cotTool = undefined;
2510
2723
  const b = a.briefBubble;
2511
2724
  const turnId = a.briefTurnId;
2512
2725
  // 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
2513
- if (!text || !b || b.done || !turnId || a.briefConcluded || answerSilent(a))
2726
+ if (!text || !b || b.done || !turnId || a.briefConcluded)
2514
2727
  return;
2515
2728
  a.cotLastSent = text;
2516
2729
  try {
2517
2730
  await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2518
- if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || answerSilent(a))
2731
+ if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId)
2519
2732
  return;
2520
- 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);
2521
2734
  }));
2522
2735
  }
2523
2736
  catch (e) {
@@ -2537,11 +2750,25 @@ export const startMirror = (deps) => {
2537
2750
  return;
2538
2751
  a.cotTimer = setTimeout(() => void flushCot(a), COT_FLUSH_MS);
2539
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
+ };
2540
2767
  // 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
2541
2768
  // 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
2542
2769
  // wizard 名字作为 ww_uniq 传下去, 让同一个 wizard 的所有 turn 详情都复用一个 WeCom 窗口。
2543
2770
  // 头怎么写归 chatHead (默认 wizard 在 home 群: 企微只留名字链接, 微信不写头); 头与正文都空时留 `…`, 不发空气泡。
2544
- 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) || "…";
2545
2772
  // ── 出处门 (chatOriginOnly) ───────────────────────────────────────────
2546
2773
  // 人在 CLI 里手敲的一轮, 镜像只发生在两处: 他眼前的终端, 和 chat 详情页 (turn
2547
2774
  // store 全量记录 + SSE 实时刷新)。聊天里不再下发它的气泡 —— 那份内容他本来就
@@ -2549,9 +2776,7 @@ export const startMirror = (deps) => {
2549
2776
  // `[mirror]` 系统提示 —— 它们都不走这道门。
2550
2777
  // 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
2551
2778
  // 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
2552
- let silenceAnswer = () => false;
2553
- const answerSilent = (a) => silenceAnswer(a.target, a.briefTurnId ?? a.liveStream?.turnId ?? "");
2554
- const turnSilent = (a) => answerSilent(a) || isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
2779
+ const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
2555
2780
  const formatFinal = (target, turn, channel, body) => {
2556
2781
  const pieces = splitMarkdown(body, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
2557
2782
  return pieces.map((text, n) => withLinkedTagHeader(target, text, chatTurnUrl(target, turn), pieces.length > 1 ? `${n + 1}/${pieces.length}` : undefined, undefined, channel));
@@ -2562,14 +2787,16 @@ export const startMirror = (deps) => {
2562
2787
  finalProgress.delete(id);
2563
2788
  };
2564
2789
  const queueFinal = (target, turn, channel, body, eventId, bubble) => {
2565
- if (!deps.events || !channel || !body.trim())
2790
+ // 投递出口剥掉行首收口标记与 ARTIFACT 行 (peers.stripForChat): 判定读原文, 群里只看结论。
2791
+ const text = stripForChat(body);
2792
+ if (!deps.events || !channel || !text.trim())
2566
2793
  return undefined;
2567
- const revision = finalId(eventId || turn, channel, body);
2794
+ const revision = finalId(eventId || turn, channel, text);
2568
2795
  const id = finalId(eventId || turn, channel, revision);
2569
- const chunks = formatFinal(target, turn, channel, body);
2796
+ const chunks = formatFinal(target, turn, channel, text);
2570
2797
  if (bubble?.pending)
2571
2798
  finalProgress.set(id, bubble.pending);
2572
- deps.events.delivery({ id, eventId, target, turn, channel, body, revision, chunks,
2799
+ deps.events.delivery({ id, eventId, target, turn, channel, body: text, revision, chunks,
2573
2800
  ...(bubble && !bubble.done ? { stream: { frame: bubble.frame, id: bubble.streamId, expires: bubble.expires ?? Date.now() } } : {}) });
2574
2801
  if (bubble) {
2575
2802
  bubble.done = true;
@@ -2580,200 +2807,34 @@ export const startMirror = (deps) => {
2580
2807
  const recoverEventFinal = (event, bubble) => {
2581
2808
  if (!event.result || event.interjected || event.canceled)
2582
2809
  return;
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
+ })();
2583
2818
  const turn = event.turn || event.id;
2584
2819
  const record = getDetail(turn);
2585
2820
  if (!record)
2586
2821
  recordTurnStart({ id: turn, eventId: event.id, target: event.target,
2587
2822
  userQuery: event.payload.text, createdAt: event.originalAt ?? event.at, channel: event.channel,
2588
2823
  speaker: event.payload.human ? event.speaker : undefined, from: event.payload.peer?.from });
2589
- if (record?.kind !== "turn" || !record.items.some((x) => x.t === "text" && x.final && x.body === event.result.body))
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))
2590
2830
  recordTurnItem(turn, { t: "text", final: true, body: event.result.body, ts: event.result.at });
2591
2831
  recordTurnClose(turn);
2592
2832
  if (!event.turn)
2593
2833
  deps.events?.bind(event.id, turn);
2594
- const parent = event.replyTo?.kind === "chat" ? deps.events?.get(event.replyTo.turn) : undefined;
2595
- if (parent && event.channel === parent.channel) {
2596
- const original = deps.events.continueResult(event.id);
2597
- if (!original)
2598
- return;
2599
- if (eventFinalGate({ ...original, awaitingContinuation: false }))
2600
- deps.events.defer(original.id, false);
2601
- const fresh = deps.events.get(original.id);
2602
- if (fresh.replyTo?.kind === "chat")
2603
- recoverEventFinal(fresh, bubble);
2604
- else if (eventFinalGate(fresh))
2605
- queueFinal(fresh.target, fresh.turn || fresh.id, fresh.channel, event.result.body, fresh.id, bubble);
2606
- }
2607
- else if (event.channel && eventFinalGate(event))
2834
+ // 终句按事件自己的 `serves` 放行 (§9.4/§9.5): 收口与否由路由表判 (eventFinalGate)。
2835
+ if (event.channel && eventFinalGate(event))
2608
2836
  queueFinal(event.target, turn, event.channel, event.result.body, event.id, bubble);
2609
2837
  };
2610
- const answerBubbles = new Map();
2611
- const answerLine = (held, text) => withLinkedTagHeader(held.receiver, text, chatTurnUrl(held.receiver, held.sourceTurn), undefined, undefined, held.channel);
2612
- // Serialise the old owner's in-flight refresh, reset, progress and final.
2613
- // A delayed ACK must never let an earlier writer overtake the new owner.
2614
- const writeAnswerProgress = (held, text) => {
2615
- held.pending = held.pending.catch(() => undefined).then(async () => {
2616
- if (!held.active || held.stopped || !held.bubble || held.bubble.done)
2617
- return;
2618
- await client.replyStream(held.bubble.frame, held.bubble.streamId, answerLine(held, text), false);
2619
- }).catch((e) => log.warn({ err: errText(e) }, "answer transfer progress refresh failed"));
2620
- };
2621
- const updateAnswerProgress = (a, item) => {
2622
- // Queued metadata is not authority: only the input boundary activates a task.
2623
- const held = a.activePeerTurn ? answerBubbles.get(a.activePeerTurn) : undefined;
2624
- if (!held || held.receiver !== a.target || held.stopped || held.bubble?.done)
2625
- return;
2626
- const raw = item.kind === "tool_use" ? cotToolLabel(item.calls)
2627
- : item.kind === "thinking" || (item.kind === "text" && item.final !== true) ? item.body : "";
2628
- const line = cotLine(raw);
2629
- if (!line)
2630
- return;
2631
- held.progress = line;
2632
- if (!held.active)
2633
- return;
2634
- if (held.timer)
2635
- return;
2636
- held.timer = setTimeout(() => {
2637
- held.timer = undefined;
2638
- if (held.progress)
2639
- writeAnswerProgress(held, `\`${held.progress}\``);
2640
- }, COT_FLUSH_MS);
2641
- };
2642
- const answerContext = (target) => {
2643
- const a = byTarget.get(target);
2644
- if (!a || a.briefConcluded || a.briefIsSlash || isPrivate(a) || a.turnFromChat === false)
2645
- return undefined;
2646
- const turn = a.briefTurnId ?? a.liveStream?.turnId;
2647
- return turn ? { turn, channel: a.channel ?? baseOfKey(target), prefix: "" } : undefined;
2648
- };
2649
- const prepareAnswer = (id, source, expectedTurn, receiver, channel) => {
2650
- const a = byTarget.get(source);
2651
- if (!a || (a.briefTurnId ?? a.liveStream?.turnId) !== expectedTurn || answerBubbles.has(id))
2652
- return false;
2653
- if ((!a.briefBubble || a.briefBubble.done) && (!a.liveStream || a.liveStream.closed || a.liveStream.dead))
2654
- return false;
2655
- answerBubbles.set(id, {
2656
- source, receiver, sourceTurn: expectedTurn, channel, active: false, stopped: false,
2657
- bubble: a.briefBubble, stream: a.liveStream, pending: Promise.resolve(),
2658
- });
2659
- return true;
2660
- };
2661
- const cancelAnswerPreparation = (id) => {
2662
- const held = answerBubbles.get(id);
2663
- if (held && !held.active)
2664
- answerBubbles.delete(id);
2665
- };
2666
- const stopAnswerProgress = (id) => {
2667
- const held = answerBubbles.get(id);
2668
- if (!held)
2669
- return;
2670
- held.stopped = true;
2671
- clearTimeout(held.timer);
2672
- held.timer = undefined;
2673
- };
2674
- const takeAnswer = async (id, target, expectedTurn, receiver, channel) => {
2675
- const a = byTarget.get(target);
2676
- const held = answerBubbles.get(id);
2677
- if (!held || held.active || held.source !== target || held.sourceTurn !== expectedTurn || held.receiver !== receiver || held.channel !== channel)
2678
- return { taken: false, cleared: false };
2679
- const sameTurn = a && (a.briefTurnId ?? a.liveStream?.turnId) === expectedTurn;
2680
- if (sameTurn) {
2681
- clearCot(a);
2682
- a.cotLastSent = undefined;
2683
- if (a.standaloneBuf) {
2684
- clearTimeout(a.standaloneBuf.timer);
2685
- a.standaloneBuf = undefined;
2686
- }
2687
- // Only this source turn becomes private; a concurrent new human turn is untouched.
2688
- a.channel = "";
2689
- }
2690
- const s = held.stream;
2691
- if (s && !s.closed && !s.dead) {
2692
- s.closed = true;
2693
- clearTimeout(s.flushTimer);
2694
- clearTimeout(s.idleTimer);
2695
- clearTimeout(s.hardTimer);
2696
- // Read the current chain, not prepare's snapshot: source may have flushed during injection.
2697
- held.bubble = { frame: s.frame, streamId: s.streamId, done: false, pending: s.pending };
2698
- if (a?.liveStream === s)
2699
- a.liveStream = undefined;
2700
- }
2701
- else if (s)
2702
- held.bubble = undefined;
2703
- const b = held.bubble;
2704
- held.active = true;
2705
- if (!b || b.done)
2706
- return { taken: true, cleared: false };
2707
- clearTimeout(b.hardTimer);
2708
- if (a?.briefBubble === b)
2709
- a.briefBubble = undefined;
2710
- let cleared = false;
2711
- held.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2712
- if (b.done)
2713
- return;
2714
- await client.replyStream(b.frame, b.streamId, answerLine(held, "…"), false);
2715
- cleared = true;
2716
- });
2717
- try {
2718
- await held.pending;
2719
- }
2720
- catch (e) {
2721
- cleared = false;
2722
- log.warn({ id, err: errText(e) }, "answer transfer reset not acknowledged");
2723
- }
2724
- // Keep delivery possible after a reset error; reset never carries the final answer.
2725
- held.pending = held.pending.catch(() => undefined);
2726
- if (!held.stopped && held.progress)
2727
- writeAnswerProgress(held, `\`${held.progress}\``);
2728
- return { taken: true, cleared };
2729
- };
2730
- const deliverAnswer = async (id, target, turn, channel, _prefix, body, standalone, frozen = false) => {
2731
- const held = answerBubbles.get(id);
2732
- const text = body.trim();
2733
- const content = frozen ? body : withLinkedTagHeader(target, text, chatTurnUrl(target, turn), undefined, undefined, channel);
2734
- stopAnswerProgress(id);
2735
- if (held?.bubble && !held.bubble.done) {
2736
- held.bubble.done = true;
2737
- clearTimeout(held.timer);
2738
- await held.pending;
2739
- try {
2740
- // Failure closes progress first; the terminal explanation is a standalone message.
2741
- const progress = withLinkedTagHeader(target, "回答已转交", chatTurnUrl(target, turn), undefined, undefined, channel);
2742
- const ack = frozen && !standalone
2743
- ? await deps.sendFrozen(channel, content, { frame: held.bubble.frame, id: held.bubble.streamId })
2744
- : await client.replyStream(held.bubble.frame, held.bubble.streamId, standalone ? progress : content, true);
2745
- if (!standalone) {
2746
- answerBubbles.delete(id);
2747
- return ack;
2748
- }
2749
- }
2750
- catch (e) {
2751
- const ack = e && typeof e === "object" ? e : undefined;
2752
- // Only a structured non-zero server ACK proves the stream frame was
2753
- // rejected. Timeout/disconnect errors are ambiguous: standalone retry
2754
- // could publish the same final answer twice, so let the caller persist
2755
- // `uncertain` and require a human resend instead.
2756
- const rejected = !!ack && typeof ack.headers === "object" && !!ack.headers && typeof ack.errcode === "number" && ack.errcode !== 0 && typeof ack.errmsg === "string";
2757
- if (!rejected && !standalone) {
2758
- answerBubbles.delete(id);
2759
- throw e;
2760
- }
2761
- if (!rejected) {
2762
- log.warn({ id, err: errText(e) }, "answer transfer progress close acknowledgement uncertain; continuing with the one standalone terminal explanation");
2763
- // In standalone mode this stream only closes progress; it never
2764
- // carries the final body, so sending the terminal explanation once
2765
- // remains necessary even when progress-close ACK is ambiguous.
2766
- }
2767
- else {
2768
- log.warn({ id, err: errText(e), errcode: ack.errcode }, "answer transfer stream was explicitly rejected; standalone fallback");
2769
- }
2770
- }
2771
- }
2772
- const ack = frozen ? await deps.sendFrozen(channel, content)
2773
- : await client.sendMessage(channel.replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content } });
2774
- answerBubbles.delete(id);
2775
- return ack;
2776
- };
2777
2838
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
2778
2839
  // raw=true skips withTagHeader (used when content already contains the linked tag header).
2779
2840
  // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
@@ -2846,6 +2907,8 @@ export const startMirror = (deps) => {
2846
2907
  const publicPeer = (from) => from?.kind === "peer" && from.public ? from.from : undefined;
2847
2908
  /** reload 后 tail 从 EOF 接上: 正在跑的这一轮的开头那句已在身后, 它的印章随旧进程没了。
2848
2909
  * 那句话自己挂着信封 (谁说的、私聊还是哪个群) —— 读回来就是这一轮的出处。没挂信封 = undefined。 */
2910
+ /** 这一轮挂在哪一笔上: 回执 (与老派活信封) 写在信封上; 新派活信封不带, 问注入事件的 `serves`。 */
2911
+ const rowOf = (env) => env.event ?? (env.eventId ? deps.events?.get(env.eventId)?.serves : undefined);
2849
2912
  const openingOf = (a) => {
2850
2913
  const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
2851
2914
  const env = t?.env;
@@ -2853,7 +2916,9 @@ export const startMirror = (deps) => {
2853
2916
  if (!env)
2854
2917
  return undefined;
2855
2918
  const home = baseOfKey(a.target);
2856
- 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 } : {}) }
2857
2922
  : env.kind === "task" ? { kind: "task", taskId: env.from.replace(/^定时 /, "") }
2858
2923
  : undefined;
2859
2924
  return {
@@ -2903,7 +2968,7 @@ export const startMirror = (deps) => {
2903
2968
  }
2904
2969
  // 私聊派来的活 (不是回执) 这一轮必是私聊, 不论印章带没带频道: 进群的只能是人问的事的
2905
2970
  // 交代与显式公开。带 task 的 fork 分身就只有出处、没有频道印章, 退回 home 就把私聊派的活答进了群。
2906
- if (from?.kind === "peer" && !from.public && !from.receipt) {
2971
+ if (from?.kind === "peer" && !from.public && !from.receipt && !from.transferred) {
2907
2972
  a.channel = "";
2908
2973
  a.speaker = undefined;
2909
2974
  a.replyTo = undefined;
@@ -2964,7 +3029,6 @@ export const startMirror = (deps) => {
2964
3029
  closeBriefTurn(a);
2965
3030
  a.activeEventId = s.eventId;
2966
3031
  s.activated = true;
2967
- a.activePeerTurn = undefined;
2968
3032
  a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
2969
3033
  a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
2970
3034
  a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
@@ -3014,7 +3078,6 @@ export const startMirror = (deps) => {
3014
3078
  closeBriefTurn(a);
3015
3079
  a.activeEventId = s.eventId;
3016
3080
  const now = Date.now();
3017
- a.activePeerTurn = s.from?.kind === "peer" ? s.from.turn : undefined;
3018
3081
  a.pendingFrom = s.from ? { from: s.from, at: now } : undefined;
3019
3082
  a.pendingOrigin = s.origin ? { origin: s.origin, at: now } : undefined;
3020
3083
  // 没写频道的注入 (graph 步骤 / 手动 inject) 等同在 home 里开口: 印章就是全部出处, 不沿用上一轮的。
@@ -3087,7 +3150,6 @@ export const startMirror = (deps) => {
3087
3150
  a.channel = r.channel === undefined || r.channel === baseOfKey(a.target) ? undefined : r.channel;
3088
3151
  a.speaker = r.speaker;
3089
3152
  a.convFrom = r.from;
3090
- a.activePeerTurn = r.from?.kind === "peer" ? r.from.turn : undefined;
3091
3153
  a.replyTo = publicPeer(r.from);
3092
3154
  a.turnFromChat = a.turnFromChat ?? true;
3093
3155
  a.briefTurnId = r.id;
@@ -3175,12 +3237,27 @@ export const startMirror = (deps) => {
3175
3237
  const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
3176
3238
  if (event)
3177
3239
  deps.events.result(event.id, body, finalId(event.id, event.channel, body), turnId);
3178
- if (!answerSilent(a) && !turnSilent(a) && (!event || eventFinalGate(event))) {
3240
+ const gated = !!event && !eventFinalGate(event);
3241
+ if (!turnSilent(a) && !gated) {
3179
3242
  if (event)
3180
3243
  recoverEventFinal(deps.events.get(event.id), a.briefBubble);
3181
3244
  else
3182
3245
  queueFinal(a.target, turnId, a.channel ?? baseOfKey(a.target), body, undefined, a.briefBubble);
3183
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
+ }
3184
3261
  // The durable result/outbox must exist before this in-memory dedup bit advances.
3185
3262
  a.briefConcluded = true;
3186
3263
  a.lastConcludedBody = body;
@@ -3219,6 +3296,8 @@ export const startMirror = (deps) => {
3219
3296
  voidTurnAgents(a);
3220
3297
  closeSubagentTurns(a); // 父 turn 收口 = 它派出的 subagent 都已结束
3221
3298
  clearCot(a);
3299
+ // 它闲下来了: 收件箱里等它闲的回执 / 派活此刻就该贴, 不等下一拍定时泵。
3300
+ void deps.events?.pump();
3222
3301
  };
3223
3302
  // ── Subagent turns ─────────────────────────────────────────────────
3224
3303
  // Task/Agent 工具派出的子 agent 在自己的转录文件里跑 (主 jsonl 只有派发与最终
@@ -3307,10 +3386,10 @@ export const startMirror = (deps) => {
3307
3386
  closeSubagentRun(a, agentId);
3308
3387
  return;
3309
3388
  }
3310
- // brief 气泡的实时进度: 静默窗 (keepalive 吞没 / 新 pane 未注入) 不推;
3389
+ // brief 气泡的实时进度: 静默窗 (新 pane 未注入) 不推;
3311
3390
  // updateBriefProgress 自身会检查气泡是否仍开。final text 之后的 end 会很快
3312
3391
  // 收口气泡, 进度行不会残留。
3313
- if (a.muteUntilInject || a.keepaliveQuiet || a.keepaliveByContent)
3392
+ if (a.muteUntilInject)
3314
3393
  return;
3315
3394
  const activity = item.kind === "tool_use"
3316
3395
  ? cotToolLabel(item.calls)
@@ -3412,7 +3491,7 @@ export const startMirror = (deps) => {
3412
3491
  const now = Date.now();
3413
3492
  if (item.kind === "tool_use") {
3414
3493
  markBriefTool(a);
3415
- updateBriefProgress(a, cotToolLabel(item.calls));
3494
+ updateBriefToolProgress(a, cotToolLabel(item.calls));
3416
3495
  for (const c of item.calls) {
3417
3496
  // 也走单卡 detail record — turn 页里 tool_use 段可点开链到独立详情 (以后有需要时)。
3418
3497
  if (c.toolUseId) {
@@ -3602,70 +3681,15 @@ export const startMirror = (deps) => {
3602
3681
  if (a.liveStream && !a.liveStream.closed)
3603
3682
  void finalizeStream(a, a.liveStream);
3604
3683
  };
3605
- // End the keepalive swallow — the ping turn is over (turn_end / genuine
3606
- // user line / inject failure), so real items flow through onItem again.
3607
- // Clears BOTH the timer window and the content flag.
3608
- const endKeepaliveSwallow = (a) => {
3609
- if (a.keepaliveQuiet) {
3610
- clearTimeout(a.keepaliveQuiet);
3611
- a.keepaliveQuiet = undefined;
3612
- }
3613
- if (a.keepaliveContentTimer) {
3614
- clearTimeout(a.keepaliveContentTimer);
3615
- a.keepaliveContentTimer = undefined;
3616
- }
3617
- a.keepaliveByContent = undefined;
3618
- };
3619
- // Content-based swallow entry: the tailed user line IS a configured ping.
3620
- // Belt to the inject-time timer window — this one keys on the user message
3621
- // itself, so it holds across daemon reloads, tail replays and replies
3622
- // slower than the 60s fail-safe. Reuses the detail turn the timer flow may
3623
- // have opened (fireKeepalive skips opening when this got there first).
3624
- const KEEPALIVE_CONTENT_FAILSAFE_MS = 5 * 60_000;
3625
- const PONG_RE = /pong/i;
3626
- const beginKeepaliveByContent = (a, text) => {
3627
- a.keepaliveByContent = true;
3628
- if (a.keepaliveContentTimer)
3629
- clearTimeout(a.keepaliveContentTimer);
3630
- // Fail-safe: a ping turn whose reply never emits turn_end (crash/hang)
3631
- // must not mute a later real turn forever.
3632
- a.keepaliveContentTimer = setTimeout(() => {
3633
- a.keepaliveContentTimer = undefined;
3634
- a.keepaliveByContent = undefined;
3635
- }, KEEPALIVE_CONTENT_FAILSAFE_MS);
3636
- if (!a.keepaliveTurnId) {
3637
- const turnId = newTurnId();
3638
- a.keepaliveTurnId = turnId;
3639
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
3640
- }
3641
- };
3642
- // A keepalive ping turn is swallowed wholesale — the reply content doesn't
3643
- // matter (model may add extra commentary beyond "pong", that's fine).
3644
3684
  const onItem = (a, item) => {
3645
3685
  // 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
3646
3686
  if (a.muteUntilInject)
3647
3687
  return;
3648
- // Keepalive ping turns are cache-warmers: swallow every item from every
3649
- // WeCom path so the ping/pong never reaches chat. Two triggers, either
3650
- // suffices — the timer window opened at inject (keepaliveQuiet) OR the
3651
- // user line itself matching a configured ping (keepaliveByContent; the
3652
- // reload/replay/slow-reply-proof one). The REAL exchange is recorded
3653
- // into its chat-detail turn — the actual assistant reply, the tool calls
3654
- // if any, and the usage — so the detail page shows the genuine heartbeat.
3655
- if (item.kind === "keepalive_start") {
3656
- beginKeepaliveByContent(a, item.body);
3657
- return;
3658
- }
3659
- // A genuine user line (not our inject, not a ping) is a hard turn
3660
- // boundary — any keepalive swallow still holding never got its turn_end
3661
- // (crashed reply). Release BEFORE the swallow gate so this real line (and
3662
- // its reply) mirror normally instead of being eaten by the stale swallow.
3663
3688
  if (item.kind === "user_text") {
3664
3689
  // 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
3665
3690
  // 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
3666
3691
  if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts, item.eventId))
3667
3692
  return;
3668
- endKeepaliveSwallow(a);
3669
3693
  // 对话边界 = 旧 turn 清算。上一 turn 派发而未返回的前台 Agent/Task 到此作废,
3670
3694
  // 清掉防残留阻塞新 turn 的软收口 (guard 的 defer 判断依据)。
3671
3695
  voidTurnAgents(a);
@@ -3675,7 +3699,6 @@ export const startMirror = (deps) => {
3675
3699
  // 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
3676
3700
  a.pendingBriefQuery = item.body;
3677
3701
  a.pendingBriefAt = item.ts;
3678
- a.activePeerTurn = undefined;
3679
3702
  a.pendingFrom = undefined;
3680
3703
  a.pendingChannel = undefined;
3681
3704
  a.activeEventId = item.eventId;
@@ -3703,46 +3726,9 @@ export const startMirror = (deps) => {
3703
3726
  recordTurnQuery(a.briefTurnId, item.body, (prev) => cleanUserText(prev ?? "") === item.said);
3704
3727
  return;
3705
3728
  }
3706
- // 后台派发的结束信号 —— 它不是 function_call_result (那只是 spawn 句柄)。放在
3707
- // keepalive 门之前: 吞没窗口里到达也必须销账, 否则账本永久悬空。
3729
+ // 后台派发的结束信号 —— 它不是 function_call_result (那只是 spawn 句柄)。
3708
3730
  if (item.kind === "skill_output" && item.agentDone)
3709
3731
  clearBackgroundAgent(a, item.agentDone);
3710
- // Reply-side belt: a "pong" arriving with NO swallow holding. Both triggers
3711
- // above live in memory, and the tail re-attaches at EOF — a reload between
3712
- // the ping and its reply forgets the ping was ever sent, and the pong opens
3713
- // a chat turn of its own. So ask the transcript: if the last thing said to
3714
- // the model is a keepalive ping, this text is its pong — swallow from here
3715
- // to the turn_end, recorded as the same marked detail turn. Gated on the
3716
- // transcript, not on the word alone: a real answer that mentions pong has a
3717
- // real question in front of it.
3718
- if (item.kind === "text" && !a.keepaliveQuiet && !a.keepaliveByContent && PONG_RE.test(item.body)) {
3719
- const ping = openKeepalivePing(a.jsonlPath, pingSigs);
3720
- if (ping)
3721
- beginKeepaliveByContent(a, ping);
3722
- }
3723
- if (a.keepaliveQuiet || a.keepaliveByContent) {
3724
- const id = a.keepaliveTurnId;
3725
- if (id) {
3726
- const now = Date.now();
3727
- if (item.kind === "text") {
3728
- recordTurnItem(id, { t: "text", body: item.body, ts: now, final: item.final === true });
3729
- }
3730
- else if (item.kind === "tool_result") {
3731
- recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
3732
- }
3733
- else if (item.kind === "turn_usage") {
3734
- recordTurnUsage(id, { model: item.model, effort: item.effort, messageId: item.messageId, usage: item.usage });
3735
- }
3736
- else if (item.kind === "turn_end") {
3737
- recordTurnClose(id);
3738
- a.keepaliveTurnId = undefined;
3739
- }
3740
- }
3741
- if (item.kind === "turn_end") {
3742
- endKeepaliveSwallow(a);
3743
- }
3744
- return;
3745
- }
3746
3732
  // 父 agent 自己的新产出到达 = 上一条"消息写完了"并不代表这一轮结束 → 撤销待确认
3747
3733
  // 的软收口。硬信号 (end_turn / turn_duration) 的后端永远不会走到这里。
3748
3734
  //
@@ -3766,7 +3752,6 @@ export const startMirror = (deps) => {
3766
3752
  // 的配置下, 人在 CLI 敲的那一行就这么原样回显进了群, 正好破了 chatOriginOnly。
3767
3753
  if (item.kind === "user_text" && (item.quiet || cfg.wrc.mirror.brief))
3768
3754
  return;
3769
- updateAnswerProgress(a, item);
3770
3755
  // thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
3771
3756
  // 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
3772
3757
  // 那就变回已下线的 thinkStyle 了。
@@ -4090,7 +4075,7 @@ export const startMirror = (deps) => {
4090
4075
  const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
4091
4076
  ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
4092
4077
  : "";
4093
- 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 }) => {
4094
4079
  const target = resolveTarget(targetOverride);
4095
4080
  if (!target)
4096
4081
  return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
@@ -4121,9 +4106,6 @@ export const startMirror = (deps) => {
4121
4106
  // onto an already-running pane doesn't, so it keeps what was recorded.
4122
4107
  const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
4123
4108
  const carryEffort = effort !== undefined ? effort.trim() : (prevByTarget?.effort ?? deps.store.get(target)?.effort ?? "");
4124
- // Same carry rule again: an explicit spawn-time choice wins, otherwise this
4125
- // wizard's existing policy (in-memory, or on disk across a reload) sticks.
4126
- const carryKeepaliveDisabled = keepaliveDisabled !== undefined ? keepaliveDisabled : (prevByTarget?.keepaliveDisabled ?? deps.store.get(target)?.keepaliveDisabled ?? false);
4127
4109
  if (prevByTarget)
4128
4110
  detach(prevByTarget, "target reassigned");
4129
4111
  // Build the attachment first so the tail's onItem closure can capture it.
@@ -4140,7 +4122,6 @@ export const startMirror = (deps) => {
4140
4122
  pendingCwd: carryPending,
4141
4123
  model: carryModel,
4142
4124
  effort: carryEffort,
4143
- keepaliveDisabled: carryKeepaliveDisabled,
4144
4125
  tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
4145
4126
  standalonePending: Promise.resolve(),
4146
4127
  recentToolSigs: new Map(),
@@ -4155,7 +4136,6 @@ export const startMirror = (deps) => {
4155
4136
  toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
4156
4137
  isOwnInject,
4157
4138
  isOwnAssistantSend,
4158
- isKeepalivePing,
4159
4139
  onItem: (item) => onItem(a, item),
4160
4140
  onCompact: () => contextCut(a.target, "compact"),
4161
4141
  detailUrlFor,
@@ -4168,9 +4148,6 @@ export const startMirror = (deps) => {
4168
4148
  startSubagentsFor(a);
4169
4149
  bySessionId.set(sessionId, a);
4170
4150
  byTarget.set(target, a);
4171
- // Preserve a persisted `/stop` pause across the re-attach: restore rebuilds the
4172
- // record here, and dropping keepaliveOff would resurrect a session the user
4173
- // explicitly quieted. restoreFromStore re-hydrates the in-memory flags below.
4174
4151
  const prevRec = deps.store.get(target);
4175
4152
  deps.store.set(target, {
4176
4153
  sessionId,
@@ -4182,9 +4159,6 @@ export const startMirror = (deps) => {
4182
4159
  effort: a.effort || undefined,
4183
4160
  pendingCwd: a.pendingCwd || undefined,
4184
4161
  cwdConfirmed: prevRec?.cwdConfirmed,
4185
- keepaliveOff: prevRec?.keepaliveOff,
4186
- keepaliveOffAt: prevRec?.keepaliveOffAt,
4187
- keepaliveDisabled: carryKeepaliveDisabled || undefined,
4188
4162
  });
4189
4163
  log.info({ sessionId, jsonlPath, target, tmuxSession: a.tmuxSession, runningCwd: a.runningCwd, pendingCwd: a.pendingCwd, mirrors: bySessionId.size }, "mirror attached");
4190
4164
  return { ok: true, sessionId, jsonlPath, target };
@@ -4197,16 +4171,14 @@ export const startMirror = (deps) => {
4197
4171
  // the pane is alive — and tmux pane ids monotonically increment within a
4198
4172
  // server lifetime, so a freed id will not be silently reused. We don't need
4199
4173
  // the session name, which matters because some attaches carry only a pane id.
4200
- const tmuxPaneAlive = async (paneId) => {
4201
- if (!paneId)
4202
- return false;
4203
- const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
4204
- return r.code === 0 && r.stdout.trim() === paneId;
4205
- };
4206
- // 注入前的存活判定要多问一句「里面还有 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,
4207
4179
  // 照旧往里贴就是把用户的话当 shell 命令跑。判否即走 respawn (旧 pane 不杀 —— 它可能是
4208
4180
  // 用户自己的终端, 经 switch 绑进来的)。
4209
- 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";
4210
4182
  // Re-attach a stored binding for `principal`. Returns the resulting state, or
4211
4183
  // undefined if the on-disk transcript is gone (in which case the entry is
4212
4184
  // dropped so the next inbound flows through /new auto-spawn).
@@ -4221,6 +4193,9 @@ export const startMirror = (deps) => {
4221
4193
  // 文件。同一个 cwd 下别的 target 的迁移 / 自愈 / 分叉跟随也在扫这个目录里「新出现的
4222
4194
  // jsonl」, 不挡住的话它会先一步把别人的新会话认走, 正主 attach 时反撞 "already bound"。
4223
4195
  const inFlightSids = new Set();
4196
+ // 已 spawn、还没 attach 的 pane: 分身要先说开场白、等分叉文件才能绑定, 这段里它在
4197
+ // tmux 里活着却不在任何绑定上 —— 孤儿清扫必须认得它, 不能只靠进程年龄宽限。
4198
+ const spawningPanes = new Set();
4224
4199
  const sidsClaimedByOthers = (principal) => {
4225
4200
  const s = new Set(inFlightSids);
4226
4201
  for (const [k, v] of Object.entries(deps.store.all()))
@@ -4348,25 +4323,10 @@ export const startMirror = (deps) => {
4348
4323
  log.warn({ principal, reason: r.reason }, "mirror restore: re-attach failed");
4349
4324
  return undefined;
4350
4325
  }
4351
- // Re-hydrate the `/stop` pause so a reload doesn't resume pinging a quieted
4352
- // session (attach preserved it on disk; this puts it back in memory).
4353
- // keepaliveDisabled is carried by attach() itself (same rule as `model`);
4354
- // only the transient `/stop` pause needs manual re-hydration here.
4355
4326
  const restored = byTarget.get(principal);
4356
- if (restored && rec.keepaliveOff) {
4357
- restored.keepaliveOff = true;
4358
- restored.keepaliveOffAt = rec.keepaliveOffAt;
4359
- }
4360
4327
  log.info({ principal, sessionId: rec.sessionId, livePane: livePane || "(spawn-mode)" }, "mirror restored from store");
4361
4328
  return restored;
4362
4329
  };
4363
- // Write the live `/stop` pause state through to the store (merging, not
4364
- // clobbering, the rest of the record) so it survives a daemon reload.
4365
- const persistPause = (a) => {
4366
- const rec = deps.store.get(a.target);
4367
- if (rec)
4368
- deps.store.set(a.target, { ...rec, keepaliveOff: a.keepaliveOff, keepaliveOffAt: a.keepaliveOffAt, keepaliveDisabled: a.keepaliveDisabled });
4369
- };
4370
4330
  // We re-attach lazily on demand too (see restoreFromStore in dispatch /
4371
4331
  // hasMirrorTarget), but eager boot-restore makes outbound (tail → push) work
4372
4332
  // even before any inbound arrives — e.g. claude finishes a long-running
@@ -4575,15 +4535,6 @@ export const startMirror = (deps) => {
4575
4535
  markCut(a.target, newSessionId, cleared ? "clear" : "switch");
4576
4536
  if (cleared)
4577
4537
  contextCut(a.target, "clear");
4578
- if (cleared) {
4579
- // 真清空 = 缓存里已无任何值得保温的内容。像 /stop 一样暂停保活 —— 与
4580
- // dispatch 里 WeCom 注入 /clear 的暂停对齐;TUI 手打 /clear 只走这个漏斗,
4581
- // 不在这里停下的话旧时钟会继续 ping 一个空上下文。busy-resume(过 grace)
4582
- // 或真实 inbound 解除。下方 store.set 一并落盘。
4583
- a.keepaliveOff = true;
4584
- a.keepaliveOffAt = Date.now();
4585
- a.keepalive = undefined;
4586
- }
4587
4538
  a.tail.stop();
4588
4539
  bySessionId.delete(oldSessionId);
4589
4540
  a.sessionId = newSessionId;
@@ -4599,7 +4550,6 @@ export const startMirror = (deps) => {
4599
4550
  toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
4600
4551
  isOwnInject,
4601
4552
  isOwnAssistantSend,
4602
- isKeepalivePing,
4603
4553
  onItem: (item) => onItem(a, item),
4604
4554
  onCompact: () => contextCut(a.target, "compact"),
4605
4555
  detailUrlFor,
@@ -4619,10 +4569,6 @@ export const startMirror = (deps) => {
4619
4569
  tmuxPane: a.tmuxPane || undefined,
4620
4570
  cwd: a.runningCwd || undefined,
4621
4571
  pendingCwd: a.pendingCwd || undefined,
4622
- // store.set 是整记录替换 —— 必须带上暂停态,否则迁移会把 dispatch 刚落盘
4623
- // 的 /clear 暂停(或之前的 /stop 暂停)从盘上抹掉,reload 后保活复活。
4624
- keepaliveOff: a.keepaliveOff || undefined,
4625
- keepaliveOffAt: a.keepaliveOffAt || undefined,
4626
4572
  });
4627
4573
  log.info({ target: a.target, oldSessionId, newSessionId, oldJsonlPath, newJsonlPath, startOffset }, "mirror migrated session");
4628
4574
  };
@@ -4856,6 +4802,10 @@ export const startMirror = (deps) => {
4856
4802
  // different project dir whose live session has a different sid, migrate onto
4857
4803
  // it. Tail from EOF (undefined startOffset): the worktree session already
4858
4804
  // carries full history we don't want to re-dump to WeCom.
4805
+ //
4806
+ // 同目录的轮换 (人在 TUI 里敲 `/clear`、或 CLI 自己换了会话) 走下面同一张表的 sameDir
4807
+ // 分支 —— 目录没变, 但「旧 jsonl 从此没人写」的后果一模一样, 而它比 worktree 常见得多
4808
+ // (人就在 pane 里敲命令)。
4859
4809
  let paneDriftTicking = false;
4860
4810
  const followPaneDrift = async () => {
4861
4811
  if (paneDriftTicking)
@@ -4891,8 +4841,26 @@ export const startMirror = (deps) => {
4891
4841
  continue;
4892
4842
  }
4893
4843
  const paneDir = projectDirFor(a.jsonlPath, expandHome(cwd));
4894
- if (backendForPath(a.jsonlPath).name === "codex" ? expandHome(cwd) === a.runningCwd : paneDir === dirname(a.jsonlPath))
4895
- 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
+ }
4896
4864
  const live = liveSessionForCwd(cwd, backendForPath(a.jsonlPath), sidsClaimedByOthers(a.target));
4897
4865
  if (!live || live.sessionId === a.sessionId)
4898
4866
  continue; // empty dir, or same-sid rename (tail follows it)
@@ -5033,7 +5001,7 @@ export const startMirror = (deps) => {
5033
5001
  cfg,
5034
5002
  log: log.child({ sub: "new-session", target }),
5035
5003
  sessionId: freshSid,
5036
- windowName: windowName ?? target,
5004
+ windowName: windowLabel(target, windowName),
5037
5005
  cwdOverride: eff,
5038
5006
  cli: effCli,
5039
5007
  model: effModel,
@@ -5065,7 +5033,6 @@ export const startMirror = (deps) => {
5065
5033
  // raw request — a later respawn's fast path then matches on try one.
5066
5034
  model: r.model ?? effModel ?? "",
5067
5035
  effort: r.effort ?? "",
5068
- keepaliveDisabled: opts?.keepalive === undefined ? undefined : !opts.keepalive,
5069
5036
  });
5070
5037
  inFlightSids.delete(freshSid);
5071
5038
  if (!att.ok)
@@ -5079,14 +5046,6 @@ export const startMirror = (deps) => {
5079
5046
  // 只有换过 pane 才是断点 —— 首次建会话时 prev 为空, 那不是"不连续", 是开局。
5080
5047
  if (prev)
5081
5048
  markCut(target, spawned.sessionId, "new");
5082
- // A freshly spawned session is empty — nothing in the cache to keep warm.
5083
- // Pause keepalive like /stop; the first real turn (WeCom inbound, or the
5084
- // pane going busy after the resume grace) re-earns the budget. Mirrors
5085
- // interruptPane so /new can't strand a ping loop on an idle blank session.
5086
- spawned.keepaliveOff = !opts?.warm;
5087
- spawned.keepaliveOffAt = Date.now();
5088
- spawned.keepalive = undefined; // re-anchors cleanly on resume
5089
- persistPause(spawned);
5090
5049
  }
5091
5050
  // Clear the chat's pendingCwd on the BASE record too — the queued switch
5092
5051
  // has just been consumed by this respawn. Without this, a subsequent /new
@@ -5176,7 +5135,7 @@ export const startMirror = (deps) => {
5176
5135
  const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
5177
5136
  if (!canInherit) {
5178
5137
  const r = await newSession(args.target, args.windowName, args.cli, {
5179
- 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,
5180
5139
  });
5181
5140
  return { ...r, inherited: false };
5182
5141
  }
@@ -5195,7 +5154,7 @@ export const startMirror = (deps) => {
5195
5154
  log: lg,
5196
5155
  resumeSessionId: parent.sessionId,
5197
5156
  forkSession: true,
5198
- windowName: args.windowName ?? args.target,
5157
+ windowName: windowLabel(args.target, args.windowName),
5199
5158
  cwdOverride: parent.runningCwd,
5200
5159
  cli: parentCli,
5201
5160
  model,
@@ -5204,51 +5163,61 @@ export const startMirror = (deps) => {
5204
5163
  });
5205
5164
  if (!r.ok)
5206
5165
  return { ok: false, reason: r.reason, inherited: false };
5207
- // 分叉文件不是开机就有的: CLI 在这个 pane **收到第一条消息**的那一刻才把父亲的
5208
- // transcript 复制成新文件。所以必须先说一句话, 分身才会有自己的 sid —— 这句话
5209
- // 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」)。
5210
- const hello = args.bootstrap?.trim()
5211
- || "(wezard 分身初始化)用一句话说明: 你是谁、从谁那里继承了哪些上下文、准备接什么活。不要复述这条指令。";
5212
- rememberInject(hello);
5213
- const boot = await inject({
5214
- text: hello, images: [], cfg, log: lg,
5215
- sessionId: parentCli === "codex" ? r.sessionId : parent.sessionId, jsonlPath: parentCli === "codex" ? r.jsonlPath : parent.jsonlPath,
5216
- tmuxTarget: r.tmuxPane, freshSpawn: true, guard: args.guard,
5217
- });
5218
- if (!boot.ok) {
5219
- await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
5220
- return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
5221
- }
5222
- const fork = parentCli === "codex" ? { sessionId: r.sessionId, jsonlPath: r.jsonlPath } : await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
5223
- homeDir: backendForPath(parent.jsonlPath).homeDir, pane: r.tmuxPane ?? "", parentSid: parent.sessionId,
5224
- });
5225
- // 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
5226
- // 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
5227
- // 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
5228
- if (!fork) {
5229
- await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
5230
- return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
5231
- }
5232
- 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 挡住别人。
5233
5216
  inFlightSids.delete(fork.sessionId);
5234
- // The bootstrap may already have executed; withhold binding, report the
5235
- // unconfirmed birth to its caller instead of claiming cancellation success.
5236
- return { ok: false, reason: "fork executed after permission withdrawal; reconcile pane manually", inherited: false };
5237
5217
  }
5238
- const att = attach({
5239
- sessionId: fork.sessionId,
5240
- jsonlPath: fork.jsonlPath,
5241
- target: args.target,
5242
- tmuxPane: r.tmuxPane,
5243
- tmuxSession: r.tmuxSession,
5244
- cwd: r.cwd,
5245
- pendingCwd: "",
5246
- model: r.model ?? model,
5247
- effort: r.effort ?? "",
5248
- keepaliveDisabled: args.keepalive === undefined ? undefined : !args.keepalive,
5249
- });
5250
- // 绑上 (或没绑上) 之后预留就没用了: 绑上的 sid 由 sidsClaimedByOthers 挡住别人。
5251
- inFlightSids.delete(fork.sessionId);
5218
+ finally {
5219
+ spawningPanes.delete(pane);
5220
+ }
5252
5221
  if (!att.ok)
5253
5222
  return { ok: false, reason: att.reason, inherited: false };
5254
5223
  const spawned = byTarget.get(args.target);
@@ -5256,10 +5225,6 @@ export const startMirror = (deps) => {
5256
5225
  // muteUntilInject 是给"全新 pane 的 greeting 噪音"准备的; 分身的开场白是正经
5257
5226
  // 内容 (rolepage 上它的第一轮), 所以这里不静音 —— 进不进群由下面的频道印章定。
5258
5227
  spawned.justSpawned = true;
5259
- spawned.keepaliveOff = true;
5260
- spawned.keepaliveOffAt = Date.now();
5261
- spawned.keepalive = undefined;
5262
- persistPause(spawned);
5263
5228
  // 开场白早在 attach 之前就落进分叉文件了 (tail 从 EOF 起, 读不到那一行): 第一轮懒建时
5264
5229
  // 才认得出它是谁派的活。印章赶在 tail 第一次 drain 之前盖上。
5265
5230
  if (args.bootstrapFrom) {
@@ -5489,18 +5454,40 @@ export const startMirror = (deps) => {
5489
5454
  };
5490
5455
  const paneOf = (target) => byTarget.get(target)?.tmuxPane || deps.store.get(target)?.tmuxPane || "";
5491
5456
  const jsonlOf = (target) => expandHome(byTarget.get(target)?.jsonlPath || deps.store.get(target)?.jsonlPath || "");
5492
- // 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
5493
5470
  // reported idle, not busy: "nothing is running there" is the truthful answer
5494
5471
  // and it keeps a graph step from blocking forever on a session that vanished.
5495
5472
  const paneBusy = async (pane) => !!pane && (await tmuxPaneAlive(pane)) && paneIsBusy(await capturePaneTail(pane, 12));
5496
- 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
+ };
5497
5477
  // 「它这一轮结束了、可以投了」。注册表认得这个 pane 时以它为准: status 在整轮里
5498
5478
  // (工具调用之间、PreToolUse 审批 hook 挂着时) 一直是 busy, 只在轮结束那一刻翻成
5499
5479
  // idle —— 所以不必抽样 spinner, 也不必连数几次安静来排除工具间隙。idle 却悬着工具
5500
5480
  // 调用 = 停在本地审批框上等人点, 不算闲。注册表不认得 (codebuddy / 旧版 CC /
5501
5481
  // 会话已退出) 返回 undefined, 交给 pane 裁决。
5502
5482
  const idleVerdict = (target) => {
5483
+ const path = jsonlOf(target);
5503
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
+ }
5504
5491
  const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
5505
5492
  if (!live)
5506
5493
  return undefined;
@@ -5523,6 +5510,9 @@ export const startMirror = (deps) => {
5523
5510
  return live?.status === "idle" && hanging(target, live.sessionId);
5524
5511
  };
5525
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;
5526
5516
  const registry = idleVerdict(target);
5527
5517
  if (registry !== undefined)
5528
5518
  return registry;
@@ -5530,14 +5520,24 @@ export const startMirror = (deps) => {
5530
5520
  if (!pane || !(await tmuxPaneAlive(pane)))
5531
5521
  return false;
5532
5522
  const { cap, box } = await readPaneInput(pane);
5533
- return !!cap.trim() && box.known && !paneIsBusy(cap) && !isModalPane(cap).modal;
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;
5534
5532
  };
5535
5533
  // 等到它闲下来, 事件驱动: 注册表目录一有写入就重判, 翻成 idle 的那一刻即返回。
5536
5534
  // 兜底轮询只防 watch 漏事件 (以及悬着的审批被点掉后 status 没再动的边角)。
5537
5535
  const IDLE_SAFETY_POLL_MS = 1000;
5538
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));
5539
5539
  if (idleVerdict(target) === undefined)
5540
- return waitForIdle(target, isBusy, timeoutMs, aborted, { rampMs: 0, confirm });
5540
+ return waitForIdle(target, waiting, timeoutMs, aborted, { rampMs: 0, confirm });
5541
5541
  return new Promise((resolve) => {
5542
5542
  let done = false;
5543
5543
  const finish = (r) => {
@@ -5557,8 +5557,7 @@ export const startMirror = (deps) => {
5557
5557
  finish({ idle: false, reason: "stopped" });
5558
5558
  return;
5559
5559
  }
5560
- const v = idleVerdict(target);
5561
- if (v ?? !(await isBusy(target)))
5560
+ if (!(await waiting(target)))
5562
5561
  finish({ idle: true });
5563
5562
  };
5564
5563
  const unwatch = activeBackends().map((b) => watchRegistry(b.homeDir, () => void check()));
@@ -5569,15 +5568,15 @@ export const startMirror = (deps) => {
5569
5568
  };
5570
5569
  const lastText = (target) => {
5571
5570
  const p = jsonlOf(target);
5572
- return p ? lastAssistantText(p, 4000, pingSigs) : "";
5571
+ return p ? lastAssistantText(p, 4000) : "";
5573
5572
  };
5574
5573
  const replySince = (target, sinceMs = 0) => {
5575
5574
  const p = jsonlOf(target);
5576
- return p ? lastReply(p, sinceMs, pingSigs) : "";
5575
+ return p ? lastReply(p, sinceMs) : "";
5577
5576
  };
5578
5577
  const replyToPeerSince = (target, fromName, sinceMs = 0, turn) => {
5579
5578
  const p = jsonlOf(target);
5580
- return p ? replyToPeerIn(p, fromName, sinceMs, pingSigs, turn) : undefined;
5579
+ return p ? replyToPeerIn(p, fromName, sinceMs, turn) : undefined;
5581
5580
  };
5582
5581
  /** 绑着的 pane 还能用 (存在、且不是别人的窗口) —— 不重生、不猜。 */
5583
5582
  const paneLiveOf = async (target) => {
@@ -5594,7 +5593,7 @@ export const startMirror = (deps) => {
5594
5593
  const quoteCanon = (s) => s.replace(OPEN_QUOTE_URL_RE, " ").replace(/[^\p{L}\p{N}]/gu, "");
5595
5594
  const openBubbleBodies = (a) => {
5596
5595
  if (a.briefBubble && !a.briefBubble.done && !a.briefConcluded) {
5597
- return [a.cotLastSent, a.cotText].filter((s) => !!s);
5596
+ return [a.cotLastSent, a.cotText, a.cotTool].filter((s) => !!s);
5598
5597
  }
5599
5598
  const s = a.liveStream;
5600
5599
  return s && !s.closed && !s.dead ? [s.acc, s.lastSent] : [];
@@ -5642,9 +5641,9 @@ export const startMirror = (deps) => {
5642
5641
  tmuxPane: pane,
5643
5642
  attached: !!a,
5644
5643
  paneAlive,
5645
- busy: paneAlive ? paneIsBusy(await capturePaneTail(pane, 12)) : false,
5644
+ busy: paneAlive ? await isBusy(t) : false,
5646
5645
  lastActivity,
5647
- summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80, pingSigs) : "(未绑定会话)",
5646
+ summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80) : "(未绑定会话)",
5648
5647
  self: t === self,
5649
5648
  };
5650
5649
  };
@@ -5690,7 +5689,7 @@ export const startMirror = (deps) => {
5690
5689
  };
5691
5690
  // Reading a peer's conversation is a transcript job, not a terminal job: the
5692
5691
  // jsonl holds whole role-tagged messages, while a pane capture is ANSI-laden
5693
- // and clipped at the viewport edge. Text turns only, keepalive stripped. Only
5692
+ // and clipped at the viewport edge. Text turns only. Only
5694
5693
  // `busy` still comes from the pane (the spinner is the one thing a transcript
5695
5694
  // cannot say during a long tool call); "where is it stuck" is answered by the
5696
5695
  // transcript too — `waiting` = tool calls still hanging on an idle session.
@@ -5701,237 +5700,16 @@ export const startMirror = (deps) => {
5701
5700
  return { ok: false, reason: "它还没有 transcript (没接过第一句话) —— send_peer 跟它说一句就有了", busy };
5702
5701
  // `n` 数的是来回: 每个来回给问话和它最新的一句 (跑完的是终句, 还在跑的是途中
5703
5702
  // 最近说的那句) —— 途中的其余叙述不给, 那是过程不是结论。
5704
- const rounds = talkRounds(jsonl, n, pingSigs).slice(-n);
5703
+ const rounds = talkRounds(jsonl, n).slice(-n);
5705
5704
  const dialog = renderDialog(rounds.flatMap((r) => (r.length > 1 ? [r[0], r.at(-1)] : r)), 800, 4000);
5706
5705
  const waiting = busy ? [] : openToolUses(jsonl);
5707
5706
  return dialog ? { ok: true, dialog, busy, waiting } : { ok: false, reason: "transcript 里还没有对话", busy, waiting };
5708
5707
  };
5709
- // ── Prompt-cache keepalive ──────────────────────────────────────────
5710
- // Anthropic's prompt cache expires a TTL (5min API default, 1h on a Claude Code
5711
- // subscription) after the start of the last request. A pane that goes idle
5712
- // (agent parked on a peer, background task running) lets the whole context fall
5713
- // out of cache — the next real turn then re-writes it. We inject a tiny ping just
5714
- // before expiry so the model makes one cheap request (cache-read) that slides the
5715
- // TTL forward. The budget is anchored to the last REAL (non-ping) activity, NOT to
5716
- // our own pings — so a dead session is never kept warm forever.
5717
- // After `/stop`, ignore pane-busy as a resume signal for this long — long
5718
- // enough for an in-flight ping (interrupted by the same /stop's Esc) to settle,
5719
- // so it can't self-resume the pause. A genuine new turn after this window does.
5720
- const RESUME_GRACE_MS = 30_000;
5721
- // A cache untouched this long is not kept warm: on a 1h entry the ping would come
5722
- // ~59min into the idle stretch, re-reading the whole context to cover a gap the
5723
- // TTL already covered — warming only pays on short (5min) entries.
5724
- const KEEPALIVE_MAX_IDLE_MS = 10 * 60_000;
5725
- // Seed the keepalive clocks from message-turn timestamps (keepaliveStamps), never
5726
- // from file mtime — non-message lines (file-history-snapshot / ai-title / mode)
5727
- // bump mtime without being a model turn, which would fake a long-dead session
5728
- // back to "just active" and pin it in an endless ping loop. lastRealMs stays
5729
- // anchored on genuine work, so the tick's realIdle guard can finally let it die.
5730
- const initKeepalive = (jsonlPath, mtime, pingSigs) => {
5731
- let lastMs = 0;
5732
- let lastRealMs = 0;
5733
- // round is seeded from the transcript too: starting at 0 would hand every
5734
- // daemon reload a fresh budget, and a session idle across N reloads gets
5735
- // N × rounds pings instead of rounds.
5736
- let round = 0;
5737
- try {
5738
- const s = keepaliveStamps(jsonlPath, pingSigs);
5739
- lastMs = s.lastMs;
5740
- lastRealMs = s.lastRealMs;
5741
- round = s.streak;
5742
- }
5743
- catch { /* unreadable tail */ }
5744
- return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), cacheAt: cacheTouchMs(jsonlPath) };
5745
- };
5746
- const fireKeepalive = async (a, guard) => {
5747
- if (!guard())
5748
- return;
5749
- const kc = cfg.wrc.mirror.keepalive;
5750
- // The SAME full instruction every round: a bare "ping" used to be sent from round 2 on
5751
- // (the instruction was already in context), but the model reads a bare "ping"
5752
- // as an open turn and answers it however it likes — anything other than
5753
- // "pong" un-swallows the heartbeat into chat and re-anchors the clocks as
5754
- // real activity. Restating the instruction is a few tokens of cache-write and
5755
- // buys a deterministic reply. keepaliveStamps matches every form, so none
5756
- // reads as real activity. (round is incremented below, after the inject lands.)
5757
- const text = kc.ping;
5758
- // Suppress the pane→WeCom echo of the ping user line, then swallow the whole
5759
- // ping turn (reply included). The 60s fail-safe clears the quiet window if
5760
- // the turn somehow never emits turn_end, so a later real turn is never muted;
5761
- // it also closes the detail turn so it can't hang open in the chat timeline.
5762
- const r = await inject({
5763
- text, images: [], cfg,
5764
- guard,
5765
- requireIdle: () => idleVerdict(a.target) === true,
5766
- beforeInput: () => {
5767
- // A queued heartbeat must not mute the peer turn that won the lane.
5768
- // Drain its tail first, then open swallow only at permitted physical input.
5769
- a.tail.drain();
5770
- rememberInject(text);
5771
- a.keepaliveQuiet = setTimeout(() => {
5772
- a.keepaliveQuiet = undefined;
5773
- if (a.keepaliveTurnId) {
5774
- recordTurnClose(a.keepaliveTurnId);
5775
- a.keepaliveTurnId = undefined;
5776
- }
5777
- }, 60_000);
5778
- },
5779
- log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
5780
- sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
5781
- });
5782
- if (!r.ok) {
5783
- endKeepaliveSwallow(a);
5784
- if (a.keepalive)
5785
- a.keepalive.pinging = false; // roll back so the next tick can retry
5786
- log.warn({ target: a.target, reason: r.reason }, "keepalive: inject failed");
5787
- return;
5788
- }
5789
- if (!guard())
5790
- return;
5791
- const tokens = lastContextTokens(a.jsonlPath);
5792
- const round = a.keepalive ? (a.keepalive.round += 1) : 1;
5793
- log.info({ target: a.target, tokens, round, totalRounds: Math.max(1, kc.rounds) }, "keepalive: ping injected");
5794
- // Open a chat-detail turn for the real heartbeat exchange: userQuery is the
5795
- // actual ping we injected; the assistant reply (expected: just "pong"), any
5796
- // tool calls, and usage are grafted on from the swallowed items in onItem;
5797
- // closed on the ping's turn_end (or the fail-safe). Kept out of chat.
5798
- // The tail's keepalive_start (content match on the user line) may have
5799
- // opened this turn already — only open when it hasn't.
5800
- if (!a.keepaliveTurnId) {
5801
- const turnId = newTurnId();
5802
- a.keepaliveTurnId = turnId;
5803
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
5804
- }
5805
- };
5806
- // One pool for both maintenance paths: slow panes do not stall other panes,
5807
- // 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.
5808
5710
  const maintenance = createKeyedScheduler(4);
5809
5711
  const maintenanceValid = (a, pane, sid, gen, signal) => !signal.aborted && byTarget.get(a.target) === a && a.tmuxPane === pane && a.sessionId === sid
5810
5712
  && (a.injectGen ?? 0) === gen && !a.migrationWatcher;
5811
- const keepaliveOne = async (a, pane, sid, gen, signal) => {
5812
- const valid = () => maintenanceValid(a, pane, sid, gen, signal) && cfg.wrc.mirror.keepalive.enabled;
5813
- if (!valid())
5814
- return;
5815
- const kc = cfg.wrc.mirror.keepalive;
5816
- const pingSigs = [normAssistant(kc.ping).slice(0, 40)].filter((s) => s.length > 0);
5817
- const now = Date.now();
5818
- if (a.keepaliveDisabled)
5819
- return; // sticky spawn-time opt-out — never pings
5820
- if (!a.tmuxPane)
5821
- return; // spawn-mode: no live pane to warm
5822
- if (a.migrationWatcher)
5823
- return; // session rotating — skip
5824
- if (a.liveStream && !a.liveStream.closed)
5825
- return; // mid typewriter — don't inject
5826
- if (!(await tmuxPaneAlive(pane)) || !valid())
5827
- return; // dead pane — nothing to keep alive
5828
- let mtime = 0;
5829
- try {
5830
- mtime = statSync(a.jsonlPath).mtimeMs;
5831
- }
5832
- catch {
5833
- return;
5834
- }
5835
- if (!mtime)
5836
- return;
5837
- const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, pingSigs));
5838
- // File mtime is only a cheap "did anything change → re-read the tail" gate.
5839
- // The clocks themselves come from message turns, so metadata churn (which
5840
- // bumps mtime but is not a turn) can never move them. `grewSinceLast` now
5841
- // means a NEW message turn appeared — not that the file grew.
5842
- const prevRealMs = k.lastRealMs;
5843
- if (mtime > k.seenMtime + 1000) {
5844
- const s = keepaliveStamps(a.jsonlPath, pingSigs);
5845
- if (s.stamped) {
5846
- k.lastMs = s.lastMs;
5847
- k.lastRealMs = s.lastRealMs;
5848
- }
5849
- else {
5850
- k.lastMs = mtime;
5851
- k.lastRealMs = mtime;
5852
- } // backend without timestamps → fall back to mtime
5853
- k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
5854
- k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
5855
- }
5856
- k.seenMtime = mtime;
5857
- // Round reset / real-activity re-anchor keys off REAL turns only — a
5858
- // keepalive ping+pong advances lastMs but NOT lastRealMs, so the heartbeat
5859
- // no longer reads as activity to itself (which was pinning round at 1/N).
5860
- const grewSinceLast = k.lastRealMs > prevRealMs + 1000;
5861
- const paneTail = await capturePaneTail(pane, 16);
5862
- if (!valid())
5863
- return;
5864
- const busy = paneIsBusy(paneTail);
5865
- if (k.pinging) {
5866
- // Waiting for our own ping to land and settle: until a NEWER message turn
5867
- // (the ping's own user line) appears and the pane goes idle, keep waiting.
5868
- if (busy || k.lastMs <= k.pingMtime)
5869
- return;
5870
- k.pinging = false;
5871
- k.settledAt = now;
5872
- return;
5873
- }
5874
- // A new real message turn (or a busy pane) re-anchors: reset the round
5875
- // counter, and past the /stop grace lift a busy-resume. grewSinceLast is
5876
- // message-based now, so `/stop`'s Esc settling writes (metadata, no new
5877
- // turn) can no longer self-resume the pause — only a busy pane past grace,
5878
- // or a WeCom inbound in dispatch, does.
5879
- // Suppress the grewSinceLast reset for 30s after pinging settles — the
5880
- // pong's tail flush can lag a tick and would otherwise fake real activity.
5881
- const justSettled = k.settledAt > 0 && now - k.settledAt < 30_000;
5882
- if (busy || (grewSinceLast && !justSettled)) {
5883
- k.round = 0;
5884
- k.settledAt = 0;
5885
- if (a.keepaliveOff && busy && now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS) {
5886
- a.keepaliveOff = false;
5887
- persistPause(a);
5888
- }
5889
- if (busy)
5890
- return;
5891
- }
5892
- if (a.keepaliveOff)
5893
- return; // paused by /stop until real activity returns
5894
- // Cache clock = start of the newest answered request (TTL runs from there),
5895
- // not the last turn's write time; no request on record → the turn clock.
5896
- const sinceCache = now - (k.cacheAt || k.lastMs || mtime);
5897
- // The TTL the session's cache entries were actually written with (1h on a
5898
- // Claude Code subscription), not a guess: pinging at the 5min cadence
5899
- // against a 1h entry re-reads the whole context ~14x per hour for nothing.
5900
- const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
5901
- const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
5902
- if (sinceCache < idleTriggerMs)
5903
- return; // cache still comfortably warm
5904
- if (sinceCache >= ttlMs)
5905
- return; // cache already cold — a ping would cold-rewrite for nothing
5906
- if (sinceCache > KEEPALIVE_MAX_IDLE_MS)
5907
- return; // idle past 10min (a long-TTL entry) — not worth warming
5908
- if (k.round >= kc.rounds)
5909
- return; // budget spent — let it go cold
5910
- if (idleVerdict(a.target) !== true)
5911
- return; // no confirmed idle → no heartbeat input
5912
- k.pinging = true;
5913
- k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
5914
- await fireKeepalive(a, () => valid() && !a.keepaliveOff);
5915
- };
5916
- const keepaliveTick = () => {
5917
- if (!cfg.wrc.mirror.keepalive.enabled)
5918
- return;
5919
- for (const a of byTarget.values()) {
5920
- if (!a.tmuxPane || a.keepaliveDisabled)
5921
- continue;
5922
- const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
5923
- void maintenance.schedule(`keepalive:${a.target}`, pane, (signal) => keepaliveOne(a, pane, sid, gen, signal))
5924
- ?.catch((e) => {
5925
- if (maintenanceValid(a, pane, sid, gen, maintenance.signal)) {
5926
- if (a.keepalive)
5927
- a.keepalive.pinging = false;
5928
- endKeepaliveSwallow(a);
5929
- }
5930
- log.warn({ target: a.target, err: e.message }, "keepalive tick failed");
5931
- });
5932
- }
5933
- };
5934
- const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
5935
5713
  // ── 原生权限 picker 哨兵 ──────────────────────────────────────────────
5936
5714
  // CodeBuddy 对一部分调用先弹自家权限 picker、PreToolUse hook 在人点掉之后才
5937
5715
  // 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
@@ -6344,14 +6122,21 @@ export const startMirror = (deps) => {
6344
6122
  const generation = a.injectGen ?? 0;
6345
6123
  const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
6346
6124
  const probedPane = a.tmuxPane;
6347
- const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
6125
+ const probe = a.tmuxPane ? await probePane(a.tmuxPane, a.jsonlPath) : { state: "gone" };
6348
6126
  if (!permitted() || a.tmuxPane !== probedPane)
6349
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
+ }
6350
6135
  // 同 dispatch: 只有刚生 / 刚重生的 pane 才要冷启动的宽限 —— 对一个热着、正忙的 pane 贴 `now`
6351
6136
  // 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
6352
6137
  let fresh = a.justSpawned === true;
6353
- if (!paneAlive) {
6354
- 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");
6355
6140
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
6356
6141
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
6357
6142
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
@@ -6383,9 +6168,9 @@ export const startMirror = (deps) => {
6383
6168
  return denied;
6384
6169
  // Busy/queued retries must not consume notices or stamp a future turn.
6385
6170
  // Render only after the pane lane grants delivery, then stamp exactly at
6386
- // the first permitted input. activePeerTurn still changes in bindLine.
6171
+ // the first permitted input.
6387
6172
  const envelope = text.trimStart().startsWith("/") ? "" : opts?.envelope ?? "";
6388
- let full = text + envelope;
6173
+ let full = text + renderTail(envelope);
6389
6174
  const beforeInput = () => {
6390
6175
  a.pendingFromCli = false;
6391
6176
  a.turnFromChat = true;
@@ -6400,10 +6185,10 @@ export const startMirror = (deps) => {
6400
6185
  const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
6401
6186
  const injectPane = a.tmuxPane;
6402
6187
  const r = await inject({
6403
- 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" }),
6404
- sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
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,
6405
6190
  requireIdle: opts?.requireIdle ? () => idleNow(target) : undefined,
6406
- prepareText: () => (full = text + envelope + noticeSuffixFor(target, text)),
6191
+ prepareText: () => (full = text + renderTail(envelope, noticeSuffixFor(target, text))),
6407
6192
  beforeInput,
6408
6193
  });
6409
6194
  if (r.ok && a.tmuxPane && permitted()) {
@@ -6442,15 +6227,6 @@ export const startMirror = (deps) => {
6442
6227
  else if (!a.tmuxPane) {
6443
6228
  return { ok: false, reason: "no live tmux pane (spawn-mode attachment)" };
6444
6229
  }
6445
- // /stop also pauses keepalive: the user is deliberately quieting this
6446
- // session, so stop poking it too. A real turn (WeCom inbound, or the pane
6447
- // going busy AFTER the resume grace) lifts the pause and re-earns the
6448
- // budget. Stamping keepaliveOffAt gates the busy-resume so the ping that
6449
- // may be mid-flight right now can't immediately self-resume.
6450
- a.keepaliveOff = true;
6451
- a.keepaliveOffAt = Date.now();
6452
- a.keepalive = undefined; // re-anchors cleanly on resume
6453
- persistPause(a); // survive a reload — don't resurrect a quieted session
6454
6230
  // Best-effort Esc. Every failure mode here is reported, never fatal: the
6455
6231
  // teardown above already gave the user their chat back.
6456
6232
  let escOk = false;
@@ -6504,15 +6280,22 @@ export const startMirror = (deps) => {
6504
6280
  const want = new Set(names.flatMap((n) => [n, `.${n}`]));
6505
6281
  if (!want.size)
6506
6282
  return [];
6507
- 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}"]);
6508
6284
  if (snap.code !== 0)
6509
6285
  return [];
6510
- 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();
6511
6291
  const doomed = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"))
6512
- .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))
6513
6293
  .map(([pane = ""]) => pane);
6514
6294
  const killed = [];
6515
6295
  for (const pane of doomed) {
6296
+ // 绑定现查: 逐个收的 await 期间别人可能刚 attach 上。
6297
+ if (spawningPanes.has(pane) || allTargets().some((t) => paneOf(t) === pane))
6298
+ continue;
6516
6299
  // 与 killPane 同款前戏: Esc 给可能已起来的 CLI 一个收束落盘的机会。
6517
6300
  await runTmux(["send-keys", "-t", pane, "Escape"]);
6518
6301
  await sleep(250);
@@ -6560,7 +6343,6 @@ export const startMirror = (deps) => {
6560
6343
  // 找)。不看注册表的 `tmux` 字段 —— codebuddy 的注册表没有它, 那条路对 codebuddy
6561
6344
  // 全盲。普通 pane (人自己开的 shell / ssh / 编辑器, 会话已退回 shell 的) 树里
6562
6345
  // 没有注册表 pid, 一律跳过 —— 认不出就不动。
6563
- const bound = new Set(targets.map(paneOf).filter(Boolean));
6564
6346
  const regPids = new Set(activeBackends().flatMap((b) => [...liveSessionPids(b.homeDir)]));
6565
6347
  const ps = await psSnap();
6566
6348
  // 沿进程树找注册表 pid; 找到返回那个 pid (它的 etimes 供宽限判), 找不到返回 0。
@@ -6580,13 +6362,15 @@ export const startMirror = (deps) => {
6580
6362
  return 0;
6581
6363
  };
6582
6364
  for (const [pane = "", , session, pid] of rows) {
6583
- 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))
6584
6367
  continue;
6585
6368
  const cliPid = hostedBy(Number(pid) || 0);
6586
6369
  if (!cliPid)
6587
6370
  continue;
6588
6371
  // 宽限按 CLI 进程年龄, 不按 window_activity: TUI 重绘会让它永远「刚动过」。
6589
- if ((ps.etimes.get(cliPid) ?? Number.POSITIVE_INFINITY) * 1000 < ORPHAN_GRACE_MS)
6372
+ // 拿不到年龄按刚生算 (跳过): 认不出就不收。
6373
+ if ((ps.etimes.get(cliPid) ?? 0) * 1000 < ORPHAN_GRACE_MS)
6590
6374
  continue;
6591
6375
  const why = await putDown(pane);
6592
6376
  if (why) {
@@ -6733,12 +6517,12 @@ export const startMirror = (deps) => {
6733
6517
  newSession,
6734
6518
  cloneSession,
6735
6519
  sessionInfo,
6520
+ inputSessionInfo,
6736
6521
  setCharterProvider,
6737
6522
  onSpawn,
6738
6523
  onContextCut,
6739
6524
  shutdown: () => {
6740
6525
  clearInterval(paneDriftTimer);
6741
- clearInterval(keepaliveTimer);
6742
6526
  clearInterval(pickerTimer);
6743
6527
  maintenance.stop();
6744
6528
  for (const a of bySessionId.values()) {
@@ -6758,8 +6542,6 @@ export const startMirror = (deps) => {
6758
6542
  return a.convFrom.asker;
6759
6543
  return a?.speaker ? { who: a.speaker, chat: a.channel ?? baseOfKey(target) } : undefined;
6760
6544
  },
6761
- answerContext, prepareAnswer, takeAnswer, cancelAnswerPreparation, stopAnswerProgress, deliverAnswer,
6762
- setAnswerSilencer: (fn) => { silenceAnswer = fn; },
6763
6545
  currentChannel: (target) => {
6764
6546
  const a = byTarget.get(target);
6765
6547
  return a?.channel ?? baseOfKey(target);
@@ -6786,28 +6568,16 @@ export const startMirror = (deps) => {
6786
6568
  }
6787
6569
  return;
6788
6570
  }
6789
- if (eventId && a.tmuxPane && await tmuxPaneAlive(a.tmuxPane) && !(await idleNow(principal))) {
6790
- onSubmitted?.({ ok: false, retryable: true, reason: "target became busy before opening the human turn" });
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" });
6791
6579
  return;
6792
6580
  }
6793
- // `/clear` resets to an empty session — nothing in the cache to keep warm,
6794
- // so pause keepalive like `/stop` instead of lifting it. Every other inbound
6795
- // is real work: lift any prior pause and re-anchor both clocks to now.
6796
- // Pane-side new turns re-anchor via the tick's stamps; this covers the
6797
- // WeCom-driven path.
6798
- if (isClearCommand(text)) {
6799
- a.keepaliveOff = true;
6800
- a.keepaliveOffAt = Date.now();
6801
- a.keepalive = undefined; // re-anchors cleanly on resume
6802
- }
6803
- else {
6804
- a.keepaliveOff = false;
6805
- if (a.keepalive) {
6806
- a.keepalive.lastMs = Date.now();
6807
- a.keepalive.lastRealMs = Date.now();
6808
- }
6809
- }
6810
- persistPause(a); // persist the pause/resume too, else a reload would revert it
6811
6581
  // 频道印章要早于任何开 turn 的路径 (startBriefTurn 同步消费)。
6812
6582
  // brief 模式下频道随这一句的印章走 (startBriefTurn); 其余路径仍是开 turn 时消费的格子。
6813
6583
  if (!cfg.wrc.mirror.brief || isClearCommand(text))
@@ -6822,7 +6592,7 @@ export const startMirror = (deps) => {
6822
6592
  const isModelSwitch = /^\/model\s+\S/.test(text.trim());
6823
6593
  // 任意 slash 命令 (/context, /cost, /status…): 产物走 skill_output standalone,
6824
6594
  // 详情链接是纯噪声 —— brief 起 turn 时不推链接。
6825
- const isSlash = /^\/[a-z]/i.test(text.trim());
6595
+ const isSlash = slashCmd;
6826
6596
  // codebuddy 没有可用的剪贴板图片通道 (tmux 里 C-v 会被它自己的
6827
6597
  // syncTmuxToSystemClipboard 冲掉, 见 injectViaTmux 分流注释) —— 图片只能以
6828
6598
  // @<path> 文本进会话。既然如此, 问话 / 印章 / 回显去重 / 静默分叉跟随都按这个
@@ -6937,11 +6707,18 @@ export const startMirror = (deps) => {
6937
6707
  // First step of the job and the historical hang point — log BEFORE the
6938
6708
  // probe so a stall is attributable to it next time, not invisible.
6939
6709
  log.info({ target: a.target, sessionId: sid, pane: a.tmuxPane, gen: myGen }, "inject job start");
6940
- const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
6710
+ const probe = a.tmuxPane ? await probePane(a.tmuxPane, a.jsonlPath) : { state: "gone" };
6941
6711
  let freshSpawn = a.justSpawned === true;
6942
6712
  a.justSpawned = false;
6943
- if (!paneAlive) {
6944
- 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");
6945
6722
  // Interactive `claude --resume <sid>` does NOT keep appending to the
6946
6723
  // same <sid>.jsonl — it FORKS to a fresh <newSid>.jsonl seeded with a
6947
6724
  // copy of the transcript. Our tail stays bound to the OLD, now-frozen
@@ -7015,16 +6792,25 @@ export const startMirror = (deps) => {
7015
6792
  // 用户行有 isOwnInject 过滤 —— 提前解除不会泄漏脏数据。
7016
6793
  a.muteUntilInject = false;
7017
6794
  const injectPane = a.tmuxPane;
6795
+ // 人话不再等 pane 闲 (§人话直入): `requireIdle` 在这一层只剩一个作用 —— 必须有 pane,
6796
+ // 不许退化成 spawn-mode (`--resume -p`) 的无窗口注入。忙闲交给 CLI 自己的输入队列:
6797
+ // 贴进运行中的一轮会被记成 queued 轮, 等它在 transcript 露面再接上这条 event。
6798
+ // slash 命令要等闲, 那一道在上面 (dispatch 开头) 已经拦过了。
7018
6799
  const r = await inject({
7019
6800
  text: effText, images: effImages, cfg, log: log.child({ principal, sessionId: sid }),
7020
- sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
7021
- requireIdle: eventId ? () => idleNow(a.target) : undefined,
7022
- guard: (phase) => byTarget.get(a.target) === a && a.tmuxPane === injectPane && (a.injectGen ?? 0) === myGen && (!guard || guard(phase)),
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)),
7023
6804
  });
7024
6805
  onSubmitted?.(r);
7025
6806
  if (!r.ok) {
6807
+ // retryable = 一个字都没进输入框, 消息事件层会稍后重投 —— 不是失败, 别报 ✗
6808
+ // (每次退避重试都会走到这里, 报 ✗ 就是一串假警报)。
6809
+ const note = r.retryable && eventId
6810
+ ? `[mirror] ⏳ 暂未送达, 稍后自动重投 (${r.reason ?? "deferred"})`
6811
+ : `[mirror] ✗ ${r.reason ?? "failed"}`;
7026
6812
  if (s) {
7027
- 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;
7028
6814
  await finalizeStream(a, s);
7029
6815
  }
7030
6816
  else if (armMigration) {
@@ -7042,7 +6828,7 @@ export const startMirror = (deps) => {
7042
6828
  if (a.outbound?.kind === "deferred")
7043
6829
  clearTimeout(a.outbound.timer);
7044
6830
  a.outbound = undefined;
7045
- sendStandalone(a, `[mirror] ✗ ${r.reason ?? "failed"}`);
6831
+ sendStandalone(a, note);
7046
6832
  }
7047
6833
  return;
7048
6834
  }