wezard 2.3.0 → 2.4.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 (68) hide show
  1. package/CLAUDE.md +3 -3
  2. package/README.md +5 -5
  3. package/config.example.jsonc +12 -0
  4. package/dist/daemon/approval.js +84 -2
  5. package/dist/daemon/approval.js.map +1 -1
  6. package/dist/daemon/chat-name.js +15 -0
  7. package/dist/daemon/chat-name.js.map +1 -1
  8. package/dist/daemon/config-api.js +265 -70
  9. package/dist/daemon/config-api.js.map +1 -1
  10. package/dist/daemon/danger.js +5 -7
  11. package/dist/daemon/danger.js.map +1 -1
  12. package/dist/daemon/detail.js +10 -2
  13. package/dist/daemon/detail.js.map +1 -1
  14. package/dist/daemon/handoff.js +4 -3
  15. package/dist/daemon/handoff.js.map +1 -1
  16. package/dist/daemon/idle-nudge.js +55 -0
  17. package/dist/daemon/idle-nudge.js.map +1 -0
  18. package/dist/daemon/inbound.js +10 -10
  19. package/dist/daemon/inbound.js.map +1 -1
  20. package/dist/daemon/index.js +872 -214
  21. package/dist/daemon/index.js.map +1 -1
  22. package/dist/daemon/jobs.js +122 -13
  23. package/dist/daemon/jobs.js.map +1 -1
  24. package/dist/daemon/mirror-bridge.js +95 -83
  25. package/dist/daemon/mirror-bridge.js.map +1 -1
  26. package/dist/daemon/model-select.js +3 -0
  27. package/dist/daemon/model-select.js.map +1 -1
  28. package/dist/daemon/notices.js +145 -33
  29. package/dist/daemon/notices.js.map +1 -1
  30. package/dist/daemon/peers.js +88 -74
  31. package/dist/daemon/peers.js.map +1 -1
  32. package/dist/daemon/pending-items.js +171 -0
  33. package/dist/daemon/pending-items.js.map +1 -0
  34. package/dist/daemon/receipts.js +54 -20
  35. package/dist/daemon/receipts.js.map +1 -1
  36. package/dist/daemon/route.js +76 -15
  37. package/dist/daemon/route.js.map +1 -1
  38. package/dist/daemon/spawn-tmux.js +2 -2
  39. package/dist/daemon/spawn-tmux.js.map +1 -1
  40. package/dist/daemon/steward-guard.js +32 -0
  41. package/dist/daemon/steward-guard.js.map +1 -0
  42. package/dist/daemon/wizard.js +104 -61
  43. package/dist/daemon/wizard.js.map +1 -1
  44. package/dist/mcp/server.js +75 -25
  45. package/dist/mcp/server.js.map +1 -1
  46. package/dist/shared/chat-http.js +8 -8
  47. package/dist/shared/chat-http.js.map +1 -1
  48. package/dist/shared/config-meta.js +91 -0
  49. package/dist/shared/config-meta.js.map +1 -0
  50. package/dist/shared/config-writer.js +5 -6
  51. package/dist/shared/config-writer.js.map +1 -1
  52. package/dist/shared/config.js +269 -294
  53. package/dist/shared/config.js.map +1 -1
  54. package/dist/shared/detail-render.js +121 -32
  55. package/dist/shared/detail-render.js.map +1 -1
  56. package/dist/shared/detail-store.js.map +1 -1
  57. package/dist/shared/keepalive.js +5 -5
  58. package/dist/shared/keepalive.js.map +1 -1
  59. package/dist/shared/role-render.js +49 -11
  60. package/dist/shared/role-render.js.map +1 -1
  61. package/dist/shared/role-view.js +18 -4
  62. package/dist/shared/role-view.js.map +1 -1
  63. package/dist/shared/world.js +82 -13
  64. package/dist/shared/world.js.map +1 -1
  65. package/hooks/pre-tool-use.sh +5 -6
  66. package/package.json +1 -1
  67. package/web/chat.css +57 -37
  68. package/web/chat.js +454 -227
@@ -38,7 +38,7 @@ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagH
38
38
  import { splitMarkdown } from "../shared/md-chunk.js";
39
39
  import { randomTip } from "./tips.js";
40
40
  import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
