wezard 1.3.3 → 1.3.7

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.
@@ -20,12 +20,13 @@ import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "
20
20
  import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer } from "../shared/modal-pane.js";
21
21
  import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow } from "./approval.js";
22
22
  import { runTmux as runTmuxCmd, spawnTmuxClaude } from "./spawn-tmux.js";
23
+ import { startSubagentWatch } from "./subagent-tail.js";
23
24
  import { recordTool, recordToolResult, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
24
- import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader } from "../shared/session-label.js";
25
+ import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, parseTagHeader } from "../shared/session-label.js";
25
26
  import { splitMarkdown } from "../shared/md-chunk.js";
26
27
  import { randomTip } from "./tips.js";
27
28
  import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peerAddress } from "./chat-name.js";
28
- import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
29
+ import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, limitResetAt, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
29
30
  // Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
30
31
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
31
32
  const NODE_BIN_DIR = dirname(process.execPath);
@@ -465,11 +466,17 @@ const renderLine = (raw, deps) => {
465
466
  const goal = goalStartOf(c);
466
467
  if (goal)
467
468
  return [goal];
468
- if (!deps.includeUser)
469
- return [];
470
469
  const text = cleanUserText(c);
471
470
  if (!text)
472
471
  return []; // pure slash-command meta / stdout — drop
472
+ // Keepalive ping user line: judged by CONTENT, before every other gate
473
+ // (includeUser / isOwnInject's 60s TTL) — the ping never echoes, and
474
+ // the reply turn is swallowed by content in onItem. Timer windows can't
475
+ // cover reloads or replays; the user message itself always can.
476
+ if (deps.isKeepalivePing?.(text))
477
+ return [{ kind: "keepalive_start", body: text }];
478
+ if (!deps.includeUser)
479
+ return [];
473
480
  if (deps.isOwnInject(text))
474
481
  return []; // dedupe WeCom→CLI echo
475
482
  const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
@@ -770,6 +777,7 @@ export const startMirrorTail = (deps) => {
770
777
  clearInterval(poll);
771
778
  },
772
779
  drain,
780
+ livePath: resolveLive,
773
781
  };
774
782
  };
775
783
  // Run a tmux subcommand, capturing stdout/stderr. Delegates to spawn-tmux's
@@ -1414,6 +1422,19 @@ export const startMirror = (deps) => {
1414
1422
  recentAssistantSends.splice(i, 1); // one-shot: a genuine later re-say still streams
1415
1423
  return true;
1416
1424
  };
1425
+ // Keepalive ping detector for the tail's user-line judgment — the SAME
1426
+ // normalized-prefix signature set keepaliveTick feeds keepaliveStamps
1427
+ // (every configured form + the bare "ping" legacy streak form), so
1428
+ // renderLine, the tick and the transcript parser all agree on what counts
1429
+ // as a ping. Content is the source of truth for the swallow: timers and
1430
+ // echo-TTLs lose it across reloads/replays.
1431
+ const keepalivePingSigs = [cfg.wrc.mirror.keepalive.ping, cfg.wrc.mirror.keepalive.resumePing]
1432
+ .map((p) => normAssistant(p).slice(0, 40))
1433
+ .filter((s) => s.length > 0);
1434
+ const isKeepalivePing = (text) => {
1435
+ const n = normAssistant(text);
1436
+ return n.toLowerCase() === "ping" || keepalivePingSigs.some((sig) => n.includes(sig));
1437
+ };
1417
1438
  // ── Typewriter stream lifecycle ────────────────────────────────────
1418
1439
  // WeCom spec: server polls us for stream refreshes for up to 6 min from the
1419
1440
  // original inbound. SDK queues replyStream calls per req_id, sends serially
