wezard 1.4.0 → 1.4.1

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 (51) hide show
  1. package/CLAUDE.md +12 -2
  2. package/README.md +40 -5
  3. package/dist/cli/init.js +1 -1
  4. package/dist/cli/init.js.map +1 -1
  5. package/dist/daemon/approval.js +0 -6
  6. package/dist/daemon/approval.js.map +1 -1
  7. package/dist/daemon/cc-bridge.js +1 -22
  8. package/dist/daemon/cc-bridge.js.map +1 -1
  9. package/dist/daemon/cfg-sync.js +3 -6
  10. package/dist/daemon/cfg-sync.js.map +1 -1
  11. package/dist/daemon/config-api.js +0 -1
  12. package/dist/daemon/config-api.js.map +1 -1
  13. package/dist/daemon/graph.js +2 -6
  14. package/dist/daemon/graph.js.map +1 -1
  15. package/dist/daemon/inbound.js +3 -3
  16. package/dist/daemon/inbound.js.map +1 -1
  17. package/dist/daemon/index.js +2 -18
  18. package/dist/daemon/index.js.map +1 -1
  19. package/dist/daemon/jobs.js +17 -35
  20. package/dist/daemon/jobs.js.map +1 -1
  21. package/dist/daemon/mirror-bridge.js +190 -182
  22. package/dist/daemon/mirror-bridge.js.map +1 -1
  23. package/dist/daemon/mirror-store.js +2 -33
  24. package/dist/daemon/mirror-store.js.map +1 -1
  25. package/dist/daemon/quota.js +1 -1
  26. package/dist/daemon/quota.js.map +1 -1
  27. package/dist/daemon/session-cache.js +0 -1
  28. package/dist/daemon/session-cache.js.map +1 -1
  29. package/dist/daemon/session-scan.js +4 -22
  30. package/dist/daemon/session-scan.js.map +1 -1
  31. package/dist/daemon/sessions.js +2 -33
  32. package/dist/daemon/sessions.js.map +1 -1
  33. package/dist/daemon/spawn-tmux.js +2 -10
  34. package/dist/daemon/spawn-tmux.js.map +1 -1
  35. package/dist/daemon/subagent-tail.js +2 -2
  36. package/dist/daemon/subagent-tail.js.map +1 -1
  37. package/dist/daemon/wizard.js +7 -30
  38. package/dist/daemon/wizard.js.map +1 -1
  39. package/dist/mcp/server.js +31 -54
  40. package/dist/mcp/server.js.map +1 -1
  41. package/dist/shared/cli-backends.js +0 -11
  42. package/dist/shared/cli-backends.js.map +1 -1
  43. package/dist/shared/detail-render.js +3 -3
  44. package/dist/shared/detail-render.js.map +1 -1
  45. package/dist/shared/exec-path.js +21 -0
  46. package/dist/shared/exec-path.js.map +1 -0
  47. package/dist/shared/json-map-store.js +41 -0
  48. package/dist/shared/json-map-store.js.map +1 -0
  49. package/dist/shared/std.js +9 -0
  50. package/dist/shared/std.js.map +1 -0
  51. package/package.json +1 -1
@@ -16,13 +16,15 @@ import { spawn } from "node:child_process";
16
16
  import { existsSync, readdirSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
17
17
  import { join, dirname } from "node:path";
18
18
  import { expandHome, sanitizeId } from "../shared/paths.js";
19
+ import { augmentedPath } from "../shared/exec-path.js";
20
+ import { sleep, truncateWithCount } from "../shared/std.js";
19
21
  import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
20
22
  import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } from "../shared/modal-pane.js";
21
23
  import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
22
24
  import { isAutoWindowActive } from "./session-cache.js";
23
25
  import { noticeSuffixFor } from "./notices.js";
24
26
  import { dangerOf } from "./danger.js";
25
- import { runTmux as runTmuxCmd, spawnTmuxClaude } from "./spawn-tmux.js";
27
+ import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
26
28
  import { startSubagentWatch } from "./subagent-tail.js";
27
29
  import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
28
30
  import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, headSep, parseTagHeader } from "../shared/session-label.js";
@@ -32,25 +34,6 @@ import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peer
32
34
  import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
33
35
  // Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
34
36
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
35
- const NODE_BIN_DIR = dirname(process.execPath);
36
- const augmentedPath = (orig) => {
37
- const extras = [
38
- NODE_BIN_DIR,
39
- "/opt/homebrew/bin",
40
- "/usr/local/bin",
41
- `${process.env.HOME ?? ""}/.local/bin`,
42
- ].filter(Boolean);
43
- const seen = new Set();
44
- return [orig ?? "", ...extras]
45
- .flatMap((p) => p.split(":"))
46
- .filter((p) => {
47
- if (!p || seen.has(p))
48
- return false;
49
- seen.add(p);
50
- return true;
51
- })
52
- .join(":");
53
- };
54
37
  // Backend resolution is per-transcript, not global: `backendForPath` recovers
55
38
  // which CLI wrote a jsonl from its projects root, so claude / claude-internal /
56
39
  // codebuddy sessions can be mirrored side by side. `projectDirFor` re-encodes a
@@ -264,15 +247,6 @@ const stripPrincipalPrefix = (s) => {
264
247
  const h = rest.indexOf("#");
265
248
  return h >= 0 ? rest.slice(0, h) : rest;
266
249
  };
267
- // Extract the `#tag` suffix from a target key, "" if untagged.
268
- const tagOfTarget = tagOfKey;
269
- // Drop the `#tag` suffix — collapses tagged session keys to the chat-scoped
270
- // base principal. Shared with the peer/graph layer via session-label.
271
- const basePrincipalOf = baseOfKey;
272
- // Prefix outbound content with `<emoji> #tag` header (blank line separator)
273
- // when the target carries a `#tag` suffix. Untagged targets pass through
274
- // unchanged — default session keeps its plain-bubble UX.
275
- const withSessionTag = withTagHeader;
276
250
  // Claude Code wraps slash-command invocations into the user message as
277
251
  // <command-message>name</command-message>
278
252
  // <command-name>/name</command-name>
@@ -330,7 +304,6 @@ const cleanUserText = (raw) => {
330
304
  }
331
305
  return stripped;
332
306
  };
333
- const truncate = (s, max) => s.length <= max ? s : `${s.slice(0, max)}…(+${s.length - max})`;
334
307
  // 后台子agent 完成通知的无标签形态 —— codebuddy 平台在子agent 跑完后注入
335
308
  // `[Framework Auto-Notification] <agent> completed` 的纯文本 user 行 (不套
336
309
  // <task-notification>)。只认"整条消息以它开头", 免得把正文里提到它的消息吞掉。