41
- import { stripAnsi, paneIsBusy, transcriptStalled, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
41
+ import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
42
42
  import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
43
43
  // PATH augmentation: launchd / systemd start the daemon
44
44
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
@@ -152,6 +152,9 @@ const findJsonlBySid = (sid, only) => {
152
152
  // codebuddy persists message/function_call records instead, so route every
153
153
  // parsed line through the owning backend's adapter first (identity for claude).
154
154
  const normalizeForPath = (path, raw) => backendForPath(path).normalizeTranscriptLine(raw);
155
+ // /model、/effort 的本地回执 (spawn 兜底路径 / set_model 敲出来的) 是守护进程自己的
156
+ // 操作痕迹, 不是聊天内容 —— 不镜像、不进详情页。
157
+ const MODEL_RECEIPT_RE = /^(?:Set model to|Kept model as|Set effort level to|Kept effort|Switch model)\b/;
155
158
  // Local-command noise on user lines: Claude marks the caveat / command-stdout
156
159
  // records isMeta; codebuddy persists them as PLAIN user messages (no isMeta
157
160
  // field at all). Predicates looking for the first REAL user line must skip
@@ -525,6 +528,8 @@ const renderLine = (raw, deps) => {
525
528
  const stdoutMatch = c.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
526
529
  if (stdoutMatch && stdoutMatch[1]) {
527
530
  const skillOutput = stdoutMatch[1].replace(/\[[0-9;]*m/g, "").trim();
531
+ if (MODEL_RECEIPT_RE.test(skillOutput))
532
+ return out;
528
533
  if (skillOutput) {
529
534
  out.push({ kind: "skill_output", body: `⚙️ ${skillOutput}` });
530
535
  }
@@ -1432,9 +1437,9 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
1432
1437
  // 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
1433
1438
  // 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
1434
1439
  // 每次补之前再问一遍证人, 已提交就不补。
1440
+ /** 具名粘贴 buffer 的序号 (见 loadAndPaste)。 */
1441
+ let pasteSeq = 0;
1435
1442
  const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
1436
- /** 具名粘贴 buffer 的序号 (见 loadAndPaste)。 */
1437
- let pasteSeq = 0;
1438
1443
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1439
1444
  // started, transcript still loading): extended timings — bracketed-paste
1440
1445
  // end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
@@ -1777,18 +1782,13 @@ export const startMirror = (deps) => {
1777
1782
  return true;
1778
1783
  };
1779
1784
  // Keepalive ping detector for the tail's user-line judgment — the SAME
1780
- // normalized-prefix signature set keepaliveTick feeds keepaliveStamps
1781
- // (every configured form + the bare "ping" legacy streak form), so
1782
- // renderLine, the tick and the transcript parser all agree on what counts
1783
- // as a ping. Content is the source of truth for the swallow: timers and
1784
- // echo-TTLs lose it across reloads/replays.
1785
- const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping, cfg.wrc.mirror.keepalive.resumePing);
1786
- // The warmer alone — what the peer readers (peek / wait / roster summary) strip.
1787
- // `resumePing` is excluded on purpose: the turn it opens is real work resumed.
1788
- const warmerSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1789
- // 行上只认保温那一句: 报错之后的续跑 (resumePing) 开出的是那一轮的接续, 整轮吞掉的话
1790
- // 续出来的结论既不进群也没有频道。续跑若只回了 pong, 回复侧的那道保险照样吞。
1791
- const isKeepalivePing = (text) => isKeepalivePingText(text, warmerSigs);
1785
+ // normalized-prefix signature set keepaliveTick feeds keepaliveStamps (the
1786
+ // configured ping + the bare "ping" legacy streak form), so renderLine, the
1787
+ // tick, the peer readers (peek / wait / roster summary) and the transcript
1788
+ // parser all agree on what counts as a ping. Content is the source of truth
1789
+ // for the swallow: timers and echo-TTLs lose it across reloads/replays.
1790
+ const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
1791
+ const isKeepalivePing = (text) => isKeepalivePingText(text, pingSigs);
1792
1792
  // ── Typewriter stream lifecycle ────────────────────────────────────
1793
1793
  // WeCom spec: server polls us for stream refreshes for up to 6 min from the
1794
1794
  // original inbound. SDK queues replyStream calls per req_id, sends serially
@@ -2438,6 +2438,13 @@ export const startMirror = (deps) => {
2438
2438
  }
2439
2439
  return opening?.query;
2440
2440
  }
2441
+ // 私聊派来的活 (不是回执) 这一轮必是私聊, 不论印章带没带频道: 进群的只能是人问的事的
2442
+ // 交代与显式公开。带 task 的 fork 分身就只有出处、没有频道印章, 退回 home 就把私聊派的活答进了群。
2443
+ if (from?.kind === "peer" && !from.public && !from.receipt) {
2444
+ a.channel = "";
2445
+ a.speaker = undefined;
2446
+ a.replyTo = undefined;
2447
+ }
2441
2448
  return undefined;
2442
2449
  };
2443
2450
  /** 本轮落 turn 记录的频道字段。 */
@@ -2473,11 +2480,21 @@ export const startMirror = (deps) => {
2473
2480
  const t = said.trim();
2474
2481
  const hit = (x) => pick(x) && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k)));
