wezard 2.3.0 → 2.4.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 +3 -3
- package/README.md +5 -5
- package/config.example.jsonc +12 -0
- package/dist/daemon/approval.js +103 -15
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/chat-name.js +15 -0
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/config-api.js +281 -70
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/danger.js +5 -7
- package/dist/daemon/danger.js.map +1 -1
- package/dist/daemon/detail.js +10 -2
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/handoff.js +4 -3
- package/dist/daemon/handoff.js.map +1 -1
- package/dist/daemon/idle-nudge.js +55 -0
- package/dist/daemon/idle-nudge.js.map +1 -0
- package/dist/daemon/inbound.js +17 -14
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +957 -225
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +122 -13
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +97 -84
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/model-select.js +3 -0
- package/dist/daemon/model-select.js.map +1 -1
- package/dist/daemon/notices.js +172 -33
- package/dist/daemon/notices.js.map +1 -1
- package/dist/daemon/peers.js +105 -78
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/pending-items.js +171 -0
- package/dist/daemon/pending-items.js.map +1 -0
- package/dist/daemon/receipts.js +58 -24
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/route.js +76 -15
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +2 -2
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/steward-guard.js +32 -0
- package/dist/daemon/steward-guard.js.map +1 -0
- package/dist/daemon/top-only-nudge.js +29 -0
- package/dist/daemon/top-only-nudge.js.map +1 -0
- package/dist/daemon/wizard.js +114 -61
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +75 -25
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +23 -13
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/config-meta.js +91 -0
- package/dist/shared/config-meta.js.map +1 -0
- package/dist/shared/config-writer.js +5 -6
- package/dist/shared/config-writer.js.map +1 -1
- package/dist/shared/config.js +279 -294
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +121 -32
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/keepalive.js +5 -5
- package/dist/shared/keepalive.js.map +1 -1
- package/dist/shared/role-render.js +52 -12
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +84 -26
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/world.js +82 -13
- package/dist/shared/world.js.map +1 -1
- package/hooks/pre-tool-use.sh +14 -12
- package/package.json +1 -1
- package/web/chat.css +63 -37
- package/web/chat.js +513 -283
|
@@ -38,7 +38,7 @@ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagH
|
|
|
38
38
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
39
39
|
import { randomTip } from "./tips.js";
|
|
40
40
|
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
41
|
-
import { stripAnsi, paneIsBusy,
|
|
41
|
+
import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
|
|
42
42
|
import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
|
|
43
43
|
// PATH augmentation: launchd / systemd start the daemon
|
|
44
44
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
@@ -152,6 +152,9 @@ const findJsonlBySid = (sid, only) => {
|
|
|
152
152
|
// codebuddy persists message/function_call records instead, so route every
|
|
153
153
|
// parsed line through the owning backend's adapter first (identity for claude).
|
|
154
154
|
const normalizeForPath = (path, raw) => backendForPath(path).normalizeTranscriptLine(raw);
|
|
155
|
+
// /model、/effort 的本地回执 (spawn 兜底路径 / set_model 敲出来的) 是守护进程自己的
|
|
156
|
+
// 操作痕迹, 不是聊天内容 —— 不镜像、不进详情页。
|
|
157
|
+
const MODEL_RECEIPT_RE = /^(?:Set model to|Kept model as|Set effort level to|Kept effort|Switch model)\b/;
|
|
155
158
|
// Local-command noise on user lines: Claude marks the caveat / command-stdout
|
|
156
159
|
// records isMeta; codebuddy persists them as PLAIN user messages (no isMeta
|
|
157
160
|
// field at all). Predicates looking for the first REAL user line must skip
|
|
@@ -525,6 +528,8 @@ const renderLine = (raw, deps) => {
|
|
|
525
528
|
const stdoutMatch = c.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
|
|
526
529
|
if (stdoutMatch && stdoutMatch[1]) {
|
|
527
530
|
const skillOutput = stdoutMatch[1].replace(/\[[0-9;]*m/g, "").trim();
|
|
531
|
+
if (MODEL_RECEIPT_RE.test(skillOutput))
|
|
532
|
+
return out;
|
|
528
533
|
if (skillOutput) {
|
|
529
534
|
out.push({ kind: "skill_output", body: `⚙️ ${skillOutput}` });
|
|
530
535
|
}
|
|
@@ -1432,9 +1437,9 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
|
|
|
1432
1437
|
// 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
|
|
1433
1438
|
// 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
|
|
1434
1439
|
// 每次补之前再问一遍证人, 已提交就不补。
|
|
1440
|
+
/** 具名粘贴 buffer 的序号 (见 loadAndPaste)。 */
|
|
1441
|
+
let pasteSeq = 0;
|
|
1435
1442
|
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
|
|
1436
|
-
/** 具名粘贴 buffer 的序号 (见 loadAndPaste)。 */
|
|
1437
|
-
let pasteSeq = 0;
|
|
1438
1443
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1439
1444
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
1440
1445
|
// end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
|
|
@@ -1777,18 +1782,13 @@ export const startMirror = (deps) => {
|
|
|
1777
1782
|
return true;
|
|
1778
1783
|
};
|
|
1779
1784
|
// Keepalive ping detector for the tail's user-line judgment — the SAME
|
|
1780
|
-
// normalized-prefix signature set keepaliveTick feeds keepaliveStamps
|
|
1781
|
-
//
|
|
1782
|
-
//
|
|
1783
|
-
// as a ping. Content is the source of truth
|
|
1784
|
-
// echo-TTLs lose it across reloads/replays.
|
|
1785
|
-
const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping
|
|
1786
|
-
|
|
1787
|
-
// `resumePing` is excluded on purpose: the turn it opens is real work resumed.
|
|
1788
|
-
const warmerSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
|
|
1789
|
-
// 行上只认保温那一句: 报错之后的续跑 (resumePing) 开出的是那一轮的接续, 整轮吞掉的话
|
|
1790
|
-
// 续出来的结论既不进群也没有频道。续跑若只回了 pong, 回复侧的那道保险照样吞。
|
|
1791
|
-
const isKeepalivePing = (text) => isKeepalivePingText(text, warmerSigs);
|
|
1785
|
+
// normalized-prefix signature set keepaliveTick feeds keepaliveStamps (the
|
|
1786
|
+
// configured ping + the bare "ping" legacy streak form), so renderLine, the
|
|
1787
|
+
// tick, the peer readers (peek / wait / roster summary) and the transcript
|
|
1788
|
+
// parser all agree on what counts as a ping. Content is the source of truth
|
|
1789
|
+
// for the swallow: timers and echo-TTLs lose it across reloads/replays.
|
|
1790
|
+
const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
|
|
1791
|
+
const isKeepalivePing = (text) => isKeepalivePingText(text, pingSigs);
|
|
1792
1792
|
// ── Typewriter stream lifecycle ────────────────────────────────────
|
|
1793
1793
|
// WeCom spec: server polls us for stream refreshes for up to 6 min from the
|
|
1794
1794
|
// original inbound. SDK queues replyStream calls per req_id, sends serially
|
|
@@ -2438,6 +2438,13 @@ export const startMirror = (deps) => {
|
|
|
2438
2438
|
}
|
|
2439
2439
|
return opening?.query;
|
|
2440
2440
|
}
|
|
2441
|
+
// 私聊派来的活 (不是回执) 这一轮必是私聊, 不论印章带没带频道: 进群的只能是人问的事的
|
|
2442
|
+
// 交代与显式公开。带 task 的 fork 分身就只有出处、没有频道印章, 退回 home 就把私聊派的活答进了群。
|
|
2443
|
+
if (from?.kind === "peer" && !from.public && !from.receipt) {
|
|
2444
|
+
a.channel = "";
|
|
2445
|
+
a.speaker = undefined;
|
|
2446
|
+
a.replyTo = undefined;
|
|
2447
|
+
}
|
|
2441
2448
|
return undefined;
|
|
2442
2449
|
};
|
|
2443
2450
|
/** 本轮落 turn 记录的频道字段。 */
|
|
@@ -2473,11 +2480,21 @@ export const startMirror = (deps) => {
|
|
|
2473
2480
|
const t = said.trim();
|
|
2474
2481
|
const hit = (x) => pick(x) && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k)));
|
|
2475
2482
|
const seals = a.seals ?? [];
|
|
2476
|
-
const
|
|
2477
|
-
|
|
2478
|
-
|
|
2483
|
+
const hits = seals.filter(hit);
|
|
2484
|
+
// CLI 把排队的几句并成一行, 这一轮只能有一个去向: 里面有私聊的那句就是私聊 —— 它的答复不能
|
|
2485
|
+
// 跟着人的话进群; 没有才轮到人从聊天说的那句 (它的轮次与气泡早已建好)。
|
|
2486
|
+
const first = hits.find((x) => x.channel === "") ?? hits.find((x) => !!x.turn) ?? hits[0];
|
|
2487
|
+
if (!first)
|
|
2488
|
+
return undefined;
|
|
2489
|
+
a.seals = seals.filter((x) => !hit(x));
|
|
2490
|
+
hits.filter((x) => x !== first && x.turn && !x.activated).forEach((x) => mergedAway(a, x.turn));
|
|
2479
2491
|
return first;
|
|
2480
2492
|
};
|
|
2493
|
+
/** 人那句被并进了一轮私聊: 它预建的气泡与轮次收口成一个链接 —— 答复不进群, 也不让气泡挂到超时。 */
|
|
2494
|
+
const mergedAway = (a, q) => {
|
|
2495
|
+
void finishBubble(a, q.bubble, `${briefDetailLink(q.turnId, a.target)} (这句和一段私聊并成了一轮, 答复只在 rolepage)`, true);
|
|
2496
|
+
recordTurnClose(q.turnId);
|
|
2497
|
+
};
|
|
2481
2498
|
/** 人在聊天里说的那一句接成活跃 turn: 收掉在跑的那一轮, 频道与发话人取它自己的印章。
|
|
2482
2499
|
* `at` = 那一行落盘的时刻: 排队的那句从这一刻才开始被处理, 轮次的起点以它为准; 闲时当场接上就是投递那一刻。 */
|
|
2483
2500
|
const activateDispatch = (a, s, at = s.at) => {
|
|
@@ -3347,7 +3364,7 @@ export const startMirror = (deps) => {
|
|
|
3347
3364
|
// codebuddy 对 AskUserQuestion 不在提问时触发 PreToolUse hook — 先弹本地
|
|
3348
3365
|
// 面板, hook 只在面板被提交后才到达 (实测可延迟数小时)。这里从 jsonl 提前
|
|
3349
3366
|
// 看到 function_call, 直接驱动 vote 卡让远端可答; 点选后注入一段触发文本
|
|
3350
|
-
// 提交本地面板, hook 到达时以 deny+reason
|
|
3367
|
+
// 提交本地面板, hook 到达时以 deny+reason 覆盖答案 (claude 路径改走 allow+updatedInput)。
|
|
3351
3368
|
// claude 系 hook 即时触发, 不走此路 (否则双重发卡)。
|
|
3352
3369
|
if (c.name === "AskUserQuestion"
|
|
3353
3370
|
&& backendForPath(a.jsonlPath).name === "codebuddy"
|
|
@@ -4495,7 +4512,11 @@ export const startMirror = (deps) => {
|
|
|
4495
4512
|
const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
|
|
4496
4513
|
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
|
|
4497
4514
|
const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
|
|
4498
|
-
|
|
4515
|
+
// 群的默认会话 (管家) 没点名模型 / 档位时用 models.router。只管这里新起的 —— 死 pane
|
|
4516
|
+
// 重生走绑定里记下的模型, 不经过这里; 已在跑的管家也不动 (换模型会让缓存整份重读)。
|
|
4517
|
+
const router = !tagOfKey(target) && !isInternalKey(target) ? cfg.models.router : undefined;
|
|
4518
|
+
const effModel = opts?.model?.trim() || router?.model || undefined;
|
|
4519
|
+
const effEffort = opts?.effort ?? parseEffort(prev?.effort ?? deps.store.get(target)?.effort) ?? router?.effort;
|
|
4499
4520
|
if (prev?.tmuxPane) {
|
|
4500
4521
|
// Best-effort kill; ignore errors (pane may already be dead).
|
|
4501
4522
|
void runTmux(["kill-pane", "-t", prev.tmuxPane]);
|
|
@@ -4512,7 +4533,7 @@ export const startMirror = (deps) => {
|
|
|
4512
4533
|
windowName: windowName ?? target,
|
|
4513
4534
|
cwdOverride: eff,
|
|
4514
4535
|
cli: effCli,
|
|
4515
|
-
model:
|
|
4536
|
+
model: effModel,
|
|
4516
4537
|
knownModels: modelSightings(effCli, [target, base]),
|
|
4517
4538
|
effort: effEffort,
|
|
4518
4539
|
systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
|
|
@@ -4534,7 +4555,7 @@ export const startMirror = (deps) => {
|
|
|
4534
4555
|
// means this CLI's default, not the dead pane's model. Store what
|
|
4535
4556
|
// spawnTmuxClaude actually confirmed via `/model` (r.model), not the
|
|
4536
4557
|
// raw request — a later respawn's fast path then matches on try one.
|
|
4537
|
-
model: r.model ??
|
|
4558
|
+
model: r.model ?? effModel ?? "",
|
|
4538
4559
|
effort: r.effort ?? "",
|
|
4539
4560
|
keepaliveDisabled: opts?.keepalive === undefined ? undefined : !opts.keepalive,
|
|
4540
4561
|
});
|
|
@@ -4674,7 +4695,7 @@ export const startMirror = (deps) => {
|
|
|
4674
4695
|
return { ok: false, reason: r.reason, inherited: false };
|
|
4675
4696
|
// 分叉文件不是开机就有的: CLI 在这个 pane **收到第一条消息**的那一刻才把父亲的
|
|
4676
4697
|
// transcript 复制成新文件。所以必须先说一句话, 分身才会有自己的 sid —— 这句话
|
|
4677
|
-
// 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」)
|
|
4698
|
+
// 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」)。
|
|
4678
4699
|
const hello = args.bootstrap?.trim()
|
|
4679
4700
|
|| "(wezard 分身初始化)用一句话说明: 你是谁、从谁那里继承了哪些上下文、准备接什么活。不要复述这条指令。";
|
|
4680
4701
|
rememberInject(hello);
|
|
@@ -4716,7 +4737,7 @@ export const startMirror = (deps) => {
|
|
|
4716
4737
|
const spawned = byTarget.get(args.target);
|
|
4717
4738
|
if (spawned) {
|
|
4718
4739
|
// muteUntilInject 是给"全新 pane 的 greeting 噪音"准备的; 分身的开场白是正经
|
|
4719
|
-
// 内容 (
|
|
4740
|
+
// 内容 (rolepage 上它的第一轮), 所以这里不静音 —— 进不进群由下面的频道印章定。
|
|
4720
4741
|
spawned.justSpawned = true;
|
|
4721
4742
|
spawned.keepaliveOff = true;
|
|
4722
4743
|
spawned.keepaliveOffAt = Date.now();
|
|
@@ -4728,6 +4749,8 @@ export const startMirror = (deps) => {
|
|
|
4728
4749
|
spawned.pendingFrom = { from: args.bootstrapFrom.from, at: Date.now() };
|
|
4729
4750
|
spawned.pendingBriefQuery = args.bootstrapFrom.query;
|
|
4730
4751
|
}
|
|
4752
|
+
// 开场白不是谁在群里问的: 带活的是私聊派活, 不带的是守护进程让它自报家门 —— 都只进 rolepage。
|
|
4753
|
+
spawned.pendingChannel = { channel: "", at: Date.now() };
|
|
4731
4754
|
}
|
|
4732
4755
|
lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
|
|
4733
4756
|
afterSpawn();
|
|
@@ -4971,6 +4994,10 @@ export const startMirror = (deps) => {
|
|
|
4971
4994
|
const check = async () => {
|
|
4972
4995
|
if (done)
|
|
4973
4996
|
return;
|
|
4997
|
+
if (aborted()) {
|
|
4998
|
+
finish({ idle: false, reason: "stopped" });
|
|
4999
|
+
return;
|
|
5000
|
+
}
|
|
4974
5001
|
const v = idleVerdict(target);
|
|
4975
5002
|
if (v ?? !(await isBusy(target)))
|
|
4976
5003
|
finish({ idle: true });
|
|
@@ -4979,23 +5006,19 @@ export const startMirror = (deps) => {
|
|
|
4979
5006
|
const tick = setInterval(() => void check(), IDLE_SAFETY_POLL_MS);
|
|
4980
5007
|
const timer = setTimeout(() => finish({ idle: false, reason: "idle wait timed out" }), timeoutMs);
|
|
4981
5008
|
void check();
|
|
4982
|
-
if (aborted()) {
|
|
4983
|
-
finish({ idle: false, reason: "stopped" });
|
|
4984
|
-
return;
|
|
4985
|
-
}
|
|
4986
5009
|
});
|
|
4987
5010
|
};
|
|
4988
5011
|
const lastText = (target) => {
|
|
4989
5012
|
const p = jsonlOf(target);
|
|
4990
|
-
return p ? lastAssistantText(p, 4000,
|
|
5013
|
+
return p ? lastAssistantText(p, 4000, pingSigs) : "";
|
|
4991
5014
|
};
|
|
4992
5015
|
const replySince = (target, sinceMs = 0) => {
|
|
4993
5016
|
const p = jsonlOf(target);
|
|
4994
|
-
return p ? lastReply(p, sinceMs,
|
|
5017
|
+
return p ? lastReply(p, sinceMs, pingSigs) : "";
|
|
4995
5018
|
};
|
|
4996
5019
|
const replyToPeerSince = (target, fromName, sinceMs = 0, turn) => {
|
|
4997
5020
|
const p = jsonlOf(target);
|
|
4998
|
-
return p ? replyToPeerIn(p, fromName, sinceMs,
|
|
5021
|
+
return p ? replyToPeerIn(p, fromName, sinceMs, pingSigs, turn) : undefined;
|
|
4999
5022
|
};
|
|
5000
5023
|
/** 绑着的 pane 还能用 (存在、且不是别人的窗口) —— 不重生、不猜。 */
|
|
5001
5024
|
const paneLiveOf = async (target) => {
|
|
@@ -5062,7 +5085,7 @@ export const startMirror = (deps) => {
|
|
|
5062
5085
|
paneAlive,
|
|
5063
5086
|
busy: paneAlive ? paneIsBusy(await capturePaneTail(pane, 12)) : false,
|
|
5064
5087
|
lastActivity,
|
|
5065
|
-
summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80,
|
|
5088
|
+
summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80, pingSigs) : "(未绑定会话)",
|
|
5066
5089
|
self: t === self,
|
|
5067
5090
|
};
|
|
5068
5091
|
};
|
|
@@ -5119,24 +5142,27 @@ export const startMirror = (deps) => {
|
|
|
5119
5142
|
return { ok: false, reason: "它还没有 transcript (没接过第一句话) —— send_peer 跟它说一句就有了", busy };
|
|
5120
5143
|
// `n` 数的是来回: 每个来回给问话和它最新的一句 (跑完的是终句, 还在跑的是途中
|
|
5121
5144
|
// 最近说的那句) —— 途中的其余叙述不给, 那是过程不是结论。
|
|
5122
|
-
const rounds = talkRounds(jsonl, n,
|
|
5145
|
+
const rounds = talkRounds(jsonl, n, pingSigs).slice(-n);
|
|
5123
5146
|
const dialog = renderDialog(rounds.flatMap((r) => (r.length > 1 ? [r[0], r.at(-1)] : r)), 800, 4000);
|
|
5124
5147
|
const waiting = busy ? [] : openToolUses(jsonl);
|
|
5125
5148
|
return dialog ? { ok: true, dialog, busy, waiting } : { ok: false, reason: "transcript 里还没有对话", busy, waiting };
|
|
5126
5149
|
};
|
|
5127
5150
|
// ── Prompt-cache keepalive ──────────────────────────────────────────
|
|
5128
|
-
// Anthropic's prompt cache expires
|
|
5129
|
-
//
|
|
5130
|
-
//
|
|
5131
|
-
//
|
|
5132
|
-
// request (cache-read
|
|
5133
|
-
// anchored to the last REAL (non-ping) activity, NOT to
|
|
5134
|
-
// dead session is never kept warm forever
|
|
5135
|
-
// `maxIdleSec` we stop and let the cache die.
|
|
5151
|
+
// Anthropic's prompt cache expires a TTL (5min API default, 1h on a Claude Code
|
|
5152
|
+
// subscription) after the start of the last request. A pane that goes idle
|
|
5153
|
+
// (agent parked on a peer, background task running) lets the whole context fall
|
|
5154
|
+
// out of cache — the next real turn then re-writes it. We inject a tiny ping just
|
|
5155
|
+
// before expiry so the model makes one cheap request (cache-read) that slides the
|
|
5156
|
+
// TTL forward. The budget is anchored to the last REAL (non-ping) activity, NOT to
|
|
5157
|
+
// our own pings — so a dead session is never kept warm forever.
|
|
5136
5158
|
// After `/stop`, ignore pane-busy as a resume signal for this long — long
|
|
5137
5159
|
// enough for an in-flight ping (interrupted by the same /stop's Esc) to settle,
|
|
5138
5160
|
// so it can't self-resume the pause. A genuine new turn after this window does.
|
|
5139
5161
|
const RESUME_GRACE_MS = 30_000;
|
|
5162
|
+
// A cache untouched this long is not kept warm: on a 1h entry the ping would come
|
|
5163
|
+
// ~59min into the idle stretch, re-reading the whole context to cover a gap the
|
|
5164
|
+
// TTL already covered — warming only pays on short (5min) entries.
|
|
5165
|
+
const KEEPALIVE_MAX_IDLE_MS = 10 * 60_000;
|
|
5140
5166
|
// Seed the keepalive clocks from message-turn timestamps (keepaliveStamps), never
|
|
5141
5167
|
// from file mtime — non-message lines (file-history-snapshot / ai-title / mode)
|
|
5142
5168
|
// bump mtime without being a model turn, which would fake a long-dead session
|
|
@@ -5156,35 +5182,30 @@ export const startMirror = (deps) => {
|
|
|
5156
5182
|
round = s.streak;
|
|
5157
5183
|
}
|
|
5158
5184
|
catch { /* unreadable tail */ }
|
|
5159
|
-
return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath),
|
|
5185
|
+
return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), cacheAt: cacheTouchMs(jsonlPath) };
|
|
5160
5186
|
};
|
|
5161
|
-
const fireKeepalive = async (a
|
|
5187
|
+
const fireKeepalive = async (a) => {
|
|
5162
5188
|
const kc = cfg.wrc.mirror.keepalive;
|
|
5163
|
-
//
|
|
5164
|
-
// — inviting the model to finish genuinely-unfinished work, or to reply
|
|
5165
|
-
// "pong" if it was legitimately parked. Otherwise the plain warmer, the SAME
|
|
5166
|
-
// full instruction every round: a bare "ping" used to be sent from round 2 on
|
|
5189
|
+
// The SAME full instruction every round: a bare "ping" used to be sent from round 2 on
|
|
5167
5190
|
// (the instruction was already in context), but the model reads a bare "ping"
|
|
5168
5191
|
// as an open turn and answers it however it likes — anything other than
|
|
5169
5192
|
// "pong" un-swallows the heartbeat into chat and re-anchors the clocks as
|
|
5170
5193
|
// real activity. Restating the instruction is a few tokens of cache-write and
|
|
5171
5194
|
// buys a deterministic reply. keepaliveStamps matches every form, so none
|
|
5172
5195
|
// reads as real activity. (round is incremented below, after the inject lands.)
|
|
5173
|
-
const text =
|
|
5196
|
+
const text = kc.ping;
|
|
5174
5197
|
// Suppress the pane→WeCom echo of the ping user line, then swallow the whole
|
|
5175
5198
|
// ping turn (reply included). The 60s fail-safe clears the quiet window if
|
|
5176
5199
|
// the turn somehow never emits turn_end, so a later real turn is never muted;
|
|
5177
5200
|
// it also closes the detail turn so it can't hang open in the chat timeline.
|
|
5178
5201
|
rememberInject(text);
|
|
5179
|
-
|
|
5180
|
-
|
|
5181
|
-
a.
|
|
5182
|
-
a.
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
}
|
|
5187
|
-
}, 60_000);
|
|
5202
|
+
a.keepaliveQuiet = setTimeout(() => {
|
|
5203
|
+
a.keepaliveQuiet = undefined;
|
|
5204
|
+
if (a.keepaliveTurnId) {
|
|
5205
|
+
recordTurnClose(a.keepaliveTurnId);
|
|
5206
|
+
a.keepaliveTurnId = undefined;
|
|
5207
|
+
}
|
|
5208
|
+
}, 60_000);
|
|
5188
5209
|
const r = await inject({
|
|
5189
5210
|
text, images: [], cfg,
|
|
5190
5211
|
log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
|
|
@@ -5206,7 +5227,7 @@ export const startMirror = (deps) => {
|
|
|
5206
5227
|
// closed on the ping's turn_end (or the fail-safe). Kept out of chat.
|
|
5207
5228
|
// The tail's keepalive_start (content match on the user line) may have
|
|
5208
5229
|
// opened this turn already — only open when it hasn't.
|
|
5209
|
-
if (!
|
|
5230
|
+
if (!a.keepaliveTurnId) {
|
|
5210
5231
|
const turnId = newTurnId();
|
|
5211
5232
|
a.keepaliveTurnId = turnId;
|
|
5212
5233
|
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
|
|
@@ -5219,14 +5240,11 @@ export const startMirror = (deps) => {
|
|
|
5219
5240
|
return;
|
|
5220
5241
|
keepaliveTicking = true;
|
|
5221
5242
|
try {
|
|
5222
|
-
|
|
5223
|
-
// error should be resumed minutes later, whatever the cache tier.
|
|
5224
|
-
const stallQuietMs = Math.max(30, kc.ttlSec - kc.marginSec) * 1000;
|
|
5225
|
-
const pingSigs = [normAssistant(kc.ping).slice(0, 40), normAssistant(kc.resumePing).slice(0, 40)].filter((s) => s.length > 0);
|
|
5243
|
+
const pingSigs = [normAssistant(kc.ping).slice(0, 40)].filter((s) => s.length > 0);
|
|
5226
5244
|
const now = Date.now();
|
|
5227
5245
|
for (const a of byTarget.values()) {
|
|
5228
5246
|
if (a.keepaliveDisabled)
|
|
5229
|
-
continue; // sticky spawn-time opt-out — never pings
|
|
5247
|
+
continue; // sticky spawn-time opt-out — never pings
|
|
5230
5248
|
if (!a.tmuxPane)
|
|
5231
5249
|
continue; // spawn-mode: no live pane to warm
|
|
5232
5250
|
if (a.migrationWatcher)
|
|
@@ -5261,6 +5279,7 @@ export const startMirror = (deps) => {
|
|
|
5261
5279
|
k.lastRealMs = mtime;
|
|
5262
5280
|
} // backend without timestamps → fall back to mtime
|
|
5263
5281
|
k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
|
|
5282
|
+
k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
|
|
5264
5283
|
}
|
|
5265
5284
|
k.seenMtime = mtime;
|
|
5266
5285
|
// Round reset / real-activity re-anchor keys off REAL turns only — a
|
|
@@ -5298,34 +5317,25 @@ export const startMirror = (deps) => {
|
|
|
5298
5317
|
}
|
|
5299
5318
|
if (a.keepaliveOff)
|
|
5300
5319
|
continue; // paused by /stop until real activity returns
|
|
5301
|
-
|
|
5320
|
+
// Cache clock = start of the newest answered request (TTL runs from there),
|
|
5321
|
+
// not the last turn's write time; no request on record → the turn clock.
|
|
5322
|
+
const sinceCache = now - (k.cacheAt || k.lastMs || mtime);
|
|
5302
5323
|
// The TTL the session's cache entries were actually written with (1h on a
|
|
5303
5324
|
// Claude Code subscription), not a guess: pinging at the 5min cadence
|
|
5304
5325
|
// against a 1h entry re-reads the whole context ~14x per hour for nothing.
|
|
5305
5326
|
const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
|
|
5306
5327
|
const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
|
|
5307
|
-
if (
|
|
5308
|
-
continue; //
|
|
5309
|
-
if (
|
|
5328
|
+
if (sinceCache < idleTriggerMs)
|
|
5329
|
+
continue; // cache still comfortably warm
|
|
5330
|
+
if (sinceCache >= ttlMs)
|
|
5310
5331
|
continue; // cache already cold — a ping would cold-rewrite for nothing
|
|
5332
|
+
if (sinceCache > KEEPALIVE_MAX_IDLE_MS)
|
|
5333
|
+
continue; // idle past 10min (a long-TTL entry) — not worth warming
|
|
5311
5334
|
if (k.round >= kc.rounds)
|
|
5312
5335
|
continue; // budget spent — let it go cold
|
|
5313
|
-
// Stall recovery, decided by RULE only (no model self-judgment) and from
|
|
5314
|
-
// the transcript's structure only — never the screen: the turn is still
|
|
5315
|
-
// unfinished (died on a synthetic API error, or a tool result nobody
|
|
5316
|
-
// answered) and has been quiet for the whole idle window. Send the resume
|
|
5317
|
-
// instruction instead of the plain warmer. A transcript found un-stalled a
|
|
5318
|
-
// minute past the quiet window stays so until a new turn lands (= lastMs
|
|
5319
|
-
// moves) — remembered, so a 1h idle stretch reads it once, not every tick.
|
|
5320
|
-
const stalled = kc.resumeOnStall && k.stallFree !== k.lastMs
|
|
5321
|
-
&& transcriptStalled(a.jsonlPath, keepalivePingSigs(kc.ping, kc.resumePing), stallQuietMs, now);
|
|
5322
|
-
if (!stalled && idleSinceTouch >= stallQuietMs + 60_000)
|
|
5323
|
-
k.stallFree = k.lastMs;
|
|
5324
|
-
if (!stalled && idleSinceTouch < idleTriggerMs)
|
|
5325
|
-
continue; // cache still comfortably warm
|
|
5326
5336
|
k.pinging = true;
|
|
5327
5337
|
k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
|
|
5328
|
-
await fireKeepalive(a
|
|
5338
|
+
await fireKeepalive(a);
|
|
5329
5339
|
}
|
|
5330
5340
|
}
|
|
5331
5341
|
catch (e) {
|
|
@@ -5482,6 +5492,7 @@ export const startMirror = (deps) => {
|
|
|
5482
5492
|
isBusy,
|
|
5483
5493
|
idleNow,
|
|
5484
5494
|
parkedNow,
|
|
5495
|
+
idleVerdict,
|
|
5485
5496
|
untilIdle,
|
|
5486
5497
|
setModel: async (target, wanted, scope) => {
|
|
5487
5498
|
const a = byTarget.get(target);
|
|
@@ -5717,6 +5728,9 @@ export const startMirror = (deps) => {
|
|
|
5717
5728
|
pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
|
|
5718
5729
|
const sid = a.sessionId;
|
|
5719
5730
|
const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
|
|
5731
|
+
// 同 dispatch: 只有刚生 / 刚重生的 pane 才要冷启动的宽限 —— 对一个热着、正忙的 pane 贴 `now`
|
|
5732
|
+
// 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
|
|
5733
|
+
let fresh = a.justSpawned === true;
|
|
5720
5734
|
if (!paneAlive) {
|
|
5721
5735
|
log.warn({ target, sessionId: sid, oldPane: a.tmuxPane }, "injectText: pane not alive, respawning");
|
|
5722
5736
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
@@ -5731,9 +5745,8 @@ export const startMirror = (deps) => {
|
|
|
5731
5745
|
a.runningCwd = r.cwd;
|
|
5732
5746
|
deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, model: a.model || undefined, effort: a.effort || undefined, pendingCwd: a.pendingCwd || undefined });
|
|
5733
5747
|
startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
|
|
5748
|
+
fresh = true;
|
|
5734
5749
|
}
|
|
5735
|
-
// freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
|
|
5736
|
-
// is still warming up so the verifier in injectViaTmux needs the slack.
|
|
5737
5750
|
// 同 dispatch: 在 inject 之前解除静默, 防止验证循环中 LLM 回复被永久吞掉。
|
|
5738
5751
|
a.muteUntilInject = false;
|
|
5739
5752
|
a.justSpawned = false;
|
|
@@ -5743,7 +5756,7 @@ export const startMirror = (deps) => {
|
|
|
5743
5756
|
const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
5744
5757
|
const r = await inject({
|
|
5745
5758
|
text: full, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
5746
|
-
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn:
|
|
5759
|
+
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
|
|
5747
5760
|
});
|
|
5748
5761
|
if (r.ok && a.tmuxPane) {
|
|
5749
5762
|
if (preClearBaseline) {
|