wezard 1.3.23 → 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 (70) hide show
  1. package/CLAUDE.md +16 -4
  2. package/README.md +82 -41
  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/chat-name.js +65 -2
  12. package/dist/daemon/chat-name.js.map +1 -1
  13. package/dist/daemon/config-api.js +0 -1
  14. package/dist/daemon/config-api.js.map +1 -1
  15. package/dist/daemon/detail.js +7 -0
  16. package/dist/daemon/detail.js.map +1 -1
  17. package/dist/daemon/graph.js +27 -9
  18. package/dist/daemon/graph.js.map +1 -1
  19. package/dist/daemon/inbound.js +116 -60
  20. package/dist/daemon/inbound.js.map +1 -1
  21. package/dist/daemon/index.js +774 -142
  22. package/dist/daemon/index.js.map +1 -1
  23. package/dist/daemon/jobs.js +73 -0
  24. package/dist/daemon/jobs.js.map +1 -0
  25. package/dist/daemon/mirror-bridge.js +378 -190
  26. package/dist/daemon/mirror-bridge.js.map +1 -1
  27. package/dist/daemon/mirror-store.js +2 -33
  28. package/dist/daemon/mirror-store.js.map +1 -1
  29. package/dist/daemon/notices.js +70 -0
  30. package/dist/daemon/notices.js.map +1 -0
  31. package/dist/daemon/peers.js +93 -61
  32. package/dist/daemon/peers.js.map +1 -1
  33. package/dist/daemon/quota.js +1 -1
  34. package/dist/daemon/quota.js.map +1 -1
  35. package/dist/daemon/session-cache.js +0 -1
  36. package/dist/daemon/session-cache.js.map +1 -1
  37. package/dist/daemon/session-scan.js +4 -22
  38. package/dist/daemon/session-scan.js.map +1 -1
  39. package/dist/daemon/sessions.js +2 -33
  40. package/dist/daemon/sessions.js.map +1 -1
  41. package/dist/daemon/spawn-tmux.js +48 -14
  42. package/dist/daemon/spawn-tmux.js.map +1 -1
  43. package/dist/daemon/subagent-tail.js +2 -2
  44. package/dist/daemon/subagent-tail.js.map +1 -1
  45. package/dist/daemon/tasks.js +90 -0
  46. package/dist/daemon/tasks.js.map +1 -0
  47. package/dist/daemon/wizard.js +128 -0
  48. package/dist/daemon/wizard.js.map +1 -0
  49. package/dist/mcp/server.js +267 -156
  50. package/dist/mcp/server.js.map +1 -1
  51. package/dist/shared/cli-backends.js +10 -11
  52. package/dist/shared/cli-backends.js.map +1 -1
  53. package/dist/shared/config.js +50 -14
  54. package/dist/shared/config.js.map +1 -1
  55. package/dist/shared/detail-render.js +6 -5
  56. package/dist/shared/detail-render.js.map +1 -1
  57. package/dist/shared/detail-store.js.map +1 -1
  58. package/dist/shared/exec-path.js +21 -0
  59. package/dist/shared/exec-path.js.map +1 -0
  60. package/dist/shared/json-map-store.js +41 -0
  61. package/dist/shared/json-map-store.js.map +1 -0
  62. package/dist/shared/schedule-spec.js +240 -0
  63. package/dist/shared/schedule-spec.js.map +1 -0
  64. package/dist/shared/session-label.js +8 -4
  65. package/dist/shared/session-label.js.map +1 -1
  66. package/dist/shared/std.js +9 -0
  67. package/dist/shared/std.js.map +1 -0
  68. package/package.json +1 -1
  69. package/dist/daemon/topics.js +0 -106
  70. package/dist/daemon/topics.js.map +0 -1
@@ -16,40 +16,24 @@ 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";
25
+ import { noticeSuffixFor } from "./notices.js";
23
26
  import { dangerOf } from "./danger.js";
24
- import { runTmux as runTmuxCmd, spawnTmuxClaude } from "./spawn-tmux.js";
27
+ import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
25
28
  import { startSubagentWatch } from "./subagent-tail.js";
26
29
  import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
27
- import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, parseTagHeader } from "../shared/session-label.js";
30
+ import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, headSep, parseTagHeader } from "../shared/session-label.js";
28
31
  import { splitMarkdown } from "../shared/md-chunk.js";
29
32
  import { randomTip } from "./tips.js";
30
33
  import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peerAddress } from "./chat-name.js";
31
34
  import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
32
35
  // Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
33
36
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
34
- const NODE_BIN_DIR = dirname(process.execPath);
35
- const augmentedPath = (orig) => {
36
- const extras = [
37
- NODE_BIN_DIR,
38
- "/opt/homebrew/bin",
39
- "/usr/local/bin",
40
- `${process.env.HOME ?? ""}/.local/bin`,
41
- ].filter(Boolean);
42
- const seen = new Set();
43
- return [orig ?? "", ...extras]
44
- .flatMap((p) => p.split(":"))
45
- .filter((p) => {
46
- if (!p || seen.has(p))
47
- return false;
48
- seen.add(p);
49
- return true;
50
- })
51
- .join(":");
52
- };
53
37
  // Backend resolution is per-transcript, not global: `backendForPath` recovers
54
38
  // which CLI wrote a jsonl from its projects root, so claude / claude-internal /
55
39
  // codebuddy sessions can be mirrored side by side. `projectDirFor` re-encodes a
@@ -263,15 +247,6 @@ const stripPrincipalPrefix = (s) => {
263
247
  const h = rest.indexOf("#");
264
248
  return h >= 0 ? rest.slice(0, h) : rest;
265
249
  };
266
- // Extract the `#tag` suffix from a target key, "" if untagged.
267
- const tagOfTarget = tagOfKey;
268
- // Drop the `#tag` suffix — collapses tagged session keys to the chat-scoped
269
- // base principal. Shared with the peer/graph layer via session-label.
270
- const basePrincipalOf = baseOfKey;
271
- // Prefix outbound content with `<emoji> #tag` header (blank line separator)
272
- // when the target carries a `#tag` suffix. Untagged targets pass through
273
- // unchanged — default session keeps its plain-bubble UX.
274
- const withSessionTag = withTagHeader;
275
250
  // Claude Code wraps slash-command invocations into the user message as
276
251
  // <command-message>name</command-message>
277
252
  // <command-name>/name</command-name>
@@ -329,7 +304,6 @@ const cleanUserText = (raw) => {
329
304
  }
330
305
  return stripped;
331
306
  };
332
- const truncate = (s, max) => s.length <= max ? s : `${s.slice(0, max)}…(+${s.length - max})`;
333
307
  // 后台子agent 完成通知的无标签形态 —— codebuddy 平台在子agent 跑完后注入
334
308
  // `[Framework Auto-Notification] <agent> completed` 的纯文本 user 行 (不套
335
309
  // <task-notification>)。只认"整条消息以它开头", 免得把正文里提到它的消息吞掉。
