wezard 1.4.2 → 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 (69) hide show
  1. package/CLAUDE.md +12 -7
  2. package/README.md +121 -67
  3. package/dist/daemon/approval.js +9 -3
  4. package/dist/daemon/approval.js.map +1 -1
  5. package/dist/daemon/cc-bridge.js.map +1 -1
  6. package/dist/daemon/chat-name.js +8 -18
  7. package/dist/daemon/chat-name.js.map +1 -1
  8. package/dist/daemon/detail.js +53 -12
  9. package/dist/daemon/detail.js.map +1 -1
  10. package/dist/daemon/graph.js +5 -7
  11. package/dist/daemon/graph.js.map +1 -1
  12. package/dist/daemon/inbound.js +135 -158
  13. package/dist/daemon/inbound.js.map +1 -1
  14. package/dist/daemon/index.js +381 -192
  15. package/dist/daemon/index.js.map +1 -1
  16. package/dist/daemon/jobs.js +2 -2
  17. package/dist/daemon/mirror-bridge.js +221 -134
  18. package/dist/daemon/mirror-bridge.js.map +1 -1
  19. package/dist/daemon/mirror-store.js.map +1 -1
  20. package/dist/daemon/model-select.js +74 -0
  21. package/dist/daemon/model-select.js.map +1 -0
  22. package/dist/daemon/peers.js +10 -16
  23. package/dist/daemon/peers.js.map +1 -1
  24. package/dist/daemon/spawn-tmux.js +27 -4
  25. package/dist/daemon/spawn-tmux.js.map +1 -1
  26. package/dist/daemon/task-gate.js +97 -0
  27. package/dist/daemon/task-gate.js.map +1 -0
  28. package/dist/daemon/task-registry.js +132 -0
  29. package/dist/daemon/task-registry.js.map +1 -0
  30. package/dist/daemon/tasks.js +126 -54
  31. package/dist/daemon/tasks.js.map +1 -1
  32. package/dist/daemon/wizard-memory.js +57 -0
  33. package/dist/daemon/wizard-memory.js.map +1 -0
  34. package/dist/daemon/wizard.js +84 -38
  35. package/dist/daemon/wizard.js.map +1 -1
  36. package/dist/mcp/server.js +138 -76
  37. package/dist/mcp/server.js.map +1 -1
  38. package/dist/shared/chat-http.js +185 -108
  39. package/dist/shared/chat-http.js.map +1 -1
  40. package/dist/shared/chat-render.js +26 -34
  41. package/dist/shared/chat-render.js.map +1 -1
  42. package/dist/shared/chat-view.js +14 -2
  43. package/dist/shared/chat-view.js.map +1 -1
  44. package/dist/shared/config.js +23 -4
  45. package/dist/shared/config.js.map +1 -1
  46. package/dist/shared/detail-render.js +12 -6
  47. package/dist/shared/detail-render.js.map +1 -1
  48. package/dist/shared/detail-store.js.map +1 -1
  49. package/dist/shared/role-render.js +52 -0
  50. package/dist/shared/role-render.js.map +1 -0
  51. package/dist/shared/role-view.js +210 -0
  52. package/dist/shared/role-view.js.map +1 -0
  53. package/dist/shared/schedule-spec.js +92 -116
  54. package/dist/shared/schedule-spec.js.map +1 -1
  55. package/dist/shared/session-label.js +103 -72
  56. package/dist/shared/session-label.js.map +1 -1
  57. package/dist/shared/std.js +3 -0
  58. package/dist/shared/std.js.map +1 -1
  59. package/dist/shared/task-file.js +120 -0
  60. package/dist/shared/task-file.js.map +1 -0
  61. package/dist/shared/trigger.js +232 -0
  62. package/dist/shared/trigger.js.map +1 -0
  63. package/dist/shared/world.js +7 -5
  64. package/dist/shared/world.js.map +1 -1
  65. package/dist/svr/index.js +67 -2
  66. package/dist/svr/index.js.map +1 -1
  67. package/package.json +1 -1
  68. package/web/chat.css +407 -212
  69. package/web/chat.js +932 -321
