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.
- package/CLAUDE.md +7 -0
- package/README.md +129 -212
- package/dist/daemon/inbound.js +6 -105
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +152 -1
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +208 -2
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +34 -0
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/tips.js +1 -1
- package/dist/daemon/tips.js.map +1 -1
- package/dist/daemon/topics.js +0 -91
- package/dist/daemon/topics.js.map +1 -1
- package/dist/mcp/server.js +74 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/config.js +32 -1
- package/dist/shared/config.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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.
|