wezard 1.2.9 → 1.2.11

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.
@@ -393,7 +393,7 @@ const renderToolInputCompact = (input, max) => {
393
393
  const renderToolUseGroupBody = (calls, deps) => {
394
394
  const renderOne = (c) => ({
395
395
  compact: safeForMarkdown(renderToolInputCompact(c.input, deps.toolUseInlineMaxChars)),
396
- url: deps.detailUrlFor(c.toolUseId),
396
+ url: deps.detailUrlFor(c.toolUseId, deps.target),
397
397
  });
398
398
  if (calls.length === 1) {
399
399
  const c = calls[0];
@@ -489,7 +489,7 @@ const renderLine = (raw, deps) => {
489
489
  if (toolUseId)
490
490
  recordToolResult(toolUseId, full);
491
491
  const compact = safeForMarkdown(oneLineSummary(full, 40));
492
- const url = deps.detailUrlFor(toolUseId);
492
+ const url = deps.detailUrlFor(toolUseId, deps.target);
493
493
  out.push({
494
494
  kind: "tool_result",
495
495
  toolUseId,
@@ -574,13 +574,12 @@ const renderLine = (raw, deps) => {
574
574
  const rawIn = u.input_tokens ?? 0;
575
575
  const cr = u.cache_read_input_tokens ?? 0;
576
576
  const cw = u.cache_creation_input_tokens ?? 0;
577
- // CodeBuddy (claude-internal) 网关把 input_tokens 报成 cr+cw+fresh 的总和,
577
+ // 网关把 input_tokens 报成 cr+cw+fresh 的总和 (claude-4.7-opus, deepseek-v4-flash),
578
578
  // 而 Anthropic 官方 input_tokens 只含 fresh (与 cache_creation/cache_read disjoint)。
579
- // 按模型命名风格判定源头: CodeBuddy "claude-4.7-opus" (点号版本),
580
- // Anthropic 官方是 "claude-opus-4-7" (纯连字符)。只对 CodeBuddy 风格反推,
581
- // 普通 Anthropic-native 会话保持原样, 不影响其 usage 计算。
582
- const isGatewayTotalized = typeof model === "string" && /\d\.\d/.test(model);
583
- const input = isGatewayTotalized && rawIn >= cr + cw ? rawIn - cr - cw : rawIn;
579
+ // 判据用数据本身: input_tokens 是否已覆盖两个 cache 档 —— 覆盖即网关口径, 反推 fresh;
580
+ // 否则按原生口径 (input fresh) 原样保留。不按模型名风格猜, deepseek 无点号版本。
581
+ const isTotalized = rawIn >= cr + cw;
582
+ const input = isTotalized ? rawIn - cr - cw : rawIn;
584
583
  out.push({
585
584
  kind: "turn_usage",
586
585
  model,
@@ -1289,6 +1288,8 @@ export const startMirror = (deps) => {
1289
1288
  // for >60s mid-turn and we don't want to drop the bubble while it's chewing.
1290
1289
  const FLUSH_MS = 250;
1291
1290
  const HARD_TIMEOUT_MS = 350_000;
1291
+ /** 首条 CLI 产出到达前的早期超时: 3s 无响应则先收气泡为详情链接。 */
1292
+ const EARLY_LINK_MS = 3_000;
1292
1293
  /** 软收口静默期。后端只能说"这条消息写完了"(codebuddy) 时, 等这么久没有新 item
1293
1294
  * 才认定一轮结束。取值只需盖住"叙述消息落盘 → 紧随其后的 function_call 落盘"
1294
1295
  * 这一段, 与模型思考/工具执行时长无关。 */
@@ -1647,7 +1648,8 @@ export const startMirror = (deps) => {
1647
1648
  const BRIEF_TURN_OPENERS = new Set(["text", "tool_use", "tool_result", "skill_output"]);
1648
1649
  // 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
1649
1650
  // 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
1650
- const briefDetailLink = (turnId) => `✴️ [View chat details](${buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId)})`;
1651
+ // target 作为 ww_uniq 传下去, 让同 chat 的所有 turn 详情都复用一个 WeCom 窗口。
1652
+ const briefDetailLink = (turnId, target) => `✴️ [View chat details](${buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target))})`;
1651
1653
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
1652
1654
  // 排队中的 turn 也持有气泡, 所以气泡是显式入参而不是从 a 上取。
1653
1655
  const finishBubble = async (a, b, content) => {
@@ -1655,6 +1657,10 @@ export const startMirror = (deps) => {
1655
1657
  return;
1656
1658
  b.done = true;
1657
1659
  clearTimeout(b.hardTimer);
1660
+ if (b.earlyTimer) {
1661
+ clearTimeout(b.earlyTimer);
1662
+ b.earlyTimer = undefined;
1663
+ }
1658
1664
  if (a.briefBubble === b)
1659
1665
  a.briefBubble = undefined;
1660
1666
  try {
@@ -1700,8 +1706,16 @@ export const startMirror = (deps) => {
1700
1706
  log.warn({ sessionId: a.sessionId, turnId }, "brief: queued turn timed out — force-closing the stuck one");
1701
1707
  closeBriefTurn(a);
1702
1708
  }
1703
- void finishBubble(a, bubble, briefDetailLink(turnId));
1709
+ void finishBubble(a, bubble, briefDetailLink(turnId, a.target));
1704
1710
  }, HARD_TIMEOUT_MS);
1711
+ // earlyTimer: 3s 无 CLI 产出 → 先把 loading 气泡收成详情链接, 用户可即时点入。
1712
+ // 首条 item 到达时清除 (handleBriefItem 路径)。用显式 bubble ref 而非 a.briefBubble,
1713
+ // 因为排队中的 turn 还没成为活跃 turn, a.briefBubble 指向的是前一个。
1714
+ bubble.earlyTimer = setTimeout(() => {
1715
+ if (bubble.done)
1716
+ return;
1717
+ void finishBubble(a, bubble, briefDetailLink(turnId, a.target));
1718
+ }, EARLY_LINK_MS);
1705
1719
  if (a.briefTurnId) {
1706
1720
  (a.briefQueue ??= []).push(q);
1707
1721
  log.info({ sessionId: a.sessionId, turnId, queued: a.briefQueue.length }, "brief: turn queued (previous still running)");
@@ -1733,7 +1747,7 @@ export const startMirror = (deps) => {
1733
1747
  a.briefHadTool = false;
1734
1748
  a.briefConcluded = false;
1735
1749
  a.briefLastText = undefined;
1736
- enqueueStandalone(a, briefDetailLink(turnId));
1750
+ enqueueStandalone(a, briefDetailLink(turnId, a.target));
1737
1751
  log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
1738
1752
  };
1739
1753
  // 本轮一旦出现工具调用 / tool_result / 非 final 文本, 就说明这不是"一句话答完"的
@@ -1744,7 +1758,7 @@ export const startMirror = (deps) => {
1744
1758
  if (!a.briefTurnId || !a.briefBubble || a.briefBubble.done)
1745
1759
  return;
1746
1760
  a.briefHadTool = true;
1747
- void finishBriefBubble(a, briefDetailLink(a.briefTurnId));
1761
+ void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target));
1748
1762
  };
1749
1763
  // 本轮结论落地, 只生效一次:
1750
1764
  // • 无工具 → 结论 + 详情链接一并写进 loading 气泡 (仍是一条消息);
@@ -1758,7 +1772,7 @@ export const startMirror = (deps) => {
1758
1772
  if (a.briefHadTool || !a.briefBubble)
1759
1773
  sendStandalone(a, body);
1760
1774
  else
1761
- void finishBriefBubble(a, `${body}\n\n${briefDetailLink(turnId)}`);
1775
+ void finishBriefBubble(a, `${body}\n\n${briefDetailLink(turnId, a.target)}`);
1762
1776
  // 排队中的 turn 已经建了记录但还没跑, 不能被这把扫帚扫成「已完成」。
1763
1777
  const keep = [turnId, ...(a.briefQueue ?? []).map((q) => q.turnId)];
1764
1778
  recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: keep });
@@ -1773,7 +1787,7 @@ export const startMirror = (deps) => {
1773
1787
  // 气泡还开着 (有工具的 turn: 结论只发了 standalone, 气泡留到这里收链接; 或空 turn
1774
1788
  // 没有结论) —— 收口: 统一写详情链接, 保证每个 turn 都有可点入口。
1775
1789
  if (a.briefBubble && !a.briefBubble.done) {
1776
- void finishBriefBubble(a, briefDetailLink(a.briefTurnId));
1790
+ void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target));
1777
1791
  }
1778
1792
  recordTurnClose(a.briefTurnId);
1779
1793
  log.info({ sessionId: a.sessionId, turnId: a.briefTurnId }, "brief: turn closed");
@@ -1793,6 +1807,11 @@ export const startMirror = (deps) => {
1793
1807
  const turnId = a.briefTurnId;
1794
1808
  if (!turnId)
1795
1809
  return;
1810
+ // 首条 item 到达 → 清除 earlyTimer (CLI 已有响应, 不需要超时收链接了)。
1811
+ if (a.briefBubble?.earlyTimer) {
1812
+ clearTimeout(a.briefBubble.earlyTimer);
1813
+ a.briefBubble.earlyTimer = undefined;
1814
+ }
1796
1815
  const now = Date.now();
1797
1816
  if (item.kind === "tool_use") {
1798
1817
  a.briefHadTool = true; // 有工具 → 气泡收口写详情链接, 结论另发 standalone
@@ -1922,6 +1941,9 @@ export const startMirror = (deps) => {
1922
1941
  a.keepaliveQuiet = undefined;
1923
1942
  };
1924
1943
  const onItem = (a, item) => {
1944
+ // 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
1945
+ if (a.muteUntilInject)
1946
+ return;
1925
1947
  // Keepalive ping turns are cache-warmers: swallow every item from the WeCom
1926
1948
  // paths so the ping/pong never reaches chat. But record the REAL exchange
1927
1949
  // into its chat-detail turn — the actual assistant reply (expected: just
@@ -2216,8 +2238,10 @@ export const startMirror = (deps) => {
2216
2238
  // Click-to-detail URL for a tool_use id. Cached on the bridge so both
2217
2239
  // attach() and migrateAttachment() share the same closure. Returns ""
2218
2240
  // when disabled in config — renderLine then drops the markdown wrapping.
2219
- const detailUrlFor = (id) => cfg.daemon.detailLinksInMirror && id
2220
- ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id)
2241
+ // `principal` flows through to ww_uniq so all detail links from the same
2242
+ // chat reuse one WeCom inner-browser window.
2243
+ const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
2244
+ ? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
2221
2245
  : "";
2222
2246
  const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, pendingCwd }) => {
2223
2247
  const target = resolveTarget(targetOverride);
@@ -2601,8 +2625,16 @@ export const startMirror = (deps) => {
2601
2625
  // 迁移是所有会话轮换的唯一漏斗 (注入的 /clear、TUI 里手打的 /clear、resume fork、
2602
2626
  // worktree drift)。只有目标 jsonl 首条用户行是 /clear 的那种才是真清空 —— fork /
2603
2627
  // drift 上下文是延续的, 让 store 去推它那条中性的 "switch"。
2604
- if (jsonlIsPostClearChild(newJsonlPath))
2628
+ if (jsonlIsPostClearChild(newJsonlPath)) {
2605
2629
  a.ctxCut = "clear";
2630
+ // 真清空 = 缓存里已无任何值得保温的内容。像 /stop 一样暂停保活 —— 与
2631
+ // dispatch 里 WeCom 注入 /clear 的暂停对齐;TUI 手打 /clear 只走这个漏斗,
2632
+ // 不在这里停下的话旧时钟会继续 ping 一个空上下文。busy-resume(过 grace)
2633
+ // 或真实 inbound 解除。下方 store.set 一并落盘。
2634
+ a.keepaliveOff = true;
2635
+ a.keepaliveOffAt = Date.now();
2636
+ a.keepalive = undefined;
2637
+ }
2606
2638
  a.tail.stop();
2607
2639
  bySessionId.delete(oldSessionId);
2608
2640
  a.sessionId = newSessionId;
@@ -2634,6 +2666,10 @@ export const startMirror = (deps) => {
2634
2666
  tmuxPane: a.tmuxPane || undefined,
2635
2667
  cwd: a.runningCwd || undefined,
2636
2668
  pendingCwd: a.pendingCwd || undefined,
2669
+ // store.set 是整记录替换 —— 必须带上暂停态,否则迁移会把 dispatch 刚落盘
2670
+ // 的 /clear 暂停(或之前的 /stop 暂停)从盘上抹掉,reload 后保活复活。
2671
+ keepaliveOff: a.keepaliveOff || undefined,
2672
+ keepaliveOffAt: a.keepaliveOffAt || undefined,
2637
2673
  });
2638
2674
  log.info({ target: a.target, oldSessionId, newSessionId, oldJsonlPath, newJsonlPath, startOffset }, "mirror migrated session");
2639
2675
  };
@@ -2994,6 +3030,7 @@ export const startMirror = (deps) => {
2994
3030
  const spawned = byTarget.get(target);
2995
3031
  if (spawned) {
2996
3032
  spawned.justSpawned = true;
3033
+ spawned.muteUntilInject = true;
2997
3034
  // 只有换过 pane 才是断点 —— 首次 /wrc 建会话时 prev 为空, 那不是"不连续", 是开局。
2998
3035
  if (prev)
2999
3036
  spawned.ctxCut = "new";
@@ -3201,11 +3238,17 @@ export const startMirror = (deps) => {
3201
3238
  };
3202
3239
  const fireKeepalive = async (a) => {
3203
3240
  const kc = cfg.wrc.mirror.keepalive;
3241
+ // Only the streak's FIRST ping carries the full instruction; consecutive
3242
+ // pings shrink to a bare "ping" — the instruction is already in context,
3243
+ // and the smaller delta means a smaller cache-write and less pane clutter.
3244
+ // keepaliveStamps matches both forms, so the bare one never reads as real
3245
+ // activity. (round is incremented below, after the inject lands.)
3246
+ const text = (a.keepalive?.round ?? 0) > 0 ? "ping" : kc.ping;
3204
3247
  // Suppress the pane→WeCom echo of the ping user line, then swallow the whole
3205
3248
  // ping turn (reply included). The 60s fail-safe clears the quiet window if
3206
3249
  // the turn somehow never emits turn_end, so a later real turn is never muted;
3207
3250
  // it also closes the detail turn so it can't hang open in the chat timeline.
3208
- rememberInject(kc.ping);
3251
+ rememberInject(text);
3209
3252
  a.keepaliveQuiet = setTimeout(() => {
3210
3253
  a.keepaliveQuiet = undefined;
3211
3254
  if (a.keepaliveTurnId) {
@@ -3214,7 +3257,7 @@ export const startMirror = (deps) => {
3214
3257
  }
3215
3258
  }, 60_000);
3216
3259
  const r = await inject({
3217
- text: kc.ping, images: [], cfg,
3260
+ text, images: [], cfg,
3218
3261
  log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
3219
3262
  sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
3220
3263
  });
@@ -3227,11 +3270,7 @@ export const startMirror = (deps) => {
3227
3270
  }
3228
3271
  const tokens = lastContextTokens(a.jsonlPath);
3229
3272
  const size = tokens > 0 ? `~${fmtTokens(tokens)} tokens` : "未知";
3230
- // Denominator tracks the ACTUAL ping cadence (idleTriggerMs = ttlSec - marginSec),
3231
- // not the nominal ttlSec — pings fire marginSec early, so ttlSec would undercount
3232
- // and let `n` exceed `N` (a 9/8). floor keeps the last shown round the last one that fires.
3233
- const cadenceSec = Math.max(30, kc.ttlSec - kc.marginSec);
3234
- const totalRounds = Math.max(1, Math.floor(kc.maxIdleSec / cadenceSec));
3273
+ const totalRounds = Math.max(1, kc.rounds);
3235
3274
  const round = a.keepalive ? (a.keepalive.round += 1) : 1;
3236
3275
  log.info({ target: a.target, tokens, round, totalRounds }, "keepalive: ping injected");
3237
3276
  // Open a chat-detail turn for the real heartbeat exchange: userQuery is the
@@ -3240,7 +3279,7 @@ export const startMirror = (deps) => {
3240
3279
  // closed on the ping's turn_end (or the fail-safe). Kept out of chat.
3241
3280
  const turnId = newTurnId();
3242
3281
  a.keepaliveTurnId = turnId;
3243
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: kc.ping });
3282
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
3244
3283
  if (kc.notify)
3245
3284
  sendStandalone(a, `KeepAlive ${round}/${totalRounds} · ${size}`);
3246
3285
  };
@@ -3253,7 +3292,6 @@ export const startMirror = (deps) => {
3253
3292
  try {
3254
3293
  const idleTriggerMs = Math.max(30, kc.ttlSec - kc.marginSec) * 1000;
3255
3294
  const ttlMs = kc.ttlSec * 1000;
3256
- const maxIdleMs = Math.max(kc.ttlSec, kc.maxIdleSec) * 1000;
3257
3295
  const pingSig = normAssistant(kc.ping).slice(0, 40);
3258
3296
  const now = Date.now();
3259
3297
  for (const a of byTarget.values()) {
@@ -3322,13 +3360,12 @@ export const startMirror = (deps) => {
3322
3360
  if (a.keepaliveOff)
3323
3361
  continue; // paused by /stop until real activity returns
3324
3362
  const idleSinceTouch = k.lastMs ? now - k.lastMs : now - mtime; // last model turn = cache touch
3325
- const realIdle = k.lastRealMs ? now - k.lastRealMs : Infinity; // no real turn in window ⇒ dead
3326
3363
  if (idleSinceTouch < idleTriggerMs)
3327
3364
  continue; // cache still comfortably warm
3328
3365
  if (idleSinceTouch >= ttlMs)
3329
3366
  continue; // cache already cold — a ping would cold-rewrite for nothing
3330
- if (realIdle >= maxIdleMs)
3331
- continue; // real work too old stop, let it die (你的「超5分钟不保活」)
3367
+ if (k.round >= kc.rounds)
3368
+ continue; // budget spent — let it go cold
3332
3369
  k.pinging = true;
3333
3370
  k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
3334
3371
  await fireKeepalive(a);
@@ -3823,6 +3860,7 @@ export const startMirror = (deps) => {
3823
3860
  sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
3824
3861
  });
3825
3862
  if (!r.ok) {
3863
+ a.muteUntilInject = false;
3826
3864
  if (s) {
3827
3865
  s.acc = s.acc ? `${s.acc}\n\n[mirror] ✗ ${r.reason ?? "failed"}` : `[mirror] ✗ ${r.reason ?? "failed"}`;
3828
3866
  await finalizeStream(a, s);
@@ -3846,6 +3884,8 @@ export const startMirror = (deps) => {
3846
3884
  }
3847
3885
  return;
3848
3886
  }
3887
+ // inject 成功 → 解除初始输出静默, 后续 onItem 正常流转。
3888
+ a.muteUntilInject = false;
3849
3889
  // Inject "succeeded" but the input box never cleared — the target
3850
3890
  // session may be busy / not consuming input (long task, full context).
3851
3891
  // Hint the user once so a silently-dropped message isn't mistaken for a