wezard 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/CLAUDE.md +1 -1
  2. package/README.md +113 -57
  3. package/cli/wezard.sh +2 -0
  4. package/config.example.jsonc +7 -4
  5. package/dist/cli/wx.js +78 -0
  6. package/dist/cli/wx.js.map +1 -0
  7. package/dist/daemon/aibot-media.js +62 -0
  8. package/dist/daemon/aibot-media.js.map +1 -0
  9. package/dist/daemon/approval.js +30 -20
  10. package/dist/daemon/approval.js.map +1 -1
  11. package/dist/daemon/ask.js +3 -2
  12. package/dist/daemon/ask.js.map +1 -1
  13. package/dist/daemon/cc-session.js +27 -4
  14. package/dist/daemon/cc-session.js.map +1 -1
  15. package/dist/daemon/chat-log.js +2 -2
  16. package/dist/daemon/chat-log.js.map +1 -1
  17. package/dist/daemon/chat-name.js +6 -2
  18. package/dist/daemon/chat-name.js.map +1 -1
  19. package/dist/daemon/config-api.js +25 -10
  20. package/dist/daemon/config-api.js.map +1 -1
  21. package/dist/daemon/direction.js +53 -0
  22. package/dist/daemon/direction.js.map +1 -0
  23. package/dist/daemon/ilink.js +162 -0
  24. package/dist/daemon/ilink.js.map +1 -0
  25. package/dist/daemon/inbound.js +64 -18
  26. package/dist/daemon/inbound.js.map +1 -1
  27. package/dist/daemon/index.js +415 -109
  28. package/dist/daemon/index.js.map +1 -1
  29. package/dist/daemon/jobs.js +4 -3
  30. package/dist/daemon/jobs.js.map +1 -1
  31. package/dist/daemon/last-response.js +6 -3
  32. package/dist/daemon/last-response.js.map +1 -1
  33. package/dist/daemon/media.js +108 -0
  34. package/dist/daemon/media.js.map +1 -0
  35. package/dist/daemon/mirror-bridge.js +111 -93
  36. package/dist/daemon/mirror-bridge.js.map +1 -1
  37. package/dist/daemon/model-select.js +159 -54
  38. package/dist/daemon/model-select.js.map +1 -1
  39. package/dist/daemon/notices.js +59 -12
  40. package/dist/daemon/notices.js.map +1 -1
  41. package/dist/daemon/outbound.js +2 -1
  42. package/dist/daemon/outbound.js.map +1 -1
  43. package/dist/daemon/peers.js +46 -27
  44. package/dist/daemon/peers.js.map +1 -1
  45. package/dist/daemon/pending-items.js +1 -1
  46. package/dist/daemon/pending-items.js.map +1 -1
  47. package/dist/daemon/reach.js +6 -0
  48. package/dist/daemon/reach.js.map +1 -0
  49. package/dist/daemon/receipts.js +149 -7
  50. package/dist/daemon/receipts.js.map +1 -1
  51. package/dist/daemon/route.js +11 -4
  52. package/dist/daemon/route.js.map +1 -1
  53. package/dist/daemon/spawn-tmux.js +157 -60
  54. package/dist/daemon/spawn-tmux.js.map +1 -1
  55. package/dist/daemon/steward-guard.js +48 -7
  56. package/dist/daemon/steward-guard.js.map +1 -1
  57. package/dist/daemon/top-only-nudge.js +29 -0
  58. package/dist/daemon/top-only-nudge.js.map +1 -0
  59. package/dist/daemon/weixin-cmd.js +268 -0
  60. package/dist/daemon/weixin-cmd.js.map +1 -0
  61. package/dist/daemon/weixin-port.js +226 -0
  62. package/dist/daemon/weixin-port.js.map +1 -0
  63. package/dist/daemon/weixin.js +772 -0
  64. package/dist/daemon/weixin.js.map +1 -0
  65. package/dist/daemon/wizard.js +56 -18
  66. package/dist/daemon/wizard.js.map +1 -1
  67. package/dist/mcp/server.js +43 -23
  68. package/dist/mcp/server.js.map +1 -1
  69. package/dist/shared/chat-http.js +61 -20
  70. package/dist/shared/chat-http.js.map +1 -1
  71. package/dist/shared/chat-render.js +9 -1
  72. package/dist/shared/chat-render.js.map +1 -1
  73. package/dist/shared/chat-view.js +16 -2
  74. package/dist/shared/chat-view.js.map +1 -1
  75. package/dist/shared/cli-backends.js +3 -0
  76. package/dist/shared/cli-backends.js.map +1 -1
  77. package/dist/shared/config.js +45 -10
  78. package/dist/shared/config.js.map +1 -1
  79. package/dist/shared/json-map-store.js +1 -0
  80. package/dist/shared/json-map-store.js.map +1 -1
  81. package/dist/shared/reminder.js +1 -0
  82. package/dist/shared/reminder.js.map +1 -1
  83. package/dist/shared/role-render.js +7 -4
  84. package/dist/shared/role-render.js.map +1 -1
  85. package/dist/shared/role-view.js +116 -37
  86. package/dist/shared/role-view.js.map +1 -1
  87. package/dist/shared/seen-log.js +26 -0
  88. package/dist/shared/seen-log.js.map +1 -0
  89. package/dist/shared/session-label.js +84 -31
  90. package/dist/shared/session-label.js.map +1 -1
  91. package/dist/shared/turn-state.js +1 -1
  92. package/dist/shared/turn-state.js.map +1 -1
  93. package/dist/shared/world.js.map +1 -1
  94. package/dist/shared/wx-text.js +101 -0
  95. package/dist/shared/wx-text.js.map +1 -0
  96. package/dist/svr/index.js +5 -2
  97. package/dist/svr/index.js.map +1 -1
  98. package/hooks/pre-tool-use.sh +9 -6
  99. package/package.json +6 -2
  100. package/web/chat.css +32 -5
  101. package/web/chat.js +160 -92