@@ -20,7 +20,7 @@ import { augmentedPath } from "../shared/exec-path.js";
20
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";
@@ -28,10 +28,10 @@ import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
28
28
  import { wizardStore } from "./wizard.js";
29
29
  import { startSubagentWatch } from "./subagent-tail.js";
30
30
  import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
31
- 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";
32
32
  import { splitMarkdown } from "../shared/md-chunk.js";
33
33
  import { randomTip } from "./tips.js";
34
- import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peerAddress } from "./chat-name.js";
34
+ import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
35
35
  import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
36
36
  // Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
37
37
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
@@ -678,9 +678,10 @@ const renderLine = (raw, deps) => {
678
678
  };
679
679
  // Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
680
680
  // fenced block or table in a way that breaks rendering — see splitMarkdown.
681
- // Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
682
- // (up to ~110 bytes in its linked `[🧙 #tag](url)` form).
683
- 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);
684
685
  export const startMirrorTail = (deps) => {
685
686
  const { log } = deps;
686
687
  // A single logical tail follows ONE sessionId whose transcript file may move
@@ -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 后仍有打字机动画要播放, 如果紧接着就下发
@@ -2124,6 +2116,28 @@ export const startMirror = (deps) => {
2124
2116
  a.pendingFrom = undefined;
2125
2117
  return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.from : undefined;
2126
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 || "");
2127
2141
  // 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
2128
2142
  // 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
2129
2143
  const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
@@ -2132,7 +2146,8 @@ export const startMirror = (deps) => {
2132
2146
  a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
2133
2147
  a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
2134
2148
  a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
2135
- 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) });
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) });
2136
2151
  // hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
2137
2152
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
2138
2153
  const q = { turnId, bubble, isSlash };
@@ -2178,7 +2193,8 @@ export const startMirror = (deps) => {
2178
2193
  const fromCli = a.pendingFromCli === true;
2179
2194
  a.pendingFromCli = false;
2180
2195
  a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
2181
- 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) });
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) });
2182
2198
  a.briefTurnId = turnId;
2183
2199
  a.briefBubble = undefined;
2184
2200
  a.briefIsSlash = false;
@@ -2187,7 +2203,7 @@ export const startMirror = (deps) => {
2187
2203
  a.briefLastText = undefined;
2188
2204
  clearCot(a);
2189
2205
  // 详情链接不在这里单独发 — 本轮的每条 standalone 都由 withLinkedTag 自带
2190
- // (linkedTagPrefix 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
2206
+ // (chatTurnUrl 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
2191
2207
  // 一条都不发: 群里只该出现有正文的消息, 纯入口提示是噪音。
2192
2208
  log.info({ sessionId: a.sessionId, turnId, fromCli, silent: turnSilent(a) }, "brief: turn started (CLI-side, no bubble)");
2193
2209
  };
