wezard 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +7 -4
- package/README.md +1 -1
- package/dist/cli/init.js +2 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/daemon/approval.js +6 -4
- 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/danger.js +17 -0
- package/dist/daemon/danger.js.map +1 -1
- package/dist/daemon/detail.js +41 -0
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/handoff.js +8 -2
- package/dist/daemon/handoff.js.map +1 -1
- package/dist/daemon/inbound.js +9 -4
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +744 -152
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +62 -23
- 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 +461 -91
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/model-select.js +99 -0
- package/dist/daemon/model-select.js.map +1 -1
- package/dist/daemon/notices.js +7 -5
- package/dist/daemon/notices.js.map +1 -1
- package/dist/daemon/peers.js +183 -60
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/receipts.js +391 -77
- package/dist/daemon/receipts.js.map +1 -1
- package/dist/daemon/route.js +63 -10
- 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/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 +102 -58
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +138 -111
- 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 +133 -31
- 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.js +9 -5
- 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 +140 -16
- 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/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 +172 -8
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +89 -25
- 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 +37 -3
- 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 +16 -3
- package/package.json +1 -1
- package/web/chat.css +173 -41
- package/web/chat.js +724 -245
|
@@ -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, transcriptStalled, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, keepaliveStamps, openKeepalivePing, talkRounds, openToolUses, renderDialog } from "./peers.js";
|
|
41
|
+
import { stripAnsi, paneIsBusy, transcriptStalled, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, 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).
|
|
@@ -472,6 +475,10 @@ const recoverToolUse = (toolUseId, deps) => {
|
|
|
472
475
|
}
|
|
473
476
|
return undefined;
|
|
474
477
|
};
|
|
478
|
+
const lineTs = (line) => {
|
|
479
|
+
const ts = Date.parse(line.timestamp ?? "");
|
|
480
|
+
return Number.isNaN(ts) ? undefined : ts;
|
|
481
|
+
};
|
|
475
482
|
// Render one transcript line into tagged items. Caller decides batching.
|
|
476
483
|
const renderLine = (raw, deps) => {
|
|
477
484
|
let line;
|
|
@@ -499,6 +506,13 @@ const renderLine = (raw, deps) => {
|
|
|
499
506
|
const goal = line.type === "user" ? goalStartOf(line.message?.content) : undefined;
|
|
500
507
|
return goal ? [goal] : [];
|
|
501
508
|
}
|
|
509
|
+
// 插话: 正在跑的这一轮中途吃进去的那句, CLI 只落这一行 attachment, 没有 user 行 ——
|
|
510
|
+
// 不报到的话, 它的印章没人认领, 这句话在详情里就不存在。
|
|
511
|
+
if (line.type === "attachment" && line.attachment?.type === "queued_command" && typeof line.attachment.prompt === "string") {
|
|
512
|
+
const said = cleanUserText(line.attachment.prompt);
|
|
513
|
+
const ts = Date.parse(line.timestamp ?? "");
|
|
514
|
+
return said ? [{ kind: "user_inject", said, ts: Number.isNaN(ts) ? Date.now() : ts }] : [];
|
|
515
|
+
}
|
|
502
516
|
const out = [];
|
|
503
517
|
if (line.type === "user") {
|
|
504
518
|
const c = line.message?.content;
|
|
@@ -544,12 +558,13 @@ const renderLine = (raw, deps) => {
|
|
|
544
558
|
// cover reloads or replays; the user message itself always can.
|
|
545
559
|
if (deps.isKeepalivePing?.(text))
|
|
546
560
|
return [{ kind: "keepalive_start", body: text }];
|
|
561
|
+
// 自己注入的那一行不回显, 但要报个到: 它的印章 (谁说的、回复去哪) 在这一刻认领。
|
|
547
562
|
if (deps.isOwnInject(text))
|
|
548
|
-
return [
|
|
563
|
+
return [{ kind: "user_query", body: unwrapPasted(c).trim(), said: text, ts: lineTs(line) }];
|
|
549
564
|
const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
|
|
550
565
|
// includeUser 只决定"渲不渲染", 不决定"算不算边界" —— quiet 的这一条照样发出,
|
|
551
566
|
// onItem 消费完边界语义后自己丢掉。
|
|
552
|
-
out.push({ kind: "user_text", body: quoted, quiet: !deps.includeUser });
|
|
567
|
+
out.push({ kind: "user_text", body: quoted, quiet: !deps.includeUser, ts: lineTs(line) });
|
|
553
568
|
}
|
|
554
569
|
else if (Array.isArray(c) && hasImages(c) && !c.some((b) => b?.type === "tool_result")) {
|
|
555
570
|
// 带图的一句话: [text "[Image #1]…", image, …]。去掉占位符才与注入时记下的原文同形,
|
|
@@ -557,10 +572,10 @@ const renderLine = (raw, deps) => {
|
|
|
557
572
|
const raw = c.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("\n");
|
|
558
573
|
const text = cleanUserText(raw.replace(IMAGE_REF_RE, ""));
|
|
559
574
|
if (text && !deps.isOwnInject(text)) {
|
|
560
|
-
out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser });
|
|
575
|
+
out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser, ts: lineTs(line) });
|
|
561
576
|
}
|
|
562
577
|
else {
|
|
563
|
-
out.push({ kind: "user_query", body: unwrapPasted(raw).trim(), said: text });
|
|
578
|
+
out.push({ kind: "user_query", body: unwrapPasted(raw).trim(), said: text, ts: lineTs(line) });
|
|
564
579
|
}
|
|
565
580
|
}
|
|
566
581
|
else if (Array.isArray(c)) {
|
|
@@ -672,6 +687,7 @@ const renderLine = (raw, deps) => {
|
|
|
672
687
|
const u = line.message?.usage;
|
|
673
688
|
if (u) {
|
|
674
689
|
const model = typeof line.message?.model === "string" ? line.message.model : undefined;
|
|
690
|
+
const effort = typeof line.effort === "string" && line.effort ? line.effort : undefined;
|
|
675
691
|
const messageId = typeof line.message?.id === "string" ? line.message.id : undefined;
|
|
676
692
|
const rawIn = u.input_tokens ?? 0;
|
|
677
693
|
const cr = u.cache_read_input_tokens ?? 0;
|
|
@@ -685,6 +701,7 @@ const renderLine = (raw, deps) => {
|
|
|
685
701
|
out.push({
|
|
686
702
|
kind: "turn_usage",
|
|
687
703
|
model,
|
|
704
|
+
effort,
|
|
688
705
|
messageId,
|
|
689
706
|
usage: {
|
|
690
707
|
input,
|
|
@@ -849,6 +866,9 @@ export const startMirrorTail = (deps) => {
|
|
|
849
866
|
buffer = buffer.slice(nl + 1);
|
|
850
867
|
if (!line.trim())
|
|
851
868
|
continue;
|
|
869
|
+
// 压缩边界: 对话被摘要替换, 系统提示 (宪章) 不变 —— 同 /clear 一样要补记忆。
|
|
870
|
+
if (deps.onCompact && line.includes('"compact_boundary"'))
|
|
871
|
+
deps.onCompact();
|
|
852
872
|
for (const item of renderLine(line, deps))
|
|
853
873
|
deps.onItem(item);
|
|
854
874
|
}
|
|
@@ -1413,6 +1433,8 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
|
|
|
1413
1433
|
// 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
|
|
1414
1434
|
// 每次补之前再问一遍证人, 已提交就不补。
|
|
1415
1435
|
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
|
|
1436
|
+
/** 具名粘贴 buffer 的序号 (见 loadAndPaste)。 */
|
|
1437
|
+
let pasteSeq = 0;
|
|
1416
1438
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1417
1439
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
1418
1440
|
// end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
|
|
@@ -1452,10 +1474,14 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1452
1474
|
const loadAndPaste = async () => {
|
|
1453
1475
|
// stdin variant of the shared exec path — it carries the same hard timeout,
|
|
1454
1476
|
// which a hand-rolled spawn here did not.
|
|
1455
|
-
|
|
1477
|
+
// 每次注入用自己的具名 buffer: 不同 pane 的注入并发时, 共用 tmux 的默认 buffer 会让
|
|
1478
|
+
// 一方 load 之后、paste 之前被另一方覆盖 —— 两段文本互换 pane (实测: 同一毫秒投的两份
|
|
1479
|
+
// 回执, 发给 A 的进了 B)。同一 pane 的串行管不到跨 pane。
|
|
1480
|
+
const buf = `wezard-${process.pid}-${++pasteSeq}`;
|
|
1481
|
+
const loaded = await runTmux(["load-buffer", "-b", buf, "-"], { stdin: text });
|
|
1456
1482
|
if (!loaded.ok)
|
|
1457
1483
|
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]);
|
|
1484
|
+
const pasted = await runTmux(["paste-buffer", "-b", buf, "-p", "-d", "-t", target]);
|
|
1459
1485
|
if (!pasted.ok)
|
|
1460
1486
|
return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
|
|
1461
1487
|
return { ok: true };
|
|
@@ -1760,7 +1786,9 @@ export const startMirror = (deps) => {
|
|
|
1760
1786
|
// The warmer alone — what the peer readers (peek / wait / roster summary) strip.
|
|
1761
1787
|
// `resumePing` is excluded on purpose: the turn it opens is real work resumed.
|
|
1762
1788
|
const warmerSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
|
|
1763
|
-
|
|
1789
|
+
// 行上只认保温那一句: 报错之后的续跑 (resumePing) 开出的是那一轮的接续, 整轮吞掉的话
|
|
1790
|
+
// 续出来的结论既不进群也没有频道。续跑若只回了 pong, 回复侧的那道保险照样吞。
|
|
1791
|
+
const isKeepalivePing = (text) => isKeepalivePingText(text, warmerSigs);
|
|
1764
1792
|
// ── Typewriter stream lifecycle ────────────────────────────────────
|
|
1765
1793
|
// WeCom spec: server polls us for stream refreshes for up to 6 min from the
|
|
1766
1794
|
// original inbound. SDK queues replyStream calls per req_id, sends serially
|
|
@@ -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,20 @@ 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;
|
|
2385
2440
|
}
|
|
2441
|
+
return undefined;
|
|
2386
2442
|
};
|
|
2387
2443
|
/** 本轮落 turn 记录的频道字段。 */
|
|
2388
2444
|
const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker });
|
|
@@ -2395,14 +2451,93 @@ export const startMirror = (deps) => {
|
|
|
2395
2451
|
// 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
|
|
2396
2452
|
// 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
|
|
2397
2453
|
const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
|
|
2398
|
-
|
|
2399
|
-
|
|
2454
|
+
// ── 印章 (见 Seal) ──────────────────────────────────────────────
|
|
2455
|
+
// 盖在行上而不是格子里: 注入时记下, 行在 transcript 里露面时认领。比回显去重的 60s 活得久 ——
|
|
2456
|
+
// 排在长轮后面的那一行露面时早过了 60s, 认不出就会被当成人在 CLI 里敲的。
|
|
2457
|
+
const SEAL_TTL_MS = 2 * 3600_000;
|
|
2458
|
+
const sealKeys = (text) => {
|
|
2459
|
+
const t = cleanUserText(text);
|
|
2460
|
+
if (!t.startsWith("/"))
|
|
2461
|
+
return t ? [t] : [];
|
|
2462
|
+
const head = t.split(/\s+/, 1)[0] ?? t;
|
|
2463
|
+
const args = t.slice(head.length).trim();
|
|
2464
|
+
return [t, `\`${head}${args ? ` ${args}` : ""}\``];
|
|
2465
|
+
};
|
|
2466
|
+
const pushSeal = (a, s) => {
|
|
2467
|
+
const now = Date.now();
|
|
2468
|
+
a.seals = [...(a.seals ?? []).filter((x) => now - x.at <= SEAL_TTL_MS), s];
|
|
2469
|
+
};
|
|
2470
|
+
/** 露面的这一行认领它的印章, 先注入的先认。CLI 把排队的几句并成一行时, 包含在里面的都算
|
|
2471
|
+
* 露过面, 出处取第一枚。认不出 = undefined。 */
|
|
2472
|
+
const takeSeal = (a, said, pick = () => true) => {
|
|
2473
|
+
const t = said.trim();
|
|
2474
|
+
const hit = (x) => pick(x) && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k)));
|
|
2475
|
+
const seals = a.seals ?? [];
|
|
2476
|
+
const first = seals.find(hit);
|
|
2477
|
+
if (first)
|
|
2478
|
+
a.seals = seals.filter((x) => !hit(x));
|
|
2479
|
+
return first;
|
|
2480
|
+
};
|
|
2481
|
+
/** 人在聊天里说的那一句接成活跃 turn: 收掉在跑的那一轮, 频道与发话人取它自己的印章。
|
|
2482
|
+
* `at` = 那一行落盘的时刻: 排队的那句从这一刻才开始被处理, 轮次的起点以它为准; 闲时当场接上就是投递那一刻。 */
|
|
2483
|
+
const activateDispatch = (a, s, at = s.at) => {
|
|
2484
|
+
const q = s.turn;
|
|
2485
|
+
s.activated = true;
|
|
2400
2486
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
2401
2487
|
a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
|
|
2402
2488
|
a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
|
|
2489
|
+
// 人说的这一轮没有同伴出处: 别的行认领了、还没开出轮的印章被这一句接管, 一并作废。
|
|
2490
|
+
a.pendingFrom = undefined;
|
|
2491
|
+
a.pendingOrigin = undefined;
|
|
2492
|
+
a.pendingBriefQuery = undefined;
|
|
2493
|
+
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
|
|
2494
|
+
consumeChannel(a, true, undefined);
|
|
2495
|
+
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) });
|
|
2496
|
+
if (a.briefTurnId)
|
|
2497
|
+
closeBriefTurn(a);
|
|
2498
|
+
openBriefTurn(a, q);
|
|
2499
|
+
};
|
|
2500
|
+
/** 一句插话记进它插进的那一轮: 出处、频道、发话人都取它自己的印章 (没写频道 = 在 home 里开口)。 */
|
|
2501
|
+
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 } : {}) });
|
|
2502
|
+
/** `attachment.queued_command` 露面: 那句被正在跑的这一轮中途吃进去了。认领它的印章记成插话。
|
|
2503
|
+
* 人从聊天里说的那句 (印章带预建的轮) 不在这里认: 它已有自己的轮次记录。认不出 (人在 CLI 里
|
|
2504
|
+
* 忙时敲的) 不记 —— 说不清是谁、在哪个频道说的。 */
|
|
2505
|
+
const interject = (a, said, ts) => {
|
|
2506
|
+
if (!a.briefTurnId)
|
|
2507
|
+
return;
|
|
2508
|
+
const s = takeSeal(a, said, (x) => !x.turn);
|
|
2509
|
+
if (s)
|
|
2510
|
+
injectInto(a.briefTurnId, a, s, ts);
|
|
2511
|
+
};
|
|
2512
|
+
/** 一行注入露面了: 把它的印章落成下一轮的出处 / 频道。返回认领到的印章。`ts` = 那一行落盘的时刻。 */
|
|
2513
|
+
const bindLine = (a, said, ts) => {
|
|
2514
|
+
const s = takeSeal(a, said);
|
|
2515
|
+
if (!s)
|
|
2516
|
+
return undefined;
|
|
2517
|
+
if (s.turn) {
|
|
2518
|
+
if (!s.activated)
|
|
2519
|
+
activateDispatch(a, s, ts);
|
|
2520
|
+
return s;
|
|
2521
|
+
}
|
|
2522
|
+
// 忙时贴进去、正在跑的那一轮还没出终句: 它是插进那一轮的话 (priority now), 那一轮的
|
|
2523
|
+
// 回复仍归原来那一方, 这句记成那一轮的一条插话。闲时贴进去的、或上一轮已经说完的, 才是新一轮的开头。
|
|
2524
|
+
if (a.briefTurnId && !a.briefConcluded && !s.fresh) {
|
|
2525
|
+
injectInto(a.briefTurnId, a, s, Date.now());
|
|
2526
|
+
return s;
|
|
2527
|
+
}
|
|
2528
|
+
if (a.briefTurnId)
|
|
2529
|
+
closeBriefTurn(a);
|
|
2530
|
+
const now = Date.now();
|
|
2531
|
+
a.pendingFrom = s.from ? { from: s.from, at: now } : undefined;
|
|
2532
|
+
a.pendingOrigin = s.origin ? { origin: s.origin, at: now } : undefined;
|
|
2533
|
+
// 没写频道的注入 (graph 步骤 / 手动 inject) 等同在 home 里开口: 印章就是全部出处, 不沿用上一轮的。
|
|
2534
|
+
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), at: now };
|
|
2535
|
+
a.pendingBriefQuery = s.query;
|
|
2536
|
+
a.pendingBriefAt = ts;
|
|
2537
|
+
return s;
|
|
2538
|
+
};
|
|
2539
|
+
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
|
|
2540
|
+
const turnId = newTurnId();
|
|
2406
2541
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2407
2542
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
2408
2543
|
const q = { turnId, bubble, isSlash };
|
|
@@ -2413,15 +2548,23 @@ export const startMirror = (deps) => {
|
|
|
2413
2548
|
const live = a.briefBubble === bubble && !a.briefConcluded;
|
|
2414
2549
|
void finishBubble(a, bubble, `${briefDetailLink(turnId, a.target)}${live ? ` ${STILL_WORKING}` : ""}`, true);
|
|
2415
2550
|
}, HARD_TIMEOUT_MS);
|
|
2551
|
+
const seal = { keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
|
|
2552
|
+
// 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
|
|
2553
|
+
// 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
|
|
2554
|
+
// 先建好, ack 里的链接点得开。斜杠命令的行未必露面, 当场接。
|
|
2555
|
+
const queued = !isSlash && idleVerdict(a.target) === false;
|
|
2556
|
+
pushSeal(a, seal);
|
|
2557
|
+
if (queued)
|
|
2558
|
+
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 });
|
|
2559
|
+
else
|
|
2560
|
+
activateDispatch(a, seal);
|
|
2416
2561
|
// 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
|
|
2417
2562
|
// closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
|
|
2418
2563
|
// 覆盖消息)。旧 turn 在 CLI 侧的剩余产出自然流入新气泡 —— "新回复走最新气泡"
|
|
2419
2564
|
// 正是这个语义。代价是旧 turn 页提前标完、尾部 item 记到新 turn 名下, 但远小于
|
|
2420
2565
|
// 排队的代价: 排队 turn 的 frame 在前一个长 turn 期间 (可达数分钟) 过期, 激活后
|
|
2421
|
-
// replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)
|
|
2422
|
-
|
|
2423
|
-
closeBriefTurn(a);
|
|
2424
|
-
openBriefTurn(a, q);
|
|
2566
|
+
// replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。所以只在前面真有别人的行排着时
|
|
2567
|
+
// 才排 (见上): 接上时气泡若已过期, 正文走 standalone (concludeBriefTurn)。
|
|
2425
2568
|
// ack 即详情链接。URL 的三个入参 (turnId / target / host) 在收消息这一刻全部已知 ——
|
|
2426
2569
|
// turnId 是本地生成的, 详情页记录也已在 recordTurnStart 建好, 所以不必等 CLI 产出,
|
|
2427
2570
|
// 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
|
|
@@ -2433,6 +2576,35 @@ export const startMirror = (deps) => {
|
|
|
2433
2576
|
log.warn({ sessionId: a.sessionId, turnId, err: e.message }, "brief: turn ack failed");
|
|
2434
2577
|
}
|
|
2435
2578
|
};
|
|
2579
|
+
/** reload 后的第一笔产出: 那一轮多半还是旧进程开的那条记录 (内存里的 briefTurnId 随进程没了) ——
|
|
2580
|
+
* 另开一轮就把一件活切成几段、每段都带着开头那句。只在 transcript 最后那句问话正是它的问话时接回,
|
|
2581
|
+
* 频道与出处取它自己记下的; 对不上 (旧进程收口前就有了新问话) 照常开新的。 */
|
|
2582
|
+
const resumeOpenTurn = (a) => {
|
|
2583
|
+
if (a.channelSeeded)
|
|
2584
|
+
return false;
|
|
2585
|
+
const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
2586
|
+
const said = head(a.jsonlPath ? lastUserTurn(a.jsonlPath)?.text : undefined);
|
|
2587
|
+
const r = said ? openTurnsOf(a.target, a.sessionId).find((t) => head(t.userQuery) === said) : undefined;
|
|
2588
|
+
if (!r)
|
|
2589
|
+
return false;
|
|
2590
|
+
a.channelSeeded = true;
|
|
2591
|
+
a.pendingFrom = undefined;
|
|
2592
|
+
a.pendingOrigin = undefined;
|
|
2593
|
+
a.pendingChannel = undefined;
|
|
2594
|
+
a.channel = r.channel === undefined || r.channel === baseOfKey(a.target) ? undefined : r.channel;
|
|
2595
|
+
a.speaker = r.speaker;
|
|
2596
|
+
a.convFrom = r.from;
|
|
2597
|
+
a.replyTo = publicPeer(r.from);
|
|
2598
|
+
a.turnFromChat = a.turnFromChat ?? true;
|
|
2599
|
+
a.briefTurnId = r.id;
|
|
2600
|
+
a.briefBubble = undefined;
|
|
2601
|
+
a.briefIsSlash = false;
|
|
2602
|
+
a.briefHadTool = r.items.some((it) => it.t === "tool_use");
|
|
2603
|
+
a.briefConcluded = false;
|
|
2604
|
+
a.briefLastText = undefined;
|
|
2605
|
+
log.info({ sessionId: a.sessionId, turnId: r.id }, "brief: turn resumed after reload");
|
|
2606
|
+
return true;
|
|
2607
|
+
};
|
|
2436
2608
|
// 无气泡 turn。CLI 侧自己开的一轮 (WeCom 从没发过消息, 拿不到 frame/streamId), 以及
|
|
2437
2609
|
// 收口后仍有 item 补写进来的情况, 都要有一个 turn 承接 —— 否则每条 tool/text 都掉进
|
|
2438
2610
|
// fallback standalone, 一轮工具密集的对话能在群里刷出几十条散装气泡。这里只推一条
|
|
@@ -2440,18 +2612,22 @@ export const startMirror = (deps) => {
|
|
|
2440
2612
|
const ensureBriefTurn = (a) => {
|
|
2441
2613
|
if (a.briefTurnId)
|
|
2442
2614
|
return;
|
|
2443
|
-
const turnId = newTurnId();
|
|
2444
2615
|
const query = a.pendingBriefQuery?.replace(/^> ?/gm, "").trim();
|
|
2616
|
+
const at = a.pendingBriefAt;
|
|
2445
2617
|
a.pendingBriefQuery = undefined;
|
|
2618
|
+
a.pendingBriefAt = undefined;
|
|
2619
|
+
if (!query && a.pendingFromCli !== true && resumeOpenTurn(a))
|
|
2620
|
+
return;
|
|
2621
|
+
const turnId = newTurnId();
|
|
2446
2622
|
// 出处继承: 有真人 CLI 输入行开头 → CLI 轮; 否则 (收口后的补写、peer/graph 注入
|
|
2447
2623
|
// 开出的轮) 沿用上一轮 —— 那些续写属于同一场对话, 不该改变可见性。
|
|
2448
2624
|
const fromCli = a.pendingFromCli === true;
|
|
2449
2625
|
a.pendingFromCli = false;
|
|
2450
2626
|
a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
|
|
2451
2627
|
const fresh = consumeFrom(a);
|
|
2452
|
-
consumeChannel(a, fromCli, fresh);
|
|
2628
|
+
const opened = consumeChannel(a, fromCli, fresh);
|
|
2453
2629
|
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) });
|
|
2630
|
+
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
2631
|
a.briefTurnId = turnId;
|
|
2456
2632
|
a.briefBubble = undefined;
|
|
2457
2633
|
a.briefIsSlash = false;
|
|
@@ -2781,7 +2957,7 @@ export const startMirror = (deps) => {
|
|
|
2781
2957
|
return;
|
|
2782
2958
|
}
|
|
2783
2959
|
if (item.kind === "turn_usage") {
|
|
2784
|
-
recordTurnUsage(turnId, { model: item.model, messageId: item.messageId, usage: item.usage });
|
|
2960
|
+
recordTurnUsage(turnId, { model: item.model, effort: item.effort, messageId: item.messageId, usage: item.usage });
|
|
2785
2961
|
return;
|
|
2786
2962
|
}
|
|
2787
2963
|
if (item.kind === "skill_output") {
|
|
@@ -2983,6 +3159,10 @@ export const startMirror = (deps) => {
|
|
|
2983
3159
|
// (crashed reply). Release BEFORE the swallow gate so this real line (and
|
|
2984
3160
|
// its reply) mirror normally instead of being eaten by the stale swallow.
|
|
2985
3161
|
if (item.kind === "user_text") {
|
|
3162
|
+
// 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
|
|
3163
|
+
// 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
|
|
3164
|
+
if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts))
|
|
3165
|
+
return;
|
|
2986
3166
|
endKeepaliveSwallow(a);
|
|
2987
3167
|
// 对话边界 = 旧 turn 清算。上一 turn 派发而未返回的前台 Agent/Task 到此作废,
|
|
2988
3168
|
// 清掉防残留阻塞新 turn 的软收口 (guard 的 defer 判断依据)。
|
|
@@ -2992,13 +3172,24 @@ export const startMirror = (deps) => {
|
|
|
2992
3172
|
// quiet (includeUser=false, 默认) 同样记: includeUser 只决定气泡渲不渲染,
|
|
2993
3173
|
// 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
|
|
2994
3174
|
a.pendingBriefQuery = item.body;
|
|
3175
|
+
a.pendingBriefAt = item.ts;
|
|
2995
3176
|
a.pendingFromCli = true;
|
|
2996
3177
|
}
|
|
3178
|
+
if (item.kind === "user_inject") {
|
|
3179
|
+
interject(a, item.said, item.ts);
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
2997
3182
|
// 还有一轮等着懒建 (peer / graph 注入先记了 pendingBriefQuery) 就换掉它; 否则这句话
|
|
2998
3183
|
// 就是已开好的那一轮 (IM 侧说的, startBriefTurn 在注入前建好)。
|
|
2999
3184
|
if (item.kind === "user_query") {
|
|
3000
|
-
|
|
3185
|
+
// 同伴 / 定时 / graph 注入的那一行: 问话就是印章里的原话 (不带信封)。
|
|
3186
|
+
const sealed = bindLine(a, item.said, item.ts);
|
|
3187
|
+
if (sealed && !sealed.turn)
|
|
3188
|
+
return;
|
|
3189
|
+
if (a.pendingBriefQuery !== undefined || !a.briefTurnId) {
|
|
3001
3190
|
a.pendingBriefQuery = item.body;
|
|
3191
|
+
a.pendingBriefAt = item.ts;
|
|
3192
|
+
}
|
|
3002
3193
|
// 只认同一句话: 轮次中途 CLI 里又粘了一张图, 不能改写正在跑的这一轮的问话。
|
|
3003
3194
|
else
|
|
3004
3195
|
recordTurnQuery(a.briefTurnId, item.body, (prev) => cleanUserText(prev ?? "") === item.said);
|
|
@@ -3032,7 +3223,7 @@ export const startMirror = (deps) => {
|
|
|
3032
3223
|
recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
|
|
3033
3224
|
}
|
|
3034
3225
|
else if (item.kind === "turn_usage") {
|
|
3035
|
-
recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
|
|
3226
|
+
recordTurnUsage(id, { model: item.model, effort: item.effort, messageId: item.messageId, usage: item.usage });
|
|
3036
3227
|
}
|
|
3037
3228
|
else if (item.kind === "turn_end") {
|
|
3038
3229
|
recordTurnClose(id);
|
|
@@ -3375,7 +3566,7 @@ export const startMirror = (deps) => {
|
|
|
3375
3566
|
const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
|
|
3376
3567
|
? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
|
|
3377
3568
|
: "";
|
|
3378
|
-
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd, keepaliveDisabled }) => {
|
|
3569
|
+
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, effort, pendingCwd, keepaliveDisabled }) => {
|
|
3379
3570
|
const target = resolveTarget(targetOverride);
|
|
3380
3571
|
if (!target)
|
|
3381
3572
|
return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
|
|
@@ -3405,6 +3596,7 @@ export const startMirror = (deps) => {
|
|
|
3405
3596
|
// model it asked for (possibly "" = CLI default) and says so; a re-attach
|
|
3406
3597
|
// onto an already-running pane doesn't, so it keeps what was recorded.
|
|
3407
3598
|
const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
|
|
3599
|
+
const carryEffort = effort !== undefined ? effort.trim() : (prevByTarget?.effort ?? deps.store.get(target)?.effort ?? "");
|
|
3408
3600
|
// Same carry rule again: an explicit spawn-time choice wins, otherwise this
|
|
3409
3601
|
// wizard's existing policy (in-memory, or on disk across a reload) sticks.
|
|
3410
3602
|
const carryKeepaliveDisabled = keepaliveDisabled !== undefined ? keepaliveDisabled : (prevByTarget?.keepaliveDisabled ?? deps.store.get(target)?.keepaliveDisabled ?? false);
|
|
@@ -3423,6 +3615,7 @@ export const startMirror = (deps) => {
|
|
|
3423
3615
|
runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
|
|
3424
3616
|
pendingCwd: carryPending,
|
|
3425
3617
|
model: carryModel,
|
|
3618
|
+
effort: carryEffort,
|
|
3426
3619
|
keepaliveDisabled: carryKeepaliveDisabled,
|
|
3427
3620
|
tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
|
|
3428
3621
|
standalonePending: Promise.resolve(),
|
|
@@ -3440,6 +3633,7 @@ export const startMirror = (deps) => {
|
|
|
3440
3633
|
isOwnAssistantSend,
|
|
3441
3634
|
isKeepalivePing,
|
|
3442
3635
|
onItem: (item) => onItem(a, item),
|
|
3636
|
+
onCompact: () => contextCut(a.target, "compact"),
|
|
3443
3637
|
detailUrlFor,
|
|
3444
3638
|
sessionId,
|
|
3445
3639
|
target,
|
|
@@ -3461,6 +3655,7 @@ export const startMirror = (deps) => {
|
|
|
3461
3655
|
tmuxPane: a.tmuxPane || undefined,
|
|
3462
3656
|
cwd: a.runningCwd || undefined,
|
|
3463
3657
|
model: a.model || undefined,
|
|
3658
|
+
effort: a.effort || undefined,
|
|
3464
3659
|
pendingCwd: a.pendingCwd || undefined,
|
|
3465
3660
|
cwdConfirmed: prevRec?.cwdConfirmed,
|
|
3466
3661
|
keepaliveOff: prevRec?.keepaliveOff,
|
|
@@ -3498,8 +3693,12 @@ export const startMirror = (deps) => {
|
|
|
3498
3693
|
// it collapses N distinct peers onto one sessionId (attach() then dedupes by
|
|
3499
3694
|
// sid and silently detaches all-but-one). Excluding claimed sids keeps each
|
|
3500
3695
|
// peer on its own session.
|
|
3696
|
+
// 正在出生、还没 attach 的会话 sid: 新会话 (spawn 时 jsonl 就预建了) 与认下了的分叉
|
|
3697
|
+
// 文件。同一个 cwd 下别的 target 的迁移 / 自愈 / 分叉跟随也在扫这个目录里「新出现的
|
|
3698
|
+
// jsonl」, 不挡住的话它会先一步把别人的新会话认走, 正主 attach 时反撞 "already bound"。
|
|
3699
|
+
const inFlightSids = new Set();
|
|
3501
3700
|
const sidsClaimedByOthers = (principal) => {
|
|
3502
|
-
const s = new Set();
|
|
3701
|
+
const s = new Set(inFlightSids);
|
|
3503
3702
|
for (const [k, v] of Object.entries(deps.store.all()))
|
|
3504
3703
|
if (k !== principal && v.sessionId)
|
|
3505
3704
|
s.add(v.sessionId);
|
|
@@ -3609,6 +3808,7 @@ export const startMirror = (deps) => {
|
|
|
3609
3808
|
tmuxSession: rec.tmuxSession ?? "",
|
|
3610
3809
|
cwd: rec.cwd,
|
|
3611
3810
|
model: rec.model,
|
|
3811
|
+
effort: rec.effort,
|
|
3612
3812
|
pendingCwd: rec.pendingCwd,
|
|
3613
3813
|
});
|
|
3614
3814
|
if (!r.ok) {
|
|
@@ -3809,6 +4009,17 @@ export const startMirror = (deps) => {
|
|
|
3809
4009
|
catch { /* unreadable */ }
|
|
3810
4010
|
return undefined;
|
|
3811
4011
|
};
|
|
4012
|
+
// A resume / --fork-session copy is seeded with the origin's history, uuids
|
|
4013
|
+
// intact — so "its first user line is ours" is pane-certain evidence that a
|
|
4014
|
+
// new jsonl is OUR fork. "Has any user content" is not: a sibling spawned in
|
|
4015
|
+
// the same cwd a moment earlier also has some, and its sid isn't claimed yet
|
|
4016
|
+
// (attach lands after the spawn) — that is how a respawned .evolve got bound
|
|
4017
|
+
// to a haiku sibling's fresh transcript and read empty from then on.
|
|
4018
|
+
const forkOf = (origin) => {
|
|
4019
|
+
const seed = firstUserUuid(origin);
|
|
4020
|
+
// origin 还没有 user 行 (没说过话就死了 / 刚开的父亲): 没有可比的种子, 退回旧判据。
|
|
4021
|
+
return seed === undefined ? (path) => firstUserUuid(path) !== undefined : (path) => firstUserUuid(path) === seed;
|
|
4022
|
+
};
|
|
3812
4023
|
// startOffset semantics differ by caller: /clear passes 0 to replay the
|
|
3813
4024
|
// freshly-rotated jsonl (only holds the /clear line + new content); resume
|
|
3814
4025
|
// migration omits it (→ tail from EOF) because the fork file is seeded with
|
|
@@ -3829,6 +4040,8 @@ export const startMirror = (deps) => {
|
|
|
3829
4040
|
// drift 上下文是延续的, 画中性的 "switch"。
|
|
3830
4041
|
const cleared = jsonlIsPostClearChild(newJsonlPath);
|
|
3831
4042
|
markCut(a.target, newSessionId, cleared ? "clear" : "switch");
|
|
4043
|
+
if (cleared)
|
|
4044
|
+
contextCut(a.target, "clear");
|
|
3832
4045
|
if (cleared) {
|
|
3833
4046
|
// 真清空 = 缓存里已无任何值得保温的内容。像 /stop 一样暂停保活 —— 与
|
|
3834
4047
|
// dispatch 里 WeCom 注入 /clear 的暂停对齐;TUI 手打 /clear 只走这个漏斗,
|
|
@@ -3855,6 +4068,7 @@ export const startMirror = (deps) => {
|
|
|
3855
4068
|
isOwnAssistantSend,
|
|
3856
4069
|
isKeepalivePing,
|
|
3857
4070
|
onItem: (item) => onItem(a, item),
|
|
4071
|
+
onCompact: () => contextCut(a.target, "compact"),
|
|
3858
4072
|
detailUrlFor,
|
|
3859
4073
|
sessionId: newSessionId,
|
|
3860
4074
|
target: a.target,
|
|
@@ -4057,6 +4271,9 @@ export const startMirror = (deps) => {
|
|
|
4057
4271
|
// Post-/clear: only transcripts that didn't exist before the rotation
|
|
4058
4272
|
// can be ours, which is what licenses the shorter fingerprint above.
|
|
4059
4273
|
.filter((n) => !pendingClear || !pendingClear.baseline.has(n))
|
|
4274
|
+
// 别人的 (含还在出生、没 attach 的新会话与分叉 —— 分叉文件带着父亲注进去的原话,
|
|
4275
|
+
// 指纹会撞上) 不认。
|
|
4276
|
+
.filter((n) => !sidsClaimedByOthers(a.target).has(n.replace(/\.jsonl$/, "")))
|
|
4060
4277
|
.map((n) => ({ n, m: (() => { try {
|
|
4061
4278
|
return statSync(join(dir, n)).mtimeMs;
|
|
4062
4279
|
}
|
|
@@ -4199,6 +4416,17 @@ export const startMirror = (deps) => {
|
|
|
4199
4416
|
// 新 pane 落地后的钩子 —— pane 上限在这里数 (由 index.ts 装上; 没装 = 不数)。
|
|
4200
4417
|
let afterSpawn = () => { };
|
|
4201
4418
|
const onSpawn = (fn) => { afterSpawn = fn; };
|
|
4419
|
+
let afterCut = () => { };
|
|
4420
|
+
const onContextCut = (fn) => { afterCut = fn; };
|
|
4421
|
+
const contextCut = (target, cut) => {
|
|
4422
|
+
log.info({ target, cut }, "context cut");
|
|
4423
|
+
try {
|
|
4424
|
+
afterCut(target, cut);
|
|
4425
|
+
}
|
|
4426
|
+
catch (e) {
|
|
4427
|
+
log.warn({ target, cut, err: e.message }, "onContextCut hook threw");
|
|
4428
|
+
}
|
|
4429
|
+
};
|
|
4202
4430
|
// `cwd` = 这个 pane 正要启动的目录。attach 在 spawn 之后才发生, 所以此刻 getCwd
|
|
4203
4431
|
// 给的还是上一个 pane 的目录 —— 换目录重开时照它渲染, 新 wizard 会以为自己还在
|
|
4204
4432
|
// 旧工作区里。
|
|
@@ -4210,6 +4438,32 @@ export const startMirror = (deps) => {
|
|
|
4210
4438
|
return undefined;
|
|
4211
4439
|
}
|
|
4212
4440
|
};
|
|
4441
|
+
// 确切模型名的证据 (见 exactModelId): transcript 里 API 真答过的 model id, 只认同一个
|
|
4442
|
+
// CLI 的。先问最可能的那几个会话 (自己、被克隆者), 再按新近扫其余 —— 惰性的,
|
|
4443
|
+
// 第一个对上就停, 绝大多数时候只读一两个 transcript 的尾巴。
|
|
4444
|
+
function* modelSightings(cli, likely) {
|
|
4445
|
+
const name = cli ?? primaryBackend().name;
|
|
4446
|
+
const pathOf = (t) => byTarget.get(t)?.jsonlPath ?? expandHome(deps.store.get(t)?.jsonlPath ?? "");
|
|
4447
|
+
const head = likely.map(pathOf).filter(Boolean);
|
|
4448
|
+
// 生成器体到第一次取值才跑: 前几个就对上时, 其余几百个 stat 一个也不做。
|
|
4449
|
+
const paths = function* () {
|
|
4450
|
+
yield* head;
|
|
4451
|
+
yield* Object.values(deps.store.all())
|
|
4452
|
+
.map((r) => expandHome(r.jsonlPath))
|
|
4453
|
+
.filter((p) => !head.includes(p))
|
|
4454
|
+
.map((p) => ({ p, m: mtimeOf(p) }))
|
|
4455
|
+
.filter((x) => x.m > 0)
|
|
4456
|
+
.sort((x, y) => y.m - x.m)
|
|
4457
|
+
.map((x) => x.p);
|
|
4458
|
+
};
|
|
4459
|
+
for (const path of paths()) {
|
|
4460
|
+
if (!existsSync(path) || backendForPath(path).name !== name)
|
|
4461
|
+
continue;
|
|
4462
|
+
const id = lastModel(path);
|
|
4463
|
+
if (id)
|
|
4464
|
+
yield id;
|
|
4465
|
+
}
|
|
4466
|
+
}
|
|
4213
4467
|
const newSession = async (target, windowName, cli, opts) => {
|
|
4214
4468
|
const prev = byTarget.get(target);
|
|
4215
4469
|
// Resolution precedence (all chat-scoped except the running-cwd fallback):
|
|
@@ -4241,23 +4495,32 @@ export const startMirror = (deps) => {
|
|
|
4241
4495
|
const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
|
|
4242
4496
|
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
|
|
4243
4497
|
const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
|
|
4498
|
+
const effEffort = opts?.effort ?? parseEffort(prev?.effort ?? deps.store.get(target)?.effort);
|
|
4244
4499
|
if (prev?.tmuxPane) {
|
|
4245
4500
|
// Best-effort kill; ignore errors (pane may already be dead).
|
|
4246
4501
|
void runTmux(["kill-pane", "-t", prev.tmuxPane]);
|
|
4247
4502
|
}
|
|
4248
4503
|
if (prev)
|
|
4249
4504
|
detach(prev, "/new respawn");
|
|
4505
|
+
// sid 由这里先定, 出生到 attach 之间挂在 inFlightSids 上 (见那里)。
|
|
4506
|
+
const freshSid = randomUUID();
|
|
4507
|
+
inFlightSids.add(freshSid);
|
|
4250
4508
|
const r = await spawnTmuxClaude({
|
|
4251
4509
|
cfg,
|
|
4252
4510
|
log: log.child({ sub: "new-session", target }),
|
|
4511
|
+
sessionId: freshSid,
|
|
4253
4512
|
windowName: windowName ?? target,
|
|
4254
4513
|
cwdOverride: eff,
|
|
4255
4514
|
cli: effCli,
|
|
4256
4515
|
model: opts?.model,
|
|
4516
|
+
knownModels: modelSightings(effCli, [target, base]),
|
|
4517
|
+
effort: effEffort,
|
|
4257
4518
|
systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
|
|
4258
|
-
});
|
|
4259
|
-
if (!r.ok)
|
|
4519
|
+
}).catch((e) => { inFlightSids.delete(freshSid); throw e; });
|
|
4520
|
+
if (!r.ok) {
|
|
4521
|
+
inFlightSids.delete(freshSid);
|
|
4260
4522
|
return { ok: false, reason: r.reason };
|
|
4523
|
+
}
|
|
4261
4524
|
const att = attach({
|
|
4262
4525
|
sessionId: r.sessionId,
|
|
4263
4526
|
jsonlPath: r.jsonlPath,
|
|
@@ -4272,8 +4535,10 @@ export const startMirror = (deps) => {
|
|
|
4272
4535
|
// spawnTmuxClaude actually confirmed via `/model` (r.model), not the
|
|
4273
4536
|
// raw request — a later respawn's fast path then matches on try one.
|
|
4274
4537
|
model: r.model ?? (opts?.model?.trim() ?? ""),
|
|
4538
|
+
effort: r.effort ?? "",
|
|
4275
4539
|
keepaliveDisabled: opts?.keepalive === undefined ? undefined : !opts.keepalive,
|
|
4276
4540
|
});
|
|
4541
|
+
inFlightSids.delete(freshSid);
|
|
4277
4542
|
if (!att.ok)
|
|
4278
4543
|
return { ok: false, reason: att.reason };
|
|
4279
4544
|
// 首条注入吃冷时序(injectText 走 /mirror/spawn 时已经硬编码 freshSpawn:true,
|
|
@@ -4307,6 +4572,8 @@ export const startMirror = (deps) => {
|
|
|
4307
4572
|
tmuxSession: baseA.tmuxSession || undefined,
|
|
4308
4573
|
tmuxPane: baseA.tmuxPane || undefined,
|
|
4309
4574
|
cwd: baseA.runningCwd || undefined,
|
|
4575
|
+
model: baseA.model || undefined,
|
|
4576
|
+
effort: baseA.effort || undefined,
|
|
4310
4577
|
cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
|
|
4311
4578
|
pendingCwd: undefined,
|
|
4312
4579
|
});
|
|
@@ -4320,7 +4587,7 @@ export const startMirror = (deps) => {
|
|
|
4320
4587
|
if (!opts?.silent)
|
|
4321
4588
|
pushProjectInfo(target, "created");
|
|
4322
4589
|
afterSpawn();
|
|
4323
|
-
return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning };
|
|
4590
|
+
return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning, effort: r.effort };
|
|
4324
4591
|
};
|
|
4325
4592
|
// ── Clone ───────────────────────────────────────────────────────────
|
|
4326
4593
|
// 分身 = `--resume <父 sid> --fork-session` 起的新 pane: CLI 把父亲的 transcript
|
|
@@ -4337,19 +4604,35 @@ export const startMirror = (deps) => {
|
|
|
4337
4604
|
// (attach 也会正当地拒绝), 一个干净的失败远比一条错绑好收拾。
|
|
4338
4605
|
const FORK_POLL_MS = 400;
|
|
4339
4606
|
const FORK_WAIT_MS = 45_000;
|
|
4340
|
-
|
|
4341
|
-
|
|
4607
|
+
// 认下的分叉文件同样记进 inFlightSids: 同一个父亲并发克隆出几个分身时, 每个分叉的首行
|
|
4608
|
+
// 都与父亲相同, 不预留的话两个 awaitFork 会认下同一个文件。
|
|
4609
|
+
const awaitFork = async (projectDir, baseline, claimed, origin, probe) => {
|
|
4610
|
+
const ours = forkOf(origin);
|
|
4611
|
+
const take = (sessionId, jsonlPath) => (inFlightSids.add(sessionId), { sessionId, jsonlPath });
|
|
4612
|
+
const start = Date.now();
|
|
4613
|
+
const deadline = start + FORK_WAIT_MS;
|
|
4342
4614
|
while (Date.now() < deadline) {
|
|
4615
|
+
// 首选会话注册表: 它按 pane 报这个进程此刻的 sid —— 并发分叉时唯一不靠猜的来源。
|
|
4616
|
+
const live = probe.pane ? sessionOnPane(probe.homeDir, probe.pane) : undefined;
|
|
4617
|
+
if (live && live.sessionId !== probe.parentSid && !claimed.has(live.sessionId)) {
|
|
4618
|
+
const path = join(projectDir, `${live.sessionId}.jsonl`);
|
|
4619
|
+
if (existsSync(path) && ours(path))
|
|
4620
|
+
return take(live.sessionId, path);
|
|
4621
|
+
}
|
|
4343
4622
|
const born = [...listJsonls(projectDir)]
|
|
4344
|
-
.filter((n) => !baseline.has(n) && !claimed.has(n.replace(/\.jsonl$/, "")))
|
|
4623
|
+
.filter((n) => !baseline.has(n) && !claimed.has(n.replace(/\.jsonl$/, "")) && !inFlightSids.has(n.replace(/\.jsonl$/, "")))
|
|
4345
4624
|
.map((n) => ({ n, path: join(projectDir, n) }))
|
|
4346
4625
|
// 种子化的 fork 一落地就带着父亲的 user 行 —— 空文件 (刚 touch 出来的)
|
|
4347
|
-
// 还不能认, 认早了 tail
|
|
4348
|
-
|
|
4626
|
+
// 还不能认, 认早了 tail 会从一个还没写完的复制过程中间开始读; 别的 pane
|
|
4627
|
+
// 同时新开的会话也有 user 行, 只认首行 uuid 与父亲相同的那个。
|
|
4628
|
+
.filter((c) => ours(c.path))
|
|
4349
4629
|
.sort((x, y) => mtimeOf(y.path) - mtimeOf(x.path));
|
|
4350
|
-
|
|
4630
|
+
// 注册表报不出 (没有注册表的后端 / 这个 pane 没登记 / 一阵子了还报着父亲的 sid)
|
|
4631
|
+
// 才按「新出现、首行同父」去猜, 认下即预留。
|
|
4632
|
+
const guess = !hasRegistry(probe.homeDir) || !live || Date.now() - start > FORK_WAIT_MS / 3;
|
|
4633
|
+
const hit = guess ? born[0] : undefined;
|
|
4351
4634
|
if (hit)
|
|
4352
|
-
return
|
|
4635
|
+
return take(hit.n.replace(/\.jsonl$/, ""), hit.path);
|
|
4353
4636
|
await sleep(FORK_POLL_MS);
|
|
4354
4637
|
}
|
|
4355
4638
|
return undefined;
|
|
@@ -4362,7 +4645,7 @@ export const startMirror = (deps) => {
|
|
|
4362
4645
|
const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
|
|
4363
4646
|
if (!canInherit) {
|
|
4364
4647
|
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,
|
|
4648
|
+
model: args.model, effort: args.effort, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive,
|
|
4366
4649
|
});
|
|
4367
4650
|
return { ...r, inherited: false };
|
|
4368
4651
|
}
|
|
@@ -4370,6 +4653,10 @@ export const startMirror = (deps) => {
|
|
|
4370
4653
|
const projectDir = dirname(parent.jsonlPath);
|
|
4371
4654
|
const baseline = listJsonls(projectDir);
|
|
4372
4655
|
const lg = log.child({ sub: "clone", target: args.target });
|
|
4656
|
+
// 分身默认跑被克隆者此刻真在跑的那个: 记下的模型, 没记 (CLI 默认) 就取它 transcript 里的 id。
|
|
4657
|
+
const parentCli = backendForPath(parent.jsonlPath).name;
|
|
4658
|
+
const model = args.model?.trim() || parent.model || lastModel(parent.jsonlPath);
|
|
4659
|
+
const effort = args.effort ?? parseEffort(parent.effort);
|
|
4373
4660
|
const r = await spawnTmuxClaude({
|
|
4374
4661
|
cfg,
|
|
4375
4662
|
log: lg,
|
|
@@ -4377,8 +4664,10 @@ export const startMirror = (deps) => {
|
|
|
4377
4664
|
forkSession: true,
|
|
4378
4665
|
windowName: args.windowName ?? args.target,
|
|
4379
4666
|
cwdOverride: parent.runningCwd,
|
|
4380
|
-
cli:
|
|
4381
|
-
model
|
|
4667
|
+
cli: parentCli,
|
|
4668
|
+
model,
|
|
4669
|
+
knownModels: modelSightings(parentCli, [args.parent]),
|
|
4670
|
+
effort,
|
|
4382
4671
|
systemPrompt: args.systemPrompt,
|
|
4383
4672
|
});
|
|
4384
4673
|
if (!r.ok)
|
|
@@ -4398,7 +4687,9 @@ export const startMirror = (deps) => {
|
|
|
4398
4687
|
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4399
4688
|
return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
|
|
4400
4689
|
}
|
|
4401
|
-
const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target)
|
|
4690
|
+
const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
|
|
4691
|
+
homeDir: backendForPath(parent.jsonlPath).homeDir, pane: r.tmuxPane ?? "", parentSid: parent.sessionId,
|
|
4692
|
+
});
|
|
4402
4693
|
// 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
|
|
4403
4694
|
// 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
|
|
4404
4695
|
// 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
|
|
@@ -4414,9 +4705,12 @@ export const startMirror = (deps) => {
|
|
|
4414
4705
|
tmuxSession: r.tmuxSession,
|
|
4415
4706
|
cwd: r.cwd,
|
|
4416
4707
|
pendingCwd: "",
|
|
4417
|
-
model: r.model ??
|
|
4708
|
+
model: r.model ?? model,
|
|
4709
|
+
effort: r.effort ?? "",
|
|
4418
4710
|
keepaliveDisabled: args.keepalive === undefined ? undefined : !args.keepalive,
|
|
4419
4711
|
});
|
|
4712
|
+
// 绑上 (或没绑上) 之后预留就没用了: 绑上的 sid 由 sidsClaimedByOthers 挡住别人。
|
|
4713
|
+
inFlightSids.delete(fork.sessionId);
|
|
4420
4714
|
if (!att.ok)
|
|
4421
4715
|
return { ok: false, reason: att.reason, inherited: false };
|
|
4422
4716
|
const spawned = byTarget.get(args.target);
|
|
@@ -4428,10 +4722,16 @@ export const startMirror = (deps) => {
|
|
|
4428
4722
|
spawned.keepaliveOffAt = Date.now();
|
|
4429
4723
|
spawned.keepalive = undefined;
|
|
4430
4724
|
persistPause(spawned);
|
|
4725
|
+
// 开场白早在 attach 之前就落进分叉文件了 (tail 从 EOF 起, 读不到那一行): 第一轮懒建时
|
|
4726
|
+
// 才认得出它是谁派的活。印章赶在 tail 第一次 drain 之前盖上。
|
|
4727
|
+
if (args.bootstrapFrom) {
|
|
4728
|
+
spawned.pendingFrom = { from: args.bootstrapFrom.from, at: Date.now() };
|
|
4729
|
+
spawned.pendingBriefQuery = args.bootstrapFrom.query;
|
|
4730
|
+
}
|
|
4431
4731
|
}
|
|
4432
4732
|
lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
|
|
4433
4733
|
afterSpawn();
|
|
4434
|
-
return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning };
|
|
4734
|
+
return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning, effort: r.effort };
|
|
4435
4735
|
};
|
|
4436
4736
|
/** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
|
|
4437
4737
|
const sessionInfo = (target) => {
|
|
@@ -4447,6 +4747,7 @@ export const startMirror = (deps) => {
|
|
|
4447
4747
|
cwd: a?.runningCwd || rec?.cwd || "",
|
|
4448
4748
|
cli: backendForPath(jsonlPath).name,
|
|
4449
4749
|
model: a?.model || rec?.model || "",
|
|
4750
|
+
effort: a?.effort || rec?.effort || "",
|
|
4450
4751
|
tmuxPane: a?.tmuxPane ?? rec?.tmuxPane ?? "",
|
|
4451
4752
|
contextTokens: lastContextTokens(jsonlPath),
|
|
4452
4753
|
};
|
|
@@ -4605,7 +4906,8 @@ export const startMirror = (deps) => {
|
|
|
4605
4906
|
...allTargets().map(baseOfKey),
|
|
4606
4907
|
baseOfKey(self),
|
|
4607
4908
|
]);
|
|
4608
|
-
|
|
4909
|
+
// 内部会话 (记忆整理者) 的 home 不是聊天: 不列、不补名。
|
|
4910
|
+
return [...bases].filter((b) => b && !isInternalKey(b)).sort().map((base) => ({
|
|
4609
4911
|
base,
|
|
4610
4912
|
name: chatNameOf(cfg, base),
|
|
4611
4913
|
self: base === baseOfKey(self),
|
|
@@ -4633,18 +4935,27 @@ export const startMirror = (deps) => {
|
|
|
4633
4935
|
return false;
|
|
4634
4936
|
if (live.status !== "idle")
|
|
4635
4937
|
return undefined;
|
|
4938
|
+
return !hanging(target, live.sessionId);
|
|
4939
|
+
};
|
|
4940
|
+
/** 闲着却悬着工具调用 = 停在审批卡 / 本地弹窗上。resume / fork 会换 sid: 悬着的
|
|
4941
|
+
* 调用记在注册表报的当前那份 transcript 里。 */
|
|
4942
|
+
const hanging = (target, sid) => {
|
|
4636
4943
|
const bound = jsonlOf(target);
|
|
4637
|
-
|
|
4638
|
-
|
|
4639
|
-
|
|
4944
|
+
const jsonl = bound ? join(dirname(bound), `${sid}.jsonl`) : "";
|
|
4945
|
+
return !!jsonl && existsSync(jsonl) && openToolUses(jsonl).length > 0;
|
|
4946
|
+
};
|
|
4947
|
+
const parkedNow = (target) => {
|
|
4948
|
+
const pane = paneOf(target);
|
|
4949
|
+
const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
|
|
4950
|
+
return live?.status === "idle" && hanging(target, live.sessionId);
|
|
4640
4951
|
};
|
|
4641
4952
|
const idleNow = async (target) => idleVerdict(target) ?? !(await isBusy(target));
|
|
4642
4953
|
// 等到它闲下来, 事件驱动: 注册表目录一有写入就重判, 翻成 idle 的那一刻即返回。
|
|
4643
4954
|
// 兜底轮询只防 watch 漏事件 (以及悬着的审批被点掉后 status 没再动的边角)。
|
|
4644
4955
|
const IDLE_SAFETY_POLL_MS = 1000;
|
|
4645
|
-
const untilIdle = (target, timeoutMs) => {
|
|
4956
|
+
const untilIdle = (target, timeoutMs, { aborted = () => false, confirm = 2 } = {}) => {
|
|
4646
4957
|
if (idleVerdict(target) === undefined)
|
|
4647
|
-
return waitForIdle(target, isBusy, timeoutMs,
|
|
4958
|
+
return waitForIdle(target, isBusy, timeoutMs, aborted, { rampMs: 0, confirm });
|
|
4648
4959
|
return new Promise((resolve) => {
|
|
4649
4960
|
let done = false;
|
|
4650
4961
|
const finish = (r) => {
|
|
@@ -4668,6 +4979,10 @@ export const startMirror = (deps) => {
|
|
|
4668
4979
|
const tick = setInterval(() => void check(), IDLE_SAFETY_POLL_MS);
|
|
4669
4980
|
const timer = setTimeout(() => finish({ idle: false, reason: "idle wait timed out" }), timeoutMs);
|
|
4670
4981
|
void check();
|
|
4982
|
+
if (aborted()) {
|
|
4983
|
+
finish({ idle: false, reason: "stopped" });
|
|
4984
|
+
return;
|
|
4985
|
+
}
|
|
4671
4986
|
});
|
|
4672
4987
|
};
|
|
4673
4988
|
const lastText = (target) => {
|
|
@@ -4678,9 +4993,9 @@ export const startMirror = (deps) => {
|
|
|
4678
4993
|
const p = jsonlOf(target);
|
|
4679
4994
|
return p ? lastReply(p, sinceMs, warmerSigs) : "";
|
|
4680
4995
|
};
|
|
4681
|
-
const replyToPeerSince = (target, fromName, sinceMs = 0) => {
|
|
4996
|
+
const replyToPeerSince = (target, fromName, sinceMs = 0, turn) => {
|
|
4682
4997
|
const p = jsonlOf(target);
|
|
4683
|
-
return p ? replyToPeerIn(p, fromName, sinceMs, warmerSigs) : undefined;
|
|
4998
|
+
return p ? replyToPeerIn(p, fromName, sinceMs, warmerSigs, turn) : undefined;
|
|
4684
4999
|
};
|
|
4685
5000
|
/** 绑着的 pane 还能用 (存在、且不是别人的窗口) —— 不重生、不猜。 */
|
|
4686
5001
|
const paneLiveOf = async (target) => {
|
|
@@ -4841,7 +5156,7 @@ export const startMirror = (deps) => {
|
|
|
4841
5156
|
round = s.streak;
|
|
4842
5157
|
}
|
|
4843
5158
|
catch { /* unreadable tail */ }
|
|
4844
|
-
return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0 };
|
|
5159
|
+
return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), stallFree: 0 };
|
|
4845
5160
|
};
|
|
4846
5161
|
const fireKeepalive = async (a, stalled) => {
|
|
4847
5162
|
const kc = cfg.wrc.mirror.keepalive;
|
|
@@ -4861,13 +5176,15 @@ export const startMirror = (deps) => {
|
|
|
4861
5176
|
// the turn somehow never emits turn_end, so a later real turn is never muted;
|
|
4862
5177
|
// it also closes the detail turn so it can't hang open in the chat timeline.
|
|
4863
5178
|
rememberInject(text);
|
|
4864
|
-
|
|
4865
|
-
|
|
4866
|
-
|
|
4867
|
-
|
|
4868
|
-
a.keepaliveTurnId
|
|
4869
|
-
|
|
4870
|
-
|
|
5179
|
+
// 续跑不是保温 (见 isKeepalivePing): 不开吞没窗口, 也不记成保温轮。
|
|
5180
|
+
if (!stalled)
|
|
5181
|
+
a.keepaliveQuiet = setTimeout(() => {
|
|
5182
|
+
a.keepaliveQuiet = undefined;
|
|
5183
|
+
if (a.keepaliveTurnId) {
|
|
5184
|
+
recordTurnClose(a.keepaliveTurnId);
|
|
5185
|
+
a.keepaliveTurnId = undefined;
|
|
5186
|
+
}
|
|
5187
|
+
}, 60_000);
|
|
4871
5188
|
const r = await inject({
|
|
4872
5189
|
text, images: [], cfg,
|
|
4873
5190
|
log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
|
|
@@ -4889,7 +5206,7 @@ export const startMirror = (deps) => {
|
|
|
4889
5206
|
// closed on the ping's turn_end (or the fail-safe). Kept out of chat.
|
|
4890
5207
|
// The tail's keepalive_start (content match on the user line) may have
|
|
4891
5208
|
// opened this turn already — only open when it hasn't.
|
|
4892
|
-
if (!a.keepaliveTurnId) {
|
|
5209
|
+
if (!stalled && !a.keepaliveTurnId) {
|
|
4893
5210
|
const turnId = newTurnId();
|
|
4894
5211
|
a.keepaliveTurnId = turnId;
|
|
4895
5212
|
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
|
|
@@ -4902,8 +5219,9 @@ export const startMirror = (deps) => {
|
|
|
4902
5219
|
return;
|
|
4903
5220
|
keepaliveTicking = true;
|
|
4904
5221
|
try {
|
|
4905
|
-
|
|
4906
|
-
|
|
5222
|
+
// Stall recovery keeps the configured window: a turn that died on an API
|
|
5223
|
+
// error should be resumed minutes later, whatever the cache tier.
|
|
5224
|
+
const stallQuietMs = Math.max(30, kc.ttlSec - kc.marginSec) * 1000;
|
|
4907
5225
|
const pingSigs = [normAssistant(kc.ping).slice(0, 40), normAssistant(kc.resumePing).slice(0, 40)].filter((s) => s.length > 0);
|
|
4908
5226
|
const now = Date.now();
|
|
4909
5227
|
for (const a of byTarget.values()) {
|
|
@@ -4942,6 +5260,7 @@ export const startMirror = (deps) => {
|
|
|
4942
5260
|
k.lastMs = mtime;
|
|
4943
5261
|
k.lastRealMs = mtime;
|
|
4944
5262
|
} // backend without timestamps → fall back to mtime
|
|
5263
|
+
k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
|
|
4945
5264
|
}
|
|
4946
5265
|
k.seenMtime = mtime;
|
|
4947
5266
|
// Round reset / real-activity re-anchor keys off REAL turns only — a
|
|
@@ -4980,8 +5299,13 @@ export const startMirror = (deps) => {
|
|
|
4980
5299
|
if (a.keepaliveOff)
|
|
4981
5300
|
continue; // paused by /stop until real activity returns
|
|
4982
5301
|
const idleSinceTouch = k.lastMs ? now - k.lastMs : now - mtime; // last model turn = cache touch
|
|
4983
|
-
|
|
4984
|
-
|
|
5302
|
+
// The TTL the session's cache entries were actually written with (1h on a
|
|
5303
|
+
// Claude Code subscription), not a guess: pinging at the 5min cadence
|
|
5304
|
+
// against a 1h entry re-reads the whole context ~14x per hour for nothing.
|
|
5305
|
+
const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
|
|
5306
|
+
const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
|
|
5307
|
+
if (idleSinceTouch < stallQuietMs)
|
|
5308
|
+
continue; // nothing due yet on either clock
|
|
4985
5309
|
if (idleSinceTouch >= ttlMs)
|
|
4986
5310
|
continue; // cache already cold — a ping would cold-rewrite for nothing
|
|
4987
5311
|
if (k.round >= kc.rounds)
|
|
@@ -4990,8 +5314,15 @@ export const startMirror = (deps) => {
|
|
|
4990
5314
|
// the transcript's structure only — never the screen: the turn is still
|
|
4991
5315
|
// unfinished (died on a synthetic API error, or a tool result nobody
|
|
4992
5316
|
// answered) and has been quiet for the whole idle window. Send the resume
|
|
4993
|
-
// instruction instead of the plain warmer.
|
|
4994
|
-
|
|
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
|
|
4995
5326
|
k.pinging = true;
|
|
4996
5327
|
k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
|
|
4997
5328
|
await fireKeepalive(a, stalled);
|
|
@@ -5150,6 +5481,7 @@ export const startMirror = (deps) => {
|
|
|
5150
5481
|
peekTurns,
|
|
5151
5482
|
isBusy,
|
|
5152
5483
|
idleNow,
|
|
5484
|
+
parkedNow,
|
|
5153
5485
|
untilIdle,
|
|
5154
5486
|
setModel: async (target, wanted, scope) => {
|
|
5155
5487
|
const a = byTarget.get(target);
|
|
@@ -5164,6 +5496,19 @@ export const startMirror = (deps) => {
|
|
|
5164
5496
|
}
|
|
5165
5497
|
return r;
|
|
5166
5498
|
},
|
|
5499
|
+
setEffort: async (target, effort) => {
|
|
5500
|
+
const a = byTarget.get(target);
|
|
5501
|
+
if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
|
|
5502
|
+
return { ok: false, reason: "它没有活着的 pane —— 档位是在 pane 里切的, 先叫醒它" };
|
|
5503
|
+
const r = await selectEffort(a.tmuxPane, effort, log.child({ sub: "set-effort", target }));
|
|
5504
|
+
if (r.ok && r.applied) {
|
|
5505
|
+
a.effort = r.applied;
|
|
5506
|
+
const rec = deps.store.get(target);
|
|
5507
|
+
if (rec)
|
|
5508
|
+
deps.store.set(target, { ...rec, effort: r.applied });
|
|
5509
|
+
}
|
|
5510
|
+
return r;
|
|
5511
|
+
},
|
|
5167
5512
|
transcripts: () => allTargets().flatMap((target) => {
|
|
5168
5513
|
const jsonlPath = jsonlOf(target);
|
|
5169
5514
|
if (!jsonlPath)
|
|
@@ -5368,15 +5713,8 @@ export const startMirror = (deps) => {
|
|
|
5368
5713
|
rememberInject(full);
|
|
5369
5714
|
// 印章要早于 inject 落地 —— tail 是独立轮询的, 它可能在 inject 的 promise
|
|
5370
5715
|
// 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;
|
|
5716
|
+
// 印章随这一行走 (见 Seal): 行露面时才落成那一轮的出处 / 频道 / 问话。
|
|
5717
|
+
pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
|
|
5380
5718
|
const sid = a.sessionId;
|
|
5381
5719
|
const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
|
|
5382
5720
|
if (!paneAlive) {
|
|
@@ -5384,15 +5722,15 @@ export const startMirror = (deps) => {
|
|
|
5384
5722
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
5385
5723
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
5386
5724
|
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 }) });
|
|
5725
|
+
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
5726
|
if (!r.ok || !r.tmuxPane)
|
|
5389
5727
|
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
5390
5728
|
a.tmuxPane = r.tmuxPane;
|
|
5391
5729
|
a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
|
|
5392
5730
|
if (r.cwd)
|
|
5393
5731
|
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, (
|
|
5732
|
+
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
|
+
startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
|
|
5396
5734
|
}
|
|
5397
5735
|
// freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
|
|
5398
5736
|
// is still warming up so the verifier in injectViaTmux needs the slack.
|
|
@@ -5507,6 +5845,27 @@ export const startMirror = (deps) => {
|
|
|
5507
5845
|
return 0;
|
|
5508
5846
|
}
|
|
5509
5847
|
},
|
|
5848
|
+
reapUnborn: async (names) => {
|
|
5849
|
+
const want = new Set(names.flatMap((n) => [n, `.${n}`]));
|
|
5850
|
+
if (!want.size)
|
|
5851
|
+
return [];
|
|
5852
|
+
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{session_name}\t#{window_name}"]);
|
|
5853
|
+
if (snap.code !== 0)
|
|
5854
|
+
return [];
|
|
5855
|
+
const bound = new Set(allTargets().map(paneOf).filter(Boolean));
|
|
5856
|
+
const doomed = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"))
|
|
5857
|
+
.filter(([pane = "", session, win = ""]) => session === cfg.wrc.tmuxPrefix && want.has(win) && !bound.has(pane))
|
|
5858
|
+
.map(([pane = ""]) => pane);
|
|
5859
|
+
const killed = [];
|
|
5860
|
+
for (const pane of doomed) {
|
|
5861
|
+
// 与 killPane 同款前戏: Esc 给可能已起来的 CLI 一个收束落盘的机会。
|
|
5862
|
+
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
5863
|
+
await sleep(250);
|
|
5864
|
+
if ((await runTmux(["kill-pane", "-t", pane])).code === 0)
|
|
5865
|
+
killed.push(pane);
|
|
5866
|
+
}
|
|
5867
|
+
return killed;
|
|
5868
|
+
},
|
|
5510
5869
|
reapOverflow: async (max, skip) => {
|
|
5511
5870
|
const now = Date.now();
|
|
5512
5871
|
const reaped = [];
|
|
@@ -5613,6 +5972,13 @@ export const startMirror = (deps) => {
|
|
|
5613
5972
|
log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /n — Enter sent to pane");
|
|
5614
5973
|
return { ok: true };
|
|
5615
5974
|
},
|
|
5975
|
+
retitlePane: async (target) => {
|
|
5976
|
+
const pane = paneOf(target);
|
|
5977
|
+
if (!pane)
|
|
5978
|
+
return { ok: false, reason: "no tmux pane bound for target" };
|
|
5979
|
+
const r = await renameWindow(pane, displayName(target) || target);
|
|
5980
|
+
return r.code === 0 ? { ok: true } : { ok: false, reason: `rename-window failed: ${r.stderr.trim() || r.code}` };
|
|
5981
|
+
},
|
|
5616
5982
|
revealPane: async (target) => {
|
|
5617
5983
|
// paneOf (not byTarget.get) so a cold binding surviving only in the
|
|
5618
5984
|
// persisted store can still be revealed after a daemon reload.
|
|
@@ -5686,6 +6052,7 @@ export const startMirror = (deps) => {
|
|
|
5686
6052
|
sessionInfo,
|
|
5687
6053
|
setCharterProvider,
|
|
5688
6054
|
onSpawn,
|
|
6055
|
+
onContextCut,
|
|
5689
6056
|
shutdown: () => {
|
|
5690
6057
|
clearInterval(paneDriftTimer);
|
|
5691
6058
|
clearInterval(keepaliveTimer);
|
|
@@ -5742,7 +6109,9 @@ export const startMirror = (deps) => {
|
|
|
5742
6109
|
}
|
|
5743
6110
|
persistPause(a); // persist the pause/resume too, else a reload would revert it
|
|
5744
6111
|
// 频道印章要早于任何开 turn 的路径 (startBriefTurn 同步消费)。
|
|
5745
|
-
|
|
6112
|
+
// brief 模式下频道随这一句的印章走 (startBriefTurn); 其余路径仍是开 turn 时消费的格子。
|
|
6113
|
+
if (!cfg.wrc.mirror.brief || isClearCommand(text))
|
|
6114
|
+
a.pendingChannel = { channel: channel ?? baseOfKey(principal), speaker, at: Date.now() };
|
|
5746
6115
|
// Finalize prior live stream (if any) so this new turn renders into its
|
|
5747
6116
|
// own message bubble. Then open a fresh stream tied to the new frame and
|
|
5748
6117
|
// ack immediately so WeCom doesn't time out while inject queues.
|
|
@@ -5836,7 +6205,7 @@ export const startMirror = (deps) => {
|
|
|
5836
6205
|
// 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
|
|
5837
6206
|
// 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
|
|
5838
6207
|
// 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
|
|
5839
|
-
await startBriefTurn(a, frame, streamId, isSlash, text);
|
|
6208
|
+
await startBriefTurn(a, frame, streamId, isSlash, text, channel ?? baseOfKey(principal), speaker);
|
|
5840
6209
|
}
|
|
5841
6210
|
else if (!armMigration && !eagerOpen) {
|
|
5842
6211
|
enterDeferred(a, frame, streamId);
|
|
@@ -5871,7 +6240,7 @@ export const startMirror = (deps) => {
|
|
|
5871
6240
|
const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
5872
6241
|
// Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
|
|
5873
6242
|
// 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 }) });
|
|
6243
|
+
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
6244
|
if (r.ok && r.tmuxPane && r.tmuxSession) {
|
|
5876
6245
|
a.tmuxPane = r.tmuxPane;
|
|
5877
6246
|
a.tmuxSession = r.tmuxSession;
|
|
@@ -5885,6 +6254,7 @@ export const startMirror = (deps) => {
|
|
|
5885
6254
|
tmuxPane: a.tmuxPane,
|
|
5886
6255
|
cwd: a.runningCwd || undefined,
|
|
5887
6256
|
model: a.model || undefined,
|
|
6257
|
+
effort: a.effort || undefined,
|
|
5888
6258
|
pendingCwd: a.pendingCwd || undefined,
|
|
5889
6259
|
});
|
|
5890
6260
|
log.info({ target: a.target, sessionId: sid, newPane: a.tmuxPane, newSession: a.tmuxSession }, "mirror: tmux respawned");
|
|
@@ -5894,7 +6264,7 @@ export const startMirror = (deps) => {
|
|
|
5894
6264
|
// file appears, the watcher times out harmlessly, and the existing
|
|
5895
6265
|
// tail keeps working — safe under either behavior.
|
|
5896
6266
|
if (resumeBaseline)
|
|
5897
|
-
startMigrationWatcher(a, resumeBaseline, (
|
|
6267
|
+
startMigrationWatcher(a, resumeBaseline, forkOf(a.jsonlPath));
|
|
5898
6268
|
}
|
|
5899
6269
|
else {
|
|
5900
6270
|
// Drop only the stale pane id; keep tmuxSession (if any) so the
|