@@ -19,7 +19,7 @@ import { join, dirname } from "node:path";
19
19
  import { expandHome, sanitizeId } from "../shared/paths.js";
20
20
  import { augmentedPath } from "../shared/exec-path.js";
21
21
  import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
22
- import { activeBackends, backendForPath, primaryBackend, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
22
+ import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
23
23
  import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } from "../shared/modal-pane.js";
24
24
  import { bindCardChannel, hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
25
25
  import { isAutoWindowActive } from "./session-cache.js";
@@ -34,10 +34,11 @@ import { wizardStore } from "./wizard.js";
34
34
  import { startSubagentWatch } from "./subagent-tail.js";
35
35
  import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
36
36
  import { errText } from "./last-response.js";
37
- import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, linkedTagHead, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey } from "../shared/session-label.js";
37
+ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey } from "../shared/session-label.js";
38
38
  import { splitMarkdown } from "../shared/md-chunk.js";
39
+ import { isWxFrame } from "../shared/wx-text.js";
39
40
  import { randomTip } from "./tips.js";
40
- import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
41
+ import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress, stewardTierOf } from "./chat-name.js";
41
42
  import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
42
43
  import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
43
44
  // PATH augmentation: launchd / systemd start the daemon
@@ -1393,10 +1394,10 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1393
1394
  // 所以 codebuddy 走 C-v 必然失败 —— 改走 @<path> 文本提及,让 LLM
1394
1395
  // 调 Read 工具读图(Read 支持图片,见 codebuddy tools-reference.md)。
1395
1396
  // v2.52.4 之后 @<path> 不自动转 image block,但 LLM 看到路径会 Read。
1397
+ // dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
1398
+ // withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
1396
1399
  if (backendName === "codebuddy") {
1397
- const refs = images.map((p) => `@${p}`);
1398
- const textWithRefs = refs.length ? (text ? `${refs.join("\n")}\n${text}` : refs.join("\n")) : text;
1399
- return injectViaTmuxText(target, textWithRefs, log, freshSpawn, witness);
1400
+ return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness);
1400
1401
  }
1401
1402
  // Pump images first via clipboard+C-v so each one is attached as a separate
1402
1403
  // image content block. Each C-v needs a brief settle for Claude Code's TUI
@@ -1424,6 +1425,13 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1424
1425
  }
1425
1426
  return injectViaTmuxText(target, text, log, freshSpawn, witness);
1426
1427
  };
1428
+ // 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
1429
+ // 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
1430
+ // 完全相同的字符串, 否则落盘行认不出是自己注入的。
1431
+ const withImageRefs = (text, images) => {
1432
+ const refs = images.map((p) => `@${p}`).join("\n");
1433
+ return refs ? (text ? `${refs}\n${text}` : refs) : text;
1434
+ };
1427
1435
  // 文本 paste + Enter 提交 + 自校验。从 injectViaTmux 抽出来,让 codebuddy
