wezard 1.4.1 → 2.0.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 (71) hide show
  1. package/CLAUDE.md +12 -7
  2. package/README.md +227 -112
  3. package/cli/wezard.sh +6 -3
  4. package/dist/daemon/approval.js +9 -3
  5. package/dist/daemon/approval.js.map +1 -1
  6. package/dist/daemon/cc-bridge.js.map +1 -1
  7. package/dist/daemon/chat-name.js +8 -14
  8. package/dist/daemon/chat-name.js.map +1 -1
  9. package/dist/daemon/detail.js +94 -9
  10. package/dist/daemon/detail.js.map +1 -1
  11. package/dist/daemon/graph.js +5 -7
  12. package/dist/daemon/graph.js.map +1 -1
  13. package/dist/daemon/inbound.js +135 -158
  14. package/dist/daemon/inbound.js.map +1 -1
  15. package/dist/daemon/index.js +452 -156
  16. package/dist/daemon/index.js.map +1 -1
  17. package/dist/daemon/jobs.js +2 -2
  18. package/dist/daemon/mirror-bridge.js +284 -140
  19. package/dist/daemon/mirror-bridge.js.map +1 -1
  20. package/dist/daemon/mirror-store.js.map +1 -1
  21. package/dist/daemon/model-select.js +74 -0
  22. package/dist/daemon/model-select.js.map +1 -0
  23. package/dist/daemon/peers.js +10 -16
  24. package/dist/daemon/peers.js.map +1 -1
  25. package/dist/daemon/spawn-tmux.js +27 -4
  26. package/dist/daemon/spawn-tmux.js.map +1 -1
  27. package/dist/daemon/task-gate.js +97 -0
  28. package/dist/daemon/task-gate.js.map +1 -0
  29. package/dist/daemon/task-registry.js +132 -0
  30. package/dist/daemon/task-registry.js.map +1 -0
  31. package/dist/daemon/tasks.js +126 -54
  32. package/dist/daemon/tasks.js.map +1 -1
  33. package/dist/daemon/wizard-memory.js +57 -0
  34. package/dist/daemon/wizard-memory.js.map +1 -0
  35. package/dist/daemon/wizard.js +84 -36
  36. package/dist/daemon/wizard.js.map +1 -1
  37. package/dist/mcp/server.js +138 -76
  38. package/dist/mcp/server.js.map +1 -1
  39. package/dist/shared/chat-http.js +193 -96
  40. package/dist/shared/chat-http.js.map +1 -1
  41. package/dist/shared/chat-render.js +31 -24
  42. package/dist/shared/chat-render.js.map +1 -1
  43. package/dist/shared/chat-view.js +14 -2
  44. package/dist/shared/chat-view.js.map +1 -1
  45. package/dist/shared/config.js +23 -4
  46. package/dist/shared/config.js.map +1 -1
  47. package/dist/shared/detail-render.js +14 -7
  48. package/dist/shared/detail-render.js.map +1 -1
  49. package/dist/shared/detail-store.js +12 -5
  50. package/dist/shared/detail-store.js.map +1 -1
  51. package/dist/shared/role-render.js +52 -0
  52. package/dist/shared/role-render.js.map +1 -0
  53. package/dist/shared/role-view.js +210 -0
  54. package/dist/shared/role-view.js.map +1 -0
  55. package/dist/shared/schedule-spec.js +92 -116
  56. package/dist/shared/schedule-spec.js.map +1 -1
  57. package/dist/shared/session-label.js +103 -72
  58. package/dist/shared/session-label.js.map +1 -1
  59. package/dist/shared/std.js +20 -0
  60. package/dist/shared/std.js.map +1 -1
  61. package/dist/shared/task-file.js +120 -0
  62. package/dist/shared/task-file.js.map +1 -0
  63. package/dist/shared/trigger.js +232 -0
  64. package/dist/shared/trigger.js.map +1 -0
  65. package/dist/shared/world.js +193 -0
  66. package/dist/shared/world.js.map +1 -0
  67. package/dist/svr/index.js +68 -3
  68. package/dist/svr/index.js.map +1 -1
  69. package/package.json +1 -1
  70. package/web/chat.css +609 -147
  71. package/web/chat.js +1274 -215
@@ -17,20 +17,21 @@ import { existsSync, readdirSync, statSync, watch, openSync, readSync, closeSync
17
17
  import { join, dirname } from "node:path";
18
18
  import { expandHome, sanitizeId } from "../shared/paths.js";
19
19
  import { augmentedPath } from "../shared/exec-path.js";
20
- import { sleep, truncateWithCount } from "../shared/std.js";
20
+ import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
21
21
  import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
22
22
  import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } from "../shared/modal-pane.js";
23
- import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
23
+ import { bindCardChannel, hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
24
24
  import { isAutoWindowActive } from "./session-cache.js";
25
25
  import { noticeSuffixFor } from "./notices.js";
26
26
  import { dangerOf } from "./danger.js";
27
27
  import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
28
+ import { wizardStore } from "./wizard.js";
28
29
  import { startSubagentWatch } from "./subagent-tail.js";
29
30
  import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
30
- import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, headSep, parseTagHeader } from "../shared/session-label.js";
31
+ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, linkedTagHead, linkTags, parseTagHeader, MAX_BODY_LINKS } from "../shared/session-label.js";
31
32
  import { splitMarkdown } from "../shared/md-chunk.js";
32
33
  import { randomTip } from "./tips.js";
33
- import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peerAddress } from "./chat-name.js";
34
+ import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
34
35
  import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
35
36
  // Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
36
37
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
@@ -677,9 +678,10 @@ const renderLine = (raw, deps) => {
677
678
  };
678
679
  // Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
679
680
  // fenced block or table in a way that breaks rendering — see splitMarkdown.
680
- // Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
681
- // (up to ~110 bytes in its linked `[🧙 #tag](url)` form).
682
- const TAG_HEADER_BUDGET = 64;
681
+ // Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line and
682
+ // the chat-detail links linkTags hangs on it and on up to MAX_BODY_LINKS body
683
+ // mentions — each `(url)` runs ~200-250 bytes.
684
+ const TAG_HEADER_BUDGET = 256 * (1 + MAX_BODY_LINKS);
683
685
  export const startMirrorTail = (deps) => {
684
686
  const { log } = deps;
685
687
  // A single logical tail follows ONE sessionId whose transcript file may move
@@ -799,11 +801,10 @@ export const startMirrorTail = (deps) => {
799
801
  livePath: resolveLive,
800
802
  };
801
803
  };