@@ -370,7 +344,7 @@ const textSimilarity = (x, y) => {
370
344
  const renderToolInput = (input) => {
371
345
  try {
372
346
  const json = JSON.stringify(input ?? {}, null, 0);
373
- return truncate(json, 600);
347
+ return truncateWithCount(json, 600);
374
348
  }
375
349
  catch {
376
350
  return "{}";
@@ -392,7 +366,7 @@ const extractToolResultText = (block) => {
392
366
  const DETAIL_RESULT_MAX = 64 * 1024;
393
367
  const oneLineSummary = (s, max = 40) => {
394
368
  const flat = s.replace(/\s+/g, " ").trim();
395
- return truncate(flat, max);
369
+ return truncateWithCount(flat, max);
396
370
  };
397
371
  // WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
398
372
  // code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
@@ -536,7 +510,7 @@ const renderLine = (raw, deps) => {
536
510
  if (!raw)
537
511
  continue;
538
512
  const toolUseId = b.tool_use_id ?? "";
539
- const full = truncate(raw, DETAIL_RESULT_MAX);
513
+ const full = truncateWithCount(raw, DETAIL_RESULT_MAX);
540
514
  // 始终把完整 result 落 detail 库 + 作为 tool_result item 发出 — 与
541
515
  // includeToolResults(气泡推送开关)彻底解耦: 关掉气泡时 detail 页与 brief
542
516
  // turn 页(handleBriefItem 消费本 item 写 turn store)都仍要看到 result。
@@ -703,7 +677,6 @@ const renderLine = (raw, deps) => {
703
677
  };
704
678
  // Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
705
679
  // fenced block or table in a way that breaks rendering — see splitMarkdown.
706
- const splitChunks = splitMarkdown;
707
680
  // Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
708
681
  // (up to ~110 bytes in its linked `[🧙 #tag](url)` form).
709
682
  const TAG_HEADER_BUDGET = 64;
@@ -831,8 +804,6 @@ export const startMirrorTail = (deps) => {
831
804
  // exactly one place where the hard timeout lives. (Two hand-rolled copies used
832
805
  // to exist here; both could hang forever, which is how a wedged tmux server
833
806
  // silently killed a whole chat. See TMUX_TIMEOUT_MS.)
834
- const tmuxRun = (args, opts) => runTmuxCmd(args, opts);
835
- const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
836
807
  // Stable JSON: sort object keys recursively. Used to fingerprint a tool_use's
837
808
  // `input` so the hook-side and the jsonl-side compute the same signature even
838
809
  // when the model emits keys in arbitrary order.
@@ -954,7 +925,7 @@ const setMacClipboardImage = async (imgPath) => {
954
925
  // the input box and would otherwise re-trigger the fingerprint, falsely
955
926
  // flagging "Enter not honored".
956
927
  const capturePaneTail = async (target, rows = 12) => {
957
- const r = await tmuxRun(["capture-pane", "-t", target, "-p", "-S", `-${rows}`]);
928
+ const r = await runTmux(["capture-pane", "-t", target, "-p", "-S", `-${rows}`]);
958
929
  return r.ok ? r.stdout : "";
959
930
  };
960
931
  // Trim slash-command stdout to the useful section. TUI panels prepend a run
@@ -1042,7 +1013,7 @@ const MODAL_SCAN_ROWS = 15;
1042
1013
  // `screen` = 实际参与判定的那一屏文本, 一并返回给调用方做选项解析 —— 再 capture
1043
1014
  // 一次会拿到"下一瞬间"的屏幕, 与 verdict 不同源(框可能已被本地按掉), 按键就按错了。
1044
1015
  const detectModalPicker = async (target) => {
1045
- const r = await tmuxRun(["capture-pane", "-t", target, "-p"]);
1016
+ const r = await runTmux(["capture-pane", "-t", target, "-p"]);
1046
1017
  if (!r.ok)
1047
1018
  return { modal: false, screen: "" };
1048
1019
  const lines = r.stdout.replace(/\s+$/u, "").split("\n");
@@ -1059,8 +1030,8 @@ const MODAL_PRESS_SETTLE_MS = 400;
1059
1030
  const MODAL_ARROW_SETTLE_MS = 150;
1060
1031
  const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
1061
1032
  const pressModalIndex = async (pane, before, index) => {
1062
- await tmuxRun(["send-keys", "-t", pane, String(index)]);
1063
- await sleepMs(MODAL_PRESS_SETTLE_MS);
1033
+ await runTmux(["send-keys", "-t", pane, String(index)]);
1034
+ await sleep(MODAL_PRESS_SETTLE_MS);
1064
1035
  let after = await detectModalPicker(pane);
1065
1036
  if (!after.modal || after.title !== before.title)
1066
1037
  return after;
@@ -1069,16 +1040,16 @@ const pressModalIndex = async (pane, before, index) => {
1069
1040
  const cur = highlightedIndex(after.screen);
1070
1041
  if (cur === undefined || cur === index)
1071
1042
  break;
1072
- await tmuxRun(["send-keys", "-t", pane, cur < index ? "Down" : "Up"]);
1073
- await sleepMs(MODAL_ARROW_SETTLE_MS);
1043
+ await runTmux(["send-keys", "-t", pane, cur < index ? "Down" : "Up"]);
1044
+ await sleep(MODAL_ARROW_SETTLE_MS);
1074
1045
  after = await detectModalPicker(pane);
1075
1046
  if (!after.modal || after.title !== before.title)
1076
1047
  return after;
1077
1048
  }
1078
1049
  if (highlightedIndex(after.screen) !== index)
1079
1050
  return after; // 核不准, 绝不盲按
1080
- await tmuxRun(["send-keys", "-t", pane, "Enter"]);
1081
- await sleepMs(MODAL_PRESS_SETTLE_MS);
1051
+ await runTmux(["send-keys", "-t", pane, "Enter"]);
1052
+ await sleep(MODAL_PRESS_SETTLE_MS);
1082
1053
  return detectModalPicker(pane);
1083
1054
  };
1084
1055
  // 「聊聊这个」收尾: 文本 inject 已把引导语贴进自定义文本行 (❯ N. <text>)。codebuddy
@@ -1096,7 +1067,7 @@ const ASKQ_CONFIRM_STAGE_MS = 2500; // 每阶段 (到提交页 / 面板关闭)
1096
1067
  const ASKQ_SECONDARY_ENTERS = 3;
1097
1068
  const confirmAskqSubmit = async (target, paneAlive) => {
1098
1069
  const capture = async () => {
1099
- const r = await tmuxRun(["capture-pane", "-t", target, "-p"]);
1070
+ const r = await runTmux(["capture-pane", "-t", target, "-p"]);
1100
1071
  return r.ok ? r.stdout.replace(/\s+$/u, "") : "";
1101
1072
  };
1102
1073
  // 轮询到 pred 为真; 期间 pane 死了立即失败。
@@ -1109,10 +1080,10 @@ const confirmAskqSubmit = async (target, paneAlive) => {
1109
1080
  return true;
1110
1081
  if (Date.now() - t0 >= timeoutMs)
1111
1082
  return false;
1112
- await sleepMs(ASKQ_CONFIRM_POLL_MS);
1083
+ await sleep(ASKQ_CONFIRM_POLL_MS);
1113
1084
  }
1114
1085
  };
1115
- const sendKey = (key) => tmuxRun(["send-keys", "-t", target, key]);
1086
+ const sendKey = (key) => runTmux(["send-keys", "-t", target, key]);
1116
1087
  for (let attempt = 0; attempt < ASKQ_CONFIRM_ENTERS; attempt++) {
1117
1088
  if (!(await paneAlive()))
1118
1089
  return { ok: false, reason: "pane_dead" };
@@ -1134,7 +1105,7 @@ const confirmAskqSubmit = async (target, paneAlive) => {
1134
1105
  const d = await sendKey("Down");
1135
1106
  if (!d.ok)
1136
1107
  return { ok: false, reason: `send-keys Down: ${d.stderr.slice(-200) || d.code}` };
1137
- await sleepMs(ASKQ_CONFIRM_POLL_MS); // 等光标移动重绘
1108
+ await sleep(ASKQ_CONFIRM_POLL_MS); // 等光标移动重绘
1138
1109
  const e = await sendKey("Enter");
1139
1110
  if (!e.ok)
1140
1111
  return { ok: false, reason: `send-keys Enter: ${e.stderr.slice(-200) || e.code}` };
@@ -1164,14 +1135,46 @@ const fingerprints = (text) => {
1164
1135
  const stripped = text.replace(/\s+/gu, "").trim();
1165
1136
  return { headFp: stripped.slice(0, 8), tailFp: stripped.slice(-8) };
1166
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));
1167
1174
  // Self-verifying inject. The cold-spawn race we guard against: paste lands
1168
1175
  // but the trailing Enter is eaten while the TUI is still initializing, so
1169
- // the prompt sits typed-but-unsent. Strategy:
1170
- // 1. paste; wide-window poll for headFp → paste reached the input box.
1171
- // 2. settle; send Enter.
1172
- // 3. narrow-window poll (last 5 rows = just the input box, NOT the echo
1173
- // above) for tailFp absence → submit was honored.
1174
- // 4. on stuck-after-Enter, retry Enter once with extra settle.
1176
+ // the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
1177
+ // 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
1175
1178
  const injectViaTmux = async (target, text, images, log, freshSpawn, backendName, bypassModalGuard = false) => {
1176
1179
  log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
1177
1180
  // Never type into a modal picker (see detectModalPicker). Checked before the
@@ -1218,10 +1221,10 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName,
1218
1221
  log.warn({ imgPath, reason: cb.reason }, "mirror inject: clipboard set failed, skipping image");
1219
1222
  continue;
1220
1223
  }
1221
- const cv = await tmuxRun(["send-keys", "-t", target, "C-v"]);
1224
+ const cv = await runTmux(["send-keys", "-t", target, "C-v"]);
1222
1225
  if (!cv.ok)
1223
1226
  return { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
1224
- await sleepMs(IMG_SETTLE_MS);
1227
+ await sleep(IMG_SETTLE_MS);
1225
1228
  }
1226
1229
  // Image-only message: TUI input box now holds the attached images; press
1227
1230
  // Enter and we're done. No fingerprint to verify (the input itself is binary
@@ -1229,13 +1232,20 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName,
1229
1232
  if (!text) {
1230
1233
  if (images.length === 0)
1231
1234
  return { ok: true };
1232
- const e = await tmuxRun(["send-keys", "-t", target, "Enter"]);
1235
+ const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1233
1236
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
1234
1237
  }
1235
1238
  return injectViaTmuxText(target, text, log, freshSpawn);
1236
1239
  };
1237
1240
  // 文本 paste + Enter 提交 + 自校验。从 injectViaTmux 抽出来,让 codebuddy
1238
1241
  // 后端的 @<path> 回退路径也能复用同样的 paste-verify 逻辑。
1242
+ //
1243
+ // 两个判据都以**输入框整块**为准 (parseInputBox), 指纹只是认不出布局时的退路:
1244
+ // 1. 贴完轮询「框变了」→ 粘贴已被 TUI 消化 (折叠成占位符也算)。这是个**正**
1245
+ // 信号, 只有确凿看见框仍是贴之前的样子才重贴 —— 存疑一律不重贴, 多贴一次
1246
+ // 的代价 (两份提示叠进同一个输入框) 远高于少贴一次。
1247
+ // 2. 回车后轮询「框回到贴之前的样子」→ 提交被吃下了。框认得出来时这条判据可
1248
+ // 信, 于是允许多补一次回车 (空框上的回车在 CLI 里是 no-op)。
1239
1249
  const injectViaTmuxText = async (target, text, log, freshSpawn) => {
1240
1250
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1241
1251
  // started, transcript still loading): extended timings — bracketed-paste
@@ -1246,13 +1256,24 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
1246
1256
  const POST_PASTE_SETTLE_FALLBACK_MS = freshSpawn ? 2500 : 700;
1247
1257
  const CLEARED_TIMEOUT_MS = freshSpawn ? 4000 : 1500;
1248
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);
1249
1270
  const loadAndPaste = async () => {
1250
1271
  // stdin variant of the shared exec path — it carries the same hard timeout,
1251
1272
  // which a hand-rolled spawn here did not.
1252
- const loaded = await tmuxRun(["load-buffer", "-"], { stdin: text });
1273
+ const loaded = await runTmux(["load-buffer", "-"], { stdin: text });
1253
1274
  if (!loaded.ok)
1254
1275
  return { ok: false, reason: `tmux load-buffer failed: ${loaded.stderr.slice(-200) || loaded.code}` };
1255
- const pasted = await tmuxRun(["paste-buffer", "-p", "-d", "-t", target]);
1276
+ const pasted = await runTmux(["paste-buffer", "-p", "-d", "-t", target]);
1256
1277
  if (!pasted.ok)
1257
1278
  return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
1258
1279
  return { ok: true };
@@ -1260,100 +1281,88 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
1260
1281
  let r = await loadAndPaste();
1261
1282
  if (!r.ok)
1262
1283
  return r;
1263
- const { headFp, tailFp } = fingerprints(text);
1264
- const POLL_MS = 100;
1265
- const stripWs = (s) => s.replace(/\s+/gu, "");
1266
- // Wide window catches a wrapped paste's head whether it's row -3 or row -10.
1267
- const sawHead = async (timeoutMs) => {
1284
+ // 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
1285
+ // 头部可能在框顶, 所以窗口要宽)。
1286
+ const sawLanded = async (timeoutMs) => {
1268
1287
  const t0 = Date.now();
1269
1288
  while (Date.now() - t0 < timeoutMs) {
1270
- const pane = await capturePaneTail(target, 12);
1271
- 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))
1272
1292
  return true;
1273
- await sleepMs(POLL_MS);
1293
+ await sleep(POLL_MS);
1274
1294
  }
1275
1295
  return false;
1276
1296
  };
1277
- // Narrow window = just the input box. tailFp is the chars next to the
1278
- // cursor, so it's always inside this window pre-submit and gone post-submit.
1297
+ // 窄窗口 = 只有输入框本身。tailFp 紧挨光标, 提交前必在窗内、提交后消失。
1298
+ // 仅用于 parseInputBox 认不出布局的后端。
1279
1299
  const inputBoxStillHasTail = async () => {
1280
1300
  const pane = await capturePaneTail(target, 5);
1281
1301
  return Boolean(tailFp) && stripWs(pane).includes(tailFp);
1282
1302
  };
1283
- let pasteSeen = await sawHead(PASTE_VERIFY_MS);
1303
+ let pasteSeen = await sawLanded(PASTE_VERIFY_MS);
1284
1304
  if (!pasteSeen) {
1285
- // headFp not seen — but on a cold fresh-spawn the TUI can render the paste
1286
- // just outside our capture window / after our budget, so `sawHead` yields a
1287
- // FALSE negative even though the text is sitting in the input box. Blindly
1288
- // re-pasting then stacks a SECOND copy → the classic doubled prompt. Guard:
1289
- // re-paste only if the box genuinely lacks our tail; otherwise the first
1290
- // paste landed and we just proceed to submit.
1291
- if (await inputBoxStillHasTail()) {
1292
- 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: 粘贴未验证但输入框非空 — 首次粘贴已落地, 不重贴");
1293
1309
  pasteSeen = true;
1294
1310
  }
1295
1311
  else {
1296
- log.warn({ target, headFp }, "mirror inject: paste headFp not seen, re-pasting");
1297
- await sleepMs(RETRY_SETTLE_MS);
1312
+ log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 输入框确认为空, 重贴一次");
1313
+ await sleep(RETRY_SETTLE_MS);
1298
1314
  r = await loadAndPaste();
1299
1315
  if (!r.ok)
1300
1316
  return r;
1301
- pasteSeen = await sawHead(PASTE_VERIFY_MS);
1317
+ pasteSeen = await sawLanded(PASTE_VERIFY_MS);
1302
1318
  }
1303
1319
  }
1304
1320
  // Bracketed-paste end + TUI catch-up. Warm pane: 400ms is invisible.
1305
1321
  // Fresh respawn: 1500ms+ — claude --resume is still loading the transcript.
1306
- 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);
1307
1323
  const sendEnter = async () => {
1308
- const e = await tmuxRun(["send-keys", "-t", target, "Enter"]);
1324
+ const e = await runTmux(["send-keys", "-t", target, "Enter"]);
1309
1325
  return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
1310
1326
  };
1327
+ const cleared = async () => {
1328
+ const box = await readInputBox(target);
1329
+ return boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
1330
+ };
1311
1331
  const waitForCleared = async (timeoutMs) => {
1312
1332
  const t0 = Date.now();
1313
1333
  while (Date.now() - t0 < timeoutMs) {
1314
- await sleepMs(POLL_MS);
1315
- if (!(await inputBoxStillHasTail()))
1334
+ await sleep(POLL_MS);
1335
+ if (await cleared())
1316
1336
  return true;
1317
1337
  }
1318
1338
  return false;
1319
1339
  };
1320
- const e1 = await sendEnter();
1321
- if (!e1.ok)
1322
- return e1;
1323
1340
  if (!pasteSeen)
1324
1341
  log.warn({ target }, "mirror inject: submitted without paste verification (capture-pane lag)");
1325
- if (!tailFp)
1326
- return { ok: true }; // empty/whitespace text — nothing to verify
1327
- if (await waitForCleared(CLEARED_TIMEOUT_MS))
1328
- return { ok: true };
1329
- // Input box still holds our tail — Enter was eaten (cold TUI) or the
1330
- // bracketed-paste end hadn't been processed yet. One retry with extra
1331
- // settle. We keep this to ONE retry to bound damage if our cleared-check
1332
- // is wrong (would otherwise spam Enters into a real conversation).
1333
- log.warn({ target, tailFp }, "mirror inject: input box still has text after Enter, retrying once");
1334
- await sleepMs(RETRY_SETTLE_MS);
1335
- const e2 = await sendEnter();
1336
- if (!e2.ok)
1337
- return e2;
1338
- if (await waitForCleared(CLEARED_TIMEOUT_MS))
1339
- return { ok: true };
1340
- // freshSpawn fallback: claude --resume can take longer than our budget to
1341
- // process bracketed-paste end. The Enter was sent twice; if it lands later,
1342
- // claude will process the prompt and the user gets their reply. Trust it
1343
- // rather than reporting a hard failure that the user actually got served.
1344
- //
1345
- // Warm-pane path also trusts: in practice tmux Enter is reliable once the
1346
- // pane exists, and the verifier has structural false-positive risk —
1347
- // tailFp (last 8 non-ws chars) can match the echo line directly above the
1348
- // input box when it falls within the 5-row capture window. Surfacing
1349
- // `[mirror] ✗` to the user when the prompt actually landed is worse than
1350
- // accepting an extra no-op Enter on the rare true-stuck case.
1351
- log.warn({ target, tailFp, freshSpawn }, "mirror inject: clear not observed, trusting submit");
1352
- // Input box still held our text after two Enters. Usually the prompt landed
1353
- // late (verifier false-positive), but it can also mean the target session is
1354
- // busy / not consuming input (e.g. running a long task, or context full) —
1355
- // the user's message would then silently go nowhere. Flag it uncertain so the
1356
- // 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");
1357
1366
  return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
1358
1367
  };
1359
1368
  const injectViaSpawn = (args) => {
@@ -1572,7 +1581,7 @@ export const startMirror = (deps) => {
1572
1581
  const detailCardFor = (s, target) => {
1573
1582
  if (s.tools.length === 0)
1574
1583
  return undefined;
1575
- const tag = tagOfTarget(target);
1584
+ const tag = tagOfKey(target);
1576
1585
  const titlePrefix = tag ? `${labelFor(tag)} #${tag} · ` : "";
1577
1586
  return {
1578
1587
  card_type: "button_interaction",
@@ -1589,7 +1598,7 @@ export const startMirror = (deps) => {
1589
1598
  const content = s.acc;
1590
1599
  s.lastSent = content;
1591
1600
  try {
1592
- 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);
1593
1602
  log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
1594
1603
  }
1595
1604
  catch (e) {
@@ -1671,7 +1680,7 @@ export const startMirror = (deps) => {
1671
1680
  // Standalone fallback (no live stream / stream dead). Per-attachment FIFO so
1672
1681
  // pushes from a single mirror stay ordered; different mirrors run in parallel.
1673
1682
  // Linked tag prefix: emoji+tag becomes a chat-detail link. Falls back to
1674
- // 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).
1675
1684
  const linkedTagPrefix = (target, turnId) => {
1676
1685
  if (!turnId)
1677
1686
  return "";
@@ -1683,9 +1692,9 @@ export const startMirror = (deps) => {
1683
1692
  const prefix = linkedTagPrefix(a.target, turnId ?? a.briefTurnId);
1684
1693
  if (prefix) {
1685
1694
  const seqBit = seq ? ` ${seq}` : "";
1686
- return `${prefix}${seqBit} ${content}`;
1695
+ return `${prefix}${seqBit}${headSep(content)}${content}`;
1687
1696
  }
1688
- return withSessionTag(a.target, content, seq);
1697
+ return withTagHeader(a.target, content, seq);
1689
1698
  };
1690
1699
  // 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`🦊 #tag` / `[🧙](url)`)
1691
1700
  // 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
@@ -1696,7 +1705,7 @@ export const startMirror = (deps) => {
1696
1705
  if (!hasVisibleBody(content))
1697
1706
  return;
1698
1707
  const chatId = stripPrincipalPrefix(a.target);
1699
- 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));
1700
1709
  const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
1701
1710
  a.standalonePending = a.standalonePending
1702
1711
  .then(async () => {
@@ -1711,7 +1720,7 @@ export const startMirror = (deps) => {
1711
1720
  })
1712
1721
  .catch(() => undefined);
1713
1722
  };
1714
- // 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).
1715
1724
  const sendRaw = (a, content) => {
1716
1725
  if (!hasVisibleBody(content))
1717
1726
  return;
@@ -1905,7 +1914,7 @@ export const startMirror = (deps) => {
1905
1914
  // sees the bubble; subsequent items grow it as today.
1906
1915
  void (async () => {
1907
1916
  try {
1908
- await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false);
1917
+ await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
1909
1918
  }
1910
1919
  catch (e) {
1911
1920
  log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
@@ -2049,7 +2058,7 @@ export const startMirror = (deps) => {
2049
2058
  // 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
2050
2059
  const turnSilent = (a) => cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false;
2051
2060
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
2052
- // 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).
2053
2062
  // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
2054
2063
  // standalone (sendMessage), 用户会看到 standalone 抢在气泡动画结束之前出现。
2055
2064
  // 把 finishBubble 的 replyStream promise 链入 standalonePending, 让后续
@@ -2109,12 +2118,15 @@ export const startMirror = (deps) => {
2109
2118
  a.pendingOrigin = undefined;
2110
2119
  return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.origin : undefined;
2111
2120
  };
2121
+ // 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
2122
+ // 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
2123
+ const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
2112
2124
  const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "") => {
2113
2125
  const turnId = newTurnId();
2114
2126
  a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
2115
2127
  a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
2116
2128
  a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
2117
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a) });
2129
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a) });
2118
2130
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
2119
2131
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
2120
2132
  const q = { turnId, bubble, isSlash };
@@ -2160,7 +2172,7 @@ export const startMirror = (deps) => {
2160
2172
  const fromCli = a.pendingFromCli === true;
2161
2173
  a.pendingFromCli = false;
2162
2174
  a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
2163
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a) });
2175
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a) });
2164
2176
  a.briefTurnId = turnId;
2165
2177
  a.briefBubble = undefined;
2166
2178
  a.briefIsSlash = false;
@@ -2311,6 +2323,7 @@ export const startMirror = (deps) => {
2311
2323
  id: turnId,
2312
2324
  target: a.target,
2313
2325
  sessionId: a.sessionId,
2326
+ cli: cliOf(a),
2314
2327
  cwd: a.runningCwd || undefined,
2315
2328
  userQuery: task,
2316
2329
  agent: { id: agentId, type: resolved?.type, description: resolved?.description, parentTurnId: a.briefTurnId },
@@ -2665,7 +2678,7 @@ export const startMirror = (deps) => {
2665
2678
  if (!a.keepaliveTurnId) {
2666
2679
  const turnId = newTurnId();
2667
2680
  a.keepaliveTurnId = turnId;
2668
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
2681
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
2669
2682
  }
2670
2683
  };
2671
2684
  // A keepalive ping turn is swallowed wholesale — the reply content doesn't
@@ -2831,7 +2844,7 @@ export const startMirror = (deps) => {
2831
2844
  return { ok: false, reason: "no_live_pane" };
2832
2845
  if (!(await tmuxPaneAlive(a.tmuxPane)))
2833
2846
  return { ok: false, reason: "pane_dead" };
2834
- const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, key]);
2847
+ const r = await runTmux(["send-keys", "-t", a.tmuxPane, key]);
2835
2848
  return r.code === 0 ? { ok: true } : { ok: false, reason: `send-keys ${key} failed: ${r.stdout.slice(-200) || r.code}` };
2836
2849
  },
2837
2850
  });
@@ -2882,13 +2895,13 @@ export const startMirror = (deps) => {
2882
2895
  }
2883
2896
  else {
2884
2897
  for (const key of act.keys) {
2885
- const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, key]);
2898
+ const r = await runTmux(["send-keys", "-t", a.tmuxPane, key]);
2886
2899
  if (r.code !== 0)
2887
2900
  return { ok: false, reason: `send-keys ${key}: ${r.stdout.slice(-200) || r.code}` };
2888
- await sleepMs(120); // 键间距 — 等 TUI 重渲染, 防吞键
2901
+ await sleep(120); // 键间距 — 等 TUI 重渲染, 防吞键
2889
2902
  }
2890
2903
  }
2891
- await sleepMs(300); // 题间/阶段间隔 — 等面板翻页
2904
+ await sleep(300); // 题间/阶段间隔 — 等面板翻页
2892
2905
  }