1428
1436
  // 后端的 @<path> 回退路径也能复用同样的 paste-verify 逻辑。
1429
1437
  //
@@ -1626,8 +1634,7 @@ const injectViaSpawn = (args) => {
1626
1634
  // Spawn-mode (no live TTY) can't do clipboard+C-v — fall back to `@<path>`,
1627
1635
  // which Claude parses at submit time and inlines as image content blocks
1628
1636
  // without a model-decided Read tool turn.
1629
- const refs = images.map((p) => `@${p}`).join("\n");
1630
- const finalText = refs ? (text ? `${refs}\n${text}` : refs) : text;
1637
+ const finalText = withImageRefs(text, images);
1631
1638
  const cliArgs = [
1632
1639
  "-p",
1633
1640
  finalText,
@@ -1838,8 +1845,8 @@ export const startMirror = (deps) => {
1838
1845
  const detailCardFor = (s, target) => {
1839
1846
  if (s.tools.length === 0)
1840
1847
  return undefined;
1841
- const name = displayName(target);
1842
- const titlePrefix = name ? `${labelFor(name)} .${name} · ` : "";
1848
+ const badge = tagBadge(target).trim();
1849
+ const titlePrefix = badge ? `${badge} · ` : "";
1843
1850
  return {
1844
1851
  card_type: "button_interaction",
1845
1852
  main_title: { title: `${titlePrefix}本轮工具调用` },
@@ -1855,7 +1862,7 @@ export const startMirror = (deps) => {
1855
1862
  const content = s.acc;
1856
1863
  s.lastSent = content;
1857
1864
  try {
1858
- await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || " ", chatTurnUrl(s.target, s.turnId)), false);
1865
+ await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || "…", chatTurnUrl(s.target, s.turnId), undefined, undefined, s.dest), false);
1859
1866
  log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
1860
1867
  }
1861
1868
  catch (e) {
@@ -1929,11 +1936,14 @@ export const startMirror = (deps) => {
1929
1936
  turnId: newTurnId(),
1930
1937
  frame, streamId,
1931
1938
  target: a.target,
1939
+ ...(a.channel ? { dest: a.channel } : {}),
1932
1940
  acc: "", lastSent: "",
1933
1941
  capped: false, closed: false, dead: false, cardSent: false,
1934
1942
  tools: [], sawTool: false,
1935
1943
  };
1936
- s.hardTimer = setTimeout(() => void finalizeStream(a, s, true), HARD_TIMEOUT_MS);
1944
+ // 微信帧没有 stream 窗口: 到点收口只会多发一条「仍在处理中」, 白占它那几格出站额度。
1945
+ if (!isWxFrame(frame))
1946
+ s.hardTimer = setTimeout(() => void finalizeStream(a, s, true), HARD_TIMEOUT_MS);
1937
1947
  log.info({ sessionId: a.sessionId, turnId: s.turnId, streamId }, "stream open");
1938
1948
  return s;
1939
1949
  };
@@ -1945,8 +1955,8 @@ export const startMirror = (deps) => {
1945
1955
  ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target))
1946
1956
  : undefined;
1947
1957
  // `[mirror]` / `[wezard]` 系统提示不是答给谁的话, 不标方向。
1948
- const withLinkedTag = (a, content, seq, turnId) => withLinkedTagHeader(a.target, content, chatTurnUrl(a.target, turnId ?? a.briefTurnId), seq, /^\[(mirror|wezard)\]/.test(content.trim()) ? undefined : a.replyTo);
1949
- // 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`🦊 #tag` / `[🧙](url)`)
1958
+ const withLinkedTag = (a, content, seq, turnId) => withLinkedTagHeader(a.target, content, chatTurnUrl(a.target, turnId ?? a.briefTurnId), seq, /^\[(mirror|wezard)\]/.test(content.trim()) ? undefined : a.replyTo, a.channel || undefined);
1959
+ // 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`.name:` / `[.name](url):`)
1950
1960
  // 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
1951
1961
  // 防线; 这里拦在源头 —— 空内容不进 standalonePending FIFO、不占防抖 buf、
1952
1962
  // 不重置计时器, 免得"空 part 入队 → flush 时 join 出空串"的死角。
@@ -2282,7 +2292,7 @@ export const startMirror = (deps) => {
2282
2292
  return;
2283
2293
  a.cotLastSent = text;
2284
2294
  try {
2285
- await client.replyStream(b.frame, b.streamId, `${briefDetailLink(turnId, a.target)} \`${text}\``, false);
2295
+ await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, `\`${text}\``), false);
2286
2296
  }
