wezard 1.1.4 → 1.2.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.
@@ -23,7 +23,7 @@ import { recordTool, recordToolResult, recordTurnStart, recordTurnItem, recordTu
23
23
  import { labelFor, tagOfKey, baseOfKey, withTagHeader } from "../shared/session-label.js";
24
24
  import { splitMarkdown } from "../shared/md-chunk.js";
25
25
  import { randomTip } from "./tips.js";
26
- import { stripAnsi, compactPane, paneIsBusy, summarizeTail, lastAssistantText } from "./peers.js";
26
+ import { stripAnsi, compactPane, paneIsBusy, summarizeTail, lastAssistantText, lastContextTokens } from "./peers.js";
27
27
  // Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
28
28
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
29
29
  const NODE_BIN_DIR = dirname(process.execPath);
@@ -1910,7 +1910,47 @@ export const startMirror = (deps) => {
1910
1910
  if (a.liveStream && !a.liveStream.closed)
1911
1911
  void finalizeStream(a, a.liveStream);
1912
1912
  };
1913
+ // Close the keepalive quiet window — the ping turn is over (or the fail-safe
1914
+ // fired), so subsequent real items flow through onItem normally again.
1915
+ const endKeepaliveQuiet = (a) => {
1916
+ if (!a.keepaliveQuiet)
1917
+ return;
1918
+ clearTimeout(a.keepaliveQuiet);
1919
+ a.keepaliveQuiet = undefined;
1920
+ };
1913
1921
  const onItem = (a, item) => {
1922
+ // Keepalive ping turns are cache-warmers: swallow every item from the WeCom
1923
+ // paths so the ping/pong never reaches chat. But record the REAL exchange
1924
+ // into its chat-detail turn — the actual assistant reply (expected: just
1925
+ // "pong"), the tool calls if any, and the usage (proof it was a cheap
1926
+ // cache-read) — so the detail page shows the genuine heartbeat, not a
1927
+ // synthetic summary. Closed on the terminal signal (hard or soft turn_end).
1928
+ if (a.keepaliveQuiet) {
1929
+ const id = a.keepaliveTurnId;
1930
+ if (id) {
1931
+ const now = Date.now();
1932
+ if (item.kind === "text") {
1933
+ recordTurnItem(id, { t: "text", body: item.body, ts: now, final: item.final === true });
1934
+ }
1935
+ else if (item.kind === "tool_use") {
1936
+ for (const c of item.calls)
1937
+ recordTurnItem(id, { t: "tool_use", toolUseId: c.toolUseId, toolName: c.name, toolInput: c.input, ts: now });
1938
+ }
1939
+ else if (item.kind === "tool_result") {
1940
+ recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
1941
+ }
1942
+ else if (item.kind === "turn_usage") {
1943
+ recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
1944
+ }
1945
+ else if (item.kind === "turn_end") {
1946
+ recordTurnClose(id);
1947
+ a.keepaliveTurnId = undefined;
1948
+ }
1949
+ }
1950
+ if (item.kind === "turn_end")
1951
+ endKeepaliveQuiet(a);
1952
+ return;
1953
+ }
1914
1954
  // 任何新 item 到达 = 上一条"消息写完了"并不代表这一轮结束 → 撤销待确认的软收口。
1915
1955
  // 硬信号 (end_turn / turn_duration) 的后端永远不会走到这里。
1916
1956
  if (a.softEnd) {
@@ -3060,6 +3100,145 @@ export const startMirror = (deps) => {
3060
3100
  const raw = await capturePaneTail(pane, Math.max(8, rows) + 12);
3061
3101
  return { ok: true, pane: compactPane(raw, rows), busy: paneIsBusy(raw) };
3062
3102
  };
3103
+ // ── Prompt-cache keepalive ──────────────────────────────────────────
3104
+ // Anthropic's prompt cache expires ~5min after the last request. A pane that
3105
+ // goes idle (agent parked on a peer, background task running) lets the whole
3106
+ // context fall out of cache — the next real turn then re-writes it at 1.25x.
3107
+ // We inject a tiny ping just before expiry so the model makes one cheap
3108
+ // request (cache-read 0.1x) that slides the TTL forward. Budget-capped so an
3109
+ // abandoned pane isn't pinged forever; the counter refreshes on real activity.
3110
+ const fmtTokens = (n) => n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n);
3111
+ // After `/stop`, ignore pane-busy as a resume signal for this long — long
3112
+ // enough for an in-flight ping (interrupted by the same /stop's Esc) to settle,
3113
+ // so it can't self-resume the pause. A genuine new turn after this window does.
3114
+ const RESUME_GRACE_MS = 30_000;
3115
+ const fireKeepalive = async (a) => {
3116
+ const kc = cfg.wrc.mirror.keepalive;
3117
+ // Suppress the pane→WeCom echo of the ping user line, then swallow the whole
3118
+ // ping turn (reply included). The 60s fail-safe clears the quiet window if
3119
+ // the turn somehow never emits turn_end, so a later real turn is never muted;
3120
+ // it also closes the detail turn so it can't hang open in the chat timeline.
3121
+ rememberInject(kc.ping);
3122
+ a.keepaliveQuiet = setTimeout(() => {
3123
+ a.keepaliveQuiet = undefined;
3124
+ if (a.keepaliveTurnId) {
3125
+ recordTurnClose(a.keepaliveTurnId);
3126
+ a.keepaliveTurnId = undefined;
3127
+ }
3128
+ }, 60_000);
3129
+ const r = await inject({
3130
+ text: kc.ping, images: [], cfg,
3131
+ log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
3132
+ sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
3133
+ });
3134
+ if (!r.ok) {
3135
+ endKeepaliveQuiet(a);
3136
+ // Roll the ping back so a failed attempt doesn't burn the budget.
3137
+ if (a.keepalive) {
3138
+ a.keepalive.pinging = false;
3139
+ a.keepalive.count = Math.max(0, a.keepalive.count - 1);
3140
+ }
3141
+ log.warn({ target: a.target, reason: r.reason }, "keepalive: inject failed");
3142
+ return;
3143
+ }
3144
+ const n = a.keepalive?.count ?? 1;
3145
+ const tokens = lastContextTokens(a.jsonlPath);
3146
+ const size = tokens > 0 ? `~${fmtTokens(tokens)} tokens` : "未知";
3147
+ log.info({ target: a.target, count: n, tokens }, "keepalive: ping injected");
3148
+ // Open a chat-detail turn for the real heartbeat exchange: userQuery is the
3149
+ // actual ping we injected; the assistant reply (expected: just "pong"), any
3150
+ // tool calls, and usage are grafted on from the swallowed items in onItem;
3151
+ // closed on the ping's turn_end (or the fail-safe). Kept out of chat.
3152
+ const turnId = newTurnId();
3153
+ a.keepaliveTurnId = turnId;
3154
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: kc.ping });
3155
+ if (kc.notify)
3156
+ sendStandalone(a, `❤️ 保活 · context ${size} · ${n}/${kc.maxPings}`);
3157
+ };
3158
+ let keepaliveTicking = false;
3159
+ const keepaliveTick = async () => {
3160
+ const kc = cfg.wrc.mirror.keepalive;
3161
+ if (!kc.enabled || keepaliveTicking)
3162
+ return;
3163
+ keepaliveTicking = true;
3164
+ try {
3165
+ const idleTriggerMs = Math.max(30, kc.ttlSec - kc.marginSec) * 1000;
3166
+ const ttlMs = kc.ttlSec * 1000;
3167
+ const now = Date.now();
3168
+ for (const a of byTarget.values()) {
3169
+ if (!a.tmuxPane)
3170
+ continue; // spawn-mode: no live pane to warm
3171
+ if (a.migrationWatcher)
3172
+ continue; // session rotating — skip
3173
+ if (a.liveStream && !a.liveStream.closed)
3174
+ continue; // mid typewriter — don't inject
3175
+ if (!(await tmuxPaneAlive(a.tmuxPane)))
3176
+ continue; // dead pane — nothing to keep alive
3177
+ let mtime = 0;
3178
+ try {
3179
+ mtime = statSync(a.jsonlPath).mtimeMs;
3180
+ }
3181
+ catch {
3182
+ continue;
3183
+ }
3184
+ if (!mtime)
3185
+ continue;
3186
+ const k = (a.keepalive ??= { count: 0, settledMtime: 0, pinging: false, pingMtime: 0 });
3187
+ const busy = paneIsBusy(await capturePaneTail(a.tmuxPane, 12));
3188
+ if (k.pinging) {
3189
+ // Waiting for our own ping to land and settle. Not written yet, or the
3190
+ // ping turn still running → keep waiting. Once written AND idle again,
3191
+ // snapshot the settled mtime; the next idle window accrues from here.
3192
+ if (busy || mtime <= k.pingMtime)
3193
+ continue;
3194
+ k.settledMtime = mtime;
3195
+ k.pinging = false;
3196
+ continue;
3197
+ }
3198
+ // Real turn running → full budget again. If `/stop` paused us, resume ONLY
3199
+ // after a grace window: the ping that may have been mid-flight when /stop
3200
+ // landed makes the pane busy too, and must NOT be mistaken for the human
3201
+ // coming back. After the grace, a busy pane is a genuine new turn → resume.
3202
+ if (busy) {
3203
+ k.count = 0;
3204
+ k.settledMtime = 0;
3205
+ if (a.keepaliveOff && now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS)
3206
+ a.keepaliveOff = false;
3207
+ continue;
3208
+ }
3209
+ // Transcript grew past the last settled ping while we weren't pinging =
3210
+ // a real turn happened → reset the budget so idle after work re-earns it.
3211
+ if (k.settledMtime && mtime > k.settledMtime + 1000) {
3212
+ k.count = 0;
3213
+ k.settledMtime = 0;
3214
+ }
3215
+ if (a.keepaliveOff)
3216
+ continue; // paused by /stop until a real turn returns (busy branch / dispatch lift it)
3217
+ const idle = now - mtime;
3218
+ if (idle < idleTriggerMs)
3219
+ continue; // still warm — too early to bother
3220
+ // Hard upper bound: past the TTL the cache is already gone, so a ping
3221
+ // would pay a full 1.25x cold re-write of the whole context for a no-op
3222
+ // turn — the exact waste we're avoiding. Only warm a cache that's still
3223
+ // hittable; a genuinely stale session waits for a real turn to pay it.
3224
+ if (idle >= ttlMs)
3225
+ continue;
3226
+ if (k.count >= kc.maxPings)
3227
+ continue; // budget spent — let it go cold
3228
+ k.count += 1;
3229
+ k.pinging = true;
3230
+ k.pingMtime = mtime;
3231
+ await fireKeepalive(a);
3232
+ }
3233
+ }
3234
+ catch (e) {
3235
+ log.warn({ err: e.message }, "keepalive tick failed");
3236
+ }
3237
+ finally {
3238
+ keepaliveTicking = false;
3239
+ }
3240
+ };
3241
+ const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
3063
3242
  return {
3064
3243
  attach,
3065
3244
  peers,
@@ -3273,7 +3452,17 @@ export const startMirror = (deps) => {
3273
3452
  const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
3274
3453
  if (r.code !== 0)
3275
3454
  return { ok: false, reason: `send-keys Escape failed: ${r.stdout.slice(-200) || r.code}` };
3276
- log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /stop Esc sent to pane");
3455
+ // /stop also pauses keepalive: the user is deliberately quieting this
3456
+ // session, so stop poking it too. A real turn (WeCom inbound, or the pane
3457
+ // going busy AFTER the resume grace) lifts the pause and re-earns the
3458
+ // budget. Stamping keepaliveOffAt gates the busy-resume so the ping that
3459
+ // may be mid-flight right now can't immediately self-resume.
3460
+ a.keepaliveOff = true;
3461
+ a.keepaliveOffAt = Date.now();
3462
+ a.keepalive = { count: 0, settledMtime: 0, pinging: false, pingMtime: 0 };
3463
+ // Any in-flight ping's quiet window + detail turn are left to close on their
3464
+ // own turn_end (or the 60s fail-safe), so the interrupted pong stays out of chat.
3465
+ log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /stop — Esc sent to pane, keepalive paused");
3277
3466
  return { ok: true };
3278
3467
  },
3279
3468
  submitPane: async (target) => {
@@ -3325,6 +3514,7 @@ export const startMirror = (deps) => {
3325
3514
  newSession,
3326
3515
  shutdown: () => {
3327
3516
  clearInterval(paneDriftTimer);
3517
+ clearInterval(keepaliveTimer);
3328
3518
  for (const a of bySessionId.values()) {
3329
3519
  if (a.outbound?.kind === "deferred")
3330
3520
  clearTimeout(a.outbound.timer);
@@ -3354,6 +3544,14 @@ export const startMirror = (deps) => {
3354
3544
  }
3355
3545
  return;
3356
3546
  }
3547
+ // A real inbound is a new conversation → lift any `/stop` keepalive pause
3548
+ // and re-earn the budget. (Pane-side new turns resume via the tick's busy
3549
+ // branch; this covers the WeCom-driven path.)
3550
+ a.keepaliveOff = false;
3551
+ if (a.keepalive) {
3552
+ a.keepalive.count = 0;
3553
+ a.keepalive.settledMtime = 0;
3554
+ }
3357
3555
  // Finalize prior live stream (if any) so this new turn renders into its
3358
3556
  // own message bubble. Then open a fresh stream tied to the new frame and
3359
3557
  // ack immediately so WeCom doesn't time out while inject queues.