@@ -1480,6 +1501,9 @@ export const startMirror = (deps) => {
1480
1501
  if (s.closed)
1481
1502
  return;
1482
1503
  s.closed = true;
1504
+ // 非 brief 路径的父 turn 收口 — subagent turns 一并关 (brief 路径在
1505
+ // closeBriefTurn 关, 双关幂等)。
1506
+ closeSubagentTurns(a);
1483
1507
  if (s.flushTimer) {
1484
1508
  clearTimeout(s.flushTimer);
1485
1509
  s.flushTimer = undefined;
@@ -1558,7 +1582,14 @@ export const startMirror = (deps) => {
1558
1582
  }
1559
1583
  return withSessionTag(a.target, content, seq);
1560
1584
  };
1585
+ // 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`🦊 #tag` / `[🧙](url)`)
1586
+ // 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
1587
+ // 防线; 这里拦在源头 —— 空内容不进 standalonePending FIFO、不占防抖 buf、
1588
+ // 不重置计时器, 免得"空 part 入队 → flush 时 join 出空串"的死角。
1589
+ const hasVisibleBody = (content) => parseTagHeader(content).body.length > 0;
1561
1590
  const sendStandalone = (a, content) => {
1591
+ if (!hasVisibleBody(content))
1592
+ return;
1562
1593
  const chatId = stripPrincipalPrefix(a.target);
1563
1594
  const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
1564
1595
  const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
@@ -1577,6 +1608,8 @@ export const startMirror = (deps) => {
1577
1608
  };
1578
1609
  // Like sendStandalone but skips withSessionTag — content already contains the tag header (e.g. as a link).
1579
1610
  const sendRaw = (a, content) => {
1611
+ if (!hasVisibleBody(content))
1612
+ return;
1580
1613
  const chatId = stripPrincipalPrefix(a.target);
1581
1614
  a.standalonePending = a.standalonePending
1582
1615
  .then(async () => {
@@ -1614,16 +1647,20 @@ export const startMirror = (deps) => {
1614
1647
  };
1615
1648
  // Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 → 合并成单条 markdown。
1616
1649
  // 0 关闭时退化为透传。flushStandalone 也用于 detach / teardown 时的 drain。
1650
+ // parts 在 enqueue 时已过 hasVisibleBody, flush 端再过滤一遍是纵深防御 ——
1651
+ // 防的是绕过 enqueue 直写 buf 的未来代码路径。
1617
1652
  const flushStandalone = (a) => {
1618
1653
  const buf = a.standaloneBuf;
1619
1654
  if (!buf)
1620
1655
  return;
1621
- const merged = buf.parts.join("\n\n");
1622
1656
  a.standaloneBuf = undefined;
1657
+ const merged = buf.parts.filter((p) => hasVisibleBody(p)).join("\n\n");
1623
1658
  if (merged)
1624
1659
  sendStandalone(a, merged);
1625
1660
  };
1626
1661
  const enqueueStandalone = (a, content) => {
1662
+ if (!hasVisibleBody(content))
1663
+ return;
1627
1664
  const ms = cfg.wrc.mirror.standaloneDebounceMs;
1628
1665
  if (ms <= 0) {
1629
1666
  sendStandalone(a, content);
@@ -1818,7 +1855,7 @@ export const startMirror = (deps) => {
1818
1855
  // 每个 turn 挂一条气泡: ack 时就写进 `tag 详情链接 …` (finish=false, 不立刻关掉),
1819
1856
  // 让群里从收到消息那一刻起就有详情页入口。收口时:
1820
1857
  // • 正文到位 → 以 `链接 正文` 覆盖这条气泡。
1821
- // • 始终没正文 / 过 6min 窗口 → ack 时那条纯 `链接` 收口。
1858
+ // • 始终没正文 / 过 6min 窗口 → 以最新 CoT 进度行 (没有则纯 `链接`) 收口。
1822
1859
  // • 气泡已收口或无气泡 turn (CLI 侧发起) → 正文另发一条 standalone。
1823
1860
  // 其它所有 item 只写入 turn detail store。
1824
1861
  // 能证明"assistant 已经在产出"的 item —— 见到它们才补开无气泡 turn。
@@ -1857,6 +1894,7 @@ export const startMirror = (deps) => {
1857
1894
  // 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
1858
1895
  if (!text || !b || b.done || !turnId || a.briefConcluded)
1859
1896
  return;
1897
+ a.cotLastSent = text;
1860
1898
  try {
1861
1899
  await client.replyStream(b.frame, b.streamId, `${briefDetailLink(turnId, a.target)} \`${text}\``, false);
1862
1900
  }
@@ -1951,8 +1989,11 @@ export const startMirror = (deps) => {
1951
1989
  const bubble = { frame, streamId, hardTimer: undefined, done: false };
1952
1990
  const q = { turnId, bubble, isSlash };
1953
1991
  bubble.hardTimer = setTimeout(() => {
1954
- // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。
1955
- void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
1992
+ // WeCom ~6min stream 窗口快到, 必须 finish=true 收口。没正文时不能拿光链接
1993
+ // 覆盖 —— finish 是整条替换, 会把屏幕上的 CoT 进度行抹成光秃秃的链接; 改用
1994
+ // 最新进度行定格 (a 上的 CoT 只在气泡仍是本轮活跃气泡时才可信, 换轮后归新轮)。
1995
+ const cot = a.briefBubble === bubble && !a.briefConcluded ? a.cotText ?? a.cotLastSent : undefined;
1996
+ void finishBubble(a, bubble, `${briefDetailLink(turnId, a.target)}${cot ? ` \`${cot}\`` : ""}`, true);
1956
1997
  }, HARD_TIMEOUT_MS);
1957
1998
  // 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
1958
1999
  // closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
@@ -1992,7 +2033,9 @@ export const startMirror = (deps) => {
1992
2033
  a.briefConcluded = false;
1993
2034
  a.briefLastText = undefined;
1994
2035
  clearCot(a);
1995
- sendRaw(a, briefDetailLink(turnId, a.target));
2036
+ // 链接暂存, 不再单独发 — 让首条 standalone body 取走拼到前缀, 一条消息解决。
2037
+ // turn 始终会通过 concludeBriefTurn / closeBriefTurn 收口, header 不会泄漏。
2038
+ a.pendingBriefHeader = briefDetailLink(turnId, a.target);
1996
2039
  log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
1997
2040
  };
1998
2041
  // 本轮出现过工具调用 / tool_result / 非 final 文本 —— 只记状态: 详情链接从 ack 起
@@ -2003,19 +2046,28 @@ export const startMirror = (deps) => {
2003
2046
  a.briefHadTool = true;
2004
2047
  };
2005
2048
  // 本轮结论落地, 只生效一次:
2006
- // • 气泡仍开 (ack 时的 `链接 …` 还在) → 以 `链接 正文` 覆盖收口;
2007
- // 气泡已收 (过 6min 窗口 / 已收口) 或无气泡 turn (CLI 侧发起) → body 走 standalone,
2008
- // 那条 standalone 自带 `链接` (withLinkedTag / ensureBriefTurn 已发过详情链接)。
2049
+ // • 气泡仍开 (ack 时的 `链接 …` 还在) → 以 `链接 正文` 覆盖收口; ensureBriefTurn
2050
+ // 暂存的 header 跟着 bubble 收口一并丢 (气泡内已自带链接)
2051
+ // 气泡已收 (过 6min 窗口 / 已收口) 或无气泡 turn (CLI 侧发起) 把暂存 header
2052
+ // 拼到 body 前缀一并 standalone, 一条消息含详情入口 + 正文。
2053
+ // • body 为空 → 一个字不发, 顺手清掉 header, 不留"只有链接"的空消息。
2009
2054
  const concludeBriefTurn = (a, body) => {
2010
2055
  const turnId = a.briefTurnId;
2011
- if (!turnId || a.briefConcluded || !body.trim())
2056
+ if (!turnId || a.briefConcluded)
2057
+ return;
2058
+ if (!body.trim()) {
2059
+ a.pendingBriefHeader = undefined;
2012
2060
  return;
2061
+ }
2013
2062
  a.briefConcluded = true;
2014
2063
  if (a.briefBubble && !a.briefBubble.done) {
2015
2064
  void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
2065
+ a.pendingBriefHeader = undefined;
2016
2066
  }
2017
2067
  else {
2018
- sendStandalone(a, body);
2068
+ const header = a.pendingBriefHeader;
2069
+ a.pendingBriefHeader = undefined;
2070
+ sendStandalone(a, header ? `${header} ${body}` : body);
2019
2071
  }
2020
2072
  // 只保留当前 turn: 其余仍开着的 turn 记录是漏收的 close, 一并扫掉。
2021
2073
  recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: [turnId] });
@@ -2037,8 +2089,123 @@ export const startMirror = (deps) => {
2037
2089
  a.briefIsSlash = false;
2038
2090
  a.briefConcluded = false;
2039
2091
  a.briefLastText = undefined;
2092
+ a.pendingBriefHeader = undefined;
2093
+ closeSubagentTurns(a); // 父 turn 收口 = 它派出的 subagent 都已结束
2040
2094
  clearCot(a);
2041
2095
  };
2096
+ // ── Subagent turns ─────────────────────────────────────────────────
2097
+ // Task/Agent 工具派出的子 agent 在自己的转录文件里跑 (主 jsonl 只有派发与最终
2098
+ // 总结)。subagent-tail 把过程流式吐到这里: 记成带 agent 标记的 turn (chat
2099
+ // detail 时间轴内联展示), 并在 brief 气泡里以 `🤖 label · 最新活动` 驱动 CoT
2100
+ // 进度行 —— 父 agent 阻塞在 Agent 调用上时, 子 agent 就是唯一的进度来源,
2101
+ // last-writer-wins 让"最后的消息是 agent 调用"期间气泡展示的正是 agent 内状态。
2102
+ // agent 类型归属: claude 的 meta.json 优先; codebuddy 没有 meta — 用父侧捕获的
2103
+ // Task/Agent 入参按 prompt 原文对上 (子文件首行 user 内容 == input.prompt,
2104
+ // codebuddy 实测逐字一致), 对不上再退到最近一次调用。
2105
+ // 返回值同喂两条消费方: detail 记录的 agent.type/description 与 brief 进度行的
2106
+ // label —— 二者必须同源, 否则"支持 codebuddy"时页面上叫 `🤖 subagent`、气泡里却
2107
+ // 叫解析出的类型, 自相矛盾。undefined = 无从归属 (老文件无 meta 且父侧无调用)。
2108
+ const resolveSubagentMeta = (a, task, meta) => {
2109
+ if (meta.type || meta.description)
2110
+ return { ...meta, label: meta.type ?? meta.description.slice(0, 24) };
2111
+ const calls = a.agentCalls ?? [];
2112
+ const hit = calls.find((c) => c.prompt && (c.prompt === task || task.startsWith(c.prompt) || c.prompt.startsWith(task)));
2113
+ const m = hit ?? [...calls].reverse().find((c) => Date.now() - c.at < 10 * 60_000);
2114
+ if (!m)
2115
+ return undefined;
2116
+ const { type, description } = m;
2117
+ return { type, description, label: type ?? description?.slice(0, 24) ?? "subagent" };
2118
+ };
2119
+ const closeSubagentRun = (a, agentId) => {
2120
+ const run = a.subagents?.get(agentId);
2121
+ if (!run || run.closed)
2122
+ return;
2123
+ run.closed = true;
2124
+ recordTurnClose(run.turnId);
2125
+ };
2126
+ // 关掉该 attachment 所有开着的 subagent turn。父 turn 收口 (closeBriefTurn /
2127
+ // finalizeStream / detach / migrate) 时调用 — subagent 必然先于父 turn 结束,
2128
+ // 父都收了, 子的开着只会让页面永远「运行中」。
2129
+ const closeSubagentTurns = (a) => {
2130
+ for (const id of a.subagents?.keys() ?? [])
2131
+ closeSubagentRun(a, id);
2132
+ };
2133
+ const handleSubagentItem = (a, agentId, item) => {
2134
+ if (!a.subagents)
2135
+ a.subagents = new Map();
2136
+ let run = a.subagents.get(agentId);
2137
+ const now = Date.now();
2138
+ // 首个 item 懒建 turn — task 行建在开头 (userQuery=任务原文), EOF 起步的存量
2139
+ // agent 任何 item 都能建 (userQuery 缺省), 不丢过程。
2140
+ if (!run && item.kind !== "end") {
2141
+ const task = item.kind === "task" ? item.body : undefined;
2142
+ const meta = item.kind === "task" ? item.meta : {};
2143
+ const resolved = resolveSubagentMeta(a, task ?? "", meta);
2144
+ const turnId = newTurnId();
2145
+ run = { turnId, label: resolved?.label ?? "subagent", closed: false };
2146
+ a.subagents.set(agentId, run);
2147
+ recordTurnStart({
2148
+ id: turnId,
2149
+ target: a.target,
2150
+ sessionId: a.sessionId,
2151
+ cwd: a.runningCwd || undefined,
2152
+ userQuery: task,
2153
+ agent: { id: agentId, type: resolved?.type, description: resolved?.description },
2154
+ });
2155
+ log.info({ agentId, turnId, label: run.label }, "subagent: turn started");
2156
+ if (item.kind === "task")
2157
+ return;
2158
+ }
2159
+ if (!run || run.closed)
2160
+ return;
2161
+ switch (item.kind) {
2162
+ case "task":
2163
+ return; // 已在建 turn 时消费
2164
+ case "text":
2165
+ recordTurnItem(run.turnId, { t: "text", body: item.body, ts: now, final: item.final });
2166
+ break;
2167
+ case "tool_use":
2168
+ for (const c of item.calls) {
2169
+ recordTurnItem(run.turnId, { t: "tool_use", toolUseId: c.toolUseId, toolName: c.name, toolInput: c.input, ts: now });
2170
+ }
2171
+ break;
2172
+ case "tool_result":
2173
+ recordTurnItem(run.turnId, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
2174
+ break;
2175
+ case "usage":
2176
+ recordTurnUsage(run.turnId, { model: item.model, messageId: item.messageId, usage: item.usage });
2177
+ break;
2178
+ case "end":
2179
+ closeSubagentRun(a, agentId);
2180
+ return;
2181
+ }
2182
+ // brief 气泡的实时进度: 静默窗 (keepalive 吞没 / 新 pane 未注入) 不推;
2183
+ // updateBriefProgress 自身会检查气泡是否仍开。final text 之后的 end 会很快
2184
+ // 收口气泡, 进度行不会残留。
2185
+ if (a.muteUntilInject || a.keepaliveQuiet || a.keepaliveByContent)
2186
+ return;
2187
+ const activity = item.kind === "tool_use"
2188
+ ? cotToolLabel(item.calls)
2189
+ : item.kind === "thinking" || item.kind === "text"
2190
+ ? item.body
2191
+ : "";
2192
+ if (activity)
2193
+ updateBriefProgress(a, `🤖 ${run.label} · ${activity}`);
2194
+ };
2195
+ // (重新) 挂上 subagent watch — attach 与 migrateAttachment 共用; 会话轮换后
2196
+ // sessionId 变了, 旧 watch 的目录定位随之作废, 必须重建。
2197
+ const startSubagentsFor = (a) => {
2198
+ a.subagentWatch?.stop();
2199
+ closeSubagentTurns(a);
2200
+ a.subagents = new Map();
2201
+ a.subagentWatch = startSubagentWatch({
2202
+ log: log.child({ sub: "subagents", sessionId: a.sessionId }),
2203
+ sessionId: a.sessionId,
2204
+ liveJsonlPath: () => a.tail.livePath(),
2205
+ normalizeLine: backendForPath(a.jsonlPath).normalizeTranscriptLine,
2206
+ onAgentItem: (agentId, item) => handleSubagentItem(a, agentId, item),
2207
+ });
2208
+ };
2042
2209
  // 强制收口一个 attachment 的全部出站通道, 并返回收掉的气泡/流条数。
2043
2210
  //
2044
2211
  // 关键约束: **一个 tmux 调用都不许有**。它的两个调用方 (`/stop` 和 inject
@@ -2057,6 +2224,7 @@ export const startMirror = (deps) => {
2057
2224
  clearTimeout(a.softEnd);
2058
2225
  a.softEnd = undefined;
2059
2226
  }
2227
+ closeSubagentTurns(a); // brief/liveStream 都不在的收口路径也把 subagent 关掉
2060
2228
  if (a.outbound) {
2061
2229
  if (a.outbound.kind === "deferred")
2062
2230
  clearTimeout(a.outbound.timer);
@@ -2169,9 +2337,14 @@ export const startMirror = (deps) => {
2169
2337
  // loading 气泡。非 slash 场景的 skill_output 是用户可见的中间反馈, 走 standalone。
2170
2338
  if (a.briefIsSlash && !a.briefHadTool && a.briefBubble && !a.briefBubble.done) {
2171
2339
  void finishBriefBubble(a, item.body);
2340
+ a.pendingBriefHeader = undefined;
2172
2341
  }
2173
2342
  else {
2174
- sendStandalone(a, item.body);
2343
+ // CLI-driven turn: 把 ensureBriefTurn 暂存的详情链接拼到 body 前缀, 避免
2344
+ // "只发了一条 link 渲染成空文本" 的前置消息。
2345
+ const header = a.pendingBriefHeader;
2346
+ a.pendingBriefHeader = undefined;
2347
+ sendStandalone(a, header ? `${header} ${item.body}` : item.body);
2175
2348
  }
2176
2349
  return;
2177
2350
  }
@@ -2232,13 +2405,41 @@ export const startMirror = (deps) => {
2232
2405
  if (a.liveStream && !a.liveStream.closed)
2233
2406
  void finalizeStream(a, a.liveStream);
2234
2407
  };
2235
- // Close the keepalive quiet window — the ping turn is over (or the fail-safe
2236
- // fired), so subsequent real items flow through onItem normally again.
2237
- const endKeepaliveQuiet = (a) => {
2238
- if (!a.keepaliveQuiet)
2239
- return;
2240
- clearTimeout(a.keepaliveQuiet);
2241
- a.keepaliveQuiet = undefined;
2408
+ // End the keepalive swallow — the ping turn is over (turn_end / genuine
2409
+ // user line / inject failure), so real items flow through onItem again.
2410
+ // Clears BOTH the timer window and the content flag.
2411
+ const endKeepaliveSwallow = (a) => {
2412
+ if (a.keepaliveQuiet) {
2413
+ clearTimeout(a.keepaliveQuiet);
2414
+ a.keepaliveQuiet = undefined;
2415
+ }
2416
+ if (a.keepaliveContentTimer) {
2417
+ clearTimeout(a.keepaliveContentTimer);
2418
+ a.keepaliveContentTimer = undefined;
2419
+ }
2420
+ a.keepaliveByContent = undefined;
2421
+ };
2422
+ // Content-based swallow entry: the tailed user line IS a configured ping.
2423
+ // Belt to the inject-time timer window — this one keys on the user message
2424
+ // itself, so it holds across daemon reloads, tail replays and replies
2425
+ // slower than the 60s fail-safe. Reuses the detail turn the timer flow may
2426
+ // have opened (fireKeepalive skips opening when this got there first).
2427
+ const KEEPALIVE_CONTENT_FAILSAFE_MS = 5 * 60_000;
2428
+ const beginKeepaliveByContent = (a, text) => {
2429
+ a.keepaliveByContent = true;
2430
+ if (a.keepaliveContentTimer)
2431
+ clearTimeout(a.keepaliveContentTimer);
2432
+ // Fail-safe: a ping turn whose reply never emits turn_end (crash/hang)
2433
+ // must not mute a later real turn forever.
2434
+ a.keepaliveContentTimer = setTimeout(() => {
2435
+ a.keepaliveContentTimer = undefined;
2436
+ a.keepaliveByContent = undefined;
2437
+ }, KEEPALIVE_CONTENT_FAILSAFE_MS);
2438
+ if (!a.keepaliveTurnId) {
2439
+ const turnId = newTurnId();
2440
+ a.keepaliveTurnId = turnId;
2441
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
2442
+ }
2242
2443
  };
2243
2444
  // A keepalive ping turn is swallowed wholesale — the reply content doesn't
2244
2445
  // matter (model may add extra commentary beyond "pong", that's fine).
@@ -2246,12 +2447,24 @@ export const startMirror = (deps) => {
2246
2447
  // 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
2247
2448
  if (a.muteUntilInject)
2248
2449
  return;
2249
- // Keepalive ping turns are cache-warmers: swallow every item from the WeCom
2250
- // paths so the ping/pong never reaches chat. But record the REAL exchange
2251
- // into its chat-detail turn — the actual assistant reply, the tool calls if
2252
- // any, and the usage (proof it was a cheap cache-read) so the detail page
2253
- // shows the genuine heartbeat, not a synthetic summary.
2254
- if (a.keepaliveQuiet) {
2450
+ // Keepalive ping turns are cache-warmers: swallow every item from every
2451
+ // WeCom path so the ping/pong never reaches chat. Two triggers, either
2452
+ // suffices — the timer window opened at inject (keepaliveQuiet) OR the
2453
+ // user line itself matching a configured ping (keepaliveByContent; the
2454
+ // reload/replay/slow-reply-proof one). The REAL exchange is recorded
2455
+ // into its chat-detail turn — the actual assistant reply, the tool calls
2456
+ // if any, and the usage — so the detail page shows the genuine heartbeat.
2457
+ if (item.kind === "keepalive_start") {
2458
+ beginKeepaliveByContent(a, item.body);
2459
+ return;
2460
+ }
2461
+ // A genuine user line (not our inject, not a ping) is a hard turn
2462
+ // boundary — any keepalive swallow still holding never got its turn_end
2463
+ // (crashed reply). Release BEFORE the swallow gate so this real line (and
2464
+ // its reply) mirror normally instead of being eaten by the stale swallow.
2465
+ if (item.kind === "user_text")
2466
+ endKeepaliveSwallow(a);
2467
+ if (a.keepaliveQuiet || a.keepaliveByContent) {
2255
2468
  const id = a.keepaliveTurnId;
2256
2469
  if (id) {
2257
2470
  const now = Date.now();
@@ -2270,7 +2483,7 @@ export const startMirror = (deps) => {
2270
2483
  }
2271
2484
  }
2272
2485
  if (item.kind === "turn_end") {
2273
- endKeepaliveQuiet(a);
2486
+ endKeepaliveSwallow(a);
2274
2487
  }
2275
2488
  return;
2276
2489
  }
@@ -2310,6 +2523,17 @@ export const startMirror = (deps) => {
2310
2523
  break;
2311
2524
  a.recentToolSigs.delete(oldest);
2312
2525
  }
2526
+ // Task/Agent 派发入参留档 — subagent 文件本身不带类型信息 (codebuddy),
2527
+ // subagentLabel 拿它按 prompt 匹配出 agent 类型。
2528
+ if (c.name === "Task" || c.name === "Agent") {
2529
+ const inp = c.input;
2530
+ a.agentCalls = [...(a.agentCalls ?? []).slice(-8), {
2531
+ prompt: typeof inp?.prompt === "string" ? inp.prompt : "",
2532
+ type: typeof inp?.subagent_type === "string" && inp.subagent_type ? inp.subagent_type : undefined,
2533
+ description: typeof inp?.description === "string" && inp.description ? inp.description : undefined,
2534
+ at: Date.now(),
2535
+ }];
2536
+ }
2313
2537
  // codebuddy 的 ExitPlanMode 完全不过 PreToolUse hook (实测: 由
2314
2538
  // interruption-service 本地对话框裁决, HookExecutor 零调用)。mirror 从
2315
2539
  // jsonl 看到 function_call → 发计划审批卡; 点选后 send-keys 裁决本地
@@ -2542,6 +2766,9 @@ export const startMirror = (deps) => {
2542
2766
  clearTimeout(a.standaloneBuf.timer);
2543
2767
  flushStandalone(a);
2544
2768
  }
2769
+ a.subagentWatch?.stop();
2770
+ a.subagentWatch = undefined;
2771
+ closeSubagentTurns(a);
2545
2772
  a.tail.stop();
2546
2773
  bySessionId.delete(a.sessionId);
2547
2774
  if (byTarget.get(a.target) === a)
@@ -2596,7 +2823,7 @@ export const startMirror = (deps) => {
2596
2823
  // collapsing to cfg.wrc.cwd (which would mislabel /pwd, /clear, /new).
2597
2824
  runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
2598
2825
  pendingCwd: carryPending,
2599
- tail: { stop: () => undefined, drain: () => undefined }, // placeholder; replaced below
2826
+ tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
2600
2827
  standalonePending: Promise.resolve(),
2601
2828
  recentToolSigs: new Map(),
2602
2829
  };
@@ -2610,6 +2837,7 @@ export const startMirror = (deps) => {
2610
2837
  toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
2611
2838
  isOwnInject,
2612
2839
  isOwnAssistantSend,
2840
+ isKeepalivePing,
2613
2841
  onItem: (item) => onItem(a, item),
2614
2842
  detailUrlFor,
2615
2843
  sessionId,
@@ -2618,6 +2846,7 @@ export const startMirror = (deps) => {
2618
2846
  // what lets a claude session and a codebuddy session be mirrored at once.
2619
2847
  normalizeLine: backendForPath(jsonlPath).normalizeTranscriptLine,
2620
2848
  });
2849
+ startSubagentsFor(a);
2621
2850
  bySessionId.set(sessionId, a);
2622
2851
  byTarget.set(target, a);
2623
2852
  // Preserve a persisted `/stop` pause across the re-attach: restore rebuilds the
@@ -2991,6 +3220,7 @@ export const startMirror = (deps) => {
2991
3220
  toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
2992
3221
  isOwnInject,
2993
3222
  isOwnAssistantSend,
3223
+ isKeepalivePing,
2994
3224
  onItem: (item) => onItem(a, item),
2995
3225
  detailUrlFor,
2996
3226
  sessionId: newSessionId,
@@ -3000,6 +3230,8 @@ export const startMirror = (deps) => {
3000
3230
  // path, not by CLI) — re-derive the dialect from the destination.
3001
3231
  normalizeLine: backendForPath(newJsonlPath).normalizeTranscriptLine,
3002
3232
  });
3233
+ // 会话轮换 = 新 <sid>/subagents/ 目录; 旧 run 的 turn 一并收口。
3234
+ startSubagentsFor(a);
3003
3235
  deps.store.set(a.target, {
3004
3236
  sessionId: newSessionId,
3005
3237
  jsonlPath: newJsonlPath,
@@ -3289,33 +3521,13 @@ export const startMirror = (deps) => {
3289
3521
  // from the running cwd). Decoupling means /pwd can show truth even after
3290
3522
  // the AI sets a new path but the user hasn't /new'd yet.
3291
3523
  const expandedDefaultCwd = expandHome(cfg.wrc.cwd);
3292
- // Long absolute paths wrap in the WeCom bubble — show only the trailing
3293
- // three segments, which is enough to identify the project.
3294
- const shortCwd = (p) => p.split("/").filter(Boolean).slice(-3).join("/");
3295
- const renderProjectInfo = (target) => {
3296
- const a = byTarget.get(target);
3297
- const rec = a ? undefined : deps.store.get(target);
3298
- const running = (a?.runningCwd?.trim()) || rec?.cwd?.trim() || expandedDefaultCwd;
3299
- // pendingCwd is chat-scoped — the queued switch applies to every session
3300
- // in this chat, so read it from the shared base slot rather than the
3301
- // caller's own (now-empty) attachment record.
3302
- const pending = chatCwdFallback(target).pending;
3303
- const lines = [`📂 cwd: \`${shortCwd(running)}\``];
3304
- if (pending && pending !== running) {
3305
- lines.push(`下次切换: \`${shortCwd(pending)}\` (使用 /new 或 /clear 生效)`);
3306
- }
3307
- // Session-boundary footer — `/new` and `/clear` are the only two callers,
3308
- // so the tip lands exactly once per fresh context, never mid-conversation.
3309
- lines.push(randomTip());
3310
- return lines.join("\n");
3311
- };
3312
3524
  // `header` folds the caller's ack ("created") into this same bubble — /new
3313
3525
  // must land as exactly ONE WeCom message, not card + separate reply.
3314
- // slashAckFirstLine: 会话边界回执只保留首行 ack, 📂 项目信息与 💡 tip 省去。
3526
+ // 会话边界回执一律不携带 cwd 信息 (📂 项目行 / 下次切换), 需要时 `/pwd` 可查。
3527
+ // slashAckFirstLine 进一步省去 💡 tip, 只剩首行 ack。
3315
3528
  const pushProjectInfo = (target, header) => {
3316
3529
  const concise = cfg.wrc.mirror.slashAckFirstLine && !!header;
3317
- const info = concise ? "" : renderProjectInfo(target);
3318
- const md = header ? (info ? `${header}\n\n${info}` : header) : info;
3530
+ const md = header ? (concise ? header : `${header}\n\n${randomTip()}`) : randomTip();
3319
3531
  const a = byTarget.get(target);
3320
3532
  if (a) {
3321
3533
  sendStandalone(a, md);
@@ -3332,7 +3544,7 @@ export const startMirror = (deps) => {
3332
3544
  // (default + any `#tag` siblings) share one cwd/pendingCwd, tracked on the
3333
3545
  // BASE principal's byTarget/store record. Tagged sessions still have their
3334
3546
  // own `runningCwd` (the tmux pane's actual working dir at spawn time), but
3335
- // cwd fallbacks and `cd` pendingCwd writes always resolve against the base.
3547
+ // cwd fallbacks and `set_workspace` pendingCwd writes always resolve against the base.
3336
3548
  const chatCwdFallback = (target) => {
3337
3549
  const base = basePrincipalOf(target);
3338
3550
  const baseA = byTarget.get(base);
@@ -3351,7 +3563,7 @@ export const startMirror = (deps) => {
3351
3563
  // base.pending > caller.pending > target.running > base.running > default
3352
3564
  // A fresh tagged session inherits the chat's current cwd; re-`/new`ing a
3353
3565
  // live tagged session keeps its pane cwd unless the base session queued a
3354
- // `cd`. This keeps siblings aligned by default without forcibly clobbering
3566
+ // cwd switch. This keeps siblings aligned by default without forcibly clobbering
3355
3567
  // an already-spawned tagged pane on every base-cwd change.
3356
3568
  const chat = chatCwdFallback(target);
3357
3569
  const rec = !prev ? deps.store.get(target) : undefined;
@@ -3449,9 +3661,9 @@ export const startMirror = (deps) => {
3449
3661
  return { ok: true, sessionId: r.sessionId, cwd: r.cwd };
3450
3662
  };
3451
3663
  const getCwd = (target) => {
3452
- // pendingCwd is chat-scoped — a `cd` from any sibling session queues the
3453
- // switch for the whole chat. runningCwd stays per-session (each pane has
3454
- // its own spawn dir).
3664
+ // pendingCwd is chat-scoped — a `set_workspace` from any sibling session
3665
+ // queues the switch for the whole chat. runningCwd stays per-session (each
3666
+ // pane has its own spawn dir).
3455
3667
  const chat = chatCwdFallback(target);
3456
3668
  const pending = chat.pending;
3457
3669
  const a = byTarget.get(target);
@@ -3463,9 +3675,9 @@ export const startMirror = (deps) => {
3463
3675
  return { runningCwd: chat.running || expandedDefaultCwd, pendingCwd: pending, defaultCwd: expandedDefaultCwd };
3464
3676
  };
3465
3677
  // Write `pendingCwd` to the BASE principal so the switch applies chat-wide —
3466
- // the next /new in any tagged/untagged session picks it up. `cd` from a
3467
- // tagged session still writes to the shared slot, not the tagged session's
3468
- // own record, matching "sessions share the chat's cwd".
3678
+ // the next /new in any tagged/untagged session picks it up. `set_workspace`
3679
+ // from a tagged session still writes to the shared slot, not the tagged
3680
+ // session's own record, matching "sessions share the chat's cwd".
3469
3681
  const setPendingCwd = (target, cwd) => {
3470
3682
  const trimmed = (cwd ?? "").trim();
3471
3683
  if (!trimmed)
@@ -3627,6 +3839,34 @@ export const startMirror = (deps) => {
3627
3839
  const p = jsonlOf(target);
3628
3840
  return p ? lastAssistantText(p) : "";
3629
3841
  };
3842
+ // ── 未收口气泡的引用判定 ────────────────────────────────────────────
3843
+ // tag 会话的 last stream 未收口时, 群里最新那条气泡是瞬态的: 详情链接 URL +
3844
+ // 最新一条实时 CoT/工具行 (brief) 或累积中的 acc (非 brief)。引用它要的是路由,
3845
+ // 不是把这些中间态贴回 prompt —— 判定命中 ⇒ 调用方只保留 tag, 正文丢弃。
3846
+ // URL 必须先于 canon 剥掉: canonForCompare 只剩字母数字, host/path 的字符会
3847
+ // 永远卡住比对。两侧同剥, 才在同一层级上比。
3848
+ const OPEN_QUOTE_URL_RE = /https?:\/\/\S+/g;
3849
+ const quoteCanon = (s) => s.replace(OPEN_QUOTE_URL_RE, " ").replace(/[^\p{L}\p{N}]/gu, "");
3850
+ const openBubbleBodies = (a) => {
3851
+ if (a.briefBubble && !a.briefBubble.done && !a.briefConcluded) {
3852
+ return [a.cotLastSent, a.cotText].filter((s) => !!s);
3853
+ }
3854
+ const s = a.liveStream;
3855
+ return s && !s.closed && !s.dead ? [s.acc, s.lastSent] : [];
3856
+ };
3857
+ const isOpenBubbleQuote = (target, quoted) => {
3858
+ const a = byTarget.get(target);
3859
+ if (!a)
3860
+ return false;
3861
+ const bodies = openBubbleBodies(a);
3862
+ if (bodies.length === 0)
3863
+ return false;
3864
+ const qc = quoteCanon(quoted);
3865
+ // 剥掉 URL 后什么都不剩 ⇒ 引用的只是链接头 + "…" 这类纯 chrome, 直接命中。
3866
+ if (!qc)
3867
+ return true;
3868
+ return bodies.some((b) => quoteCanon(b).includes(qc));
3869
+ };
3630
3870
  // 一个 target 的完整画像。本 chat 的兄弟和外 chat 的 peer 走同一条,只有
3631
3871
  // `self` / `address` 因观察者而异 —— 地址本来就是相对调用方说的话。
3632
3872
  const peerInfoOf = async (t, self) => {
@@ -3756,7 +3996,7 @@ export const startMirror = (deps) => {
3756
3996
  sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
3757
3997
  });
3758
3998
  if (!r.ok) {
3759
- endKeepaliveQuiet(a);
3999
+ endKeepaliveSwallow(a);
3760
4000
  if (a.keepalive)
3761
4001
  a.keepalive.pinging = false; // roll back so the next tick can retry
3762
4002
  log.warn({ target: a.target, reason: r.reason }, "keepalive: inject failed");
@@ -3769,9 +4009,13 @@ export const startMirror = (deps) => {
3769
4009
  // actual ping we injected; the assistant reply (expected: just "pong"), any
3770
4010
  // tool calls, and usage are grafted on from the swallowed items in onItem;
3771
4011
  // closed on the ping's turn_end (or the fail-safe). Kept out of chat.
3772
- const turnId = newTurnId();
3773
- a.keepaliveTurnId = turnId;
3774
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
4012
+ // The tail's keepalive_start (content match on the user line) may have
4013
+ // opened this turn already — only open when it hasn't.
4014
+ if (!a.keepaliveTurnId) {
4015
+ const turnId = newTurnId();
4016
+ a.keepaliveTurnId = turnId;
4017
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
4018
+ }
3775
4019
  };
3776
4020
  let keepaliveTicking = false;
3777
4021
  const keepaliveTick = async () => {
@@ -3865,8 +4109,10 @@ export const startMirror = (deps) => {
3865
4109
  // Stall recovery, decided by RULE only (no model self-judgment): the last
3866
4110
  // transcript turn is a synthetic API-error/limit line, or the idle pane
3867
4111
  // still shows an error banner ⇒ a turn died mid-work. Send the resume
3868
- // instruction instead of the plain warmer.
3869
- const stalled = kc.resumeOnStall && (transcriptStalled(a.jsonlPath) || paneIsStalled(paneTail));
4112
+ // instruction instead of the plain warmer. A limit-parked episode is
4113
+ // excluded: retrying before resetsAt only buys another 429 line —
4114
+ // limitResumeTick owns that recovery, anchored on the reset moment.
4115
+ const stalled = kc.resumeOnStall && !a.limitResume && (transcriptStalled(a.jsonlPath) || paneIsStalled(paneTail));
3870
4116
  k.pinging = true;
3871
4117
  k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
3872
4118
  await fireKeepalive(a, stalled);
@@ -3880,6 +4126,83 @@ export const startMirror = (deps) => {
3880
4126
  }
3881
4127
  };
3882
4128
  const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
4129
+ // ── Rate-limit auto-resume ────────────────────────────────────────────
4130
+ // Detection is poll-derived from the transcript tail every tick (limitResetAt:
4131
+ // "last message turn IS a 429 line"), so there is nothing to persist — a
4132
+ // daemon reload, a human retry in the TTY, or our own inject all converge
4133
+ // naturally: any newer turn makes detection stop firing and drops the state.
4134
+ const fmtClock = (ms) => {
4135
+ const d = new Date(ms);
4136
+ return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
4137
+ };
4138
+ let limitResumeTicking = false;
4139
+ const limitResumeTick = async () => {
4140
+ const lr = cfg.wrc.mirror.limitResume;
4141
+ if (!lr.enabled || limitResumeTicking)
4142
+ return;
4143
+ limitResumeTicking = true;
4144
+ try {
4145
+ const now = Date.now();
4146
+ for (const a of byTarget.values()) {
4147
+ const resetsAt = limitResetAt(a.jsonlPath, now);
4148
+ if (!resetsAt) {
4149
+ a.limitResume = undefined;
4150
+ continue;
4151
+ }
4152
+ if (a.migrationWatcher)
4153
+ continue; // session rotating — judge after it settles
4154
+ if (a.keepaliveOff)
4155
+ continue; // /stop = deliberately quieted; honor it
4156
+ if (a.limitResume?.resetsAt !== resetsAt)
4157
+ a.limitResume = { resetsAt, notified: false, retried: false };
4158
+ const st = a.limitResume;
4159
+ const dueAt = resetsAt + lr.delaySec * 1000;
4160
+ if (!st.notified) {
4161
+ st.notified = true;
4162
+ log.info({ target: a.target, resetsAt, dueAt }, "limit-resume: parked, retry scheduled");
4163
+ sendStandalone(a, `[mirror] ⏳ 已触限额 (reset ${fmtClock(resetsAt)}) — ${fmtClock(dueAt)} 自动注入 \`${lr.text}\` 续跑`);
4164
+ }
4165
+ if (st.retried || now < dueAt)
4166
+ continue;
4167
+ if (a.liveStream && !a.liveStream.closed)
4168
+ continue; // mid typewriter — don't inject
4169
+ if (a.tmuxPane) {
4170
+ // Pane mode: a dead pane means the human closed shop — auto-respawning
4171
+ // a TUI at 3am would be a surprise, not a service. Busy means someone
4172
+ // (human, or another turn) is already driving; detection clears itself
4173
+ // once their turn lands. Spawn-mode has no pane and no such hazards —
4174
+ // inject's `claude --resume -p` fallback IS its normal operation.
4175
+ if (!(await tmuxPaneAlive(a.tmuxPane)))
4176
+ continue;
4177
+ if (paneIsBusy(await capturePaneTail(a.tmuxPane, 16)))
4178
+ continue;
4179
+ }
4180
+ st.retried = true;
4181
+ // Unlike keepalive there is no swallow: the resumed work is real work —
4182
+ // the tail mirrors its output to chat normally. rememberInject only
4183
+ // suppresses the echo of the injected user line itself.
4184
+ rememberInject(lr.text);
4185
+ const r = await inject({
4186
+ text: lr.text, images: [], cfg,
4187
+ log: log.child({ target: a.target, sessionId: a.sessionId, sub: "limit-resume" }),
4188
+ sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
4189
+ });
4190
+ if (!r.ok) {
4191
+ st.retried = false; // roll back so the next tick retries the inject
4192
+ log.warn({ target: a.target, reason: r.reason }, "limit-resume: inject failed");
4193
+ continue;
4194
+ }
4195
+ log.info({ target: a.target, resetsAt }, "limit-resume: resume injected");
4196
+ }
4197
+ }
4198
+ catch (e) {
4199
+ log.warn({ err: e.message }, "limit-resume tick failed");
4200
+ }
4201
+ finally {
4202
+ limitResumeTicking = false;
4203
+ }
4204
+ };
4205
+ const limitResumeTimer = setInterval(() => void limitResumeTick(), 30_000);
3883
4206
  return {
3884
4207
  attach,
3885
4208
  chatTargets,
@@ -3891,6 +4214,7 @@ export const startMirror = (deps) => {
3891
4214
  peekTurns,
3892
4215
  isBusy,
3893
4216
  lastText,
4217
+ isOpenBubbleQuote,
3894
4218
  status: () => {
3895
4219
  const list = Array.from(bySessionId.values()).map((a) => ({
3896
4220
  sessionId: a.sessionId,
@@ -3917,7 +4241,7 @@ export const startMirror = (deps) => {
3917
4241
  },
3918
4242
  targetForSession: (sessionId) => {
3919
4243
  // Live attach is the fast path. Fall back to scanning persisted store:
3920
- // an MCP tool (e.g. `cd`) called from a claude that isn't mirror-attached
4244
+ // an MCP tool (e.g. `set_workspace`) called from a claude that isn't mirror-attached
3921
4245
  // would otherwise miss here and silently retarget to defaultChat — that
3922
4246
  // bug stranded pendingCwd on the wrong principal. Store keeps each
3923
4247
  // target's sessionId in sync via migrate/attach/setPendingCwd writes.
@@ -4269,6 +4593,7 @@ export const startMirror = (deps) => {
4269
4593
  shutdown: () => {
4270
4594
  clearInterval(paneDriftTimer);
4271
4595
  clearInterval(keepaliveTimer);
4596
+ clearInterval(limitResumeTimer);
4272
4597
  for (const a of bySessionId.values()) {
4273
4598
  if (a.outbound?.kind === "deferred")
4274
4599
  clearTimeout(a.outbound.timer);
@@ -4572,10 +4897,10 @@ export const startMirror = (deps) => {
4572
4897
  // and surface a standalone "cleared" so the user gets explicit
4573
4898
  // feedback (the skip-stream path otherwise leaves WeCom silent).
4574
4899
  if (armMigration) {
4575
- // slashAckFirstLine: 只回首行 ack, 项目信息/tip 省去 (与 /new 一致)。
4900
+ // cwd 不随回执下发 (/pwd 可查); slashAckFirstLine tip 也省去 (与 /new 一致)。
4576
4901
  sendStandalone(a, cfg.wrc.mirror.slashAckFirstLine
4577
4902
  ? "cleared"
4578
- : `cleared\n\n${renderProjectInfo(a.target)}`);
4903
+ : `cleared\n\n${randomTip()}`);
4579
4904
  a.clearRebind = { baseline: preClearBaseline };
4580
4905
  startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
4581
4906
  }