2287
2297
  catch (e) {
2288
2298
  log.debug({ sessionId: a.sessionId, turnId, err: e.message }, "brief: cot refresh failed");
@@ -2304,7 +2314,8 @@ export const startMirror = (deps) => {
2304
2314
  // 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
2305
2315
  // 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
2306
2316
  // wizard 名字作为 ww_uniq 传下去, 让同一个 wizard 的所有 turn 详情都复用一个 WeCom 窗口。
2307
- const briefDetailLink = (turnId, target) => linkedTagHead(target, buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(target)));
2317
+ // 头怎么写归 chatHead (默认 wizard 在 home 群: 企微只留名字链接, 微信不写头); 头与正文都空时留 `…`, 不发空气泡。
2318
+ const briefLine = (a, turnId, rest, dest = a.channel) => headed(chatHead(a.target, buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, roleUniq(a.target)), undefined, dest || undefined), rest) || "…";
2308
2319
  // ── 出处门 (chatOriginOnly) ───────────────────────────────────────────
2309
2320
  // 人在 CLI 里手敲的一轮, 镜像只发生在两处: 他眼前的终端, 和 chat 详情页 (turn
2310
2321
  // store 全量记录 + SSE 实时刷新)。聊天里不再下发它的气泡 —— 那份内容他本来就
@@ -2492,7 +2503,7 @@ export const startMirror = (deps) => {
2492
2503
  };
2493
2504
  /** 人那句被并进了一轮私聊: 它预建的气泡与轮次收口成一个链接 —— 答复不进群, 也不让气泡挂到超时。 */
2494
2505
  const mergedAway = (a, q) => {
2495
- void finishBubble(a, q.bubble, `${briefDetailLink(q.turnId, a.target)} (这句和一段私聊并成了一轮, 答复只在 rolepage)`, true);
2506
+ void finishBubble(a, q.bubble, briefLine(a, q.turnId, "(这句和一段私聊并成了一轮, 答复只在 rolepage)"), true);
2496
2507
  recordTurnClose(q.turnId);
2497
2508
  };