2893
2906
  return { ok: true };
2894
2907
  },
@@ -3057,7 +3070,7 @@ export const startMirror = (deps) => {
3057
3070
  const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
3058
3071
  ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
3059
3072
  : "";
3060
- const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, pendingCwd }) => {
3073
+ const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd }) => {
3061
3074
  const target = resolveTarget(targetOverride);
3062
3075
  if (!target)
3063
3076
  return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
@@ -3083,6 +3096,10 @@ export const startMirror = (deps) => {
3083
3096
  // otherwise. `undefined` (omitted) → carry from prev (re-attach case);
3084
3097
  // `""` → explicit clear (newSession just consumed it); `"/foo"` → set.
3085
3098
  const carryPending = pendingCwd !== undefined ? pendingCwd : (prevByTarget?.pendingCwd ?? "");
3099
+ // Same carry rule as pendingCwd, and for the same reason: a spawn knows the
3100
+ // model it asked for (possibly "" = CLI default) and says so; a re-attach
3101
+ // onto an already-running pane doesn't, so it keeps what was recorded.
3102
+ const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
3086
3103
  if (prevByTarget)
3087
3104
  detach(prevByTarget, "target reassigned");
3088
3105
  // Build the attachment first so the tail's onItem closure can capture it.
@@ -3097,6 +3114,7 @@ export const startMirror = (deps) => {
3097
3114
  // collapsing to cfg.wrc.cwd (which would mislabel /pwd, /clear, /new).
3098
3115
  runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
3099
3116
  pendingCwd: carryPending,
3117
+ model: carryModel,
3100
3118
  tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
3101
3119
  standalonePending: Promise.resolve(),
3102
3120
  recentToolSigs: new Map(),
@@ -3133,6 +3151,7 @@ export const startMirror = (deps) => {
3133
3151
  tmuxSession: a.tmuxSession || undefined,
3134
3152
  tmuxPane: a.tmuxPane || undefined,
3135
3153
  cwd: a.runningCwd || undefined,
3154
+ model: a.model || undefined,
3136
3155
  pendingCwd: a.pendingCwd || undefined,
3137
3156
  keepaliveOff: prevRec?.keepaliveOff,
3138
3157
  keepaliveOffAt: prevRec?.keepaliveOffAt,
@@ -3151,7 +3170,7 @@ export const startMirror = (deps) => {
3151
3170
  const tmuxPaneAlive = async (paneId) => {
3152
3171
  if (!paneId)
3153
3172
  return false;
3154
- const r = await tmuxRun(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
3173
+ const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
3155
3174
  return r.code === 0 && r.stdout.trim() === paneId;
3156
3175
  };
3157
3176
  // Re-attach a stored binding for `principal`. Returns the resulting state, or
@@ -3244,7 +3263,7 @@ export const startMirror = (deps) => {
3244
3263
  // is silently detached; that detached chat then never mirrors again. Runs at
3245
3264
  // boot; the 3s drift follower maintains it thereafter.
3246
3265
  if (livePane) {
3247
- 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}"]);
3248
3267
  const paneCwd = cwdRes.stdout.trim();
3249
3268
  if (paneCwd) {
3250
3269
  // Encode under the backend that owns the bound transcript — comparing
@@ -3271,6 +3290,7 @@ export const startMirror = (deps) => {
3271
3290
  tmuxPane: livePane,
3272
3291
  tmuxSession: rec.tmuxSession ?? "",
3273
3292
  cwd: rec.cwd,
3293
+ model: rec.model,
3274
3294
  pendingCwd: rec.pendingCwd,
3275
3295
  });
3276
3296
  if (!r.ok) {
@@ -3336,10 +3356,10 @@ export const startMirror = (deps) => {
3336
3356
  }
3337
3357
  })();
3338
3358
  const result = t.result ?? "(no result captured)";
3339
- 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\`\`\``);
3340
3360
  }
3341
3361
  const merged = parts.join("\n\n---\n\n");
3342
- 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));
3343
3363
  };
3344
3364
  const resolveToolDetail = (turnId) => {
3345
3365
  evictTurns();
@@ -3357,6 +3377,15 @@ export const startMirror = (deps) => {
3357
3377
  const t = text.trim();
3358
3378
  return t === "/clear" || t.startsWith("/clear ") || t.startsWith("/clear\n");
3359
3379
  };
3380
+ /** mtime(ms), 0 when unreadable — "newest transcript wins" 的排序键。 */
3381
+ const mtimeOf = (path) => {
3382
+ try {
3383
+ return statSync(path).mtimeMs;
3384
+ }
3385
+ catch {
3386
+ return 0;
3387
+ }
3388
+ };
3360
3389
  const listJsonls = (dir) => {
3361
3390
  try {
3362
3391
  return new Set(readdirSync(dir).filter((n) => n.endsWith(".jsonl")));
@@ -3675,7 +3704,7 @@ export const startMirror = (deps) => {
3675
3704
  // Guard 1+2: pane must be alive AND still in the bound project dir. One
3676
3705
  // display-message gives both — it fails on a dead pane, and its cwd tells
3677
3706
  // us whether the pane still belongs here.
3678
- 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}"]);
3679
3708
  if (stopped)
3680
3709
  return;
3681
3710
  const paneCwd = r.code === 0 ? r.stdout.trim() : "";
@@ -3753,7 +3782,7 @@ export const startMirror = (deps) => {
3753
3782
  const attachments = Array.from(byTarget.values()).filter((a) => a.tmuxPane);
3754
3783
  if (attachments.length === 0)
3755
3784
  return;
3756
- 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}"]);
3757
3786
  if (r.code !== 0)
3758
3787
  return;
3759
3788
  const paneCwd = new Map();
@@ -3812,7 +3841,7 @@ export const startMirror = (deps) => {
3812
3841
  // Send via plain sendMessage so the user still gets the info.
3813
3842
  const chatId = stripPrincipalPrefix(target);
3814
3843
  void client
3815
- .sendMessage(chatId, { msgtype: "markdown", markdown: { content: withSessionTag(target, md) } })
3844
+ .sendMessage(chatId, { msgtype: "markdown", markdown: { content: withTagHeader(target, md) } })
3816
3845
  .catch((e) => log.warn({ err: e.message, target }, "pushProjectInfo (no attach) failed"));
3817
3846
  };
3818
3847
  // Cwd is CHAT-SCOPED, not session-scoped: all sessions in the same chat
@@ -3821,7 +3850,7 @@ export const startMirror = (deps) => {
3821
3850
  // own `runningCwd` (the tmux pane's actual working dir at spawn time), but
3822
3851
  // cwd fallbacks and `set_workspace` pendingCwd writes always resolve against the base.
3823
3852
  const chatCwdFallback = (target) => {
3824
- const base = basePrincipalOf(target);
3853
+ const base = baseOfKey(target);
3825
3854
  const baseA = byTarget.get(base);
3826
3855
  const baseRec = deps.store.get(base);
3827
3856
  return {
@@ -3832,6 +3861,20 @@ export const startMirror = (deps) => {
3832
3861
  // /new path: kill the old pane (so we don't leak orphan tmux windows) and
3833
3862
  // spawn a fresh claude in pendingCwd ?? runningCwd ?? default. Returns the
3834
3863
  // new sessionId/cwd so callers can render the user-facing reply.
3864
+ // 身份提供者。newSession / 重生 / clone 全都从这里取「这个 target 是谁」, 于是
3865
+ // 无论是群里手打的 /new、pane 死了的自愈重生, 还是编排生出来的分身, 新进程一
3866
+ // 睁眼就知道自己是谁 —— 而不是只有走 wizard 路由那一条路才有身份。
3867
+ // 由 index.ts 在启动时装上 (注册表活在那边); 没装 = 退回无身份行为, 全链路无回归。
3868
+ let charterOf;
3869
+ const setCharterProvider = (fn) => { charterOf = fn; };
3870
+ const charterFor = (target) => {
3871
+ try {
3872
+ return charterOf?.(target);
3873
+ }
3874
+ catch {
3875
+ return undefined;
3876
+ }
3877
+ };
3835
3878
  const newSession = async (target, windowName, cli, opts) => {
3836
3879
  const prev = byTarget.get(target);
3837
3880
  // Resolution precedence (all chat-scoped except the running-cwd fallback):
@@ -3859,13 +3902,13 @@ export const startMirror = (deps) => {
3859
3902
  // is chat-scoped like cwd — a FIRST `/new #tag` has no record of its own,
3860
3903
  // so it falls back to the base session's binding instead of `defaultCli`
3861
3904
  // (otherwise a tagged sibling silently forks onto a different CLI).
3862
- const base = basePrincipalOf(target);
3905
+ const base = baseOfKey(target);
3863
3906
  const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
3864
3907
  const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
3865
3908
  const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
3866
3909
  if (prev?.tmuxPane) {
3867
3910
  // Best-effort kill; ignore errors (pane may already be dead).
3868
- void tmuxRun(["kill-pane", "-t", prev.tmuxPane]);
3911
+ void runTmux(["kill-pane", "-t", prev.tmuxPane]);
3869
3912
  }
3870
3913
  if (prev)
3871
3914
  detach(prev, "/new respawn");
@@ -3876,6 +3919,7 @@ export const startMirror = (deps) => {
3876
3919
  cwdOverride: eff,
3877
3920
  cli: effCli,
3878
3921
  model: opts?.model,
3922
+ systemPrompt: opts?.systemPrompt ?? charterFor(target),
3879
3923
  });
3880
3924
  if (!r.ok)
3881
3925
  return { ok: false, reason: r.reason };
@@ -3888,6 +3932,9 @@ export const startMirror = (deps) => {
3888
3932
  cwd: r.cwd,
3889
3933
  // Explicit "" clears any carried-over pending — it has just been applied.
3890
3934
  pendingCwd: "",
3935
+ // Likewise explicit: `/new` starts a NEW session, so an unnamed model
3936
+ // means this CLI's default, not the dead pane's model.
3937
+ model: opts?.model?.trim() ?? "",
3891
3938
  });
3892
3939
  if (!att.ok)
3893
3940
  return { ok: false, reason: att.reason };
@@ -3935,6 +3982,133 @@ export const startMirror = (deps) => {
3935
3982
  pushProjectInfo(target, "created");
3936
3983
  return { ok: true, sessionId: r.sessionId, cwd: r.cwd };
3937
3984
  };
3985
+ // ── Clone ───────────────────────────────────────────────────────────
3986
+ // 分身 = `--resume <父 sid> --fork-session` 起的新 pane: CLI 把父亲的 transcript
3987
+ // 复制成一份新的再继续写, 于是子进程开局就带着父亲此刻读过的一切 (规范、目录、
3988
+ // 刚啃完的那份文档), 而父亲毫发无损。这正是「先把公共材料载进一个基座, 再从它
3989
+ // 身上分出 N 个干活的」所需要的语义: 材料只读一遍, 却进了 N 份上下文。
3990
+ //
3991
+ // 两件事非做不可:
3992
+ // `--fork-session` —— 少了它, 这个 pane 会**续写父亲的 jsonl**。两个 pane 写
3993
+ // 同一个 transcript, 两个 target 指向同一个 sid, 群与群从此串线且无法回滚。
3994
+ // 先说话再 attach —— 分叉文件不是开机就有的, CLI 在收到第一条消息那一刻才写。
3995
+ // 所以 spawn 之后先注入开场白, 等分叉出现拿到它自己的 sid, 再 attach。等不到
3996
+ // 就把 pane 一起收掉: 那时唯一已知的 sid 是父亲的, 认下去就是上面那种串线
3997
+ // (attach 也会正当地拒绝), 一个干净的失败远比一条错绑好收拾。
3998
+ const FORK_POLL_MS = 400;
3999
+ const FORK_WAIT_MS = 45_000;
4000
+ const awaitFork = async (projectDir, baseline, claimed) => {
4001
+ const deadline = Date.now() + FORK_WAIT_MS;
4002
+ while (Date.now() < deadline) {
4003
+ const born = [...listJsonls(projectDir)]
4004
+ .filter((n) => !baseline.has(n) && !claimed.has(n.replace(/\.jsonl$/, "")))
4005
+ .map((n) => ({ n, path: join(projectDir, n) }))
4006
+ // 种子化的 fork 一落地就带着父亲的 user 行 —— 空文件 (刚 touch 出来的)
4007
+ // 还不能认, 认早了 tail 会从一个还没写完的复制过程中间开始读。
4008
+ .filter((c) => firstUserUuid(c.path) !== undefined)
4009
+ .sort((x, y) => mtimeOf(y.path) - mtimeOf(x.path));
4010
+ const hit = born[0];
4011
+ if (hit)
4012
+ return { sessionId: hit.n.replace(/\.jsonl$/, ""), jsonlPath: hit.path };
4013
+ await sleep(FORK_POLL_MS);
4014
+ }
4015
+ return undefined;
4016
+ };
4017
+ const cloneSession = async (args) => {
4018
+ const p = byTarget.get(args.parent) ?? (await restoreFromStore(args.parent));
4019
+ // 继承上下文要求父亲有一个活着的 transcript, 且分身必须待在同一个项目目录 ——
4020
+ // `--resume` 是按项目目录寻址 session 的。调用方点名换目录 = 明确放弃继承。
4021
+ const sameCwd = !args.cwd?.trim() || expandHome(args.cwd.trim()) === (p?.runningCwd ?? "");
4022
+ const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
4023
+ if (!canInherit) {
4024
+ const r = await newSession(args.target, args.windowName, args.cli, {
4025
+ model: args.model, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true,
4026
+ });
4027
+ return { ...r, inherited: false };
4028
+ }
4029
+ const parent = p;
4030
+ const projectDir = dirname(parent.jsonlPath);
4031
+ const baseline = listJsonls(projectDir);
4032
+ const lg = log.child({ sub: "clone", target: args.target });
4033
+ const r = await spawnTmuxClaude({
4034
+ cfg,
4035
+ log: lg,
4036
+ resumeSessionId: parent.sessionId,
4037
+ forkSession: true,
4038
+ windowName: args.windowName ?? args.target,
4039
+ cwdOverride: parent.runningCwd,
4040
+ cli: backendForPath(parent.jsonlPath).name,
4041
+ model: args.model,
4042
+ systemPrompt: args.systemPrompt,
4043
+ });
4044
+ if (!r.ok)
4045
+ return { ok: false, reason: r.reason, inherited: false };
4046
+ // 分叉文件不是开机就有的: CLI 在这个 pane **收到第一条消息**的那一刻才把父亲的
4047
+ // transcript 复制成新文件。所以必须先说一句话, 分身才会有自己的 sid —— 这句话
4048
+ // 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」), 群里看到的第一条也是它。
4049
+ const hello = args.bootstrap?.trim()
4050
+ || "(wezard 分身初始化)用一句话说明: 你是谁、从谁那里继承了哪些上下文、准备接什么活。不要复述这条指令。";
4051
+ rememberInject(hello);
4052
+ const boot = await inject({
4053
+ text: hello, images: [], cfg, log: lg,
4054
+ sessionId: parent.sessionId, jsonlPath: parent.jsonlPath,
4055
+ tmuxTarget: r.tmuxPane, freshSpawn: true,
4056
+ });
4057
+ if (!boot.ok) {
4058
+ await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
4059
+ return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
4060
+ }
4061
+ const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target));
4062
+ // 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
4063
+ // 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
4064
+ // 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
4065
+ if (!fork) {
4066
+ await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
4067
+ return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
4068
+ }
4069
+ const att = attach({
4070
+ sessionId: fork.sessionId,
4071
+ jsonlPath: fork.jsonlPath,
4072
+ target: args.target,
4073
+ tmuxPane: r.tmuxPane,
4074
+ tmuxSession: r.tmuxSession,
4075
+ cwd: r.cwd,
4076
+ pendingCwd: "",
4077
+ model: args.model?.trim() ?? "",
4078
+ });
4079
+ if (!att.ok)
4080
+ return { ok: false, reason: att.reason, inherited: false };
4081
+ const spawned = byTarget.get(args.target);
4082
+ if (spawned) {
4083
+ // muteUntilInject 是给"全新 pane 的 greeting 噪音"准备的; 分身的开场白是正经
4084
+ // 内容 (群里第一条就该是它), 所以这里不静音。
4085
+ spawned.justSpawned = true;
4086
+ spawned.keepaliveOff = true;
4087
+ spawned.keepaliveOffAt = Date.now();
4088
+ spawned.keepalive = undefined;
4089
+ persistPause(spawned);
4090
+ }
4091
+ lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
4092
+ return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true };
4093
+ };
4094
+ /** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
4095
+ const sessionInfo = (target) => {
4096
+ const a = byTarget.get(target);
4097
+ const rec = a ? undefined : deps.store.get(target);
4098
+ const sessionId = a?.sessionId ?? rec?.sessionId ?? "";
4099
+ const jsonlPath = a?.jsonlPath ?? (rec?.jsonlPath ? expandHome(rec.jsonlPath) : "");
4100
+ if (!sessionId || !jsonlPath)
4101
+ return undefined;
4102
+ return {
4103
+ sessionId,
4104
+ jsonlPath,
4105
+ cwd: a?.runningCwd || rec?.cwd || "",
4106
+ cli: backendForPath(jsonlPath).name,
4107
+ model: a?.model || rec?.model || "",
4108
+ tmuxPane: a?.tmuxPane ?? rec?.tmuxPane ?? "",
4109
+ contextTokens: lastContextTokens(jsonlPath),
4110
+ };
4111
+ };
3938
4112
  const getCwd = (target) => {
3939
4113
  // pendingCwd is chat-scoped — a `set_workspace` from any sibling session
3940
4114
  // queues the switch for the whole chat. runningCwd stays per-session (each
@@ -3960,7 +4134,7 @@ export const startMirror = (deps) => {
3960
4134
  const expanded = expandHome(trimmed);
3961
4135
  if (!expanded.startsWith("/"))
3962
4136
  return { ok: false, reason: "cwd must be absolute (or start with ~)", runningCwd: "", pendingCwd: "" };
3963
- const base = basePrincipalOf(target);
4137
+ const base = baseOfKey(target);
3964
4138
  const callerA = byTarget.get(target);
3965
4139
  const callerRunning = callerA?.runningCwd?.trim() || deps.store.get(target)?.cwd?.trim() || expandedDefaultCwd;
3966
4140
  const baseA = byTarget.get(base);
@@ -4018,8 +4192,8 @@ export const startMirror = (deps) => {
4018
4192
  return Array.from(keys).sort();
4019
4193
  };
4020
4194
  const chatTargets = (target) => {
4021
- const base = basePrincipalOf(target);
4022
- return allTargets().filter((k) => basePrincipalOf(k) === base);
4195
+ const base = baseOfKey(target);
4196
+ return allTargets().filter((k) => baseOfKey(k) === base);
4023
4197
  };
4024
4198
  // 名字解析:`daily` / `chat:wrxxx` → base principal。认不出就把已知名字一并回给
4025
4199
  // 调用方 —— 跨 chat 出错时"我该写什么"比"你写错了"有用得多。
@@ -4052,21 +4226,23 @@ export const startMirror = (deps) => {
4052
4226
  return c;
4053
4227
  const target = keyOf(c.base, t);
4054
4228
  if (allTargets().includes(target)) {
4055
- return { ok: true, target, foreign: c.base !== basePrincipalOf(self) };
4229
+ return { ok: true, target, foreign: c.base !== baseOfKey(self) };
4056
4230
  }
4057
4231
  return {
4058
4232
  ok: false,
4059
4233
  reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
4060
- candidates: allTargets().filter((k) => basePrincipalOf(k) === c.base),
4234
+ // 候选给**地址**而不是裸 key —— 这份清单存在的意义就是让调用方照着改一个
4235
+ // 能用的串; 隔壁两个分支早就这么做了, 只有这一条漏掉。
4236
+ candidates: allTargets().filter((k) => baseOfKey(k) === c.base).map((k) => peerAddress(cfg, self, k)),
4061
4237
  };
4062
4238
  }
4063
- const local = keyOf(basePrincipalOf(self), t);
4239
+ const local = keyOf(baseOfKey(self), t);
4064
4240
  if (!t)
4065
4241
  return { ok: true, target: local, foreign: false };
4066
4242
  const all = allTargets();
4067
4243
  if (all.includes(local))
4068
4244
  return { ok: true, target: local, foreign: false };
4069
- 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));
4070
4246
  if (foreignMatches.length === 1)
4071
4247
  return { ok: true, target: foreignMatches[0], foreign: true };
4072
4248
  if (foreignMatches.length === 0) {
@@ -4077,7 +4253,7 @@ export const startMirror = (deps) => {
4077
4253
  return {
4078
4254
  ok: false,
4079
4255
  reason: `'${t}' is a CHAT, not a tag — address one of its sessions, e.g. '${t}#' for its default`,
4080
- 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)),
4081
4257
  };
4082
4258
  }
4083
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)` };
@@ -4093,14 +4269,14 @@ export const startMirror = (deps) => {
4093
4269
  const chatRoster = (self) => {
4094
4270
  const bases = new Set([
4095
4271
  ...listChatNames(cfg).map((c) => c.base),
4096
- ...allTargets().map(basePrincipalOf),
4097
- basePrincipalOf(self),
4272
+ ...allTargets().map(baseOfKey),
4273
+ baseOfKey(self),
4098
4274
  ]);
4099
4275
  return [...bases].filter(Boolean).sort().map((base) => ({
4100
4276
  base,
4101
4277
  name: chatNameOf(cfg, base),
4102
- self: base === basePrincipalOf(self),
4103
- targets: allTargets().filter((k) => basePrincipalOf(k) === base),
4278
+ self: base === baseOfKey(self),
4279
+ targets: allTargets().filter((k) => baseOfKey(k) === base),
4104
4280
  }));
4105
4281
  };
4106
4282
  const paneOf = (target) => byTarget.get(target)?.tmuxPane || deps.store.get(target)?.tmuxPane || "";
@@ -4150,7 +4326,7 @@ export const startMirror = (deps) => {
4150
4326
  const jsonlPath = jsonlOf(t);
4151
4327
  const pane = paneOf(t);
4152
4328
  const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
4153
- const tag = tagOfTarget(t);
4329
+ const tag = tagOfKey(t);
4154
4330
  let lastActivity = 0;
4155
4331
  try {
4156
4332
  if (jsonlPath)
@@ -4167,6 +4343,7 @@ export const startMirror = (deps) => {
4167
4343
  jsonlPath,
4168
4344
  cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
4169
4345
  cli: jsonlPath ? backendForPath(jsonlPath).name : (activeBackends()[0]?.name ?? "claude"),
4346
+ model: a?.model || rec?.model || "",
4170
4347
  tmuxPane: pane,
4171
4348
  attached: !!a,
4172
4349
  paneAlive,
@@ -4183,8 +4360,8 @@ export const startMirror = (deps) => {
4183
4360
  // `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
4184
4361
  // 只要给群起个名,不必回去改别人的 tag。
4185
4362
  const foreignPeers = async (self) => {
4186
- const selfBase = basePrincipalOf(self);
4187
- 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);
4188
4365
  const tagCount = foreign.reduce((acc, k) => acc.set(tagOfKey(k), (acc.get(tagOfKey(k)) ?? 0) + 1), new Map());
4189
4366
  const reachable = foreign.filter((k) => tagCount.get(tagOfKey(k)) === 1 || chatNameOf(cfg, k));
4190
4367
  return (await Promise.all(reachable.map((t) => peerInfoOf(t, self)))).sort(byRecent);
@@ -4289,7 +4466,7 @@ export const startMirror = (deps) => {
4289
4466
  if (!a.keepaliveTurnId) {
4290
4467
  const turnId = newTurnId();
4291
4468
  a.keepaliveTurnId = turnId;
4292
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
4469
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
4293
4470
  }
4294
4471
  };
4295
4472
  let keepaliveTicking = false;
@@ -4517,8 +4694,8 @@ export const startMirror = (deps) => {
4517
4694
  return { ok: false, reason: `当前确认框已是另一个调用(${now.toolName}),不代按` };
4518
4695
  }
4519
4696
  if (what === "no") {
4520
- await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
4521
- await sleepMs(MODAL_PRESS_SETTLE_MS);
4697
+ await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
4698
+ await sleep(MODAL_PRESS_SETTLE_MS);
4522
4699
  const after = await detectModalPicker(a.tmuxPane);
4523
4700
  return after.modal ? { ok: false, reason: "Escape 后确认框仍在" } : { ok: true, label: "Escape" };
4524
4701
  }
@@ -4648,7 +4825,7 @@ export const startMirror = (deps) => {
4648
4825
  if (a.recentToolSigs.has(expectSig))
4649
4826
  break;
4650
4827
  polls++;
4651
- await sleepMs(50);
4828
+ await sleep(50);
4652
4829
  }
4653
4830
  if (!a.recentToolSigs.has(expectSig)) {
4654
4831
  log.warn({ sessionId, polls, toolName: expect?.toolName }, "flushBeforeCard wait timed out — sending card without sig confirm");
@@ -4702,7 +4879,7 @@ export const startMirror = (deps) => {
4702
4879
  // 队列内单条失败已在 sendStandalone 里 warn 过, 这里吞掉, 不阻塞发卡。
4703
4880
  }
4704
4881
  },
4705
- injectText: async (target, text, origin) => {
4882
+ injectText: async (target, text, origin, opts) => {
4706
4883
  // Lazy restore, same as dispatch: a peer session that hasn't been talked
4707
4884
  // to in this process lifetime (or any session after a reload) has an empty
4708
4885
  // in-memory slot but a perfectly good persisted binding. Without this, an
@@ -4712,10 +4889,17 @@ export const startMirror = (deps) => {
4712
4889
  return { ok: false, reason: "no mirror attached for target" };
4713
4890
  if (!text.trim())
4714
4891
  return { ok: false, reason: "empty text" };
4892
+ // 出处改写要早于 inject: tail 可能在 promise resolve 之前就开出 turn。
4893
+ if (opts?.fromChat)
4894
+ a.turnFromChat = true;
4895
+ // 名册增量搭这趟车进去 —— 同伴派活 / 定时任务 / graph 步骤都走这里, 所以
4896
+ // 一个从不被人直接说话的分身也能知道群里多了谁、少了谁。人说的话那一条
4897
+ // 路径在 inbound.send 上挂 (dispatch 有自己的 inject)。
4898
+ const full = text + noticeSuffixFor(target, text);
4715
4899
  // Pre-record so the tail's user-line emission is suppressed by the
4716
4900
  // recentInjects dedupe (otherwise the user sees their own demo prompt
4717
4901
  // echoed back as a quoted bubble).
4718
- rememberInject(text);
4902
+ rememberInject(full);
4719
4903
  // 印章要早于 inject 落地 —— tail 是独立轮询的, 它可能在 inject 的 promise
4720
4904
  // resolve 之前就看到 user 行并开出 turn, 那时归因必须已经在位。
4721
4905
  if (origin)
@@ -4727,14 +4911,14 @@ export const startMirror = (deps) => {
4727
4911
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
4728
4912
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
4729
4913
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
4730
- 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 });
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) });
4731
4915
  if (!r.ok || !r.tmuxPane)
4732
4916
  return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
4733
4917
  a.tmuxPane = r.tmuxPane;
4734
4918
  a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
4735
4919
  if (r.cwd)
4736
4920
  a.runningCwd = r.cwd;
4737
- deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, pendingCwd: a.pendingCwd || undefined });
4921
+ deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, model: a.model || undefined, pendingCwd: a.pendingCwd || undefined });
4738
4922
  startMigrationWatcher(a, resumeBaseline, (p) => firstUserUuid(p) !== undefined);
4739
4923
  }
4740
4924
  // freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
@@ -4743,7 +4927,7 @@ export const startMirror = (deps) => {
4743
4927
  a.muteUntilInject = false;
4744
4928
  a.justSpawned = false;
4745
4929
  const r = await inject({
4746
- text, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
4930
+ text: full, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
4747
4931
  sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: true,
4748
4932
  });
4749
4933
  return r;
@@ -4788,7 +4972,7 @@ export const startMirror = (deps) => {
4788
4972
  escReason = "tmux pane no longer alive";
4789
4973
  }
4790
4974
  else {
4791
- const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
4975
+ const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
4792
4976
  escOk = r.ok;
4793
4977
  if (!r.ok)
4794
4978
  escReason = `send-keys Escape failed: ${(r.stderr || r.stdout).slice(-200) || r.code}`;
@@ -4813,9 +4997,9 @@ export const startMirror = (deps) => {
4813
4997
  if (pane && (await tmuxPaneAlive(pane))) {
4814
4998
  // Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
4815
4999
  // flush its transcript before the TTY is yanked out from under it.
4816
- await tmuxRun(["send-keys", "-t", pane, "Escape"]);
4817
- await sleepMs(250);
4818
- 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]);
4819
5003
  if (r.code !== 0)
4820
5004
  return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
4821
5005
  }
@@ -4838,7 +5022,7 @@ export const startMirror = (deps) => {
4838
5022
  const alive = await tmuxPaneAlive(a.tmuxPane);
4839
5023
  if (!alive)
4840
5024
  return { ok: false, reason: "tmux pane no longer alive" };
4841
- const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Enter"]);
5025
+ const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Enter"]);
4842
5026
  if (r.code !== 0)
4843
5027
  return { ok: false, reason: `send-keys Enter failed: ${r.stdout.slice(-200) || r.code}` };
4844
5028
  log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /n — Enter sent to pane");
@@ -4856,9 +5040,9 @@ export const startMirror = (deps) => {
4856
5040
  // switching a client that sits on an unrelated session would yank the
4857
5041
  // user's other terminal window into wezard. Most-recently-active wins —
4858
5042
  // switch-client with no -c is ambiguous under multiple clients.
4859
- const s = await tmuxRun(["display-message", "-p", "-t", pane, "#{session_name}"]);
5043
+ const s = await runTmux(["display-message", "-p", "-t", pane, "#{session_name}"]);
4860
5044
  const sess = s.stdout.trim() || cfg.wrc.tmuxPrefix;
4861
- 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}"]);
4862
5046
  const best = clients.stdout.split("\n").filter(Boolean)
4863
5047
  .map((l) => { const i = l.indexOf(" "); return { act: Number(l.slice(0, i)), name: l.slice(i + 1) }; })
4864
5048
  .sort((x, y) => y.act - x.act)[0]?.name;
@@ -4867,7 +5051,7 @@ export const startMirror = (deps) => {
4867
5051
  return { ok: false, reason: `没有 attach 到 \`${sess}\` 的 tmux 客户端。先在终端执行: \`tmux attach -t ${sess}\`` };
4868
5052
  }
4869
5053
  // A pane target pulls session + window selection along with it.
4870
- const r = await tmuxRun(["switch-client", "-c", best, "-t", pane]);
5054
+ const r = await runTmux(["switch-client", "-c", best, "-t", pane]);
4871
5055
  if (r.code !== 0)
4872
5056
  return { ok: false, reason: `switch-client failed: ${r.stdout.slice(-200) || r.code}` };
4873
5057
  log.info({ target, pane, client: best }, "mirror /reveal — tmux client switched");
@@ -4887,7 +5071,7 @@ export const startMirror = (deps) => {
4887
5071
  const deadline = Date.now() + Math.max(0, opts.waitMs);
4888
5072
  let v = await detectModalPicker(pane);
4889
5073
  while (!v.modal && Date.now() < deadline) {
4890
- await sleepMs(POLL_MS);
5074
+ await sleep(POLL_MS);
4891
5075
  v = await detectModalPicker(pane);
4892
5076
  }
4893
5077
  // 没等到: 这次 CC 没弹框(会话内已授权过 / 命中盲点判断失误)。无事可做。
@@ -4911,6 +5095,9 @@ export const startMirror = (deps) => {
4911
5095
  getCwd,
4912
5096
  setPendingCwd,
4913
5097
  newSession,
5098
+ cloneSession,
5099
+ sessionInfo,
5100
+ setCharterProvider,
4914
5101
  shutdown: () => {
4915
5102
  clearInterval(paneDriftTimer);
4916
5103
  clearInterval(keepaliveTimer);
@@ -4937,7 +5124,7 @@ export const startMirror = (deps) => {
4937
5124
  a = await restoreFromStore(principal);
4938
5125
  if (!a) {
4939
5126
  try {
4940
- 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);
4941
5128
  }
4942
5129
  catch {
4943
5130
  /* ignore */
@@ -4985,11 +5172,11 @@ export const startMirror = (deps) => {
4985
5172
  log.info({ target: a.target, runningCwd: a.runningCwd, pendingCwd: pending }, "/clear upgraded to /new (cwd switch)");
4986
5173
  // Prefer the tag suffix as tmux window name when present (matches
4987
5174
  // /new #tag behavior), fall back to the full target for untagged.
4988
- const tag = tagOfTarget(a.target);
5175
+ const tag = tagOfKey(a.target);
4989
5176
  const r = await newSession(a.target, tag || a.target);
4990
5177
  if (!r.ok) {
4991
5178
  try {
4992
- 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);
4993
5180
  }
4994
5181
  catch { /* ignore */ }
4995
5182
  }
@@ -5047,7 +5234,7 @@ export const startMirror = (deps) => {
5047
5234
  if (s) {
5048
5235
  a.liveStream = s;
5049
5236
  try {
5050
- await client.replyStream(frame, streamId, withSessionTag(a.target, "…"), false);
5237
+ await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
5051
5238
  }
5052
5239
  catch (e) {
5053
5240
  log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
@@ -5092,7 +5279,7 @@ export const startMirror = (deps) => {
5092
5279
  const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
5093
5280
  // Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
5094
5281
  // mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
5095
- 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 });
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) });
5096
5283
  if (r.ok && r.tmuxPane && r.tmuxSession) {
5097
5284
  a.tmuxPane = r.tmuxPane;
5098
5285
  a.tmuxSession = r.tmuxSession;
@@ -5105,6 +5292,7 @@ export const startMirror = (deps) => {
5105
5292
  tmuxSession: a.tmuxSession,
5106
5293
  tmuxPane: a.tmuxPane,
5107
5294
  cwd: a.runningCwd || undefined,
5295
+ model: a.model || undefined,
5108
5296
  pendingCwd: a.pendingCwd || undefined,
5109
5297
  });
5110
5298
  log.info({ target: a.target, sessionId: sid, newPane: a.tmuxPane, newSession: a.tmuxSession }, "mirror: tmux respawned");
@@ -5161,7 +5349,7 @@ export const startMirror = (deps) => {
5161
5349
  // /clear path has no live stream — surface failure as a one-shot
5162
5350
  // terse reply ("clean" per project convention).
5163
5351
  try {
5164
- await client.replyStream(frame, streamId, withSessionTag(a.target, "clean"), true);
5352
+ await client.replyStream(frame, streamId, withTagHeader(a.target, "clean"), true);
5165
5353
  }
5166
5354
  catch { /* ignore */ }
5167
5355
  }
@@ -5204,8 +5392,8 @@ export const startMirror = (deps) => {
5204
5392
  // "Set model to …" as <local-command-stdout> — the tail mirrors that
5205
5393
  // back to the chat as a ⚙️ bubble, which IS the readback.
5206
5394
  if (isModelSwitch && a.tmuxPane) {
5207
- await sleepMs(1000);
5208
- 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"]);
5209
5397
  if (e.code !== 0)
5210
5398
  log.warn({ pane: a.tmuxPane, reason: e.stdout.slice(-200) || e.code }, "/model confirm Enter failed — user can send /n manually");
5211
5399
  }
@@ -5250,7 +5438,7 @@ export const installMirrorEventListener = (client, bridge, log) => {
5250
5438
  const n = detail.markdown.length;
5251
5439
  for (const [i, md] of detail.markdown.entries()) {
5252
5440
  try {
5253
- 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);
5254
5442
  await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
5255
5443
  }
5256
5444
  catch (e) {