@@ -371,7 +344,7 @@ const textSimilarity = (x, y) => {
371
344
  const renderToolInput = (input) => {
372
345
  try {
373
346
  const json = JSON.stringify(input ?? {}, null, 0);
374
- return truncate(json, 600);
347
+ return truncateWithCount(json, 600);
375
348
  }
376
349
  catch {
377
350
  return "{}";
@@ -393,7 +366,7 @@ const extractToolResultText = (block) => {
393
366
  const DETAIL_RESULT_MAX = 64 * 1024;
394
367
  const oneLineSummary = (s, max = 40) => {
395
368
  const flat = s.replace(/\s+/g, " ").trim();
396
- return truncate(flat, max);
369
+ return truncateWithCount(flat, max);
397
370
  };
398
371
  // WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
399
372
  // code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
@@ -537,7 +510,7 @@ const renderLine = (raw, deps) => {
537
510
  if (!raw)
538
511
  continue;
539
512
  const toolUseId = b.tool_use_id ?? "";
540
- const full = truncate(raw, DETAIL_RESULT_MAX);
513
+ const full = truncateWithCount(raw, DETAIL_RESULT_MAX);
541
514
  // 始终把完整 result 落 detail 库 + 作为 tool_result item 发出 — 与
542
515
  // includeToolResults(气泡推送开关)彻底解耦: 关掉气泡时 detail 页与 brief
543
516
  // turn 页(handleBriefItem 消费本 item 写 turn store)都仍要看到 result。
@@ -704,7 +677,6 @@ const renderLine = (raw, deps) => {
704
677
  };
705
678
  // Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
706
679
  // fenced block or table in a way that breaks rendering — see splitMarkdown.
707
- const splitChunks = splitMarkdown;
708
680
  // Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
709
681
  // (up to ~110 bytes in its linked `[🧙 #tag](url)` form).
710
682
  const TAG_HEADER_BUDGET = 64;
@@ -832,8 +804,6 @@ export const startMirrorTail = (deps) => {
832
804
  // exactly one place where the hard timeout lives. (Two hand-rolled copies used
833
805
  // to exist here; both could hang forever, which is how a wedged tmux server
834
806
  // silently killed a whole chat. See TMUX_TIMEOUT_MS.)
835
- const tmuxRun = (args, opts) => runTmuxCmd(args, opts);
836
- const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
837
807
  // Stable JSON: sort object keys recursively. Used to fingerprint a tool_use's
838
808
  // `input` so the hook-side and the jsonl-side compute the same signature even
839
809
  // when the model emits keys in arbitrary order.
@@ -955,7 +925,7 @@ const setMacClipboardImage = async (imgPath) => {
955
925
  // the input box and would otherwise re-trigger the fingerprint, falsely
956
926
  // flagging "Enter not honored".
957
927
  const capturePaneTail = async (target, rows = 12) => {
958
- const r = await tmuxRun(["capture-pane", "-t", target, "-p", "-S", `-${rows}`]);
928
+ const r = await runTmux(["capture-pane", "-t", target, "-p", "-S", `-${rows}`]);
959
929
  return r.ok ? r.stdout : "";
960
930
  };
961
931
  // Trim slash-command stdout to the useful section. TUI panels prepend a run
@@ -1043,7 +1013,7 @@ const MODAL_SCAN_ROWS = 15;
1043
1013
  // `screen` = 实际参与判定的那一屏文本, 一并返回给调用方做选项解析 —— 再 capture
1044
1014
  // 一次会拿到"下一瞬间"的屏幕, 与 verdict 不同源(框可能已被本地按掉), 按键就按错了。
1045
1015
  const detectModalPicker = async (target) => {
1046
- const r = await tmuxRun(["capture-pane", "-t", target, "-p"]);
1016
+ const r = await runTmux(["capture-pane", "-t", target, "-p"]);
1047
1017
  if (!r.ok)
1048
1018
  return { modal: false, screen: "" };
1049
1019
  const lines = r.stdout.replace(/\s+$/u, "").split("\n");
@@ -1060,8 +1030,8 @@ const MODAL_PRESS_SETTLE_MS = 400;
1060
1030
  const MODAL_ARROW_SETTLE_MS = 150;
1061
1031
  const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
1062
1032
  const pressModalIndex = async (pane, before, index) => {
1063
- await tmuxRun(["send-keys", "-t", pane, String(index)]);
1064
- await sleepMs(MODAL_PRESS_SETTLE_MS);
1033
+ await runTmux(["send-keys", "-t", pane, String(index)]);
1034
+ await sleep(MODAL_PRESS_SETTLE_MS);
1065
1035
  let after = await detectModalPicker(pane);
1066
1036
  if (!after.modal || after.title !== before.title)
1067
1037
  return after;
@@ -1070,16 +1040,16 @@ const pressModalIndex = async (pane, before, index) => {
1070
1040
  const cur = highlightedIndex(after.screen);
1071
1041
  if (cur === undefined || cur === index)
1072
1042
  break;
1073
- await tmuxRun(["send-keys", "-t", pane, cur < index ? "Down" : "Up"]);
1074
- await sleepMs(MODAL_ARROW_SETTLE_MS);
1043
+ await runTmux(["send-keys", "-t", pane, cur < index ? "Down" : "Up"]);
1044
+ await sleep(MODAL_ARROW_SETTLE_MS);
1075
1045
  after = await detectModalPicker(pane);
1076
1046
  if (!after.modal || after.title !== before.title)
1077
1047
  return after;
1078
1048
  }
1079
1049
  if (highlightedIndex(after.screen) !== index)
1080
1050
  return after; // 核不准, 绝不盲按
1081
- await tmuxRun(["send-keys", "-t", pane, "Enter"]);
1082
- await sleepMs(MODAL_PRESS_SETTLE_MS);
1051
+ await runTmux(["send-keys", "-t", pane, "Enter"]);
1052
+ await sleep(MODAL_PRESS_SETTLE_MS);
1083
1053
  return detectModalPicker(pane);
1084
1054
  };
1085
1055
  // 「聊聊这个」收尾: 文本 inject 已把引导语贴进自定义文本行 (❯ N. <text>)。codebuddy
@@ -1097,7 +1067,7 @@ const ASKQ_CONFIRM_STAGE_MS = 2500; // 每阶段 (到提交页 / 面板关闭)
1097
1067
  const ASKQ_SECONDARY_ENTERS = 3;
1098
1068
  const confirmAskqSubmit = async (target, paneAlive) => {
1099
1069
  const capture = async () => {
1100
- const r = await tmuxRun(["capture-pane", "-t", target, "-p"]);
1070
+ const r = await runTmux(["capture-pane", "-t", target, "-p"]);
1101
1071
  return r.ok ? r.stdout.replace(/\s+$/u, "") : "";
1102
1072
  };
1103
1073
  // 轮询到 pred 为真; 期间 pane 死了立即失败。
@@ -1110,10 +1080,10 @@ const confirmAskqSubmit = async (target, paneAlive) => {
1110
1080
  return true;
1111
1081
  if (Date.now() - t0 >= timeoutMs)
1112
1082
  return false;
1113
- await sleepMs(ASKQ_CONFIRM_POLL_MS);
1083
+ await sleep(ASKQ_CONFIRM_POLL_MS);
1114
1084
  }
1115
1085
  };
1116
- const sendKey = (key) => tmuxRun(["send-keys", "-t", target, key]);
1086
+ const sendKey = (key) => runTmux(["send-keys", "-t", target, key]);
1117
1087
  for (let attempt = 0; attempt < ASKQ_CONFIRM_ENTERS; attempt++) {
1118
1088
  if (!(await paneAlive()))
1119
1089
  return { ok: false, reason: "pane_dead" };
@@ -1135,7 +1105,7 @@ const confirmAskqSubmit = async (target, paneAlive) => {
1135
1105
  const d = await sendKey("Down");
1136
1106
  if (!d.ok)
1137
1107
  return { ok: false, reason: `send-keys Down: ${d.stderr.slice(-200) || d.code}` };
1138
- await sleepMs(ASKQ_CONFIRM_POLL_MS); // 等光标移动重绘
1108
+ await sleep(ASKQ_CONFIRM_POLL_MS); // 等光标移动重绘
1139
1109
  const e = await sendKey("Enter");
1140
1110
  if (!e.ok)
1141
1111
  return { ok: false, reason: `send-keys Enter: ${e.stderr.slice(-200) || e.code}` };
@@ -1165,14 +1135,46 @@ const fingerprints = (text) => {
1165
1135
  const stripped = text.replace(/\s+/gu, "").trim();
1166
1136
  return { headFp: stripped.slice(0, 8), tailFp: stripped.slice(-8) };
1167
1137
  };
1138
+ // 输入框本体 = 两条 ruler 之间那几行, 首行以 `❯ `/`> ` 打头:
1139
+ //
1140
+ // ────────────────────────
1141
+ // ❯ [Pasted text #1 +5 lines]
1142
+ // ────────────────────────
1143
+ // <cwd> <status>
1144
+ //
1145
+ // 读整块、跟「贴之前」的快照比对, 判据就与文本内容无关了 —— 这恰恰是指纹法的
1146
+ // 盲区: Claude Code 把多行粘贴折叠成 `[Pasted text #N +M lines]`, headFp/tailFp
1147
+ // 双双不在屏上, 于是「没贴进去」被误判 → 盲目重贴 → 两份提示叠在一个输入框里
1148
+ // (实测第三次粘贴渲染成 `…OMEGATAIL[Pasted text #2 +5 lines]`)。同一个盲区也让
1149
+ // 回车后的「框清空了吗」永远立刻为真, 等于根本没校验过提交。
1150
+ const BOX_RULER_RE = /^[\u2500-\u257F\u2014-]{8,}\s*$/u;
1151
+ const BOX_PROMPT_RE = /^\s*[❯>]\s?/u;
1152
+ /** 底 ruler 之下只该剩 cwd/模式两行状态; 多于此说明框到的不是输入框。 */
1153
+ const BOX_FOOTER_ROWS = 6;
1154
+ const UNKNOWN_BOX = { known: false, body: "" };
1155
+ export const parseInputBox = (cap) => {
1156
+ const lines = cap.split("\n");
1157
+ while (lines.length && lines[lines.length - 1].trim() === "")
1158
+ lines.pop();
1159
+ const rulers = lines.reduce((acc, l, i) => (BOX_RULER_RE.test(l) ? [...acc, i] : acc), []);
1160
+ const bottom = rulers[rulers.length - 1];
1161
+ const top = rulers[rulers.length - 2];
1162
+ if (top === undefined || bottom === undefined || bottom - top < 1)
1163
+ return UNKNOWN_BOX;
1164
+ if (lines.length - bottom > BOX_FOOTER_ROWS)
1165
+ return UNKNOWN_BOX;
1166
+ const rows = lines.slice(top + 1, bottom);
1167
+ if (!rows.length || !BOX_PROMPT_RE.test(rows[0]))
1168
+ return UNKNOWN_BOX;
1169
+ const body = [rows[0].replace(BOX_PROMPT_RE, ""), ...rows.slice(1)].join("\n").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
1170
+ return { known: true, body };
1171
+ };
1172
+ // 24 行足够容纳展开后的多行粘贴 (折叠态只占一行)。
1173
+ const readInputBox = async (target) => parseInputBox(await capturePaneTail(target, 24));
1168
1174
  // Self-verifying inject. The cold-spawn race we guard against: paste lands
1169
1175
  // but the trailing Enter is eaten while the TUI is still initializing, so
1170
- // the prompt sits typed-but-unsent. Strategy:
1171
- // 1. paste; wide-window poll for headFp → paste reached the input box.
1172
- // 2. settle; send Enter.
1173
- // 3. narrow-window poll (last 5 rows = just the input box, NOT the echo
1174
- // above) for tailFp absence → submit was honored.
1175
- // 4. on stuck-after-Enter, retry Enter once with extra settle.
1176
+ // the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
1177
+ // 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
1176
1178
  const injectViaTmux = async (target, text, images, log, freshSpawn, backendName, bypassModalGuard = false) => {
1177
1179
  log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
1178
1180
  // Never type into a modal picker (see detectModalPicker). Checked before the
@@ -1219,10 +1221,10 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName,
1219
1221
  log.warn({ imgPath, reason: cb.reason }, "mirror inject: clipboard set failed, skipping image");
1220
1222
  continue;
1221
1223
  }
1222
- const cv = await tmuxRun(["send-keys", "-t", target, "C-v"]);
1224
+ const cv = await runTmux(["send-keys", "-t", target, "C-v"]);
1223
1225
  if (!cv.ok)
1224
1226
  return { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
1225
- await sleepMs(IMG_SETTLE_MS);
1227
+ await sleep(IMG_SETTLE_MS);
1226
1228
  }
1227
1229
  // Image-only message: TUI input box now holds the attached images; press
1228
1230
  // Enter and we're done. No fingerprint to verify (the input itself is binary
@@ -1230,13 +1232,20 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName,
1230
1232
  if (!text) {
1231
1233
  if (images.length === 0)
1232
1234
  return { ok: true };
1233
- const e = await tmuxRun(["send-keys", "-t", target, "Enter"]);
1235
+ const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1234
1236
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
1235
1237
  }
1236
1238
  return injectViaTmuxText(target, text, log, freshSpawn);
1237
1239
  };
1238
1240
  // 文本 paste + Enter 提交 + 自校验。从 injectViaTmux 抽出来,让 codebuddy
1239
1241
  // 后端的 @<path> 回退路径也能复用同样的 paste-verify 逻辑。
1242
+ //
1243
+ // 两个判据都以**输入框整块**为准 (parseInputBox), 指纹只是认不出布局时的退路:
1244
+ // 1. 贴完轮询「框变了」→ 粘贴已被 TUI 消化 (折叠成占位符也算)。这是个**正**
1245
+ // 信号, 只有确凿看见框仍是贴之前的样子才重贴 —— 存疑一律不重贴, 多贴一次
1246
+ // 的代价 (两份提示叠进同一个输入框) 远高于少贴一次。
1247
+ // 2. 回车后轮询「框回到贴之前的样子」→ 提交被吃下了。框认得出来时这条判据可
1248
+ // 信, 于是允许多补一次回车 (空框上的回车在 CLI 里是 no-op)。
1240
1249
  const injectViaTmuxText = async (target, text, log, freshSpawn) => {
1241
1250
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1242
1251
  // started, transcript still loading): extended timings — bracketed-paste
@@ -1247,13 +1256,24 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
1247
1256
  const POST_PASTE_SETTLE_FALLBACK_MS = freshSpawn ? 2500 : 700;
1248
1257
  const CLEARED_TIMEOUT_MS = freshSpawn ? 4000 : 1500;
1249
1258
  const RETRY_SETTLE_MS = freshSpawn ? 1500 : 800;
1259
+ const { headFp, tailFp } = fingerprints(text);
1260
+ const POLL_MS = 100;
1261
+ const stripWs = (s) => s.replace(/\s+/gu, "");
1262
+ // 贴之前框里是什么: 空框、幽灵提示 (`Try "how do I log an error?"`)、甚至用户
1263
+ // 自己敲了一半的字 —— 一律作为基线, 免得去维护一张「哪些文案算空」的清单。
1264
+ const preBox = await readInputBox(target);
1265
+ // 基线读不出来 (框被模态顶掉 / 正在重绘) 就整条退回指纹法: 没有基线,「框变了」
1266
+ // 无从谈起, 而把幽灵提示 (`Try "…"`) 误当成「贴进去了」会让这条消息静静消失。
1267
+ const boxVerify = preBox.known;
1268
+ /** 框里还是贴之前那样 (或彻底空了) = 我们的文本不在框里。 */
1269
+ const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === preBox.body);
1250
1270
  const loadAndPaste = async () => {
1251
1271
  // stdin variant of the shared exec path — it carries the same hard timeout,
1252
1272
  // which a hand-rolled spawn here did not.
1253
- const loaded = await tmuxRun(["load-buffer", "-"], { stdin: text });
1273
+ const loaded = await runTmux(["load-buffer", "-"], { stdin: text });
1254
1274
  if (!loaded.ok)
1255
1275
  return { ok: false, reason: `tmux load-buffer failed: ${loaded.stderr.slice(-200) || loaded.code}` };
1256
- const pasted = await tmuxRun(["paste-buffer", "-p", "-d", "-t", target]);
1276
+ const pasted = await runTmux(["paste-buffer", "-p", "-d", "-t", target]);
1257
1277
  if (!pasted.ok)
1258
1278
  return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
1259
1279
  return { ok: true };
@@ -1261,100 +1281,88 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
1261
1281
  let r = await loadAndPaste();
1262
1282
  if (!r.ok)
1263
1283
  return r;
1264
- const { headFp, tailFp } = fingerprints(text);
1265
- const POLL_MS = 100;
1266
- const stripWs = (s) => s.replace(/\s+/gu, "");
1267
- // Wide window catches a wrapped paste's head whether it's row -3 or row -10.
1268
- const sawHead = async (timeoutMs) => {
1284
+ // 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
1285
+ // 头部可能在框顶, 所以窗口要宽)。
1286
+ const sawLanded = async (timeoutMs) => {
1269
1287
  const t0 = Date.now();
1270
1288
  while (Date.now() - t0 < timeoutMs) {
1271
- const pane = await capturePaneTail(target, 12);
1272
- if (headFp && stripWs(pane).includes(headFp))
1289
+ const cap = await capturePaneTail(target, 24);
1290
+ const box = parseInputBox(cap);
1291
+ if (boxVerify && box.known ? !boxIntact(box) : Boolean(headFp) && stripWs(cap).includes(headFp))
1273
1292
  return true;
1274
- await sleepMs(POLL_MS);
1293
+ await sleep(POLL_MS);
1275
1294
  }
1276
1295
  return false;
1277
1296
  };
1278
- // Narrow window = just the input box. tailFp is the chars next to the
1279
- // cursor, so it's always inside this window pre-submit and gone post-submit.
1297
+ // 窄窗口 = 只有输入框本身。tailFp 紧挨光标, 提交前必在窗内、提交后消失。
1298
+ // 仅用于 parseInputBox 认不出布局的后端。
1280
1299
  const inputBoxStillHasTail = async () => {
1281
1300
  const pane = await capturePaneTail(target, 5);
1282
1301
  return Boolean(tailFp) && stripWs(pane).includes(tailFp);
1283
1302
  };
1284
- let pasteSeen = await sawHead(PASTE_VERIFY_MS);
1303
+ let pasteSeen = await sawLanded(PASTE_VERIFY_MS);
1285
1304
  if (!pasteSeen) {
1286
- // headFp not seen — but on a cold fresh-spawn the TUI can render the paste
1287
- // just outside our capture window / after our budget, so `sawHead` yields a
1288
- // FALSE negative even though the text is sitting in the input box. Blindly
1289
- // re-pasting then stacks a SECOND copy → the classic doubled prompt. Guard:
1290
- // re-paste only if the box genuinely lacks our tail; otherwise the first
1291
- // paste landed and we just proceed to submit.
1292
- if (await inputBoxStillHasTail()) {
1293
- log.warn({ target, headFp }, "mirror inject: headFp not seen but tail present in box — first paste landed, skipping re-paste");
1305
+ const box = await readInputBox(target);
1306
+ const empty = boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
1307
+ if (!empty) {
1308
+ log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 粘贴未验证但输入框非空 — 首次粘贴已落地, 不重贴");
1294
1309
  pasteSeen = true;
1295
1310
  }
1296
1311
  else {
1297
- log.warn({ target, headFp }, "mirror inject: paste headFp not seen, re-pasting");
1298
- await sleepMs(RETRY_SETTLE_MS);
1312
+ log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 输入框确认为空, 重贴一次");
1313
+ await sleep(RETRY_SETTLE_MS);
1299
1314
  r = await loadAndPaste();
1300
1315
  if (!r.ok)
1301
1316
  return r;
1302
- pasteSeen = await sawHead(PASTE_VERIFY_MS);
1317
+ pasteSeen = await sawLanded(PASTE_VERIFY_MS);
1303
1318
  }
1304
1319
  }
1305
1320
  // Bracketed-paste end + TUI catch-up. Warm pane: 400ms is invisible.
1306
1321
  // Fresh respawn: 1500ms+ — claude --resume is still loading the transcript.
1307
- await sleepMs(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
1322
+ await sleep(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
1308
1323
  const sendEnter = async () => {
1309
- const e = await tmuxRun(["send-keys", "-t", target, "Enter"]);
1324
+ const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1310
1325
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
1311
1326
  };
1327
+ const cleared = async () => {
1328
+ const box = await readInputBox(target);
1329
+ return boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
1330
+ };
1312
1331
  const waitForCleared = async (timeoutMs) => {
1313
1332
  const t0 = Date.now();
1314
1333
  while (Date.now() - t0 < timeoutMs) {
1315
- await sleepMs(POLL_MS);
1316
- if (!(await inputBoxStillHasTail()))
1334
+ await sleep(POLL_MS);
1335
+ if (await cleared())
1317
1336
  return true;
1318
1337
  }
1319
1338
  return false;
1320
1339
  };
1321
- const e1 = await sendEnter();
1322
- if (!e1.ok)
1323
- return e1;
1324
1340
  if (!pasteSeen)
1325
1341
  log.warn({ target }, "mirror inject: submitted without paste verification (capture-pane lag)");
1326
- if (!tailFp)
1327
- return { ok: true }; // empty/whitespace text — nothing to verify
1328
- if (await waitForCleared(CLEARED_TIMEOUT_MS))
1329
- return { ok: true };
1330
- // Input box still holds our tail — Enter was eaten (cold TUI) or the
1331
- // bracketed-paste end hadn't been processed yet. One retry with extra
1332
- // settle. We keep this to ONE retry to bound damage if our cleared-check
1333
- // is wrong (would otherwise spam Enters into a real conversation).
1334
- log.warn({ target, tailFp }, "mirror inject: input box still has text after Enter, retrying once");
1335
- await sleepMs(RETRY_SETTLE_MS);
1336
- const e2 = await sendEnter();
1337
- if (!e2.ok)
1338
- return e2;
1339
- if (await waitForCleared(CLEARED_TIMEOUT_MS))
1340
- return { ok: true };
1341
- // freshSpawn fallback: claude --resume can take longer than our budget to
1342
- // process bracketed-paste end. The Enter was sent twice; if it lands later,
1343
- // claude will process the prompt and the user gets their reply. Trust it
1344
- // rather than reporting a hard failure that the user actually got served.
1345
- //
1346
- // Warm-pane path also trusts: in practice tmux Enter is reliable once the
1347
- // pane exists, and the verifier has structural false-positive risk —
1348
- // tailFp (last 8 non-ws chars) can match the echo line directly above the
1349
- // input box when it falls within the 5-row capture window. Surfacing
1350
- // `[mirror] ✗` to the user when the prompt actually landed is worse than
1351
- // accepting an extra no-op Enter on the rare true-stuck case.
1352
- log.warn({ target, tailFp, freshSpawn }, "mirror inject: clear not observed, trusting submit");
1353
- // Input box still held our text after two Enters. Usually the prompt landed
1354
- // late (verifier false-positive), but it can also mean the target session is
1355
- // busy / not consuming input (e.g. running a long task, or context full) —
1356
- // the user's message would then silently go nowhere. Flag it uncertain so the
1357
- // caller can hint the user, without reporting a hard failure.
1342
+ if (!tailFp) {
1343
+ // empty/whitespace text — nothing to verify, fire once and go.
1344
+ const e = await sendEnter();
1345
+ return e.ok ? { ok: true } : e;
1346
+ }
1347
+ // 回车可能被冷启动中的 TUI 吞掉 (框里躺着没提交的提示)。框认得出来时判据可信,
1348
+ // 多补一次; 认不出来时维持原来的「只补一次」, 免得判据出错往真实对话里灌回车。
1349
+ const boxTrusted = boxVerify && (await readInputBox(target)).known;
1350
+ const attempts = boxTrusted ? 3 : 2;
1351
+ for (let i = 0; i < attempts; i++) {
1352
+ if (i > 0) {
1353
+ log.warn({ target, tailFp, attempt: i + 1 }, "mirror inject: 回车后输入框未清空, 再补一次");
1354
+ await sleep(RETRY_SETTLE_MS);
1355
+ }
1356
+ const e = await sendEnter();
1357
+ if (!e.ok)
1358
+ return e;
1359
+ if (await waitForCleared(CLEARED_TIMEOUT_MS))
1360
+ return { ok: true };
1361
+ }
1362
+ // 补到头还没清空。通常是渲染滞后 (回车其实落了), 但也可能是目标会话正忙 /
1363
+ // 上下文满了不消费输入 —— 那样用户这条消息会悄无声息地消失。不报硬失败 (用户
1364
+ // 多半已经被服务到了), 标 uncertain 让调用方按需提示。
1365
+ log.warn({ target, tailFp, freshSpawn, boxTrusted }, "mirror inject: clear not observed, trusting submit");
1358
1366
  return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
1359
1367
  };
1360
1368
  const injectViaSpawn = (args) => {
@@ -1573,7 +1581,7 @@ export const startMirror = (deps) => {
1573
1581
  const detailCardFor = (s, target) => {
1574
1582
  if (s.tools.length === 0)
1575
1583
  return undefined;
1576
- const tag = tagOfTarget(target);
1584
+ const tag = tagOfKey(target);
1577
1585
  const titlePrefix = tag ? `${labelFor(tag)} #${tag} · ` : "";
1578
1586
  return {
1579
1587
  card_type: "button_interaction",
@@ -1590,7 +1598,7 @@ export const startMirror = (deps) => {
1590
1598
  const content = s.acc;
1591
1599
  s.lastSent = content;
1592
1600
  try {
1593
- await client.replyStream(s.frame, s.streamId, withSessionTag(s.target, content || " "), false);
1601
+ await client.replyStream(s.frame, s.streamId, withTagHeader(s.target, content || " "), false);
1594
1602
  log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
1595
1603
  }
1596
1604
  catch (e) {
@@ -1672,7 +1680,7 @@ export const startMirror = (deps) => {
1672
1680
  // Standalone fallback (no live stream / stream dead). Per-attachment FIFO so
1673
1681
  // pushes from a single mirror stay ordered; different mirrors run in parallel.
1674
1682
  // Linked tag prefix: emoji+tag becomes a chat-detail link. Falls back to
1675
- // plain withSessionTag when no turnId is available (no active turn to link).
1683
+ // plain withTagHeader when no turnId is available (no active turn to link).
1676
1684
  const linkedTagPrefix = (target, turnId) => {
1677
1685
  if (!turnId)
1678
1686
  return "";
@@ -1686,7 +1694,7 @@ export const startMirror = (deps) => {
1686
1694
  const seqBit = seq ? ` ${seq}` : "";
1687
1695
  return `${prefix}${seqBit}${headSep(content)}${content}`;
1688
1696
  }
1689
- return withSessionTag(a.target, content, seq);
1697
+ return withTagHeader(a.target, content, seq);
1690
1698
  };
1691
1699
  // 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`🦊 #tag` / `[🧙](url)`)
1692
1700
  // 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
@@ -1697,7 +1705,7 @@ export const startMirror = (deps) => {
1697
1705
  if (!hasVisibleBody(content))
1698
1706
  return;
1699
1707
  const chatId = stripPrincipalPrefix(a.target);
1700
- const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
1708
+ const pieces = splitMarkdown(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
1701
1709
  const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
1702
1710
  a.standalonePending = a.standalonePending
1703
1711
  .then(async () => {
@@ -1712,7 +1720,7 @@ export const startMirror = (deps) => {
1712
1720
  })
1713
1721
  .catch(() => undefined);
1714
1722
  };
1715
- // Like sendStandalone but skips withSessionTag — content already contains the tag header (e.g. as a link).
1723
+ // Like sendStandalone but skips withTagHeader — content already contains the tag header (e.g. as a link).
1716
1724
  const sendRaw = (a, content) => {
1717
1725
  if (!hasVisibleBody(content))
1718
1726
  return;
@@ -1906,7 +1914,7 @@ export const startMirror = (deps) => {
1906
1914
  // sees the bubble; subsequent items grow it as today.
1907
1915
  void (async () => {
1908
1916
  try {
1909
- await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false);
1917
+ await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
1910
1918
  }
1911
1919
  catch (e) {
1912
1920
  log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
@@ -2050,7 +2058,7 @@ export const startMirror = (deps) => {
2050
2058
  // 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
2051
2059
  const turnSilent = (a) => cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false;
2052
2060
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
2053
- // raw=true skips withSessionTag (used when content already contains the linked tag header).
2061
+ // raw=true skips withTagHeader (used when content already contains the linked tag header).
2054
2062
  // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
2055
2063
  // standalone (sendMessage), 用户会看到 standalone 抢在气泡动画结束之前出现。
2056
2064
  // 把 finishBubble 的 replyStream promise 链入 standalonePending, 让后续
@@ -2836,7 +2844,7 @@ export const startMirror = (deps) => {
2836
2844
  return { ok: false, reason: "no_live_pane" };
2837
2845
  if (!(await tmuxPaneAlive(a.tmuxPane)))
2838
2846
  return { ok: false, reason: "pane_dead" };
2839
- const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, key]);
2847
+ const r = await runTmux(["send-keys", "-t", a.tmuxPane, key]);
2840
2848
  return r.code === 0 ? { ok: true } : { ok: false, reason: `send-keys ${key} failed: ${r.stdout.slice(-200) || r.code}` };
2841
2849
  },
2842
2850
  });
@@ -2887,13 +2895,13 @@ export const startMirror = (deps) => {
2887
2895
  }
2888
2896
  else {
2889
2897
  for (const key of act.keys) {
2890
- const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, key]);
2898
+ const r = await runTmux(["send-keys", "-t", a.tmuxPane, key]);
2891
2899
  if (r.code !== 0)
2892
2900
  return { ok: false, reason: `send-keys ${key}: ${r.stdout.slice(-200) || r.code}` };
2893
- await sleepMs(120); // 键间距 — 等 TUI 重渲染, 防吞键
2901
+ await sleep(120); // 键间距 — 等 TUI 重渲染, 防吞键
2894
2902
  }
2895
2903
  }
2896
- await sleepMs(300); // 题间/阶段间隔 — 等面板翻页
2904
+ await sleep(300); // 题间/阶段间隔 — 等面板翻页
2897
2905
  }
2898
2906
  return { ok: true };
2899
2907
  },
@@ -3162,7 +3170,7 @@ export const startMirror = (deps) => {
3162
3170
  const tmuxPaneAlive = async (paneId) => {
3163
3171
  if (!paneId)
3164
3172
  return false;
3165
- const r = await tmuxRun(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
3173
+ const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
3166
3174
  return r.code === 0 && r.stdout.trim() === paneId;
3167
3175
  };
3168
3176
  // Re-attach a stored binding for `principal`. Returns the resulting state, or
@@ -3255,7 +3263,7 @@ export const startMirror = (deps) => {
3255
3263
  // is silently detached; that detached chat then never mirrors again. Runs at
3256
3264
  // boot; the 3s drift follower maintains it thereafter.
3257
3265
  if (livePane) {
3258
- const cwdRes = await tmuxRun(["display-message", "-p", "-t", livePane, "#{pane_current_path}"]);
3266
+ const cwdRes = await runTmux(["display-message", "-p", "-t", livePane, "#{pane_current_path}"]);
3259
3267
  const paneCwd = cwdRes.stdout.trim();
3260
3268
  if (paneCwd) {
3261
3269
  // Encode under the backend that owns the bound transcript — comparing
@@ -3348,10 +3356,10 @@ export const startMirror = (deps) => {
3348
3356
  }
3349
3357
  })();
3350
3358
  const result = t.result ?? "(no result captured)";
3351
- parts.push(`### ${i}. 🔧 ${t.name}\n\n**input**\n\`\`\`json\n${truncate(inputJson, 4000)}\n\`\`\`\n\n**result**\n\`\`\`\n${truncate(result, 4000)}\n\`\`\``);
3359
+ parts.push(`### ${i}. 🔧 ${t.name}\n\n**input**\n\`\`\`json\n${truncateWithCount(inputJson, 4000)}\n\`\`\`\n\n**result**\n\`\`\`\n${truncateWithCount(result, 4000)}\n\`\`\``);
3352
3360
  }
3353
3361
  const merged = parts.join("\n\n---\n\n");
3354
- return splitChunks(merged, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
3362
+ return splitMarkdown(merged, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
3355
3363
  };
3356
3364
  const resolveToolDetail = (turnId) => {
3357
3365
  evictTurns();
@@ -3696,7 +3704,7 @@ export const startMirror = (deps) => {
3696
3704
  // Guard 1+2: pane must be alive AND still in the bound project dir. One
3697
3705
  // display-message gives both — it fails on a dead pane, and its cwd tells
3698
3706
  // us whether the pane still belongs here.
3699
- const r = await tmuxRun(["display-message", "-p", "-t", a.tmuxPane, "#{pane_current_path}"]);
3707
+ const r = await runTmux(["display-message", "-p", "-t", a.tmuxPane, "#{pane_current_path}"]);
3700
3708
  if (stopped)
3701
3709
  return;
3702
3710
  const paneCwd = r.code === 0 ? r.stdout.trim() : "";
@@ -3774,7 +3782,7 @@ export const startMirror = (deps) => {
3774
3782
  const attachments = Array.from(byTarget.values()).filter((a) => a.tmuxPane);
3775
3783
  if (attachments.length === 0)
3776
3784
  return;
3777
- const r = await tmuxRun(["list-panes", "-a", "-F", "#{pane_id}\t#{pane_current_path}"]);
3785
+ const r = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{pane_current_path}"]);
3778
3786
  if (r.code !== 0)
3779
3787
  return;
3780
3788
  const paneCwd = new Map();
@@ -3833,7 +3841,7 @@ export const startMirror = (deps) => {
3833
3841
  // Send via plain sendMessage so the user still gets the info.
3834
3842
  const chatId = stripPrincipalPrefix(target);
3835
3843
  void client
3836
- .sendMessage(chatId, { msgtype: "markdown", markdown: { content: withSessionTag(target, md) } })
3844
+ .sendMessage(chatId, { msgtype: "markdown", markdown: { content: withTagHeader(target, md) } })
3837
3845
  .catch((e) => log.warn({ err: e.message, target }, "pushProjectInfo (no attach) failed"));
3838
3846
  };
3839
3847
  // Cwd is CHAT-SCOPED, not session-scoped: all sessions in the same chat
@@ -3842,7 +3850,7 @@ export const startMirror = (deps) => {
3842
3850
  // own `runningCwd` (the tmux pane's actual working dir at spawn time), but
3843
3851
  // cwd fallbacks and `set_workspace` pendingCwd writes always resolve against the base.
3844
3852
  const chatCwdFallback = (target) => {
3845
- const base = basePrincipalOf(target);
3853
+ const base = baseOfKey(target);
3846
3854
  const baseA = byTarget.get(base);
3847
3855
  const baseRec = deps.store.get(base);
3848
3856
  return {
@@ -3894,13 +3902,13 @@ export const startMirror = (deps) => {
3894
3902
  // is chat-scoped like cwd — a FIRST `/new #tag` has no record of its own,
3895
3903
  // so it falls back to the base session's binding instead of `defaultCli`
3896
3904
  // (otherwise a tagged sibling silently forks onto a different CLI).
3897
- const base = basePrincipalOf(target);
3905
+ const base = baseOfKey(target);
3898
3906
  const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
3899
3907
  const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
3900
3908
  const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
3901
3909
  if (prev?.tmuxPane) {
3902
3910
  // Best-effort kill; ignore errors (pane may already be dead).
3903
- void tmuxRun(["kill-pane", "-t", prev.tmuxPane]);
3911
+ void runTmux(["kill-pane", "-t", prev.tmuxPane]);
3904
3912
  }
3905
3913
  if (prev)
3906
3914
  detach(prev, "/new respawn");
@@ -4002,7 +4010,7 @@ export const startMirror = (deps) => {
4002
4010
  const hit = born[0];
4003
4011
  if (hit)
4004
4012
  return { sessionId: hit.n.replace(/\.jsonl$/, ""), jsonlPath: hit.path };
4005
- await sleepMs(FORK_POLL_MS);
4013
+ await sleep(FORK_POLL_MS);
4006
4014
  }
4007
4015
  return undefined;
4008
4016
  };
@@ -4047,7 +4055,7 @@ export const startMirror = (deps) => {
4047
4055
  tmuxTarget: r.tmuxPane, freshSpawn: true,
4048
4056
  });
4049
4057
  if (!boot.ok) {
4050
- await tmuxRun(["kill-pane", "-t", r.tmuxPane ?? ""]);
4058
+ await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
4051
4059
  return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
4052
4060
  }
4053
4061
  const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target));
@@ -4055,7 +4063,7 @@ export const startMirror = (deps) => {
4055
4063
  // 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
4056
4064
  // 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
4057
4065
  if (!fork) {
4058
- await tmuxRun(["kill-pane", "-t", r.tmuxPane ?? ""]);
4066
+ await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
4059
4067
  return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
4060
4068
  }
4061
4069
  const att = attach({
@@ -4126,7 +4134,7 @@ export const startMirror = (deps) => {
4126
4134
  const expanded = expandHome(trimmed);
4127
4135
  if (!expanded.startsWith("/"))
4128
4136
  return { ok: false, reason: "cwd must be absolute (or start with ~)", runningCwd: "", pendingCwd: "" };
4129
- const base = basePrincipalOf(target);
4137
+ const base = baseOfKey(target);
4130
4138
  const callerA = byTarget.get(target);
4131
4139
  const callerRunning = callerA?.runningCwd?.trim() || deps.store.get(target)?.cwd?.trim() || expandedDefaultCwd;
4132
4140
  const baseA = byTarget.get(base);
@@ -4184,8 +4192,8 @@ export const startMirror = (deps) => {
4184
4192
  return Array.from(keys).sort();
4185
4193
  };
4186
4194
  const chatTargets = (target) => {
4187
- const base = basePrincipalOf(target);
4188
- return allTargets().filter((k) => basePrincipalOf(k) === base);
4195
+ const base = baseOfKey(target);
4196
+ return allTargets().filter((k) => baseOfKey(k) === base);
4189
4197
  };
4190
4198
  // 名字解析:`daily` / `chat:wrxxx` → base principal。认不出就把已知名字一并回给
4191
4199
  // 调用方 —— 跨 chat 出错时"我该写什么"比"你写错了"有用得多。
@@ -4218,23 +4226,23 @@ export const startMirror = (deps) => {
4218
4226
  return c;
4219
4227
  const target = keyOf(c.base, t);
4220
4228
  if (allTargets().includes(target)) {
4221
- return { ok: true, target, foreign: c.base !== basePrincipalOf(self) };
4229
+ return { ok: true, target, foreign: c.base !== baseOfKey(self) };
4222
4230
  }
4223
4231
  return {
4224
4232
  ok: false,
4225
4233
  reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
4226
4234
  // 候选给**地址**而不是裸 key —— 这份清单存在的意义就是让调用方照着改一个
4227
4235
  // 能用的串; 隔壁两个分支早就这么做了, 只有这一条漏掉。
4228
- candidates: allTargets().filter((k) => basePrincipalOf(k) === c.base).map((k) => peerAddress(cfg, self, k)),
4236
+ candidates: allTargets().filter((k) => baseOfKey(k) === c.base).map((k) => peerAddress(cfg, self, k)),
4229
4237
  };
4230
4238
  }
4231
- const local = keyOf(basePrincipalOf(self), t);
4239
+ const local = keyOf(baseOfKey(self), t);
4232
4240
  if (!t)
4233
4241
  return { ok: true, target: local, foreign: false };
4234
4242
  const all = allTargets();
4235
4243
  if (all.includes(local))
4236
4244
  return { ok: true, target: local, foreign: false };
4237
- const foreignMatches = all.filter((k) => tagOfKey(k) === t && basePrincipalOf(k) !== basePrincipalOf(self));
4245
+ const foreignMatches = all.filter((k) => tagOfKey(k) === t && baseOfKey(k) !== baseOfKey(self));
4238
4246
  if (foreignMatches.length === 1)
4239
4247
  return { ok: true, target: foreignMatches[0], foreign: true };
4240
4248
  if (foreignMatches.length === 0) {
@@ -4245,7 +4253,7 @@ export const startMirror = (deps) => {
4245
4253
  return {
4246
4254
  ok: false,
4247
4255
  reason: `'${t}' is a CHAT, not a tag — address one of its sessions, e.g. '${t}#' for its default`,
4248
- candidates: allTargets().filter((k) => basePrincipalOf(k) === asChat).map((k) => peerAddress(cfg, self, k)),
4256
+ candidates: allTargets().filter((k) => baseOfKey(k) === asChat).map((k) => peerAddress(cfg, self, k)),
4249
4257
  };
4250
4258
  }
4251
4259
  return { ok: false, reason: `no peer with tag '#${t}' — create one with new_claude_session, or address it in full as 'chatName#${t}' (list_chats shows the names)` };
@@ -4261,14 +4269,14 @@ export const startMirror = (deps) => {
4261
4269
  const chatRoster = (self) => {
4262
4270
  const bases = new Set([
4263
4271
  ...listChatNames(cfg).map((c) => c.base),
4264
- ...allTargets().map(basePrincipalOf),
4265
- basePrincipalOf(self),
4272
+ ...allTargets().map(baseOfKey),
4273
+ baseOfKey(self),
4266
4274
  ]);
4267
4275
  return [...bases].filter(Boolean).sort().map((base) => ({
4268
4276
  base,
4269
4277
  name: chatNameOf(cfg, base),
4270
- self: base === basePrincipalOf(self),
4271
- targets: allTargets().filter((k) => basePrincipalOf(k) === base),
4278
+ self: base === baseOfKey(self),
4279
+ targets: allTargets().filter((k) => baseOfKey(k) === base),
4272
4280
  }));
4273
4281
  };
4274
4282
  const paneOf = (target) => byTarget.get(target)?.tmuxPane || deps.store.get(target)?.tmuxPane || "";
@@ -4318,7 +4326,7 @@ export const startMirror = (deps) => {
4318
4326
  const jsonlPath = jsonlOf(t);
4319
4327
  const pane = paneOf(t);
4320
4328
  const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
4321
- const tag = tagOfTarget(t);
4329
+ const tag = tagOfKey(t);
4322
4330
  let lastActivity = 0;
4323
4331
  try {
4324
4332
  if (jsonlPath)
@@ -4352,8 +4360,8 @@ export const startMirror = (deps) => {
4352
4360
  // `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
4353
4361
  // 只要给群起个名,不必回去改别人的 tag。
4354
4362
  const foreignPeers = async (self) => {
4355
- const selfBase = basePrincipalOf(self);
4356
- const foreign = allTargets().filter((k) => tagOfKey(k) && basePrincipalOf(k) !== selfBase);
4363
+ const selfBase = baseOfKey(self);
4364
+ const foreign = allTargets().filter((k) => tagOfKey(k) && baseOfKey(k) !== selfBase);
4357
4365
  const tagCount = foreign.reduce((acc, k) => acc.set(tagOfKey(k), (acc.get(tagOfKey(k)) ?? 0) + 1), new Map());
4358
4366
  const reachable = foreign.filter((k) => tagCount.get(tagOfKey(k)) === 1 || chatNameOf(cfg, k));
4359
4367
  return (await Promise.all(reachable.map((t) => peerInfoOf(t, self)))).sort(byRecent);
@@ -4686,8 +4694,8 @@ export const startMirror = (deps) => {
4686
4694
  return { ok: false, reason: `当前确认框已是另一个调用(${now.toolName}),不代按` };
4687
4695
  }
4688
4696
  if (what === "no") {
4689
- await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
4690
- await sleepMs(MODAL_PRESS_SETTLE_MS);
4697
+ await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
4698
+ await sleep(MODAL_PRESS_SETTLE_MS);
4691
4699
  const after = await detectModalPicker(a.tmuxPane);
4692
4700
  return after.modal ? { ok: false, reason: "Escape 后确认框仍在" } : { ok: true, label: "Escape" };
4693
4701
  }
@@ -4817,7 +4825,7 @@ export const startMirror = (deps) => {
4817
4825
  if (a.recentToolSigs.has(expectSig))
4818
4826
  break;
4819
4827
  polls++;
4820
- await sleepMs(50);
4828
+ await sleep(50);
4821
4829
  }
4822
4830
  if (!a.recentToolSigs.has(expectSig)) {
4823
4831
  log.warn({ sessionId, polls, toolName: expect?.toolName }, "flushBeforeCard wait timed out — sending card without sig confirm");
@@ -4903,7 +4911,7 @@ export const startMirror = (deps) => {
4903
4911
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
4904
4912
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
4905
4913
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
4906
- const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: tagOfTarget(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(target) });
4914
+ const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: tagOfKey(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(target) });
4907
4915
  if (!r.ok || !r.tmuxPane)
4908
4916
  return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
4909
4917
  a.tmuxPane = r.tmuxPane;
@@ -4964,7 +4972,7 @@ export const startMirror = (deps) => {
4964
4972
  escReason = "tmux pane no longer alive";
4965
4973
  }
4966
4974
  else {
4967
- const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
4975
+ const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
4968
4976
  escOk = r.ok;
4969
4977
  if (!r.ok)
4970
4978
  escReason = `send-keys Escape failed: ${(r.stderr || r.stdout).slice(-200) || r.code}`;
@@ -4989,9 +4997,9 @@ export const startMirror = (deps) => {
4989
4997
  if (pane && (await tmuxPaneAlive(pane))) {
4990
4998
  // Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
4991
4999
  // flush its transcript before the TTY is yanked out from under it.
4992
- await tmuxRun(["send-keys", "-t", pane, "Escape"]);
4993
- await sleepMs(250);
4994
- const r = await tmuxRun(["kill-pane", "-t", pane]);
5000
+ await runTmux(["send-keys", "-t", pane, "Escape"]);
5001
+ await sleep(250);
5002
+ const r = await runTmux(["kill-pane", "-t", pane]);
4995
5003
  if (r.code !== 0)
4996
5004
  return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
4997
5005
  }
@@ -5014,7 +5022,7 @@ export const startMirror = (deps) => {
5014
5022
  const alive = await tmuxPaneAlive(a.tmuxPane);
5015
5023
  if (!alive)
5016
5024
  return { ok: false, reason: "tmux pane no longer alive" };
5017
- const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Enter"]);
5025
+ const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Enter"]);
5018
5026
  if (r.code !== 0)
5019
5027
  return { ok: false, reason: `send-keys Enter failed: ${r.stdout.slice(-200) || r.code}` };
5020
5028
  log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /n — Enter sent to pane");
@@ -5032,9 +5040,9 @@ export const startMirror = (deps) => {
5032
5040
  // switching a client that sits on an unrelated session would yank the
5033
5041
  // user's other terminal window into wezard. Most-recently-active wins —
5034
5042
  // switch-client with no -c is ambiguous under multiple clients.
5035
- const s = await tmuxRun(["display-message", "-p", "-t", pane, "#{session_name}"]);
5043
+ const s = await runTmux(["display-message", "-p", "-t", pane, "#{session_name}"]);
5036
5044
  const sess = s.stdout.trim() || cfg.wrc.tmuxPrefix;
5037
- const clients = await tmuxRun(["list-clients", "-t", sess, "-F", "#{client_activity} #{client_name}"]);
5045
+ const clients = await runTmux(["list-clients", "-t", sess, "-F", "#{client_activity} #{client_name}"]);
5038
5046
  const best = clients.stdout.split("\n").filter(Boolean)
5039
5047
  .map((l) => { const i = l.indexOf(" "); return { act: Number(l.slice(0, i)), name: l.slice(i + 1) }; })
5040
5048
  .sort((x, y) => y.act - x.act)[0]?.name;
@@ -5043,7 +5051,7 @@ export const startMirror = (deps) => {
5043
5051
  return { ok: false, reason: `没有 attach 到 \`${sess}\` 的 tmux 客户端。先在终端执行: \`tmux attach -t ${sess}\`` };
5044
5052
  }
5045
5053
  // A pane target pulls session + window selection along with it.
5046
- const r = await tmuxRun(["switch-client", "-c", best, "-t", pane]);
5054
+ const r = await runTmux(["switch-client", "-c", best, "-t", pane]);
5047
5055
  if (r.code !== 0)
5048
5056
  return { ok: false, reason: `switch-client failed: ${r.stdout.slice(-200) || r.code}` };
5049
5057
  log.info({ target, pane, client: best }, "mirror /reveal — tmux client switched");
@@ -5063,7 +5071,7 @@ export const startMirror = (deps) => {
5063
5071
  const deadline = Date.now() + Math.max(0, opts.waitMs);
5064
5072
  let v = await detectModalPicker(pane);
5065
5073
  while (!v.modal && Date.now() < deadline) {
5066
- await sleepMs(POLL_MS);
5074
+ await sleep(POLL_MS);
5067
5075
  v = await detectModalPicker(pane);
5068
5076
  }
5069
5077
  // 没等到: 这次 CC 没弹框(会话内已授权过 / 命中盲点判断失误)。无事可做。
@@ -5116,7 +5124,7 @@ export const startMirror = (deps) => {
5116
5124
  a = await restoreFromStore(principal);
5117
5125
  if (!a) {
5118
5126
  try {
5119
- await client.replyStream(frame, streamId, withSessionTag(principal, "[wezard] wecom remote control not attached — run `/wrc` inside the target Claude session"), true);
5127
+ await client.replyStream(frame, streamId, withTagHeader(principal, "[wezard] wecom remote control not attached — run `/wrc` inside the target Claude session"), true);
5120
5128
  }
5121
5129
  catch {
5122
5130
  /* ignore */
@@ -5164,11 +5172,11 @@ export const startMirror = (deps) => {
5164
5172
  log.info({ target: a.target, runningCwd: a.runningCwd, pendingCwd: pending }, "/clear upgraded to /new (cwd switch)");
5165
5173
  // Prefer the tag suffix as tmux window name when present (matches
5166
5174
  // /new #tag behavior), fall back to the full target for untagged.
5167
- const tag = tagOfTarget(a.target);
5175
+ const tag = tagOfKey(a.target);
5168
5176
  const r = await newSession(a.target, tag || a.target);
5169
5177
  if (!r.ok) {
5170
5178
  try {
5171
- await client.replyStream(frame, streamId, withSessionTag(a.target, `[mirror] 切换失败: ${r.reason ?? "unknown"}`), true);
5179
+ await client.replyStream(frame, streamId, withTagHeader(a.target, `[mirror] 切换失败: ${r.reason ?? "unknown"}`), true);
5172
5180
  }
5173
5181
  catch { /* ignore */ }
5174
5182
  }
@@ -5226,7 +5234,7 @@ export const startMirror = (deps) => {
5226
5234
  if (s) {
5227
5235
  a.liveStream = s;
5228
5236
  try {
5229
- await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false);
5237
+ await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
5230
5238
  }
5231
5239
  catch (e) {
5232
5240
  log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
@@ -5271,7 +5279,7 @@ export const startMirror = (deps) => {
5271
5279
  const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
5272
5280
  // Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
5273
5281
  // mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
5274
- const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: tagOfTarget(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(a.target) });
5282
+ const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: tagOfKey(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(a.target) });
5275
5283
  if (r.ok && r.tmuxPane && r.tmuxSession) {
5276
5284
  a.tmuxPane = r.tmuxPane;
5277
5285
  a.tmuxSession = r.tmuxSession;
@@ -5341,7 +5349,7 @@ export const startMirror = (deps) => {
5341
5349
  // /clear path has no live stream — surface failure as a one-shot
5342
5350
  // terse reply ("clean" per project convention).
5343
5351
  try {
5344
- await client.replyStream(frame, streamId, withSessionTag(a.target, "clean"), true);
5352
+ await client.replyStream(frame, streamId, withTagHeader(a.target, "clean"), true);
5345
5353
  }
5346
5354
  catch { /* ignore */ }
5347
5355
  }
@@ -5384,8 +5392,8 @@ export const startMirror = (deps) => {
5384
5392
  // "Set model to …" as <local-command-stdout> — the tail mirrors that
5385
5393
  // back to the chat as a ⚙️ bubble, which IS the readback.
5386
5394
  if (isModelSwitch && a.tmuxPane) {
5387
- await sleepMs(1000);
5388
- const e = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Enter"]);
5395
+ await sleep(1000);
5396
+ const e = await runTmux(["send-keys", "-t", a.tmuxPane, "Enter"]);
5389
5397
  if (e.code !== 0)
5390
5398
  log.warn({ pane: a.tmuxPane, reason: e.stdout.slice(-200) || e.code }, "/model confirm Enter failed — user can send /n manually");
5391
5399
  }
@@ -5430,7 +5438,7 @@ export const installMirrorEventListener = (client, bridge, log) => {
5430
5438
  const n = detail.markdown.length;
5431
5439
  for (const [i, md] of detail.markdown.entries()) {
5432
5440
  try {
5433
- const content = withSessionTag(detail.target, md, n > 1 ? `${i + 1}/${n}` : undefined);
5441
+ const content = withTagHeader(detail.target, md, n > 1 ? `${i + 1}/${n}` : undefined);
5434
5442
  await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
5435
5443
  }
5436
5444
  catch (e) {