@@ -2232,7 +2248,7 @@ export const startMirror = (deps) => {
2232
2248
  a.lastConcludedEpoch = a.queryEpoch ?? 0;
2233
2249
  a.lastConcludedAt = Date.now();
2234
2250
  if (a.briefBubble && !a.briefBubble.done) {
2235
- void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
2251
+ void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${linkTags(a.target, body)}`, true);
2236
2252
  }
2237
2253
  else if (turnSilent(a)) {
2238
2254
  // CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
@@ -2838,6 +2854,7 @@ export const startMirror = (deps) => {
2838
2854
  && backendForPath(a.jsonlPath).name === "codebuddy"
2839
2855
  && !hasMirrorPlan(a.sessionId)) {
2840
2856
  void runMirrorPlanFlow({
2857
+ cfg,
2841
2858
  log: log.child({ sessionId: a.sessionId }),
2842
2859
  client,
2843
2860
  sessionId: a.sessionId,
@@ -2864,6 +2881,7 @@ export const startMirror = (deps) => {
2864
2881
  && backendForPath(a.jsonlPath).name === "codebuddy"
2865
2882
  && !hasMirrorAskq(a.sessionId)) {
2866
2883
  void runMirrorAskqFlow({
2884
+ cfg,
2867
2885
  log: log.child({ sessionId: a.sessionId }),
2868
2886
  client,
2869
2887
  sessionId: a.sessionId,
@@ -3076,7 +3094,7 @@ export const startMirror = (deps) => {
3076
3094
  const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
3077
3095
  ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
3078
3096
  : "";
3079
- const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd }) => {
3097
+ const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd, keepaliveDisabled }) => {
3080
3098
  const target = resolveTarget(targetOverride);
3081
3099
  if (!target)
3082
3100
  return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
@@ -3106,6 +3124,9 @@ export const startMirror = (deps) => {
3106
3124
  // model it asked for (possibly "" = CLI default) and says so; a re-attach
3107
3125
  // onto an already-running pane doesn't, so it keeps what was recorded.
3108
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);
3109
3130
  if (prevByTarget)
3110
3131
  detach(prevByTarget, "target reassigned");
3111
3132
  // Build the attachment first so the tail's onItem closure can capture it.
@@ -3121,6 +3142,7 @@ export const startMirror = (deps) => {
3121
3142
  runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
3122
3143
  pendingCwd: carryPending,
3123
3144
  model: carryModel,
3145
+ keepaliveDisabled: carryKeepaliveDisabled,
3124
3146
  tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
3125
3147
  standalonePending: Promise.resolve(),
3126
3148
  recentToolSigs: new Map(),
@@ -3159,8 +3181,10 @@ export const startMirror = (deps) => {
3159
3181
  cwd: a.runningCwd || undefined,
3160
3182
  model: a.model || undefined,
3161
3183
  pendingCwd: a.pendingCwd || undefined,
3184
+ cwdConfirmed: prevRec?.cwdConfirmed,
3162
3185
  keepaliveOff: prevRec?.keepaliveOff,
3163
3186
  keepaliveOffAt: prevRec?.keepaliveOffAt,
3187
+ keepaliveDisabled: carryKeepaliveDisabled || undefined,
3164
3188
  });
3165
3189
  log.info({ sessionId, jsonlPath, target, tmuxSession: a.tmuxSession, runningCwd: a.runningCwd, pendingCwd: a.pendingCwd, mirrors: bySessionId.size }, "mirror attached");
3166
3190
  return { ok: true, sessionId, jsonlPath, target };
@@ -3308,6 +3332,8 @@ export const startMirror = (deps) => {
3308
3332
  }
3309
3333
  // Re-hydrate the `/stop` pause so a reload doesn't resume pinging a quieted
3310
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.
3311
3337
  const restored = byTarget.get(principal);
3312
3338
  if (restored && rec.keepaliveOff) {
3313
3339
  restored.keepaliveOff = true;
@@ -3321,7 +3347,7 @@ export const startMirror = (deps) => {
3321
3347
  const persistPause = (a) => {
3322
3348
  const rec = deps.store.get(a.target);
3323
3349
  if (rec)
3324
- 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 });
3325
3351
  };
3326
3352
  // We re-attach lazily on demand too (see restoreFromStore in dispatch /
3327
3353
  // hasMirrorTarget), but eager boot-restore makes outbound (tail → push) work
@@ -3884,9 +3910,12 @@ export const startMirror = (deps) => {
3884
3910
  // 由 index.ts 在启动时装上 (注册表活在那边); 没装 = 退回无身份行为, 全链路无回归。
3885
3911
  let charterOf;
3886
3912
  const setCharterProvider = (fn) => { charterOf = fn; };
3887
- const charterFor = (target) => {
3913
+ // `cwd` = 这个 pane 正要启动的目录。attach 在 spawn 之后才发生, 所以此刻 getCwd
3914
+ // 给的还是上一个 pane 的目录 —— 换目录重开时照它渲染, 新 wizard 会以为自己还在
3915
+ // 旧工作区里。
3916
+ const charterFor = (target, ctx) => {
3888
3917
  try {
3889
- return charterOf?.(target);
3918
+ return charterOf?.(target, ctx);
3890
3919
  }
3891
3920
  catch {
3892
3921
  return undefined;
@@ -3936,7 +3965,7 @@ export const startMirror = (deps) => {
3936
3965
  cwdOverride: eff,
3937
3966
  cli: effCli,
3938
3967
  model: opts?.model,
3939
- systemPrompt: opts?.systemPrompt ?? charterFor(target),
3968
+ systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
3940
3969
  });
3941
3970
  if (!r.ok)
3942
3971
  return { ok: false, reason: r.reason };
@@ -3950,8 +3979,11 @@ export const startMirror = (deps) => {
3950
3979
  // Explicit "" clears any carried-over pending — it has just been applied.
3951
3980
  pendingCwd: "",
3952
3981
  // Likewise explicit: `/new` starts a NEW session, so an unnamed model
3953
- // means this CLI's default, not the dead pane's model.
3954
- 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,
3955
3987
  });
3956
3988
  if (!att.ok)
3957
3989
  return { ok: false, reason: att.reason };
@@ -3986,6 +4018,7 @@ export const startMirror = (deps) => {
3986
4018
  tmuxSession: baseA.tmuxSession || undefined,
3987
4019
  tmuxPane: baseA.tmuxPane || undefined,
3988
4020
  cwd: baseA.runningCwd || undefined,
4021
+ cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
3989
4022
  pendingCwd: undefined,
3990
4023
  });
3991
4024
  }
@@ -3997,7 +4030,7 @@ export const startMirror = (deps) => {
3997
4030
  }
3998
4031
  if (!opts?.silent)
3999
4032
  pushProjectInfo(target, "created");
4000
- 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 };
4001
4034
  };
4002
4035
  // ── Clone ───────────────────────────────────────────────────────────
4003
4036
  // 分身 = `--resume <父 sid> --fork-session` 起的新 pane: CLI 把父亲的 transcript
@@ -4039,7 +4072,7 @@ export const startMirror = (deps) => {
4039
4072
  const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
4040
4073
  if (!canInherit) {
4041
4074
  const r = await newSession(args.target, args.windowName, args.cli, {
4042
- 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,
4043
4076
  });
4044
4077
  return { ...r, inherited: false };
4045
4078
  }
@@ -4091,7 +4124,8 @@ export const startMirror = (deps) => {
4091
4124
  tmuxSession: r.tmuxSession,
4092
4125
  cwd: r.cwd,
4093
4126
  pendingCwd: "",
4094
- model: args.model?.trim() ?? "",
4127
+ model: r.model ?? (args.model?.trim() ?? ""),
4128
+ keepaliveDisabled: args.keepalive === undefined ? undefined : !args.keepalive,
4095
4129
  });
4096
4130
  if (!att.ok)
4097
4131
  return { ok: false, reason: att.reason, inherited: false };
@@ -4106,7 +4140,7 @@ export const startMirror = (deps) => {
4106
4140
  persistPause(spawned);
4107
4141
  }
4108
4142
  lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
4109
- 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 };
4110
4144
  };
4111
4145
  /** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
4112
4146
  const sessionInfo = (target) => {
@@ -4163,6 +4197,7 @@ export const startMirror = (deps) => {
4163
4197
  tmuxSession: baseA.tmuxSession || undefined,
4164
4198
  tmuxPane: baseA.tmuxPane || undefined,
4165
4199
  cwd: baseA.runningCwd || undefined,
4200
+ cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
4166
4201
  pendingCwd: baseA.pendingCwd || undefined,
4167
4202
  });
4168
4203
  log.info({ target, base, runningCwd: callerRunning, pendingCwd: expanded }, "setPendingCwd (chat-scoped, live base)");
@@ -4186,6 +4221,7 @@ export const startMirror = (deps) => {
4186
4221
  tmuxSession: callerA.tmuxSession || undefined,
4187
4222
  tmuxPane: callerA.tmuxPane || undefined,
4188
4223
  cwd: callerA.runningCwd || undefined,
4224
+ cwdConfirmed: deps.store.get(target)?.cwdConfirmed,
4189
4225
  pendingCwd: callerA.pendingCwd || undefined,
4190
4226
  });
4191
4227
  log.info({ target, runningCwd: callerA.runningCwd, pendingCwd: callerA.pendingCwd }, "setPendingCwd (fallback: no base, wrote to caller)");
@@ -4199,6 +4235,32 @@ export const startMirror = (deps) => {
4199
4235
  }
4200
4236
  return { ok: false, reason: "no mirror binding for target — send a message in the WeCom chat first", runningCwd: "", pendingCwd: "" };
4201
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
+ };
4202
4264
  // ── Peer graph (sibling sessions of one chat) ────────────────────────
4203
4265
  // A chat's sessions are exactly the keys sharing its base principal: the
4204
4266
  // untagged default plus every `#tag`. Live attachments are the truth; the
@@ -4212,80 +4274,36 @@ export const startMirror = (deps) => {
4212
4274
  const base = baseOfKey(target);
4213
4275
  return allTargets().filter((k) => baseOfKey(k) === base);
4214
4276
  };
4215
- // 名字解析:`daily` / `chat:wrxxx` → base principal。认不出就把已知名字一并回给
4216
- // 调用方 —— 跨 chat 出错时"我该写什么"比"你写错了"有用得多。
4217
- const resolveChatRef = (ref) => {
4218
- const base = chatBaseOf(cfg, ref);
4219
- if (base)
4220
- return { ok: true, base };
4221
- const known = listChatNames(cfg).map((c) => c.name);
4222
- return {
4223
- ok: false,
4224
- reason: `unknown chat '${ref}' — give that chat a name first (\`/name ${normChatName(ref) || "<name>"}\` inside it)`,
4225
- candidates: known,
4226
- };
4227
- };
4228
- // peer 寻址。地址是两级的(见 chat-name.ts):
4229
- // "" 本 chat 的 default —— 自身语义不变;
4230
- // `fix` 本 chat 优先,本 chat 没有再全局兜底(全 host 唯一才认)——
4231
- // 命名之前唯一的跨 chat 路子,老调用方不能因为引入命名而断掉;
4232
- // `daily#fix` daily 这个 chat 里的 `#fix`,精确到点,不问 tag 全不全局唯一;
4233
- // `daily` daily 这个聊天的 default 会话 —— 默认 wizard 的名字就是聊天名,
4234
- // 所以裸聊天名就是它的地址 (`daily#` 这种老写法继续认);
4235
- // `chat:wr…#fix` 全量 key 同理(list_peers 吐的就是它)。
4236
- // 裸 tag 的 0 命中 / ≥2 命中依旧拒绝,但出路从"回去改 tag 名"变成"用带 chat 名
4237
- // 的全称地址"——后者不需要动别人的会话。
4277
+ // peer 寻址。地址就是 wizard 的**全局名字** (`.fix` / `fix`), 与它住在哪个聊天无关:
4278
+ // "" 本 chat 的默认 wizard —— 自身语义不变;
4279
+ // `fix` / `.fix` 名册里叫这个名字的 wizard, 全局唯一, 不分聊天;
4280
+ // `chat:wr…#k` 全量 key (调试 / 老调用方);
4281
+ // `daily#fix` 改名前的老写法 —— 名字取自 tag 迁移而来, 照旧认得。
4238
4282
  const resolvePeerTag = (self, ref) => {
4239
- const { chat, tag } = parsePeerRef(ref ?? "");
4240
- const t = tag.trim();
4241
- if (chat) {
4242
- const c = resolveChatRef(chat);
4243
- if (!c.ok)
4244
- return c;
4245
- const target = keyOf(c.base, t);
4246
- if (allTargets().includes(target)) {
4247
- return { ok: true, target, foreign: c.base !== baseOfKey(self) };
4248
- }
4249
- return {
4250
- ok: false,
4251
- reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
4252
- // 候选给**地址**而不是裸 key —— 这份清单存在的意义就是让调用方照着改一个
4253
- // 能用的串; 隔壁两个分支早就这么做了, 只有这一条漏掉。
4254
- candidates: allTargets().filter((k) => baseOfKey(k) === c.base).map((k) => peerAddress(cfg, self, k)),
4255
- };
4256
- }
4257
- const local = keyOf(baseOfKey(self), t);
4258
- if (!t)
4259
- return { ok: true, target: local, foreign: false };
4283
+ const raw = stripSigil(ref);
4260
4284
  const all = allTargets();
4261
- if (all.includes(local))
4262
- return { ok: true, target: local, foreign: false };
4263
- const foreignMatches = all.filter((k) => tagOfKey(k) === t && baseOfKey(k) !== baseOfKey(self));
4264
- if (foreignMatches.length === 1)
4265
- return { ok: true, target: foreignMatches[0], foreign: true };
4266
- if (foreignMatches.length === 0) {
4267
- // 裸 token 正好是个 chat 名字 —— 用户/agent 想说的是"那个群",不是"那个 tag"。
4268
- // 直接给出它的会话地址,比让人再查一次 list_chats 快。
4269
- const asChat = chatBaseOf(cfg, t);
4270
- if (asChat) {
4271
- // 裸聊天名 = 那个聊天的**默认 wizard**: 它的名字就是聊天名, peerAddress
4272
- // 印出来的地址也就是这个裸名字, 必须能原样喂回来 (此前这里一律报错, 于是
4273
- // 「名册里读到的地址」与「send_peer 收得下的地址」对不上)。
4274
- if (allTargets().includes(asChat)) {
4275
- return { ok: true, target: asChat, foreign: asChat !== baseOfKey(self) };
4276
- }
4277
- return {
4278
- ok: false,
4279
- reason: `chat '${t}' has no default session — address one of its tagged sessions, or create it with new_claude_session({ chat: '${t}', cwd })`,
4280
- candidates: allTargets().filter((k) => baseOfKey(k) === asChat).map((k) => peerAddress(cfg, self, k)),
4281
- };
4282
- }
4283
- 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);
4284
4302
  }
4285
4303
  return {
4286
4304
  ok: false,
4287
- reason: `tag '#${t}' exists in ${foreignMatches.length} chats — address it in full as 'chatName#${t}' (list_chats shows the names)`,
4288
- 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)),
4289
4307
  };