2475
2482
  const seals = a.seals ?? [];
2476
- const first = seals.find(hit);
2477
- if (first)
2478
- a.seals = seals.filter((x) => !hit(x));
2483
+ const hits = seals.filter(hit);
2484
+ // CLI 把排队的几句并成一行, 这一轮只能有一个去向: 里面有私聊的那句就是私聊 —— 它的答复不能
2485
+ // 跟着人的话进群; 没有才轮到人从聊天说的那句 (它的轮次与气泡早已建好)。
2486
+ const first = hits.find((x) => x.channel === "") ?? hits.find((x) => !!x.turn) ?? hits[0];
2487
+ if (!first)
2488
+ return undefined;
2489
+ a.seals = seals.filter((x) => !hit(x));
2490
+ hits.filter((x) => x !== first && x.turn && !x.activated).forEach((x) => mergedAway(a, x.turn));
2479
2491
  return first;
2480
2492
  };
2493
+ /** 人那句被并进了一轮私聊: 它预建的气泡与轮次收口成一个链接 —— 答复不进群, 也不让气泡挂到超时。 */
2494
+ const mergedAway = (a, q) => {
2495
+ void finishBubble(a, q.bubble, `${briefDetailLink(q.turnId, a.target)} (这句和一段私聊并成了一轮, 答复只在 rolepage)`, true);
2496
+ recordTurnClose(q.turnId);
2497
+ };
2481
2498
  /** 人在聊天里说的那一句接成活跃 turn: 收掉在跑的那一轮, 频道与发话人取它自己的印章。
2482
2499
  * `at` = 那一行落盘的时刻: 排队的那句从这一刻才开始被处理, 轮次的起点以它为准; 闲时当场接上就是投递那一刻。 */