802
- // Run a tmux subcommand, capturing stdout/stderr. Delegates to spawn-tmux's
803
- // runTmux so there is exactly ONE tmux exec path in the daemon — and therefore
804
- // exactly one place where the hard timeout lives. (Two hand-rolled copies used
805
- // to exist here; both could hang forever, which is how a wedged tmux server
806
- // silently killed a whole chat. See TMUX_TIMEOUT_MS.)
804
+ const parsePaneList = (out) => new Map(out.split("\n").flatMap((line) => {
805
+ const [id, ...rest] = line.trim().split("\t");
806
+ return id?.startsWith("%") ? [[id, rest.join("\t")]] : [];
807
+ }));
807
808
  // Stable JSON: sort object keys recursively. Used to fingerprint a tool_use's
808
809
  // `input` so the hook-side and the jsonl-side compute the same signature even
809
810
  // when the model emits keys in arbitrary order.
@@ -1581,8 +1582,8 @@ export const startMirror = (deps) => {
1581
1582
  const detailCardFor = (s, target) => {
1582
1583
  if (s.tools.length === 0)
1583
1584
  return undefined;
1584
- const tag = tagOfKey(target);
1585
- const titlePrefix = tag ? `${labelFor(tag)} #${tag} · ` : "";
1585
+ const name = displayName(target);
1586
+ const titlePrefix = name ? `${labelFor(name)} .${name} · ` : "";
1586
1587
  return {
1587
1588
  card_type: "button_interaction",
1588
1589
  main_title: { title: `${titlePrefix}本轮工具调用` },
@@ -1598,7 +1599,7 @@ export const startMirror = (deps) => {
1598
1599
  const content = s.acc;
1599
1600
  s.lastSent = content;
1600
1601
  try {
1601
- await client.replyStream(s.frame, s.streamId, withTagHeader(s.target, content || " "), false);
1602
+ await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || " ", chatTurnUrl(s.target, s.turnId)), false);
1602
1603
  log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
1603
1604
  }
1604
1605
  catch (e) {
@@ -1679,32 +1680,26 @@ export const startMirror = (deps) => {
1679
1680
  };
1680
1681
  // Standalone fallback (no live stream / stream dead). Per-attachment FIFO so
1681
1682
  // pushes from a single mirror stay ordered; different mirrors run in parallel.
1682
- // Linked tag prefix: emoji+tag becomes a chat-detail link. Falls back to
1683
- // plain withTagHeader when no turnId is available (no active turn to link).
1684
- const linkedTagPrefix = (target, turnId) => {
1685
- if (!turnId)
1686
- return "";
1687
- const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
1688
- const tag = tagOfKey(target);
1689
- return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
1690
- };
1691
- const withLinkedTag = (a, content, seq, turnId) => {
1692
- const prefix = linkedTagPrefix(a.target, turnId ?? a.briefTurnId);
1693
- if (prefix) {
1694
- const seqBit = seq ? ` ${seq}` : "";
1695
- return `${prefix}${seqBit}${headSep(content)}${content}`;
1696
- }
1697
- return withTagHeader(a.target, content, seq);
1698
- };
1683
+ // 头挂 chat 详情页的票据: 本轮那条 turn。没有活 turn 可挂 → undefined, 头退回裸
1684
+ // `emoji #tag` (withLinkedTagHeader 自己兜)。
1685
+ const chatTurnUrl = (target, turnId) => turnId
1686
+ ? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target))
1687
+ : undefined;
1688
+ const withLinkedTag = (a, content, seq, turnId) => withLinkedTagHeader(a.target, content, chatTurnUrl(a.target, turnId ?? a.briefTurnId), seq);
1699
1689
  // 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`🦊 #tag` / `[🧙](url)`)
1700
1690
  // 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
1701
1691
  // 防线; 这里拦在源头 —— 空内容不进 standalonePending FIFO、不占防抖 buf、
1702
1692
  // 不重置计时器, 免得"空 part 入队 → flush 时 join 出空串"的死角。
1703
1693
  const hasVisibleBody = (content) => parseTagHeader(content).body.length > 0;
1694
+ // 推到本轮的频道 (人从别的群叫它 → 回那个群)。私聊轮的正文 brief 下在上游
1695
+ // turnSilent 已被拦下; 非 brief 的逐条推送在这里兜一道 —— 只放 `[mirror]` /
1696
+ // `[wezard]` 系统提示 (它们回 home)。
1704
1697
  const sendStandalone = (a, content) => {
1705
1698
  if (!hasVisibleBody(content))
1706
1699
  return;
1707
- const chatId = stripPrincipalPrefix(a.target);
1700
+ if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
1701
+ return;
1702
+ const chatId = replyChatId(a);
1708
1703
  const pieces = splitMarkdown(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
1709
1704
  const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
1710
1705
  a.standalonePending = a.standalonePending
@@ -1724,7 +1719,7 @@ export const startMirror = (deps) => {
1724
1719
  const sendRaw = (a, content) => {
1725
1720
  if (!hasVisibleBody(content))
1726
1721
  return;
1727
- const chatId = stripPrincipalPrefix(a.target);
1722
+ const chatId = replyChatId(a);
1728
1723
  a.standalonePending = a.standalonePending
1729
1724
  .then(async () => {
1730
1725
  try {
@@ -2045,18 +2040,15 @@ export const startMirror = (deps) => {
2045
2040
  // 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
2046
2041
  // 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
2047
2042
  // target 作为 ww_uniq 传下去, 让同 chat 的所有 turn 详情都复用一个 WeCom 窗口。
2048
- const briefDetailLink = (turnId, target) => {
2049
- const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
2050
- const tag = tagOfKey(target);
2051
- return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
2052
- };
2043
+ const briefDetailLink = (turnId, target) => linkedTagHead(target, buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target)));
2053
2044
  // ── 出处门 (chatOriginOnly) ───────────────────────────────────────────
2054
2045
  // 人在 CLI 里手敲的一轮, 镜像只发生在两处: 他眼前的终端, 和 chat 详情页 (turn
2055
2046
  // store 全量记录 + SSE 实时刷新)。聊天里不再下发它的气泡 —— 那份内容他本来就
2056
2047
  // 在看。群里保留的是需要在群里发生的事: WeCom 发起的轮次、审批/提问卡、
2057
2048
  // `[mirror]` 系统提示 —— 它们都不走这道门。
2058
2049
  // 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
2059
- const turnSilent = (a) => cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false;
2050
+ // 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
2051
+ const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
2060
2052
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
2061
2053
  // raw=true skips withTagHeader (used when content already contains the linked tag header).
2062
2054
  // WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
@@ -2118,6 +2110,34 @@ export const startMirror = (deps) => {
2118
2110
  a.pendingOrigin = undefined;
2119
2111
  return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.origin : undefined;
2120
2112
  };
2113
+ /** 同上, 出处那一枚印章。 */
2114
+ const consumeFrom = (a) => {
2115
+ const p = a.pendingFrom;
2116
+ a.pendingFrom = undefined;
2117
+ return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.from : undefined;
2118
+ };
2119
+ /** 频道印章: 有新印章就换频道; 没有则 `reset` (CLI 手敲 / 人从聊天发起) 回到
2120
+ * home, 其余续写 (收口后的补写) 沿用上一轮。 */
2121
+ const consumeChannel = (a, reset) => {
2122
+ const p = a.pendingChannel;
2123
+ a.pendingChannel = undefined;
2124
+ if (p && Date.now() - p.at <= ORIGIN_TTL_MS) {
2125
+ a.channel = p.channel === baseOfKey(a.target) ? undefined : p.channel;
2126
+ a.speaker = p.speaker;
2127
+ }
2128
+ else if (reset) {
2129
+ a.channel = undefined;
2130
+ a.speaker = undefined;
2131
+ }
2132
+ };
2133
+ /** 本轮落 turn 记录的频道字段。 */
2134
+ const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker });
2135
+ /** 私聊轮: 回复只落 turn 记录。 */
2136
+ const isPrivate = (a) => a.channel === "";
2137
+ /** 本轮回复该推去的 chatId。 */
2138
+ const replyChatId = (a) => stripPrincipalPrefix(a.channel || a.target);
2139
+ // 审批/提问卡同样回本轮的频道; 私聊轮 ("") 的卡回 home —— 要点它的人总得在某个群里。
2140
+ bindCardChannel((key) => byTarget.get(key)?.channel || "");
2121
2141
  // 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
2122
2142
  // 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
2123
2143
  const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
@@ -2126,7 +2146,8 @@ export const startMirror = (deps) => {
2126
2146
  a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
2127
2147
  a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
2128
2148
  a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
2129
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a) });
2149
+ consumeChannel(a, true);
2150
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a), from: consumeFrom(a), ...channelFields(a) });
2130
2151
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
2131
2152
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
2132
2153
  const q = { turnId, bubble, isSlash };
