wezard 1.3.6 → 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);
@@ -776,6 +777,7 @@ export const startMirrorTail = (deps) => {
776
777
  clearInterval(poll);
777
778
  },
778
779
  drain,
780
+ livePath: resolveLive,
779
781
  };
780
782
  };
781
783
  // Run a tmux subcommand, capturing stdout/stderr. Delegates to spawn-tmux's
@@ -1499,6 +1501,9 @@ export const startMirror = (deps) => {
1499
1501
  if (s.closed)
1500
1502
  return;
1501
1503
  s.closed = true;
1504
+ // 非 brief 路径的父 turn 收口 — subagent turns 一并关 (brief 路径在
1505
+ // closeBriefTurn 关, 双关幂等)。
1506
+ closeSubagentTurns(a);
1502
1507
  if (s.flushTimer) {
1503
1508
  clearTimeout(s.flushTimer);
1504
1509
  s.flushTimer = undefined;
@@ -1577,7 +1582,14 @@ export const startMirror = (deps) => {
1577
1582
  }
1578
1583
  return withSessionTag(a.target, content, seq);
1579
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;
1580
1590
  const sendStandalone = (a, content) => {
1591
+ if (!hasVisibleBody(content))
1592
+ return;
1581
1593
  const chatId = stripPrincipalPrefix(a.target);
1582
1594
  const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
1583
1595
  const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
@@ -1596,6 +1608,8 @@ export const startMirror = (deps) => {
1596
1608
  };
1597
1609
  // Like sendStandalone but skips withSessionTag — content already contains the tag header (e.g. as a link).
1598
1610
  const sendRaw = (a, content) => {
1611
+ if (!hasVisibleBody(content))
1612
+ return;
1599
1613
  const chatId = stripPrincipalPrefix(a.target);
1600
1614
  a.standalonePending = a.standalonePending
1601
1615
  .then(async () => {
@@ -1633,16 +1647,20 @@ export const startMirror = (deps) => {
1633
1647
  };
1634
1648
  // Debounce 聚合: 仅 standalone 路径用。窗口内 onItem 多次落入 → 合并成单条 markdown。
1635
1649
  // 0 关闭时退化为透传。flushStandalone 也用于 detach / teardown 时的 drain。
1650
+ // parts 在 enqueue 时已过 hasVisibleBody, flush 端再过滤一遍是纵深防御 ——
1651
+ // 防的是绕过 enqueue 直写 buf 的未来代码路径。
1636
1652
  const flushStandalone = (a) => {
1637
1653
  const buf = a.standaloneBuf;
1638
1654
  if (!buf)
1639
1655
  return;
1640
- const merged = buf.parts.join("\n\n");
1641
1656
  a.standaloneBuf = undefined;
1657
+ const merged = buf.parts.filter((p) => hasVisibleBody(p)).join("\n\n");
1642
1658
  if (merged)
1643
1659
  sendStandalone(a, merged);
1644
1660
  };
1645
1661
  const enqueueStandalone = (a, content) => {
1662
+ if (!hasVisibleBody(content))
1663
+ return;
1646
1664
  const ms = cfg.wrc.mirror.standaloneDebounceMs;
1647
1665
  if (ms <= 0) {
1648
1666
  sendStandalone(a, content);
@@ -2015,7 +2033,9 @@ export const startMirror = (deps) => {
2015
2033
  a.briefConcluded = false;
2016
2034
  a.briefLastText = undefined;
2017
2035
  clearCot(a);
2018
- sendRaw(a, briefDetailLink(turnId, a.target));
2036
+ // 链接暂存, 不再单独发 — 让首条 standalone body 取走拼到前缀, 一条消息解决。
2037
+ // turn 始终会通过 concludeBriefTurn / closeBriefTurn 收口, header 不会泄漏。
2038
+ a.pendingBriefHeader = briefDetailLink(turnId, a.target);
2019
2039
  log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
2020
2040
  };
2021
2041
  // 本轮出现过工具调用 / tool_result / 非 final 文本 —— 只记状态: 详情链接从 ack 起
@@ -2026,19 +2046,28 @@ export const startMirror = (deps) => {
2026
2046
  a.briefHadTool = true;
2027
2047
  };
2028
2048
  // 本轮结论落地, 只生效一次:
2029
- // • 气泡仍开 (ack 时的 `链接 …` 还在) → 以 `链接 正文` 覆盖收口;
2030
- // 气泡已收 (过 6min 窗口 / 已收口) 或无气泡 turn (CLI 侧发起) → body 走 standalone,
2031
- // 那条 standalone 自带 `链接` (withLinkedTag / ensureBriefTurn 已发过详情链接)。
2049
+ // • 气泡仍开 (ack 时的 `链接 …` 还在) → 以 `链接 正文` 覆盖收口; ensureBriefTurn
2050
+ // 暂存的 header 跟着 bubble 收口一并丢 (气泡内已自带链接)
2051
+ // 气泡已收 (过 6min 窗口 / 已收口) 或无气泡 turn (CLI 侧发起) 把暂存 header
2052
+ // 拼到 body 前缀一并 standalone, 一条消息含详情入口 + 正文。
2053
+ // • body 为空 → 一个字不发, 顺手清掉 header, 不留"只有链接"的空消息。
2032
2054
  const concludeBriefTurn = (a, body) => {
2033
2055
  const turnId = a.briefTurnId;
2034
- if (!turnId || a.briefConcluded || !body.trim())
2056
+ if (!turnId || a.briefConcluded)
2057
+ return;
2058
+ if (!body.trim()) {
2059
+ a.pendingBriefHeader = undefined;
2035
2060
  return;
2061
+ }
2036
2062
  a.briefConcluded = true;
2037
2063
  if (a.briefBubble && !a.briefBubble.done) {
2038
2064
  void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
2065
+ a.pendingBriefHeader = undefined;
2039
2066
  }
2040
2067
  else {
2041
- sendStandalone(a, body);
2068
+ const header = a.pendingBriefHeader;
2069
+ a.pendingBriefHeader = undefined;
2070
+ sendStandalone(a, header ? `${header} ${body}` : body);
2042
2071
  }
2043
2072
  // 只保留当前 turn: 其余仍开着的 turn 记录是漏收的 close, 一并扫掉。
2044
2073
  recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: [turnId] });
@@ -2060,8 +2089,123 @@ export const startMirror = (deps) => {
2060
2089
  a.briefIsSlash = false;
2061
2090
  a.briefConcluded = false;
2062
2091
  a.briefLastText = undefined;
2092
+ a.pendingBriefHeader = undefined;
2093
+ closeSubagentTurns(a); // 父 turn 收口 = 它派出的 subagent 都已结束
2063
2094
  clearCot(a);
2064
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
+ };
2065
2209
  // 强制收口一个 attachment 的全部出站通道, 并返回收掉的气泡/流条数。
2066
2210
  //
2067
2211
  // 关键约束: **一个 tmux 调用都不许有**。它的两个调用方 (`/stop` 和 inject
@@ -2080,6 +2224,7 @@ export const startMirror = (deps) => {
2080
2224
  clearTimeout(a.softEnd);
2081
2225
  a.softEnd = undefined;
2082
2226
  }
2227
+ closeSubagentTurns(a); // brief/liveStream 都不在的收口路径也把 subagent 关掉
2083
2228
  if (a.outbound) {
2084
2229
  if (a.outbound.kind === "deferred")
2085
2230
  clearTimeout(a.outbound.timer);
@@ -2192,9 +2337,14 @@ export const startMirror = (deps) => {
2192
2337
  // loading 气泡。非 slash 场景的 skill_output 是用户可见的中间反馈, 走 standalone。
2193
2338
  if (a.briefIsSlash && !a.briefHadTool && a.briefBubble && !a.briefBubble.done) {
2194
2339
  void finishBriefBubble(a, item.body);
2340
+ a.pendingBriefHeader = undefined;
2195
2341
  }
2196
2342
  else {
2197
- 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);
2198
2348
  }
2199
2349
  return;
2200
2350
  }
@@ -2373,6 +2523,17 @@ export const startMirror = (deps) => {
2373
2523
  break;
2374
2524
  a.recentToolSigs.delete(oldest);
2375
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
+ }
2376
2537
  // codebuddy 的 ExitPlanMode 完全不过 PreToolUse hook (实测: 由
2377
2538
  // interruption-service 本地对话框裁决, HookExecutor 零调用)。mirror 从
2378
2539
  // jsonl 看到 function_call → 发计划审批卡; 点选后 send-keys 裁决本地
@@ -2605,6 +2766,9 @@ export const startMirror = (deps) => {
2605
2766
  clearTimeout(a.standaloneBuf.timer);
2606
2767
  flushStandalone(a);
2607
2768
  }
2769
+ a.subagentWatch?.stop();
2770
+ a.subagentWatch = undefined;
2771
+ closeSubagentTurns(a);
2608
2772
  a.tail.stop();
2609
2773
  bySessionId.delete(a.sessionId);
2610
2774
  if (byTarget.get(a.target) === a)
@@ -2659,7 +2823,7 @@ export const startMirror = (deps) => {
2659
2823
  // collapsing to cfg.wrc.cwd (which would mislabel /pwd, /clear, /new).
2660
2824
  runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
2661
2825
  pendingCwd: carryPending,
2662
- tail: { stop: () => undefined, drain: () => undefined }, // placeholder; replaced below
2826
+ tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
2663
2827
  standalonePending: Promise.resolve(),
2664
2828
  recentToolSigs: new Map(),
2665
2829
  };
@@ -2682,6 +2846,7 @@ export const startMirror = (deps) => {
2682
2846
  // what lets a claude session and a codebuddy session be mirrored at once.
2683
2847
  normalizeLine: backendForPath(jsonlPath).normalizeTranscriptLine,
2684
2848
  });
2849
+ startSubagentsFor(a);
2685
2850
  bySessionId.set(sessionId, a);
2686
2851
  byTarget.set(target, a);
2687
2852
  // Preserve a persisted `/stop` pause across the re-attach: restore rebuilds the
@@ -3065,6 +3230,8 @@ export const startMirror = (deps) => {
3065
3230
  // path, not by CLI) — re-derive the dialect from the destination.
3066
3231
  normalizeLine: backendForPath(newJsonlPath).normalizeTranscriptLine,
3067
3232
  });
3233
+ // 会话轮换 = 新 <sid>/subagents/ 目录; 旧 run 的 turn 一并收口。
3234
+ startSubagentsFor(a);
3068
3235
  deps.store.set(a.target, {
3069
3236
  sessionId: newSessionId,
3070
3237
  jsonlPath: newJsonlPath,
@@ -3942,8 +4109,10 @@ export const startMirror = (deps) => {
3942
4109
  // Stall recovery, decided by RULE only (no model self-judgment): the last
3943
4110
  // transcript turn is a synthetic API-error/limit line, or the idle pane
3944
4111
  // still shows an error banner ⇒ a turn died mid-work. Send the resume
3945
- // instruction instead of the plain warmer.
3946
- 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));
3947
4116
  k.pinging = true;
3948
4117
  k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
3949
4118
  await fireKeepalive(a, stalled);
@@ -3957,6 +4126,83 @@ export const startMirror = (deps) => {
3957
4126
  }
3958
4127
  };
3959
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);
3960
4206
  return {
3961
4207
  attach,
3962
4208
  chatTargets,
@@ -4347,6 +4593,7 @@ export const startMirror = (deps) => {
4347
4593
  shutdown: () => {
4348
4594
  clearInterval(paneDriftTimer);
4349
4595
  clearInterval(keepaliveTimer);
4596
+ clearInterval(limitResumeTimer);
4350
4597
  for (const a of bySessionId.values()) {
4351
4598
  if (a.outbound?.kind === "deferred")
4352
4599
  clearTimeout(a.outbound.timer);