2498
2509
  /** 人在聊天里说的那一句接成活跃 turn: 收掉在跑的那一轮, 频道与发话人取它自己的印章。
@@ -2556,15 +2567,17 @@ export const startMirror = (deps) => {
2556
2567
  const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
2557
2568
  const turnId = newTurnId();
2558
2569
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
2559
- const bubble = { frame, streamId, hardTimer: undefined, done: false };
2570
+ const bubble = { frame, streamId, done: false };
2560
2571
  const q = { turnId, bubble, isSlash };
2561
- bubble.hardTimer = setTimeout(() => {
2562
- // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。finish 是整条替换 ——
2563
- // 定格在一行过期的 CoT 进度上看着像卡死, 光链接又像说完了; 气泡仍是本轮活跃
2564
- // 气泡 (turn 还在跑) 就明说「仍在处理中」, 已被顶替的旧轮只留链接。
2565
- const live = a.briefBubble === bubble && !a.briefConcluded;
2566
- void finishBubble(a, bubble, `${briefDetailLink(turnId, a.target)}${live ? ` ${STILL_WORKING}` : ""}`, true);
2567
- }, HARD_TIMEOUT_MS);
2572
+ // 微信帧不挂: 没有 stream 窗口要赶, 气泡等终句来了再一次发出 (见 openStream 同一条)。
2573
+ if (!isWxFrame(frame))
2574
+ bubble.hardTimer = setTimeout(() => {
2575
+ // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。finish 是整条替换 ——
2576
+ // 定格在一行过期的 CoT 进度上看着像卡死, 光链接又像说完了; 气泡仍是本轮活跃
2577
+ // 气泡 (turn 还在跑) 就明说「仍在处理中」, 已被顶替的旧轮只留链接。
2578
+ const live = a.briefBubble === bubble && !a.briefConcluded;
2579
+ void finishBubble(a, bubble, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
2580
+ }, HARD_TIMEOUT_MS);
2568
2581
  const seal = { keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
2569
2582
  // 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
2570
2583
  // 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
@@ -2587,7 +2600,7 @@ export const startMirror = (deps) => {
2587
2600
  // 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
2588
2601
  // 尾随 `…` 表示"正文还没到", 正文到位时整条内容被 `链接 正文` 覆盖。
2589
2602
  try {
2590
- await client.replyStream(frame, streamId, `${briefDetailLink(turnId, a.target)} …`, false);
2603
+ await client.replyStream(frame, streamId, briefLine(a, turnId, "…", channel), false);
2591
2604
  }
2592
2605
  catch (e) {
2593
2606
  log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
@@ -2698,7 +2711,7 @@ export const startMirror = (deps) => {
2698
2711
  a.lastConcludedEpoch = a.queryEpoch ?? 0;
2699
2712
  a.lastConcludedAt = Date.now();
2700
2713
  if (a.briefBubble && !a.briefBubble.done) {
2701
- void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${linkTags(a.target, body)}`, true);
2714
+ void finishBriefBubble(a, briefLine(a, turnId, linkTags(a.target, body)), true);
2702
2715
  }
2703
2716
  else if (turnSilent(a)) {
2704
2717
  // CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
@@ -3364,7 +3377,7 @@ export const startMirror = (deps) => {
3364
3377
  // codebuddy 对 AskUserQuestion 不在提问时触发 PreToolUse hook — 先弹本地
3365
3378
  // 面板, hook 只在面板被提交后才到达 (实测可延迟数小时)。这里从 jsonl 提前
3366
3379
  // 看到 function_call, 直接驱动 vote 卡让远端可答; 点选后注入一段触发文本
3367
- // 提交本地面板, hook 到达时以 deny+reason 覆盖答案, 与 claude 路径同产物。
3380
+ // 提交本地面板, hook 到达时以 deny+reason 覆盖答案 (claude 路径改走 allow+updatedInput)。
3368
3381
  // claude 系 hook 即时触发, 不走此路 (否则双重发卡)。
3369
3382
  if (c.name === "AskUserQuestion"
3370
3383
  && backendForPath(a.jsonlPath).name === "codebuddy"
@@ -4455,32 +4468,14 @@ export const startMirror = (deps) => {
4455
4468
  return undefined;
4456
4469
  }
4457
4470
  };
4458
- // 确切模型名的证据 (见 exactModelId): transcript 里 API 真答过的 model id, 只认同一个
4459
- // CLI 的。先问最可能的那几个会话 (自己、被克隆者), 再按新近扫其余 —— 惰性的,
4460
- // 第一个对上就停, 绝大多数时候只读一两个 transcript 的尾巴。
4461
- function* modelSightings(cli, likely) {
4462
- const name = cli ?? primaryBackend().name;
4463
- const pathOf = (t) => byTarget.get(t)?.jsonlPath ?? expandHome(deps.store.get(t)?.jsonlPath ?? "");
4464
- const head = likely.map(pathOf).filter(Boolean);
4465
- // 生成器体到第一次取值才跑: 前几个就对上时, 其余几百个 stat 一个也不做。
4466
- const paths = function* () {
4467
- yield* head;
4468
- yield* Object.values(deps.store.all())
4469
- .map((r) => expandHome(r.jsonlPath))
4470
- .filter((p) => !head.includes(p))
4471
- .map((p) => ({ p, m: mtimeOf(p) }))
4472
- .filter((x) => x.m > 0)
4473
- .sort((x, y) => y.m - x.m)
4474
- .map((x) => x.p);
4475
- };
4476
- for (const path of paths()) {
4477
- if (!existsSync(path) || backendForPath(path).name !== name)
4478
- continue;
4479
- const id = lastModel(path);
4480
- if (id)
4481
- yield id;
4482
- }
4483
- }
4471
+ /** 管家该跑的那一档 (模型 + effort); 不是管家 = undefined。 */
4472
+ const stewardSpec = (target) => {
4473
+ const tier = stewardTierOf(cfg, target);
4474
+ return tier ? { model: cfg.models.tiers[tier].model || undefined, effort: cfg.models.tiers[tier].effort } : undefined;
4475
+ };
4476
+ /** 死 pane 重生 (`--resume` 同一个会话) 的模型: 绑定里记着的优先 —— set_model 换上的也记在那;
4477
+ * 没记 (老绑定 / CLI 默认) 的管家补上管家档。 */
4478
+ const respawnSpec = (a) => a.model ? { model: a.model, effort: parseEffort(a.effort) } : stewardSpec(a.target) ?? { effort: parseEffort(a.effort) };
4484
4479
  const newSession = async (target, windowName, cli, opts) => {
4485
4480
  const prev = byTarget.get(target);
4486
4481
  // Resolution precedence (all chat-scoped except the running-cwd fallback):
@@ -4512,11 +4507,12 @@ export const startMirror = (deps) => {
4512
4507
  const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
4513
4508
  const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
4514
4509
  const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
4515
- // 群的默认会话 (管家) 没点名模型 / 档位时用 models.router。只管这里新起的 —— 死 pane
4516
- // 重生走绑定里记下的模型, 不经过这里; 已在跑的管家也不动 (换模型会让缓存整份重读)。
4517
- const router = !tagOfKey(target) && !isInternalKey(target) ? cfg.models.router : undefined;
4518
- const effModel = opts?.model?.trim() || router?.model || undefined;
4519
- const effEffort = opts?.effort ?? parseEffort(prev?.effort ?? deps.store.get(target)?.effort) ?? router?.effort;
4510
+ // 管家 (群的默认会话) 新起的会话按管家档起 (models.steward / chatPolicy), 档压过绑定里记下的
4511
+ // effort —— `/new`、换目录、交接重开都是让改过的档位生效的那一下。点名了模型就整档让开。
4512
+ // 已在跑的管家不动 (换模型会让缓存整份重读); 死 pane 重生见 respawnSpec。
4513
+ const steward = opts?.model?.trim() ? undefined : stewardSpec(target);
4514
+ const effModel = opts?.model?.trim() || steward?.model || undefined;
4515
+ const effEffort = opts?.effort ?? steward?.effort ?? parseEffort(prev?.effort ?? deps.store.get(target)?.effort);
4520
4516
  if (prev?.tmuxPane) {
4521
4517
  // Best-effort kill; ignore errors (pane may already be dead).
4522
4518
  void runTmux(["kill-pane", "-t", prev.tmuxPane]);
@@ -4534,7 +4530,6 @@ export const startMirror = (deps) => {
4534
4530
  cwdOverride: eff,
4535
4531
  cli: effCli,
4536
4532
  model: effModel,
4537
- knownModels: modelSightings(effCli, [target, base]),
4538
4533
  effort: effEffort,
4539
4534
  systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
4540
4535
  }).catch((e) => { inFlightSids.delete(freshSid); throw e; });
@@ -4687,7 +4682,6 @@ export const startMirror = (deps) => {
4687
4682
  cwdOverride: parent.runningCwd,
4688
4683
  cli: parentCli,
4689
4684
  model,
4690
- knownModels: modelSightings(parentCli, [args.parent]),
4691
4685
  effort,
4692
4686
  systemPrompt: args.systemPrompt,
4693
4687
  });
@@ -4877,6 +4871,37 @@ export const startMirror = (deps) => {
4877
4871
  return eff === expandedDefaultCwd;
4878
4872
  };
4879
4873
  // ── Peer graph (sibling sessions of one chat) ────────────────────────
4874
+ // 关一个会话的 pane。`drop` = /kill: 连绑定一起删, 下一条消息另起白板; 否则只是睡着 (同 pane cap 的 reap), 绑定留着、`--resume` 带全上下文复活。
4875
+ const downPane = async (target, drop) => {
4876
+ // paneOf (not byTarget.get) so a binding surviving only in the persisted
4877
+ // store — e.g. after a reload that couldn't re-attach — is still killable.
4878
+ const a = byTarget.get(target);
4879
+ const pane = a?.tmuxPane || paneOf(target);
4880
+ if (!a && !pane)
4881
+ return { ok: false, reason: "no session bound to target" };
4882
+ if (pane && (await tmuxPaneAlive(pane))) {
4883
+ // Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
4884
+ // flush its transcript before the TTY is yanked out from under it.
4885
+ await runTmux(["send-keys", "-t", pane, "Escape"]);
4886
+ await sleep(250);
4887
+ const r = await runTmux(["kill-pane", "-t", pane]);
4888
+ if (r.code !== 0)
4889
+ return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
4890
+ }
4891
+ if (a)
4892
+ detach(a, drop ? "/kill" : "sleep");
4893
+ if (!drop) {
4894
+ log.info({ target, sessionId: a?.sessionId, pane }, "mirror sleep — pane killed, binding kept");
4895
+ return { ok: true };
4896
+ }
4897
+ // Drop the persisted record too — keeping it would let the next inbound
4898
+ // resurrect this very session via the dead-pane `--resume` self-heal,
4899
+ // which is the opposite of what /kill means. The chat then auto-spawns a
4900
+ // fresh session on its next message.
4901
+ deps.store.drop(target);
4902
+ log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
4903
+ return { ok: true };
4904
+ };
4880
4905
  // A chat's sessions are exactly the keys sharing its base principal: the
4881
4906
  // untagged default plus every `#tag`. Live attachments are the truth; the
4882
4907
  // persisted store fills in cold bindings so a peer nobody has talked to since
@@ -5492,6 +5517,7 @@ export const startMirror = (deps) => {
5492
5517
  isBusy,
5493
5518
  idleNow,
5494
5519
  parkedNow,
5520
+ idleVerdict,
5495
5521
  untilIdle,
5496
5522
  setModel: async (target, wanted, scope) => {
5497
5523
  const a = byTarget.get(target);
@@ -5735,13 +5761,17 @@ export const startMirror = (deps) => {
5735
5761
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
5736
5762
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
5737
5763
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
5738
- const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: displayName(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, knownModels: modelSightings(backendForPath(a.jsonlPath).name, [target]), effort: parseEffort(a.effort), systemPrompt: charterFor(target, { cwd: a.runningCwd }) });
5764
+ const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: displayName(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, ...respawnSpec(a), systemPrompt: charterFor(target, { cwd: a.runningCwd }) });
5739
5765
  if (!r.ok || !r.tmuxPane)
5740
5766
  return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
5741
5767
  a.tmuxPane = r.tmuxPane;
5742
5768
  a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
5743
5769
  if (r.cwd)
5744
5770
  a.runningCwd = r.cwd;
5771
+ // What the pane really landed on: a recorded string the CLI refused was
5772
+ // relaunched past, and must not cost the same extra launch next time.
5773
+ if (r.model !== undefined)
5774
+ a.model = r.model;
5745
5775
  deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, model: a.model || undefined, effort: a.effort || undefined, pendingCwd: a.pendingCwd || undefined });