2483
2500
  const activateDispatch = (a, s, at = s.at) => {
@@ -4495,7 +4512,11 @@ export const startMirror = (deps) => {
4495
4512
  const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
4496
4513
  const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
4497
4514
  const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
4498
- const effEffort = opts?.effort ?? parseEffort(prev?.effort ?? deps.store.get(target)?.effort);
4515
+ // 群的默认会话 (管家) 没点名模型 / 档位时用 models.router。只管这里新起的 —— 死 pane
4516
+ // 重生走绑定里记下的模型, 不经过这里; 已在跑的管家也不动 (换模型会让缓存整份重读)。
4517
+ const router = !tagOfKey(target) && !isInternalKey(target) ? cfg.models.router : undefined;
4518
+ const effModel = opts?.model?.trim() || router?.model || undefined;
4519
+ const effEffort = opts?.effort ?? parseEffort(prev?.effort ?? deps.store.get(target)?.effort) ?? router?.effort;
4499
4520
  if (prev?.tmuxPane) {
4500
4521
  // Best-effort kill; ignore errors (pane may already be dead).
4501
4522
  void runTmux(["kill-pane", "-t", prev.tmuxPane]);
@@ -4512,7 +4533,7 @@ export const startMirror = (deps) => {
4512
4533
  windowName: windowName ?? target,
4513
4534
  cwdOverride: eff,
4514
4535
  cli: effCli,
4515
- model: opts?.model,
4536
+ model: effModel,
4516
4537
  knownModels: modelSightings(effCli, [target, base]),
4517
4538
  effort: effEffort,
4518
4539
  systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
@@ -4534,7 +4555,7 @@ export const startMirror = (deps) => {
4534
4555
  // means this CLI's default, not the dead pane's model. Store what
4535
4556
  // spawnTmuxClaude actually confirmed via `/model` (r.model), not the
4536
4557
  // raw request — a later respawn's fast path then matches on try one.
4537
- model: r.model ?? (opts?.model?.trim() ?? ""),
4558
+ model: r.model ?? effModel ?? "",
4538
4559
  effort: r.effort ?? "",
4539
4560
  keepaliveDisabled: opts?.keepalive === undefined ? undefined : !opts.keepalive,
4540
4561
  });
@@ -4674,7 +4695,7 @@ export const startMirror = (deps) => {
4674
4695
  return { ok: false, reason: r.reason, inherited: false };
4675
4696
  // 分叉文件不是开机就有的: CLI 在这个 pane **收到第一条消息**的那一刻才把父亲的
4676
4697
  // transcript 复制成新文件。所以必须先说一句话, 分身才会有自己的 sid —— 这句话
4677
- // 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」), 群里看到的第一条也是它。
4698
+ // 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」)。
4678
4699
  const hello = args.bootstrap?.trim()
4679
4700
  || "(wezard 分身初始化)用一句话说明: 你是谁、从谁那里继承了哪些上下文、准备接什么活。不要复述这条指令。";
4680
4701
  rememberInject(hello);
@@ -4716,7 +4737,7 @@ export const startMirror = (deps) => {
4716
4737
  const spawned = byTarget.get(args.target);
4717
4738
  if (spawned) {
4718
4739
  // muteUntilInject 是给"全新 pane 的 greeting 噪音"准备的; 分身的开场白是正经
4719
- // 内容 (群里第一条就该是它), 所以这里不静音。
4740
+ // 内容 (rolepage 上它的第一轮), 所以这里不静音 —— 进不进群由下面的频道印章定。
4720
4741
  spawned.justSpawned = true;
4721
4742
  spawned.keepaliveOff = true;
4722
4743
  spawned.keepaliveOffAt = Date.now();
@@ -4728,6 +4749,8 @@ export const startMirror = (deps) => {
4728
4749
  spawned.pendingFrom = { from: args.bootstrapFrom.from, at: Date.now() };
4729
4750
  spawned.pendingBriefQuery = args.bootstrapFrom.query;
4730
4751
  }
4752
+ // 开场白不是谁在群里问的: 带活的是私聊派活, 不带的是守护进程让它自报家门 —— 都只进 rolepage。
4753
+ spawned.pendingChannel = { channel: "", at: Date.now() };
4731
4754
  }
4732
4755
  lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
4733
4756
  afterSpawn();
@@ -4971,6 +4994,10 @@ export const startMirror = (deps) => {
4971
4994
  const check = async () => {
4972
4995
  if (done)
4973
4996
  return;
4997
+ if (aborted()) {
4998
+ finish({ idle: false, reason: "stopped" });
4999
+ return;
5000
+ }
4974
5001
  const v = idleVerdict(target);
4975
5002
  if (v ?? !(await isBusy(target)))
4976
5003
  finish({ idle: true });
@@ -4979,23 +5006,19 @@ export const startMirror = (deps) => {
4979
5006
  const tick = setInterval(() => void check(), IDLE_SAFETY_POLL_MS);
4980
5007
  const timer = setTimeout(() => finish({ idle: false, reason: "idle wait timed out" }), timeoutMs);
4981
5008
  void check();
4982
- if (aborted()) {
4983
- finish({ idle: false, reason: "stopped" });
4984
- return;
4985
- }
4986
5009
  });
4987
5010
  };
4988
5011
  const lastText = (target) => {
4989
5012
  const p = jsonlOf(target);
4990
- return p ? lastAssistantText(p, 4000, warmerSigs) : "";
5013
+ return p ? lastAssistantText(p, 4000, pingSigs) : "";
4991
5014
  };
4992
5015
  const replySince = (target, sinceMs = 0) => {
4993
5016
  const p = jsonlOf(target);
4994
- return p ? lastReply(p, sinceMs, warmerSigs) : "";
5017
+ return p ? lastReply(p, sinceMs, pingSigs) : "";
4995
5018
  };
4996
5019
  const replyToPeerSince = (target, fromName, sinceMs = 0, turn) => {
4997
5020
  const p = jsonlOf(target);
4998
- return p ? replyToPeerIn(p, fromName, sinceMs, warmerSigs, turn) : undefined;
5021
+ return p ? replyToPeerIn(p, fromName, sinceMs, pingSigs, turn) : undefined;
4999
5022
  };
5000
5023
  /** 绑着的 pane 还能用 (存在、且不是别人的窗口) —— 不重生、不猜。 */
5001
5024
  const paneLiveOf = async (target) => {
@@ -5062,7 +5085,7 @@ export const startMirror = (deps) => {
5062
5085
  paneAlive,
5063
5086
  busy: paneAlive ? paneIsBusy(await capturePaneTail(pane, 12)) : false,
5064
5087
  lastActivity,
5065
- summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80, warmerSigs) : "(未绑定会话)",
5088
+ summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80, pingSigs) : "(未绑定会话)",
5066
5089
  self: t === self,
5067
5090
  };
5068
5091
  };
@@ -5119,24 +5142,27 @@ export const startMirror = (deps) => {
5119
5142
  return { ok: false, reason: "它还没有 transcript (没接过第一句话) —— send_peer 跟它说一句就有了", busy };
5120
5143
  // `n` 数的是来回: 每个来回给问话和它最新的一句 (跑完的是终句, 还在跑的是途中
5121
5144
  // 最近说的那句) —— 途中的其余叙述不给, 那是过程不是结论。
5122
- const rounds = talkRounds(jsonl, n, warmerSigs).slice(-n);
5145
+ const rounds = talkRounds(jsonl, n, pingSigs).slice(-n);
5123
5146
  const dialog = renderDialog(rounds.flatMap((r) => (r.length > 1 ? [r[0], r.at(-1)] : r)), 800, 4000);
5124
5147
  const waiting = busy ? [] : openToolUses(jsonl);
5125
5148
  return dialog ? { ok: true, dialog, busy, waiting } : { ok: false, reason: "transcript 里还没有对话", busy, waiting };
5126
5149
  };
5127
5150
  // ── Prompt-cache keepalive ──────────────────────────────────────────
5128
- // Anthropic's prompt cache expires ~5min after the last request. A pane that
5129
- // goes idle (agent parked on a peer, background task running) lets the whole
5130
- // context fall out of cache — the next real turn then re-writes it at 1.25x.
5131
- // We inject a tiny ping just before expiry so the model makes one cheap
5132
- // request (cache-read 0.1x) that slides the TTL forward. The whole schedule is
5133
- // anchored to the last REAL (non-ping) activity, NOT to our own pings — so a
5134
- // dead session is never kept warm forever, and once real work is older than
5135
- // `maxIdleSec` we stop and let the cache die.
5151
+ // Anthropic's prompt cache expires a TTL (5min API default, 1h on a Claude Code
5152
+ // subscription) after the start of the last request. A pane that goes idle
5153
+ // (agent parked on a peer, background task running) lets the whole context fall
5154
+ // out of cache — the next real turn then re-writes it. We inject a tiny ping just
5155
+ // before expiry so the model makes one cheap request (cache-read) that slides the
5156
+ // TTL forward. The budget is anchored to the last REAL (non-ping) activity, NOT to
5157
+ // our own pings — so a dead session is never kept warm forever.
5136
5158
  // After `/stop`, ignore pane-busy as a resume signal for this long — long
5137
5159
  // enough for an in-flight ping (interrupted by the same /stop's Esc) to settle,
5138
5160
  // so it can't self-resume the pause. A genuine new turn after this window does.
5139
5161
  const RESUME_GRACE_MS = 30_000;
5162
+ // A cache untouched this long is not kept warm: on a 1h entry the ping would come
5163
+ // ~59min into the idle stretch, re-reading the whole context to cover a gap the
5164
+ // TTL already covered — warming only pays on short (5min) entries.
5165
+ const KEEPALIVE_MAX_IDLE_MS = 10 * 60_000;
5140
5166
  // Seed the keepalive clocks from message-turn timestamps (keepaliveStamps), never
5141
5167
  // from file mtime — non-message lines (file-history-snapshot / ai-title / mode)
5142
5168
  // bump mtime without being a model turn, which would fake a long-dead session
@@ -5156,35 +5182,30 @@ export const startMirror = (deps) => {
5156
5182
  round = s.streak;
5157
5183
  }
5158
5184
  catch { /* unreadable tail */ }
5159
- return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), stallFree: 0 };
5185
+ return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), cacheAt: cacheTouchMs(jsonlPath) };
5160
5186
  };
