wezard 1.1.5 → 1.2.1

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, tailTurns } 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,153 @@ 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. The whole schedule is
3109
+ // anchored to the last REAL (non-ping) activity, NOT to our own pings — so a
3110
+ // dead session is never kept warm forever, and once real work is older than
3111
+ // `maxIdleSec` we stop and let the cache die.
3112
+ const fmtTokens = (n) => n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n);
3113
+ // After `/stop`, ignore pane-busy as a resume signal for this long — long
3114
+ // enough for an in-flight ping (interrupted by the same /stop's Esc) to settle,
3115
+ // so it can't self-resume the pause. A genuine new turn after this window does.
3116
+ const RESUME_GRACE_MS = 30_000;
3117
+ // Seed the keepalive anchor. If the transcript's last user turn IS one of our
3118
+ // pings, the session has been idling under keepalive — anchor realMtime in the
3119
+ // past so a daemon restart doesn't resume pinging a stale session (it waits for
3120
+ // genuine activity). Otherwise anchor to the current mtime (fresh real work).
3121
+ const initKeepalive = (jsonlPath, mtime, pingText, ttlMs) => {
3122
+ let stale = false;
3123
+ try {
3124
+ const users = tailTurns(jsonlPath, 8).filter((t) => t.role === "user");
3125
+ const last = users[users.length - 1]?.text ?? "";
3126
+ const sig = normAssistant(pingText).slice(0, 40);
3127
+ stale = sig.length > 0 && normAssistant(last).includes(sig);
3128
+ }
3129
+ catch { /* unreadable tail — treat as fresh */ }
3130
+ return { realMtime: stale ? mtime - ttlMs : mtime, seenMtime: mtime, pinging: false, pingMtime: 0 };
3131
+ };
3132
+ const fireKeepalive = async (a) => {
3133
+ const kc = cfg.wrc.mirror.keepalive;
3134
+ // Suppress the pane→WeCom echo of the ping user line, then swallow the whole
3135
+ // ping turn (reply included). The 60s fail-safe clears the quiet window if
3136
+ // the turn somehow never emits turn_end, so a later real turn is never muted;
3137
+ // it also closes the detail turn so it can't hang open in the chat timeline.
3138
+ rememberInject(kc.ping);
3139
+ a.keepaliveQuiet = setTimeout(() => {
3140
+ a.keepaliveQuiet = undefined;
3141
+ if (a.keepaliveTurnId) {
3142
+ recordTurnClose(a.keepaliveTurnId);
3143
+ a.keepaliveTurnId = undefined;
3144
+ }
3145
+ }, 60_000);
3146
+ const r = await inject({
3147
+ text: kc.ping, images: [], cfg,
3148
+ log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
3149
+ sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
3150
+ });
3151
+ if (!r.ok) {
3152
+ endKeepaliveQuiet(a);
3153
+ if (a.keepalive)
3154
+ a.keepalive.pinging = false; // roll back so the next tick can retry
3155
+ log.warn({ target: a.target, reason: r.reason }, "keepalive: inject failed");
3156
+ return;
3157
+ }
3158
+ const tokens = lastContextTokens(a.jsonlPath);
3159
+ const size = tokens > 0 ? `~${fmtTokens(tokens)} tokens` : "未知";
3160
+ log.info({ target: a.target, tokens }, "keepalive: ping injected");
3161
+ // Open a chat-detail turn for the real heartbeat exchange: userQuery is the
3162
+ // actual ping we injected; the assistant reply (expected: just "pong"), any
3163
+ // tool calls, and usage are grafted on from the swallowed items in onItem;
3164
+ // closed on the ping's turn_end (or the fail-safe). Kept out of chat.
3165
+ const turnId = newTurnId();
3166
+ a.keepaliveTurnId = turnId;
3167
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: kc.ping });
3168
+ if (kc.notify)
3169
+ sendStandalone(a, `KeepAlive · ${size}`);
3170
+ };
3171
+ let keepaliveTicking = false;
3172
+ const keepaliveTick = async () => {
3173
+ const kc = cfg.wrc.mirror.keepalive;
3174
+ if (!kc.enabled || keepaliveTicking)
3175
+ return;
3176
+ keepaliveTicking = true;
3177
+ try {
3178
+ const idleTriggerMs = Math.max(30, kc.ttlSec - kc.marginSec) * 1000;
3179
+ const ttlMs = kc.ttlSec * 1000;
3180
+ const maxIdleMs = Math.max(kc.ttlSec, kc.maxIdleSec) * 1000;
3181
+ const now = Date.now();
3182
+ for (const a of byTarget.values()) {
3183
+ if (!a.tmuxPane)
3184
+ continue; // spawn-mode: no live pane to warm
3185
+ if (a.migrationWatcher)
3186
+ continue; // session rotating — skip
3187
+ if (a.liveStream && !a.liveStream.closed)
3188
+ continue; // mid typewriter — don't inject
3189
+ if (!(await tmuxPaneAlive(a.tmuxPane)))
3190
+ continue; // dead pane — nothing to keep alive
3191
+ let mtime = 0;
3192
+ try {
3193
+ mtime = statSync(a.jsonlPath).mtimeMs;
3194
+ }
3195
+ catch {
3196
+ continue;
3197
+ }
3198
+ if (!mtime)
3199
+ continue;
3200
+ const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, kc.ping, ttlMs));
3201
+ // Growth since the previous tick — computed BEFORE updating seenMtime.
3202
+ // A real turn writing between ticks trips this; our own ping's growth is
3203
+ // absorbed by the pinging-branch ticks (which update seenMtime too), so it
3204
+ // never looks like real activity here — that's the whole fix.
3205
+ const grewSinceLast = mtime > k.seenMtime + 1000;
3206
+ k.seenMtime = mtime;
3207
+ const busy = paneIsBusy(await capturePaneTail(a.tmuxPane, 12));
3208
+ if (k.pinging) {
3209
+ // Waiting for our own ping to land and settle. Not written yet, or the
3210
+ // ping turn still running → keep waiting. realMtime is NEVER advanced by
3211
+ // a ping — a ping is not real work.
3212
+ if (busy || mtime <= k.pingMtime)
3213
+ continue;
3214
+ k.pinging = false;
3215
+ continue;
3216
+ }
3217
+ // Real activity re-anchors the schedule and lifts a `/stop` pause: the pane
3218
+ // is busy on a genuine turn, or the transcript grew since last tick (a turn
3219
+ // that completed between ticks). Only a busy pane keeps looping.
3220
+ if (busy || grewSinceLast) {
3221
+ k.realMtime = mtime;
3222
+ if (a.keepaliveOff && (busy ? now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS : true))
3223
+ a.keepaliveOff = false;
3224
+ if (busy)
3225
+ continue;
3226
+ }
3227
+ if (a.keepaliveOff)
3228
+ continue; // paused by /stop until real activity returns
3229
+ const idleSinceTouch = now - mtime; // cache warmth: last touch (real OR ping)
3230
+ const realIdle = now - k.realMtime; // user cutoff: last REAL work only
3231
+ if (idleSinceTouch < idleTriggerMs)
3232
+ continue; // cache still comfortably warm
3233
+ if (idleSinceTouch >= ttlMs)
3234
+ continue; // cache already cold — a ping would cold-rewrite for nothing
3235
+ if (realIdle >= maxIdleMs)
3236
+ continue; // real work too old — stop, let it die (你的「超5分钟不保活」)
3237
+ k.pinging = true;
3238
+ k.pingMtime = mtime;
3239
+ await fireKeepalive(a);
3240
+ }
3241
+ }
3242
+ catch (e) {
3243
+ log.warn({ err: e.message }, "keepalive tick failed");
3244
+ }
3245
+ finally {
3246
+ keepaliveTicking = false;
3247
+ }
3248
+ };
3249
+ const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
3063
3250
  return {
3064
3251
  attach,
3065
3252
  peers,
@@ -3273,7 +3460,17 @@ export const startMirror = (deps) => {
3273
3460
  const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
3274
3461
  if (r.code !== 0)
3275
3462
  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");
3463
+ // /stop also pauses keepalive: the user is deliberately quieting this
3464
+ // session, so stop poking it too. A real turn (WeCom inbound, or the pane
3465
+ // going busy AFTER the resume grace) lifts the pause and re-earns the
3466
+ // budget. Stamping keepaliveOffAt gates the busy-resume so the ping that
3467
+ // may be mid-flight right now can't immediately self-resume.
3468
+ a.keepaliveOff = true;
3469
+ a.keepaliveOffAt = Date.now();
3470
+ a.keepalive = undefined; // re-anchors cleanly on resume
3471
+ // Any in-flight ping's quiet window + detail turn are left to close on their
3472
+ // own turn_end (or the 60s fail-safe), so the interrupted pong stays out of chat.
3473
+ log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /stop — Esc sent to pane, keepalive paused");
3277
3474
  return { ok: true };
3278
3475
  },
3279
3476
  submitPane: async (target) => {
@@ -3325,6 +3522,7 @@ export const startMirror = (deps) => {
3325
3522
  newSession,
3326
3523
  shutdown: () => {
3327
3524
  clearInterval(paneDriftTimer);
3525
+ clearInterval(keepaliveTimer);
3328
3526
  for (const a of bySessionId.values()) {
3329
3527
  if (a.outbound?.kind === "deferred")
3330
3528
  clearTimeout(a.outbound.timer);
@@ -3354,6 +3552,14 @@ export const startMirror = (deps) => {
3354
3552
  }
3355
3553
  return;
3356
3554
  }
3555
+ // A real inbound is a new conversation → lift any `/stop` keepalive pause
3556
+ // and re-anchor to now (this turn is real work). Pane-side new turns
3557
+ // re-anchor via the tick's busy branch; this covers the WeCom-driven path.
3558
+ a.keepaliveOff = false;
3559
+ if (a.keepalive) {
3560
+ a.keepalive.realMtime = Date.now();
3561
+ a.keepalive.seenMtime = Date.now();
3562
+ }
3357
3563
  // Finalize prior live stream (if any) so this new turn renders into its
3358
3564
  // own message bubble. Then open a fresh stream tied to the new frame and
3359
3565
  // ack immediately so WeCom doesn't time out while inject queues.