wezard 2.2.2 → 2.4.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 +9 -6
- package/README.md +5 -5
- package/config.example.jsonc +12 -0
- package/dist/cli/init.js +2 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/daemon/approval.js +90 -6
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/chat-log.js +6 -1
- package/dist/daemon/chat-log.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 +265 -70
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/danger.js +15 -0
- package/dist/daemon/danger.js.map +1 -1
- package/dist/daemon/detail.js +49 -0
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/handoff.js +12 -5
- 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 +19 -14
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +1509 -259
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +180 -32
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +15 -0
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/memory-steward.js +124 -68
- package/dist/daemon/memory-steward.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +506 -124
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/model-select.js +102 -0
- package/dist/daemon/model-select.js.map +1 -1
- package/dist/daemon/notices.js +147 -33
- package/dist/daemon/notices.js.map +1 -1
- package/dist/daemon/peers.js +261 -124
- 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 +428 -80
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/route.js +133 -19
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +24 -13
- 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/task-registry.js +1 -1
- package/dist/daemon/task-registry.js.map +1 -1
- package/dist/daemon/tasks.js +12 -4
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/usage.js +4 -11
- package/dist/daemon/usage.js.map +1 -1
- package/dist/daemon/wizard-memory.js +36 -4
- package/dist/daemon/wizard-memory.js.map +1 -1
- package/dist/daemon/wizard.js +174 -87
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +209 -132
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/charter-view.js +35 -0
- package/dist/shared/charter-view.js.map +1 -0
- package/dist/shared/chat-http.js +134 -32
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +6 -3
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +17 -6
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +2 -0
- package/dist/shared/cli-backends.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 +273 -294
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/daemon-token.js +18 -0
- package/dist/shared/daemon-token.js.map +1 -0
- package/dist/shared/detail-render.js +233 -20
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +33 -12
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/effort.js +11 -0
- package/dist/shared/effort.js.map +1 -0
- package/dist/shared/keepalive.js +5 -5
- package/dist/shared/keepalive.js.map +1 -1
- package/dist/shared/model-prices.js +258 -0
- package/dist/shared/model-prices.js.map +1 -0
- package/dist/shared/pricing.js +55 -0
- package/dist/shared/pricing.js.map +1 -0
- package/dist/shared/reminder.js +18 -4
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/role-render.js +210 -8
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +104 -26
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/session-label.js +4 -0
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/task-file.js +16 -1
- package/dist/shared/task-file.js.map +1 -1
- package/dist/shared/turn-state.js +24 -0
- package/dist/shared/turn-state.js.map +1 -0
- package/dist/shared/world.js +116 -13
- package/dist/shared/world.js.map +1 -1
- package/dist/svr/index.js +2 -1
- package/dist/svr/index.js.map +1 -1
- package/hooks/pre-tool-use.sh +15 -3
- package/package.json +1 -1
- package/web/chat.css +226 -74
- package/web/chat.js +1075 -370
|
@@ -13,29 +13,32 @@
|
|
|
13
13
|
// Caveat: the user shouldn't be hammering the same session in their local TTY
|
|
14
14
|
// while a `--resume` injection is in flight; Claude Code locks aren't strict.
|
|
15
15
|
import { spawn } from "node:child_process";
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
16
17
|
import { existsSync, readdirSync, readFileSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
|
|
17
18
|
import { join, dirname } from "node:path";
|
|
18
19
|
import { expandHome, sanitizeId } from "../shared/paths.js";
|
|
19
20
|
import { augmentedPath } from "../shared/exec-path.js";
|
|
20
21
|
import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
|
|
21
|
-
import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
|
|
22
|
+
import { activeBackends, backendForPath, primaryBackend, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
|
|
22
23
|
import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } from "../shared/modal-pane.js";
|
|
23
24
|
import { bindCardChannel, hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
|
|
24
25
|
import { isAutoWindowActive } from "./session-cache.js";
|
|
25
26
|
import { noticeSuffixFor } from "./notices.js";
|
|
26
27
|
import { dangerOf } from "./danger.js";
|
|
27
|
-
import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
28
|
-
import { selectModel } from "./model-select.js";
|
|
28
|
+
import { renameWindow, runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
29
|
+
import { selectEffort, selectModel } from "./model-select.js";
|
|
30
|
+
import { parseEffort } from "../shared/effort.js";
|
|
29
31
|
import { hasRegistry, markTranscript, probeOf, sessionOnPane, sessionPanes, submittedSince, watchRegistry } from "./cc-session.js";
|
|
30
32
|
import { waitForIdle } from "./graph.js";
|
|
31
33
|
import { wizardStore } from "./wizard.js";
|
|
32
34
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
33
|
-
import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
|
|
34
|
-
import {
|
|
35
|
+
import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
|
|
36
|
+
import { errText } from "./last-response.js";
|
|
37
|
+
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, linkedTagHead, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey } from "../shared/session-label.js";
|
|
35
38
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
36
39
|
import { randomTip } from "./tips.js";
|
|
37
40
|
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
38
|
-
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";
|
|
39
42
|
import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
|
|
40
43
|
// PATH augmentation: launchd / systemd start the daemon
|
|
41
44
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
@@ -149,6 +152,9 @@ const findJsonlBySid = (sid, only) => {
|
|
|
149
152
|
// codebuddy persists message/function_call records instead, so route every
|
|
150
153
|
// parsed line through the owning backend's adapter first (identity for claude).
|
|
151
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/;
|
|
152
158
|
// Local-command noise on user lines: Claude marks the caveat / command-stdout
|
|
153
159
|
// records isMeta; codebuddy persists them as PLAIN user messages (no isMeta
|
|
154
160
|
// field at all). Predicates looking for the first REAL user line must skip
|
|
@@ -472,6 +478,10 @@ const recoverToolUse = (toolUseId, deps) => {
|
|
|
472
478
|
}
|
|
473
479
|
return undefined;
|
|
474
480
|
};
|
|
481
|
+
const lineTs = (line) => {
|
|
482
|
+
const ts = Date.parse(line.timestamp ?? "");
|
|
483
|
+
return Number.isNaN(ts) ? undefined : ts;
|
|
484
|
+
};
|
|
475
485
|
// Render one transcript line into tagged items. Caller decides batching.
|
|
476
486
|
const renderLine = (raw, deps) => {
|
|
477
487
|
let line;
|
|
@@ -499,6 +509,13 @@ const renderLine = (raw, deps) => {
|
|
|
499
509
|
const goal = line.type === "user" ? goalStartOf(line.message?.content) : undefined;
|
|
500
510
|
return goal ? [goal] : [];
|
|
501
511
|
}
|
|
512
|
+
// 插话: 正在跑的这一轮中途吃进去的那句, CLI 只落这一行 attachment, 没有 user 行 ——
|
|
513
|
+
// 不报到的话, 它的印章没人认领, 这句话在详情里就不存在。
|
|
514
|
+
if (line.type === "attachment" && line.attachment?.type === "queued_command" && typeof line.attachment.prompt === "string") {
|
|
515
|
+
const said = cleanUserText(line.attachment.prompt);
|
|
516
|
+
const ts = Date.parse(line.timestamp ?? "");
|
|
517
|
+
return said ? [{ kind: "user_inject", said, ts: Number.isNaN(ts) ? Date.now() : ts }] : [];
|
|
518
|
+
}
|
|
502
519
|
const out = [];
|
|
503
520
|
if (line.type === "user") {
|
|
504
521
|
const c = line.message?.content;
|
|
@@ -511,6 +528,8 @@ const renderLine = (raw, deps) => {
|
|
|
511
528
|
const stdoutMatch = c.match(/<local-command-stdout>([\s\S]*?)<\/local-command-stdout>/);
|
|
512
529
|
if (stdoutMatch && stdoutMatch[1]) {
|
|
513
530
|
const skillOutput = stdoutMatch[1].replace(/\[[0-9;]*m/g, "").trim();
|
|
531
|
+
if (MODEL_RECEIPT_RE.test(skillOutput))
|
|
532
|
+
return out;
|
|
514
533
|
if (skillOutput) {
|
|
515
534
|
out.push({ kind: "skill_output", body: `⚙️ ${skillOutput}` });
|
|
516
535
|
}
|
|
@@ -544,12 +563,13 @@ const renderLine = (raw, deps) => {
|
|
|
544
563
|
// cover reloads or replays; the user message itself always can.
|
|
545
564
|
if (deps.isKeepalivePing?.(text))
|
|
546
565
|
return [{ kind: "keepalive_start", body: text }];
|
|
566
|
+
// 自己注入的那一行不回显, 但要报个到: 它的印章 (谁说的、回复去哪) 在这一刻认领。
|
|
547
567
|
if (deps.isOwnInject(text))
|
|
548
|
-
return [
|
|
568
|
+
return [{ kind: "user_query", body: unwrapPasted(c).trim(), said: text, ts: lineTs(line) }];
|
|
549
569
|
const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
|
|
550
570
|
// includeUser 只决定"渲不渲染", 不决定"算不算边界" —— quiet 的这一条照样发出,
|
|
551
571
|
// onItem 消费完边界语义后自己丢掉。
|
|
552
|
-
out.push({ kind: "user_text", body: quoted, quiet: !deps.includeUser });
|
|
572
|
+
out.push({ kind: "user_text", body: quoted, quiet: !deps.includeUser, ts: lineTs(line) });
|
|
553
573
|
}
|
|
554
574
|
else if (Array.isArray(c) && hasImages(c) && !c.some((b) => b?.type === "tool_result")) {
|
|
555
575
|
// 带图的一句话: [text "[Image #1]…", image, …]。去掉占位符才与注入时记下的原文同形,
|
|
@@ -557,10 +577,10 @@ const renderLine = (raw, deps) => {
|
|
|
557
577
|
const raw = c.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("\n");
|
|
558
578
|
const text = cleanUserText(raw.replace(IMAGE_REF_RE, ""));
|
|
559
579
|
if (text && !deps.isOwnInject(text)) {
|
|
560
|
-
out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser });
|
|
580
|
+
out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser, ts: lineTs(line) });
|
|
561
581
|
}
|
|
562
582
|
else {
|
|
563
|
-
out.push({ kind: "user_query", body: unwrapPasted(raw).trim(), said: text });
|
|
583
|
+
out.push({ kind: "user_query", body: unwrapPasted(raw).trim(), said: text, ts: lineTs(line) });
|
|
564
584
|
}
|
|
565
585
|
}
|
|
566
586
|
else if (Array.isArray(c)) {
|
|
@@ -672,6 +692,7 @@ const renderLine = (raw, deps) => {
|
|
|
672
692
|
const u = line.message?.usage;
|
|
673
693
|
if (u) {
|
|
674
694
|
const model = typeof line.message?.model === "string" ? line.message.model : undefined;
|
|
695
|
+
const effort = typeof line.effort === "string" && line.effort ? line.effort : undefined;
|
|
675
696
|
const messageId = typeof line.message?.id === "string" ? line.message.id : undefined;
|
|
676
697
|
const rawIn = u.input_tokens ?? 0;
|
|
677
698
|
const cr = u.cache_read_input_tokens ?? 0;
|
|
@@ -685,6 +706,7 @@ const renderLine = (raw, deps) => {
|
|
|
685
706
|
out.push({
|
|
686
707
|
kind: "turn_usage",
|
|
687
708
|
model,
|
|
709
|
+
effort,
|
|
688
710
|
messageId,
|
|
689
711
|
usage: {
|
|
690
712
|
input,
|
|
@@ -849,6 +871,9 @@ export const startMirrorTail = (deps) => {
|
|
|
849
871
|
buffer = buffer.slice(nl + 1);
|
|
850
872
|
if (!line.trim())
|
|
851
873
|
continue;
|
|
874
|
+
// 压缩边界: 对话被摘要替换, 系统提示 (宪章) 不变 —— 同 /clear 一样要补记忆。
|
|
875
|
+
if (deps.onCompact && line.includes('"compact_boundary"'))
|
|
876
|
+
deps.onCompact();
|
|
852
877
|
for (const item of renderLine(line, deps))
|
|
853
878
|
deps.onItem(item);
|
|
854
879
|
}
|
|
@@ -1412,6 +1437,8 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
|
|
|
1412
1437
|
// 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
|
|
1413
1438
|
// 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
|
|
1414
1439
|
// 每次补之前再问一遍证人, 已提交就不补。
|
|
1440
|
+
/** 具名粘贴 buffer 的序号 (见 loadAndPaste)。 */
|
|
1441
|
+
let pasteSeq = 0;
|
|
1415
1442
|
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
|
|
1416
1443
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1417
1444
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
@@ -1452,10 +1479,14 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1452
1479
|
const loadAndPaste = async () => {
|
|
1453
1480
|
// stdin variant of the shared exec path — it carries the same hard timeout,
|
|
1454
1481
|
// which a hand-rolled spawn here did not.
|
|
1455
|
-
|
|
1482
|
+
// 每次注入用自己的具名 buffer: 不同 pane 的注入并发时, 共用 tmux 的默认 buffer 会让
|
|
1483
|
+
// 一方 load 之后、paste 之前被另一方覆盖 —— 两段文本互换 pane (实测: 同一毫秒投的两份
|
|
1484
|
+
// 回执, 发给 A 的进了 B)。同一 pane 的串行管不到跨 pane。
|
|
1485
|
+
const buf = `wezard-${process.pid}-${++pasteSeq}`;
|
|
1486
|
+
const loaded = await runTmux(["load-buffer", "-b", buf, "-"], { stdin: text });
|
|
1456
1487
|
if (!loaded.ok)
|
|
1457
1488
|
return { ok: false, reason: `tmux load-buffer failed: ${loaded.stderr.slice(-200) || loaded.code}` };
|
|
1458
|
-
const pasted = await runTmux(["paste-buffer", "-p", "-d", "-t", target]);
|
|
1489
|
+
const pasted = await runTmux(["paste-buffer", "-b", buf, "-p", "-d", "-t", target]);
|
|
1459
1490
|
if (!pasted.ok)
|
|
1460
1491
|
return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
|
|
1461
1492
|
return { ok: true };
|
|
@@ -1751,15 +1782,12 @@ export const startMirror = (deps) => {
|
|
|
1751
1782
|
return true;
|
|
1752
1783
|
};
|
|
1753
1784
|
// Keepalive ping detector for the tail's user-line judgment — the SAME
|
|
1754
|
-
// normalized-prefix signature set keepaliveTick feeds keepaliveStamps
|
|
1755
|
-
//
|
|
1756
|
-
//
|
|
1757
|
-
// as a ping. Content is the source of truth
|
|
1758
|
-
// echo-TTLs lose it across reloads/replays.
|
|
1759
|
-
const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping
|
|
1760
|
-
// The warmer alone — what the peer readers (peek / wait / roster summary) strip.
|
|
1761
|
-
// `resumePing` is excluded on purpose: the turn it opens is real work resumed.
|
|
1762
|
-
const warmerSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
|
|
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);
|
|
1763
1791
|
const isKeepalivePing = (text) => isKeepalivePingText(text, pingSigs);
|
|
1764
1792
|
// ── Typewriter stream lifecycle ────────────────────────────────────
|
|
1765
1793
|
// WeCom spec: server polls us for stream refreshes for up to 6 min from the
|
|
@@ -1932,16 +1960,18 @@ export const startMirror = (deps) => {
|
|
|
1932
1960
|
if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
|
|
1933
1961
|
return;
|
|
1934
1962
|
const chatId = replyChatId(a);
|
|
1963
|
+
const turnId = a.briefTurnId; // 推送是排队异步的, 到发出时 turn 早已收口
|
|
1935
1964
|
const pieces = splitMarkdown(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
1936
1965
|
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
1937
1966
|
a.standalonePending = a.standalonePending
|
|
1938
1967
|
.then(async () => {
|
|
1939
1968
|
for (const c of chunks) {
|
|
1940
1969
|
try {
|
|
1941
|
-
await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
|
|
1970
|
+
const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
|
|
1971
|
+
log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
|
|
1942
1972
|
}
|
|
1943
1973
|
catch (e) {
|
|
1944
|
-
log.warn({ sessionId: a.sessionId, err: e
|
|
1974
|
+
log.warn({ sessionId: a.sessionId, chatId, err: errText(e) }, "standalone push failed");
|
|
1945
1975
|
}
|
|
1946
1976
|
}
|
|
1947
1977
|
})
|
|
@@ -1952,13 +1982,15 @@ export const startMirror = (deps) => {
|
|
|
1952
1982
|
if (!hasVisibleBody(content))
|
|
1953
1983
|
return;
|
|
1954
1984
|
const chatId = replyChatId(a);
|
|
1985
|
+
const turnId = a.briefTurnId;
|
|
1955
1986
|
a.standalonePending = a.standalonePending
|
|
1956
1987
|
.then(async () => {
|
|
1957
1988
|
try {
|
|
1958
1989
|
await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
|
|
1990
|
+
log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
|
|
1959
1991
|
}
|
|
1960
1992
|
catch (e) {
|
|
1961
|
-
log.warn({ sessionId: a.sessionId, err: e
|
|
1993
|
+
log.warn({ sessionId: a.sessionId, chatId, err: errText(e) }, "raw push failed");
|
|
1962
1994
|
}
|
|
1963
1995
|
})
|
|
1964
1996
|
.catch(() => undefined);
|
|
@@ -2350,8 +2382,27 @@ export const startMirror = (deps) => {
|
|
|
2350
2382
|
};
|
|
2351
2383
|
/** 公开派活的那个同伴 —— 回复的头要标给它 (见 AttachState.replyTo)。 */
|
|
2352
2384
|
const publicPeer = (from) => from?.kind === "peer" && from.public ? from.from : undefined;
|
|
2385
|
+
/** reload 后 tail 从 EOF 接上: 正在跑的这一轮的开头那句已在身后, 它的印章随旧进程没了。
|
|
2386
|
+
* 那句话自己挂着信封 (谁说的、私聊还是哪个群) —— 读回来就是这一轮的出处。没挂信封 = undefined。 */
|
|
2387
|
+
const openingOf = (a) => {
|
|
2388
|
+
const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
|
|
2389
|
+
const env = t?.env;
|
|
2390
|
+
if (!env)
|
|
2391
|
+
return undefined;
|
|
2392
|
+
const home = baseOfKey(a.target);
|
|
2393
|
+
const from = env.kind === "peer" ? { kind: "peer", from: wizardStore()?.byName(stripSigil(env.from))?.target, ...(env.private ? {} : { public: true }), ...(env.receipt ? { receipt: true } : {}), ...(env.status ? { status: env.status } : {}), ...(env.turn ? { turn: env.turn } : {}) }
|
|
2394
|
+
: env.kind === "task" ? { kind: "task", taskId: env.from.replace(/^定时 /, "") }
|
|
2395
|
+
: undefined;
|
|
2396
|
+
return {
|
|
2397
|
+
channel: env.private ? "" : (env.chat && chatBaseOf(cfg, env.chat)) || home,
|
|
2398
|
+
speaker: env.kind === "human" ? `user:${env.from}` : undefined,
|
|
2399
|
+
from,
|
|
2400
|
+
query: t.text,
|
|
2401
|
+
};
|
|
2402
|
+
};
|
|
2353
2403
|
/** 频道印章: 有新印章就换频道; 没有则 `reset` (CLI 手敲 / 人从聊天发起) 回到
|
|
2354
|
-
* home, 其余续写 (收口后的补写) 沿用上一轮。`from` = 本轮的出处, 答给谁跟着频道走。
|
|
2404
|
+
* home, 其余续写 (收口后的补写) 沿用上一轮。`from` = 本轮的出处, 答给谁跟着频道走。
|
|
2405
|
+
* 返回 reload 后从 transcript 读回的开头那句 (见 openingOf), 没读到 = undefined。 */
|
|
2355
2406
|
const consumeChannel = (a, reset, from) => {
|
|
2356
2407
|
const p = a.pendingChannel;
|
|
2357
2408
|
a.pendingChannel = undefined;
|
|
@@ -2374,15 +2425,27 @@ export const startMirror = (deps) => {
|
|
|
2374
2425
|
}
|
|
2375
2426
|
else if (!seeded) {
|
|
2376
2427
|
// reload 后的第一轮续写: 内存里的频道随进程没了, 缺省 = home 会把一段私聊的
|
|
2377
|
-
// 后续记成群里公开说的 (rolepage 上私聊窗口从此不再更新)
|
|
2378
|
-
|
|
2428
|
+
// 后续记成群里公开说的 (rolepage 上私聊窗口从此不再更新), 私聊的终句还会推进群。
|
|
2429
|
+
// 先信这一轮开头那句自带的信封; 没有 (人在 home / CLI 里说的) 才接回最近一轮的印章 ——
|
|
2430
|
+
// 刚出生的分身没有「最近一轮」, 只能靠信封。
|
|
2431
|
+
const opening = openingOf(a);
|
|
2432
|
+
const last = opening ?? lastChannelOf(a.target);
|
|
2379
2433
|
if (last) {
|
|
2380
2434
|
a.channel = last.channel === baseOfKey(a.target) ? undefined : last.channel;
|
|
2381
2435
|
a.speaker = last.speaker;
|
|
2382
2436
|
a.replyTo = publicPeer(last.from);
|
|
2383
2437
|
a.convFrom = last.from;
|
|
2384
2438
|
}
|
|
2439
|
+
return opening?.query;
|
|
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;
|
|
2385
2447
|
}
|
|
2448
|
+
return undefined;
|
|
2386
2449
|
};
|
|
2387
2450
|
/** 本轮落 turn 记录的频道字段。 */
|
|
2388
2451
|
const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker });
|
|
@@ -2395,14 +2458,103 @@ export const startMirror = (deps) => {
|
|
|
2395
2458
|
// 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
|
|
2396
2459
|
// 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
|
|
2397
2460
|
const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
|
|
2398
|
-
|
|
2399
|
-
|
|
2461
|
+
// ── 印章 (见 Seal) ──────────────────────────────────────────────
|
|
2462
|
+
// 盖在行上而不是格子里: 注入时记下, 行在 transcript 里露面时认领。比回显去重的 60s 活得久 ——
|
|
2463
|
+
// 排在长轮后面的那一行露面时早过了 60s, 认不出就会被当成人在 CLI 里敲的。
|
|
2464
|
+
const SEAL_TTL_MS = 2 * 3600_000;
|
|
2465
|
+
const sealKeys = (text) => {
|
|
2466
|
+
const t = cleanUserText(text);
|
|
2467
|
+
if (!t.startsWith("/"))
|
|
2468
|
+
return t ? [t] : [];
|
|
2469
|
+
const head = t.split(/\s+/, 1)[0] ?? t;
|
|
2470
|
+
const args = t.slice(head.length).trim();
|
|
2471
|
+
return [t, `\`${head}${args ? ` ${args}` : ""}\``];
|
|
2472
|
+
};
|
|
2473
|
+
const pushSeal = (a, s) => {
|
|
2474
|
+
const now = Date.now();
|
|
2475
|
+
a.seals = [...(a.seals ?? []).filter((x) => now - x.at <= SEAL_TTL_MS), s];
|
|
2476
|
+
};
|
|
2477
|
+
/** 露面的这一行认领它的印章, 先注入的先认。CLI 把排队的几句并成一行时, 包含在里面的都算
|
|
2478
|
+
* 露过面, 出处取第一枚。认不出 = undefined。 */
|
|
2479
|
+
const takeSeal = (a, said, pick = () => true) => {
|
|
2480
|
+
const t = said.trim();
|
|
2481
|
+
const hit = (x) => pick(x) && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k)));
|
|
2482
|
+
const seals = a.seals ?? [];
|
|
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));
|
|
2491
|
+
return first;
|
|
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
|
+
};
|
|
2498
|
+
/** 人在聊天里说的那一句接成活跃 turn: 收掉在跑的那一轮, 频道与发话人取它自己的印章。
|
|
2499
|
+
* `at` = 那一行落盘的时刻: 排队的那句从这一刻才开始被处理, 轮次的起点以它为准; 闲时当场接上就是投递那一刻。 */
|
|
2500
|
+
const activateDispatch = (a, s, at = s.at) => {
|
|
2501
|
+
const q = s.turn;
|
|
2502
|
+
s.activated = true;
|
|
2400
2503
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
2401
2504
|
a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
|
|
2402
2505
|
a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2506
|
+
// 人说的这一轮没有同伴出处: 别的行认领了、还没开出轮的印章被这一句接管, 一并作废。
|
|
2507
|
+
a.pendingFrom = undefined;
|
|
2508
|
+
a.pendingOrigin = undefined;
|
|
2509
|
+
a.pendingBriefQuery = undefined;
|
|
2510
|
+
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
|
|
2511
|
+
consumeChannel(a, true, undefined);
|
|
2512
|
+
recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: at, ...channelFields(a) });
|
|
2513
|
+
if (a.briefTurnId)
|
|
2514
|
+
closeBriefTurn(a);
|
|
2515
|
+
openBriefTurn(a, q);
|
|
2516
|
+
};
|
|
2517
|
+
/** 一句插话记进它插进的那一轮: 出处、频道、发话人都取它自己的印章 (没写频道 = 在 home 里开口)。 */
|
|
2518
|
+
const injectInto = (turnId, a, s, ts) => recordTurnInject(turnId, { ts, body: s.query.trim(), ...(s.from ? { from: s.from } : {}), channel: s.channel ?? baseOfKey(a.target), ...(s.speaker ? { speaker: s.speaker } : {}) });
|
|
2519
|
+
/** `attachment.queued_command` 露面: 那句被正在跑的这一轮中途吃进去了。认领它的印章记成插话。
|
|
2520
|
+
* 人从聊天里说的那句 (印章带预建的轮) 不在这里认: 它已有自己的轮次记录。认不出 (人在 CLI 里
|
|
2521
|
+
* 忙时敲的) 不记 —— 说不清是谁、在哪个频道说的。 */
|
|
2522
|
+
const interject = (a, said, ts) => {
|
|
2523
|
+
if (!a.briefTurnId)
|
|
2524
|
+
return;
|
|
2525
|
+
const s = takeSeal(a, said, (x) => !x.turn);
|
|
2526
|
+
if (s)
|
|
2527
|
+
injectInto(a.briefTurnId, a, s, ts);
|
|
2528
|
+
};
|
|
2529
|
+
/** 一行注入露面了: 把它的印章落成下一轮的出处 / 频道。返回认领到的印章。`ts` = 那一行落盘的时刻。 */
|
|
2530
|
+
const bindLine = (a, said, ts) => {
|
|
2531
|
+
const s = takeSeal(a, said);
|
|
2532
|
+
if (!s)
|
|
2533
|
+
return undefined;
|
|
2534
|
+
if (s.turn) {
|
|
2535
|
+
if (!s.activated)
|
|
2536
|
+
activateDispatch(a, s, ts);
|
|
2537
|
+
return s;
|
|
2538
|
+
}
|
|
2539
|
+
// 忙时贴进去、正在跑的那一轮还没出终句: 它是插进那一轮的话 (priority now), 那一轮的
|
|
2540
|
+
// 回复仍归原来那一方, 这句记成那一轮的一条插话。闲时贴进去的、或上一轮已经说完的, 才是新一轮的开头。
|
|
2541
|
+
if (a.briefTurnId && !a.briefConcluded && !s.fresh) {
|
|
2542
|
+
injectInto(a.briefTurnId, a, s, Date.now());
|
|
2543
|
+
return s;
|
|
2544
|
+
}
|
|
2545
|
+
if (a.briefTurnId)
|
|
2546
|
+
closeBriefTurn(a);
|
|
2547
|
+
const now = Date.now();
|
|
2548
|
+
a.pendingFrom = s.from ? { from: s.from, at: now } : undefined;
|
|
2549
|
+
a.pendingOrigin = s.origin ? { origin: s.origin, at: now } : undefined;
|
|
2550
|
+
// 没写频道的注入 (graph 步骤 / 手动 inject) 等同在 home 里开口: 印章就是全部出处, 不沿用上一轮的。
|
|
2551
|
+
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), at: now };
|
|
2552
|
+
a.pendingBriefQuery = s.query;
|
|
2553
|
+
a.pendingBriefAt = ts;
|
|
2554
|
+
return s;
|
|
2555
|
+
};
|
|
2556
|
+
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
|
|
2557
|
+
const turnId = newTurnId();
|
|
2406
2558
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2407
2559
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
2408
2560
|
const q = { turnId, bubble, isSlash };
|
|
@@ -2413,15 +2565,23 @@ export const startMirror = (deps) => {
|
|
|
2413
2565
|
const live = a.briefBubble === bubble && !a.briefConcluded;
|
|
2414
2566
|
void finishBubble(a, bubble, `${briefDetailLink(turnId, a.target)}${live ? ` ${STILL_WORKING}` : ""}`, true);
|
|
2415
2567
|
}, HARD_TIMEOUT_MS);
|
|
2568
|
+
const seal = { keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
|
|
2569
|
+
// 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
|
|
2570
|
+
// 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
|
|
2571
|
+
// 先建好, ack 里的链接点得开。斜杠命令的行未必露面, 当场接。
|
|
2572
|
+
const queued = !isSlash && idleVerdict(a.target) === false;
|
|
2573
|
+
pushSeal(a, seal);
|
|
2574
|
+
if (queued)
|
|
2575
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, channel: channel ?? baseOfKey(a.target), speaker });
|
|
2576
|
+
else
|
|
2577
|
+
activateDispatch(a, seal);
|
|
2416
2578
|
// 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
|
|
2417
2579
|
// closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
|
|
2418
2580
|
// 覆盖消息)。旧 turn 在 CLI 侧的剩余产出自然流入新气泡 —— "新回复走最新气泡"
|
|
2419
2581
|
// 正是这个语义。代价是旧 turn 页提前标完、尾部 item 记到新 turn 名下, 但远小于
|
|
2420
2582
|
// 排队的代价: 排队 turn 的 frame 在前一个长 turn 期间 (可达数分钟) 过期, 激活后
|
|
2421
|
-
// replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)
|
|
2422
|
-
|
|
2423
|
-
closeBriefTurn(a);
|
|
2424
|
-
openBriefTurn(a, q);
|
|
2583
|
+
// replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。所以只在前面真有别人的行排着时
|
|
2584
|
+
// 才排 (见上): 接上时气泡若已过期, 正文走 standalone (concludeBriefTurn)。
|
|
2425
2585
|
// ack 即详情链接。URL 的三个入参 (turnId / target / host) 在收消息这一刻全部已知 ——
|
|
2426
2586
|
// turnId 是本地生成的, 详情页记录也已在 recordTurnStart 建好, 所以不必等 CLI 产出,
|
|
2427
2587
|
// 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
|
|
@@ -2433,6 +2593,35 @@ export const startMirror = (deps) => {
|
|
|
2433
2593
|
log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
|
|
2434
2594
|
}
|
|
2435
2595
|
};
|
|
2596
|
+
/** reload 后的第一笔产出: 那一轮多半还是旧进程开的那条记录 (内存里的 briefTurnId 随进程没了) ——
|
|
2597
|
+
* 另开一轮就把一件活切成几段、每段都带着开头那句。只在 transcript 最后那句问话正是它的问话时接回,
|
|
2598
|
+
* 频道与出处取它自己记下的; 对不上 (旧进程收口前就有了新问话) 照常开新的。 */
|
|
2599
|
+
const resumeOpenTurn = (a) => {
|
|
2600
|
+
if (a.channelSeeded)
|
|
2601
|
+
return false;
|
|
2602
|
+
const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
2603
|
+
const said = head(a.jsonlPath ? lastUserTurn(a.jsonlPath)?.text : undefined);
|
|
2604
|
+
const r = said ? openTurnsOf(a.target, a.sessionId).find((t) => head(t.userQuery) === said) : undefined;
|
|
2605
|
+
if (!r)
|
|
2606
|
+
return false;
|
|
2607
|
+
a.channelSeeded = true;
|
|
2608
|
+
a.pendingFrom = undefined;
|
|
2609
|
+
a.pendingOrigin = undefined;
|
|
2610
|
+
a.pendingChannel = undefined;
|
|
2611
|
+
a.channel = r.channel === undefined || r.channel === baseOfKey(a.target) ? undefined : r.channel;
|
|
2612
|
+
a.speaker = r.speaker;
|
|
2613
|
+
a.convFrom = r.from;
|
|
2614
|
+
a.replyTo = publicPeer(r.from);
|
|
2615
|
+
a.turnFromChat = a.turnFromChat ?? true;
|
|
2616
|
+
a.briefTurnId = r.id;
|
|
2617
|
+
a.briefBubble = undefined;
|
|
2618
|
+
a.briefIsSlash = false;
|
|
2619
|
+
a.briefHadTool = r.items.some((it) => it.t === "tool_use");
|
|
2620
|
+
a.briefConcluded = false;
|
|
2621
|
+
a.briefLastText = undefined;
|
|
2622
|
+
log.info({ sessionId: a.sessionId, turnId: r.id }, "brief: turn resumed after reload");
|
|
2623
|
+
return true;
|
|
2624
|
+
};
|
|
2436
2625
|
// 无气泡 turn。CLI 侧自己开的一轮 (WeCom 从没发过消息, 拿不到 frame/streamId), 以及
|
|
2437
2626
|
// 收口后仍有 item 补写进来的情况, 都要有一个 turn 承接 —— 否则每条 tool/text 都掉进
|
|
2438
2627
|
// fallback standalone, 一轮工具密集的对话能在群里刷出几十条散装气泡。这里只推一条
|
|
@@ -2440,18 +2629,22 @@ export const startMirror = (deps) => {
|
|
|
2440
2629
|
const ensureBriefTurn = (a) => {
|
|
2441
2630
|
if (a.briefTurnId)
|
|
2442
2631
|
return;
|
|
2443
|
-
const turnId = newTurnId();
|
|
2444
2632
|
const query = a.pendingBriefQuery?.replace(/^> ?/gm, "").trim();
|
|
2633
|
+
const at = a.pendingBriefAt;
|
|
2445
2634
|
a.pendingBriefQuery = undefined;
|
|
2635
|
+
a.pendingBriefAt = undefined;
|
|
2636
|
+
if (!query && a.pendingFromCli !== true && resumeOpenTurn(a))
|
|
2637
|
+
return;
|
|
2638
|
+
const turnId = newTurnId();
|
|
2446
2639
|
// 出处继承: 有真人 CLI 输入行开头 → CLI 轮; 否则 (收口后的补写、peer/graph 注入
|
|
2447
2640
|
// 开出的轮) 沿用上一轮 —— 那些续写属于同一场对话, 不该改变可见性。
|
|
2448
2641
|
const fromCli = a.pendingFromCli === true;
|
|
2449
2642
|
a.pendingFromCli = false;
|
|
2450
2643
|
a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
|
|
2451
2644
|
const fresh = consumeFrom(a);
|
|
2452
|
-
consumeChannel(a, fromCli, fresh);
|
|
2645
|
+
const opened = consumeChannel(a, fromCli, fresh);
|
|
2453
2646
|
const from = fresh ?? a.convFrom;
|
|
2454
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a), from, ...channelFields(a) });
|
|
2647
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || opened || undefined, origin: consumeOrigin(a), from, ...channelFields(a), ...(query && at ? { createdAt: at } : {}) });
|
|
2455
2648
|
a.briefTurnId = turnId;
|
|
2456
2649
|
a.briefBubble = undefined;
|
|
2457
2650
|
a.briefIsSlash = false;
|
|
@@ -2781,7 +2974,7 @@ export const startMirror = (deps) => {
|
|
|
2781
2974
|
return;
|
|
2782
2975
|
}
|
|
2783
2976
|
if (item.kind === "turn_usage") {
|
|
2784
|
-
recordTurnUsage(turnId, { model: item.model, messageId: item.messageId, usage: item.usage });
|
|
2977
|
+
recordTurnUsage(turnId, { model: item.model, effort: item.effort, messageId: item.messageId, usage: item.usage });
|
|
2785
2978
|
return;
|
|
2786
2979
|
}
|
|
2787
2980
|
if (item.kind === "skill_output") {
|
|
@@ -2983,6 +3176,10 @@ export const startMirror = (deps) => {
|
|
|
2983
3176
|
// (crashed reply). Release BEFORE the swallow gate so this real line (and
|
|
2984
3177
|
// its reply) mirror normally instead of being eaten by the stale swallow.
|
|
2985
3178
|
if (item.kind === "user_text") {
|
|
3179
|
+
// 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
|
|
3180
|
+
// 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
|
|
3181
|
+
if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts))
|
|
3182
|
+
return;
|
|
2986
3183
|
endKeepaliveSwallow(a);
|
|
2987
3184
|
// 对话边界 = 旧 turn 清算。上一 turn 派发而未返回的前台 Agent/Task 到此作废,
|
|
2988
3185
|
// 清掉防残留阻塞新 turn 的软收口 (guard 的 defer 判断依据)。
|
|
@@ -2992,13 +3189,24 @@ export const startMirror = (deps) => {
|
|
|
2992
3189
|
// quiet (includeUser=false, 默认) 同样记: includeUser 只决定气泡渲不渲染,
|
|
2993
3190
|
// 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
|
|
2994
3191
|
a.pendingBriefQuery = item.body;
|
|
3192
|
+
a.pendingBriefAt = item.ts;
|
|
2995
3193
|
a.pendingFromCli = true;
|
|
2996
3194
|
}
|
|
3195
|
+
if (item.kind === "user_inject") {
|
|
3196
|
+
interject(a, item.said, item.ts);
|
|
3197
|
+
return;
|
|
3198
|
+
}
|
|
2997
3199
|
// 还有一轮等着懒建 (peer / graph 注入先记了 pendingBriefQuery) 就换掉它; 否则这句话
|
|
2998
3200
|
// 就是已开好的那一轮 (IM 侧说的, startBriefTurn 在注入前建好)。
|
|
2999
3201
|
if (item.kind === "user_query") {
|
|
3000
|
-
|
|
3202
|
+
// 同伴 / 定时 / graph 注入的那一行: 问话就是印章里的原话 (不带信封)。
|
|
3203
|
+
const sealed = bindLine(a, item.said, item.ts);
|
|
3204
|
+
if (sealed && !sealed.turn)
|
|
3205
|
+
return;
|
|
3206
|
+
if (a.pendingBriefQuery !== undefined || !a.briefTurnId) {
|
|
3001
3207
|
a.pendingBriefQuery = item.body;
|
|
3208
|
+
a.pendingBriefAt = item.ts;
|
|
3209
|
+
}
|
|
3002
3210
|
// 只认同一句话: 轮次中途 CLI 里又粘了一张图, 不能改写正在跑的这一轮的问话。
|
|
3003
3211
|
else
|
|
3004
3212
|
recordTurnQuery(a.briefTurnId, item.body, (prev) => cleanUserText(prev ?? "") === item.said);
|
|
@@ -3032,7 +3240,7 @@ export const startMirror = (deps) => {
|
|
|
3032
3240
|
recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
|
|
3033
3241
|
}
|
|
3034
3242
|
else if (item.kind === "turn_usage") {
|
|
3035
|
-
recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
|
|
3243
|
+
recordTurnUsage(id, { model: item.model, effort: item.effort, messageId: item.messageId, usage: item.usage });
|
|
3036
3244
|
}
|
|
3037
3245
|
else if (item.kind === "turn_end") {
|
|
3038
3246
|
recordTurnClose(id);
|
|
@@ -3375,7 +3583,7 @@ export const startMirror = (deps) => {
|
|
|
3375
3583
|
const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
|
|
3376
3584
|
? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
|
|
3377
3585
|
: "";
|
|
3378
|
-
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd, keepaliveDisabled }) => {
|
|
3586
|
+
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, effort, pendingCwd, keepaliveDisabled }) => {
|
|
3379
3587
|
const target = resolveTarget(targetOverride);
|
|
3380
3588
|
if (!target)
|
|
3381
3589
|
return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
|
|
@@ -3405,6 +3613,7 @@ export const startMirror = (deps) => {
|
|
|
3405
3613
|
// model it asked for (possibly "" = CLI default) and says so; a re-attach
|
|
3406
3614
|
// onto an already-running pane doesn't, so it keeps what was recorded.
|
|
3407
3615
|
const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
|
|
3616
|
+
const carryEffort = effort !== undefined ? effort.trim() : (prevByTarget?.effort ?? deps.store.get(target)?.effort ?? "");
|
|
3408
3617
|
// Same carry rule again: an explicit spawn-time choice wins, otherwise this
|
|
3409
3618
|
// wizard's existing policy (in-memory, or on disk across a reload) sticks.
|
|
3410
3619
|
const carryKeepaliveDisabled = keepaliveDisabled !== undefined ? keepaliveDisabled : (prevByTarget?.keepaliveDisabled ?? deps.store.get(target)?.keepaliveDisabled ?? false);
|
|
@@ -3423,6 +3632,7 @@ export const startMirror = (deps) => {
|
|
|
3423
3632
|
runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
|
|
3424
3633
|
pendingCwd: carryPending,
|
|
3425
3634
|
model: carryModel,
|
|
3635
|
+
effort: carryEffort,
|
|
3426
3636
|
keepaliveDisabled: carryKeepaliveDisabled,
|
|
3427
3637
|
tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
|
|
3428
3638
|
standalonePending: Promise.resolve(),
|
|
@@ -3440,6 +3650,7 @@ export const startMirror = (deps) => {
|
|
|
3440
3650
|
isOwnAssistantSend,
|
|
3441
3651
|
isKeepalivePing,
|
|
3442
3652
|
onItem: (item) => onItem(a, item),
|
|
3653
|
+
onCompact: () => contextCut(a.target, "compact"),
|
|
3443
3654
|
detailUrlFor,
|
|
3444
3655
|
sessionId,
|
|
3445
3656
|
target,
|
|
@@ -3461,6 +3672,7 @@ export const startMirror = (deps) => {
|
|
|
3461
3672
|
tmuxPane: a.tmuxPane || undefined,
|
|
3462
3673
|
cwd: a.runningCwd || undefined,
|
|
3463
3674
|
model: a.model || undefined,
|
|
3675
|
+
effort: a.effort || undefined,
|
|
3464
3676
|
pendingCwd: a.pendingCwd || undefined,
|
|
3465
3677
|
cwdConfirmed: prevRec?.cwdConfirmed,
|
|
3466
3678
|
keepaliveOff: prevRec?.keepaliveOff,
|
|
@@ -3498,8 +3710,12 @@ export const startMirror = (deps) => {
|
|
|
3498
3710
|
// it collapses N distinct peers onto one sessionId (attach() then dedupes by
|
|
3499
3711
|
// sid and silently detaches all-but-one). Excluding claimed sids keeps each
|
|
3500
3712
|
// peer on its own session.
|
|
3713
|
+
// 正在出生、还没 attach 的会话 sid: 新会话 (spawn 时 jsonl 就预建了) 与认下了的分叉
|
|
3714
|
+
// 文件。同一个 cwd 下别的 target 的迁移 / 自愈 / 分叉跟随也在扫这个目录里「新出现的
|
|
3715
|
+
// jsonl」, 不挡住的话它会先一步把别人的新会话认走, 正主 attach 时反撞 "already bound"。
|
|
3716
|
+
const inFlightSids = new Set();
|
|
3501
3717
|
const sidsClaimedByOthers = (principal) => {
|
|
3502
|
-
const s = new Set();
|
|
3718
|
+
const s = new Set(inFlightSids);
|
|
3503
3719
|
for (const [k, v] of Object.entries(deps.store.all()))
|
|
3504
3720
|
if (k !== principal && v.sessionId)
|
|
3505
3721
|
s.add(v.sessionId);
|
|
@@ -3609,6 +3825,7 @@ export const startMirror = (deps) => {
|
|
|
3609
3825
|
tmuxSession: rec.tmuxSession ?? "",
|
|
3610
3826
|
cwd: rec.cwd,
|
|
3611
3827
|
model: rec.model,
|
|
3828
|
+
effort: rec.effort,
|
|
3612
3829
|
pendingCwd: rec.pendingCwd,
|
|
3613
3830
|
});
|
|
3614
3831
|
if (!r.ok) {
|
|
@@ -3809,6 +4026,17 @@ export const startMirror = (deps) => {
|
|
|
3809
4026
|
catch { /* unreadable */ }
|
|
3810
4027
|
return undefined;
|
|
3811
4028
|
};
|
|
4029
|
+
// A resume / --fork-session copy is seeded with the origin's history, uuids
|
|
4030
|
+
// intact — so "its first user line is ours" is pane-certain evidence that a
|
|
4031
|
+
// new jsonl is OUR fork. "Has any user content" is not: a sibling spawned in
|
|
4032
|
+
// the same cwd a moment earlier also has some, and its sid isn't claimed yet
|
|
4033
|
+
// (attach lands after the spawn) — that is how a respawned .evolve got bound
|
|
4034
|
+
// to a haiku sibling's fresh transcript and read empty from then on.
|
|
4035
|
+
const forkOf = (origin) => {
|
|
4036
|
+
const seed = firstUserUuid(origin);
|
|
4037
|
+
// origin 还没有 user 行 (没说过话就死了 / 刚开的父亲): 没有可比的种子, 退回旧判据。
|
|
4038
|
+
return seed === undefined ? (path) => firstUserUuid(path) !== undefined : (path) => firstUserUuid(path) === seed;
|
|
4039
|
+
};
|
|
3812
4040
|
// startOffset semantics differ by caller: /clear passes 0 to replay the
|
|
3813
4041
|
// freshly-rotated jsonl (only holds the /clear line + new content); resume
|
|
3814
4042
|
// migration omits it (→ tail from EOF) because the fork file is seeded with
|
|
@@ -3829,6 +4057,8 @@ export const startMirror = (deps) => {
|
|
|
3829
4057
|
// drift 上下文是延续的, 画中性的 "switch"。
|
|
3830
4058
|
const cleared = jsonlIsPostClearChild(newJsonlPath);
|
|
3831
4059
|
markCut(a.target, newSessionId, cleared ? "clear" : "switch");
|
|
4060
|
+
if (cleared)
|
|
4061
|
+
contextCut(a.target, "clear");
|
|
3832
4062
|
if (cleared) {
|
|
3833
4063
|
// 真清空 = 缓存里已无任何值得保温的内容。像 /stop 一样暂停保活 —— 与
|
|
3834
4064
|
// dispatch 里 WeCom 注入 /clear 的暂停对齐;TUI 手打 /clear 只走这个漏斗,
|
|
@@ -3855,6 +4085,7 @@ export const startMirror = (deps) => {
|
|
|
3855
4085
|
isOwnAssistantSend,
|
|
3856
4086
|
isKeepalivePing,
|
|
3857
4087
|
onItem: (item) => onItem(a, item),
|
|
4088
|
+
onCompact: () => contextCut(a.target, "compact"),
|
|
3858
4089
|
detailUrlFor,
|
|
3859
4090
|
sessionId: newSessionId,
|
|
3860
4091
|
target: a.target,
|
|
@@ -4057,6 +4288,9 @@ export const startMirror = (deps) => {
|
|
|
4057
4288
|
// Post-/clear: only transcripts that didn't exist before the rotation
|
|
4058
4289
|
// can be ours, which is what licenses the shorter fingerprint above.
|
|
4059
4290
|
.filter((n) => !pendingClear || !pendingClear.baseline.has(n))
|
|
4291
|
+
// 别人的 (含还在出生、没 attach 的新会话与分叉 —— 分叉文件带着父亲注进去的原话,
|
|
4292
|
+
// 指纹会撞上) 不认。
|
|
4293
|
+
.filter((n) => !sidsClaimedByOthers(a.target).has(n.replace(/\.jsonl$/, "")))
|
|
4060
4294
|
.map((n) => ({ n, m: (() => { try {
|
|
4061
4295
|
return statSync(join(dir, n)).mtimeMs;
|
|
4062
4296
|
}
|
|
@@ -4199,6 +4433,17 @@ export const startMirror = (deps) => {
|
|
|
4199
4433
|
// 新 pane 落地后的钩子 —— pane 上限在这里数 (由 index.ts 装上; 没装 = 不数)。
|
|
4200
4434
|
let afterSpawn = () => { };
|
|
4201
4435
|
const onSpawn = (fn) => { afterSpawn = fn; };
|
|
4436
|
+
let afterCut = () => { };
|
|
4437
|
+
const onContextCut = (fn) => { afterCut = fn; };
|
|
4438
|
+
const contextCut = (target, cut) => {
|
|
4439
|
+
log.info({ target, cut }, "context cut");
|
|
4440
|
+
try {
|
|
4441
|
+
afterCut(target, cut);
|
|
4442
|
+
}
|
|
4443
|
+
catch (e) {
|
|
4444
|
+
log.warn({ target, cut, err: e.message }, "onContextCut hook threw");
|
|
4445
|
+
}
|
|
4446
|
+
};
|
|
4202
4447
|
// `cwd` = 这个 pane 正要启动的目录。attach 在 spawn 之后才发生, 所以此刻 getCwd
|
|
4203
4448
|
// 给的还是上一个 pane 的目录 —— 换目录重开时照它渲染, 新 wizard 会以为自己还在
|
|
4204
4449
|
// 旧工作区里。
|
|
@@ -4210,6 +4455,32 @@ export const startMirror = (deps) => {
|
|
|
4210
4455
|
return undefined;
|
|
4211
4456
|
}
|
|
4212
4457
|
};
|
|
4458
|
+
// 确切模型名的证据 (见 exactModelId): transcript 里 API 真答过的 model id, 只认同一个
|
|
4459
|
+
// CLI 的。先问最可能的那几个会话 (自己、被克隆者), 再按新近扫其余 —— 惰性的,
|
|
4460
|
+
// 第一个对上就停, 绝大多数时候只读一两个 transcript 的尾巴。
|
|
4461
|
+
function* modelSightings(cli, likely) {
|
|
4462
|
+
const name = cli ?? primaryBackend().name;
|
|
4463
|
+
const pathOf = (t) => byTarget.get(t)?.jsonlPath ?? expandHome(deps.store.get(t)?.jsonlPath ?? "");
|
|
4464
|
+
const head = likely.map(pathOf).filter(Boolean);
|
|
4465
|
+
// 生成器体到第一次取值才跑: 前几个就对上时, 其余几百个 stat 一个也不做。
|
|
4466
|
+
const paths = function* () {
|
|
4467
|
+
yield* head;
|
|
4468
|
+
yield* Object.values(deps.store.all())
|
|
4469
|
+
.map((r) => expandHome(r.jsonlPath))
|
|
4470
|
+
.filter((p) => !head.includes(p))
|
|
4471
|
+
.map((p) => ({ p, m: mtimeOf(p) }))
|
|
4472
|
+
.filter((x) => x.m > 0)
|
|
4473
|
+
.sort((x, y) => y.m - x.m)
|
|
4474
|
+
.map((x) => x.p);
|
|
4475
|
+
};
|
|
4476
|
+
for (const path of paths()) {
|
|
4477
|
+
if (!existsSync(path) || backendForPath(path).name !== name)
|
|
4478
|
+
continue;
|
|
4479
|
+
const id = lastModel(path);
|
|
4480
|
+
if (id)
|
|
4481
|
+
yield id;
|
|
4482
|
+
}
|
|
4483
|
+
}
|
|
4213
4484
|
const newSession = async (target, windowName, cli, opts) => {
|
|
4214
4485
|
const prev = byTarget.get(target);
|
|
4215
4486
|
// Resolution precedence (all chat-scoped except the running-cwd fallback):
|
|
@@ -4241,23 +4512,36 @@ export const startMirror = (deps) => {
|
|
|
4241
4512
|
const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
|
|
4242
4513
|
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
|
|
4243
4514
|
const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
|
|
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;
|
|
4244
4520
|
if (prev?.tmuxPane) {
|
|
4245
4521
|
// Best-effort kill; ignore errors (pane may already be dead).
|
|
4246
4522
|
void runTmux(["kill-pane", "-t", prev.tmuxPane]);
|
|
4247
4523
|
}
|
|
4248
4524
|
if (prev)
|
|
4249
4525
|
detach(prev, "/new respawn");
|
|
4526
|
+
// sid 由这里先定, 出生到 attach 之间挂在 inFlightSids 上 (见那里)。
|
|
4527
|
+
const freshSid = randomUUID();
|
|
4528
|
+
inFlightSids.add(freshSid);
|
|
4250
4529
|
const r = await spawnTmuxClaude({
|
|
4251
4530
|
cfg,
|
|
4252
4531
|
log: log.child({ sub: "new-session", target }),
|
|
4532
|
+
sessionId: freshSid,
|
|
4253
4533
|
windowName: windowName ?? target,
|
|
4254
4534
|
cwdOverride: eff,
|
|
4255
4535
|
cli: effCli,
|
|
4256
|
-
model:
|
|
4536
|
+
model: effModel,
|
|
4537
|
+
knownModels: modelSightings(effCli, [target, base]),
|
|
4538
|
+
effort: effEffort,
|
|
4257
4539
|
systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
|
|
4258
|
-
});
|
|
4259
|
-
if (!r.ok)
|
|
4540
|
+
}).catch((e) => { inFlightSids.delete(freshSid); throw e; });
|
|
4541
|
+
if (!r.ok) {
|
|
4542
|
+
inFlightSids.delete(freshSid);
|
|
4260
4543
|
return { ok: false, reason: r.reason };
|
|
4544
|
+
}
|
|
4261
4545
|
const att = attach({
|
|
4262
4546
|
sessionId: r.sessionId,
|
|
4263
4547
|
jsonlPath: r.jsonlPath,
|
|
@@ -4271,9 +4555,11 @@ export const startMirror = (deps) => {
|
|
|
4271
4555
|
// means this CLI's default, not the dead pane's model. Store what
|
|
4272
4556
|
// spawnTmuxClaude actually confirmed via `/model` (r.model), not the
|
|
4273
4557
|
// raw request — a later respawn's fast path then matches on try one.
|
|
4274
|
-
model: r.model ??
|
|
4558
|
+
model: r.model ?? effModel ?? "",
|
|
4559
|
+
effort: r.effort ?? "",
|
|
4275
4560
|
keepaliveDisabled: opts?.keepalive === undefined ? undefined : !opts.keepalive,
|
|
4276
4561
|
});
|
|
4562
|
+
inFlightSids.delete(freshSid);
|
|
4277
4563
|
if (!att.ok)
|
|
4278
4564
|
return { ok: false, reason: att.reason };
|
|
4279
4565
|
// 首条注入吃冷时序(injectText 走 /mirror/spawn 时已经硬编码 freshSpawn:true,
|
|
@@ -4307,6 +4593,8 @@ export const startMirror = (deps) => {
|
|
|
4307
4593
|
tmuxSession: baseA.tmuxSession || undefined,
|
|
4308
4594
|
tmuxPane: baseA.tmuxPane || undefined,
|
|
4309
4595
|
cwd: baseA.runningCwd || undefined,
|
|
4596
|
+
model: baseA.model || undefined,
|
|
4597
|
+
effort: baseA.effort || undefined,
|
|
4310
4598
|
cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
|
|
4311
4599
|
pendingCwd: undefined,
|
|
4312
4600
|
});
|
|
@@ -4320,7 +4608,7 @@ export const startMirror = (deps) => {
|
|
|
4320
4608
|
if (!opts?.silent)
|
|
4321
4609
|
pushProjectInfo(target, "created");
|
|
4322
4610
|
afterSpawn();
|
|
4323
|
-
return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning };
|
|
4611
|
+
return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning, effort: r.effort };
|
|
4324
4612
|
};
|
|
4325
4613
|
// ── Clone ───────────────────────────────────────────────────────────
|
|
4326
4614
|
// 分身 = `--resume <父 sid> --fork-session` 起的新 pane: CLI 把父亲的 transcript
|
|
@@ -4337,19 +4625,35 @@ export const startMirror = (deps) => {
|
|
|
4337
4625
|
// (attach 也会正当地拒绝), 一个干净的失败远比一条错绑好收拾。
|
|
4338
4626
|
const FORK_POLL_MS = 400;
|
|
4339
4627
|
const FORK_WAIT_MS = 45_000;
|
|
4340
|
-
|
|
4341
|
-
|
|
4628
|
+
// 认下的分叉文件同样记进 inFlightSids: 同一个父亲并发克隆出几个分身时, 每个分叉的首行
|
|
4629
|
+
// 都与父亲相同, 不预留的话两个 awaitFork 会认下同一个文件。
|
|
4630
|
+
const awaitFork = async (projectDir, baseline, claimed, origin, probe) => {
|
|
4631
|
+
const ours = forkOf(origin);
|
|
4632
|
+
const take = (sessionId, jsonlPath) => (inFlightSids.add(sessionId), { sessionId, jsonlPath });
|
|
4633
|
+
const start = Date.now();
|
|
4634
|
+
const deadline = start + FORK_WAIT_MS;
|
|
4342
4635
|
while (Date.now() < deadline) {
|
|
4636
|
+
// 首选会话注册表: 它按 pane 报这个进程此刻的 sid —— 并发分叉时唯一不靠猜的来源。
|
|
4637
|
+
const live = probe.pane ? sessionOnPane(probe.homeDir, probe.pane) : undefined;
|
|
4638
|
+
if (live && live.sessionId !== probe.parentSid && !claimed.has(live.sessionId)) {
|
|
4639
|
+
const path = join(projectDir, `${live.sessionId}.jsonl`);
|
|
4640
|
+
if (existsSync(path) && ours(path))
|
|
4641
|
+
return take(live.sessionId, path);
|
|
4642
|
+
}
|
|
4343
4643
|
const born = [...listJsonls(projectDir)]
|
|
4344
|
-
.filter((n) => !baseline.has(n) && !claimed.has(n.replace(/\.jsonl$/, "")))
|
|
4644
|
+
.filter((n) => !baseline.has(n) && !claimed.has(n.replace(/\.jsonl$/, "")) && !inFlightSids.has(n.replace(/\.jsonl$/, "")))
|
|
4345
4645
|
.map((n) => ({ n, path: join(projectDir, n) }))
|
|
4346
4646
|
// 种子化的 fork 一落地就带着父亲的 user 行 —— 空文件 (刚 touch 出来的)
|
|
4347
|
-
// 还不能认, 认早了 tail
|
|
4348
|
-
|
|
4647
|
+
// 还不能认, 认早了 tail 会从一个还没写完的复制过程中间开始读; 别的 pane
|
|
4648
|
+
// 同时新开的会话也有 user 行, 只认首行 uuid 与父亲相同的那个。
|
|
4649
|
+
.filter((c) => ours(c.path))
|
|
4349
4650
|
.sort((x, y) => mtimeOf(y.path) - mtimeOf(x.path));
|
|
4350
|
-
|
|
4651
|
+
// 注册表报不出 (没有注册表的后端 / 这个 pane 没登记 / 一阵子了还报着父亲的 sid)
|
|
4652
|
+
// 才按「新出现、首行同父」去猜, 认下即预留。
|
|
4653
|
+
const guess = !hasRegistry(probe.homeDir) || !live || Date.now() - start > FORK_WAIT_MS / 3;
|
|
4654
|
+
const hit = guess ? born[0] : undefined;
|
|
4351
4655
|
if (hit)
|
|
4352
|
-
return
|
|
4656
|
+
return take(hit.n.replace(/\.jsonl$/, ""), hit.path);
|
|
4353
4657
|
await sleep(FORK_POLL_MS);
|
|
4354
4658
|
}
|
|
4355
4659
|
return undefined;
|
|
@@ -4362,7 +4666,7 @@ export const startMirror = (deps) => {
|
|
|
4362
4666
|
const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
|
|
4363
4667
|
if (!canInherit) {
|
|
4364
4668
|
const r = await newSession(args.target, args.windowName, args.cli, {
|
|
4365
|
-
model: args.model, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive,
|
|
4669
|
+
model: args.model, effort: args.effort, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive,
|
|
4366
4670
|
});
|
|
4367
4671
|
return { ...r, inherited: false };
|
|
4368
4672
|
}
|
|
@@ -4370,6 +4674,10 @@ export const startMirror = (deps) => {
|
|
|
4370
4674
|
const projectDir = dirname(parent.jsonlPath);
|
|
4371
4675
|
const baseline = listJsonls(projectDir);
|
|
4372
4676
|
const lg = log.child({ sub: "clone", target: args.target });
|
|
4677
|
+
// 分身默认跑被克隆者此刻真在跑的那个: 记下的模型, 没记 (CLI 默认) 就取它 transcript 里的 id。
|
|
4678
|
+
const parentCli = backendForPath(parent.jsonlPath).name;
|
|
4679
|
+
const model = args.model?.trim() || parent.model || lastModel(parent.jsonlPath);
|
|
4680
|
+
const effort = args.effort ?? parseEffort(parent.effort);
|
|
4373
4681
|
const r = await spawnTmuxClaude({
|
|
4374
4682
|
cfg,
|
|
4375
4683
|
log: lg,
|
|
@@ -4377,15 +4685,17 @@ export const startMirror = (deps) => {
|
|
|
4377
4685
|
forkSession: true,
|
|
4378
4686
|
windowName: args.windowName ?? args.target,
|
|
4379
4687
|
cwdOverride: parent.runningCwd,
|
|
4380
|
-
cli:
|
|
4381
|
-
model
|
|
4688
|
+
cli: parentCli,
|
|
4689
|
+
model,
|
|
4690
|
+
knownModels: modelSightings(parentCli, [args.parent]),
|
|
4691
|
+
effort,
|
|
4382
4692
|
systemPrompt: args.systemPrompt,
|
|
4383
4693
|
});
|
|
4384
4694
|
if (!r.ok)
|
|
4385
4695
|
return { ok: false, reason: r.reason, inherited: false };
|
|
4386
4696
|
// 分叉文件不是开机就有的: CLI 在这个 pane **收到第一条消息**的那一刻才把父亲的
|
|
4387
4697
|
// transcript 复制成新文件。所以必须先说一句话, 分身才会有自己的 sid —— 这句话
|
|
4388
|
-
// 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」)
|
|
4698
|
+
// 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」)。
|
|
4389
4699
|
const hello = args.bootstrap?.trim()
|
|
4390
4700
|
|| "(wezard 分身初始化)用一句话说明: 你是谁、从谁那里继承了哪些上下文、准备接什么活。不要复述这条指令。";
|
|
4391
4701
|
rememberInject(hello);
|
|
@@ -4398,7 +4708,9 @@ export const startMirror = (deps) => {
|
|
|
4398
4708
|
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4399
4709
|
return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
|
|
4400
4710
|
}
|
|
4401
|
-
const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target)
|
|
4711
|
+
const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
|
|
4712
|
+
homeDir: backendForPath(parent.jsonlPath).homeDir, pane: r.tmuxPane ?? "", parentSid: parent.sessionId,
|
|
4713
|
+
});
|
|
4402
4714
|
// 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
|
|
4403
4715
|
// 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
|
|
4404
4716
|
// 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
|
|
@@ -4414,24 +4726,35 @@ export const startMirror = (deps) => {
|
|
|
4414
4726
|
tmuxSession: r.tmuxSession,
|
|
4415
4727
|
cwd: r.cwd,
|
|
4416
4728
|
pendingCwd: "",
|
|
4417
|
-
model: r.model ??
|
|
4729
|
+
model: r.model ?? model,
|
|
4730
|
+
effort: r.effort ?? "",
|
|
4418
4731
|
keepaliveDisabled: args.keepalive === undefined ? undefined : !args.keepalive,
|
|
4419
4732
|
});
|
|
4733
|
+
// 绑上 (或没绑上) 之后预留就没用了: 绑上的 sid 由 sidsClaimedByOthers 挡住别人。
|
|
4734
|
+
inFlightSids.delete(fork.sessionId);
|
|
4420
4735
|
if (!att.ok)
|
|
4421
4736
|
return { ok: false, reason: att.reason, inherited: false };
|
|
4422
4737
|
const spawned = byTarget.get(args.target);
|
|
4423
4738
|
if (spawned) {
|
|
4424
4739
|
// muteUntilInject 是给"全新 pane 的 greeting 噪音"准备的; 分身的开场白是正经
|
|
4425
|
-
// 内容 (
|
|
4740
|
+
// 内容 (rolepage 上它的第一轮), 所以这里不静音 —— 进不进群由下面的频道印章定。
|
|
4426
4741
|
spawned.justSpawned = true;
|
|
4427
4742
|
spawned.keepaliveOff = true;
|
|
4428
4743
|
spawned.keepaliveOffAt = Date.now();
|
|
4429
4744
|
spawned.keepalive = undefined;
|
|
4430
4745
|
persistPause(spawned);
|
|
4746
|
+
// 开场白早在 attach 之前就落进分叉文件了 (tail 从 EOF 起, 读不到那一行): 第一轮懒建时
|
|
4747
|
+
// 才认得出它是谁派的活。印章赶在 tail 第一次 drain 之前盖上。
|
|
4748
|
+
if (args.bootstrapFrom) {
|
|
4749
|
+
spawned.pendingFrom = { from: args.bootstrapFrom.from, at: Date.now() };
|
|
4750
|
+
spawned.pendingBriefQuery = args.bootstrapFrom.query;
|
|
4751
|
+
}
|
|
4752
|
+
// 开场白不是谁在群里问的: 带活的是私聊派活, 不带的是守护进程让它自报家门 —— 都只进 rolepage。
|
|
4753
|
+
spawned.pendingChannel = { channel: "", at: Date.now() };
|
|
4431
4754
|
}
|
|
4432
4755
|
lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
|
|
4433
4756
|
afterSpawn();
|
|
4434
|
-
return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning };
|
|
4757
|
+
return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning, effort: r.effort };
|
|
4435
4758
|
};
|
|
4436
4759
|
/** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
|
|
4437
4760
|
const sessionInfo = (target) => {
|
|
@@ -4447,6 +4770,7 @@ export const startMirror = (deps) => {
|
|
|
4447
4770
|
cwd: a?.runningCwd || rec?.cwd || "",
|
|
4448
4771
|
cli: backendForPath(jsonlPath).name,
|
|
4449
4772
|
model: a?.model || rec?.model || "",
|
|
4773
|
+
effort: a?.effort || rec?.effort || "",
|
|
4450
4774
|
tmuxPane: a?.tmuxPane ?? rec?.tmuxPane ?? "",
|
|
4451
4775
|
contextTokens: lastContextTokens(jsonlPath),
|
|
4452
4776
|
};
|
|
@@ -4605,7 +4929,8 @@ export const startMirror = (deps) => {
|
|
|
4605
4929
|
...allTargets().map(baseOfKey),
|
|
4606
4930
|
baseOfKey(self),
|
|
4607
4931
|
]);
|
|
4608
|
-
|
|
4932
|
+
// 内部会话 (记忆整理者) 的 home 不是聊天: 不列、不补名。
|
|
4933
|
+
return [...bases].filter((b) => b && !isInternalKey(b)).sort().map((base) => ({
|
|
4609
4934
|
base,
|
|
4610
4935
|
name: chatNameOf(cfg, base),
|
|
4611
4936
|
self: base === baseOfKey(self),
|
|
@@ -4633,18 +4958,27 @@ export const startMirror = (deps) => {
|
|
|
4633
4958
|
return false;
|
|
4634
4959
|
if (live.status !== "idle")
|
|
4635
4960
|
return undefined;
|
|
4961
|
+
return !hanging(target, live.sessionId);
|
|
4962
|
+
};
|
|
4963
|
+
/** 闲着却悬着工具调用 = 停在审批卡 / 本地弹窗上。resume / fork 会换 sid: 悬着的
|
|
4964
|
+
* 调用记在注册表报的当前那份 transcript 里。 */
|
|
4965
|
+
const hanging = (target, sid) => {
|
|
4636
4966
|
const bound = jsonlOf(target);
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4967
|
+
const jsonl = bound ? join(dirname(bound), `${sid}.jsonl`) : "";
|
|
4968
|
+
return !!jsonl && existsSync(jsonl) && openToolUses(jsonl).length > 0;
|
|
4969
|
+
};
|
|
4970
|
+
const parkedNow = (target) => {
|
|
4971
|
+
const pane = paneOf(target);
|
|
4972
|
+
const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
|
|
4973
|
+
return live?.status === "idle" && hanging(target, live.sessionId);
|
|
4640
4974
|
};
|
|
4641
4975
|
const idleNow = async (target) => idleVerdict(target) ?? !(await isBusy(target));
|
|
4642
4976
|
// 等到它闲下来, 事件驱动: 注册表目录一有写入就重判, 翻成 idle 的那一刻即返回。
|
|
4643
4977
|
// 兜底轮询只防 watch 漏事件 (以及悬着的审批被点掉后 status 没再动的边角)。
|
|
4644
4978
|
const IDLE_SAFETY_POLL_MS = 1000;
|
|
4645
|
-
const untilIdle = (target, timeoutMs) => {
|
|
4979
|
+
const untilIdle = (target, timeoutMs, { aborted = () => false, confirm = 2 } = {}) => {
|
|
4646
4980
|
if (idleVerdict(target) === undefined)
|
|
4647
|
-
return waitForIdle(target, isBusy, timeoutMs,
|
|
4981
|
+
return waitForIdle(target, isBusy, timeoutMs, aborted, { rampMs: 0, confirm });
|
|
4648
4982
|
return new Promise((resolve) => {
|
|
4649
4983
|
let done = false;
|
|
4650
4984
|
const finish = (r) => {
|
|
@@ -4660,6 +4994,10 @@ export const startMirror = (deps) => {
|
|
|
4660
4994
|
const check = async () => {
|
|
4661
4995
|
if (done)
|
|
4662
4996
|
return;
|
|
4997
|
+
if (aborted()) {
|
|
4998
|
+
finish({ idle: false, reason: "stopped" });
|
|
4999
|
+
return;
|
|
5000
|
+
}
|
|
4663
5001
|
const v = idleVerdict(target);
|
|
4664
5002
|
if (v ?? !(await isBusy(target)))
|
|
4665
5003
|
finish({ idle: true });
|
|
@@ -4672,15 +5010,15 @@ export const startMirror = (deps) => {
|
|
|
4672
5010
|
};
|
|
4673
5011
|
const lastText = (target) => {
|
|
4674
5012
|
const p = jsonlOf(target);
|
|
4675
|
-
return p ? lastAssistantText(p, 4000,
|
|
5013
|
+
return p ? lastAssistantText(p, 4000, pingSigs) : "";
|
|
4676
5014
|
};
|
|
4677
5015
|
const replySince = (target, sinceMs = 0) => {
|
|
4678
5016
|
const p = jsonlOf(target);
|
|
4679
|
-
return p ? lastReply(p, sinceMs,
|
|
5017
|
+
return p ? lastReply(p, sinceMs, pingSigs) : "";
|
|
4680
5018
|
};
|
|
4681
|
-
const replyToPeerSince = (target, fromName, sinceMs = 0) => {
|
|
5019
|
+
const replyToPeerSince = (target, fromName, sinceMs = 0, turn) => {
|
|
4682
5020
|
const p = jsonlOf(target);
|
|
4683
|
-
return p ? replyToPeerIn(p, fromName, sinceMs,
|
|
5021
|
+
return p ? replyToPeerIn(p, fromName, sinceMs, pingSigs, turn) : undefined;
|
|
4684
5022
|
};
|
|
4685
5023
|
/** 绑着的 pane 还能用 (存在、且不是别人的窗口) —— 不重生、不猜。 */
|
|
4686
5024
|
const paneLiveOf = async (target) => {
|
|
@@ -4747,7 +5085,7 @@ export const startMirror = (deps) => {
|
|
|
4747
5085
|
paneAlive,
|
|
4748
5086
|
busy: paneAlive ? paneIsBusy(await capturePaneTail(pane, 12)) : false,
|
|
4749
5087
|
lastActivity,
|
|
4750
|
-
summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80,
|
|
5088
|
+
summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80, pingSigs) : "(未绑定会话)",
|
|
4751
5089
|
self: t === self,
|
|
4752
5090
|
};
|
|
4753
5091
|
};
|
|
@@ -4804,24 +5142,27 @@ export const startMirror = (deps) => {
|
|
|
4804
5142
|
return { ok: false, reason: "它还没有 transcript (没接过第一句话) —— send_peer 跟它说一句就有了", busy };
|
|
4805
5143
|
// `n` 数的是来回: 每个来回给问话和它最新的一句 (跑完的是终句, 还在跑的是途中
|
|
4806
5144
|
// 最近说的那句) —— 途中的其余叙述不给, 那是过程不是结论。
|
|
4807
|
-
const rounds = talkRounds(jsonl, n,
|
|
5145
|
+
const rounds = talkRounds(jsonl, n, pingSigs).slice(-n);
|
|
4808
5146
|
const dialog = renderDialog(rounds.flatMap((r) => (r.length > 1 ? [r[0], r.at(-1)] : r)), 800, 4000);
|
|
4809
5147
|
const waiting = busy ? [] : openToolUses(jsonl);
|
|
4810
5148
|
return dialog ? { ok: true, dialog, busy, waiting } : { ok: false, reason: "transcript 里还没有对话", busy, waiting };
|
|
4811
5149
|
};
|
|
4812
5150
|
// ── Prompt-cache keepalive ──────────────────────────────────────────
|
|
4813
|
-
// Anthropic's prompt cache expires
|
|
4814
|
-
//
|
|
4815
|
-
//
|
|
4816
|
-
//
|
|
4817
|
-
// request (cache-read
|
|
4818
|
-
// anchored to the last REAL (non-ping) activity, NOT to
|
|
4819
|
-
// dead session is never kept warm forever
|
|
4820
|
-
// `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.
|
|
4821
5158
|
// After `/stop`, ignore pane-busy as a resume signal for this long — long
|
|
4822
5159
|
// enough for an in-flight ping (interrupted by the same /stop's Esc) to settle,
|
|
4823
5160
|
// so it can't self-resume the pause. A genuine new turn after this window does.
|
|
4824
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;
|
|
4825
5166
|
// Seed the keepalive clocks from message-turn timestamps (keepaliveStamps), never
|
|
4826
5167
|
// from file mtime — non-message lines (file-history-snapshot / ai-title / mode)
|
|
4827
5168
|
// bump mtime without being a model turn, which would fake a long-dead session
|
|
@@ -4841,21 +5182,18 @@ export const startMirror = (deps) => {
|
|
|
4841
5182
|
round = s.streak;
|
|
4842
5183
|
}
|
|
4843
5184
|
catch { /* unreadable tail */ }
|
|
4844
|
-
return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0 };
|
|
5185
|
+
return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), cacheAt: cacheTouchMs(jsonlPath) };
|
|
4845
5186
|
};
|
|
4846
|
-
const fireKeepalive = async (a
|
|
5187
|
+
const fireKeepalive = async (a) => {
|
|
4847
5188
|
const kc = cfg.wrc.mirror.keepalive;
|
|
4848
|
-
//
|
|
4849
|
-
// — inviting the model to finish genuinely-unfinished work, or to reply
|
|
4850
|
-
// "pong" if it was legitimately parked. Otherwise the plain warmer, the SAME
|
|
4851
|
-
// 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
|
|
4852
5190
|
// (the instruction was already in context), but the model reads a bare "ping"
|
|
4853
5191
|
// as an open turn and answers it however it likes — anything other than
|
|
4854
5192
|
// "pong" un-swallows the heartbeat into chat and re-anchors the clocks as
|
|
4855
5193
|
// real activity. Restating the instruction is a few tokens of cache-write and
|
|
4856
5194
|
// buys a deterministic reply. keepaliveStamps matches every form, so none
|
|
4857
5195
|
// reads as real activity. (round is incremented below, after the inject lands.)
|
|
4858
|
-
const text =
|
|
5196
|
+
const text = kc.ping;
|
|
4859
5197
|
// Suppress the pane→WeCom echo of the ping user line, then swallow the whole
|
|
4860
5198
|
// ping turn (reply included). The 60s fail-safe clears the quiet window if
|
|
4861
5199
|
// the turn somehow never emits turn_end, so a later real turn is never muted;
|
|
@@ -4902,13 +5240,11 @@ export const startMirror = (deps) => {
|
|
|
4902
5240
|
return;
|
|
4903
5241
|
keepaliveTicking = true;
|
|
4904
5242
|
try {
|
|
4905
|
-
const
|
|
4906
|
-
const ttlMs = kc.ttlSec * 1000;
|
|
4907
|
-
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);
|
|
4908
5244
|
const now = Date.now();
|
|
4909
5245
|
for (const a of byTarget.values()) {
|
|
4910
5246
|
if (a.keepaliveDisabled)
|
|
4911
|
-
continue; // sticky spawn-time opt-out — never pings
|
|
5247
|
+
continue; // sticky spawn-time opt-out — never pings
|
|
4912
5248
|
if (!a.tmuxPane)
|
|
4913
5249
|
continue; // spawn-mode: no live pane to warm
|
|
4914
5250
|
if (a.migrationWatcher)
|
|
@@ -4942,6 +5278,8 @@ export const startMirror = (deps) => {
|
|
|
4942
5278
|
k.lastMs = mtime;
|
|
4943
5279
|
k.lastRealMs = mtime;
|
|
4944
5280
|
} // backend without timestamps → fall back to mtime
|
|
5281
|
+
k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
|
|
5282
|
+
k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
|
|
4945
5283
|
}
|
|
4946
5284
|
k.seenMtime = mtime;
|
|
4947
5285
|
// Round reset / real-activity re-anchor keys off REAL turns only — a
|
|
@@ -4979,22 +5317,25 @@ export const startMirror = (deps) => {
|
|
|
4979
5317
|
}
|
|
4980
5318
|
if (a.keepaliveOff)
|
|
4981
5319
|
continue; // paused by /stop until real activity returns
|
|
4982
|
-
|
|
4983
|
-
|
|
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);
|
|
5323
|
+
// The TTL the session's cache entries were actually written with (1h on a
|
|
5324
|
+
// Claude Code subscription), not a guess: pinging at the 5min cadence
|
|
5325
|
+
// against a 1h entry re-reads the whole context ~14x per hour for nothing.
|
|
5326
|
+
const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
|
|
5327
|
+
const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
|
|
5328
|
+
if (sinceCache < idleTriggerMs)
|
|
4984
5329
|
continue; // cache still comfortably warm
|
|
4985
|
-
if (
|
|
5330
|
+
if (sinceCache >= ttlMs)
|
|
4986
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
|
|
4987
5334
|
if (k.round >= kc.rounds)
|
|
4988
5335
|
continue; // budget spent — let it go cold
|
|
4989
|
-
// Stall recovery, decided by RULE only (no model self-judgment) and from
|
|
4990
|
-
// the transcript's structure only — never the screen: the turn is still
|
|
4991
|
-
// unfinished (died on a synthetic API error, or a tool result nobody
|
|
4992
|
-
// answered) and has been quiet for the whole idle window. Send the resume
|
|
4993
|
-
// instruction instead of the plain warmer.
|
|
4994
|
-
const stalled = kc.resumeOnStall && transcriptStalled(a.jsonlPath, keepalivePingSigs(kc.ping, kc.resumePing), idleTriggerMs, now);
|
|
4995
5336
|
k.pinging = true;
|
|
4996
5337
|
k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
|
|
4997
|
-
await fireKeepalive(a
|
|
5338
|
+
await fireKeepalive(a);
|
|
4998
5339
|
}
|
|
4999
5340
|
}
|
|
5000
5341
|
catch (e) {
|
|
@@ -5150,6 +5491,7 @@ export const startMirror = (deps) => {
|
|
|
5150
5491
|
peekTurns,
|
|
5151
5492
|
isBusy,
|
|
5152
5493
|
idleNow,
|
|
5494
|
+
parkedNow,
|
|
5153
5495
|
untilIdle,
|
|
5154
5496
|
setModel: async (target, wanted, scope) => {
|
|
5155
5497
|
const a = byTarget.get(target);
|
|
@@ -5164,6 +5506,19 @@ export const startMirror = (deps) => {
|
|
|
5164
5506
|
}
|
|
5165
5507
|
return r;
|
|
5166
5508
|
},
|
|
5509
|
+
setEffort: async (target, effort) => {
|
|
5510
|
+
const a = byTarget.get(target);
|
|
5511
|
+
if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
5512
|
+
return { ok: false, reason: "它没有活着的 pane —— 档位是在 pane 里切的, 先叫醒它" };
|
|
5513
|
+
const r = await selectEffort(a.tmuxPane, effort, log.child({ sub: "set-effort", target }));
|
|
5514
|
+
if (r.ok && r.applied) {
|
|
5515
|
+
a.effort = r.applied;
|
|
5516
|
+
const rec = deps.store.get(target);
|
|
5517
|
+
if (rec)
|
|
5518
|
+
deps.store.set(target, { ...rec, effort: r.applied });
|
|
5519
|
+
}
|
|
5520
|
+
return r;
|
|
5521
|
+
},
|
|
5167
5522
|
transcripts: () => allTargets().flatMap((target) => {
|
|
5168
5523
|
const jsonlPath = jsonlOf(target);
|
|
5169
5524
|
if (!jsonlPath)
|
|
@@ -5368,34 +5723,29 @@ export const startMirror = (deps) => {
|
|
|
5368
5723
|
rememberInject(full);
|
|
5369
5724
|
// 印章要早于 inject 落地 —— tail 是独立轮询的, 它可能在 inject 的 promise
|
|
5370
5725
|
// resolve 之前就看到 user 行并开出 turn, 那时归因必须已经在位。
|
|
5371
|
-
|
|
5372
|
-
|
|
5373
|
-
if (opts?.from)
|
|
5374
|
-
a.pendingFrom = { from: opts.from, at: Date.now() };
|
|
5375
|
-
if (opts?.channel !== undefined)
|
|
5376
|
-
a.pendingChannel = { channel: opts.channel, at: Date.now() };
|
|
5377
|
-
// 注入的那一行会被 recentInjects 当回显吞掉, 走不到 user_text —— 本轮的 query
|
|
5378
|
-
// (rolepage 上「谁对它说了什么」那一半) 只能在这里记。
|
|
5379
|
-
a.pendingBriefQuery = text;
|
|
5726
|
+
// 印章随这一行走 (见 Seal): 行露面时才落成那一轮的出处 / 频道 / 问话。
|
|
5727
|
+
pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
|
|
5380
5728
|
const sid = a.sessionId;
|
|
5381
5729
|
const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
|
|
5730
|
+
// 同 dispatch: 只有刚生 / 刚重生的 pane 才要冷启动的宽限 —— 对一个热着、正忙的 pane 贴 `now`
|
|
5731
|
+
// 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
|
|
5732
|
+
let fresh = a.justSpawned === true;
|
|
5382
5733
|
if (!paneAlive) {
|
|
5383
5734
|
log.warn({ target, sessionId: sid, oldPane: a.tmuxPane }, "injectText: pane not alive, respawning");
|
|
5384
5735
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
5385
5736
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
5386
5737
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
5387
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: displayName(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(target, { cwd: a.runningCwd }) });
|
|
5738
|
+
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: displayName(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, knownModels: modelSightings(backendForPath(a.jsonlPath).name, [target]), effort: parseEffort(a.effort), systemPrompt: charterFor(target, { cwd: a.runningCwd }) });
|
|
5388
5739
|
if (!r.ok || !r.tmuxPane)
|
|
5389
5740
|
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
5390
5741
|
a.tmuxPane = r.tmuxPane;
|
|
5391
5742
|
a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
|
|
5392
5743
|
if (r.cwd)
|
|
5393
5744
|
a.runningCwd = r.cwd;
|
|
5394
|
-
deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, model: a.model || undefined, pendingCwd: a.pendingCwd || undefined });
|
|
5395
|
-
startMigrationWatcher(a, resumeBaseline, (
|
|
5745
|
+
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 });
|
|
5746
|
+
startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
|
|
5747
|
+
fresh = true;
|
|
5396
5748
|
}
|
|
5397
|
-
// freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
|
|
5398
|
-
// is still warming up so the verifier in injectViaTmux needs the slack.
|
|
5399
5749
|
// 同 dispatch: 在 inject 之前解除静默, 防止验证循环中 LLM 回复被永久吞掉。
|
|
5400
5750
|
a.muteUntilInject = false;
|
|
5401
5751
|
a.justSpawned = false;
|
|
@@ -5405,7 +5755,7 @@ export const startMirror = (deps) => {
|
|
|
5405
5755
|
const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
5406
5756
|
const r = await inject({
|
|
5407
5757
|
text: full, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
5408
|
-
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn:
|
|
5758
|
+
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
|
|
5409
5759
|
});
|
|
5410
5760
|
if (r.ok && a.tmuxPane) {
|
|
5411
5761
|
if (preClearBaseline) {
|
|
@@ -5507,6 +5857,27 @@ export const startMirror = (deps) => {
|
|
|
5507
5857
|
return 0;
|
|
5508
5858
|
}
|
|
5509
5859
|
},
|
|
5860
|
+
reapUnborn: async (names) => {
|
|
5861
|
+
const want = new Set(names.flatMap((n) => [n, `.${n}`]));
|
|
5862
|
+
if (!want.size)
|
|
5863
|
+
return [];
|
|
5864
|
+
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{session_name}\t#{window_name}"]);
|
|
5865
|
+
if (snap.code !== 0)
|
|
5866
|
+
return [];
|
|
5867
|
+
const bound = new Set(allTargets().map(paneOf).filter(Boolean));
|
|
5868
|
+
const doomed = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"))
|
|
5869
|
+
.filter(([pane = "", session, win = ""]) => session === cfg.wrc.tmuxPrefix && want.has(win) && !bound.has(pane))
|
|
5870
|
+
.map(([pane = ""]) => pane);
|
|
5871
|
+
const killed = [];
|
|
5872
|
+
for (const pane of doomed) {
|
|
5873
|
+
// 与 killPane 同款前戏: Esc 给可能已起来的 CLI 一个收束落盘的机会。
|
|
5874
|
+
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
5875
|
+
await sleep(250);
|
|
5876
|
+
if ((await runTmux(["kill-pane", "-t", pane])).code === 0)
|
|
5877
|
+
killed.push(pane);
|
|
5878
|
+
}
|
|
5879
|
+
return killed;
|
|
5880
|
+
},
|
|
5510
5881
|
reapOverflow: async (max, skip) => {
|
|
5511
5882
|
const now = Date.now();
|
|
5512
5883
|
const reaped = [];
|
|
@@ -5613,6 +5984,13 @@ export const startMirror = (deps) => {
|
|
|
5613
5984
|
log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /n — Enter sent to pane");
|
|
5614
5985
|
return { ok: true };
|
|
5615
5986
|
},
|
|
5987
|
+
retitlePane: async (target) => {
|
|
5988
|
+
const pane = paneOf(target);
|
|
5989
|
+
if (!pane)
|
|
5990
|
+
return { ok: false, reason: "no tmux pane bound for target" };
|
|
5991
|
+
const r = await renameWindow(pane, displayName(target) || target);
|
|
5992
|
+
return r.code === 0 ? { ok: true } : { ok: false, reason: `rename-window failed: ${r.stderr.trim() || r.code}` };
|
|
5993
|
+
},
|
|
5616
5994
|
revealPane: async (target) => {
|
|
5617
5995
|
// paneOf (not byTarget.get) so a cold binding surviving only in the
|
|
5618
5996
|
// persisted store can still be revealed after a daemon reload.
|
|
@@ -5686,6 +6064,7 @@ export const startMirror = (deps) => {
|
|
|
5686
6064
|
sessionInfo,
|
|
5687
6065
|
setCharterProvider,
|
|
5688
6066
|
onSpawn,
|
|
6067
|
+
onContextCut,
|
|
5689
6068
|
shutdown: () => {
|
|
5690
6069
|
clearInterval(paneDriftTimer);
|
|
5691
6070
|
clearInterval(keepaliveTimer);
|
|
@@ -5742,7 +6121,9 @@ export const startMirror = (deps) => {
|
|
|
5742
6121
|
}
|
|
5743
6122
|
persistPause(a); // persist the pause/resume too, else a reload would revert it
|
|
5744
6123
|
// 频道印章要早于任何开 turn 的路径 (startBriefTurn 同步消费)。
|
|
5745
|
-
|
|
6124
|
+
// brief 模式下频道随这一句的印章走 (startBriefTurn); 其余路径仍是开 turn 时消费的格子。
|
|
6125
|
+
if (!cfg.wrc.mirror.brief || isClearCommand(text))
|
|
6126
|
+
a.pendingChannel = { channel: channel ?? baseOfKey(principal), speaker, at: Date.now() };
|
|
5746
6127
|
// Finalize prior live stream (if any) so this new turn renders into its
|
|
5747
6128
|
// own message bubble. Then open a fresh stream tied to the new frame and
|
|
5748
6129
|
// ack immediately so WeCom doesn't time out while inject queues.
|
|
@@ -5836,7 +6217,7 @@ export const startMirror = (deps) => {
|
|
|
5836
6217
|
// 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
|
|
5837
6218
|
// 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
|
|
5838
6219
|
// 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
|
|
5839
|
-
await startBriefTurn(a, frame, streamId, isSlash, text);
|
|
6220
|
+
await startBriefTurn(a, frame, streamId, isSlash, text, channel ?? baseOfKey(principal), speaker);
|
|
5840
6221
|
}
|
|
5841
6222
|
else if (!armMigration && !eagerOpen) {
|
|
5842
6223
|
enterDeferred(a, frame, streamId);
|
|
@@ -5871,7 +6252,7 @@ export const startMirror = (deps) => {
|
|
|
5871
6252
|
const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
5872
6253
|
// Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
|
|
5873
6254
|
// mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
|
|
5874
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: displayName(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(a.target, { cwd: a.runningCwd }) });
|
|
6255
|
+
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: displayName(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, knownModels: modelSightings(backendForPath(a.jsonlPath).name, [a.target]), effort: parseEffort(a.effort), systemPrompt: charterFor(a.target, { cwd: a.runningCwd }) });
|
|
5875
6256
|
if (r.ok && r.tmuxPane && r.tmuxSession) {
|
|
5876
6257
|
a.tmuxPane = r.tmuxPane;
|
|
5877
6258
|
a.tmuxSession = r.tmuxSession;
|
|
@@ -5885,6 +6266,7 @@ export const startMirror = (deps) => {
|
|
|
5885
6266
|
tmuxPane: a.tmuxPane,
|
|
5886
6267
|
cwd: a.runningCwd || undefined,
|
|
5887
6268
|
model: a.model || undefined,
|
|
6269
|
+
effort: a.effort || undefined,
|
|
5888
6270
|
pendingCwd: a.pendingCwd || undefined,
|
|
5889
6271
|
});
|
|
5890
6272
|
log.info({ target: a.target, sessionId: sid, newPane: a.tmuxPane, newSession: a.tmuxSession }, "mirror: tmux respawned");
|
|
@@ -5894,7 +6276,7 @@ export const startMirror = (deps) => {
|
|
|
5894
6276
|
// file appears, the watcher times out harmlessly, and the existing
|
|
5895
6277
|
// tail keeps working — safe under either behavior.
|
|
5896
6278
|
if (resumeBaseline)
|
|
5897
|
-
startMigrationWatcher(a, resumeBaseline, (
|
|
6279
|
+
startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
|
|
5898
6280
|
}
|
|
5899
6281
|
else {
|
|
5900
6282
|
// Drop only the stale pane id; keep tmuxSession (if any) so the
|