5161
- const fireKeepalive = async (a, stalled) => {
5187
+ const fireKeepalive = async (a) => {
5162
5188
  const kc = cfg.wrc.mirror.keepalive;
5163
- // Stalled (error/limit banner + idle pane): send the full resume instruction
5164
- // — inviting the model to finish genuinely-unfinished work, or to reply
5165
- // "pong" if it was legitimately parked. Otherwise the plain warmer, the SAME
5166
- // full instruction every round: a bare "ping" used to be sent from round 2 on
5189
+ // The SAME full instruction every round: a bare "ping" used to be sent from round 2 on
5167
5190
  // (the instruction was already in context), but the model reads a bare "ping"
5168
5191
  // as an open turn and answers it however it likes — anything other than
5169
5192
  // "pong" un-swallows the heartbeat into chat and re-anchors the clocks as
5170
5193
  // real activity. Restating the instruction is a few tokens of cache-write and
5171
5194
  // buys a deterministic reply. keepaliveStamps matches every form, so none
5172
5195
  // reads as real activity. (round is incremented below, after the inject lands.)
5173
- const text = stalled ? kc.resumePing : kc.ping;
5196
+ const text = kc.ping;
5174
5197
  // Suppress the pane→WeCom echo of the ping user line, then swallow the whole
5175
5198
  // ping turn (reply included). The 60s fail-safe clears the quiet window if
5176
5199
  // the turn somehow never emits turn_end, so a later real turn is never muted;
5177
5200
  // it also closes the detail turn so it can't hang open in the chat timeline.
5178
5201
  rememberInject(text);
5179
- // 续跑不是保温 (见 isKeepalivePing): 不开吞没窗口, 也不记成保温轮。
5180
- if (!stalled)
5181
- a.keepaliveQuiet = setTimeout(() => {
5182
- a.keepaliveQuiet = undefined;
5183
- if (a.keepaliveTurnId) {
5184
- recordTurnClose(a.keepaliveTurnId);
5185
- a.keepaliveTurnId = undefined;
5186
- }
5187
- }, 60_000);
5202
+ a.keepaliveQuiet = setTimeout(() => {
5203
+ a.keepaliveQuiet = undefined;
5204
+ if (a.keepaliveTurnId) {
5205
+ recordTurnClose(a.keepaliveTurnId);
5206
+ a.keepaliveTurnId = undefined;
5207
+ }
5208
+ }, 60_000);
5188
5209
  const r = await inject({
5189
5210
  text, images: [], cfg,
5190
5211
  log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
@@ -5206,7 +5227,7 @@ export const startMirror = (deps) => {
5206
5227
  // closed on the ping's turn_end (or the fail-safe). Kept out of chat.
5207
5228
  // The tail's keepalive_start (content match on the user line) may have
5208
5229
  // opened this turn already — only open when it hasn't.
5209
- if (!stalled && !a.keepaliveTurnId) {
5230
+ if (!a.keepaliveTurnId) {
5210
5231
  const turnId = newTurnId();
5211
5232
  a.keepaliveTurnId = turnId;
5212
5233
  recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
@@ -5219,14 +5240,11 @@ export const startMirror = (deps) => {
5219
5240
  return;
5220
5241
  keepaliveTicking = true;
5221
5242
  try {
5222
- // Stall recovery keeps the configured window: a turn that died on an API
5223
- // error should be resumed minutes later, whatever the cache tier.
5224
- const stallQuietMs = Math.max(30, kc.ttlSec - kc.marginSec) * 1000;
5225
- const pingSigs = [normAssistant(kc.ping).slice(0, 40), normAssistant(kc.resumePing).slice(0, 40)].filter((s) => s.length > 0);
5243
+ const pingSigs = [normAssistant(kc.ping).slice(0, 40)].filter((s) => s.length > 0);
5226
5244
  const now = Date.now();
5227
5245
  for (const a of byTarget.values()) {
5228
5246
  if (a.keepaliveDisabled)
5229
- continue; // sticky spawn-time opt-out — never pings, never auto-resumes
5247
+ continue; // sticky spawn-time opt-out — never pings
5230
5248
  if (!a.tmuxPane)
5231
5249
  continue; // spawn-mode: no live pane to warm
5232
5250
  if (a.migrationWatcher)
@@ -5261,6 +5279,7 @@ export const startMirror = (deps) => {
5261
5279
  k.lastRealMs = mtime;
5262
5280
  } // backend without timestamps → fall back to mtime
5263
5281
  k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
5282
+ k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
5264
5283
  }
5265
5284
  k.seenMtime = mtime;
5266
5285
  // Round reset / real-activity re-anchor keys off REAL turns only — a
@@ -5298,34 +5317,25 @@ export const startMirror = (deps) => {
5298
5317
  }
5299
5318
  if (a.keepaliveOff)
5300
5319
  continue; // paused by /stop until real activity returns
5301
- const idleSinceTouch = k.lastMs ? now - k.lastMs : now - mtime; // last model turn = cache touch
5320
+ // Cache clock = start of the newest answered request (TTL runs from there),
5321
+ // not the last turn's write time; no request on record → the turn clock.
5322
+ const sinceCache = now - (k.cacheAt || k.lastMs || mtime);
5302
5323
  // The TTL the session's cache entries were actually written with (1h on a
5303
5324
  // Claude Code subscription), not a guess: pinging at the 5min cadence
5304
5325
  // against a 1h entry re-reads the whole context ~14x per hour for nothing.
5305
5326
  const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
5306
5327
  const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
5307
- if (idleSinceTouch < stallQuietMs)
5308
- continue; // nothing due yet on either clock
5309
- if (idleSinceTouch >= ttlMs)
5328
+ if (sinceCache < idleTriggerMs)
5329
+ continue; // cache still comfortably warm
5330
+ if (sinceCache >= ttlMs)
5310
5331
  continue; // cache already cold — a ping would cold-rewrite for nothing
5332
+ if (sinceCache > KEEPALIVE_MAX_IDLE_MS)
5333
+ continue; // idle past 10min (a long-TTL entry) — not worth warming
5311
5334
  if (k.round >= kc.rounds)
5312
5335
  continue; // budget spent — let it go cold
5313
- // Stall recovery, decided by RULE only (no model self-judgment) and from
5314
- // the transcript's structure only — never the screen: the turn is still
5315
- // unfinished (died on a synthetic API error, or a tool result nobody
5316
- // answered) and has been quiet for the whole idle window. Send the resume
5317
- // instruction instead of the plain warmer. A transcript found un-stalled a
5318
- // minute past the quiet window stays so until a new turn lands (= lastMs
5319
- // moves) — remembered, so a 1h idle stretch reads it once, not every tick.
5320
- const stalled = kc.resumeOnStall && k.stallFree !== k.lastMs
5321
- && transcriptStalled(a.jsonlPath, keepalivePingSigs(kc.ping, kc.resumePing), stallQuietMs, now);
5322
- if (!stalled && idleSinceTouch >= stallQuietMs + 60_000)
5323
- k.stallFree = k.lastMs;
5324
- if (!stalled && idleSinceTouch < idleTriggerMs)
5325
- continue; // cache still comfortably warm
5326
5336
  k.pinging = true;
5327
5337
  k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
5328
- await fireKeepalive(a, stalled);
5338
+ await fireKeepalive(a);
5329
5339
  }
5330
5340
  }
5331
5341
  catch (e) {
@@ -5717,6 +5727,9 @@ export const startMirror = (deps) => {
5717
5727
  pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
5718
5728
  const sid = a.sessionId;
5719
5729
  const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
5730
+ // 同 dispatch: 只有刚生 / 刚重生的 pane 才要冷启动的宽限 —— 对一个热着、正忙的 pane 贴 `now`
5731
+ // 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
5732
+ let fresh = a.justSpawned === true;
5720
5733
  if (!paneAlive) {
5721
5734
  log.warn({ target, sessionId: sid, oldPane: a.tmuxPane }, "injectText: pane not alive, respawning");
5722
5735
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
@@ -5731,9 +5744,8 @@ export const startMirror = (deps) => {
5731
5744
  a.runningCwd = r.cwd;
5732
5745
  deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, model: a.model || undefined, effort: a.effort || undefined, pendingCwd: a.pendingCwd || undefined });
5733
5746
  startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
5747
+ fresh = true;
5734
5748
  }
5735
- // freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
5736
- // is still warming up so the verifier in injectViaTmux needs the slack.
5737
5749
  // 同 dispatch: 在 inject 之前解除静默, 防止验证循环中 LLM 回复被永久吞掉。
5738
5750
  a.muteUntilInject = false;
5739
5751
  a.justSpawned = false;
@@ -5743,7 +5755,7 @@ export const startMirror = (deps) => {
5743
5755
  const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
5744
5756
  const r = await inject({
5745
5757
  text: full, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
5746
- sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: true,
5758
+ sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
5747
5759
  });
5748
5760
  if (r.ok && a.tmuxPane) {
5749
5761
  if (preClearBaseline) {