@@ -2172,7 +2193,8 @@ export const startMirror = (deps) => {
2172
2193
  const fromCli = a.pendingFromCli === true;
2173
2194
  a.pendingFromCli = false;
2174
2195
  a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
2175
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a) });
2196
+ consumeChannel(a, fromCli);
2197
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a), from: consumeFrom(a), ...channelFields(a) });
2176
2198
  a.briefTurnId = turnId;
2177
2199
  a.briefBubble = undefined;
2178
2200
  a.briefIsSlash = false;
@@ -2181,7 +2203,7 @@ export const startMirror = (deps) => {
2181
2203
  a.briefLastText = undefined;
2182
2204
  clearCot(a);
2183
2205
  // 详情链接不在这里单独发 — 本轮的每条 standalone 都由 withLinkedTag 自带
2184
- // (linkedTagPrefix 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
2206
+ // (chatTurnUrl 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
2185
2207
  // 一条都不发: 群里只该出现有正文的消息, 纯入口提示是噪音。
2186
2208
  log.info({ sessionId: a.sessionId, turnId, fromCli, silent: turnSilent(a) }, "brief: turn started (CLI-side, no bubble)");
2187
2209
  };
@@ -2226,7 +2248,7 @@ export const startMirror = (deps) => {
2226
2248
  a.lastConcludedEpoch = a.queryEpoch ?? 0;
2227
2249
  a.lastConcludedAt = Date.now();
2228
2250
  if (a.briefBubble && !a.briefBubble.done) {
2229
- void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
2251
+ void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${linkTags(a.target, body)}`, true);
2230
2252
  }
2231
2253
  else if (turnSilent(a)) {
2232
2254
  // CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
@@ -2832,6 +2854,7 @@ export const startMirror = (deps) => {
2832
2854
  && backendForPath(a.jsonlPath).name === "codebuddy"
2833
2855
  && !hasMirrorPlan(a.sessionId)) {
2834
2856
  void runMirrorPlanFlow({
2857
+ cfg,
2835
2858
  log: log.child({ sessionId: a.sessionId }),
2836
2859
  client,
2837
2860
  sessionId: a.sessionId,
@@ -2858,6 +2881,7 @@ export const startMirror = (deps) => {
2858
2881
  && backendForPath(a.jsonlPath).name === "codebuddy"
2859
2882
  && !hasMirrorAskq(a.sessionId)) {
2860
2883
  void runMirrorAskqFlow({
2884
+ cfg,
2861
2885
  log: log.child({ sessionId: a.sessionId }),
2862
2886
  client,
2863
2887
  sessionId: a.sessionId,
@@ -3070,7 +3094,7 @@ export const startMirror = (deps) => {
3070
3094
  const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
3071
3095
  ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
3072
3096
  : "";
3073
- const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd }) => {
3097
+ const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd, keepaliveDisabled }) => {
3074
3098
  const target = resolveTarget(targetOverride);
3075
3099
  if (!target)
3076
3100
  return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
@@ -3100,6 +3124,9 @@ export const startMirror = (deps) => {
3100
3124
  // model it asked for (possibly "" = CLI default) and says so; a re-attach
3101
3125
  // onto an already-running pane doesn't, so it keeps what was recorded.
3102
3126
  const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
3127
+ // Same carry rule again: an explicit spawn-time choice wins, otherwise this
3128
+ // wizard's existing policy (in-memory, or on disk across a reload) sticks.
3129
+ const carryKeepaliveDisabled = keepaliveDisabled !== undefined ? keepaliveDisabled : (prevByTarget?.keepaliveDisabled ?? deps.store.get(target)?.keepaliveDisabled ?? false);
3103
3130
  if (prevByTarget)
3104
3131
  detach(prevByTarget, "target reassigned");
3105
3132
  // Build the attachment first so the tail's onItem closure can capture it.
@@ -3115,6 +3142,7 @@ export const startMirror = (deps) => {
3115
3142
  runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
3116
3143
  pendingCwd: carryPending,
3117
3144
  model: carryModel,
3145
+ keepaliveDisabled: carryKeepaliveDisabled,
3118
3146
  tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
3119
3147
  standalonePending: Promise.resolve(),
3120
3148
  recentToolSigs: new Map(),
@@ -3153,8 +3181,10 @@ export const startMirror = (deps) => {
3153
3181
  cwd: a.runningCwd || undefined,
3154
3182
  model: a.model || undefined,
3155
3183
  pendingCwd: a.pendingCwd || undefined,
3184
+ cwdConfirmed: prevRec?.cwdConfirmed,
3156
3185
  keepaliveOff: prevRec?.keepaliveOff,
3157
3186
  keepaliveOffAt: prevRec?.keepaliveOffAt,
3187
+ keepaliveDisabled: carryKeepaliveDisabled || undefined,
3158
3188
  });
3159
3189
  log.info({ sessionId, jsonlPath, target, tmuxSession: a.tmuxSession, runningCwd: a.runningCwd, pendingCwd: a.pendingCwd, mirrors: bySessionId.size }, "mirror attached");
3160
3190
  return { ok: true, sessionId, jsonlPath, target };
@@ -3193,7 +3223,9 @@ export const startMirror = (deps) => {
3193
3223
  s.add(a.sessionId);
3194
3224
  return s;
3195
3225
  };
3196
- const restoreFromStore = async (principal) => {
3226
+ // `panes` = boot 快照; 给了就不再为每个 principal 单独问 tmux。lazy 调用方
3227
+ // 不传, 维持原来的逐个探活。
3228
+ const restoreFromStore = async (principal, panes) => {
3197
3229
  const rec = deps.store.get(principal);
3198
3230
  if (!rec)
3199
3231
  return undefined;
@@ -3210,7 +3242,7 @@ export const startMirror = (deps) => {
3210
3242
  // jsonl means "no input yet", NOT "rotated away" — healing it onto some
3211
3243
  // unrelated newest-in-cwd file is exactly the cross-wire we must avoid.
3212
3244
  const storedPane = (rec.tmuxPane ?? "").trim();
3213
- const livePane = storedPane && (await tmuxPaneAlive(storedPane)) ? storedPane : "";
3245
+ const livePane = storedPane && (panes ? panes.has(storedPane) : await tmuxPaneAlive(storedPane)) ? storedPane : "";
3214
3246
  if (!existsSync(jsonlAbs)) {
3215
3247
  // First: the SAME-sid transcript may have merely relocated to a sibling
3216
3248
  // project dir (Claude Code EnterWorktree/ExitWorktree moved it while the
@@ -3263,8 +3295,9 @@ export const startMirror = (deps) => {
3263
3295
  // is silently detached; that detached chat then never mirrors again. Runs at
3264
3296
  // boot; the 3s drift follower maintains it thereafter.
3265
3297
  if (livePane) {
3266
- const cwdRes = await runTmux(["display-message", "-p", "-t", livePane, "#{pane_current_path}"]);
3267
- const paneCwd = cwdRes.stdout.trim();
3298
+ const paneCwd = panes
3299
+ ? (panes.get(livePane) ?? "")
3300
+ : (await runTmux(["display-message", "-p", "-t", livePane, "#{pane_current_path}"])).stdout.trim();
3268
3301
  if (paneCwd) {
3269
3302
  // Encode under the backend that owns the bound transcript — comparing
3270
3303
  // with the primary dialect would report a phantom drift for every
@@ -3299,6 +3332,8 @@ export const startMirror = (deps) => {
3299
3332
  }
3300
3333
  // Re-hydrate the `/stop` pause so a reload doesn't resume pinging a quieted
3301
3334
  // session (attach preserved it on disk; this puts it back in memory).
3335
+ // keepaliveDisabled is carried by attach() itself (same rule as `model`);
3336
+ // only the transient `/stop` pause needs manual re-hydration here.
3302
3337
  const restored = byTarget.get(principal);
3303
3338
  if (restored && rec.keepaliveOff) {
3304
3339
  restored.keepaliveOff = true;
@@ -3312,18 +3347,26 @@ export const startMirror = (deps) => {
3312
3347
  const persistPause = (a) => {
3313
3348
  const rec = deps.store.get(a.target);
3314
3349
  if (rec)
3315
- deps.store.set(a.target, { ...rec, keepaliveOff: a.keepaliveOff, keepaliveOffAt: a.keepaliveOffAt });
3350
+ deps.store.set(a.target, { ...rec, keepaliveOff: a.keepaliveOff, keepaliveOffAt: a.keepaliveOffAt, keepaliveDisabled: a.keepaliveDisabled });
3316
3351
  };
3317
3352
  // We re-attach lazily on demand too (see restoreFromStore in dispatch /
3318
3353
  // hasMirrorTarget), but eager boot-restore makes outbound (tail → push) work
3319
3354
  // even before any inbound arrives — e.g. claude finishes a long-running
3320
3355
  // background task and writes assistant content to the jsonl while idle.
3356
+ const RESTORE_CONC = 8;
3321
3357
  const persisted = deps.store.all();
3322
3358
  const persistedKeys = Object.keys(persisted);
3323
3359
  if (persistedKeys.length > 0) {
3324
- for (const principal of persistedKeys) {
3325
- void restoreFromStore(principal);
3326
- }
3360
+ // 裸 fan-out 会把 2N 次 tmux 同时压下去 —— 几百个绑定就是七百个 client 一起
3361
+ // 撞 10s 超时, spawn 风暴顺带把事件循环压住, :17890 要 25s 才 bind, 而 CLI 的
3362
+ // reload 探测早放弃了 (「reload issued but /status not responding」的真身)。
3363
+ // 一张快照 + 封顶并发; await 立刻让出, startHttp 先绑端口。
3364
+ void (async () => {
3365
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{pane_current_path}"]);
3366
+ const panes = snap.ok ? parsePaneList(snap.stdout) : undefined;
3367
+ await mapLimit(persistedKeys, RESTORE_CONC, (principal) => restoreFromStore(principal, panes));
3368
+ log.info({ restored: persistedKeys.length, panes: panes?.size ?? -1 }, "mirror: boot restore done");
3369
+ })();
3327
3370
  }
3328
3371
  else if (cfg.wrc.mirror.sessionId.trim()) {
3329
3372
  // Pinned-sessionId fallback: only honor when the store is empty (otherwise
@@ -3867,9 +3910,12 @@ export const startMirror = (deps) => {
3867
3910
  // 由 index.ts 在启动时装上 (注册表活在那边); 没装 = 退回无身份行为, 全链路无回归。
3868
3911
  let charterOf;
3869
3912
  const setCharterProvider = (fn) => { charterOf = fn; };
3870
- const charterFor = (target) => {
3913
+ // `cwd` = 这个 pane 正要启动的目录。attach 在 spawn 之后才发生, 所以此刻 getCwd
3914
+ // 给的还是上一个 pane 的目录 —— 换目录重开时照它渲染, 新 wizard 会以为自己还在
3915
+ // 旧工作区里。
3916
+ const charterFor = (target, ctx) => {
3871
3917
  try {
3872
- return charterOf?.(target);
3918
+ return charterOf?.(target, ctx);
3873
3919
  }
3874
3920
  catch {
3875
3921
  return undefined;
@@ -3919,7 +3965,7 @@ export const startMirror = (deps) => {
3919
3965
  cwdOverride: eff,
3920
3966
  cli: effCli,
3921
3967
  model: opts?.model,
3922
- systemPrompt: opts?.systemPrompt ?? charterFor(target),
3968
+ systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
3923
3969
  });
3924
3970
  if (!r.ok)
3925
3971
  return { ok: false, reason: r.reason };
@@ -3933,8 +3979,11 @@ export const startMirror = (deps) => {
3933
3979
  // Explicit "" clears any carried-over pending — it has just been applied.
3934
3980
  pendingCwd: "",
3935
3981
  // 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() ?? "",
3982
+ // means this CLI's default, not the dead pane's model. Store what
3983
+ // spawnTmuxClaude actually confirmed via `/model` (r.model), not the
3984
+ // raw request — a later respawn's fast path then matches on try one.
3985
+ model: r.model ?? (opts?.model?.trim() ?? ""),
3986
+ keepaliveDisabled: opts?.keepalive === undefined ? undefined : !opts.keepalive,
3938
3987
  });
3939
3988
  if (!att.ok)
3940
3989
  return { ok: false, reason: att.reason };
@@ -3969,6 +4018,7 @@ export const startMirror = (deps) => {
3969
4018
  tmuxSession: baseA.tmuxSession || undefined,
3970
4019
  tmuxPane: baseA.tmuxPane || undefined,
3971
4020
  cwd: baseA.runningCwd || undefined,
4021
+ cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
3972
4022
  pendingCwd: undefined,
3973
4023
  });
3974
4024
  }
@@ -3980,7 +4030,7 @@ export const startMirror = (deps) => {
3980
4030
  }
3981
4031
  if (!opts?.silent)
3982
4032
  pushProjectInfo(target, "created");
3983
- return { ok: true, sessionId: r.sessionId, cwd: r.cwd };
4033
+ return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning };
3984
4034
  };
3985
4035
  // ── Clone ───────────────────────────────────────────────────────────
3986
4036
  // 分身 = `--resume <父 sid> --fork-session` 起的新 pane: CLI 把父亲的 transcript
@@ -4022,7 +4072,7 @@ export const startMirror = (deps) => {
4022
4072
  const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
4023
4073
  if (!canInherit) {
4024
4074
  const r = await newSession(args.target, args.windowName, args.cli, {
4025
- model: args.model, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true,
4075
+ model: args.model, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive,
4026
4076
  });
4027
4077
  return { ...r, inherited: false };
4028
4078
  }
@@ -4074,7 +4124,8 @@ export const startMirror = (deps) => {
4074
4124
  tmuxSession: r.tmuxSession,
4075
4125
  cwd: r.cwd,
4076
4126
  pendingCwd: "",
4077
- model: args.model?.trim() ?? "",
4127
+ model: r.model ?? (args.model?.trim() ?? ""),
4128
+ keepaliveDisabled: args.keepalive === undefined ? undefined : !args.keepalive,
4078
4129
  });
4079
4130
  if (!att.ok)
4080
4131
  return { ok: false, reason: att.reason, inherited: false };
@@ -4089,7 +4140,7 @@ export const startMirror = (deps) => {
4089
4140
  persistPause(spawned);
4090
4141
  }
4091
4142
  lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
4092
- return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true };
4143
+ return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning };
4093
4144
  };
4094
4145
  /** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
4095
4146
  const sessionInfo = (target) => {
@@ -4146,6 +4197,7 @@ export const startMirror = (deps) => {
4146
4197
  tmuxSession: baseA.tmuxSession || undefined,
4147
4198
  tmuxPane: baseA.tmuxPane || undefined,
4148
4199
  cwd: baseA.runningCwd || undefined,
4200
+ cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
4149
4201
  pendingCwd: baseA.pendingCwd || undefined,
4150
4202
  });
4151
4203
  log.info({ target, base, runningCwd: callerRunning, pendingCwd: expanded }, "setPendingCwd (chat-scoped, live base)");
@@ -4169,6 +4221,7 @@ export const startMirror = (deps) => {
4169
4221
  tmuxSession: callerA.tmuxSession || undefined,
4170
4222
  tmuxPane: callerA.tmuxPane || undefined,
4171
4223
  cwd: callerA.runningCwd || undefined,
4224
+ cwdConfirmed: deps.store.get(target)?.cwdConfirmed,
4172
4225
  pendingCwd: callerA.pendingCwd || undefined,
4173
4226
  });
4174
4227
  log.info({ target, runningCwd: callerA.runningCwd, pendingCwd: callerA.pendingCwd }, "setPendingCwd (fallback: no base, wrote to caller)");
@@ -4182,6 +4235,32 @@ export const startMirror = (deps) => {
4182
4235
  }
4183
4236
  return { ok: false, reason: "no mirror binding for target — send a message in the WeCom chat first", runningCwd: "", pendingCwd: "" };
4184
4237
  };
4238
+ // 「人说了就用这个目录」。一个新聊天的第一个 wizard 落在默认兜底目录里 —— 那不是
4239
+ // 选择, 只是没人说过。所以默认按「还要换」办 (charter 让它开工前先问一句), 直到人
4240
+ // 明说不用换。**只有这一个写入点**: 换去别的目录本身就是选择, 不必记一笔, 那时
4241
+ // runningCwd 已经不等于默认目录, cwdUnconfirmed 自然为假。
4242
+ // 寻址与 pendingCwd 同规 —— 写在 base 上 (聊天级), 没有 base 记录 (只有 tagged
4243
+ // 会话的聊天) 就退回调用方自己的那条。
4244
+ const confirmCwd = (target) => {
4245
+ const base = baseOfKey(target);
4246
+ const key = deps.store.get(base) ? base : deps.store.get(target) ? target : "";
4247
+ const rec = key ? deps.store.get(key) : undefined;
4248
+ if (!rec)
4249
+ return { ok: false, reason: "no mirror binding for target — send a message in the WeCom chat first", cwd: "" };
4250
+ deps.store.set(key, { ...rec, cwdConfirmed: true });
4251
+ const cwd = getCwd(target).runningCwd;
4252
+ log.info({ target, key, cwd }, "cwd confirmed by user");
4253
+ return { ok: true, cwd };
4254
+ };
4255
+ /** 该不该问人「要在哪个项目下干活」: 还蹲在默认兜底目录里, 而且人没说过就用它。 */
4256
+ const cwdUnconfirmed = (target, cwd) => {
4257
+ const base = baseOfKey(target);
4258
+ if (deps.store.get(base)?.cwdConfirmed || deps.store.get(target)?.cwdConfirmed)
4259
+ return false;
4260
+ // `cwd` 是调用方已经知道的「即将生效的目录」(spawn 前的宪章渲染)。没给就问现状。
4261
+ const eff = cwd?.trim() ? expandHome(cwd.trim()) : getCwd(target).runningCwd;
4262
+ return eff === expandedDefaultCwd;
4263
+ };
4185
4264
  // ── Peer graph (sibling sessions of one chat) ────────────────────────
4186
4265
  // A chat's sessions are exactly the keys sharing its base principal: the
4187
4266
  // untagged default plus every `#tag`. Live attachments are the truth; the
@@ -4195,73 +4274,36 @@ export const startMirror = (deps) => {
4195
4274
  const base = baseOfKey(target);
4196
4275
  return allTargets().filter((k) => baseOfKey(k) === base);
4197
4276
  };
4198
- // 名字解析:`daily` / `chat:wrxxx` → base principal。认不出就把已知名字一并回给
4199
- // 调用方 —— 跨 chat 出错时"我该写什么"比"你写错了"有用得多。
4200
- const resolveChatRef = (ref) => {
4201
- const base = chatBaseOf(cfg, ref);
4202
- if (base)
4203
- return { ok: true, base };
4204
- const known = listChatNames(cfg).map((c) => c.name);
4205
- return {
4206
- ok: false,
4207
- reason: `unknown chat '${ref}' — give that chat a name first (\`/name ${normChatName(ref) || "<name>"}\` inside it)`,
4208
- candidates: known,
4209
- };
4210
- };
4211
- // peer 寻址。地址是两级的(见 chat-name.ts):
4212
- // "" 本 chat 的 default —— 自身语义不变;
4213
- // `fix` 本 chat 优先,本 chat 没有再全局兜底(全 host 唯一才认)——
4214
- // 命名之前唯一的跨 chat 路子,老调用方不能因为引入命名而断掉;
4215
- // `daily#fix` daily 这个 chat 里的 `#fix`,精确到点,不问 tag 全不全局唯一;
4216
- // `daily#` daily 的 default 会话;
4217
- // `chat:wr…#fix` 全量 key 同理(list_peers 吐的就是它)。
4218
- // 裸 tag 的 0 命中 / ≥2 命中依旧拒绝,但出路从"回去改 tag 名"变成"用带 chat 名
4219
- // 的全称地址"——后者不需要动别人的会话。
4277
+ // peer 寻址。地址就是 wizard 的**全局名字** (`.fix` / `fix`), 与它住在哪个聊天无关:
4278
+ // "" 本 chat 的默认 wizard —— 自身语义不变;
4279
+ // `fix` / `.fix` 名册里叫这个名字的 wizard, 全局唯一, 不分聊天;
4280
+ // `chat:wr…#k` 全量 key (调试 / 老调用方);
4281
+ // `daily#fix` 改名前的老写法 —— 名字取自 tag 迁移而来, 照旧认得。
4220
4282
  const resolvePeerTag = (self, ref) => {
4221
- const { chat, tag } = parsePeerRef(ref ?? "");
4222
- const t = tag.trim();
4223
- if (chat) {
4224
- const c = resolveChatRef(chat);
4225
- if (!c.ok)
4226
- return c;
4227
- const target = keyOf(c.base, t);
4228
- if (allTargets().includes(target)) {
4229
- return { ok: true, target, foreign: c.base !== baseOfKey(self) };
4230
- }
4231
- return {
4232
- ok: false,
4233
- reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
4234
- // 候选给**地址**而不是裸 key —— 这份清单存在的意义就是让调用方照着改一个
4235
- // 能用的串; 隔壁两个分支早就这么做了, 只有这一条漏掉。
4236
- candidates: allTargets().filter((k) => baseOfKey(k) === c.base).map((k) => peerAddress(cfg, self, k)),
4237
- };
4238
- }
4239
- const local = keyOf(baseOfKey(self), t);
4240
- if (!t)
4241
- return { ok: true, target: local, foreign: false };
4283
+ const raw = stripSigil(ref);
4242
4284
  const all = allTargets();
4243
- if (all.includes(local))
4244
- return { ok: true, target: local, foreign: false };
4245
- const foreignMatches = all.filter((k) => tagOfKey(k) === t && baseOfKey(k) !== baseOfKey(self));
4246
- if (foreignMatches.length === 1)
4247
- return { ok: true, target: foreignMatches[0], foreign: true };
4248
- if (foreignMatches.length === 0) {
4249
- // 裸 token 正好是个 chat 名字 —— 用户/agent 想说的是"那个群",不是"那个 tag"。
4250
- // 直接给出它的会话地址,比让人再查一次 list_chats 快。
4251
- const asChat = chatBaseOf(cfg, t);
4252
- if (asChat) {
4253
- return {
4254
- ok: false,
4255
- reason: `'${t}' is a CHAT, not a tag — address one of its sessions, e.g. '${t}#' for its default`,
4256
- candidates: allTargets().filter((k) => baseOfKey(k) === asChat).map((k) => peerAddress(cfg, self, k)),
4257
- };
4258
- }
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)` };
4285
+ const hit = (target) => ({ ok: true, target, foreign: baseOfKey(target) !== baseOfKey(self) });
4286
+ if (!raw)
4287
+ return hit(baseOfKey(self));
4288
+ if (/^(user|chat|group):/.test(raw)) {
4289
+ return all.includes(raw) ? hit(raw) : { ok: false, reason: `no running session '${raw}'` };
4290
+ }
4291
+ const named = wizardStore()?.byName(raw)?.target;
4292
+ if (named && all.includes(named))
4293
+ return hit(named);
4294
+ if (named)
4295
+ return { ok: false, reason: `wizard '.${raw}' exists but its session is not running — respawn it (new_claude_session) or pick another` };
4296
+ const { chat, tag } = parsePeerRef(raw);
4297
+ if (chat) {
4298
+ const base = chatBaseOf(cfg, chat);
4299
+ const legacy = base ? keyOf(base, tag) : "";
4300
+ if (legacy && all.includes(legacy))
4301
+ return hit(legacy);
4260
4302
  }
4261
4303
  return {
4262
4304
  ok: false,
4263
- reason: `tag '#${t}' exists in ${foreignMatches.length} chats — address it in full as 'chatName#${t}' (list_chats shows the names)`,
4264
- candidates: foreignMatches.map((k) => peerAddress(cfg, self, k)),
4305
+ reason: `no wizard named '.${raw}' — wizard_roster lists every name`,
4306
+ candidates: all.map((k) => peerAddress(cfg, self, k)),
4265
4307
  };
4266
4308
  };
4267
4309
  /** 已知的每个 chat:命名的 + 有会话在跑的。`name` 为空即"还没起名",那就是
@@ -4327,6 +4369,7 @@ export const startMirror = (deps) => {
4327
4369
  const pane = paneOf(t);
4328
4370
  const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
4329
4371
  const tag = tagOfKey(t);
4372
+ const name = displayName(t);
4330
4373
  let lastActivity = 0;
4331
4374
  try {
4332
4375
  if (jsonlPath)
@@ -4338,7 +4381,7 @@ export const startMirror = (deps) => {
4338
4381
  tag,
4339
4382
  chat: chatNameOf(cfg, t),
4340
4383
  address: peerAddress(cfg, self, t),
4341
- label: tag ? labelFor(tag) : "🧙",
4384
+ label: name ? labelFor(name) : "🧙",
4342
4385
  sessionId: a?.sessionId || rec?.sessionId || "",
4343
4386
  jsonlPath,
4344
4387
  cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
@@ -4355,16 +4398,43 @@ export const startMirror = (deps) => {
4355
4398
  };
4356
4399
  const byRecent = (x, y) => y.lastActivity - x.lastActivity;
4357
4400
  const peers = async (target) => (await Promise.all(chatTargets(target).map((t) => peerInfoOf(t, target)))).sort(byRecent);
4358
- // list_peers 的跨 chat 补充:其他 chat 里**当前调用方叫得动**的 session。两条
4359
- // 入选路径 —— tag 全局唯一(裸 tag 就能命中),或者它所在的 chat 有名字(全称
4360
- // `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
4361
- // 只要给群起个名,不必回去改别人的 tag。
4401
+ // list_peers 的跨 chat 补充: 其他 chat 里的 wizard。名字全局唯一, 所以它们
4402
+ // 全都叫得动 —— 地址就是 `.name`, 与住在哪个聊天无关。
4362
4403
  const foreignPeers = async (self) => {
4363
4404
  const selfBase = baseOfKey(self);
4364
- const foreign = allTargets().filter((k) => tagOfKey(k) && baseOfKey(k) !== selfBase);
4365
- const tagCount = foreign.reduce((acc, k) => acc.set(tagOfKey(k), (acc.get(tagOfKey(k)) ?? 0) + 1), new Map());
4366
- const reachable = foreign.filter((k) => tagCount.get(tagOfKey(k)) === 1 || chatNameOf(cfg, k));
4367
- return (await Promise.all(reachable.map((t) => peerInfoOf(t, self)))).sort(byRecent);
4405
+ // 只列活着的 / 有身份的 —— 冷绑定可能有几百个, 每个探活是两次 tmux。
4406
+ const foreign = allTargets().filter((k) => baseOfKey(k) !== selfBase && (byTarget.has(k) || !!wizardStore()?.get(k)));
4407
+ return (await Promise.all(foreign.map((t) => peerInfoOf(t, self)))).sort(byRecent);
4408
+ };
4409
+ // peerInfoOf 每个 target 要两次 tmux (pane 存活 + capture tail)。跑了几个月的
4410
+ // 机器上 allTargets() 是几百个 —— 一次全量探活就是上千次 spawn, 足够打穿 fd
4411
+ // 上限、把 tmux server 拖垮, 守护进程跟着崩进重启循环 (CLAUDE.md 的 fd 那一条
4412
+ // 记的就是这个失效模式)。所以两道闸:
4413
+ // 1. 先用**不 spawn 的**证据排序 —— transcript 的 mtime, 一次 statSync;
4414
+ // 注册表里登记过的 wizard 无条件入选 (它们是有身份的那些, 也就那么几个)。
4415
+ // 2. 探活并发封顶, 且只探前 WORLD_PROBE 个。
4416
+ // 没被探到的照旧出现在名册里, 只是 busy/alive 按冷处理 —— 图上少一盏呼吸灯,
4417
+ // 远好过把整台机器上的会话一起搞停。
4418
+ const WORLD_PROBE = 48;
4419
+ const WORLD_CONC = 6;
4420
+ const worldPeers = async (self) => {
4421
+ const mtimeOf = (t) => {
4422
+ const p = jsonlOf(t);
4423
+ if (!p)
4424
+ return 0;
4425
+ try {
4426
+ return statSync(p).mtimeMs;
4427
+ }
4428
+ catch {
4429
+ return 0;
4430
+ }
4431
+ };
4432
+ const named = new Set(wizardStore()?.all().map((w) => w.target) ?? []);
4433
+ const ranked = allTargets()
4434
+ .map((t) => ({ t, ts: mtimeOf(t), named: named.has(t) }))
4435
+ .sort((a, b) => (a.named !== b.named ? (a.named ? -1 : 1) : b.ts - a.ts));
4436
+ const hot = ranked.slice(0, WORLD_PROBE).map((x) => x.t);
4437
+ return (await mapLimit(hot, WORLD_CONC, (t) => peerInfoOf(t, self))).sort(byRecent);
4368
4438
  };
4369
4439
  // Capture a few extra rows then compact away the TUI's blank padding, so
4370
4440
  // `rows` counts lines the user actually cares about.
@@ -4481,6 +4551,8 @@ export const startMirror = (deps) => {
4481
4551
  const pingSigs = [normAssistant(kc.ping).slice(0, 40), normAssistant(kc.resumePing).slice(0, 40)].filter((s) => s.length > 0);
4482
4552
  const now = Date.now();
4483
4553
  for (const a of byTarget.values()) {
4554
+ if (a.keepaliveDisabled)
4555
+ continue; // sticky spawn-time opt-out — never pings, never auto-resumes
4484
4556
  if (!a.tmuxPane)
4485
4557
  continue; // spawn-mode: no live pane to warm
4486
4558
  if (a.migrationWatcher)
@@ -4715,6 +4787,7 @@ export const startMirror = (deps) => {
4715
4787
  chatTargets,
4716
4788
  peers,
4717
4789
  foreignPeers,
4790
+ worldPeers,
4718
4791
  resolvePeerTag,
4719
4792
  chatRoster,
4720
4793
  peekPane,
@@ -4904,6 +4977,16 @@ export const startMirror = (deps) => {
4904
4977
  // resolve 之前就看到 user 行并开出 turn, 那时归因必须已经在位。
4905
4978
  if (origin)
4906
4979
  a.pendingOrigin = { origin, at: Date.now() };
4980
+ if (opts?.from)
4981
+ a.pendingFrom = { from: opts.from, at: Date.now() };
4982
+ if (opts?.channel !== undefined)
4983
+ a.pendingChannel = { channel: opts.channel, at: Date.now() };
4984
+ // 注入的那一行会被 recentInjects 当回显吞掉, 走不到 user_text —— 本轮的 query
4985
+ // (rolepage 上「谁对它说了什么」那一半) 只能在这里记。
4986
+ a.pendingBriefQuery = text;
4987
+ // 公开频道里说的话, 回复就该在群里看得见 —— 哪怕上一轮是人在 CLI 里敲的。
4988
+ if (opts?.channel)
4989
+ a.turnFromChat = true;
4907
4990
  const sid = a.sessionId;
4908
4991
  const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
4909
4992
  if (!paneAlive) {
@@ -4911,7 +4994,7 @@ export const startMirror = (deps) => {
4911
4994
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
4912
4995
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
4913
4996
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
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) });
4997
+ 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, systemPrompt: charterFor(target, { cwd: a.runningCwd }) });
4915
4998
  if (!r.ok || !r.tmuxPane)
4916
4999
  return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
4917
5000
  a.tmuxPane = r.tmuxPane;
@@ -5013,6 +5096,61 @@ export const startMirror = (deps) => {
5013
5096
  log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
5014
5097
  return { ok: true };
5015
5098
  },
5099
+ reapIdle: async (ttlMs, skip) => {
5100
+ const now = Date.now();
5101
+ const reaped = [];
5102
+ const skipped = {};
5103
+ for (const target of allTargets()) {
5104
+ const a = byTarget.get(target);
5105
+ const sessionId = a?.sessionId || deps.store.get(target)?.sessionId || "";
5106
+ // 便宜闸门先行: transcript mtime 一次 statSync, 不 spawn —— 跑了几个
5107
+ // 月的机器上 allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd
5108
+ // (同 worldPeers 的 WORLD_PROBE 取舍)。还没写过 transcript 的 spawn-mode
5109
+ // 绑定没有活动证据, 不动它。
5110
+ const jsonl = jsonlOf(target);
5111
+ let mtime = 0;
5112
+ try {
5113
+ if (jsonl)
5114
+ mtime = statSync(jsonl).mtimeMs;
5115
+ }
5116
+ catch { /* 文件没了 */ }
5117
+ if (!mtime || now - mtime < ttlMs)
5118
+ continue;
5119
+ const veto = skip?.(target, sessionId);
5120
+ if (veto) {
5121
+ skipped[target] = veto;
5122
+ continue;
5123
+ }
5124
+ const pane = paneOf(target);
5125
+ if (!pane || !(await tmuxPaneAlive(pane)))
5126
+ continue; // pane 已死, 没什么可收的
5127
+ if (paneIsBusy(await capturePaneTail(pane, 12))) {
5128
+ skipped[target] = "busy";
5129
+ continue;
5130
+ }
5131
+ // 人正盯着这个 window —— 收掉他的视野比省下那点内存更讨嫌。
5132
+ const w = await runTmux(["display-message", "-p", "-t", pane, "#{window_active}:#{session_attached}"]);
5133
+ const [wActive, sAttached] = w.stdout.trim().split(":");
5134
+ if (wActive === "1" && sAttached !== "0") {
5135
+ skipped[target] = "watched";
5136
+ continue;
5137
+ }
5138
+ // 与 killPane 同款前戏: Esc 给生成中的 CLI 一个收束落盘的机会。
5139
+ await runTmux(["send-keys", "-t", pane, "Escape"]);
5140
+ await sleep(250);
5141
+ const r = await runTmux(["kill-pane", "-t", pane]);
5142
+ if (r.code !== 0) {
5143
+ skipped[target] = `kill-pane failed: ${r.stdout.slice(-120) || r.code}`;
5144
+ continue;
5145
+ }
5146
+ if (a)
5147
+ detach(a, "idle reap");
5148
+ // 不碰 store —— 绑定留着, 下一条 inbound 走 dead-pane `--resume` 复活。
5149
+ reaped.push(target);
5150
+ log.info({ target, sessionId, pane, idleHours: Math.round((now - mtime) / 3.6e6) }, "mirror idle reap — pane killed, binding kept");
5151
+ }
5152
+ return { reaped, skipped };
5153
+ },
5016
5154
  submitPane: async (target) => {
5017
5155
  const a = byTarget.get(target);
5018
5156
  if (!a)
@@ -5094,6 +5232,8 @@ export const startMirror = (deps) => {
5094
5232
  },
5095
5233
  getCwd,
5096
5234
  setPendingCwd,
5235
+ cwdUnconfirmed,
5236
+ confirmCwd,
5097
5237
  newSession,
5098
5238
  cloneSession,
5099
5239
  sessionInfo,
@@ -5113,7 +5253,11 @@ export const startMirror = (deps) => {
5113
5253
  bySessionId.clear();
5114
5254
  byTarget.clear();
5115
5255
  },
5116
- dispatch: async ({ principal, text, images, frame, streamId }) => {
5256
+ currentChannel: (target) => {
5257
+ const a = byTarget.get(target);
5258
+ return a?.channel ?? baseOfKey(target);
5259
+ },
5260
+ dispatch: async ({ principal, text, images, frame, streamId, channel, speaker }) => {
5117
5261
  // Route by inbound principal — the WeCom chat the user just messaged us
5118
5262
  // from is the same string we registered as `target` on attach. After a
5119
5263
  // daemon reload the in-memory map is empty; restore from the persisted
@@ -5149,6 +5293,8 @@ export const startMirror = (deps) => {
5149
5293
  }
5150
5294
  }
5151
5295
  persistPause(a); // persist the pause/resume too, else a reload would revert it
5296
+ // 频道印章要早于任何开 turn 的路径 (startBriefTurn 同步消费)。
5297
+ a.pendingChannel = { channel: channel ?? baseOfKey(principal), speaker, at: Date.now() };
5152
5298
  // Finalize prior live stream (if any) so this new turn renders into its
5153
5299
  // own message bubble. Then open a fresh stream tied to the new frame and
5154
5300
  // ack immediately so WeCom doesn't time out while inject queues.
@@ -5170,10 +5316,8 @@ export const startMirror = (deps) => {
5170
5316
  if (a.liveStream && !a.liveStream.closed)
5171
5317
  await finalizeStream(a, a.liveStream);
5172
5318
  log.info({ target: a.target, runningCwd: a.runningCwd, pendingCwd: pending }, "/clear upgraded to /new (cwd switch)");
5173
- // Prefer the tag suffix as tmux window name when present (matches
5174
- // /new #tag behavior), fall back to the full target for untagged.
5175
- const tag = tagOfKey(a.target);
5176
- const r = await newSession(a.target, tag || a.target);
5319
+ // The wizard's global name is the tmux window name (matches /new).
5320
+ const r = await newSession(a.target, displayName(a.target) || a.target);
5177
5321
  if (!r.ok) {
5178
5322
  try {
5179
5323
  await client.replyStream(frame, streamId, withTagHeader(a.target, `[mirror] 切换失败: ${r.reason ?? "unknown"}`), true);
@@ -5279,7 +5423,7 @@ export const startMirror = (deps) => {
5279
5423
  const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
5280
5424
  // Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
5281
5425
  // mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
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) });
5426
+ 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, systemPrompt: charterFor(a.target, { cwd: a.runningCwd }) });
5283
5427
  if (r.ok && r.tmuxPane && r.tmuxSession) {
5284
5428
  a.tmuxPane = r.tmuxPane;
5285
5429
  a.tmuxSession = r.tmuxSession;