4290
4308
  };
4291
4309
  /** 已知的每个 chat:命名的 + 有会话在跑的。`name` 为空即"还没起名",那就是
@@ -4351,6 +4369,7 @@ export const startMirror = (deps) => {
4351
4369
  const pane = paneOf(t);
4352
4370
  const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
4353
4371
  const tag = tagOfKey(t);
4372
+ const name = displayName(t);
4354
4373
  let lastActivity = 0;
4355
4374
  try {
4356
4375
  if (jsonlPath)
@@ -4362,7 +4381,7 @@ export const startMirror = (deps) => {
4362
4381
  tag,
4363
4382
  chat: chatNameOf(cfg, t),
4364
4383
  address: peerAddress(cfg, self, t),
4365
- label: tag ? labelFor(tag) : "🧙",
4384
+ label: name ? labelFor(name) : "🧙",
4366
4385
  sessionId: a?.sessionId || rec?.sessionId || "",
4367
4386
  jsonlPath,
4368
4387
  cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
@@ -4379,16 +4398,13 @@ export const startMirror = (deps) => {
4379
4398
  };
4380
4399
  const byRecent = (x, y) => y.lastActivity - x.lastActivity;
4381
4400
  const peers = async (target) => (await Promise.all(chatTargets(target).map((t) => peerInfoOf(t, target)))).sort(byRecent);
4382
- // list_peers 的跨 chat 补充:其他 chat 里**当前调用方叫得动**的 session。两条
4383
- // 入选路径 —— tag 全局唯一(裸 tag 就能命中),或者它所在的 chat 有名字(全称
4384
- // `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
4385
- // 只要给群起个名,不必回去改别人的 tag。
4401
+ // list_peers 的跨 chat 补充: 其他 chat 里的 wizard。名字全局唯一, 所以它们
4402
+ // 全都叫得动 —— 地址就是 `.name`, 与住在哪个聊天无关。
4386
4403
  const foreignPeers = async (self) => {
4387
4404
  const selfBase = baseOfKey(self);
4388
- const foreign = allTargets().filter((k) => tagOfKey(k) && baseOfKey(k) !== selfBase);
4389
- const tagCount = foreign.reduce((acc, k) => acc.set(tagOfKey(k), (acc.get(tagOfKey(k)) ?? 0) + 1), new Map());
4390
- const reachable = foreign.filter((k) => tagCount.get(tagOfKey(k)) === 1 || chatNameOf(cfg, k));
4391
- 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);
4392
4408
  };
4393
4409
  // peerInfoOf 每个 target 要两次 tmux (pane 存活 + capture tail)。跑了几个月的
4394
4410
  // 机器上 allTargets() 是几百个 —— 一次全量探活就是上千次 spawn, 足够打穿 fd
@@ -4535,6 +4551,8 @@ export const startMirror = (deps) => {
4535
4551
  const pingSigs = [normAssistant(kc.ping).slice(0, 40), normAssistant(kc.resumePing).slice(0, 40)].filter((s) => s.length > 0);
4536
4552
  const now = Date.now();
4537
4553
  for (const a of byTarget.values()) {
4554
+ if (a.keepaliveDisabled)
4555
+ continue; // sticky spawn-time opt-out — never pings, never auto-resumes
4538
4556
  if (!a.tmuxPane)
4539
4557
  continue; // spawn-mode: no live pane to warm
4540
4558
  if (a.migrationWatcher)
@@ -4961,6 +4979,14 @@ export const startMirror = (deps) => {
4961
4979
  a.pendingOrigin = { origin, at: Date.now() };
4962
4980
  if (opts?.from)
4963
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;
4964
4990
  const sid = a.sessionId;
4965
4991
  const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
4966
4992
  if (!paneAlive) {
@@ -4968,7 +4994,7 @@ export const startMirror = (deps) => {
4968
4994
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
4969
4995
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
4970
4996
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
4971
- 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 }) });
4972
4998
  if (!r.ok || !r.tmuxPane)
4973
4999
  return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
4974
5000
  a.tmuxPane = r.tmuxPane;
@@ -5070,6 +5096,61 @@ export const startMirror = (deps) => {
5070
5096
  log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
5071
5097
  return { ok: true };
5072
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
+ },
5073
5154
  submitPane: async (target) => {
5074
5155
  const a = byTarget.get(target);
5075
5156
  if (!a)
@@ -5151,6 +5232,8 @@ export const startMirror = (deps) => {
5151
5232
  },
5152
5233
  getCwd,
5153
5234
  setPendingCwd,
5235
+ cwdUnconfirmed,
5236
+ confirmCwd,
5154
5237
  newSession,
5155
5238
  cloneSession,
5156
5239
  sessionInfo,
@@ -5170,7 +5253,11 @@ export const startMirror = (deps) => {
5170
5253
  bySessionId.clear();
5171
5254
  byTarget.clear();
5172
5255
  },
5173
- 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 }) => {
5174
5261
  // Route by inbound principal — the WeCom chat the user just messaged us
5175
5262
  // from is the same string we registered as `target` on attach. After a
5176
5263
  // daemon reload the in-memory map is empty; restore from the persisted
@@ -5206,6 +5293,8 @@ export const startMirror = (deps) => {
5206
5293
  }
5207
5294
  }
5208
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() };
5209
5298
  // Finalize prior live stream (if any) so this new turn renders into its
5210
5299
  // own message bubble. Then open a fresh stream tied to the new frame and
5211
5300
  // ack immediately so WeCom doesn't time out while inject queues.
@@ -5227,10 +5316,8 @@ export const startMirror = (deps) => {
5227
5316
  if (a.liveStream && !a.liveStream.closed)
5228
5317
  await finalizeStream(a, a.liveStream);
5229
5318
  log.info({ target: a.target, runningCwd: a.runningCwd, pendingCwd: pending }, "/clear upgraded to /new (cwd switch)");
5230
- // Prefer the tag suffix as tmux window name when present (matches
5231
- // /new #tag behavior), fall back to the full target for untagged.
5232
- const tag = tagOfKey(a.target);
5233
- 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);
5234
5321
  if (!r.ok) {
5235
5322
  try {
5236
5323
  await client.replyStream(frame, streamId, withTagHeader(a.target, `[mirror] 切换失败: ${r.reason ?? "unknown"}`), true);
@@ -5336,7 +5423,7 @@ export const startMirror = (deps) => {
5336
5423
  const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
5337
5424
  // Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
5338
5425
  // mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
5339
- 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 }) });
5340
5427
  if (r.ok && r.tmuxPane && r.tmuxSession) {
5341
5428
  a.tmuxPane = r.tmuxPane;
5342
5429
  a.tmuxSession = r.tmuxSession;