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.
- package/CLAUDE.md +7 -0
- package/README.md +129 -212
- package/dist/cli/init.js +1 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/sync.js +24 -3
- package/dist/cli/sync.js.map +1 -1
- package/dist/daemon/approval.js +6 -1
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/danger.js +2 -0
- package/dist/daemon/danger.js.map +1 -1
- 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 +200 -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 +33 -1
- package/dist/shared/config.js.map +1 -1
- package/launchd/com.wezard.svr.plist.template +46 -0
- package/package.json +3 -1
- package/systemd/wezard-svr.service.template +17 -0
|
@@ -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
|
-
|
|
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.
|