5746
5776
  startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
5747
5777
  fresh = true;
@@ -5822,32 +5852,8 @@ export const startMirror = (deps) => {
5822
5852
  return { ok: false, reason: escReason };
5823
5853
  return { ok: true, torndown, escOk, escReason: escReason || undefined };
5824
5854
  },
5825
- killPane: async (target) => {
5826
- // paneOf (not byTarget.get) so a binding surviving only in the persisted
5827
- // store — e.g. after a reload that couldn't re-attach — is still killable.
5828
- const a = byTarget.get(target);
5829
- const pane = a?.tmuxPane || paneOf(target);
5830
- if (!a && !pane)
5831
- return { ok: false, reason: "no session bound to target" };
5832
- if (pane && (await tmuxPaneAlive(pane))) {
5833
- // Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
5834
- // flush its transcript before the TTY is yanked out from under it.
5835
- await runTmux(["send-keys", "-t", pane, "Escape"]);
5836
- await sleep(250);
5837
- const r = await runTmux(["kill-pane", "-t", pane]);
5838
- if (r.code !== 0)
5839
- return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
5840
- }
5841
- if (a)
5842
- detach(a, "/kill");
5843
- // Drop the persisted record too — keeping it would let the next inbound
5844
- // resurrect this very session via the dead-pane `--resume` self-heal,
5845
- // which is the opposite of what /kill means. The chat then auto-spawns a
5846
- // fresh session on its next message.
5847
- deps.store.drop(target);
5848
- log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
5849
- return { ok: true };
5850
- },
5855
+ killPane: (target) => downPane(target, true),
5856
+ sleepPane: (target) => downPane(target, false),
5851
5857
  lastActivity: (target) => {
5852
5858
  const jsonl = jsonlOf(target);
5853
5859
  try {
@@ -6135,6 +6141,16 @@ export const startMirror = (deps) => {
6135
6141
  // 任意 slash 命令 (/context, /cost, /status…): 产物走 skill_output standalone,
6136
6142
  // 详情链接是纯噪声 —— brief 起 turn 时不推链接。
6137
6143
  const isSlash = /^\/[a-z]/i.test(text.trim());
6144
+ // codebuddy 没有可用的剪贴板图片通道 (tmux 里 C-v 会被它自己的
6145
+ // syncTmuxToSystemClipboard 冲掉, 见 injectViaTmux 分流注释) —— 图片只能以
6146
+ // @<path> 文本进会话。既然如此, 问话 / 印章 / 回显去重 / 静默分叉跟随都按这个
6147
+ // 最终落盘形态记 (与 inject 拿到的同一个字符串); 否则落盘的 `@<path>` 行认不出
6148
+ // 是自己注入的: 被当成 CLI 手敲另开幽灵轮、触发 chatOriginOnly 吞回复, 而 IM 侧
6149
+ // 预建的 turn 永远等不到问话改写, rolepage 的入消息丢了图 (rolepage 一律按 CLI
6150
+ // 最终展示的那条消息渲染)。slash / /clear 判定仍用原始 text —— 命令不带图。
6151
+ const refsFirst = backendForPath(a.jsonlPath).name === "codebuddy" && (images?.length ?? 0) > 0;
6152
+ const effText = refsFirst ? withImageRefs(text, images ?? []) : text;
6153
+ const effImages = refsFirst ? [] : images;
6138
6154
  // Auto-upgrade /clear → /new when the user has queued a project switch:
6139
6155
  // a plain /clear would only rotate sessionId in the same pane, which sits
6140
6156
  // in the OLD cwd. Killing+respawning is the only way to honor the switch.
@@ -6217,7 +6233,7 @@ export const startMirror = (deps) => {
6217
6233
  // 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
6218
6234
  // 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
6219
6235
  // 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
6220
- await startBriefTurn(a, frame, streamId, isSlash, text, channel ?? baseOfKey(principal), speaker);
6236
+ await startBriefTurn(a, frame, streamId, isSlash, effText, channel ?? baseOfKey(principal), speaker);
6221
6237
  }
6222
6238
  else if (!armMigration && !eagerOpen) {
6223
6239
  enterDeferred(a, frame, streamId);
@@ -6252,13 +6268,15 @@ export const startMirror = (deps) => {
6252
6268
  const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
6253
6269
  // Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
6254
6270
  // mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
6255
- const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: displayName(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, knownModels: modelSightings(backendForPath(a.jsonlPath).name, [a.target]), effort: parseEffort(a.effort), systemPrompt: charterFor(a.target, { cwd: a.runningCwd }) });
6271
+ const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: displayName(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, ...respawnSpec(a), systemPrompt: charterFor(a.target, { cwd: a.runningCwd }) });
6256
6272
  if (r.ok && r.tmuxPane && r.tmuxSession) {
6257
6273
  a.tmuxPane = r.tmuxPane;
6258
6274
  a.tmuxSession = r.tmuxSession;
6259
6275
  if (r.cwd)
6260
6276
  a.runningCwd = r.cwd;
6261
6277
  freshSpawn = true;
6278
+ if (r.model !== undefined)
6279
+ a.model = r.model; // 同 respawn-init: 记真落地的模型
6262
6280
  deps.store.set(a.target, {
6263
6281
  sessionId: sid,
6264
6282
  jsonlPath: a.jsonlPath,
@@ -6302,7 +6320,7 @@ export const startMirror = (deps) => {
6302
6320
  log.warn({ target: a.target, sessionId: sid, myGen, gen: a.injectGen }, "inject aborted — superseded by /stop or watchdog");
6303
6321
  return;
6304
6322
  }
6305
- rememberInject(text);
6323
+ rememberInject(effText);
6306
6324
  // 解除静默必须在 inject 之前: inject 内部的 freshSpawn 验证循环可达 10+s
6307
6325
  // (paste-verify + settle + waitForCleared + retry), 而 LLM 的回复可能在 Enter
6308
6326
  // 发出后 2-3s 就落盘 —— tail 在 mute 期间读到的 item 会被 onItem 永久丢弃,
@@ -6311,7 +6329,7 @@ export const startMirror = (deps) => {
6311
6329
  // 用户行有 isOwnInject 过滤 —— 提前解除不会泄漏脏数据。
6312
6330
  a.muteUntilInject = false;
6313
6331
  const r = await inject({
6314
- text, images, cfg, log: log.child({ principal, sessionId: sid }),
6332
+ text: effText, images: effImages, cfg, log: log.child({ principal, sessionId: sid }),
6315
6333
  sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
6316
6334
  });
6317
6335
  if (!r.ok) {
@@ -6359,7 +6377,7 @@ export const startMirror = (deps) => {
6359
6377
  // stuck on the dead old jsonl. /clear via WeCom (armMigration) has its
6360
6378
  // own watcher; skip spawn-mode (no shared pane to fork under us).
6361
6379
  if (!armMigration && a.tmuxPane)
6362
- armSilentForkRebind(a, text);
6380
+ armSilentForkRebind(a, effText);
6363
6381
  // Confirm the /model picker. Early Enter (before the picker renders)
6364
6382
  // would leave it unconfirmed, so settle first; a late Enter is a
6365
6383
  // harmless no-op in the empty input box. On confirm, claude writes