wezard 2.6.3 → 2.7.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-plugin/plugin.json +1 -1
- package/CLAUDE.md +6 -6
- package/README.md +1 -15
- package/dist/cli/codex-hook.js +5 -1
- package/dist/cli/codex-hook.js.map +1 -1
- package/dist/cli/sync.js +5 -4
- package/dist/cli/sync.js.map +1 -1
- package/dist/daemon/approval.js +12 -5
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/bot-mux.js +4 -0
- package/dist/daemon/bot-mux.js.map +1 -1
- package/dist/daemon/chat-log.js +5 -5
- package/dist/daemon/chat-log.js.map +1 -1
- package/dist/daemon/chat-name.js +5 -2
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/detail.js +28 -5
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/direction.js +3 -2
- package/dist/daemon/direction.js.map +1 -1
- package/dist/daemon/dispatch.js +6 -0
- package/dist/daemon/dispatch.js.map +1 -0
- package/dist/daemon/handoff.js +66 -15
- package/dist/daemon/handoff.js.map +1 -1
- package/dist/daemon/image-input.js +25 -5
- package/dist/daemon/image-input.js.map +1 -1
- package/dist/daemon/inbound.js +5 -6
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +1232 -671
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +13 -3
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/last-response.js +29 -4
- package/dist/daemon/last-response.js.map +1 -1
- package/dist/daemon/message-events.js +534 -0
- package/dist/daemon/message-events.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +702 -790
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/notices.js +9 -2
- package/dist/daemon/notices.js.map +1 -1
- package/dist/daemon/peers.js +124 -145
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/pending-items.js +33 -34
- package/dist/daemon/pending-items.js.map +1 -1
- package/dist/daemon/route.js +5 -2
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/router.js +643 -0
- package/dist/daemon/router.js.map +1 -0
- package/dist/daemon/session-name.js +1 -1
- package/dist/daemon/session-name.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +32 -13
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/subagent-tail.js +3 -3
- package/dist/daemon/subagent-tail.js.map +1 -1
- package/dist/daemon/weixin.js +111 -7
- package/dist/daemon/weixin.js.map +1 -1
- package/dist/daemon/{cc-session.js → wezard-session.js} +32 -2
- package/dist/daemon/wezard-session.js.map +1 -0
- package/dist/daemon/wizard.js +5 -5
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/daemon/workflow-nodes.js +3 -3
- package/dist/daemon/workflow-nodes.js.map +1 -1
- package/dist/daemon/workflow-plans.js +9 -6
- package/dist/daemon/workflow-plans.js.map +1 -1
- package/dist/mcp/server.js +38 -47
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +2 -2
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-view.js +0 -4
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +11 -1
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/codex.js +1 -1
- package/dist/shared/codex.js.map +1 -1
- package/dist/shared/config.js +3 -44
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +36 -11
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/message-footer.js +8 -8
- package/dist/shared/message-footer.js.map +1 -1
- package/dist/shared/reminder.js +109 -37
- package/dist/shared/reminder.js.map +1 -1
- package/dist/shared/role-render.js +28 -20
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-search.js +3 -3
- package/dist/shared/role-search.js.map +1 -1
- package/dist/shared/role-view.js +50 -30
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/session-label.js +25 -6
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/turn-state.js +7 -7
- package/dist/shared/turn-state.js.map +1 -1
- package/dist/shared/world.js +1 -3
- package/dist/shared/world.js.map +1 -1
- package/hooks/pre-tool-use.sh +5 -25
- package/package.json +1 -1
- package/web/chat.css +3 -33
- package/web/chat.js +11 -62
- package/dist/daemon/answer-transfer.js +0 -56
- package/dist/daemon/answer-transfer.js.map +0 -1
- package/dist/daemon/cc-session.js.map +0 -1
- package/dist/daemon/receipts.js +0 -906
- package/dist/daemon/receipts.js.map +0 -1
- package/dist/shared/keepalive.js +0 -46
- package/dist/shared/keepalive.js.map +0 -1
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { finalId } from "./message-events.js";
|
|
2
|
+
import { parseEnvelope, renderTail } from "../shared/reminder.js";
|
|
1
3
|
import { codexSessions, codexSessionForPane } from "../shared/codex.js";
|
|
2
4
|
import { codexHookLaunchArgs, codexHookCommand } from "../shared/codex-hooks.js";
|
|
3
5
|
import { fileURLToPath } from "node:url";
|
|
@@ -19,7 +21,7 @@ import { spawn } from "node:child_process";
|
|
|
19
21
|
import { randomUUID } from "node:crypto";
|
|
20
22
|
import { pasteTmuxBuffer } from "./tmux-buffer.js";
|
|
21
23
|
import { existsSync, readdirSync, readFileSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
|
|
22
|
-
import { join, dirname } from "node:path";
|
|
24
|
+
import { basename, join, dirname } from "node:path";
|
|
23
25
|
import { newStewardSpec, resumedStewardSpec } from "./steward-model.js";
|
|
24
26
|
import { expandHome, sanitizeId } from "../shared/paths.js";
|
|
25
27
|
import { augmentedPath } from "../shared/exec-path.js";
|
|
@@ -36,18 +38,17 @@ import { dispatchPickerPolicy, pickerPolicy } from "./picker-policy.js";
|
|
|
36
38
|
import { renameWindow, runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
37
39
|
import { selectEffort, selectModel } from "./model-select.js";
|
|
38
40
|
import { parseEffort } from "../shared/effort.js";
|
|
39
|
-
import { hasRegistry, liveSessionPids, markTranscript, probeOf, sessionOnPane, submittedSince, watchRegistry } from "./
|
|
41
|
+
import { hasRegistry, liveSessionPids, markTranscript, probeOf, sessionOfPid, sessionOnPane, submittedSince, submittedEventSince, watchRegistry } from "./wezard-session.js";
|
|
40
42
|
import { waitForIdle } from "./graph.js";
|
|
41
43
|
import { wizardStore } from "./wizard.js";
|
|
42
44
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
43
|
-
import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
|
|
45
|
+
import { getDetail, knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq, markTurnTextFinal } from "./detail.js";
|
|
44
46
|
import { errText } from "./last-response.js";
|
|
45
|
-
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge,
|
|
47
|
+
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, windowLabel, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, parseTagHeader, MAX_BODY_LINKS, isInternalKey, sameBot } from "../shared/session-label.js";
|
|
46
48
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
47
49
|
import { isWxFrame } from "../shared/wx-text.js";
|
|
48
50
|
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
49
|
-
import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel,
|
|
50
|
-
import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
|
|
51
|
+
import { stripAnsi, paneIsBusy, codexTurnBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, lastUserTurn, talkRounds, openToolUses, turnClosed, renderDialog, stripForChat } from "./peers.js";
|
|
51
52
|
// PATH augmentation: launchd / systemd start the daemon
|
|
52
53
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
53
54
|
// Backend resolution is per-transcript, not global: `backendForPath` recovers
|
|
@@ -526,7 +527,7 @@ const renderLine = (raw, deps) => {
|
|
|
526
527
|
if (line.type === "attachment" && line.attachment?.type === "queued_command" && typeof line.attachment.prompt === "string") {
|
|
527
528
|
const said = cleanUserText(line.attachment.prompt);
|
|
528
529
|
const ts = Date.parse(line.timestamp ?? "");
|
|
529
|
-
return said ? [{ kind: "user_inject", said, ts: Number.isNaN(ts) ? Date.now() : ts }] : [];
|
|
530
|
+
return said ? [{ kind: "user_inject", said, eventId: parseEnvelope(line.attachment.prompt)?.eventId, ts: Number.isNaN(ts) ? Date.now() : ts }] : [];
|
|
530
531
|
}
|
|
531
532
|
const out = [];
|
|
532
533
|
if (line.type === "user") {
|
|
@@ -569,15 +570,11 @@ const renderLine = (raw, deps) => {
|
|
|
569
570
|
const text = cleanUserText(c);
|
|
570
571
|
if (!text)
|
|
571
572
|
return []; // pure slash-command meta / stdout — drop
|
|
572
|
-
// Keepalive ping user line: judged by CONTENT, before every other gate
|
|
573
|
-
// (includeUser / isOwnInject's 60s TTL) — the ping never echoes, and
|
|
574
|
-
// the reply turn is swallowed by content in onItem. Timer windows can't
|
|
575
|
-
// cover reloads or replays; the user message itself always can.
|
|
576
|
-
if (deps.isKeepalivePing?.(text))
|
|
577
|
-
return [{ kind: "keepalive_start", body: text }];
|
|
578
573
|
// 自己注入的那一行不回显, 但要报个到: 它的印章 (谁说的、回复去哪) 在这一刻认领。
|
|
579
|
-
|
|
580
|
-
|
|
574
|
+
// 注入事件 id: 信封的 `msg` (老形状是信封外的 message-event 块) —— 认得出就是守护进程投的。
|
|
575
|
+
const eventId = parseEnvelope(c)?.eventId;
|
|
576
|
+
if (eventId || deps.isOwnInject(text))
|
|
577
|
+
return [{ kind: "user_query", eventId, body: unwrapPasted(c).trim(), said: text, ts: lineTs(line) }];
|
|
581
578
|
const quoted = text.split("\n").map((l) => `> ${l}`).join("\n");
|
|
582
579
|
// includeUser 只决定"渲不渲染", 不决定"算不算边界" —— quiet 的这一条照样发出,
|
|
583
580
|
// onItem 消费完边界语义后自己丢掉。
|
|
@@ -588,11 +585,11 @@ const renderLine = (raw, deps) => {
|
|
|
588
585
|
// 回显去重对得上; 记进详情页的则是带占位符的原样文本, 图本身不画。
|
|
589
586
|
const raw = c.filter((b) => b?.type === "text").map((b) => b.text ?? "").join("\n");
|
|
590
587
|
const text = cleanUserText(raw.replace(IMAGE_REF_RE, ""));
|
|
591
|
-
if (text && !deps.isOwnInject(text)) {
|
|
588
|
+
if (text && !parseEnvelope(raw)?.eventId && !deps.isOwnInject(text)) {
|
|
592
589
|
out.push({ kind: "user_text", body: cleanUserText(raw).split("\n").map((l) => `> ${l}`).join("\n"), quiet: !deps.includeUser, ts: lineTs(line) });
|
|
593
590
|
}
|
|
594
591
|
else {
|
|
595
|
-
out.push({ kind: "user_query", body: unwrapPasted(raw).trim(), said: text, ts: lineTs(line) });
|
|
592
|
+
out.push({ kind: "user_query", eventId: parseEnvelope(raw)?.eventId, body: unwrapPasted(raw).trim(), said: text, ts: lineTs(line) });
|
|
596
593
|
}
|
|
597
594
|
}
|
|
598
595
|
else if (Array.isArray(c)) {
|
|
@@ -1336,6 +1333,22 @@ export const parseInputBox = (cap, styledLayout = cap) => {
|
|
|
1336
1333
|
const body = [rows[0].replace(BOX_PROMPT_RE, ""), ...rows.slice(1)].join("\n").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
|
|
1337
1334
|
return { known: true, body };
|
|
1338
1335
|
};
|
|
1336
|
+
// CodeBuddy 忙时回车不落盘: 那句挂进输入框上方的待发队列, 一条一行
|
|
1337
|
+
// Messages to be submitted after next tool call (…)
|
|
1338
|
+
// ↳ <整段压成一行, 过长以 … 截断>
|
|
1339
|
+
// 直到出队才写进 transcript —— 这期间「框清空了、transcript 里没有」是正常的排队态, 不是丢了。
|
|
1340
|
+
const QUEUED_ROW_RE = /^\s*↳\s?(.+)$/u;
|
|
1341
|
+
const QUEUED_HEAD_CHARS = 24;
|
|
1342
|
+
/** 队列里有几行是这段文本 (按去空白后的开头比对; 截断的行只比它露出的那截)。 */
|
|
1343
|
+
export const queuedCopies = (cap, text) => {
|
|
1344
|
+
const head = text.replace(/\s+/gu, "").slice(0, QUEUED_HEAD_CHARS);
|
|
1345
|
+
if (!head)
|
|
1346
|
+
return 0;
|
|
1347
|
+
return cap.split("\n")
|
|
1348
|
+
.map((l) => QUEUED_ROW_RE.exec(l)?.[1]?.replace(/\s+/gu, "").replace(/…$/u, "") ?? "")
|
|
1349
|
+
.filter((row) => row.length >= Math.min(8, head.length) && (row.startsWith(head) || head.startsWith(row)))
|
|
1350
|
+
.length;
|
|
1351
|
+
};
|
|
1339
1352
|
// 空框里的灰字不是输入: 幽灵提示 (`Try "…"`) 和 CC 预测的下一句 (prompt suggestion,
|
|
1340
1353
|
// 回车不采纳) 都以 SGR 2 (dim) 渲染。纯文本 capture 看不出区别 —— 预测句
|
|
1341
1354
|
// `commit 这次改动…` 曾让一条 `commit` 被判成「已在框里」, 于是没贴, 只按了三下
|
|
@@ -1372,7 +1385,7 @@ const readPaneInput = async (target) => {
|
|
|
1372
1385
|
return { cap, box: parseInputBox(cap, raw) };
|
|
1373
1386
|
};
|
|
1374
1387
|
const readInputBox = async (target) => (await readPaneInput(target)).box;
|
|
1375
|
-
// ── 注入门控的会话证人 (
|
|
1388
|
+
// ── 注入门控的会话证人 (wezard-session) ──────────────────────────────
|
|
1376
1389
|
// 能从会话自己写的 json 读到的判据, 就不读 pane: 就绪看注册表的 status, 提交看
|
|
1377
1390
|
// 注册表的 busy 翻转或 jsonl 里新落的那一行。输入框里「贴进去没有」是唯一只有
|
|
1378
1391
|
// pane 知道的事 (未提交的输入不落任何盘), 那一段仍旧 capture。
|
|
@@ -1382,9 +1395,89 @@ const paneRunsShell = async (target) => {
|
|
|
1382
1395
|
const r = await runTmux(["display-message", "-p", "-t", target, "#{pane_current_command}"]);
|
|
1383
1396
|
return r.ok && SHELL_RE.test(r.stdout.trim());
|
|
1384
1397
|
};
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1398
|
+
const paneLiveness = async (paneId) => {
|
|
1399
|
+
if (!paneId)
|
|
1400
|
+
return "gone";
|
|
1401
|
+
const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
|
|
1402
|
+
if (r.timedOut || r.code === null)
|
|
1403
|
+
return "unknown";
|
|
1404
|
+
if (r.code === 0)
|
|
1405
|
+
return r.stdout.trim() === paneId ? "alive" : "unknown";
|
|
1406
|
+
return "gone"; // "can't find pane: %N" / "no server running" —— 两条都等于它没了
|
|
1407
|
+
};
|
|
1408
|
+
/** 沿 ps 快照从某个 pid 往下找「会话注册表登记过、进程还活着」的那个 pid。注册表没有
|
|
1409
|
+
* `tmux` 字段的后端 (codebuddy) 只能这样把 pane 和进程对上: pane_pid 是 shell, CLI 是
|
|
1410
|
+
* 它的子孙。认不出返回 undefined。 */
|
|
1411
|
+
const registeredPidUnder = (root, livePids, kids) => {
|
|
1412
|
+
const stack = [root];
|
|
1413
|
+
const seen = new Set();
|
|
1414
|
+
while (stack.length) {
|
|
1415
|
+
const p = stack.pop() ?? 0;
|
|
1416
|
+
if (!p || seen.has(p))
|
|
1417
|
+
continue;
|
|
1418
|
+
if (livePids.has(p))
|
|
1419
|
+
return p;
|
|
1420
|
+
seen.add(p);
|
|
1421
|
+
for (const k of kids.get(p) ?? [])
|
|
1422
|
+
stack.push(k);
|
|
1423
|
+
}
|
|
1424
|
+
return undefined;
|
|
1425
|
+
};
|
|
1426
|
+
const panePidOf = async (paneId) => {
|
|
1427
|
+
const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_pid}"]);
|
|
1428
|
+
return r.ok ? Number(r.stdout.trim()) || 0 : 0;
|
|
1429
|
+
};
|
|
1430
|
+
/** pane 里有没有活着的 CLI 会话进程。注册表带 `tmux` 字段的后端按 pane 直接认; codebuddy
|
|
1431
|
+
* 不写那个字段, 只能沿 pane 的进程树找到注册表登记过的 pid。
|
|
1432
|
+
* **它只证明「里面有活的 CLI」, 不证明「里面是哪份转录」** —— codebuddy 的注册行里
|
|
1433
|
+
* sessionId 是续接链的根, 会话换过 sid 之后不更新 (2026-10-14 实测: pid 84679 报
|
|
1434
|
+
* 3d51b76b, 而那个进程实际在写 01a13ae5)。要认会话得另找权威来源, 别拿这个 sid 用。 */
|
|
1435
|
+
const paneHasLiveSession = async (paneId, homeDir, panePid, ps) => {
|
|
1436
|
+
if (sessionOnPane(homeDir, paneId))
|
|
1437
|
+
return true;
|
|
1438
|
+
const root = panePid ?? (await panePidOf(paneId));
|
|
1439
|
+
if (!root)
|
|
1440
|
+
return false;
|
|
1441
|
+
const { kids } = ps ?? (await psSnap());
|
|
1442
|
+
return registeredPidUnder(root, liveSessionPids(homeDir), kids) !== undefined;
|
|
1443
|
+
};
|
|
1444
|
+
/** 注入前问 pane 一句话: 还在吗 / 里面还有 AI 会话吗。
|
|
1445
|
+
* `unknown` 必须由调用方按「不知道」处理 —— 不许当 pane 死了去重生。 */
|
|
1446
|
+
const probePane = async (paneId, jsonlPath) => {
|
|
1447
|
+
const live = await paneLiveness(paneId);
|
|
1448
|
+
if (live !== "alive")
|
|
1449
|
+
return { state: live };
|
|
1450
|
+
if (await paneHasLiveSession(paneId, backendForPath(jsonlPath).homeDir))
|
|
1451
|
+
return { state: "session" };
|
|
1452
|
+
// 认不出会话进程 (注册表缺席 / 进程不在 tmux 里记的行) 不能被当成会话不在: 只有前台确实
|
|
1453
|
+
// 退回 shell 才判否。而这一次探问自己也可能是超时 —— 那仍旧只能算 unknown。
|
|
1454
|
+
const cmd = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_current_command}"]);
|
|
1455
|
+
if (cmd.timedOut || !cmd.ok)
|
|
1456
|
+
return { state: "unknown" };
|
|
1457
|
+
return SHELL_RE.test(cmd.stdout.trim()) ? { state: "shell" } : { state: "session" };
|
|
1458
|
+
};
|
|
1459
|
+
/** 注册表没有 tmux 字段的后端 (codebuddy): pane 里住着谁只能沿 pane 的进程树往下找
|
|
1460
|
+
* 注册表登记过、还活着的 pid。冷 pane 的注册行晚于进程出生, `waitMs` 内轮询。 */
|
|
1461
|
+
const hostPidOnPane = async (target, homeDir, waitMs) => {
|
|
1462
|
+
const deadline = Date.now() + waitMs;
|
|
1463
|
+
const probe = async () => {
|
|
1464
|
+
const r = await runTmux(["display-message", "-p", "-t", target, "#{pane_pid}"]);
|
|
1465
|
+
const root = r.ok ? Number(r.stdout.trim()) : 0;
|
|
1466
|
+
if (!root)
|
|
1467
|
+
return undefined;
|
|
1468
|
+
const reg = liveSessionPids(homeDir);
|
|
1469
|
+
const { kids } = await psSnap();
|
|
1470
|
+
return registeredPidUnder(root, reg, kids);
|
|
1471
|
+
};
|
|
1472
|
+
const poll = async () => {
|
|
1473
|
+
const pid = await probe();
|
|
1474
|
+
if (pid || Date.now() >= deadline)
|
|
1475
|
+
return pid;
|
|
1476
|
+
await sleep(300);
|
|
1477
|
+
return poll();
|
|
1478
|
+
};
|
|
1479
|
+
return poll();
|
|
1480
|
+
};
|
|
1388
1481
|
const SESSION_READY_TIMEOUT_MS = 15_000;
|
|
1389
1482
|
/** 冷 pane 等注册表报 idle (会话建好、输入框等人); 热 pane 只核一次会话还在 —— 忙也照贴,
|
|
1390
1483
|
* CC 会把它排进队列。等不到 idle 不算失败 (贴后校验会兜), 前台退回 shell 才算:
|
|
@@ -1412,32 +1505,32 @@ const awaitSession = async (target, homeDir, fresh, log) => {
|
|
|
1412
1505
|
}
|
|
1413
1506
|
return { ok: true };
|
|
1414
1507
|
};
|
|
1415
|
-
const NO_WITNESS = { status: "", submitted: () => false };
|
|
1508
|
+
const NO_WITNESS = { status: "", submitted: () => false, submittedEvent: () => false };
|
|
1416
1509
|
/** 贴之前给会话可能落盘的 jsonl 打书签: 绑定的那份, 加上注册表报的当前 sid 那份
|
|
1417
1510
|
* (resume / fork 会换 sid)。提交后 sid 若又变了, 新文件从 0 读 —— 时间门挡掉 fork 里的旧话。 */
|
|
1418
|
-
const witnessFor = (target, homeDir, jsonlPath, live) => {
|
|
1419
|
-
if (!live)
|
|
1420
|
-
return NO_WITNESS;
|
|
1511
|
+
const witnessFor = (target, homeDir, jsonlPath, live, actualTranscript) => {
|
|
1421
1512
|
const dir = dirname(jsonlPath);
|
|
1422
|
-
const marks = new Map([jsonlPath, join(dir, `${live.sessionId}.jsonl`)].
|
|
1513
|
+
const marks = new Map([...new Set([jsonlPath, actualTranscript, ...(live ? [join(dir, `${live.sessionId}.jsonl`)] : [])].filter((p) => !!p))]
|
|
1514
|
+
.map((p) => [p, markTranscript(p)]));
|
|
1423
1515
|
return {
|
|
1424
|
-
status: live
|
|
1516
|
+
status: live?.status ?? "",
|
|
1425
1517
|
submitted: (probe, since, enterAt) => {
|
|
1426
1518
|
const now = sessionOnPane(homeDir, target);
|
|
1427
|
-
if (now && live.status !== "busy" && now.status === "busy" && now.statusUpdatedAt >= enterAt)
|
|
1519
|
+
if (live && now && live.status !== "busy" && now.status === "busy" && now.statusUpdatedAt >= enterAt)
|
|
1428
1520
|
return true;
|
|
1429
1521
|
const p = now && join(dir, `${now.sessionId}.jsonl`);
|
|
1430
1522
|
if (p && !marks.has(p))
|
|
1431
1523
|
marks.set(p, { path: p, offset: 0 });
|
|
1432
1524
|
return submittedSince([...marks.values()], probe, since);
|
|
1433
1525
|
},
|
|
1526
|
+
submittedEvent: (eventId, since) => submittedEventSince([...marks.values()], eventId, since),
|
|
1434
1527
|
};
|
|
1435
1528
|
};
|
|
1436
1529
|
// Self-verifying inject. The cold-spawn race we guard against: paste lands
|
|
1437
1530
|
// but the trailing Enter is eaten while the TUI is still initializing, so
|
|
1438
1531
|
// the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
|
|
1439
1532
|
// 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
|
|
1440
|
-
const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard) => {
|
|
1533
|
+
const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false, guard, eventId, expectedSessionId) => {
|
|
1441
1534
|
const backendName = backend.name;
|
|
1442
1535
|
log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
|
|
1443
1536
|
// 就绪门: pane 里得真有一个进入了交互态的 AI 会话, 才轮得到贴。先于 modal guard ——
|
|
@@ -1445,7 +1538,74 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
|
|
|
1445
1538
|
const gate = await awaitSession(target, backend.homeDir, freshSpawn, log);
|
|
1446
1539
|
if (!gate.ok)
|
|
1447
1540
|
return gate;
|
|
1448
|
-
const
|
|
1541
|
+
const tmuxRegistry = hasRegistry(backend.homeDir);
|
|
1542
|
+
const eventNeedsIdentity = !!eventId && !text.trimStart().startsWith("/");
|
|
1543
|
+
// codebuddy 的注册行有 pid/sessionId 却没有 tmux, 而且 sessionId 在会话内换 sid
|
|
1544
|
+
// (handoff / 续接) 后不更新 —— 只能证明「pane 里住着一个活的 CLI 进程」, 收件的
|
|
1545
|
+
// 会话以 mirror 自己绑定的那份为准 (expectedSessionId / jsonlPath)。
|
|
1546
|
+
const hostPid = !tmuxRegistry && backendName !== "codex" && eventNeedsIdentity
|
|
1547
|
+
? await hostPidOnPane(target, backend.homeDir, freshSpawn ? SESSION_READY_TIMEOUT_MS : 0)
|
|
1548
|
+
: undefined;
|
|
1549
|
+
const paneIdentity = () => {
|
|
1550
|
+
// A readable live-session registry is the physical pane authority. Codex's
|
|
1551
|
+
// SessionStart map is the fallback on installations without that registry.
|
|
1552
|
+
if (tmuxRegistry) {
|
|
1553
|
+
const session = sessionOnPane(backend.homeDir, target);
|
|
1554
|
+
return session ? { sessionId: session.sessionId, transcript: join(dirname(jsonlPath), `${session.sessionId}.jsonl`) } : undefined;
|
|
1555
|
+
}
|
|
1556
|
+
if (backendName === "codex") {
|
|
1557
|
+
const session = codexSessionForPane(target);
|
|
1558
|
+
return session ? { sessionId: session.sessionId, transcript: session.jsonlPath } : undefined;
|
|
1559
|
+
}
|
|
1560
|
+
if (hostPid && sessionOfPid(backend.homeDir, hostPid)) {
|
|
1561
|
+
const sessionId = expectedSessionId || basename(jsonlPath, ".jsonl");
|
|
1562
|
+
return { sessionId, transcript: jsonlPath };
|
|
1563
|
+
}
|
|
1564
|
+
return undefined;
|
|
1565
|
+
};
|
|
1566
|
+
const observedIdentity = paneIdentity();
|
|
1567
|
+
if (eventNeedsIdentity && (!observedIdentity?.sessionId || !observedIdentity.transcript)) {
|
|
1568
|
+
const reason = backendName === "codebuddy"
|
|
1569
|
+
? "CodeBuddy pane hosts no live registered CLI process; refusing event injection before input exposure"
|
|
1570
|
+
: backendName === "codex"
|
|
1571
|
+
? "Codex SessionStart pane identity is unavailable; refusing event injection before input exposure"
|
|
1572
|
+
: "Claude pane session identity is unavailable; refusing event injection before input exposure because the receiving session/transcript cannot be verified";
|
|
1573
|
+
return { ok: false, retryable: true, reason };
|
|
1574
|
+
}
|
|
1575
|
+
const initialIdentity = observedIdentity ?? (gate.live
|
|
1576
|
+
? { sessionId: gate.live.sessionId, transcript: join(dirname(jsonlPath), `${gate.live.sessionId}.jsonl`) }
|
|
1577
|
+
: { transcript: jsonlPath });
|
|
1578
|
+
const witness = witnessFor(target, backend.homeDir, jsonlPath, gate.live, initialIdentity.transcript);
|
|
1579
|
+
let guardFailureReason = "dispatch guard rejected input";
|
|
1580
|
+
let inputGuardRejected = false;
|
|
1581
|
+
const boundGuard = (phase) => {
|
|
1582
|
+
// Resolve again at each physical paste/submit boundary. The mapping can
|
|
1583
|
+
// change after awaitSession or while an image is being staged.
|
|
1584
|
+
const current = paneIdentity();
|
|
1585
|
+
if (eventNeedsIdentity && (!current?.sessionId || !current.transcript)) {
|
|
1586
|
+
guardFailureReason = "receiving pane session identity became unavailable before input";
|
|
1587
|
+
inputGuardRejected = true;
|
|
1588
|
+
return false;
|
|
1589
|
+
}
|
|
1590
|
+
if (!current)
|
|
1591
|
+
return !eventNeedsIdentity && (!guard || guard(phase, { transcript: jsonlPath }));
|
|
1592
|
+
if (current.sessionId !== initialIdentity.sessionId || current.transcript !== initialIdentity.transcript) {
|
|
1593
|
+
guardFailureReason = "receiving pane session changed before input; refusing to attach event to a different session";
|
|
1594
|
+
inputGuardRejected = true;
|
|
1595
|
+
return false;
|
|
1596
|
+
}
|
|
1597
|
+
if (expectedSessionId && current.sessionId && current.sessionId !== expectedSessionId) {
|
|
1598
|
+
guardFailureReason = "receiving pane session no longer matches the queued event target";
|
|
1599
|
+
inputGuardRejected = true;
|
|
1600
|
+
return false;
|
|
1601
|
+
}
|
|
1602
|
+
const allowed = !guard || guard(phase, current);
|
|
1603
|
+
if (!allowed) {
|
|
1604
|
+
guardFailureReason = "dispatch guard withdrew permission before input";
|
|
1605
|
+
inputGuardRejected = true;
|
|
1606
|
+
}
|
|
1607
|
+
return allowed;
|
|
1608
|
+
};
|
|
1449
1609
|
// Never type into a modal picker (see detectModalPicker). Checked before the
|
|
1450
1610
|
// image pump too — a C-v into a picker is just as destructive as a paste.
|
|
1451
1611
|
// 必须在按后端分流之前: 每个后端都有自己的原生确认框, 往任何一个里打字都会
|
|
@@ -1478,27 +1638,31 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
|
|
|
1478
1638
|
// dispatch 已对 codebuddy 把 refs 合进 text (images 传空), 这里的
|
|
1479
1639
|
// withImageRefs 是其它调用方直传 images 时的兜底 —— 幂等, 不会贴两份。
|
|
1480
1640
|
if (backendName === "codebuddy") {
|
|
1481
|
-
return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness,
|
|
1641
|
+
return injectViaTmuxText(target, withImageRefs(text, images), log, freshSpawn, witness, boundGuard, eventId);
|
|
1482
1642
|
}
|
|
1483
1643
|
// Pump images first via clipboard+C-v so each one is attached as a separate
|
|
1484
1644
|
// image content block. Each C-v needs a brief settle for Claude Code's TUI
|
|
1485
1645
|
// to read the clipboard before the next overwrite. Fresh spawn extends.
|
|
1486
1646
|
const IMG_SETTLE_MS = freshSpawn ? 700 : 350;
|
|
1647
|
+
let imagePasteMayHaveExposed = false;
|
|
1487
1648
|
const pumped = images.length ? await withImageClipboard(async () => {
|
|
1488
1649
|
for (const imgPath of images) {
|
|
1489
1650
|
const paste = async () => {
|
|
1490
1651
|
const cb = await setMacClipboardImage(imgPath);
|
|
1491
1652
|
if (!cb.ok)
|
|
1492
1653
|
return cb;
|
|
1493
|
-
if (
|
|
1494
|
-
return { ok: false, reason:
|
|
1654
|
+
if (!boundGuard("paste"))
|
|
1655
|
+
return { ok: false, reason: guardFailureReason };
|
|
1656
|
+
// Once send-keys starts, a failed client result cannot prove C-v was
|
|
1657
|
+
// not delivered; preserve uncertainty to prevent duplicate attachments.
|
|
1658
|
+
imagePasteMayHaveExposed = true;
|
|
1495
1659
|
const cv = await runTmux(["send-keys", "-t", target, "C-v"]);
|
|
1496
1660
|
return cv.ok ? { ok: true } : { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
|
|
1497
1661
|
};
|
|
1498
1662
|
// Cold Codex can ignore C-v before its composer exists. Do not submit
|
|
1499
1663
|
// the text until the composer proves this attachment has landed.
|
|
1500
1664
|
const result = backendName === "codex"
|
|
1501
|
-
? await pasteVerifiedImage({ read: () => readInputBox(target), paste, valid: () =>
|
|
1665
|
+
? await pasteVerifiedImage({ read: () => readInputBox(target), paste, valid: () => boundGuard("paste") })
|
|
1502
1666
|
: await paste();
|
|
1503
1667
|
if (!result.ok) {
|
|
1504
1668
|
log.warn({ target, imgPath, reason: result.reason }, "mirror inject: image not confirmed, refusing text submission");
|
|
@@ -1512,19 +1676,21 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jso
|
|
|
1512
1676
|
return { ok: true };
|
|
1513
1677
|
}) : { ok: true };
|
|
1514
1678
|
if (!pumped.ok)
|
|
1515
|
-
return
|
|
1679
|
+
return imagePasteMayHaveExposed
|
|
1680
|
+
? { ...pumped, ...(inputGuardRejected ? { reason: guardFailureReason } : {}), uncertain: true, retryable: false }
|
|
1681
|
+
: { ...pumped, ...(inputGuardRejected ? { reason: guardFailureReason } : {}), uncertain: false, retryable: true };
|
|
1516
1682
|
// Image-only message: TUI input box now holds the attached images; press
|
|
1517
1683
|
// Enter and we're done. No fingerprint to verify (the input itself is binary
|
|
1518
1684
|
// image refs, not text).
|
|
1519
1685
|
if (!text) {
|
|
1520
1686
|
if (images.length === 0)
|
|
1521
1687
|
return { ok: true };
|
|
1522
|
-
if (
|
|
1688
|
+
if (!boundGuard("submit"))
|
|
1523
1689
|
return { ok: false, reason: "submit canceled by dispatch guard" };
|
|
1524
1690
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1525
1691
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
|
|
1526
1692
|
}
|
|
1527
|
-
return injectViaTmuxText(target, text, log, freshSpawn, witness,
|
|
1693
|
+
return injectViaTmuxText(target, text, log, freshSpawn, witness, boundGuard, eventId);
|
|
1528
1694
|
};
|
|
1529
1695
|
// 图片以 @<path> 文本提及的形式进会话 (codebuddy 的 tmux 路径、一切 spawn 路径)。
|
|
1530
1696
|
// 提升为共享 helper: dispatch 侧的印章 / 回显去重 / turn 记录必须拿到与 inject
|
|
@@ -1546,7 +1712,7 @@ const withImageRefs = (text, images) => {
|
|
|
1546
1712
|
// 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
|
|
1547
1713
|
// 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
|
|
1548
1714
|
// 每次补之前再问一遍证人, 已提交就不补。
|
|
1549
|
-
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard) => {
|
|
1715
|
+
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS, guard, eventId) => {
|
|
1550
1716
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1551
1717
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
1552
1718
|
// end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
|
|
@@ -1563,6 +1729,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1563
1729
|
// (会话正忙 → 只是排队; slash 命令 → 不一定落 user 行) 不空等, 直接看框。
|
|
1564
1730
|
const witnessQuiet = witness.status === "" || witness.status === "busy" || text.trimStart().startsWith("/");
|
|
1565
1731
|
const WITNESS_ONLY_MS = witnessQuiet ? 0 : freshSpawn ? 2500 : 1000;
|
|
1732
|
+
const requireEventEvidence = !!eventId && !text.trimStart().startsWith("/");
|
|
1733
|
+
// 粘贴迟到的追加观察窗: 只走在「本来就要判 uncertain」的那条路上, 正常注入不付这笔。
|
|
1734
|
+
const LATE_LAND_MS = 15_000;
|
|
1735
|
+
const LATE_POLL_MS = 250;
|
|
1566
1736
|
const { headFp, tailFp } = fingerprints(text);
|
|
1567
1737
|
const probe = probeOf(text);
|
|
1568
1738
|
const POLL_MS = 100;
|
|
@@ -1570,10 +1740,14 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1570
1740
|
// 时间门往前放 1s: jsonl 的 timestamp 与本机时钟同源, 只差写盘的毫秒。
|
|
1571
1741
|
const since = Date.now() - 1000;
|
|
1572
1742
|
let enterAt = Number.POSITIVE_INFINITY;
|
|
1573
|
-
const submitted = () =>
|
|
1743
|
+
const submitted = () => requireEventEvidence
|
|
1744
|
+
? witness.submittedEvent(eventId, since) : witness.submitted(probe, since, enterAt);
|
|
1574
1745
|
// 贴之前框里是什么: 空框、幽灵提示 (`Try "how do I log an error?"`)、甚至用户
|
|
1575
1746
|
// 自己敲了一半的字 —— 一律作为基线, 免得去维护一张「哪些文案算空」的清单。
|
|
1576
|
-
const
|
|
1747
|
+
const pre = await readPaneInput(target);
|
|
1748
|
+
const preBox = pre.box;
|
|
1749
|
+
// 贴之前队列里已有几份同样开头的 (同一段文本上次排进去了 / 兄弟回执同开头): 只认新增的。
|
|
1750
|
+
const preQueued = queuedCopies(pre.cap, text);
|
|
1577
1751
|
// 基线读不出来 (框被模态顶掉 / 正在重绘) 就整条退回指纹法: 没有基线,「框变了」
|
|
1578
1752
|
// 无从谈起, 而把幽灵提示 (`Try "…"`) 误当成「贴进去了」会让这条消息静静消失。
|
|
1579
1753
|
const boxVerify = preBox.known;
|
|
@@ -1598,6 +1772,21 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1598
1772
|
}
|
|
1599
1773
|
return false;
|
|
1600
1774
|
};
|
|
1775
|
+
/** 粘贴过了 PASTE_VERIFY_MS 还没露面, 再盯一阵, 直到四者之一: 会话已收下 / 框变了 (迟到落地) /
|
|
1776
|
+
* 到点时框认得出且原样 (确实没进框) / 到点时框仍认不出 (无从判断)。 */
|
|
1777
|
+
const lateLanding = async (timeoutMs, t0 = Date.now()) => {
|
|
1778
|
+
if (submitted())
|
|
1779
|
+
return "submitted";
|
|
1780
|
+
const { cap, box } = await readPaneInput(target);
|
|
1781
|
+
if (box.known ? (boxVerify ? !boxIntact(box) : box.body !== "") : Boolean(headFp) && stripWs(cap).includes(headFp))
|
|
1782
|
+
return "landed";
|
|
1783
|
+
if (Date.now() - t0 >= timeoutMs)
|
|
1784
|
+
return box.known ? "intact" : "unknown";
|
|
1785
|
+
await sleep(LATE_POLL_MS);
|
|
1786
|
+
return lateLanding(timeoutMs, t0);
|
|
1787
|
+
};
|
|
1788
|
+
/** 回车后它进了 CodeBuddy 的待发队列 (见 queuedCopies) —— 提交已确凿, 只是还没落盘。 */
|
|
1789
|
+
const queued = async () => queuedCopies((await readPaneInput(target)).cap, text) > preQueued;
|
|
1601
1790
|
// 窄窗口 = 只有输入框本身。tailFp 紧挨光标, 提交前必在窗内、提交后消失。
|
|
1602
1791
|
// 仅用于 parseInputBox 认不出布局的后端。
|
|
1603
1792
|
const inputBoxStillHasTail = async () => {
|
|
@@ -1622,7 +1811,22 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1622
1811
|
// and then sleeping another 2.5s before the first Enter.
|
|
1623
1812
|
pasteSeen = await sawLanded(boxVerify ? PASTE_VERIFY_MS : POST_PASTE_SETTLE_FALLBACK_MS);
|
|
1624
1813
|
}
|
|
1625
|
-
if (!pasteSeen) {
|
|
1814
|
+
if (!pasteSeen && requireEventEvidence) {
|
|
1815
|
+
// 事件注入不重贴, 但也绝不能把一份可能已进框的粘贴撂下不交: CodeBuddy 在轮次交接 (提交 /
|
|
1816
|
+
// 收尾的 hook、摘要) 时会把粘贴的渲染拖过 PASTE_VERIFY_MS, 撂下的那份等 TUI 缓过来才冒出,
|
|
1817
|
+
// 被下一次注入接在后面一起回车 —— 两件事并成一行提交 (2026-10-14 两起: 人话 + 回执)。
|
|
1818
|
+
// 所以先多等它一阵; 认不出框 ≠ 框是空的, 那种情况照样回车, 交给证人判。
|
|
1819
|
+
const late = await lateLanding(LATE_LAND_MS);
|
|
1820
|
+
if (late === "submitted")
|
|
1821
|
+
return { ok: true };
|
|
1822
|
+
if (late === "intact") {
|
|
1823
|
+
log.warn({ target, headFp, waitedMs: Date.now() - pastedAt }, "mirror inject: 粘贴始终没进框, 事件不重贴");
|
|
1824
|
+
return { ok: false, uncertain: true, reason: "composer is empty but this event is absent from the transcript; delivery remains uncertain and will not be pasted again" };
|
|
1825
|
+
}
|
|
1826
|
+
pasteSeen = late === "landed";
|
|
1827
|
+
log.warn({ target, headFp, late, waitedMs: Date.now() - pastedAt }, "mirror inject: 粘贴迟到, 按迟到结果继续提交");
|
|
1828
|
+
}
|
|
1829
|
+
else if (!pasteSeen) {
|
|
1626
1830
|
const box = await readInputBox(target);
|
|
1627
1831
|
const empty = boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
|
|
1628
1832
|
if (submitted()) {
|
|
@@ -1691,13 +1895,19 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1691
1895
|
await sleep(POLL_MS);
|
|
1692
1896
|
if (submitted())
|
|
1693
1897
|
return true;
|
|
1694
|
-
if (Date.now() - t0
|
|
1898
|
+
if (Date.now() - t0 < WITNESS_ONLY_MS)
|
|
1899
|
+
continue;
|
|
1900
|
+
if (await queued())
|
|
1901
|
+
return true;
|
|
1902
|
+
// An unchanged empty composer proves submission only after we actually
|
|
1903
|
+
// saw this paste land. Otherwise a swallowed paste produces a false ACK.
|
|
1904
|
+
if (!requireEventEvidence && pasteSeen && (await cleared()))
|
|
1695
1905
|
return true;
|
|
1696
1906
|
}
|
|
1697
1907
|
return false;
|
|
1698
1908
|
};
|
|
1699
1909
|
if (!pasteSeen)
|
|
1700
|
-
log.warn({ target }, "mirror inject:
|
|
1910
|
+
log.warn({ target }, "mirror inject: paste unverified, requiring session submission evidence");
|
|
1701
1911
|
if (!tailFp) {
|
|
1702
1912
|
// empty/whitespace text — nothing to verify, fire once and go.
|
|
1703
1913
|
const e = await sendEnter();
|
|
@@ -1706,7 +1916,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1706
1916
|
// 回车可能被冷启动中的 TUI 吞掉 (框里躺着没提交的提示)。框认得出来时判据可信,
|
|
1707
1917
|
// 多补一次; 认不出来时维持原来的「只补一次」, 免得判据出错往真实对话里灌回车。
|
|
1708
1918
|
const boxTrusted = boxVerify && (await readInputBox(target)).known;
|
|
1709
|
-
const attempts = boxTrusted ? 3 : 2;
|
|
1919
|
+
const attempts = requireEventEvidence ? 1 : boxTrusted ? 3 : 2;
|
|
1710
1920
|
for (let i = 0; i < attempts; i++) {
|
|
1711
1921
|
if (i > 0) {
|
|
1712
1922
|
await sleep(RETRY_SETTLE_MS);
|
|
@@ -1723,9 +1933,13 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
|
|
|
1723
1933
|
return e;
|
|
1724
1934
|
if (await waitForSubmit(CLEARED_TIMEOUT_MS))
|
|
1725
1935
|
return { ok: true };
|
|
1936
|
+
if (requireEventEvidence && await cleared())
|
|
1937
|
+
return await queued() ? { ok: true } : { ok: false, uncertain: true, reason: "composer cleared without this event's transcript marker; delivery remains uncertain and will not be resubmitted" };
|
|
1726
1938
|
}
|
|
1727
|
-
if (submitted())
|
|
1939
|
+
if (submitted() || await queued())
|
|
1728
1940
|
return { ok: true };
|
|
1941
|
+
if (requireEventEvidence && await cleared())
|
|
1942
|
+
return { ok: false, uncertain: true, reason: "composer cleared without this event's transcript marker; delivery remains uncertain and will not be resubmitted" };
|
|
1729
1943
|
// 补到头还没清空。通常是渲染滞后 (回车其实落了), 但也可能是目标会话正忙 /
|
|
1730
1944
|
// 上下文满了不消费输入 —— 那样用户这条消息会悄无声息地消失。不报硬失败 (用户
|
|
1731
1945
|
// 多半已经被服务到了), 标 uncertain 让调用方按需提示。
|
|
@@ -1765,6 +1979,8 @@ const injectViaSpawn = async (args) => {
|
|
|
1765
1979
|
// which Claude parses at submit time and inlines as image content blocks
|
|
1766
1980
|
// without a model-decided Read tool turn.
|
|
1767
1981
|
const finalText = withImageRefs(text, images);
|
|
1982
|
+
const eventMark = args.eventId && !text.trimStart().startsWith("/") ? markTranscript(jsonlPath) : undefined;
|
|
1983
|
+
const eventSince = Date.now() - 1000;
|
|
1768
1984
|
const cliArgs = backend.name === "codex" ? ["exec", "resume", "--json", ...codexArgs,
|
|
1769
1985
|
...cfg.wrc.extraArgs.filter((arg) => !arg.startsWith("--disallowedTools")),
|
|
1770
1986
|
...images.flatMap((path) => ["--image", path]), sessionId, text] : [
|
|
@@ -1778,7 +1994,7 @@ const injectViaSpawn = async (args) => {
|
|
|
1778
1994
|
...cfg.wrc.extraArgs,
|
|
1779
1995
|
];
|
|
1780
1996
|
log.info({ sessionId, bin, len: text.length }, "mirror inject");
|
|
1781
|
-
if (args.guard && !args.guard("submit"))
|
|
1997
|
+
if (args.guard && !args.guard("submit", { sessionId, transcript: jsonlPath }))
|
|
1782
1998
|
return { ok: false, reason: "spawn canceled by dispatch guard" };
|
|
1783
1999
|
return new Promise((resolve) => {
|
|
1784
2000
|
const proc = spawn(bin, cliArgs, {
|
|
@@ -1805,6 +2021,10 @@ const injectViaSpawn = async (args) => {
|
|
|
1805
2021
|
resolve({ ok: false, reason: `${bin} exited ${code}: ${stderrTail.slice(-300)}` });
|
|
1806
2022
|
return;
|
|
1807
2023
|
}
|
|
2024
|
+
if (eventMark && args.eventId && !submittedEventSince([eventMark], args.eventId, eventSince)) {
|
|
2025
|
+
resolve({ ok: false, uncertain: true, reason: "CLI exited successfully but this event's transcript marker is absent; delivery remains uncertain" });
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
1808
2028
|
resolve({ ok: true });
|
|
1809
2029
|
});
|
|
1810
2030
|
});
|
|
@@ -1819,8 +2039,8 @@ const inject = async (args) => {
|
|
|
1819
2039
|
return { ok: false, retryable: true, reason: "spawn permission withdrawn" };
|
|
1820
2040
|
const text = args.prepareText?.() ?? args.text;
|
|
1821
2041
|
let activated = false;
|
|
1822
|
-
const result = await injectViaSpawn({ ...args, text, guard: (phase) => {
|
|
1823
|
-
if (args.guard && !args.guard(phase))
|
|
2042
|
+
const result = await injectViaSpawn({ ...args, text, guard: (phase, identity) => {
|
|
2043
|
+
if (args.guard && !args.guard(phase, identity))
|
|
1824
2044
|
return false;
|
|
1825
2045
|
if (phase && !activated) {
|
|
1826
2046
|
activated = true;
|
|
@@ -1842,11 +2062,12 @@ const inject = async (args) => {
|
|
|
1842
2062
|
return classify(await withPaneInput(target, async (live) => {
|
|
1843
2063
|
// Busy and unknown are both a deferred delivery. Never hold the input
|
|
1844
2064
|
// lock while waiting for a model's whole turn to finish.
|
|
1845
|
-
|
|
2065
|
+
const idleApproved = !args.requireIdle || await args.requireIdle();
|
|
2066
|
+
if (!idleApproved)
|
|
1846
2067
|
return { ok: false, reason: "target has no confirmed idle evidence" };
|
|
1847
2068
|
const text = args.prepareText?.() ?? args.text;
|
|
1848
|
-
const guard = (phase) => {
|
|
1849
|
-
if (!live() || (!exposed &&
|
|
2069
|
+
const guard = (phase, identity) => {
|
|
2070
|
+
if (!live() || (!exposed && !idleApproved) || (args.guard && !args.guard(phase, identity)))
|
|
1850
2071
|
return false;
|
|
1851
2072
|
if (phase && !exposed) {
|
|
1852
2073
|
exposed = true;
|
|
@@ -1854,7 +2075,7 @@ const inject = async (args) => {
|
|
|
1854
2075
|
}
|
|
1855
2076
|
return true;
|
|
1856
2077
|
};
|
|
1857
|
-
return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard);
|
|
2078
|
+
return injectViaTmux(target, text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false, guard, args.eventId, args.sessionId);
|
|
1858
2079
|
}, { guard: args.guard }));
|
|
1859
2080
|
}
|
|
1860
2081
|
catch (error) {
|
|
@@ -1873,15 +2094,24 @@ const INJECT_JOB_TIMEOUT_MS = 90_000;
|
|
|
1873
2094
|
/** 没有绑定的 pane 静默多久才算孤儿。刚 spawn、还没 attach 的 pane (并发 fan-out 时
|
|
1874
2095
|
* 别人的 sweep 会撞见它) 在这段时间里一直有输出, 远大于一次 spawn → attach 的耗时。 */
|
|
1875
2096
|
const ORPHAN_GRACE_MS = 10 * 60_000;
|
|
2097
|
+
/** ps 的 `etime` ([[dd-]hh:]mm:ss) → 秒; 认不出 = NaN。 */
|
|
2098
|
+
const etimeSec = (s) => {
|
|
2099
|
+
const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(s);
|
|
2100
|
+
if (!m)
|
|
2101
|
+
return Number.NaN;
|
|
2102
|
+
const [d = 0, h = 0, mi = 0, se = 0] = m.slice(1).map((x) => Number(x ?? 0));
|
|
2103
|
+
return ((d * 24 + h) * 60 + mi) * 60 + se;
|
|
2104
|
+
};
|
|
1876
2105
|
const psSnap = () => new Promise((resolve) => {
|
|
1877
|
-
const p = spawn("ps", ["-axo", "pid=,ppid=,
|
|
2106
|
+
const p = spawn("ps", ["-axo", "pid=,ppid=,etime="], { env: { ...process.env, PATH: augmentedPath(process.env.PATH) }, stdio: ["ignore", "pipe", "ignore"] });
|
|
1878
2107
|
let out = "";
|
|
1879
2108
|
p.stdout?.on("data", (c) => (out += c.toString("utf8")));
|
|
1880
2109
|
const done = () => {
|
|
1881
2110
|
const kids = new Map();
|
|
1882
2111
|
const etimes = new Map();
|
|
1883
2112
|
for (const line of out.split("\n")) {
|
|
1884
|
-
const [
|
|
2113
|
+
const [pidS = "", ppidS = "", etS = ""] = line.trim().split(/\s+/);
|
|
2114
|
+
const pid = Number(pidS), ppid = Number(ppidS), et = etimeSec(etS);
|
|
1885
2115
|
if (!pid || !ppid)
|
|
1886
2116
|
continue;
|
|
1887
2117
|
const arr = kids.get(ppid);
|
|
@@ -1889,7 +2119,7 @@ const psSnap = () => new Promise((resolve) => {
|
|
|
1889
2119
|
arr.push(pid);
|
|
1890
2120
|
else
|
|
1891
2121
|
kids.set(ppid, [pid]);
|
|
1892
|
-
if (
|
|
2122
|
+
if (!Number.isNaN(et))
|
|
1893
2123
|
etimes.set(pid, et);
|
|
1894
2124
|
}
|
|
1895
2125
|
resolve({ kids, etimes });
|
|
@@ -1992,14 +2222,6 @@ export const startMirror = (deps) => {
|
|
|
1992
2222
|
recentAssistantSends.splice(i, 1); // one-shot: a genuine later re-say still streams
|
|
1993
2223
|
return true;
|
|
1994
2224
|
};
|
|
1995
|
-
// Keepalive ping detector for the tail's user-line judgment — the SAME
|
|
1996
|
-
// normalized-prefix signature set keepaliveTick feeds keepaliveStamps (the
|
|
1997
|
-
// configured ping + the bare "ping" legacy streak form), so renderLine, the
|
|
1998
|
-
// tick, the peer readers (peek / wait / roster summary) and the transcript
|
|
1999
|
-
// parser all agree on what counts as a ping. Content is the source of truth
|
|
2000
|
-
// for the swallow: timers and echo-TTLs lose it across reloads/replays.
|
|
2001
|
-
const pingSigs = keepalivePingSigs(cfg.wrc.mirror.keepalive.ping);
|
|
2002
|
-
const isKeepalivePing = (text) => isKeepalivePingText(text, pingSigs);
|
|
2003
2225
|
// ── Typewriter stream lifecycle ────────────────────────────────────
|
|
2004
2226
|
// WeCom spec: server polls us for stream refreshes for up to 6 min from the
|
|
2005
2227
|
// original inbound. SDK queues replyStream calls per req_id, sends serially
|
|
@@ -2044,6 +2266,7 @@ export const startMirror = (deps) => {
|
|
|
2044
2266
|
turnRegistry.delete(k);
|
|
2045
2267
|
};
|
|
2046
2268
|
const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
|
|
2269
|
+
let eventFinalGate = () => true;
|
|
2047
2270
|
const newTurnId = () => `t${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
2048
2271
|
const newMarkId = () => `m${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
2049
2272
|
const detailCardFor = (s, target) => {
|
|
@@ -2176,8 +2399,6 @@ export const startMirror = (deps) => {
|
|
|
2176
2399
|
const sendStandalone = (a, content) => {
|
|
2177
2400
|
if (!hasVisibleBody(content))
|
|
2178
2401
|
return;
|
|
2179
|
-
if (answerSilent(a))
|
|
2180
|
-
return;
|
|
2181
2402
|
if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
|
|
2182
2403
|
return;
|
|
2183
2404
|
const chatId = replyChatId(a);
|
|
@@ -2186,11 +2407,7 @@ export const startMirror = (deps) => {
|
|
|
2186
2407
|
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
2187
2408
|
a.standalonePending = a.standalonePending
|
|
2188
2409
|
.then(async () => {
|
|
2189
|
-
if (silenceAnswer(a.target, turnId ?? ""))
|
|
2190
|
-
return;
|
|
2191
2410
|
for (const c of chunks) {
|
|
2192
|
-
if (silenceAnswer(a.target, turnId ?? ""))
|
|
2193
|
-
return;
|
|
2194
2411
|
try {
|
|
2195
2412
|
const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
|
|
2196
2413
|
log.info({ sessionId: a.sessionId, turnId, chatId, len: c.length, ack: JSON.stringify(ack ?? null).slice(0, 300) }, "standalone pushed");
|
|
@@ -2210,8 +2427,6 @@ export const startMirror = (deps) => {
|
|
|
2210
2427
|
const turnId = a.briefTurnId ?? a.liveStream?.turnId;
|
|
2211
2428
|
a.standalonePending = a.standalonePending
|
|
2212
2429
|
.then(async () => {
|
|
2213
|
-
if (silenceAnswer(a.target, turnId ?? ""))
|
|
2214
|
-
return;
|
|
2215
2430
|
try {
|
|
2216
2431
|
await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
|
|
2217
2432
|
log.info({ sessionId: a.sessionId, turnId, chatId, len: content.length }, "raw pushed");
|
|
@@ -2483,9 +2698,9 @@ export const startMirror = (deps) => {
|
|
|
2483
2698
|
// • 节流 —— 气泡刷新是整条内容重发, 高频 thinking 不能 1:1 打到 stream 上。
|
|
2484
2699
|
const COT_MAX_CHARS = 100;
|
|
2485
2700
|
const COT_FLUSH_MS = 1500;
|
|
2486
|
-
//
|
|
2487
|
-
//
|
|
2488
|
-
//
|
|
2701
|
+
// 压成一行的进度片段, 纯文本上气泡 (不包代码段)。safeForMarkdown 必须先于截断:
|
|
2702
|
+
// 进度行与链接头同行, thinking 里的 `[]`<>` 会戳破链接与排版。尾随的省略号 =
|
|
2703
|
+
// "还在进行中"; 截断与进行中共用同一个 …, 不叠加两个。
|
|
2489
2704
|
const cotLine = (s) => {
|
|
2490
2705
|
const flat = safeForMarkdown(s.replace(/\s+/g, " ").trim());
|
|
2491
2706
|
return flat ? `${flat.slice(0, COT_MAX_CHARS)}…` : "";
|
|
@@ -2497,22 +2712,25 @@ export const startMirror = (deps) => {
|
|
|
2497
2712
|
a.cotTimer = undefined;
|
|
2498
2713
|
}
|
|
2499
2714
|
a.cotText = undefined;
|
|
2715
|
+
a.cotTool = undefined;
|
|
2500
2716
|
};
|
|
2501
2717
|
const flushCot = async (a) => {
|
|
2502
2718
|
a.cotTimer = undefined;
|
|
2503
|
-
|
|
2719
|
+
// 展示选取: assistant text (含 thinking) 优先; 没有新 text 时才落最新 tool_use。
|
|
2720
|
+
const text = a.cotText ?? a.cotTool;
|
|
2504
2721
|
a.cotText = undefined;
|
|
2722
|
+
a.cotTool = undefined;
|
|
2505
2723
|
const b = a.briefBubble;
|
|
2506
2724
|
const turnId = a.briefTurnId;
|
|
2507
2725
|
// 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
|
|
2508
|
-
if (!text || !b || b.done || !turnId || a.briefConcluded
|
|
2726
|
+
if (!text || !b || b.done || !turnId || a.briefConcluded)
|
|
2509
2727
|
return;
|
|
2510
2728
|
a.cotLastSent = text;
|
|
2511
2729
|
try {
|
|
2512
2730
|
await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2513
|
-
if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId
|
|
2731
|
+
if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId)
|
|
2514
2732
|
return;
|
|
2515
|
-
await client.replyStream(b.frame, b.streamId, briefLine(a, turnId,
|
|
2733
|
+
await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, text), false);
|
|
2516
2734
|
}));
|
|
2517
2735
|
}
|
|
2518
2736
|
catch (e) {
|
|
@@ -2532,11 +2750,25 @@ export const startMirror = (deps) => {
|
|
|
2532
2750
|
return;
|
|
2533
2751
|
a.cotTimer = setTimeout(() => void flushCot(a), COT_FLUSH_MS);
|
|
2534
2752
|
};
|
|
2753
|
+
/** tool_use 进度进 tool 槽 (不顶掉待发的 assistant text): flush 时 text 优先,
|
|
2754
|
+
* 只有没有新 text 的窗口才把最新 tool 行刷上气泡。节流与 text 共用一个计时器。 */
|
|
2755
|
+
const updateBriefToolProgress = (a, raw) => {
|
|
2756
|
+
const b = a.briefBubble;
|
|
2757
|
+
if (!b || b.done || a.briefConcluded)
|
|
2758
|
+
return;
|
|
2759
|
+
const line = cotLine(raw);
|
|
2760
|
+
if (!line)
|
|
2761
|
+
return;
|
|
2762
|
+
a.cotTool = line;
|
|
2763
|
+
if (a.cotTimer)
|
|
2764
|
+
return;
|
|
2765
|
+
a.cotTimer = setTimeout(() => void flushCot(a), COT_FLUSH_MS);
|
|
2766
|
+
};
|
|
2535
2767
|
// 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
|
|
2536
2768
|
// 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
|
|
2537
2769
|
// wizard 名字作为 ww_uniq 传下去, 让同一个 wizard 的所有 turn 详情都复用一个 WeCom 窗口。
|
|
2538
2770
|
// 头怎么写归 chatHead (默认 wizard 在 home 群: 企微只留名字链接, 微信不写头); 头与正文都空时留 `…`, 不发空气泡。
|
|
2539
|
-
const briefLine = (a, turnId, rest, dest = a.channel) => headed(chatHead(a.target,
|
|
2771
|
+
const briefLine = (a, turnId, rest, dest = a.channel) => headed(chatHead(a.target, chatTurnUrl(a.target, turnId), undefined, dest || undefined), rest) || "…";
|
|
2540
2772
|
// ── 出处门 (chatOriginOnly) ───────────────────────────────────────────
|
|
2541
2773
|
// 人在 CLI 里手敲的一轮, 镜像只发生在两处: 他眼前的终端, 和 chat 详情页 (turn
|
|
2542
2774
|
// store 全量记录 + SSE 实时刷新)。聊天里不再下发它的气泡 —— 那份内容他本来就
|
|
@@ -2544,171 +2776,64 @@ export const startMirror = (deps) => {
|
|
|
2544
2776
|
// `[mirror]` 系统提示 —— 它们都不走这道门。
|
|
2545
2777
|
// 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
|
|
2546
2778
|
// 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
|
|
2547
|
-
|
|
2548
|
-
const
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
|
|
2561
|
-
const updateAnswerProgress = (a, item) => {
|
|
2562
|
-
// Queued metadata is not authority: only the input boundary activates a task.
|
|
2563
|
-
const held = a.activePeerTurn ? answerBubbles.get(a.activePeerTurn) : undefined;
|
|
2564
|
-
if (!held || held.receiver !== a.target || held.stopped || held.bubble?.done)
|
|
2565
|
-
return;
|
|
2566
|
-
const raw = item.kind === "tool_use" ? cotToolLabel(item.calls)
|
|
2567
|
-
: item.kind === "thinking" || (item.kind === "text" && item.final !== true) ? item.body : "";
|
|
2568
|
-
const line = cotLine(raw);
|
|
2569
|
-
if (!line)
|
|
2570
|
-
return;
|
|
2571
|
-
held.progress = line;
|
|
2572
|
-
if (!held.active)
|
|
2573
|
-
return;
|
|
2574
|
-
if (held.timer)
|
|
2575
|
-
return;
|
|
2576
|
-
held.timer = setTimeout(() => {
|
|
2577
|
-
held.timer = undefined;
|
|
2578
|
-
if (held.progress)
|
|
2579
|
-
writeAnswerProgress(held, `\`${held.progress}\``);
|
|
2580
|
-
}, COT_FLUSH_MS);
|
|
2581
|
-
};
|
|
2582
|
-
const answerContext = (target) => {
|
|
2583
|
-
const a = byTarget.get(target);
|
|
2584
|
-
if (!a || a.briefConcluded || a.briefIsSlash || isPrivate(a) || a.turnFromChat === false)
|
|
2779
|
+
const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
|
|
2780
|
+
const formatFinal = (target, turn, channel, body) => {
|
|
2781
|
+
const pieces = splitMarkdown(body, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
2782
|
+
return pieces.map((text, n) => withLinkedTagHeader(target, text, chatTurnUrl(target, turn), pieces.length > 1 ? `${n + 1}/${pieces.length}` : undefined, undefined, channel));
|
|
2783
|
+
};
|
|
2784
|
+
const finalProgress = new Map();
|
|
2785
|
+
const settleFinalProgress = async (id) => {
|
|
2786
|
+
await finalProgress.get(id)?.catch(() => undefined);
|
|
2787
|
+
finalProgress.delete(id);
|
|
2788
|
+
};
|
|
2789
|
+
const queueFinal = (target, turn, channel, body, eventId, bubble) => {
|
|
2790
|
+
// 投递出口剥掉行首收口标记与 ARTIFACT 行 (peers.stripForChat): 判定读原文, 群里只看结论。
|
|
2791
|
+
const text = stripForChat(body);
|
|
2792
|
+
if (!deps.events || !channel || !text.trim())
|
|
2585
2793
|
return undefined;
|
|
2586
|
-
const
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
if (
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
const cancelAnswerPreparation = (id) => {
|
|
2602
|
-
const held = answerBubbles.get(id);
|
|
2603
|
-
if (held && !held.active)
|
|
2604
|
-
answerBubbles.delete(id);
|
|
2605
|
-
};
|
|
2606
|
-
const stopAnswerProgress = (id) => {
|
|
2607
|
-
const held = answerBubbles.get(id);
|
|
2608
|
-
if (!held)
|
|
2794
|
+
const revision = finalId(eventId || turn, channel, text);
|
|
2795
|
+
const id = finalId(eventId || turn, channel, revision);
|
|
2796
|
+
const chunks = formatFinal(target, turn, channel, text);
|
|
2797
|
+
if (bubble?.pending)
|
|
2798
|
+
finalProgress.set(id, bubble.pending);
|
|
2799
|
+
deps.events.delivery({ id, eventId, target, turn, channel, body: text, revision, chunks,
|
|
2800
|
+
...(bubble && !bubble.done ? { stream: { frame: bubble.frame, id: bubble.streamId, expires: bubble.expires ?? Date.now() } } : {}) });
|
|
2801
|
+
if (bubble) {
|
|
2802
|
+
bubble.done = true;
|
|
2803
|
+
clearTimeout(bubble.hardTimer);
|
|
2804
|
+
}
|
|
2805
|
+
return id;
|
|
2806
|
+
};
|
|
2807
|
+
const recoverEventFinal = (event, bubble) => {
|
|
2808
|
+
if (!event.result || event.interjected || event.canceled)
|
|
2609
2809
|
return;
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
const
|
|
2620
|
-
if (
|
|
2621
|
-
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
held.bubble = { frame: s.frame, streamId: s.streamId, done: false, pending: s.pending };
|
|
2638
|
-
if (a?.liveStream === s)
|
|
2639
|
-
a.liveStream = undefined;
|
|
2640
|
-
}
|
|
2641
|
-
else if (s)
|
|
2642
|
-
held.bubble = undefined;
|
|
2643
|
-
const b = held.bubble;
|
|
2644
|
-
held.active = true;
|
|
2645
|
-
if (!b || b.done)
|
|
2646
|
-
return { taken: true, cleared: false };
|
|
2647
|
-
clearTimeout(b.hardTimer);
|
|
2648
|
-
if (a?.briefBubble === b)
|
|
2649
|
-
a.briefBubble = undefined;
|
|
2650
|
-
let cleared = false;
|
|
2651
|
-
held.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
|
|
2652
|
-
if (b.done)
|
|
2653
|
-
return;
|
|
2654
|
-
await client.replyStream(b.frame, b.streamId, answerLine(held, "…"), false);
|
|
2655
|
-
cleared = true;
|
|
2656
|
-
});
|
|
2657
|
-
try {
|
|
2658
|
-
await held.pending;
|
|
2659
|
-
}
|
|
2660
|
-
catch (e) {
|
|
2661
|
-
cleared = false;
|
|
2662
|
-
log.warn({ id, err: errText(e) }, "answer transfer reset not acknowledged");
|
|
2663
|
-
}
|
|
2664
|
-
// Keep delivery possible after a reset error; reset never carries the final answer.
|
|
2665
|
-
held.pending = held.pending.catch(() => undefined);
|
|
2666
|
-
if (!held.stopped && held.progress)
|
|
2667
|
-
writeAnswerProgress(held, `\`${held.progress}\``);
|
|
2668
|
-
return { taken: true, cleared };
|
|
2669
|
-
};
|
|
2670
|
-
const deliverAnswer = async (id, target, turn, channel, _prefix, body, standalone) => {
|
|
2671
|
-
const held = answerBubbles.get(id);
|
|
2672
|
-
const text = body.trim();
|
|
2673
|
-
const content = withLinkedTagHeader(target, text, chatTurnUrl(target, turn), undefined, undefined, channel);
|
|
2674
|
-
stopAnswerProgress(id);
|
|
2675
|
-
if (held?.bubble && !held.bubble.done) {
|
|
2676
|
-
held.bubble.done = true;
|
|
2677
|
-
clearTimeout(held.timer);
|
|
2678
|
-
await held.pending;
|
|
2679
|
-
try {
|
|
2680
|
-
// Failure closes progress first; the terminal explanation is a standalone message.
|
|
2681
|
-
const progress = withLinkedTagHeader(target, "回答已转交", chatTurnUrl(target, turn), undefined, undefined, channel);
|
|
2682
|
-
await client.replyStream(held.bubble.frame, held.bubble.streamId, standalone ? progress : content, true);
|
|
2683
|
-
if (!standalone) {
|
|
2684
|
-
answerBubbles.delete(id);
|
|
2685
|
-
return;
|
|
2686
|
-
}
|
|
2687
|
-
}
|
|
2688
|
-
catch (e) {
|
|
2689
|
-
const ack = e && typeof e === "object" ? e : undefined;
|
|
2690
|
-
// Only a structured non-zero server ACK proves the stream frame was
|
|
2691
|
-
// rejected. Timeout/disconnect errors are ambiguous: standalone retry
|
|
2692
|
-
// could publish the same final answer twice, so let the caller persist
|
|
2693
|
-
// `uncertain` and require a human resend instead.
|
|
2694
|
-
const rejected = !!ack && typeof ack.headers === "object" && !!ack.headers && typeof ack.errcode === "number" && ack.errcode !== 0 && typeof ack.errmsg === "string";
|
|
2695
|
-
if (!rejected && !standalone) {
|
|
2696
|
-
answerBubbles.delete(id);
|
|
2697
|
-
throw e;
|
|
2698
|
-
}
|
|
2699
|
-
if (!rejected) {
|
|
2700
|
-
log.warn({ id, err: errText(e) }, "answer transfer progress close acknowledgement uncertain; continuing with the one standalone terminal explanation");
|
|
2701
|
-
// In standalone mode this stream only closes progress; it never
|
|
2702
|
-
// carries the final body, so sending the terminal explanation once
|
|
2703
|
-
// remains necessary even when progress-close ACK is ambiguous.
|
|
2704
|
-
}
|
|
2705
|
-
else {
|
|
2706
|
-
log.warn({ id, err: errText(e), errcode: ack.errcode }, "answer transfer stream was explicitly rejected; standalone fallback");
|
|
2707
|
-
}
|
|
2708
|
-
}
|
|
2709
|
-
}
|
|
2710
|
-
await client.sendMessage(channel.replace(/^(user|chat|group):/, ""), { msgtype: "markdown", markdown: { content } });
|
|
2711
|
-
answerBubbles.delete(id);
|
|
2810
|
+
// 事件泵的 evidence 探针 (observe → io.recovered) 与 brief 软收口赛跑: 两者同为
|
|
2811
|
+
// ~1s 周期, 探针常先跑到, 而它调这里不带气泡 —— queueFinal 拿不到 stream, 终句
|
|
2812
|
+
// 全部走 standalone, 气泡定格在最后一行 CoT 进度。没传气泡时按 target/turn 找回
|
|
2813
|
+
// 活着的那只 (turn 已换人/气泡已收则不挂, 维持 standalone)。
|
|
2814
|
+
bubble ??= (() => {
|
|
2815
|
+
const a = byTarget.get(event.target);
|
|
2816
|
+
return a && (!event.turn || a.briefTurnId === event.turn) && a.briefBubble && !a.briefBubble.done ? a.briefBubble : undefined;
|
|
2817
|
+
})();
|
|
2818
|
+
const turn = event.turn || event.id;
|
|
2819
|
+
const record = getDetail(turn);
|
|
2820
|
+
if (!record)
|
|
2821
|
+
recordTurnStart({ id: turn, eventId: event.id, target: event.target,
|
|
2822
|
+
userQuery: event.payload.text, createdAt: event.originalAt ?? event.at, channel: event.channel,
|
|
2823
|
+
speaker: event.payload.human ? event.speaker : undefined, from: event.payload.peer?.from });
|
|
2824
|
+
// tail 已按软收口记过这句话 (final:false —— 它不带终句语义, doneAt 不认): 就地升级,
|
|
2825
|
+
// 不追加同文第二条, 否则 rolepage 上草稿与终稿各画一颗气泡。没有同文的才补 —— 但也
|
|
2826
|
+
// 只补「这轮还没有任何终句」的: 已有终句而 body 不同, 是续答链把子事件的终句写进了
|
|
2827
|
+
// 同一段话只落一次 (子轮 / 父轮不各画一颗气泡)。
|
|
2828
|
+
if (!(record?.kind === "turn" && markTurnTextFinal(turn, event.result.body)) &&
|
|
2829
|
+
(record ? record.kind === "turn" && !record.items.some((x) => x.t === "text" && x.final) : true))
|
|
2830
|
+
recordTurnItem(turn, { t: "text", final: true, body: event.result.body, ts: event.result.at });
|
|
2831
|
+
recordTurnClose(turn);
|
|
2832
|
+
if (!event.turn)
|
|
2833
|
+
deps.events?.bind(event.id, turn);
|
|
2834
|
+
// 终句按事件自己的 `serves` 放行 (§9.4/§9.5): 收口与否由路由表判 (eventFinalGate)。
|
|
2835
|
+
if (event.channel && eventFinalGate(event))
|
|
2836
|
+
queueFinal(event.target, turn, event.channel, event.result.body, event.id, bubble);
|
|
2712
2837
|
};
|
|
2713
2838
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
2714
2839
|
// raw=true skips withTagHeader (used when content already contains the linked tag header).
|
|
@@ -2782,13 +2907,18 @@ export const startMirror = (deps) => {
|
|
|
2782
2907
|
const publicPeer = (from) => from?.kind === "peer" && from.public ? from.from : undefined;
|
|
2783
2908
|
/** reload 后 tail 从 EOF 接上: 正在跑的这一轮的开头那句已在身后, 它的印章随旧进程没了。
|
|
2784
2909
|
* 那句话自己挂着信封 (谁说的、私聊还是哪个群) —— 读回来就是这一轮的出处。没挂信封 = undefined。 */
|
|
2910
|
+
/** 这一轮挂在哪一笔上: 回执 (与老派活信封) 写在信封上; 新派活信封不带, 问注入事件的 `serves`。 */
|
|
2911
|
+
const rowOf = (env) => env.event ?? (env.eventId ? deps.events?.get(env.eventId)?.serves : undefined);
|
|
2785
2912
|
const openingOf = (a) => {
|
|
2786
2913
|
const t = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
|
|
2787
2914
|
const env = t?.env;
|
|
2915
|
+
a.activeEventId = env?.eventId;
|
|
2788
2916
|
if (!env)
|
|
2789
2917
|
return undefined;
|
|
2790
2918
|
const home = baseOfKey(a.target);
|
|
2791
|
-
|
|
2919
|
+
// transfer 过来的那一笔信封上写着群 (不是 private), 但它不是公开派活: 回复不标给发话方。
|
|
2920
|
+
const handed = !!(env.eventId && deps.events?.get(env.eventId)?.payload.peer?.from?.transferred);
|
|
2921
|
+
const from = env.kind === "peer" ? { kind: "peer", from: wizardStore()?.byName(stripSigil(env.from))?.target, ...(handed ? { transferred: true } : env.private ? {} : { public: true }), ...(env.receipt ? { receipt: true } : {}), ...(env.status ? { status: env.status } : {}), ...((turn) => (turn ? { turn } : {}))(rowOf(env)), ...(env.asker ? { asker: env.asker } : {}) }
|
|
2792
2922
|
: env.kind === "task" ? { kind: "task", taskId: env.from.replace(/^定时 /, "") }
|
|
2793
2923
|
: undefined;
|
|
2794
2924
|
return {
|
|
@@ -2838,7 +2968,7 @@ export const startMirror = (deps) => {
|
|
|
2838
2968
|
}
|
|
2839
2969
|
// 私聊派来的活 (不是回执) 这一轮必是私聊, 不论印章带没带频道: 进群的只能是人问的事的
|
|
2840
2970
|
// 交代与显式公开。带 task 的 fork 分身就只有出处、没有频道印章, 退回 home 就把私聊派的活答进了群。
|
|
2841
|
-
if (from?.kind === "peer" && !from.public && !from.receipt) {
|
|
2971
|
+
if (from?.kind === "peer" && !from.public && !from.receipt && !from.transferred) {
|
|
2842
2972
|
a.channel = "";
|
|
2843
2973
|
a.speaker = undefined;
|
|
2844
2974
|
a.replyTo = undefined;
|
|
@@ -2846,7 +2976,7 @@ export const startMirror = (deps) => {
|
|
|
2846
2976
|
return undefined;
|
|
2847
2977
|
};
|
|
2848
2978
|
/** 本轮落 turn 记录的频道字段。 */
|
|
2849
|
-
const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker });
|
|
2979
|
+
const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker, eventId: a.activeEventId });
|
|
2850
2980
|
/** 私聊轮: 回复只落 turn 记录。 */
|
|
2851
2981
|
const isPrivate = (a) => a.channel === "";
|
|
2852
2982
|
/** 本轮回复该推去的 chatId。 */
|
|
@@ -2872,9 +3002,9 @@ export const startMirror = (deps) => {
|
|
|
2872
3002
|
};
|
|
2873
3003
|
/** 露面的这一行认领它的印章, 先注入的先认。CLI 把排队的几句并成一行时, 包含在里面的都算
|
|
2874
3004
|
* 露过面, 出处取第一枚。认不出 = undefined。 */
|
|
2875
|
-
const takeSeal = (a, said, pick = () => true) => {
|
|
3005
|
+
const takeSeal = (a, said, pick = () => true, eventId) => {
|
|
2876
3006
|
const t = said.trim();
|
|
2877
|
-
const hit = (x) => pick(x) && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k)));
|
|
3007
|
+
const hit = (x) => pick(x) && (eventId ? x.eventId === eventId : !x.eventId && x.keys.some((k) => k === t || (k.length >= 8 && t.includes(k))));
|
|
2878
3008
|
const seals = a.seals ?? [];
|
|
2879
3009
|
const hits = seals.filter(hit);
|
|
2880
3010
|
// CLI 把排队的几句并成一行, 这一轮只能有一个去向: 里面有私聊的那句就是私聊 —— 它的答复不能
|
|
@@ -2895,8 +3025,10 @@ export const startMirror = (deps) => {
|
|
|
2895
3025
|
* `at` = 那一行落盘的时刻: 排队的那句从这一刻才开始被处理, 轮次的起点以它为准; 闲时当场接上就是投递那一刻。 */
|
|
2896
3026
|
const activateDispatch = (a, s, at = s.at) => {
|
|
2897
3027
|
const q = s.turn;
|
|
3028
|
+
if (a.briefTurnId && a.briefTurnId !== q.turnId)
|
|
3029
|
+
closeBriefTurn(a);
|
|
3030
|
+
a.activeEventId = s.eventId;
|
|
2898
3031
|
s.activated = true;
|
|
2899
|
-
a.activePeerTurn = undefined;
|
|
2900
3032
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
2901
3033
|
a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
|
|
2902
3034
|
a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
|
|
@@ -2906,9 +3038,7 @@ export const startMirror = (deps) => {
|
|
|
2906
3038
|
a.pendingBriefQuery = undefined;
|
|
2907
3039
|
a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
|
|
2908
3040
|
consumeChannel(a, true, undefined);
|
|
2909
|
-
recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: at, ...channelFields(a) });
|
|
2910
|
-
if (a.briefTurnId)
|
|
2911
|
-
closeBriefTurn(a);
|
|
3041
|
+
recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: (s.eventId ? deps.events?.get(s.eventId)?.originalAt : undefined) ?? at, ...channelFields(a) });
|
|
2912
3042
|
openBriefTurn(a, q);
|
|
2913
3043
|
};
|
|
2914
3044
|
/** 一句插话记进它插进的那一轮: 出处、频道、发话人都取它自己的印章 (没写频道 = 在 home 里开口)。 */
|
|
@@ -2916,16 +3046,21 @@ export const startMirror = (deps) => {
|
|
|
2916
3046
|
/** `attachment.queued_command` 露面: 那句被正在跑的这一轮中途吃进去了。认领它的印章记成插话。
|
|
2917
3047
|
* 人从聊天里说的那句 (印章带预建的轮) 不在这里认: 它已有自己的轮次记录。认不出 (人在 CLI 里
|
|
2918
3048
|
* 忙时敲的) 不记 —— 说不清是谁、在哪个频道说的。 */
|
|
2919
|
-
const interject = (a, said, ts) => {
|
|
3049
|
+
const interject = (a, said, ts, eventId) => {
|
|
2920
3050
|
if (!a.briefTurnId)
|
|
2921
3051
|
return;
|
|
2922
|
-
const s = takeSeal(a, said, (x) => !x.turn);
|
|
3052
|
+
const s = takeSeal(a, said, (x) => !x.turn, eventId);
|
|
2923
3053
|
if (s)
|
|
2924
3054
|
injectInto(a.briefTurnId, a, s, ts);
|
|
2925
3055
|
};
|
|
2926
3056
|
/** 一行注入露面了: 把它的印章落成下一轮的出处 / 频道。返回认领到的印章。`ts` = 那一行落盘的时刻。 */
|
|
2927
|
-
const bindLine = (a, said, ts) => {
|
|
2928
|
-
const
|
|
3057
|
+
const bindLine = (a, said, ts, eventId) => {
|
|
3058
|
+
const recovered = eventId ? deps.events?.get(eventId) : undefined;
|
|
3059
|
+
const s = takeSeal(a, said, undefined, eventId) ?? (recovered?.target === a.target ? {
|
|
3060
|
+
eventId, keys: [], at: recovered.at, query: recovered.payload.text, channel: recovered.channel,
|
|
3061
|
+
speaker: recovered.payload.human ? recovered.speaker : undefined, from: recovered.payload.peer?.from,
|
|
3062
|
+
fresh: recovered.priority === "normal" || idleVerdict(a.target) === true,
|
|
3063
|
+
} : undefined);
|
|
2929
3064
|
if (!s)
|
|
2930
3065
|
return undefined;
|
|
2931
3066
|
if (s.turn) {
|
|
@@ -2941,8 +3076,8 @@ export const startMirror = (deps) => {
|
|
|
2941
3076
|
}
|
|
2942
3077
|
if (a.briefTurnId)
|
|
2943
3078
|
closeBriefTurn(a);
|
|
3079
|
+
a.activeEventId = s.eventId;
|
|
2944
3080
|
const now = Date.now();
|
|
2945
|
-
a.activePeerTurn = s.from?.kind === "peer" ? s.from.turn : undefined;
|
|
2946
3081
|
a.pendingFrom = s.from ? { from: s.from, at: now } : undefined;
|
|
2947
3082
|
a.pendingOrigin = s.origin ? { origin: s.origin, at: now } : undefined;
|
|
2948
3083
|
// 没写频道的注入 (graph 步骤 / 手动 inject) 等同在 home 里开口: 印章就是全部出处, 不沿用上一轮的。
|
|
@@ -2956,10 +3091,12 @@ export const startMirror = (deps) => {
|
|
|
2956
3091
|
}
|
|
2957
3092
|
return s;
|
|
2958
3093
|
};
|
|
2959
|
-
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker) => {
|
|
3094
|
+
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "", channel, speaker, eventId) => {
|
|
2960
3095
|
const turnId = newTurnId();
|
|
2961
3096
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2962
|
-
const bubble = { frame, streamId, done: false };
|
|
3097
|
+
const bubble = { frame, streamId, done: false, expires: (eventId ? deps.events?.get(eventId)?.at ?? Date.now() : Date.now()) + HARD_TIMEOUT_MS };
|
|
3098
|
+
if (eventId)
|
|
3099
|
+
deps.events?.bind(eventId, turnId);
|
|
2963
3100
|
const q = { turnId, bubble, isSlash };
|
|
2964
3101
|
// 微信帧不挂: 没有 stream 窗口要赶, 气泡等终句来了再一次发出 (见 openStream 同一条)。
|
|
2965
3102
|
if (!isWxFrame(frame))
|
|
@@ -2970,23 +3107,17 @@ export const startMirror = (deps) => {
|
|
|
2970
3107
|
const live = a.briefBubble === bubble && !a.briefConcluded;
|
|
2971
3108
|
void finishBubble(a, bubble, briefLine(a, turnId, live ? STILL_WORKING : "", channel), true);
|
|
2972
3109
|
}, HARD_TIMEOUT_MS);
|
|
2973
|
-
const seal = { keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
|
|
3110
|
+
const seal = { eventId, keys: sealKeys(userQuery), at: Date.now(), query: userQuery, channel, speaker, fresh: true, turn: q };
|
|
2974
3111
|
// 对方闲着 → 这一句就是下一轮, 当场接上。正忙 (在跑别人的回执 / 派活 / 上一句) 就先排着:
|
|
2975
3112
|
// 那一轮的回复不能流进这一句的气泡, 等它在 transcript 里露面再接 (bindLine)。turn 记录
|
|
2976
3113
|
// 先建好, ack 里的链接点得开。斜杠命令的行未必露面, 当场接。
|
|
2977
3114
|
const queued = !isSlash && idleVerdict(a.target) === false;
|
|
2978
3115
|
pushSeal(a, seal);
|
|
2979
3116
|
if (queued)
|
|
2980
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, channel: channel ?? baseOfKey(a.target), speaker });
|
|
3117
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, eventId, createdAt: eventId ? deps.events?.get(eventId)?.originalAt : undefined, channel: channel ?? baseOfKey(a.target), speaker });
|
|
2981
3118
|
else
|
|
2982
3119
|
activateDispatch(a, seal);
|
|
2983
|
-
//
|
|
2984
|
-
// closeBriefTurn: 有正文收入旧气泡, 没正文不写一个字 (绝不因边界结束凭空新发/
|
|
2985
|
-
// 覆盖消息)。旧 turn 在 CLI 侧的剩余产出自然流入新气泡 —— "新回复走最新气泡"
|
|
2986
|
-
// 正是这个语义。代价是旧 turn 页提前标完、尾部 item 记到新 turn 名下, 但远小于
|
|
2987
|
-
// 排队的代价: 排队 turn 的 frame 在前一个长 turn 期间 (可达数分钟) 过期, 激活后
|
|
2988
|
-
// replyStream 全被 WeCom 拒收 (#stream 事故的静默根因)。所以只在前面真有别人的行排着时
|
|
2989
|
-
// 才排 (见上): 接上时气泡若已过期, 正文走 standalone (concludeBriefTurn)。
|
|
3120
|
+
// Durable normal inputs arrive only after the preceding full turn; expired bubbles use the final outbox standalone path.
|
|
2990
3121
|
// ack 即详情链接。URL 的三个入参 (turnId / target / host) 在收消息这一刻全部已知 ——
|
|
2991
3122
|
// turnId 是本地生成的, 详情页记录也已在 recordTurnStart 建好, 所以不必等 CLI 产出,
|
|
2992
3123
|
// 也不必猜后端: codebuddy 那种几十秒后才落盘的轮, 入口从第一秒就在。
|
|
@@ -3005,8 +3136,11 @@ export const startMirror = (deps) => {
|
|
|
3005
3136
|
if (a.channelSeeded)
|
|
3006
3137
|
return false;
|
|
3007
3138
|
const head = (t) => (t ?? "").replace(/\s+/g, " ").trim().slice(0, 60);
|
|
3008
|
-
const
|
|
3009
|
-
const
|
|
3139
|
+
const opening = a.jsonlPath ? lastUserTurn(a.jsonlPath) : undefined;
|
|
3140
|
+
const said = head(opening?.text);
|
|
3141
|
+
const eventId = opening?.env?.eventId;
|
|
3142
|
+
const r = openTurnsOf(a.target, a.sessionId).find((t) => eventId ? t.eventId === eventId : !!said && head(t.userQuery) === said);
|
|
3143
|
+
a.activeEventId = eventId;
|
|
3010
3144
|
if (!r)
|
|
3011
3145
|
return false;
|
|
3012
3146
|
a.channelSeeded = true;
|
|
@@ -3016,7 +3150,6 @@ export const startMirror = (deps) => {
|
|
|
3016
3150
|
a.channel = r.channel === undefined || r.channel === baseOfKey(a.target) ? undefined : r.channel;
|
|
3017
3151
|
a.speaker = r.speaker;
|
|
3018
3152
|
a.convFrom = r.from;
|
|
3019
|
-
a.activePeerTurn = r.from?.kind === "peer" ? r.from.turn : undefined;
|
|
3020
3153
|
a.replyTo = publicPeer(r.from);
|
|
3021
3154
|
a.turnFromChat = a.turnFromChat ?? true;
|
|
3022
3155
|
a.briefTurnId = r.id;
|
|
@@ -3052,6 +3185,8 @@ export const startMirror = (deps) => {
|
|
|
3052
3185
|
const from = fresh ?? a.convFrom;
|
|
3053
3186
|
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: query || opened || undefined, origin: consumeOrigin(a), from, ...channelFields(a), ...(query && at ? { createdAt: at } : {}) });
|
|
3054
3187
|
a.briefTurnId = turnId;
|
|
3188
|
+
if (a.activeEventId)
|
|
3189
|
+
deps.events?.bind(a.activeEventId, turnId);
|
|
3055
3190
|
a.briefBubble = undefined;
|
|
3056
3191
|
a.briefIsSlash = false;
|
|
3057
3192
|
a.briefHadTool = false;
|
|
@@ -3094,28 +3229,41 @@ export const startMirror = (deps) => {
|
|
|
3094
3229
|
return;
|
|
3095
3230
|
if (!body.trim())
|
|
3096
3231
|
return;
|
|
3097
|
-
if (isDuplicateConclusion(a, body)) {
|
|
3232
|
+
if (!a.activeEventId && isDuplicateConclusion(a, body)) {
|
|
3098
3233
|
a.briefConcluded = true; // 本轮已"定稿", 只是不投递
|
|
3099
3234
|
log.info({ sessionId: a.sessionId, turnId, epoch: a.queryEpoch ?? 0 }, "brief: duplicate conclusion suppressed");
|
|
3100
3235
|
return;
|
|
3101
3236
|
}
|
|
3237
|
+
const event = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
|
|
3238
|
+
if (event)
|
|
3239
|
+
deps.events.result(event.id, body, finalId(event.id, event.channel, body), turnId);
|
|
3240
|
+
const gated = !!event && !eventFinalGate(event);
|
|
3241
|
+
if (!turnSilent(a) && !gated) {
|
|
3242
|
+
if (event)
|
|
3243
|
+
recoverEventFinal(deps.events.get(event.id), a.briefBubble);
|
|
3244
|
+
else
|
|
3245
|
+
queueFinal(a.target, turnId, a.channel ?? baseOfKey(a.target), body, undefined, a.briefBubble);
|
|
3246
|
+
}
|
|
3247
|
+
else if (gated && !turnSilent(a)) {
|
|
3248
|
+
// 链式收口 (eventFinalGate: 子活未落定) 延后的是 closing 的释放, 不该扣住气泡:
|
|
3249
|
+
// queueFinal 没跑 = 没人收气泡, 它会定格在最后一行 CoT 进度上 (看着像卡在 tool
|
|
3250
|
+
// use), hardTimer 到点的空 finish 又被 chat-gate 当 empty push 丢掉。气泡还在
|
|
3251
|
+
// stream 窗口内就把终句刷进气泡 (`链接 正文` 覆盖, 与正常收口同形); 气泡已收 /
|
|
3252
|
+
// 过期 / 终句超单条上限则 standalone 展示终句。
|
|
3253
|
+
const b = a.briefBubble;
|
|
3254
|
+
const text = stripForChat(body); // 同 queueFinal: 群气泡不带收口标记 / ARTIFACT 行
|
|
3255
|
+
const fitsOneChunk = splitMarkdown(text, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET)).length === 1;
|
|
3256
|
+
if (b && !b.done && fitsOneChunk && Date.now() < (b.expires ?? 0))
|
|
3257
|
+
void finishBriefBubble(a, text);
|
|
3258
|
+
else if (text.trim())
|
|
3259
|
+
sendStandalone(a, text);
|
|
3260
|
+
}
|
|
3261
|
+
// The durable result/outbox must exist before this in-memory dedup bit advances.
|
|
3102
3262
|
a.briefConcluded = true;
|
|
3103
3263
|
a.lastConcludedBody = body;
|
|
3104
3264
|
a.lastConcludedEpoch = a.queryEpoch ?? 0;
|
|
3105
3265
|
a.lastConcludedAt = Date.now();
|
|
3106
|
-
|
|
3107
|
-
log.info({ target: a.target, turnId }, "answer transferred; source conclusion suppressed");
|
|
3108
|
-
}
|
|
3109
|
-
else if (a.briefBubble && !a.briefBubble.done) {
|
|
3110
|
-
void finishBriefBubble(a, briefLine(a, turnId, linkTags(a.target, body)), true);
|
|
3111
|
-
}
|
|
3112
|
-
else if (turnSilent(a)) {
|
|
3113
|
-
// CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
|
|
3114
|
-
log.info({ sessionId: a.sessionId, turnId }, "brief: CLI-origin conclusion kept out of chat");
|
|
3115
|
-
}
|
|
3116
|
-
else {
|
|
3117
|
-
sendStandalone(a, body); // 详情链接由 withLinkedTag 统一前缀, 不在这里拼
|
|
3118
|
-
}
|
|
3266
|
+
clearCot(a);
|
|
3119
3267
|
// 只保留当前 turn: 其余仍开着的 turn 记录是漏收的 close, 一并扫掉。
|
|
3120
3268
|
recordCloseOpenTurns({ target: a.target, sessionId: a.sessionId, exceptIds: [turnId] });
|
|
3121
3269
|
};
|
|
@@ -3148,6 +3296,8 @@ export const startMirror = (deps) => {
|
|
|
3148
3296
|
voidTurnAgents(a);
|
|
3149
3297
|
closeSubagentTurns(a); // 父 turn 收口 = 它派出的 subagent 都已结束
|
|
3150
3298
|
clearCot(a);
|
|
3299
|
+
// 它闲下来了: 收件箱里等它闲的回执 / 派活此刻就该贴, 不等下一拍定时泵。
|
|
3300
|
+
void deps.events?.pump();
|
|
3151
3301
|
};
|
|
3152
3302
|
// ── Subagent turns ─────────────────────────────────────────────────
|
|
3153
3303
|
// Task/Agent 工具派出的子 agent 在自己的转录文件里跑 (主 jsonl 只有派发与最终
|
|
@@ -3236,10 +3386,10 @@ export const startMirror = (deps) => {
|
|
|
3236
3386
|
closeSubagentRun(a, agentId);
|
|
3237
3387
|
return;
|
|
3238
3388
|
}
|
|
3239
|
-
// brief 气泡的实时进度: 静默窗 (
|
|
3389
|
+
// brief 气泡的实时进度: 静默窗 (新 pane 未注入) 不推;
|
|
3240
3390
|
// updateBriefProgress 自身会检查气泡是否仍开。final text 之后的 end 会很快
|
|
3241
3391
|
// 收口气泡, 进度行不会残留。
|
|
3242
|
-
if (a.muteUntilInject
|
|
3392
|
+
if (a.muteUntilInject)
|
|
3243
3393
|
return;
|
|
3244
3394
|
const activity = item.kind === "tool_use"
|
|
3245
3395
|
? cotToolLabel(item.calls)
|
|
@@ -3341,7 +3491,7 @@ export const startMirror = (deps) => {
|
|
|
3341
3491
|
const now = Date.now();
|
|
3342
3492
|
if (item.kind === "tool_use") {
|
|
3343
3493
|
markBriefTool(a);
|
|
3344
|
-
|
|
3494
|
+
updateBriefToolProgress(a, cotToolLabel(item.calls));
|
|
3345
3495
|
for (const c of item.calls) {
|
|
3346
3496
|
// 也走单卡 detail record — turn 页里 tool_use 段可点开链到独立详情 (以后有需要时)。
|
|
3347
3497
|
if (c.toolUseId) {
|
|
@@ -3382,6 +3532,8 @@ export const startMirror = (deps) => {
|
|
|
3382
3532
|
return;
|
|
3383
3533
|
}
|
|
3384
3534
|
if (item.kind === "turn_end") {
|
|
3535
|
+
if (a.activeEventId && !item.soft)
|
|
3536
|
+
deps.events?.finished(a.activeEventId);
|
|
3385
3537
|
closeBriefTurn(a);
|
|
3386
3538
|
return;
|
|
3387
3539
|
}
|
|
@@ -3529,70 +3681,15 @@ export const startMirror = (deps) => {
|
|
|
3529
3681
|
if (a.liveStream && !a.liveStream.closed)
|
|
3530
3682
|
void finalizeStream(a, a.liveStream);
|
|
3531
3683
|
};
|
|
3532
|
-
// End the keepalive swallow — the ping turn is over (turn_end / genuine
|
|
3533
|
-
// user line / inject failure), so real items flow through onItem again.
|
|
3534
|
-
// Clears BOTH the timer window and the content flag.
|
|
3535
|
-
const endKeepaliveSwallow = (a) => {
|
|
3536
|
-
if (a.keepaliveQuiet) {
|
|
3537
|
-
clearTimeout(a.keepaliveQuiet);
|
|
3538
|
-
a.keepaliveQuiet = undefined;
|
|
3539
|
-
}
|
|
3540
|
-
if (a.keepaliveContentTimer) {
|
|
3541
|
-
clearTimeout(a.keepaliveContentTimer);
|
|
3542
|
-
a.keepaliveContentTimer = undefined;
|
|
3543
|
-
}
|
|
3544
|
-
a.keepaliveByContent = undefined;
|
|
3545
|
-
};
|
|
3546
|
-
// Content-based swallow entry: the tailed user line IS a configured ping.
|
|
3547
|
-
// Belt to the inject-time timer window — this one keys on the user message
|
|
3548
|
-
// itself, so it holds across daemon reloads, tail replays and replies
|
|
3549
|
-
// slower than the 60s fail-safe. Reuses the detail turn the timer flow may
|
|
3550
|
-
// have opened (fireKeepalive skips opening when this got there first).
|
|
3551
|
-
const KEEPALIVE_CONTENT_FAILSAFE_MS = 5 * 60_000;
|
|
3552
|
-
const PONG_RE = /pong/i;
|
|
3553
|
-
const beginKeepaliveByContent = (a, text) => {
|
|
3554
|
-
a.keepaliveByContent = true;
|
|
3555
|
-
if (a.keepaliveContentTimer)
|
|
3556
|
-
clearTimeout(a.keepaliveContentTimer);
|
|
3557
|
-
// Fail-safe: a ping turn whose reply never emits turn_end (crash/hang)
|
|
3558
|
-
// must not mute a later real turn forever.
|
|
3559
|
-
a.keepaliveContentTimer = setTimeout(() => {
|
|
3560
|
-
a.keepaliveContentTimer = undefined;
|
|
3561
|
-
a.keepaliveByContent = undefined;
|
|
3562
|
-
}, KEEPALIVE_CONTENT_FAILSAFE_MS);
|
|
3563
|
-
if (!a.keepaliveTurnId) {
|
|
3564
|
-
const turnId = newTurnId();
|
|
3565
|
-
a.keepaliveTurnId = turnId;
|
|
3566
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
|
|
3567
|
-
}
|
|
3568
|
-
};
|
|
3569
|
-
// A keepalive ping turn is swallowed wholesale — the reply content doesn't
|
|
3570
|
-
// matter (model may add extra commentary beyond "pong", that's fine).
|
|
3571
3684
|
const onItem = (a, item) => {
|
|
3572
3685
|
// 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
|
|
3573
3686
|
if (a.muteUntilInject)
|
|
3574
3687
|
return;
|
|
3575
|
-
// Keepalive ping turns are cache-warmers: swallow every item from every
|
|
3576
|
-
// WeCom path so the ping/pong never reaches chat. Two triggers, either
|
|
3577
|
-
// suffices — the timer window opened at inject (keepaliveQuiet) OR the
|
|
3578
|
-
// user line itself matching a configured ping (keepaliveByContent; the
|
|
3579
|
-
// reload/replay/slow-reply-proof one). The REAL exchange is recorded
|
|
3580
|
-
// into its chat-detail turn — the actual assistant reply, the tool calls
|
|
3581
|
-
// if any, and the usage — so the detail page shows the genuine heartbeat.
|
|
3582
|
-
if (item.kind === "keepalive_start") {
|
|
3583
|
-
beginKeepaliveByContent(a, item.body);
|
|
3584
|
-
return;
|
|
3585
|
-
}
|
|
3586
|
-
// A genuine user line (not our inject, not a ping) is a hard turn
|
|
3587
|
-
// boundary — any keepalive swallow still holding never got its turn_end
|
|
3588
|
-
// (crashed reply). Release BEFORE the swallow gate so this real line (and
|
|
3589
|
-
// its reply) mirror normally instead of being eaten by the stale swallow.
|
|
3590
3688
|
if (item.kind === "user_text") {
|
|
3591
3689
|
// 回显去重只认 60s 内的注入: 排在长轮后面的那一行露面时已经过期, 会被当成人在 CLI 里敲的。
|
|
3592
3690
|
// 印章认得出就仍是那次注入 —— 不回显, 也不算 CLI 轮。
|
|
3593
|
-
if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts))
|
|
3691
|
+
if (bindLine(a, item.body.replace(/^> ?/gm, ""), item.ts, item.eventId))
|
|
3594
3692
|
return;
|
|
3595
|
-
endKeepaliveSwallow(a);
|
|
3596
3693
|
// 对话边界 = 旧 turn 清算。上一 turn 派发而未返回的前台 Agent/Task 到此作废,
|
|
3597
3694
|
// 清掉防残留阻塞新 turn 的软收口 (guard 的 defer 判断依据)。
|
|
3598
3695
|
voidTurnAgents(a);
|
|
@@ -3602,22 +3699,22 @@ export const startMirror = (deps) => {
|
|
|
3602
3699
|
// 不决定详情页记不记 —— 聊天不再下发 CLI 轮之后, 详情页是唯一的落点。
|
|
3603
3700
|
a.pendingBriefQuery = item.body;
|
|
3604
3701
|
a.pendingBriefAt = item.ts;
|
|
3605
|
-
a.activePeerTurn = undefined;
|
|
3606
3702
|
a.pendingFrom = undefined;
|
|
3607
3703
|
a.pendingChannel = undefined;
|
|
3704
|
+
a.activeEventId = item.eventId;
|
|
3608
3705
|
a.pendingFromCli = true;
|
|
3609
3706
|
if (!cfg.wrc.mirror.brief)
|
|
3610
3707
|
consumeChannel(a, true, undefined);
|
|
3611
3708
|
}
|
|
3612
3709
|
if (item.kind === "user_inject") {
|
|
3613
|
-
interject(a, item.said, item.ts);
|
|
3710
|
+
interject(a, item.said, item.ts, item.eventId);
|
|
3614
3711
|
return;
|
|
3615
3712
|
}
|
|
3616
3713
|
// 还有一轮等着懒建 (peer / graph 注入先记了 pendingBriefQuery) 就换掉它; 否则这句话
|
|
3617
3714
|
// 就是已开好的那一轮 (IM 侧说的, startBriefTurn 在注入前建好)。
|
|
3618
3715
|
if (item.kind === "user_query") {
|
|
3619
3716
|
// 同伴 / 定时 / graph 注入的那一行: 问话就是印章里的原话 (不带信封)。
|
|
3620
|
-
const sealed = bindLine(a, item.said, item.ts);
|
|
3717
|
+
const sealed = bindLine(a, item.said, item.ts, item.eventId);
|
|
3621
3718
|
if (sealed && !sealed.turn)
|
|
3622
3719
|
return;
|
|
3623
3720
|
if (a.pendingBriefQuery !== undefined || !a.briefTurnId) {
|
|
@@ -3629,46 +3726,9 @@ export const startMirror = (deps) => {
|
|
|
3629
3726
|
recordTurnQuery(a.briefTurnId, item.body, (prev) => cleanUserText(prev ?? "") === item.said);
|
|
3630
3727
|
return;
|
|
3631
3728
|
}
|
|
3632
|
-
// 后台派发的结束信号 —— 它不是 function_call_result (那只是 spawn 句柄)
|
|
3633
|
-
// keepalive 门之前: 吞没窗口里到达也必须销账, 否则账本永久悬空。
|
|
3729
|
+
// 后台派发的结束信号 —— 它不是 function_call_result (那只是 spawn 句柄)。
|
|
3634
3730
|
if (item.kind === "skill_output" && item.agentDone)
|
|
3635
3731
|
clearBackgroundAgent(a, item.agentDone);
|
|
3636
|
-
// Reply-side belt: a "pong" arriving with NO swallow holding. Both triggers
|
|
3637
|
-
// above live in memory, and the tail re-attaches at EOF — a reload between
|
|
3638
|
-
// the ping and its reply forgets the ping was ever sent, and the pong opens
|
|
3639
|
-
// a chat turn of its own. So ask the transcript: if the last thing said to
|
|
3640
|
-
// the model is a keepalive ping, this text is its pong — swallow from here
|
|
3641
|
-
// to the turn_end, recorded as the same marked detail turn. Gated on the
|
|
3642
|
-
// transcript, not on the word alone: a real answer that mentions pong has a
|
|
3643
|
-
// real question in front of it.
|
|
3644
|
-
if (item.kind === "text" && !a.keepaliveQuiet && !a.keepaliveByContent && PONG_RE.test(item.body)) {
|
|
3645
|
-
const ping = openKeepalivePing(a.jsonlPath, pingSigs);
|
|
3646
|
-
if (ping)
|
|
3647
|
-
beginKeepaliveByContent(a, ping);
|
|
3648
|
-
}
|
|
3649
|
-
if (a.keepaliveQuiet || a.keepaliveByContent) {
|
|
3650
|
-
const id = a.keepaliveTurnId;
|
|
3651
|
-
if (id) {
|
|
3652
|
-
const now = Date.now();
|
|
3653
|
-
if (item.kind === "text") {
|
|
3654
|
-
recordTurnItem(id, { t: "text", body: item.body, ts: now, final: item.final === true });
|
|
3655
|
-
}
|
|
3656
|
-
else if (item.kind === "tool_result") {
|
|
3657
|
-
recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
|
|
3658
|
-
}
|
|
3659
|
-
else if (item.kind === "turn_usage") {
|
|
3660
|
-
recordTurnUsage(id, { model: item.model, effort: item.effort, messageId: item.messageId, usage: item.usage });
|
|
3661
|
-
}
|
|
3662
|
-
else if (item.kind === "turn_end") {
|
|
3663
|
-
recordTurnClose(id);
|
|
3664
|
-
a.keepaliveTurnId = undefined;
|
|
3665
|
-
}
|
|
3666
|
-
}
|
|
3667
|
-
if (item.kind === "turn_end") {
|
|
3668
|
-
endKeepaliveSwallow(a);
|
|
3669
|
-
}
|
|
3670
|
-
return;
|
|
3671
|
-
}
|
|
3672
3732
|
// 父 agent 自己的新产出到达 = 上一条"消息写完了"并不代表这一轮结束 → 撤销待确认
|
|
3673
3733
|
// 的软收口。硬信号 (end_turn / turn_duration) 的后端永远不会走到这里。
|
|
3674
3734
|
//
|
|
@@ -3692,7 +3752,6 @@ export const startMirror = (deps) => {
|
|
|
3692
3752
|
// 的配置下, 人在 CLI 敲的那一行就这么原样回显进了群, 正好破了 chatOriginOnly。
|
|
3693
3753
|
if (item.kind === "user_text" && (item.quiet || cfg.wrc.mirror.brief))
|
|
3694
3754
|
return;
|
|
3695
|
-
updateAnswerProgress(a, item);
|
|
3696
3755
|
// thinking 唯一的出口是 brief 气泡的 CoT 进度行。必须在这里就吃掉 —— 漏到下面任何
|
|
3697
3756
|
// 一条通道 (deferred buf → renderBuf / standalone / stream append) 都会把它写进正文,
|
|
3698
3757
|
// 那就变回已下线的 thinkStyle 了。
|
|
@@ -3840,6 +3899,19 @@ export const startMirror = (deps) => {
|
|
|
3840
3899
|
}
|
|
3841
3900
|
}
|
|
3842
3901
|
}
|
|
3902
|
+
if (!cfg.wrc.mirror.brief && item.kind === "text" && item.final !== false && a.activeEventId && deps.events) {
|
|
3903
|
+
const e = deps.events.get(a.activeEventId);
|
|
3904
|
+
if (e) {
|
|
3905
|
+
// Soft final text waits for transcript/idle evidence; it must not escape through the legacy stream.
|
|
3906
|
+
if (item.final !== true)
|
|
3907
|
+
return;
|
|
3908
|
+
const turn = a.liveStream?.turnId ?? e.id;
|
|
3909
|
+
deps.events.result(e.id, item.body, finalId(e.id, e.channel, item.body), turn);
|
|
3910
|
+
if (eventFinalGate(e))
|
|
3911
|
+
recoverEventFinal(deps.events.get(e.id));
|
|
3912
|
+
return;
|
|
3913
|
+
}
|
|
3914
|
+
}
|
|
3843
3915
|
if (!cfg.wrc.mirror.brief && isPrivate(a))
|
|
3844
3916
|
return;
|
|
3845
3917
|
// /goal mode overrides everything below: the session-scoped Stop hook
|
|
@@ -4003,7 +4075,7 @@ export const startMirror = (deps) => {
|
|
|
4003
4075
|
const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
|
|
4004
4076
|
? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
|
|
4005
4077
|
: "";
|
|
4006
|
-
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, effort, pendingCwd
|
|
4078
|
+
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, effort, pendingCwd }) => {
|
|
4007
4079
|
const target = resolveTarget(targetOverride);
|
|
4008
4080
|
if (!target)
|
|
4009
4081
|
return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
|
|
@@ -4034,9 +4106,6 @@ export const startMirror = (deps) => {
|
|
|
4034
4106
|
// onto an already-running pane doesn't, so it keeps what was recorded.
|
|
4035
4107
|
const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
|
|
4036
4108
|
const carryEffort = effort !== undefined ? effort.trim() : (prevByTarget?.effort ?? deps.store.get(target)?.effort ?? "");
|
|
4037
|
-
// Same carry rule again: an explicit spawn-time choice wins, otherwise this
|
|
4038
|
-
// wizard's existing policy (in-memory, or on disk across a reload) sticks.
|
|
4039
|
-
const carryKeepaliveDisabled = keepaliveDisabled !== undefined ? keepaliveDisabled : (prevByTarget?.keepaliveDisabled ?? deps.store.get(target)?.keepaliveDisabled ?? false);
|
|
4040
4109
|
if (prevByTarget)
|
|
4041
4110
|
detach(prevByTarget, "target reassigned");
|
|
4042
4111
|
// Build the attachment first so the tail's onItem closure can capture it.
|
|
@@ -4053,7 +4122,6 @@ export const startMirror = (deps) => {
|
|
|
4053
4122
|
pendingCwd: carryPending,
|
|
4054
4123
|
model: carryModel,
|
|
4055
4124
|
effort: carryEffort,
|
|
4056
|
-
keepaliveDisabled: carryKeepaliveDisabled,
|
|
4057
4125
|
tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
|
|
4058
4126
|
standalonePending: Promise.resolve(),
|
|
4059
4127
|
recentToolSigs: new Map(),
|
|
@@ -4068,7 +4136,6 @@ export const startMirror = (deps) => {
|
|
|
4068
4136
|
toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
|
|
4069
4137
|
isOwnInject,
|
|
4070
4138
|
isOwnAssistantSend,
|
|
4071
|
-
isKeepalivePing,
|
|
4072
4139
|
onItem: (item) => onItem(a, item),
|
|
4073
4140
|
onCompact: () => contextCut(a.target, "compact"),
|
|
4074
4141
|
detailUrlFor,
|
|
@@ -4081,9 +4148,6 @@ export const startMirror = (deps) => {
|
|
|
4081
4148
|
startSubagentsFor(a);
|
|
4082
4149
|
bySessionId.set(sessionId, a);
|
|
4083
4150
|
byTarget.set(target, a);
|
|
4084
|
-
// Preserve a persisted `/stop` pause across the re-attach: restore rebuilds the
|
|
4085
|
-
// record here, and dropping keepaliveOff would resurrect a session the user
|
|
4086
|
-
// explicitly quieted. restoreFromStore re-hydrates the in-memory flags below.
|
|
4087
4151
|
const prevRec = deps.store.get(target);
|
|
4088
4152
|
deps.store.set(target, {
|
|
4089
4153
|
sessionId,
|
|
@@ -4095,9 +4159,6 @@ export const startMirror = (deps) => {
|
|
|
4095
4159
|
effort: a.effort || undefined,
|
|
4096
4160
|
pendingCwd: a.pendingCwd || undefined,
|
|
4097
4161
|
cwdConfirmed: prevRec?.cwdConfirmed,
|
|
4098
|
-
keepaliveOff: prevRec?.keepaliveOff,
|
|
4099
|
-
keepaliveOffAt: prevRec?.keepaliveOffAt,
|
|
4100
|
-
keepaliveDisabled: carryKeepaliveDisabled || undefined,
|
|
4101
4162
|
});
|
|
4102
4163
|
log.info({ sessionId, jsonlPath, target, tmuxSession: a.tmuxSession, runningCwd: a.runningCwd, pendingCwd: a.pendingCwd, mirrors: bySessionId.size }, "mirror attached");
|
|
4103
4164
|
return { ok: true, sessionId, jsonlPath, target };
|
|
@@ -4110,16 +4171,14 @@ export const startMirror = (deps) => {
|
|
|
4110
4171
|
// the pane is alive — and tmux pane ids monotonically increment within a
|
|
4111
4172
|
// server lifetime, so a freed id will not be silently reused. We don't need
|
|
4112
4173
|
// the session name, which matters because some attaches carry only a pane id.
|
|
4113
|
-
|
|
4114
|
-
|
|
4115
|
-
|
|
4116
|
-
|
|
4117
|
-
|
|
4118
|
-
};
|
|
4119
|
-
// 注入前的存活判定要多问一句「里面还有 AI 会话吗」: pane 在、claude 却退回了 shell,
|
|
4174
|
+
// 布尔形式留给只问「这个 pane 还在不在」的调用点 (submitPane / 忙闲判定): 三态里的
|
|
4175
|
+
// `unknown` 仍旧是 false —— 它们不动会话, 判错没有副作用。决定要不要重生 pane 的地方
|
|
4176
|
+
// 必须自己看 `probePane` 的三态, 见 injectText / inject job。
|
|
4177
|
+
const tmuxPaneAlive = async (paneId) => (await paneLiveness(paneId)) === "alive";
|
|
4178
|
+
// 注入前的存活判定要多问一句「里面还有 AI 会话吗」: pane 在、CLI 却退回了 shell,
|
|
4120
4179
|
// 照旧往里贴就是把用户的话当 shell 命令跑。判否即走 respawn (旧 pane 不杀 —— 它可能是
|
|
4121
4180
|
// 用户自己的终端, 经 switch 绑进来的)。
|
|
4122
|
-
const paneUsable = async (paneId, jsonlPath) => (await
|
|
4181
|
+
const paneUsable = async (paneId, jsonlPath) => (await probePane(paneId, jsonlPath)).state === "session";
|
|
4123
4182
|
// Re-attach a stored binding for `principal`. Returns the resulting state, or
|
|
4124
4183
|
// undefined if the on-disk transcript is gone (in which case the entry is
|
|
4125
4184
|
// dropped so the next inbound flows through /new auto-spawn).
|
|
@@ -4134,6 +4193,9 @@ export const startMirror = (deps) => {
|
|
|
4134
4193
|
// 文件。同一个 cwd 下别的 target 的迁移 / 自愈 / 分叉跟随也在扫这个目录里「新出现的
|
|
4135
4194
|
// jsonl」, 不挡住的话它会先一步把别人的新会话认走, 正主 attach 时反撞 "already bound"。
|
|
4136
4195
|
const inFlightSids = new Set();
|
|
4196
|
+
// 已 spawn、还没 attach 的 pane: 分身要先说开场白、等分叉文件才能绑定, 这段里它在
|
|
4197
|
+
// tmux 里活着却不在任何绑定上 —— 孤儿清扫必须认得它, 不能只靠进程年龄宽限。
|
|
4198
|
+
const spawningPanes = new Set();
|
|
4137
4199
|
const sidsClaimedByOthers = (principal) => {
|
|
4138
4200
|
const s = new Set(inFlightSids);
|
|
4139
4201
|
for (const [k, v] of Object.entries(deps.store.all()))
|
|
@@ -4261,25 +4323,10 @@ export const startMirror = (deps) => {
|
|
|
4261
4323
|
log.warn({ principal, reason: r.reason }, "mirror restore: re-attach failed");
|
|
4262
4324
|
return undefined;
|
|
4263
4325
|
}
|
|
4264
|
-
// Re-hydrate the `/stop` pause so a reload doesn't resume pinging a quieted
|
|
4265
|
-
// session (attach preserved it on disk; this puts it back in memory).
|
|
4266
|
-
// keepaliveDisabled is carried by attach() itself (same rule as `model`);
|
|
4267
|
-
// only the transient `/stop` pause needs manual re-hydration here.
|
|
4268
4326
|
const restored = byTarget.get(principal);
|
|
4269
|
-
if (restored && rec.keepaliveOff) {
|
|
4270
|
-
restored.keepaliveOff = true;
|
|
4271
|
-
restored.keepaliveOffAt = rec.keepaliveOffAt;
|
|
4272
|
-
}
|
|
4273
4327
|
log.info({ principal, sessionId: rec.sessionId, livePane: livePane || "(spawn-mode)" }, "mirror restored from store");
|
|
4274
4328
|
return restored;
|
|
4275
4329
|
};
|
|
4276
|
-
// Write the live `/stop` pause state through to the store (merging, not
|
|
4277
|
-
// clobbering, the rest of the record) so it survives a daemon reload.
|
|
4278
|
-
const persistPause = (a) => {
|
|
4279
|
-
const rec = deps.store.get(a.target);
|
|
4280
|
-
if (rec)
|
|
4281
|
-
deps.store.set(a.target, { ...rec, keepaliveOff: a.keepaliveOff, keepaliveOffAt: a.keepaliveOffAt, keepaliveDisabled: a.keepaliveDisabled });
|
|
4282
|
-
};
|
|
4283
4330
|
// We re-attach lazily on demand too (see restoreFromStore in dispatch /
|
|
4284
4331
|
// hasMirrorTarget), but eager boot-restore makes outbound (tail → push) work
|
|
4285
4332
|
// even before any inbound arrives — e.g. claude finishes a long-running
|
|
@@ -4488,15 +4535,6 @@ export const startMirror = (deps) => {
|
|
|
4488
4535
|
markCut(a.target, newSessionId, cleared ? "clear" : "switch");
|
|
4489
4536
|
if (cleared)
|
|
4490
4537
|
contextCut(a.target, "clear");
|
|
4491
|
-
if (cleared) {
|
|
4492
|
-
// 真清空 = 缓存里已无任何值得保温的内容。像 /stop 一样暂停保活 —— 与
|
|
4493
|
-
// dispatch 里 WeCom 注入 /clear 的暂停对齐;TUI 手打 /clear 只走这个漏斗,
|
|
4494
|
-
// 不在这里停下的话旧时钟会继续 ping 一个空上下文。busy-resume(过 grace)
|
|
4495
|
-
// 或真实 inbound 解除。下方 store.set 一并落盘。
|
|
4496
|
-
a.keepaliveOff = true;
|
|
4497
|
-
a.keepaliveOffAt = Date.now();
|
|
4498
|
-
a.keepalive = undefined;
|
|
4499
|
-
}
|
|
4500
4538
|
a.tail.stop();
|
|
4501
4539
|
bySessionId.delete(oldSessionId);
|
|
4502
4540
|
a.sessionId = newSessionId;
|
|
@@ -4512,7 +4550,6 @@ export const startMirror = (deps) => {
|
|
|
4512
4550
|
toolUseInlineMaxChars: cfg.wrc.mirror.toolUseInlineMaxChars,
|
|
4513
4551
|
isOwnInject,
|
|
4514
4552
|
isOwnAssistantSend,
|
|
4515
|
-
isKeepalivePing,
|
|
4516
4553
|
onItem: (item) => onItem(a, item),
|
|
4517
4554
|
onCompact: () => contextCut(a.target, "compact"),
|
|
4518
4555
|
detailUrlFor,
|
|
@@ -4532,10 +4569,6 @@ export const startMirror = (deps) => {
|
|
|
4532
4569
|
tmuxPane: a.tmuxPane || undefined,
|
|
4533
4570
|
cwd: a.runningCwd || undefined,
|
|
4534
4571
|
pendingCwd: a.pendingCwd || undefined,
|
|
4535
|
-
// store.set 是整记录替换 —— 必须带上暂停态,否则迁移会把 dispatch 刚落盘
|
|
4536
|
-
// 的 /clear 暂停(或之前的 /stop 暂停)从盘上抹掉,reload 后保活复活。
|
|
4537
|
-
keepaliveOff: a.keepaliveOff || undefined,
|
|
4538
|
-
keepaliveOffAt: a.keepaliveOffAt || undefined,
|
|
4539
4572
|
});
|
|
4540
4573
|
log.info({ target: a.target, oldSessionId, newSessionId, oldJsonlPath, newJsonlPath, startOffset }, "mirror migrated session");
|
|
4541
4574
|
};
|
|
@@ -4769,6 +4802,10 @@ export const startMirror = (deps) => {
|
|
|
4769
4802
|
// different project dir whose live session has a different sid, migrate onto
|
|
4770
4803
|
// it. Tail from EOF (undefined startOffset): the worktree session already
|
|
4771
4804
|
// carries full history we don't want to re-dump to WeCom.
|
|
4805
|
+
//
|
|
4806
|
+
// 同目录的轮换 (人在 TUI 里敲 `/clear`、或 CLI 自己换了会话) 走下面同一张表的 sameDir
|
|
4807
|
+
// 分支 —— 目录没变, 但「旧 jsonl 从此没人写」的后果一模一样, 而它比 worktree 常见得多
|
|
4808
|
+
// (人就在 pane 里敲命令)。
|
|
4772
4809
|
let paneDriftTicking = false;
|
|
4773
4810
|
const followPaneDrift = async () => {
|
|
4774
4811
|
if (paneDriftTicking)
|
|
@@ -4804,8 +4841,26 @@ export const startMirror = (deps) => {
|
|
|
4804
4841
|
continue;
|
|
4805
4842
|
}
|
|
4806
4843
|
const paneDir = projectDirFor(a.jsonlPath, expandHome(cwd));
|
|
4807
|
-
|
|
4808
|
-
|
|
4844
|
+
const sameDir = backendForPath(a.jsonlPath).name === "codex" ? expandHome(cwd) === a.runningCwd : paneDir === dirname(a.jsonlPath);
|
|
4845
|
+
if (sameDir) {
|
|
4846
|
+
// 同一个项目目录里的会话轮换: 人在 TUI 里直接敲了 `/clear` (或 CLI 自己换了会话)。
|
|
4847
|
+
// 目录没变, 下面那条按目录判的漂移看不见它, 结果一样是「旧 jsonl 再没人写, 气泡
|
|
4848
|
+
// 永远停在最后一格」, 而每次 reload 的 boot 恢复都会把这份死绑定再恢复一遍。
|
|
4849
|
+
// 判据只认**注册表按 pane 报的会话** —— 只有写 `tmux` 字段的后端有这条路。
|
|
4850
|
+
// codebuddy 没有: 它的注册行里 sessionId 是续接链的根, 换过 sid 后不更新 (实测
|
|
4851
|
+
// pid 84679 报 3d51b76b 而进程实际在写 01a13ae5), 沿进程树只能认出「有活的 CLI」
|
|
4852
|
+
// 而不是「哪份转录」; 而「目录里最新的是谁」在共用 cwd 下会把别人的转录认到自己
|
|
4853
|
+
// 身上。两条都不能用, 所以这里对 codebuddy 什么都不做 —— 认不出就不动。
|
|
4854
|
+
const liveOnPane = sessionOnPane(backendForPath(a.jsonlPath).homeDir, a.tmuxPane);
|
|
4855
|
+
if (!liveOnPane || liveOnPane.sessionId === a.sessionId || sidsClaimedByOthers(a.target).has(liveOnPane.sessionId))
|
|
4856
|
+
continue;
|
|
4857
|
+
// 从 EOF 起读 (不给 startOffset): 轮换之后新文件已经自己长了一段 (轮换那一刻到这一
|
|
4858
|
+
// tick 之间人敲进去的话), 从 0 重读等于把整段会话倒进聊天窗口。补不上接缝,
|
|
4859
|
+
// 但从此往后是通的 —— 比一直不通强。
|
|
4860
|
+
log.info({ target: a.target, pane: a.tmuxPane, from: a.sessionId, to: liveOnPane.sessionId }, "mirror: pane 里的会话轮换 (同目录), 跟随它");
|
|
4861
|
+
migrateAttachment(a, liveOnPane.sessionId, join(dirname(a.jsonlPath), `${liveOnPane.sessionId}.jsonl`));
|
|
4862
|
+
continue;
|
|
4863
|
+
}
|
|
4809
4864
|
const live = liveSessionForCwd(cwd, backendForPath(a.jsonlPath), sidsClaimedByOthers(a.target));
|
|
4810
4865
|
if (!live || live.sessionId === a.sessionId)
|
|
4811
4866
|
continue; // empty dir, or same-sid rename (tail follows it)
|
|
@@ -4946,7 +5001,7 @@ export const startMirror = (deps) => {
|
|
|
4946
5001
|
cfg,
|
|
4947
5002
|
log: log.child({ sub: "new-session", target }),
|
|
4948
5003
|
sessionId: freshSid,
|
|
4949
|
-
windowName:
|
|
5004
|
+
windowName: windowLabel(target, windowName),
|
|
4950
5005
|
cwdOverride: eff,
|
|
4951
5006
|
cli: effCli,
|
|
4952
5007
|
model: effModel,
|
|
@@ -4978,7 +5033,6 @@ export const startMirror = (deps) => {
|
|
|
4978
5033
|
// raw request — a later respawn's fast path then matches on try one.
|
|
4979
5034
|
model: r.model ?? effModel ?? "",
|
|
4980
5035
|
effort: r.effort ?? "",
|
|
4981
|
-
keepaliveDisabled: opts?.keepalive === undefined ? undefined : !opts.keepalive,
|
|
4982
5036
|
});
|
|
4983
5037
|
inFlightSids.delete(freshSid);
|
|
4984
5038
|
if (!att.ok)
|
|
@@ -4992,14 +5046,6 @@ export const startMirror = (deps) => {
|
|
|
4992
5046
|
// 只有换过 pane 才是断点 —— 首次建会话时 prev 为空, 那不是"不连续", 是开局。
|
|
4993
5047
|
if (prev)
|
|
4994
5048
|
markCut(target, spawned.sessionId, "new");
|
|
4995
|
-
// A freshly spawned session is empty — nothing in the cache to keep warm.
|
|
4996
|
-
// Pause keepalive like /stop; the first real turn (WeCom inbound, or the
|
|
4997
|
-
// pane going busy after the resume grace) re-earns the budget. Mirrors
|
|
4998
|
-
// interruptPane so /new can't strand a ping loop on an idle blank session.
|
|
4999
|
-
spawned.keepaliveOff = !opts?.warm;
|
|
5000
|
-
spawned.keepaliveOffAt = Date.now();
|
|
5001
|
-
spawned.keepalive = undefined; // re-anchors cleanly on resume
|
|
5002
|
-
persistPause(spawned);
|
|
5003
5049
|
}
|
|
5004
5050
|
// Clear the chat's pendingCwd on the BASE record too — the queued switch
|
|
5005
5051
|
// has just been consumed by this respawn. Without this, a subsequent /new
|
|
@@ -5089,7 +5135,7 @@ export const startMirror = (deps) => {
|
|
|
5089
5135
|
const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
|
|
5090
5136
|
if (!canInherit) {
|
|
5091
5137
|
const r = await newSession(args.target, args.windowName, args.cli, {
|
|
5092
|
-
model: args.model, effort: args.effort, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true,
|
|
5138
|
+
model: args.model, effort: args.effort, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, guard: args.guard ? () => args.guard("paste") : undefined,
|
|
5093
5139
|
});
|
|
5094
5140
|
return { ...r, inherited: false };
|
|
5095
5141
|
}
|
|
@@ -5108,7 +5154,7 @@ export const startMirror = (deps) => {
|
|
|
5108
5154
|
log: lg,
|
|
5109
5155
|
resumeSessionId: parent.sessionId,
|
|
5110
5156
|
forkSession: true,
|
|
5111
|
-
windowName: args.
|
|
5157
|
+
windowName: windowLabel(args.target, args.windowName),
|
|
5112
5158
|
cwdOverride: parent.runningCwd,
|
|
5113
5159
|
cli: parentCli,
|
|
5114
5160
|
model,
|
|
@@ -5117,51 +5163,61 @@ export const startMirror = (deps) => {
|
|
|
5117
5163
|
});
|
|
5118
5164
|
if (!r.ok)
|
|
5119
5165
|
return { ok: false, reason: r.reason, inherited: false };
|
|
5120
|
-
//
|
|
5121
|
-
|
|
5122
|
-
|
|
5123
|
-
|
|
5124
|
-
|
|
5125
|
-
|
|
5126
|
-
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5132
|
-
|
|
5133
|
-
|
|
5134
|
-
|
|
5135
|
-
|
|
5136
|
-
|
|
5137
|
-
|
|
5138
|
-
|
|
5139
|
-
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
|
|
5166
|
+
// 开场白 + 等分叉期间 pane 活着却没绑定: 挂上 spawningPanes, 别被孤儿清扫当尸体收掉。
|
|
5167
|
+
const pane = r.tmuxPane ?? "";
|
|
5168
|
+
spawningPanes.add(pane);
|
|
5169
|
+
let att;
|
|
5170
|
+
let fork;
|
|
5171
|
+
try {
|
|
5172
|
+
// 分叉文件不是开机就有的: CLI 在这个 pane **收到第一条消息**的那一刻才把父亲的
|
|
5173
|
+
// transcript 复制成新文件。所以必须先说一句话, 分身才会有自己的 sid —— 这句话
|
|
5174
|
+
// 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」)。
|
|
5175
|
+
const hello = args.bootstrap?.trim()
|
|
5176
|
+
|| "(wezard 分身初始化)用一句话说明: 你是谁、从谁那里继承了哪些上下文、准备接什么活。不要复述这条指令。";
|
|
5177
|
+
rememberInject(hello);
|
|
5178
|
+
const boot = await inject({
|
|
5179
|
+
text: hello, images: [], cfg, log: lg,
|
|
5180
|
+
sessionId: parentCli === "codex" ? r.sessionId : parent.sessionId, jsonlPath: parentCli === "codex" ? r.jsonlPath : parent.jsonlPath,
|
|
5181
|
+
tmuxTarget: r.tmuxPane, freshSpawn: true, guard: args.guard,
|
|
5182
|
+
});
|
|
5183
|
+
if (!boot.ok) {
|
|
5184
|
+
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
5185
|
+
return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
|
|
5186
|
+
}
|
|
5187
|
+
const forked = parentCli === "codex" ? { sessionId: r.sessionId, jsonlPath: r.jsonlPath } : await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
|
|
5188
|
+
homeDir: backendForPath(parent.jsonlPath).homeDir, pane: r.tmuxPane ?? "", parentSid: parent.sessionId,
|
|
5189
|
+
});
|
|
5190
|
+
// 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
|
|
5191
|
+
// 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
|
|
5192
|
+
// 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
|
|
5193
|
+
if (!forked) {
|
|
5194
|
+
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
5195
|
+
return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
|
|
5196
|
+
}
|
|
5197
|
+
fork = forked;
|
|
5198
|
+
if (args.guard && !args.guard("submit")) {
|
|
5199
|
+
inFlightSids.delete(fork.sessionId);
|
|
5200
|
+
// The bootstrap may already have executed; withhold binding, report the
|
|
5201
|
+
// unconfirmed birth to its caller instead of claiming cancellation success.
|
|
5202
|
+
return { ok: false, reason: "fork executed after permission withdrawal; reconcile pane manually", inherited: false };
|
|
5203
|
+
}
|
|
5204
|
+
att = attach({
|
|
5205
|
+
sessionId: fork.sessionId,
|
|
5206
|
+
jsonlPath: fork.jsonlPath,
|
|
5207
|
+
target: args.target,
|
|
5208
|
+
tmuxPane: r.tmuxPane,
|
|
5209
|
+
tmuxSession: r.tmuxSession,
|
|
5210
|
+
cwd: r.cwd,
|
|
5211
|
+
pendingCwd: "",
|
|
5212
|
+
model: r.model ?? model,
|
|
5213
|
+
effort: r.effort ?? "",
|
|
5214
|
+
});
|
|
5215
|
+
// 绑上 (或没绑上) 之后预留就没用了: 绑上的 sid 由 sidsClaimedByOthers 挡住别人。
|
|
5146
5216
|
inFlightSids.delete(fork.sessionId);
|
|
5147
|
-
// The bootstrap may already have executed; withhold binding, report the
|
|
5148
|
-
// unconfirmed birth to its caller instead of claiming cancellation success.
|
|
5149
|
-
return { ok: false, reason: "fork executed after permission withdrawal; reconcile pane manually", inherited: false };
|
|
5150
5217
|
}
|
|
5151
|
-
|
|
5152
|
-
|
|
5153
|
-
|
|
5154
|
-
target: args.target,
|
|
5155
|
-
tmuxPane: r.tmuxPane,
|
|
5156
|
-
tmuxSession: r.tmuxSession,
|
|
5157
|
-
cwd: r.cwd,
|
|
5158
|
-
pendingCwd: "",
|
|
5159
|
-
model: r.model ?? model,
|
|
5160
|
-
effort: r.effort ?? "",
|
|
5161
|
-
keepaliveDisabled: args.keepalive === undefined ? undefined : !args.keepalive,
|
|
5162
|
-
});
|
|
5163
|
-
// 绑上 (或没绑上) 之后预留就没用了: 绑上的 sid 由 sidsClaimedByOthers 挡住别人。
|
|
5164
|
-
inFlightSids.delete(fork.sessionId);
|
|
5218
|
+
finally {
|
|
5219
|
+
spawningPanes.delete(pane);
|
|
5220
|
+
}
|
|
5165
5221
|
if (!att.ok)
|
|
5166
5222
|
return { ok: false, reason: att.reason, inherited: false };
|
|
5167
5223
|
const spawned = byTarget.get(args.target);
|
|
@@ -5169,10 +5225,6 @@ export const startMirror = (deps) => {
|
|
|
5169
5225
|
// muteUntilInject 是给"全新 pane 的 greeting 噪音"准备的; 分身的开场白是正经
|
|
5170
5226
|
// 内容 (rolepage 上它的第一轮), 所以这里不静音 —— 进不进群由下面的频道印章定。
|
|
5171
5227
|
spawned.justSpawned = true;
|
|
5172
|
-
spawned.keepaliveOff = true;
|
|
5173
|
-
spawned.keepaliveOffAt = Date.now();
|
|
5174
|
-
spawned.keepalive = undefined;
|
|
5175
|
-
persistPause(spawned);
|
|
5176
5228
|
// 开场白早在 attach 之前就落进分叉文件了 (tail 从 EOF 起, 读不到那一行): 第一轮懒建时
|
|
5177
5229
|
// 才认得出它是谁派的活。印章赶在 tail 第一次 drain 之前盖上。
|
|
5178
5230
|
if (args.bootstrapFrom) {
|
|
@@ -5402,18 +5454,40 @@ export const startMirror = (deps) => {
|
|
|
5402
5454
|
};
|
|
5403
5455
|
const paneOf = (target) => byTarget.get(target)?.tmuxPane || deps.store.get(target)?.tmuxPane || "";
|
|
5404
5456
|
const jsonlOf = (target) => expandHome(byTarget.get(target)?.jsonlPath || deps.store.get(target)?.jsonlPath || "");
|
|
5405
|
-
|
|
5457
|
+
const inputSessionInfo = (target) => {
|
|
5458
|
+
const pane = paneOf(target);
|
|
5459
|
+
const transcript = jsonlOf(target);
|
|
5460
|
+
if (!pane || !transcript)
|
|
5461
|
+
return undefined;
|
|
5462
|
+
if (backendForPath(transcript).name === "codex") {
|
|
5463
|
+
const session = codexSessionForPane(pane);
|
|
5464
|
+
return session && { sessionId: session.sessionId, jsonlPath: session.jsonlPath };
|
|
5465
|
+
}
|
|
5466
|
+
const live = activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find((s) => s?.pane === pane);
|
|
5467
|
+
return live && { sessionId: live.sessionId, jsonlPath: join(dirname(transcript), `${live.sessionId}.jsonl`) };
|
|
5468
|
+
};
|
|
5469
|
+
// Busy comes from the native lifecycle or an interrupt hint. A dead/unbound pane is
|
|
5406
5470
|
// reported idle, not busy: "nothing is running there" is the truthful answer
|
|
5407
5471
|
// and it keeps a graph step from blocking forever on a session that vanished.
|
|
5408
5472
|
const paneBusy = async (pane) => !!pane && (await tmuxPaneAlive(pane)) && paneIsBusy(await capturePaneTail(pane, 12));
|
|
5409
|
-
const isBusy = (target) =>
|
|
5473
|
+
const isBusy = async (target) => {
|
|
5474
|
+
const pane = paneOf(target);
|
|
5475
|
+
return !!pane && (await tmuxPaneAlive(pane)) && (idleVerdict(target) === false || await paneBusy(pane));
|
|
5476
|
+
};
|
|
5410
5477
|
// 「它这一轮结束了、可以投了」。注册表认得这个 pane 时以它为准: status 在整轮里
|
|
5411
5478
|
// (工具调用之间、PreToolUse 审批 hook 挂着时) 一直是 busy, 只在轮结束那一刻翻成
|
|
5412
5479
|
// idle —— 所以不必抽样 spinner, 也不必连数几次安静来排除工具间隙。idle 却悬着工具
|
|
5413
5480
|
// 调用 = 停在本地审批框上等人点, 不算闲。注册表不认得 (codebuddy / 旧版 CC /
|
|
5414
5481
|
// 会话已退出) 返回 undefined, 交给 pane 裁决。
|
|
5415
5482
|
const idleVerdict = (target) => {
|
|
5483
|
+
const path = jsonlOf(target);
|
|
5416
5484
|
const pane = paneOf(target);
|
|
5485
|
+
if (path && backendForPath(path).name === "codex") {
|
|
5486
|
+
const session = codexSessionForPane(pane);
|
|
5487
|
+
const busy = codexTurnBusy(path, session?.jsonlPath === path ? session.startedAt : 0);
|
|
5488
|
+
if (busy !== undefined)
|
|
5489
|
+
return !busy;
|
|
5490
|
+
}
|
|
5417
5491
|
const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
|
|
5418
5492
|
if (!live)
|
|
5419
5493
|
return undefined;
|
|
@@ -5435,13 +5509,35 @@ export const startMirror = (deps) => {
|
|
|
5435
5509
|
const live = pane ? activeBackends().map((b) => sessionOnPane(b.homeDir, pane)).find(Boolean) : undefined;
|
|
5436
5510
|
return live?.status === "idle" && hanging(target, live.sessionId);
|
|
5437
5511
|
};
|
|
5438
|
-
const idleNow = async (target) =>
|
|
5512
|
+
const idleNow = async (target) => {
|
|
5513
|
+
// An interrupt footer vetoes stale idle records; never strip its dim text.
|
|
5514
|
+
if (await isBusy(target))
|
|
5515
|
+
return false;
|
|
5516
|
+
const registry = idleVerdict(target);
|
|
5517
|
+
if (registry !== undefined)
|
|
5518
|
+
return registry;
|
|
5519
|
+
const pane = paneOf(target);
|
|
5520
|
+
if (!pane || !(await tmuxPaneAlive(pane)))
|
|
5521
|
+
return false;
|
|
5522
|
+
const { cap, box } = await readPaneInput(pane);
|
|
5523
|
+
if (paneIsBusy(cap) || isModalPane(cap).modal)
|
|
5524
|
+
return false;
|
|
5525
|
+
// 注册表说不出 (codebuddy 的注册表没有 status) 时, 转录里这一轮紧跟正文落了轮终记录
|
|
5526
|
+
// (turn-metrics) 就是确定闲了, 不必再认出输入框 —— 只读屏曾把闲了 5 分钟的 owner 一直
|
|
5527
|
+
// 判成不闲, 回执卡在收件箱里直到它下一轮结束。CLI 退回 shell 的 pane 照旧不算。
|
|
5528
|
+
const path = jsonlOf(target);
|
|
5529
|
+
if (path && turnClosed(path))
|
|
5530
|
+
return !(await paneRunsShell(pane));
|
|
5531
|
+
return !!cap.trim() && box.known;
|
|
5532
|
+
};
|
|
5439
5533
|
// 等到它闲下来, 事件驱动: 注册表目录一有写入就重判, 翻成 idle 的那一刻即返回。
|
|
5440
5534
|
// 兜底轮询只防 watch 漏事件 (以及悬着的审批被点掉后 status 没再动的边角)。
|
|
5441
5535
|
const IDLE_SAFETY_POLL_MS = 1000;
|
|
5442
5536
|
const untilIdle = (target, timeoutMs, { aborted = () => false, confirm = 2 } = {}) => {
|
|
5537
|
+
// A vanished pane ends a wait, but must not become eligible for injection.
|
|
5538
|
+
const waiting = async (t) => !!paneOf(t) && (await tmuxPaneAlive(paneOf(t))) && !(await idleNow(t));
|
|
5443
5539
|
if (idleVerdict(target) === undefined)
|
|
5444
|
-
return waitForIdle(target,
|
|
5540
|
+
return waitForIdle(target, waiting, timeoutMs, aborted, { rampMs: 0, confirm });
|
|
5445
5541
|
return new Promise((resolve) => {
|
|
5446
5542
|
let done = false;
|
|
5447
5543
|
const finish = (r) => {
|
|
@@ -5461,8 +5557,7 @@ export const startMirror = (deps) => {
|
|
|
5461
5557
|
finish({ idle: false, reason: "stopped" });
|
|
5462
5558
|
return;
|
|
5463
5559
|
}
|
|
5464
|
-
|
|
5465
|
-
if (v ?? !(await isBusy(target)))
|
|
5560
|
+
if (!(await waiting(target)))
|
|
5466
5561
|
finish({ idle: true });
|
|
5467
5562
|
};
|
|
5468
5563
|
const unwatch = activeBackends().map((b) => watchRegistry(b.homeDir, () => void check()));
|
|
@@ -5473,15 +5568,15 @@ export const startMirror = (deps) => {
|
|
|
5473
5568
|
};
|
|
5474
5569
|
const lastText = (target) => {
|
|
5475
5570
|
const p = jsonlOf(target);
|
|
5476
|
-
return p ? lastAssistantText(p, 4000
|
|
5571
|
+
return p ? lastAssistantText(p, 4000) : "";
|
|
5477
5572
|
};
|
|
5478
5573
|
const replySince = (target, sinceMs = 0) => {
|
|
5479
5574
|
const p = jsonlOf(target);
|
|
5480
|
-
return p ? lastReply(p, sinceMs
|
|
5575
|
+
return p ? lastReply(p, sinceMs) : "";
|
|
5481
5576
|
};
|
|
5482
5577
|
const replyToPeerSince = (target, fromName, sinceMs = 0, turn) => {
|
|
5483
5578
|
const p = jsonlOf(target);
|
|
5484
|
-
return p ? replyToPeerIn(p, fromName, sinceMs,
|
|
5579
|
+
return p ? replyToPeerIn(p, fromName, sinceMs, turn) : undefined;
|
|
5485
5580
|
};
|
|
5486
5581
|
/** 绑着的 pane 还能用 (存在、且不是别人的窗口) —— 不重生、不猜。 */
|
|
5487
5582
|
const paneLiveOf = async (target) => {
|
|
@@ -5498,7 +5593,7 @@ export const startMirror = (deps) => {
|
|
|
5498
5593
|
const quoteCanon = (s) => s.replace(OPEN_QUOTE_URL_RE, " ").replace(/[^\p{L}\p{N}]/gu, "");
|
|
5499
5594
|
const openBubbleBodies = (a) => {
|
|
5500
5595
|
if (a.briefBubble && !a.briefBubble.done && !a.briefConcluded) {
|
|
5501
|
-
return [a.cotLastSent, a.cotText].filter((s) => !!s);
|
|
5596
|
+
return [a.cotLastSent, a.cotText, a.cotTool].filter((s) => !!s);
|
|
5502
5597
|
}
|
|
5503
5598
|
const s = a.liveStream;
|
|
5504
5599
|
return s && !s.closed && !s.dead ? [s.acc, s.lastSent] : [];
|
|
@@ -5546,9 +5641,9 @@ export const startMirror = (deps) => {
|
|
|
5546
5641
|
tmuxPane: pane,
|
|
5547
5642
|
attached: !!a,
|
|
5548
5643
|
paneAlive,
|
|
5549
|
-
busy: paneAlive ?
|
|
5644
|
+
busy: paneAlive ? await isBusy(t) : false,
|
|
5550
5645
|
lastActivity,
|
|
5551
|
-
summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80
|
|
5646
|
+
summary: jsonlPath ? summarizeTail(jsonlPath, 3, 80) : "(未绑定会话)",
|
|
5552
5647
|
self: t === self,
|
|
5553
5648
|
};
|
|
5554
5649
|
};
|
|
@@ -5594,7 +5689,7 @@ export const startMirror = (deps) => {
|
|
|
5594
5689
|
};
|
|
5595
5690
|
// Reading a peer's conversation is a transcript job, not a terminal job: the
|
|
5596
5691
|
// jsonl holds whole role-tagged messages, while a pane capture is ANSI-laden
|
|
5597
|
-
// and clipped at the viewport edge. Text turns only
|
|
5692
|
+
// and clipped at the viewport edge. Text turns only. Only
|
|
5598
5693
|
// `busy` still comes from the pane (the spinner is the one thing a transcript
|
|
5599
5694
|
// cannot say during a long tool call); "where is it stuck" is answered by the
|
|
5600
5695
|
// transcript too — `waiting` = tool calls still hanging on an idle session.
|
|
@@ -5605,237 +5700,16 @@ export const startMirror = (deps) => {
|
|
|
5605
5700
|
return { ok: false, reason: "它还没有 transcript (没接过第一句话) —— send_peer 跟它说一句就有了", busy };
|
|
5606
5701
|
// `n` 数的是来回: 每个来回给问话和它最新的一句 (跑完的是终句, 还在跑的是途中
|
|
5607
5702
|
// 最近说的那句) —— 途中的其余叙述不给, 那是过程不是结论。
|
|
5608
|
-
const rounds = talkRounds(jsonl, n
|
|
5703
|
+
const rounds = talkRounds(jsonl, n).slice(-n);
|
|
5609
5704
|
const dialog = renderDialog(rounds.flatMap((r) => (r.length > 1 ? [r[0], r.at(-1)] : r)), 800, 4000);
|
|
5610
5705
|
const waiting = busy ? [] : openToolUses(jsonl);
|
|
5611
5706
|
return dialog ? { ok: true, dialog, busy, waiting } : { ok: false, reason: "transcript 里还没有对话", busy, waiting };
|
|
5612
5707
|
};
|
|
5613
|
-
//
|
|
5614
|
-
//
|
|
5615
|
-
// subscription) after the start of the last request. A pane that goes idle
|
|
5616
|
-
// (agent parked on a peer, background task running) lets the whole context fall
|
|
5617
|
-
// out of cache — the next real turn then re-writes it. We inject a tiny ping just
|
|
5618
|
-
// before expiry so the model makes one cheap request (cache-read) that slides the
|
|
5619
|
-
// TTL forward. The budget is anchored to the last REAL (non-ping) activity, NOT to
|
|
5620
|
-
// our own pings — so a dead session is never kept warm forever.
|
|
5621
|
-
// After `/stop`, ignore pane-busy as a resume signal for this long — long
|
|
5622
|
-
// enough for an in-flight ping (interrupted by the same /stop's Esc) to settle,
|
|
5623
|
-
// so it can't self-resume the pause. A genuine new turn after this window does.
|
|
5624
|
-
const RESUME_GRACE_MS = 30_000;
|
|
5625
|
-
// A cache untouched this long is not kept warm: on a 1h entry the ping would come
|
|
5626
|
-
// ~59min into the idle stretch, re-reading the whole context to cover a gap the
|
|
5627
|
-
// TTL already covered — warming only pays on short (5min) entries.
|
|
5628
|
-
const KEEPALIVE_MAX_IDLE_MS = 10 * 60_000;
|
|
5629
|
-
// Seed the keepalive clocks from message-turn timestamps (keepaliveStamps), never
|
|
5630
|
-
// from file mtime — non-message lines (file-history-snapshot / ai-title / mode)
|
|
5631
|
-
// bump mtime without being a model turn, which would fake a long-dead session
|
|
5632
|
-
// back to "just active" and pin it in an endless ping loop. lastRealMs stays
|
|
5633
|
-
// anchored on genuine work, so the tick's realIdle guard can finally let it die.
|
|
5634
|
-
const initKeepalive = (jsonlPath, mtime, pingSigs) => {
|
|
5635
|
-
let lastMs = 0;
|
|
5636
|
-
let lastRealMs = 0;
|
|
5637
|
-
// round is seeded from the transcript too: starting at 0 would hand every
|
|
5638
|
-
// daemon reload a fresh budget, and a session idle across N reloads gets
|
|
5639
|
-
// N × rounds pings instead of rounds.
|
|
5640
|
-
let round = 0;
|
|
5641
|
-
try {
|
|
5642
|
-
const s = keepaliveStamps(jsonlPath, pingSigs);
|
|
5643
|
-
lastMs = s.lastMs;
|
|
5644
|
-
lastRealMs = s.lastRealMs;
|
|
5645
|
-
round = s.streak;
|
|
5646
|
-
}
|
|
5647
|
-
catch { /* unreadable tail */ }
|
|
5648
|
-
return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round, settledAt: 0, ttlSec: cacheTtlSec(jsonlPath), cacheAt: cacheTouchMs(jsonlPath) };
|
|
5649
|
-
};
|
|
5650
|
-
const fireKeepalive = async (a, guard) => {
|
|
5651
|
-
if (!guard())
|
|
5652
|
-
return;
|
|
5653
|
-
const kc = cfg.wrc.mirror.keepalive;
|
|
5654
|
-
// The SAME full instruction every round: a bare "ping" used to be sent from round 2 on
|
|
5655
|
-
// (the instruction was already in context), but the model reads a bare "ping"
|
|
5656
|
-
// as an open turn and answers it however it likes — anything other than
|
|
5657
|
-
// "pong" un-swallows the heartbeat into chat and re-anchors the clocks as
|
|
5658
|
-
// real activity. Restating the instruction is a few tokens of cache-write and
|
|
5659
|
-
// buys a deterministic reply. keepaliveStamps matches every form, so none
|
|
5660
|
-
// reads as real activity. (round is incremented below, after the inject lands.)
|
|
5661
|
-
const text = kc.ping;
|
|
5662
|
-
// Suppress the pane→WeCom echo of the ping user line, then swallow the whole
|
|
5663
|
-
// ping turn (reply included). The 60s fail-safe clears the quiet window if
|
|
5664
|
-
// the turn somehow never emits turn_end, so a later real turn is never muted;
|
|
5665
|
-
// it also closes the detail turn so it can't hang open in the chat timeline.
|
|
5666
|
-
const r = await inject({
|
|
5667
|
-
text, images: [], cfg,
|
|
5668
|
-
guard,
|
|
5669
|
-
requireIdle: () => idleVerdict(a.target) === true,
|
|
5670
|
-
beforeInput: () => {
|
|
5671
|
-
// A queued heartbeat must not mute the peer turn that won the lane.
|
|
5672
|
-
// Drain its tail first, then open swallow only at permitted physical input.
|
|
5673
|
-
a.tail.drain();
|
|
5674
|
-
rememberInject(text);
|
|
5675
|
-
a.keepaliveQuiet = setTimeout(() => {
|
|
5676
|
-
a.keepaliveQuiet = undefined;
|
|
5677
|
-
if (a.keepaliveTurnId) {
|
|
5678
|
-
recordTurnClose(a.keepaliveTurnId);
|
|
5679
|
-
a.keepaliveTurnId = undefined;
|
|
5680
|
-
}
|
|
5681
|
-
}, 60_000);
|
|
5682
|
-
},
|
|
5683
|
-
log: log.child({ target: a.target, sessionId: a.sessionId, sub: "keepalive" }),
|
|
5684
|
-
sessionId: a.sessionId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane,
|
|
5685
|
-
});
|
|
5686
|
-
if (!r.ok) {
|
|
5687
|
-
endKeepaliveSwallow(a);
|
|
5688
|
-
if (a.keepalive)
|
|
5689
|
-
a.keepalive.pinging = false; // roll back so the next tick can retry
|
|
5690
|
-
log.warn({ target: a.target, reason: r.reason }, "keepalive: inject failed");
|
|
5691
|
-
return;
|
|
5692
|
-
}
|
|
5693
|
-
if (!guard())
|
|
5694
|
-
return;
|
|
5695
|
-
const tokens = lastContextTokens(a.jsonlPath);
|
|
5696
|
-
const round = a.keepalive ? (a.keepalive.round += 1) : 1;
|
|
5697
|
-
log.info({ target: a.target, tokens, round, totalRounds: Math.max(1, kc.rounds) }, "keepalive: ping injected");
|
|
5698
|
-
// Open a chat-detail turn for the real heartbeat exchange: userQuery is the
|
|
5699
|
-
// actual ping we injected; the assistant reply (expected: just "pong"), any
|
|
5700
|
-
// tool calls, and usage are grafted on from the swallowed items in onItem;
|
|
5701
|
-
// closed on the ping's turn_end (or the fail-safe). Kept out of chat.
|
|
5702
|
-
// The tail's keepalive_start (content match on the user line) may have
|
|
5703
|
-
// opened this turn already — only open when it hasn't.
|
|
5704
|
-
if (!a.keepaliveTurnId) {
|
|
5705
|
-
const turnId = newTurnId();
|
|
5706
|
-
a.keepaliveTurnId = turnId;
|
|
5707
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
|
|
5708
|
-
}
|
|
5709
|
-
};
|
|
5710
|
-
// One pool for both maintenance paths: slow panes do not stall other panes,
|
|
5711
|
-
// and a keepalive never overlaps a picker scan/automatic press on the same pane.
|
|
5708
|
+
// One pool for the picker sentinel's pane probes: slow panes do not stall
|
|
5709
|
+
// other panes, and scans on the same pane never overlap.
|
|
5712
5710
|
const maintenance = createKeyedScheduler(4);
|
|
5713
5711
|
const maintenanceValid = (a, pane, sid, gen, signal) => !signal.aborted && byTarget.get(a.target) === a && a.tmuxPane === pane && a.sessionId === sid
|
|
5714
5712
|
&& (a.injectGen ?? 0) === gen && !a.migrationWatcher;
|
|
5715
|
-
const keepaliveOne = async (a, pane, sid, gen, signal) => {
|
|
5716
|
-
const valid = () => maintenanceValid(a, pane, sid, gen, signal) && cfg.wrc.mirror.keepalive.enabled;
|
|
5717
|
-
if (!valid())
|
|
5718
|
-
return;
|
|
5719
|
-
const kc = cfg.wrc.mirror.keepalive;
|
|
5720
|
-
const pingSigs = [normAssistant(kc.ping).slice(0, 40)].filter((s) => s.length > 0);
|
|
5721
|
-
const now = Date.now();
|
|
5722
|
-
if (a.keepaliveDisabled)
|
|
5723
|
-
return; // sticky spawn-time opt-out — never pings
|
|
5724
|
-
if (!a.tmuxPane)
|
|
5725
|
-
return; // spawn-mode: no live pane to warm
|
|
5726
|
-
if (a.migrationWatcher)
|
|
5727
|
-
return; // session rotating — skip
|
|
5728
|
-
if (a.liveStream && !a.liveStream.closed)
|
|
5729
|
-
return; // mid typewriter — don't inject
|
|
5730
|
-
if (!(await tmuxPaneAlive(pane)) || !valid())
|
|
5731
|
-
return; // dead pane — nothing to keep alive
|
|
5732
|
-
let mtime = 0;
|
|
5733
|
-
try {
|
|
5734
|
-
mtime = statSync(a.jsonlPath).mtimeMs;
|
|
5735
|
-
}
|
|
5736
|
-
catch {
|
|
5737
|
-
return;
|
|
5738
|
-
}
|
|
5739
|
-
if (!mtime)
|
|
5740
|
-
return;
|
|
5741
|
-
const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, pingSigs));
|
|
5742
|
-
// File mtime is only a cheap "did anything change → re-read the tail" gate.
|
|
5743
|
-
// The clocks themselves come from message turns, so metadata churn (which
|
|
5744
|
-
// bumps mtime but is not a turn) can never move them. `grewSinceLast` now
|
|
5745
|
-
// means a NEW message turn appeared — not that the file grew.
|
|
5746
|
-
const prevRealMs = k.lastRealMs;
|
|
5747
|
-
if (mtime > k.seenMtime + 1000) {
|
|
5748
|
-
const s = keepaliveStamps(a.jsonlPath, pingSigs);
|
|
5749
|
-
if (s.stamped) {
|
|
5750
|
-
k.lastMs = s.lastMs;
|
|
5751
|
-
k.lastRealMs = s.lastRealMs;
|
|
5752
|
-
}
|
|
5753
|
-
else {
|
|
5754
|
-
k.lastMs = mtime;
|
|
5755
|
-
k.lastRealMs = mtime;
|
|
5756
|
-
} // backend without timestamps → fall back to mtime
|
|
5757
|
-
k.ttlSec = cacheTtlSec(a.jsonlPath) || k.ttlSec;
|
|
5758
|
-
k.cacheAt = cacheTouchMs(a.jsonlPath) || k.cacheAt;
|
|
5759
|
-
}
|
|
5760
|
-
k.seenMtime = mtime;
|
|
5761
|
-
// Round reset / real-activity re-anchor keys off REAL turns only — a
|
|
5762
|
-
// keepalive ping+pong advances lastMs but NOT lastRealMs, so the heartbeat
|
|
5763
|
-
// no longer reads as activity to itself (which was pinning round at 1/N).
|
|
5764
|
-
const grewSinceLast = k.lastRealMs > prevRealMs + 1000;
|
|
5765
|
-
const paneTail = await capturePaneTail(pane, 16);
|
|
5766
|
-
if (!valid())
|
|
5767
|
-
return;
|
|
5768
|
-
const busy = paneIsBusy(paneTail);
|
|
5769
|
-
if (k.pinging) {
|
|
5770
|
-
// Waiting for our own ping to land and settle: until a NEWER message turn
|
|
5771
|
-
// (the ping's own user line) appears and the pane goes idle, keep waiting.
|
|
5772
|
-
if (busy || k.lastMs <= k.pingMtime)
|
|
5773
|
-
return;
|
|
5774
|
-
k.pinging = false;
|
|
5775
|
-
k.settledAt = now;
|
|
5776
|
-
return;
|
|
5777
|
-
}
|
|
5778
|
-
// A new real message turn (or a busy pane) re-anchors: reset the round
|
|
5779
|
-
// counter, and past the /stop grace lift a busy-resume. grewSinceLast is
|
|
5780
|
-
// message-based now, so `/stop`'s Esc settling writes (metadata, no new
|
|
5781
|
-
// turn) can no longer self-resume the pause — only a busy pane past grace,
|
|
5782
|
-
// or a WeCom inbound in dispatch, does.
|
|
5783
|
-
// Suppress the grewSinceLast reset for 30s after pinging settles — the
|
|
5784
|
-
// pong's tail flush can lag a tick and would otherwise fake real activity.
|
|
5785
|
-
const justSettled = k.settledAt > 0 && now - k.settledAt < 30_000;
|
|
5786
|
-
if (busy || (grewSinceLast && !justSettled)) {
|
|
5787
|
-
k.round = 0;
|
|
5788
|
-
k.settledAt = 0;
|
|
5789
|
-
if (a.keepaliveOff && busy && now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS) {
|
|
5790
|
-
a.keepaliveOff = false;
|
|
5791
|
-
persistPause(a);
|
|
5792
|
-
}
|
|
5793
|
-
if (busy)
|
|
5794
|
-
return;
|
|
5795
|
-
}
|
|
5796
|
-
if (a.keepaliveOff)
|
|
5797
|
-
return; // paused by /stop until real activity returns
|
|
5798
|
-
// Cache clock = start of the newest answered request (TTL runs from there),
|
|
5799
|
-
// not the last turn's write time; no request on record → the turn clock.
|
|
5800
|
-
const sinceCache = now - (k.cacheAt || k.lastMs || mtime);
|
|
5801
|
-
// The TTL the session's cache entries were actually written with (1h on a
|
|
5802
|
-
// Claude Code subscription), not a guess: pinging at the 5min cadence
|
|
5803
|
-
// against a 1h entry re-reads the whole context ~14x per hour for nothing.
|
|
5804
|
-
const ttlMs = (k.ttlSec || kc.ttlSec) * 1000;
|
|
5805
|
-
const idleTriggerMs = Math.max(30_000, ttlMs - kc.marginSec * 1000);
|
|
5806
|
-
if (sinceCache < idleTriggerMs)
|
|
5807
|
-
return; // cache still comfortably warm
|
|
5808
|
-
if (sinceCache >= ttlMs)
|
|
5809
|
-
return; // cache already cold — a ping would cold-rewrite for nothing
|
|
5810
|
-
if (sinceCache > KEEPALIVE_MAX_IDLE_MS)
|
|
5811
|
-
return; // idle past 10min (a long-TTL entry) — not worth warming
|
|
5812
|
-
if (k.round >= kc.rounds)
|
|
5813
|
-
return; // budget spent — let it go cold
|
|
5814
|
-
if (idleVerdict(a.target) !== true)
|
|
5815
|
-
return; // no confirmed idle → no heartbeat input
|
|
5816
|
-
k.pinging = true;
|
|
5817
|
-
k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
|
|
5818
|
-
await fireKeepalive(a, () => valid() && !a.keepaliveOff);
|
|
5819
|
-
};
|
|
5820
|
-
const keepaliveTick = () => {
|
|
5821
|
-
if (!cfg.wrc.mirror.keepalive.enabled)
|
|
5822
|
-
return;
|
|
5823
|
-
for (const a of byTarget.values()) {
|
|
5824
|
-
if (!a.tmuxPane || a.keepaliveDisabled)
|
|
5825
|
-
continue;
|
|
5826
|
-
const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
|
|
5827
|
-
void maintenance.schedule(`keepalive:${a.target}`, pane, (signal) => keepaliveOne(a, pane, sid, gen, signal))
|
|
5828
|
-
?.catch((e) => {
|
|
5829
|
-
if (maintenanceValid(a, pane, sid, gen, maintenance.signal)) {
|
|
5830
|
-
if (a.keepalive)
|
|
5831
|
-
a.keepalive.pinging = false;
|
|
5832
|
-
endKeepaliveSwallow(a);
|
|
5833
|
-
}
|
|
5834
|
-
log.warn({ target: a.target, err: e.message }, "keepalive tick failed");
|
|
5835
|
-
});
|
|
5836
|
-
}
|
|
5837
|
-
};
|
|
5838
|
-
const keepaliveTimer = setInterval(() => void keepaliveTick(), 15_000);
|
|
5839
5713
|
// ── 原生权限 picker 哨兵 ──────────────────────────────────────────────
|
|
5840
5714
|
// CodeBuddy 对一部分调用先弹自家权限 picker、PreToolUse hook 在人点掉之后才
|
|
5841
5715
|
// 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
|
|
@@ -6248,14 +6122,21 @@ export const startMirror = (deps) => {
|
|
|
6248
6122
|
const generation = a.injectGen ?? 0;
|
|
6249
6123
|
const permitted = () => permission() && byTarget.get(target) === a && a.sessionId === sid && (a.injectGen ?? 0) === generation;
|
|
6250
6124
|
const probedPane = a.tmuxPane;
|
|
6251
|
-
const
|
|
6125
|
+
const probe = a.tmuxPane ? await probePane(a.tmuxPane, a.jsonlPath) : { state: "gone" };
|
|
6252
6126
|
if (!permitted() || a.tmuxPane !== probedPane)
|
|
6253
6127
|
return denied;
|
|
6128
|
+
// tmux 没给出定论 (server 卡住时 display-message 会超时): 既不能贴 (不知道往哪贴),
|
|
6129
|
+
// 也不能重生 —— pane 很可能好端端的。按「稍后重发」交回去: 超时是分钟级的偶发, 重发
|
|
6130
|
+
// 几乎总能落地, 而重生的代价是这个会话被整个换掉。
|
|
6131
|
+
if (probe.state === "unknown") {
|
|
6132
|
+
log.warn({ target, sessionId: sid, pane: a.tmuxPane }, "injectText: pane 探活无定论 (tmux 无响应), 不重生");
|
|
6133
|
+
return { ok: false, retryable: true, reason: "tmux 探活无定论 (server 可能卡住), 未重生 pane, 稍后重发即可" };
|
|
6134
|
+
}
|
|
6254
6135
|
// 同 dispatch: 只有刚生 / 刚重生的 pane 才要冷启动的宽限 —— 对一个热着、正忙的 pane 贴 `now`
|
|
6255
6136
|
// 也按冷启动等 idle 至多 15s, 「不等不打断」就成了「晚 15–20s 才进」(handoff-owe 的竞态即此)。
|
|
6256
6137
|
let fresh = a.justSpawned === true;
|
|
6257
|
-
if (
|
|
6258
|
-
log.warn({ target, sessionId: sid, oldPane: a.tmuxPane }, "injectText: pane not alive, respawning");
|
|
6138
|
+
if (probe.state !== "session") {
|
|
6139
|
+
log.warn({ target, sessionId: sid, oldPane: a.tmuxPane, why: probe.state }, "injectText: pane not alive, respawning");
|
|
6259
6140
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
6260
6141
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
6261
6142
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
@@ -6287,14 +6168,14 @@ export const startMirror = (deps) => {
|
|
|
6287
6168
|
return denied;
|
|
6288
6169
|
// Busy/queued retries must not consume notices or stamp a future turn.
|
|
6289
6170
|
// Render only after the pane lane grants delivery, then stamp exactly at
|
|
6290
|
-
// the first permitted input.
|
|
6171
|
+
// the first permitted input.
|
|
6291
6172
|
const envelope = text.trimStart().startsWith("/") ? "" : opts?.envelope ?? "";
|
|
6292
|
-
let full = text + envelope;
|
|
6173
|
+
let full = text + renderTail(envelope);
|
|
6293
6174
|
const beforeInput = () => {
|
|
6294
6175
|
a.pendingFromCli = false;
|
|
6295
6176
|
a.turnFromChat = true;
|
|
6296
6177
|
rememberInject(full);
|
|
6297
|
-
pushSeal(a, { keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
|
|
6178
|
+
pushSeal(a, { eventId: parseEnvelope(full)?.eventId, keys: sealKeys(full), at: Date.now(), query: text, from: opts?.from, channel: opts?.channel, origin, fresh: idleVerdict(target) === true });
|
|
6298
6179
|
a.muteUntilInject = false;
|
|
6299
6180
|
a.justSpawned = false;
|
|
6300
6181
|
};
|
|
@@ -6304,10 +6185,10 @@ export const startMirror = (deps) => {
|
|
|
6304
6185
|
const preClearBaseline = clearing ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
6305
6186
|
const injectPane = a.tmuxPane;
|
|
6306
6187
|
const r = await inject({
|
|
6307
|
-
text: full, images: [], cfg, guard: (phase) => permitted() && a.tmuxPane === injectPane && (!opts?.guard || opts.guard(phase)), log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
6308
|
-
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
|
|
6309
|
-
requireIdle: opts?.requireIdle ? () =>
|
|
6310
|
-
prepareText: () => (full = text + envelope
|
|
6188
|
+
text: full, images: [], cfg, guard: (phase, identity) => permitted() && a.tmuxPane === injectPane && (!opts?.guard || opts.guard(phase, identity)), log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
6189
|
+
sessionId: sid, eventId: opts?.eventId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: fresh,
|
|
6190
|
+
requireIdle: opts?.requireIdle ? () => idleNow(target) : undefined,
|
|
6191
|
+
prepareText: () => (full = text + renderTail(envelope, noticeSuffixFor(target, text))),
|
|
6311
6192
|
beforeInput,
|
|
6312
6193
|
});
|
|
6313
6194
|
if (r.ok && a.tmuxPane && permitted()) {
|
|
@@ -6321,9 +6202,15 @@ export const startMirror = (deps) => {
|
|
|
6321
6202
|
return r;
|
|
6322
6203
|
},
|
|
6323
6204
|
interruptPane: async (target, opts) => {
|
|
6205
|
+
if (opts?.guard && !opts.guard())
|
|
6206
|
+
return { ok: false, reason: "interrupt authority changed" };
|
|
6324
6207
|
const a = byTarget.get(target);
|
|
6325
6208
|
if (!a)
|
|
6326
6209
|
return { ok: false, reason: "no mirror attached for target" };
|
|
6210
|
+
const interruptedEvent = a.activeEventId;
|
|
6211
|
+
const interruptedPane = a.tmuxPane;
|
|
6212
|
+
if (opts?.teardown && interruptedEvent)
|
|
6213
|
+
deps.events?.cancel((e) => e.id === interruptedEvent);
|
|
6327
6214
|
// `/stop` (teardown: true) must work when tmux itself is the problem, so
|
|
6328
6215
|
// the order is: local state first, pane second. The old implementation
|
|
6329
6216
|
// probed the pane FIRST and returned early on failure — meaning a wedged
|
|
@@ -6340,15 +6227,6 @@ export const startMirror = (deps) => {
|
|
|
6340
6227
|
else if (!a.tmuxPane) {
|
|
6341
6228
|
return { ok: false, reason: "no live tmux pane (spawn-mode attachment)" };
|
|
6342
6229
|
}
|
|
6343
|
-
// /stop also pauses keepalive: the user is deliberately quieting this
|
|
6344
|
-
// session, so stop poking it too. A real turn (WeCom inbound, or the pane
|
|
6345
|
-
// going busy AFTER the resume grace) lifts the pause and re-earns the
|
|
6346
|
-
// budget. Stamping keepaliveOffAt gates the busy-resume so the ping that
|
|
6347
|
-
// may be mid-flight right now can't immediately self-resume.
|
|
6348
|
-
a.keepaliveOff = true;
|
|
6349
|
-
a.keepaliveOffAt = Date.now();
|
|
6350
|
-
a.keepalive = undefined; // re-anchors cleanly on resume
|
|
6351
|
-
persistPause(a); // survive a reload — don't resurrect a quieted session
|
|
6352
6230
|
// Best-effort Esc. Every failure mode here is reported, never fatal: the
|
|
6353
6231
|
// teardown above already gave the user their chat back.
|
|
6354
6232
|
let escOk = false;
|
|
@@ -6360,7 +6238,13 @@ export const startMirror = (deps) => {
|
|
|
6360
6238
|
escReason = "tmux pane no longer alive";
|
|
6361
6239
|
}
|
|
6362
6240
|
else {
|
|
6363
|
-
|
|
6241
|
+
if (byTarget.get(target) !== a || a.tmuxPane !== interruptedPane || a.activeEventId !== interruptedEvent || (opts?.guard && !opts.guard()))
|
|
6242
|
+
return { ok: false, reason: "turn changed during interrupt probe; Escape withheld" };
|
|
6243
|
+
const r = await withPaneInput(interruptedPane, async (live) => {
|
|
6244
|
+
if (!live() || a.activeEventId !== interruptedEvent || a.tmuxPane !== interruptedPane || (opts?.guard && !opts.guard()))
|
|
6245
|
+
return { ok: false, stderr: "turn changed before Escape", stdout: "", code: 1 };
|
|
6246
|
+
return runTmux(["send-keys", "-t", interruptedPane, "Escape"]);
|
|
6247
|
+
});
|
|
6364
6248
|
escOk = r.ok;
|
|
6365
6249
|
if (!r.ok)
|
|
6366
6250
|
escReason = `send-keys Escape failed: ${(r.stderr || r.stdout).slice(-200) || r.code}`;
|
|
@@ -6375,7 +6259,13 @@ export const startMirror = (deps) => {
|
|
|
6375
6259
|
return { ok: false, reason: escReason };
|
|
6376
6260
|
return { ok: true, torndown, escOk, escReason: escReason || undefined };
|
|
6377
6261
|
},
|
|
6378
|
-
killPane: (target) =>
|
|
6262
|
+
killPane: async (target) => {
|
|
6263
|
+
deps.events?.cancel((e) => e.target === target);
|
|
6264
|
+
const r = await downPane(target, true);
|
|
6265
|
+
if (r.ok)
|
|
6266
|
+
deps.events?.stopped(target, "pane kill acknowledged; queued input revoked");
|
|
6267
|
+
return r;
|
|
6268
|
+
},
|
|
6379
6269
|
sleepPane: (target) => downPane(target, false),
|
|
6380
6270
|
lastActivity: (target) => {
|
|
6381
6271
|
const jsonl = jsonlOf(target);
|
|
@@ -6390,15 +6280,22 @@ export const startMirror = (deps) => {
|
|
|
6390
6280
|
const want = new Set(names.flatMap((n) => [n, `.${n}`]));
|
|
6391
6281
|
if (!want.size)
|
|
6392
6282
|
return [];
|
|
6393
|
-
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{session_name}\t#{window_name}"]);
|
|
6283
|
+
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{session_name}\t#{window_name}\t#{pane_pid}"]);
|
|
6394
6284
|
if (snap.code !== 0)
|
|
6395
6285
|
return [];
|
|
6396
|
-
|
|
6286
|
+
// 「生到一半」只可能是上一个进程留下的: 本进程自己 spawn 的 pane (名字被 sweepUnborn
|
|
6287
|
+
// 腾出后同名重生、或 spawnTmux 等 TUI 就绪那十几秒里还没回 pane id 的) 一律不碰 ——
|
|
6288
|
+
// pane 的 shell 比本进程年轻就是本进程生的。拿不到年龄 = 认不出, 不收。
|
|
6289
|
+
const ps = await psSnap();
|
|
6290
|
+
const bornBeforeUs = (pid) => (ps.etimes.get(Number(pid)) ?? 0) > process.uptime();
|
|
6397
6291
|
const doomed = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"))
|
|
6398
|
-
.filter(([
|
|
6292
|
+
.filter(([, session, win = "", pid = ""]) => session === cfg.wrc.tmuxPrefix && want.has(win) && bornBeforeUs(pid))
|
|
6399
6293
|
.map(([pane = ""]) => pane);
|
|
6400
6294
|
const killed = [];
|
|
6401
6295
|
for (const pane of doomed) {
|
|
6296
|
+
// 绑定现查: 逐个收的 await 期间别人可能刚 attach 上。
|
|
6297
|
+
if (spawningPanes.has(pane) || allTargets().some((t) => paneOf(t) === pane))
|
|
6298
|
+
continue;
|
|
6402
6299
|
// 与 killPane 同款前戏: Esc 给可能已起来的 CLI 一个收束落盘的机会。
|
|
6403
6300
|
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
6404
6301
|
await sleep(250);
|
|
@@ -6446,7 +6343,6 @@ export const startMirror = (deps) => {
|
|
|
6446
6343
|
// 找)。不看注册表的 `tmux` 字段 —— codebuddy 的注册表没有它, 那条路对 codebuddy
|
|
6447
6344
|
// 全盲。普通 pane (人自己开的 shell / ssh / 编辑器, 会话已退回 shell 的) 树里
|
|
6448
6345
|
// 没有注册表 pid, 一律跳过 —— 认不出就不动。
|
|
6449
|
-
const bound = new Set(targets.map(paneOf).filter(Boolean));
|
|
6450
6346
|
const regPids = new Set(activeBackends().flatMap((b) => [...liveSessionPids(b.homeDir)]));
|
|
6451
6347
|
const ps = await psSnap();
|
|
6452
6348
|
// 沿进程树找注册表 pid; 找到返回那个 pid (它的 etimes 供宽限判), 找不到返回 0。
|
|
@@ -6466,13 +6362,15 @@ export const startMirror = (deps) => {
|
|
|
6466
6362
|
return 0;
|
|
6467
6363
|
};
|
|
6468
6364
|
for (const [pane = "", , session, pid] of rows) {
|
|
6469
|
-
|
|
6365
|
+
// 绑定现查而非用开头的快照: 前面几轮 putDown 的 await 期间别人可能刚 attach 上。
|
|
6366
|
+
if (session !== cfg.wrc.tmuxPrefix || spawningPanes.has(pane) || allTargets().some((t) => paneOf(t) === pane))
|
|
6470
6367
|
continue;
|
|
6471
6368
|
const cliPid = hostedBy(Number(pid) || 0);
|
|
6472
6369
|
if (!cliPid)
|
|
6473
6370
|
continue;
|
|
6474
6371
|
// 宽限按 CLI 进程年龄, 不按 window_activity: TUI 重绘会让它永远「刚动过」。
|
|
6475
|
-
|
|
6372
|
+
// 拿不到年龄按刚生算 (跳过): 认不出就不收。
|
|
6373
|
+
if ((ps.etimes.get(cliPid) ?? 0) * 1000 < ORPHAN_GRACE_MS)
|
|
6476
6374
|
continue;
|
|
6477
6375
|
const why = await putDown(pane);
|
|
6478
6376
|
if (why) {
|
|
@@ -6619,12 +6517,12 @@ export const startMirror = (deps) => {
|
|
|
6619
6517
|
newSession,
|
|
6620
6518
|
cloneSession,
|
|
6621
6519
|
sessionInfo,
|
|
6520
|
+
inputSessionInfo,
|
|
6622
6521
|
setCharterProvider,
|
|
6623
6522
|
onSpawn,
|
|
6624
6523
|
onContextCut,
|
|
6625
6524
|
shutdown: () => {
|
|
6626
6525
|
clearInterval(paneDriftTimer);
|
|
6627
|
-
clearInterval(keepaliveTimer);
|
|
6628
6526
|
clearInterval(pickerTimer);
|
|
6629
6527
|
maintenance.stop();
|
|
6630
6528
|
for (const a of bySessionId.values()) {
|
|
@@ -6644,13 +6542,15 @@ export const startMirror = (deps) => {
|
|
|
6644
6542
|
return a.convFrom.asker;
|
|
6645
6543
|
return a?.speaker ? { who: a.speaker, chat: a.channel ?? baseOfKey(target) } : undefined;
|
|
6646
6544
|
},
|
|
6647
|
-
answerContext, prepareAnswer, takeAnswer, cancelAnswerPreparation, stopAnswerProgress, deliverAnswer,
|
|
6648
|
-
setAnswerSilencer: (fn) => { silenceAnswer = fn; },
|
|
6649
6545
|
currentChannel: (target) => {
|
|
6650
6546
|
const a = byTarget.get(target);
|
|
6651
6547
|
return a?.channel ?? baseOfKey(target);
|
|
6652
6548
|
},
|
|
6653
|
-
|
|
6549
|
+
formatFinal,
|
|
6550
|
+
recoverEventFinal,
|
|
6551
|
+
setEventFinalGate: (gate) => { eventFinalGate = gate; },
|
|
6552
|
+
settleFinalProgress,
|
|
6553
|
+
dispatch: async ({ principal, text, images, frame, streamId, channel, speaker, eventId, guard, onSubmitted }) => {
|
|
6654
6554
|
// Route by inbound principal — the WeCom chat the user just messaged us
|
|
6655
6555
|
// from is the same string we registered as `target` on attach. After a
|
|
6656
6556
|
// daemon reload the in-memory map is empty; restore from the persisted
|
|
@@ -6668,24 +6568,16 @@ export const startMirror = (deps) => {
|
|
|
6668
6568
|
}
|
|
6669
6569
|
return;
|
|
6670
6570
|
}
|
|
6671
|
-
//
|
|
6672
|
-
//
|
|
6673
|
-
//
|
|
6674
|
-
//
|
|
6675
|
-
//
|
|
6676
|
-
|
|
6677
|
-
|
|
6678
|
-
|
|
6679
|
-
|
|
6680
|
-
}
|
|
6681
|
-
else {
|
|
6682
|
-
a.keepaliveOff = false;
|
|
6683
|
-
if (a.keepalive) {
|
|
6684
|
-
a.keepalive.lastMs = Date.now();
|
|
6685
|
-
a.keepalive.lastRealMs = Date.now();
|
|
6686
|
-
}
|
|
6571
|
+
// 人说的普通消息不再问「你现在忙不忙」: 忙就贴进去, 顺序交给 CLI 自己的输入队列 ——
|
|
6572
|
+
// 贴进运行中的一轮会由 startBriefTurn 记成 queued 轮, 等它在 transcript 里露面再 bindLine,
|
|
6573
|
+
// 终句照样归到这条 event。只有 slash 命令仍旧等它闲: /clear、/model 这些要改会话状态,
|
|
6574
|
+
// 落在运行中的一轮上会跟 armMigration / 原生 picker 抢 pane。(modal 那一层守卫在
|
|
6575
|
+
// injectViaTmux 里照旧管着: 停在确认框上时既不贴也不吞。)
|
|
6576
|
+
const slashCmd = /^\/[a-z]/i.test(text.trim());
|
|
6577
|
+
if (eventId && slashCmd && a.tmuxPane && await tmuxPaneAlive(a.tmuxPane) && !(await idleNow(principal))) {
|
|
6578
|
+
onSubmitted?.({ ok: false, retryable: true, reason: "target became busy before opening the slash command turn" });
|
|
6579
|
+
return;
|
|
6687
6580
|
}
|
|
6688
|
-
persistPause(a); // persist the pause/resume too, else a reload would revert it
|
|
6689
6581
|
// 频道印章要早于任何开 turn 的路径 (startBriefTurn 同步消费)。
|
|
6690
6582
|
// brief 模式下频道随这一句的印章走 (startBriefTurn); 其余路径仍是开 turn 时消费的格子。
|
|
6691
6583
|
if (!cfg.wrc.mirror.brief || isClearCommand(text))
|
|
@@ -6700,7 +6592,7 @@ export const startMirror = (deps) => {
|
|
|
6700
6592
|
const isModelSwitch = /^\/model\s+\S/.test(text.trim());
|
|
6701
6593
|
// 任意 slash 命令 (/context, /cost, /status…): 产物走 skill_output standalone,
|
|
6702
6594
|
// 详情链接是纯噪声 —— brief 起 turn 时不推链接。
|
|
6703
|
-
const isSlash =
|
|
6595
|
+
const isSlash = slashCmd;
|
|
6704
6596
|
// codebuddy 没有可用的剪贴板图片通道 (tmux 里 C-v 会被它自己的
|
|
6705
6597
|
// syncTmuxToSystemClipboard 冲掉, 见 injectViaTmux 分流注释) —— 图片只能以
|
|
6706
6598
|
// @<path> 文本进会话。既然如此, 问话 / 印章 / 回显去重 / 静默分叉跟随都按这个
|
|
@@ -6789,11 +6681,13 @@ export const startMirror = (deps) => {
|
|
|
6789
6681
|
log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
|
|
6790
6682
|
}
|
|
6791
6683
|
}
|
|
6684
|
+
if (!brief)
|
|
6685
|
+
a.activeEventId = eventId;
|
|
6792
6686
|
if (brief) {
|
|
6793
6687
|
// 挂 loading 气泡并起新 turn —— 上一 turn 若还在跑, startBriefTurn 内部
|
|
6794
6688
|
// 会立刻把它收掉 (对话边界策略), 本轮直接成为活跃 turn。
|
|
6795
6689
|
// 后续 onItem 走 handleBriefItem, 不再走 stream / defer 路径。
|
|
6796
|
-
await startBriefTurn(a, frame, streamId, isSlash, effText, channel ?? baseOfKey(principal), speaker);
|
|
6690
|
+
await startBriefTurn(a, frame, streamId, isSlash, effText, channel ?? baseOfKey(principal), speaker, eventId);
|
|
6797
6691
|
}
|
|
6798
6692
|
else if (!armMigration && !eagerOpen) {
|
|
6799
6693
|
enterDeferred(a, frame, streamId);
|
|
@@ -6813,11 +6707,18 @@ export const startMirror = (deps) => {
|
|
|
6813
6707
|
// First step of the job and the historical hang point — log BEFORE the
|
|
6814
6708
|
// probe so a stall is attributable to it next time, not invisible.
|
|
6815
6709
|
log.info({ target: a.target, sessionId: sid, pane: a.tmuxPane, gen: myGen }, "inject job start");
|
|
6816
|
-
const
|
|
6710
|
+
const probe = a.tmuxPane ? await probePane(a.tmuxPane, a.jsonlPath) : { state: "gone" };
|
|
6817
6711
|
let freshSpawn = a.justSpawned === true;
|
|
6818
6712
|
a.justSpawned = false;
|
|
6819
|
-
|
|
6820
|
-
|
|
6713
|
+
// tmux 没给出定论: 不重生 (同 injectText)。turn 已经开了头 (气泡可能已挂), 所以
|
|
6714
|
+
// 报一个可重试的失败让调用方收口 —— 比换掉整个会话温和得多。
|
|
6715
|
+
if (probe.state === "unknown") {
|
|
6716
|
+
log.warn({ target: a.target, sessionId: sid, pane: a.tmuxPane }, "mirror: pane 探活无定论 (tmux 无响应), 不重生");
|
|
6717
|
+
onSubmitted?.({ ok: false, retryable: true, reason: "tmux 探活无定论 (server 可能卡住), 未重生 pane" });
|
|
6718
|
+
return;
|
|
6719
|
+
}
|
|
6720
|
+
if (probe.state !== "session") {
|
|
6721
|
+
log.warn({ target: a.target, sessionId: sid, oldPane: a.tmuxPane, oldSession: a.tmuxSession, why: probe.state }, "mirror: no live tmux pane, respawning");
|
|
6821
6722
|
// Interactive `claude --resume <sid>` does NOT keep appending to the
|
|
6822
6723
|
// same <sid>.jsonl — it FORKS to a fresh <newSid>.jsonl seeded with a
|
|
6823
6724
|
// copy of the transcript. Our tail stays bound to the OLD, now-frozen
|
|
@@ -6881,7 +6782,7 @@ export const startMirror = (deps) => {
|
|
|
6881
6782
|
return;
|
|
6882
6783
|
}
|
|
6883
6784
|
if (!brief)
|
|
6884
|
-
pushSeal(a, { keys: sealKeys(effText), at: Date.now(), query: effText, channel: channel ?? baseOfKey(principal), speaker, fresh: true });
|
|
6785
|
+
pushSeal(a, { eventId: a.activeEventId, keys: sealKeys(effText), at: Date.now(), query: effText, channel: channel ?? baseOfKey(principal), speaker, fresh: true });
|
|
6885
6786
|
rememberInject(effText);
|
|
6886
6787
|
// 解除静默必须在 inject 之前: inject 内部的 freshSpawn 验证循环可达 10+s
|
|
6887
6788
|
// (paste-verify + settle + waitForCleared + retry), 而 LLM 的回复可能在 Enter
|
|
@@ -6891,14 +6792,25 @@ export const startMirror = (deps) => {
|
|
|
6891
6792
|
// 用户行有 isOwnInject 过滤 —— 提前解除不会泄漏脏数据。
|
|
6892
6793
|
a.muteUntilInject = false;
|
|
6893
6794
|
const injectPane = a.tmuxPane;
|
|
6795
|
+
// 人话不再等 pane 闲 (§人话直入): `requireIdle` 在这一层只剩一个作用 —— 必须有 pane,
|
|
6796
|
+
// 不许退化成 spawn-mode (`--resume -p`) 的无窗口注入。忙闲交给 CLI 自己的输入队列:
|
|
6797
|
+
// 贴进运行中的一轮会被记成 queued 轮, 等它在 transcript 露面再接上这条 event。
|
|
6798
|
+
// slash 命令要等闲, 那一道在上面 (dispatch 开头) 已经拦过了。
|
|
6894
6799
|
const r = await inject({
|
|
6895
6800
|
text: effText, images: effImages, cfg, log: log.child({ principal, sessionId: sid }),
|
|
6896
|
-
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
|
|
6897
|
-
|
|
6801
|
+
sessionId: sid, eventId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
|
|
6802
|
+
requireIdle: eventId ? () => true : undefined,
|
|
6803
|
+
guard: (phase, identity) => byTarget.get(a.target) === a && a.tmuxPane === injectPane && (a.injectGen ?? 0) === myGen && (!guard || guard(phase, identity)),
|
|
6898
6804
|
});
|
|
6805
|
+
onSubmitted?.(r);
|
|
6899
6806
|
if (!r.ok) {
|
|
6807
|
+
// retryable = 一个字都没进输入框, 消息事件层会稍后重投 —— 不是失败, 别报 ✗
|
|
6808
|
+
// (每次退避重试都会走到这里, 报 ✗ 就是一串假警报)。
|
|
6809
|
+
const note = r.retryable && eventId
|
|
6810
|
+
? `[mirror] ⏳ 暂未送达, 稍后自动重投 (${r.reason ?? "deferred"})`
|
|
6811
|
+
: `[mirror] ✗ ${r.reason ?? "failed"}`;
|
|
6900
6812
|
if (s) {
|
|
6901
|
-
s.acc = s.acc ? `${s.acc}\n\n
|
|
6813
|
+
s.acc = s.acc ? `${s.acc}\n\n${note}` : note;
|
|
6902
6814
|
await finalizeStream(a, s);
|
|
6903
6815
|
}
|
|
6904
6816
|
else if (armMigration) {
|
|
@@ -6916,7 +6828,7 @@ export const startMirror = (deps) => {
|
|
|
6916
6828
|
if (a.outbound?.kind === "deferred")
|
|
6917
6829
|
clearTimeout(a.outbound.timer);
|
|
6918
6830
|
a.outbound = undefined;
|
|
6919
|
-
sendStandalone(a,
|
|
6831
|
+
sendStandalone(a, note);
|
|
6920
6832
|
}
|
|
6921
6833
|
return;
|
|
6922
6834
|
}
|