wezard 1.2.12 → 1.2.18
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/README.md +2 -3
- package/config.example.jsonc +32 -0
- package/dist/cli/init.js +53 -2
- package/dist/cli/init.js.map +1 -1
- package/dist/daemon/approval.js +479 -106
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/danger.js +23 -0
- package/dist/daemon/danger.js.map +1 -1
- package/dist/daemon/graph.js +22 -4
- package/dist/daemon/graph.js.map +1 -1
- package/dist/daemon/inbound.js +112 -71
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +67 -17
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +564 -133
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +69 -7
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/pending.js +4 -3
- package/dist/daemon/pending.js.map +1 -1
- package/dist/daemon/session-name.js +133 -0
- package/dist/daemon/session-name.js.map +1 -0
- package/dist/daemon/session-scan.js +18 -2
- package/dist/daemon/session-scan.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +103 -16
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/ws.js +19 -1
- package/dist/daemon/ws.js.map +1 -1
- package/dist/mcp/server.js +4 -4
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/allow-rules.js +391 -0
- package/dist/shared/allow-rules.js.map +1 -0
- package/dist/shared/chat-render.js +2 -0
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +37 -2
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/claude-config-path.js +132 -0
- package/dist/shared/claude-config-path.js.map +1 -0
- package/dist/shared/claude-permissions.js +63 -0
- package/dist/shared/claude-permissions.js.map +1 -0
- package/dist/shared/cli-backends.js +15 -2
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +42 -2
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +22 -1
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/modal-pane.js +101 -0
- package/dist/shared/modal-pane.js.map +1 -0
- package/dist/shared/session-label.js +26 -3
- package/dist/shared/session-label.js.map +1 -1
- package/package.json +1 -1
- package/web/chat.css +30 -0
- package/web/chat.js +60 -6
|
@@ -17,13 +17,14 @@ import { existsSync, readdirSync, statSync, watch, openSync, readSync, closeSync
|
|
|
17
17
|
import { join, dirname } from "node:path";
|
|
18
18
|
import { expandHome, sanitizeId } from "../shared/paths.js";
|
|
19
19
|
import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
|
|
20
|
+
import { isModalPane, parseModalOptions, pickModalAnswer } from "../shared/modal-pane.js";
|
|
20
21
|
import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow } from "./approval.js";
|
|
21
|
-
import { spawnTmuxClaude } from "./spawn-tmux.js";
|
|
22
|
+
import { runTmux as runTmuxCmd, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
22
23
|
import { recordTool, recordToolResult, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
|
|
23
24
|
import { labelFor, tagOfKey, baseOfKey, withTagHeader } from "../shared/session-label.js";
|
|
24
25
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
25
26
|
import { randomTip } from "./tips.js";
|
|
26
|
-
import { stripAnsi, compactPane, paneIsBusy, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps } from "./peers.js";
|
|
27
|
+
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
|
|
27
28
|
// Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
|
|
28
29
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
29
30
|
const NODE_BIN_DIR = dirname(process.execPath);
|
|
@@ -763,16 +764,12 @@ export const startMirrorTail = (deps) => {
|
|
|
763
764
|
drain,
|
|
764
765
|
};
|
|
765
766
|
};
|
|
766
|
-
// Run a tmux subcommand, capturing stdout/stderr.
|
|
767
|
-
//
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
p.stderr?.on("data", (c) => (err += c.toString("utf8")));
|
|
773
|
-
p.on("error", (e) => resolve({ ok: false, stdout: "", stderr: e.message }));
|
|
774
|
-
p.on("close", (code) => resolve({ ok: code === 0, stdout: out, stderr: err }));
|
|
775
|
-
});
|
|
767
|
+
// Run a tmux subcommand, capturing stdout/stderr. Delegates to spawn-tmux's
|
|
768
|
+
// runTmux so there is exactly ONE tmux exec path in the daemon — and therefore
|
|
769
|
+
// exactly one place where the hard timeout lives. (Two hand-rolled copies used
|
|
770
|
+
// to exist here; both could hang forever, which is how a wedged tmux server
|
|
771
|
+
// silently killed a whole chat. See TMUX_TIMEOUT_MS.)
|
|
772
|
+
const tmuxRun = (args, opts) => runTmuxCmd(args, opts);
|
|
776
773
|
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
777
774
|
// Stable JSON: sort object keys recursively. Used to fingerprint a tool_use's
|
|
778
775
|
// `input` so the hook-side and the jsonl-side compute the same signature even
|
|
@@ -821,12 +818,29 @@ const PB_CLASS_BY_EXT = {
|
|
|
821
818
|
tif: "«class TIFF»",
|
|
822
819
|
tiff: "«class TIFF»",
|
|
823
820
|
};
|
|
821
|
+
// Same no-unbounded-await rule as runTmux: this sits on the inject path (image
|
|
822
|
+
// pump), and `osascript` in particular can block indefinitely if macOS decides
|
|
823
|
+
// to raise an automation-permission prompt nobody is there to answer. 30s is
|
|
824
|
+
// generous for a sips transcode of a large screenshot.
|
|
825
|
+
const PROC_TIMEOUT_MS = 30_000;
|
|
824
826
|
const runProc = (cmd, args) => new Promise((resolve) => {
|
|
825
827
|
const p = spawn(cmd, args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
826
828
|
let err = "";
|
|
829
|
+
let settled = false;
|
|
830
|
+
const finish = (r) => {
|
|
831
|
+
if (settled)
|
|
832
|
+
return;
|
|
833
|
+
settled = true;
|
|
834
|
+
clearTimeout(timer);
|
|
835
|
+
resolve(r);
|
|
836
|
+
};
|
|
837
|
+
const timer = setTimeout(() => {
|
|
838
|
+
p.kill("SIGKILL");
|
|
839
|
+
finish({ ok: false, stderr: `${cmd} timeout after ${PROC_TIMEOUT_MS}ms` });
|
|
840
|
+
}, PROC_TIMEOUT_MS);
|
|
827
841
|
p.stderr?.on("data", (c) => (err += c.toString("utf8")));
|
|
828
|
-
p.on("error", (e) =>
|
|
829
|
-
p.on("close", (code) =>
|
|
842
|
+
p.on("error", (e) => finish({ ok: false, stderr: e.message }));
|
|
843
|
+
p.on("close", (code) => finish({ ok: code === 0, stderr: err }));
|
|
830
844
|
});
|
|
831
845
|
const setMacClipboardImage = async (imgPath) => {
|
|
832
846
|
const ext = (imgPath.split(".").pop() ?? "").toLowerCase();
|
|
@@ -951,6 +965,28 @@ const extractPaneAssistantTail = (pane) => {
|
|
|
951
965
|
const text = out.join("\n").trim();
|
|
952
966
|
return text.length >= 12 ? text : "";
|
|
953
967
|
};
|
|
968
|
+
// Is the pane sitting on a modal picker that would eat an injection?
|
|
969
|
+
//
|
|
970
|
+
// Deliberately NOT capturePaneTail: that one passes `-S -N`, which reaches
|
|
971
|
+
// into the scrollback. A confirm the user already answered lives in the
|
|
972
|
+
// scrollback forever, so a scrollback-inclusive capture would keep matching it
|
|
973
|
+
// long after the pane went back to accepting input — wedging the mirror shut
|
|
974
|
+
// permanently. Bare `-p` captures only what is currently on screen.
|
|
975
|
+
//
|
|
976
|
+
// Trailing blank rows are trimmed first so the 15-row window lands on the
|
|
977
|
+
// bottom-most *content* (a picker's footer is its last line); 15 rows spans
|
|
978
|
+
// "title → options → footer" on every confirm layout we've seen.
|
|
979
|
+
const MODAL_SCAN_ROWS = 15;
|
|
980
|
+
// `screen` = 实际参与判定的那一屏文本, 一并返回给调用方做选项解析 —— 再 capture
|
|
981
|
+
// 一次会拿到"下一瞬间"的屏幕, 与 verdict 不同源(框可能已被本地按掉), 按键就按错了。
|
|
982
|
+
const detectModalPicker = async (target) => {
|
|
983
|
+
const r = await tmuxRun(["capture-pane", "-t", target, "-p"]);
|
|
984
|
+
if (!r.ok)
|
|
985
|
+
return { modal: false, screen: "" };
|
|
986
|
+
const lines = r.stdout.replace(/\s+$/u, "").split("\n");
|
|
987
|
+
const screen = lines.slice(-MODAL_SCAN_ROWS).join("\n");
|
|
988
|
+
return { ...isModalPane(screen), screen };
|
|
989
|
+
};
|
|
954
990
|
// Two fingerprints, derived from different ends of `text`:
|
|
955
991
|
// headFp — first 8 non-ws chars; used for "did paste land" against a wide
|
|
956
992
|
// window because long pastes wrap and the head sits near the top of the
|
|
@@ -974,6 +1010,24 @@ const fingerprints = (text) => {
|
|
|
974
1010
|
// 4. on stuck-after-Enter, retry Enter once with extra settle.
|
|
975
1011
|
const injectViaTmux = async (target, text, images, log, freshSpawn, backendName) => {
|
|
976
1012
|
log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
|
|
1013
|
+
// Never type into a modal picker (see detectModalPicker). Checked before the
|
|
1014
|
+
// image pump too — a C-v into a picker is just as destructive as a paste.
|
|
1015
|
+
// 必须在按后端分流之前: 每个后端都有自己的原生确认框, 往任何一个里打字都会
|
|
1016
|
+
// 替用户点掉框并吞掉这条消息。放到 codebuddy 早退之后就等于只保护了 claude。
|
|
1017
|
+
// Escape hatch: WEZARD_MODAL_GUARD=0, in case a future TUI layout trips it.
|
|
1018
|
+
if (process.env.WEZARD_MODAL_GUARD !== "0") {
|
|
1019
|
+
const modal = await detectModalPicker(target);
|
|
1020
|
+
if (modal.modal) {
|
|
1021
|
+
log.warn({ target, title: modal.title, backendName }, "mirror inject: modal picker on pane, refusing to inject");
|
|
1022
|
+
const what = modal.title ? `「${modal.title}」` : "原生确认框";
|
|
1023
|
+
return {
|
|
1024
|
+
ok: false,
|
|
1025
|
+
reason: `目标会话停在 CLI ${what} 等待确认,消息未送达。`
|
|
1026
|
+
+ `(强行注入会替你点掉确认框并吞掉这条消息)`
|
|
1027
|
+
+ `请到 tmux 里处理该确认框,或发 /stop 取消当前操作后重发。`,
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
977
1031
|
// 图片注入策略按后端分流:
|
|
978
1032
|
// claude / claude-internal —— 走 macOS 剪贴板 + Ctrl+V,TUI 收到 \x16 后
|
|
979
1033
|
// 直接读系统剪贴板的 PNGf flavor,附加为 image content block。
|
|
@@ -1028,21 +1082,11 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1028
1082
|
const CLEARED_TIMEOUT_MS = freshSpawn ? 4000 : 1500;
|
|
1029
1083
|
const RETRY_SETTLE_MS = freshSpawn ? 1500 : 800;
|
|
1030
1084
|
const loadAndPaste = async () => {
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
loader.stderr?.on("data", (c) => (lerr += c.toString("utf8")));
|
|
1035
|
-
loader.on("error", (e) => resolve({ ok: false, reason: `tmux not found: ${e.message}` }));
|
|
1036
|
-
loader.on("close", (code) => {
|
|
1037
|
-
if (code !== 0)
|
|
1038
|
-
resolve({ ok: false, reason: `tmux load-buffer exit ${code}: ${lerr.slice(-200)}` });
|
|
1039
|
-
else
|
|
1040
|
-
resolve({ ok: true });
|
|
1041
|
-
});
|
|
1042
|
-
loader.stdin?.end(text);
|
|
1043
|
-
});
|
|
1085
|
+
// stdin variant of the shared exec path — it carries the same hard timeout,
|
|
1086
|
+
// which a hand-rolled spawn here did not.
|
|
1087
|
+
const loaded = await tmuxRun(["load-buffer", "-"], { stdin: text });
|
|
1044
1088
|
if (!loaded.ok)
|
|
1045
|
-
return loaded;
|
|
1089
|
+
return { ok: false, reason: `tmux load-buffer failed: ${loaded.stderr.slice(-200) || loaded.code}` };
|
|
1046
1090
|
const pasted = await tmuxRun(["paste-buffer", "-p", "-d", "-t", target]);
|
|
1047
1091
|
if (!pasted.ok)
|
|
1048
1092
|
return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
|
|
@@ -1192,6 +1236,21 @@ const inject = (args) => {
|
|
|
1192
1236
|
return injectViaTmux(target, args.text, args.images ?? [], args.log, args.freshSpawn ?? false, backendName);
|
|
1193
1237
|
};
|
|
1194
1238
|
const queues = new Map();
|
|
1239
|
+
/** Ceiling for one whole inject job (respawn + paste + submit verification).
|
|
1240
|
+
* Worst legitimate case is a cold `--resume` respawn (~8s) plus a fresh-spawn
|
|
1241
|
+
* inject with both paste re-try and both cleared-polls (~30s), so 90s is ~2.5x
|
|
1242
|
+
* headroom. Past that the job is wedged, and holding the queue is strictly
|
|
1243
|
+
* worse than dropping the turn: every later message in that chat would queue
|
|
1244
|
+
* behind it forever. Well under brief's 350s bubble ceiling so the user still
|
|
1245
|
+
* gets an error bubble rather than a silent stream timeout. */
|
|
1246
|
+
const INJECT_JOB_TIMEOUT_MS = 90_000;
|
|
1247
|
+
/** Drop a session's queue chain. A hung job can't be cancelled (it's parked in a
|
|
1248
|
+
* tmux read), but detaching the chain means the NEXT message doesn't inherit
|
|
1249
|
+
* its deadlock. Pair with an injectGen bump so the zombie can't act when it
|
|
1250
|
+
* eventually returns. */
|
|
1251
|
+
const abortInjectQueue = (key) => {
|
|
1252
|
+
queues.delete(key);
|
|
1253
|
+
};
|
|
1195
1254
|
const enqueue = (key, job) => {
|
|
1196
1255
|
const prev = queues.get(key) ?? Promise.resolve();
|
|
1197
1256
|
const next = prev.then(job, job).catch(() => undefined);
|
|
@@ -1359,10 +1418,10 @@ export const startMirror = (deps) => {
|
|
|
1359
1418
|
try {
|
|
1360
1419
|
if (card) {
|
|
1361
1420
|
s.cardSent = true;
|
|
1362
|
-
await client.replyStreamWithCard(s.frame, s.streamId,
|
|
1421
|
+
await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, s.acc || " ", undefined, s.turnId), true, { templateCard: card });
|
|
1363
1422
|
}
|
|
1364
1423
|
else {
|
|
1365
|
-
await client.replyStream(s.frame, s.streamId,
|
|
1424
|
+
await client.replyStream(s.frame, s.streamId, withLinkedTag(a, s.acc || " ", undefined, s.turnId), true);
|
|
1366
1425
|
}
|
|
1367
1426
|
log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, withCard: !!card }, "stream finalize");
|
|
1368
1427
|
}
|
|
@@ -1400,12 +1459,27 @@ export const startMirror = (deps) => {
|
|
|
1400
1459
|
};
|
|
1401
1460
|
// Standalone fallback (no live stream / stream dead). Per-attachment FIFO so
|
|
1402
1461
|
// pushes from a single mirror stay ordered; different mirrors run in parallel.
|
|
1462
|
+
// Linked tag prefix: emoji+tag becomes a chat-detail link. Falls back to
|
|
1463
|
+
// plain withSessionTag when no turnId is available (no active turn to link).
|
|
1464
|
+
const linkedTagPrefix = (target, turnId) => {
|
|
1465
|
+
if (!turnId)
|
|
1466
|
+
return "";
|
|
1467
|
+
const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
|
|
1468
|
+
const tag = tagOfKey(target);
|
|
1469
|
+
return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
|
|
1470
|
+
};
|
|
1471
|
+
const withLinkedTag = (a, content, seq, turnId) => {
|
|
1472
|
+
const prefix = linkedTagPrefix(a.target, turnId ?? a.briefTurnId);
|
|
1473
|
+
if (prefix) {
|
|
1474
|
+
const seqBit = seq ? ` ${seq}` : "";
|
|
1475
|
+
return `${prefix}${seqBit} ${content}`;
|
|
1476
|
+
}
|
|
1477
|
+
return withSessionTag(a.target, content, seq);
|
|
1478
|
+
};
|
|
1403
1479
|
const sendStandalone = (a, content) => {
|
|
1404
1480
|
const chatId = stripPrincipalPrefix(a.target);
|
|
1405
|
-
// Tag AFTER splitting — one header per bubble, so chunk 2..N stay
|
|
1406
|
-
// attributable to the session instead of arriving anonymous.
|
|
1407
1481
|
const pieces = splitChunks(content, Math.max(200, cfg.wrc.mirror.chunkChars - TAG_HEADER_BUDGET));
|
|
1408
|
-
const chunks = pieces.map((p, i) =>
|
|
1482
|
+
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
1409
1483
|
a.standalonePending = a.standalonePending
|
|
1410
1484
|
.then(async () => {
|
|
1411
1485
|
for (const c of chunks) {
|
|
@@ -1419,6 +1493,20 @@ export const startMirror = (deps) => {
|
|
|
1419
1493
|
})
|
|
1420
1494
|
.catch(() => undefined);
|
|
1421
1495
|
};
|
|
1496
|
+
// Like sendStandalone but skips withSessionTag — content already contains the tag header (e.g. as a link).
|
|
1497
|
+
const sendRaw = (a, content) => {
|
|
1498
|
+
const chatId = stripPrincipalPrefix(a.target);
|
|
1499
|
+
a.standalonePending = a.standalonePending
|
|
1500
|
+
.then(async () => {
|
|
1501
|
+
try {
|
|
1502
|
+
await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
|
|
1503
|
+
}
|
|
1504
|
+
catch (e) {
|
|
1505
|
+
log.warn({ sessionId: a.sessionId, err: e.message }, "raw push failed");
|
|
1506
|
+
}
|
|
1507
|
+
})
|
|
1508
|
+
.catch(() => undefined);
|
|
1509
|
+
};
|
|
1422
1510
|
// Pre-card preamble recovery. Called right before an approval card when the
|
|
1423
1511
|
// gating tool_use is NOT yet in the jsonl (mirror mode: CC defers the whole
|
|
1424
1512
|
// tool-terminated turn's flush until the tool resolves — after the card is
|
|
@@ -1553,7 +1641,7 @@ export const startMirror = (deps) => {
|
|
|
1553
1641
|
flushPendingStandalone(a);
|
|
1554
1642
|
void (async () => {
|
|
1555
1643
|
try {
|
|
1556
|
-
await client.replyStream(out.frame, out.streamId,
|
|
1644
|
+
await client.replyStream(out.frame, out.streamId, withLinkedTag(a, bubbleMd || " "), true);
|
|
1557
1645
|
}
|
|
1558
1646
|
catch (e) {
|
|
1559
1647
|
log.warn({ sessionId: a.sessionId, err: e.message }, "exit-deferred finalize failed; falling back to standalone");
|
|
@@ -1649,10 +1737,17 @@ export const startMirror = (deps) => {
|
|
|
1649
1737
|
// 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
|
|
1650
1738
|
// 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
|
|
1651
1739
|
// target 作为 ww_uniq 传下去, 让同 chat 的所有 turn 详情都复用一个 WeCom 窗口。
|
|
1652
|
-
const briefDetailLink = (turnId, target) =>
|
|
1740
|
+
const briefDetailLink = (turnId, target) => {
|
|
1741
|
+
const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
|
|
1742
|
+
const tag = tagOfKey(target);
|
|
1743
|
+
return tag
|
|
1744
|
+
? `[${labelFor(tag)} #${tag}](${url}) ← View chat details`
|
|
1745
|
+
: `[🧙](${url}) ← View chat details`;
|
|
1746
|
+
};
|
|
1653
1747
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
1654
1748
|
// 排队中的 turn 也持有气泡, 所以气泡是显式入参而不是从 a 上取。
|
|
1655
|
-
|
|
1749
|
+
// raw=true skips withSessionTag (used when content already contains the linked tag header).
|
|
1750
|
+
const finishBubble = async (a, b, content, raw = false) => {
|
|
1656
1751
|
if (!b || b.done)
|
|
1657
1752
|
return;
|
|
1658
1753
|
b.done = true;
|
|
@@ -1664,15 +1759,15 @@ export const startMirror = (deps) => {
|
|
|
1664
1759
|
if (a.briefBubble === b)
|
|
1665
1760
|
a.briefBubble = undefined;
|
|
1666
1761
|
try {
|
|
1667
|
-
await client.replyStream(b.frame, b.streamId,
|
|
1762
|
+
await client.replyStream(b.frame, b.streamId, raw ? (content || " ") : withLinkedTag(a, content || " "), true);
|
|
1668
1763
|
}
|
|
1669
1764
|
catch (e) {
|
|
1670
1765
|
log.warn({ sessionId: a.sessionId, err: e.message }, "brief: bubble finish failed; standalone fallback");
|
|
1671
1766
|
if (content.trim())
|
|
1672
|
-
sendStandalone(a, content);
|
|
1767
|
+
raw ? sendRaw(a, content) : sendStandalone(a, content);
|
|
1673
1768
|
}
|
|
1674
1769
|
};
|
|
1675
|
-
const finishBriefBubble = (a, content) => finishBubble(a, a.briefBubble, content);
|
|
1770
|
+
const finishBriefBubble = (a, content, raw = false) => finishBubble(a, a.briefBubble, content, raw);
|
|
1676
1771
|
// 让一个已建好记录的 turn 成为活跃 turn。turn 级状态在这里统一归零 —— 唯一入口。
|
|
1677
1772
|
const openBriefTurn = (a, q) => {
|
|
1678
1773
|
a.briefTurnId = q.turnId;
|
|
@@ -1692,9 +1787,18 @@ export const startMirror = (deps) => {
|
|
|
1692
1787
|
a.ctxCut = undefined;
|
|
1693
1788
|
return cut;
|
|
1694
1789
|
};
|
|
1790
|
+
// graph 归因同样一次性: 一次注入只解释它开出的那一轮。超过 ORIGIN_TTL_MS 还没被
|
|
1791
|
+
// 认领 = 那次注入没能开出 turn, 印章作废 —— 宁可少标一轮, 也不能把人打的字冒认成
|
|
1792
|
+
// graph 派的 (归因错了比没有更糟)。
|
|
1793
|
+
const ORIGIN_TTL_MS = 5 * 60_000;
|
|
1794
|
+
const consumeOrigin = (a) => {
|
|
1795
|
+
const p = a.pendingOrigin;
|
|
1796
|
+
a.pendingOrigin = undefined;
|
|
1797
|
+
return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.origin : undefined;
|
|
1798
|
+
};
|
|
1695
1799
|
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "") => {
|
|
1696
1800
|
const turnId = newTurnId();
|
|
1697
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: userQuery.trim() || undefined, cut: consumeCut(a) });
|
|
1801
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, cut: consumeCut(a), origin: consumeOrigin(a) });
|
|
1698
1802
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
1699
1803
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
1700
1804
|
const q = { turnId, bubble, isSlash };
|
|
@@ -1706,7 +1810,7 @@ export const startMirror = (deps) => {
|
|
|
1706
1810
|
log.warn({ sessionId: a.sessionId, turnId }, "brief: queued turn timed out — force-closing the stuck one");
|
|
1707
1811
|
closeBriefTurn(a);
|
|
1708
1812
|
}
|
|
1709
|
-
void finishBubble(a, bubble, briefDetailLink(turnId, a.target));
|
|
1813
|
+
void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
|
|
1710
1814
|
}, HARD_TIMEOUT_MS);
|
|
1711
1815
|
// earlyTimer: 3s 无 CLI 产出 → 先把 loading 气泡收成详情链接, 用户可即时点入。
|
|
1712
1816
|
// 首条 item 到达时清除 (handleBriefItem 路径)。用显式 bubble ref 而非 a.briefBubble,
|
|
@@ -1714,7 +1818,7 @@ export const startMirror = (deps) => {
|
|
|
1714
1818
|
bubble.earlyTimer = setTimeout(() => {
|
|
1715
1819
|
if (bubble.done)
|
|
1716
1820
|
return;
|
|
1717
|
-
void finishBubble(a, bubble, briefDetailLink(turnId, a.target));
|
|
1821
|
+
void finishBubble(a, bubble, briefDetailLink(turnId, a.target), true);
|
|
1718
1822
|
}, EARLY_LINK_MS);
|
|
1719
1823
|
if (a.briefTurnId) {
|
|
1720
1824
|
(a.briefQueue ??= []).push(q);
|
|
@@ -1740,14 +1844,14 @@ export const startMirror = (deps) => {
|
|
|
1740
1844
|
const turnId = newTurnId();
|
|
1741
1845
|
const query = a.pendingBriefQuery?.replace(/^> ?/gm, "").trim();
|
|
1742
1846
|
a.pendingBriefQuery = undefined;
|
|
1743
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: query || undefined, cut: consumeCut(a) });
|
|
1847
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: query || undefined, cut: consumeCut(a), origin: consumeOrigin(a) });
|
|
1744
1848
|
a.briefTurnId = turnId;
|
|
1745
1849
|
a.briefBubble = undefined;
|
|
1746
1850
|
a.briefIsSlash = false;
|
|
1747
1851
|
a.briefHadTool = false;
|
|
1748
1852
|
a.briefConcluded = false;
|
|
1749
1853
|
a.briefLastText = undefined;
|
|
1750
|
-
|
|
1854
|
+
sendRaw(a, briefDetailLink(turnId, a.target));
|
|
1751
1855
|
log.info({ sessionId: a.sessionId, turnId }, "brief: turn started (CLI-side, no bubble)");
|
|
1752
1856
|
};
|
|
1753
1857
|
// 本轮一旦出现工具调用 / tool_result / 非 final 文本, 就说明这不是"一句话答完"的
|
|
@@ -1758,7 +1862,7 @@ export const startMirror = (deps) => {
|
|
|
1758
1862
|
if (!a.briefTurnId || !a.briefBubble || a.briefBubble.done)
|
|
1759
1863
|
return;
|
|
1760
1864
|
a.briefHadTool = true;
|
|
1761
|
-
void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target));
|
|
1865
|
+
void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
|
|
1762
1866
|
};
|
|
1763
1867
|
// 本轮结论落地, 只生效一次:
|
|
1764
1868
|
// • 无工具 → 结论 + 详情链接一并写进 loading 气泡 (仍是一条消息);
|
|
@@ -1787,7 +1891,7 @@ export const startMirror = (deps) => {
|
|
|
1787
1891
|
// 气泡还开着 (有工具的 turn: 结论只发了 standalone, 气泡留到这里收链接; 或空 turn
|
|
1788
1892
|
// 没有结论) —— 收口: 统一写详情链接, 保证每个 turn 都有可点入口。
|
|
1789
1893
|
if (a.briefBubble && !a.briefBubble.done) {
|
|
1790
|
-
void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target));
|
|
1894
|
+
void finishBriefBubble(a, briefDetailLink(a.briefTurnId, a.target), true);
|
|
1791
1895
|
}
|
|
1792
1896
|
recordTurnClose(a.briefTurnId);
|
|
1793
1897
|
log.info({ sessionId: a.sessionId, turnId: a.briefTurnId }, "brief: turn closed");
|
|
@@ -1802,6 +1906,78 @@ export const startMirror = (deps) => {
|
|
|
1802
1906
|
if (next)
|
|
1803
1907
|
openBriefTurn(a, next);
|
|
1804
1908
|
};
|
|
1909
|
+
// 强制收口一个 attachment 的全部出站通道, 并返回收掉的气泡/流条数。
|
|
1910
|
+
//
|
|
1911
|
+
// 关键约束: **一个 tmux 调用都不许有**。它的两个调用方 (`/stop` 和 inject
|
|
1912
|
+
// watchdog) 恰恰是在 tmux 已经卡死时才最需要生效 —— 历史故障里 tmux 无响应
|
|
1913
|
+
// 导致 dispatch 静默挂死, 用户在手机上看到一条永不结束的 "…" 气泡, 发 /stop
|
|
1914
|
+
// 也没用 (旧 /stop 第一件事就是 tmuxPaneAlive, 一起卡死)。所以这里只做纯内存
|
|
1915
|
+
// 状态的清算, Esc 之类要碰 pane 的动作留给调用方在这之后自己尽力去做。
|
|
1916
|
+
const teardownOutbound = (a, note) => {
|
|
1917
|
+
let closed = 0;
|
|
1918
|
+
// 排队中的 turn 先清空再动当前 turn —— 反序会让 closeBriefTurn 顺手 shift
|
|
1919
|
+
// 一个僵尸 turn 上来当活跃 turn, 白收一遍。
|
|
1920
|
+
for (const q of a.briefQueue ?? []) {
|
|
1921
|
+
closed++;
|
|
1922
|
+
void finishBubble(a, q.bubble, briefDetailLink(q.turnId, a.target));
|
|
1923
|
+
recordTurnClose(q.turnId);
|
|
1924
|
+
}
|
|
1925
|
+
a.briefQueue = [];
|
|
1926
|
+
if (a.briefTurnId) {
|
|
1927
|
+
closed++;
|
|
1928
|
+
closeBriefTurn(a);
|
|
1929
|
+
}
|
|
1930
|
+
if (a.softEnd) {
|
|
1931
|
+
clearTimeout(a.softEnd);
|
|
1932
|
+
a.softEnd = undefined;
|
|
1933
|
+
}
|
|
1934
|
+
if (a.outbound) {
|
|
1935
|
+
if (a.outbound.kind === "deferred")
|
|
1936
|
+
clearTimeout(a.outbound.timer);
|
|
1937
|
+
a.outbound = undefined;
|
|
1938
|
+
}
|
|
1939
|
+
if (a.standaloneBuf) {
|
|
1940
|
+
clearTimeout(a.standaloneBuf.timer);
|
|
1941
|
+
flushStandalone(a);
|
|
1942
|
+
}
|
|
1943
|
+
if (a.liveStream && !a.liveStream.closed) {
|
|
1944
|
+
closed++;
|
|
1945
|
+
void finalizeStream(a, a.liveStream);
|
|
1946
|
+
}
|
|
1947
|
+
if (note)
|
|
1948
|
+
sendStandalone(a, note);
|
|
1949
|
+
return closed;
|
|
1950
|
+
};
|
|
1951
|
+
// Watchdog around one inject job. The failure this exists for: every await in
|
|
1952
|
+
// the job chain used to be unbounded, so a wedged tmux server parked the job
|
|
1953
|
+
// forever — no log line, no error bubble, and the per-session queue stayed
|
|
1954
|
+
// locked behind it, muting that chat until the daemon restarted. Now the queue
|
|
1955
|
+
// is released on a deadline and the user gets told.
|
|
1956
|
+
const withInjectWatchdog = async (a, gen, job) => {
|
|
1957
|
+
let timer;
|
|
1958
|
+
const timedOut = new Promise((resolve) => {
|
|
1959
|
+
timer = setTimeout(() => resolve("timeout"), INJECT_JOB_TIMEOUT_MS);
|
|
1960
|
+
});
|
|
1961
|
+
const outcome = await Promise.race([
|
|
1962
|
+
job().then(() => "done", (e) => {
|
|
1963
|
+
log.error({ target: a.target, sessionId: a.sessionId, err: e?.message }, "inject job threw");
|
|
1964
|
+
return "done";
|
|
1965
|
+
}),
|
|
1966
|
+
timedOut,
|
|
1967
|
+
]);
|
|
1968
|
+
if (timer)
|
|
1969
|
+
clearTimeout(timer);
|
|
1970
|
+
if (outcome !== "timeout")
|
|
1971
|
+
return;
|
|
1972
|
+
// Racing already released the queue for the next message. The zombie is
|
|
1973
|
+
// still parked in tmux, so bump the generation to disarm its paste, and
|
|
1974
|
+
// detach the queue chain it thinks it owns.
|
|
1975
|
+
if ((a.injectGen ?? 0) === gen)
|
|
1976
|
+
a.injectGen = gen + 1;
|
|
1977
|
+
abortInjectQueue(a.sessionId);
|
|
1978
|
+
log.error({ target: a.target, sessionId: a.sessionId, gen: a.injectGen, timeoutMs: INJECT_JOB_TIMEOUT_MS }, "inject job watchdog fired — queue released, turn abandoned");
|
|
1979
|
+
teardownOutbound(a, `[mirror] ✗ 注入超时 ${Math.round(INJECT_JOB_TIMEOUT_MS / 1000)}s — tmux 无响应, 本轮已放弃。再发一条消息即可重试, 或 \`/new\` 换个 pane。`);
|
|
1980
|
+
};
|
|
1805
1981
|
// Route one RenderItem into the turn store. final text 会额外作为 standalone 发群。
|
|
1806
1982
|
const handleBriefItem = (a, item) => {
|
|
1807
1983
|
const turnId = a.briefTurnId;
|
|
@@ -1940,6 +2116,29 @@ export const startMirror = (deps) => {
|
|
|
1940
2116
|
clearTimeout(a.keepaliveQuiet);
|
|
1941
2117
|
a.keepaliveQuiet = undefined;
|
|
1942
2118
|
};
|
|
2119
|
+
// A reply is still a heartbeat while its letters remain a prefix of "pong"
|
|
2120
|
+
// (covers streamed partials "p"/"po"/"pon" and trailing punctuation). Anything
|
|
2121
|
+
// else means the model ignored the ping and resumed real work.
|
|
2122
|
+
const pongPrefix = (s) => "pong".startsWith(s.replace(/[^a-zA-Z]/g, "").toLowerCase());
|
|
2123
|
+
// Stop swallowing the in-flight ping turn: close its heartbeat detail turn,
|
|
2124
|
+
// lift the quiet window, and re-anchor the clocks to "real activity now" so the
|
|
2125
|
+
// resumed work resets the round budget. The caller then falls through to the
|
|
2126
|
+
// normal onItem path, letting this and the rest of the turn reach chat.
|
|
2127
|
+
const releaseKeepalive = (a) => {
|
|
2128
|
+
if (a.keepaliveTurnId) {
|
|
2129
|
+
recordTurnClose(a.keepaliveTurnId);
|
|
2130
|
+
a.keepaliveTurnId = undefined;
|
|
2131
|
+
}
|
|
2132
|
+
endKeepaliveQuiet(a);
|
|
2133
|
+
a.keepalivePongBuf = undefined;
|
|
2134
|
+
if (a.keepalive) {
|
|
2135
|
+
a.keepalive.round = 0;
|
|
2136
|
+
a.keepalive.pinging = false;
|
|
2137
|
+
a.keepalive.settledAt = 0;
|
|
2138
|
+
a.keepalive.lastRealMs = Date.now();
|
|
2139
|
+
}
|
|
2140
|
+
log.info({ target: a.target, sessionId: a.sessionId }, "keepalive: reply diverged from pong — un-swallowing resumed turn");
|
|
2141
|
+
};
|
|
1943
2142
|
const onItem = (a, item) => {
|
|
1944
2143
|
// 新 spawn 的 pane 在首次 inject 落地前吞掉所有初始输出 (greeting/system)。
|
|
1945
2144
|
if (a.muteUntilInject)
|
|
@@ -1951,30 +2150,39 @@ export const startMirror = (deps) => {
|
|
|
1951
2150
|
// cache-read) — so the detail page shows the genuine heartbeat, not a
|
|
1952
2151
|
// synthetic summary. Closed on the terminal signal (hard or soft turn_end).
|
|
1953
2152
|
if (a.keepaliveQuiet) {
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
2153
|
+
// Swallow only while the reply is consistent with a bare "pong". A text
|
|
2154
|
+
// whose accumulated letters leave the "pong" prefix, or ANY tool_use (a
|
|
2155
|
+
// heartbeat never calls tools), means the model resumed real work → release
|
|
2156
|
+
// and fall through so the turn reaches chat instead of being hidden.
|
|
2157
|
+
const nextBuf = item.kind === "text" ? (a.keepalivePongBuf ?? "") + item.body : (a.keepalivePongBuf ?? "");
|
|
2158
|
+
const divergent = item.kind === "tool_use" || (item.kind === "text" && !pongPrefix(nextBuf));
|
|
2159
|
+
if (!divergent) {
|
|
2160
|
+
if (item.kind === "text")
|
|
2161
|
+
a.keepalivePongBuf = nextBuf;
|
|
2162
|
+
const id = a.keepaliveTurnId;
|
|
2163
|
+
if (id) {
|
|
2164
|
+
const now = Date.now();
|
|
2165
|
+
if (item.kind === "text") {
|
|
2166
|
+
recordTurnItem(id, { t: "text", body: item.body, ts: now, final: item.final === true });
|
|
2167
|
+
}
|
|
2168
|
+
else if (item.kind === "tool_result") {
|
|
2169
|
+
recordTurnItem(id, { t: "tool_result", toolUseId: item.toolUseId, body: item.full, ts: now });
|
|
2170
|
+
}
|
|
2171
|
+
else if (item.kind === "turn_usage") {
|
|
2172
|
+
recordTurnUsage(id, { model: item.model, messageId: item.messageId, usage: item.usage });
|
|
2173
|
+
}
|
|
2174
|
+
else if (item.kind === "turn_end") {
|
|
2175
|
+
recordTurnClose(id);
|
|
2176
|
+
a.keepaliveTurnId = undefined;
|
|
2177
|
+
}
|
|
1969
2178
|
}
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
a.
|
|
2179
|
+
if (item.kind === "turn_end") {
|
|
2180
|
+
endKeepaliveQuiet(a);
|
|
2181
|
+
a.keepalivePongBuf = undefined;
|
|
1973
2182
|
}
|
|
2183
|
+
return;
|
|
1974
2184
|
}
|
|
1975
|
-
|
|
1976
|
-
endKeepaliveQuiet(a);
|
|
1977
|
-
return;
|
|
2185
|
+
releaseKeepalive(a); // fall through to normal handling for this item
|
|
1978
2186
|
}
|
|
1979
2187
|
// 任何新 item 到达 = 上一条"消息写完了"并不代表这一轮结束 → 撤销待确认的软收口。
|
|
1980
2188
|
// 硬信号 (end_turn / turn_duration) 的后端永远不会走到这里。
|
|
@@ -2214,6 +2422,7 @@ export const startMirror = (deps) => {
|
|
|
2214
2422
|
a.migrationWatcher.cancel();
|
|
2215
2423
|
a.migrationWatcher = undefined;
|
|
2216
2424
|
}
|
|
2425
|
+
a.clearRebind = undefined;
|
|
2217
2426
|
if (a.outbound?.kind === "deferred")
|
|
2218
2427
|
clearTimeout(a.outbound.timer);
|
|
2219
2428
|
a.outbound = undefined;
|
|
@@ -2254,7 +2463,14 @@ export const startMirror = (deps) => {
|
|
|
2254
2463
|
// Replace any existing attach with the same sessionId or same target. The
|
|
2255
2464
|
// sessionId clash is the "/wrc again from same window" case; the target
|
|
2256
2465
|
// clash is "different window steals my WeCom chat" — both end the previous.
|
|
2466
|
+
// Guard: if a DIFFERENT principal already owns this sessionId, refuse rather
|
|
2467
|
+
// than detaching it — prevents cross-wire when concurrent restoreFromStore
|
|
2468
|
+
// calls race or heal logic picks the same newest jsonl.
|
|
2257
2469
|
const prevBySid = bySessionId.get(sessionId);
|
|
2470
|
+
if (prevBySid && prevBySid.target !== target) {
|
|
2471
|
+
log.warn({ sessionId, existingTarget: prevBySid.target, incomingTarget: target }, "mirror attach: sessionId already bound to another principal, refusing");
|
|
2472
|
+
return { ok: false, reason: `sessionId ${sessionId} already bound to ${prevBySid.target}` };
|
|
2473
|
+
}
|
|
2258
2474
|
if (prevBySid)
|
|
2259
2475
|
detach(prevBySid, "sessionId reattach");
|
|
2260
2476
|
const prevByTarget = byTarget.get(target);
|
|
@@ -2318,16 +2534,9 @@ export const startMirror = (deps) => {
|
|
|
2318
2534
|
return { ok: true, sessionId, jsonlPath, target };
|
|
2319
2535
|
};
|
|
2320
2536
|
// ── Persistence: restore-from-store (lazy + boot) ────────────────────
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
stdio: ["ignore", "pipe", "ignore"],
|
|
2325
|
-
});
|
|
2326
|
-
let out = "";
|
|
2327
|
-
p.stdout?.on("data", (c) => (out += c.toString("utf8")));
|
|
2328
|
-
p.on("error", () => resolve({ code: null, stdout: "" }));
|
|
2329
|
-
p.on("close", (code) => resolve({ code, stdout: out }));
|
|
2330
|
-
});
|
|
2537
|
+
// NOTE: this used to be a second hand-rolled `spawn("tmux", …)` that shadowed
|
|
2538
|
+
// the module-level helper — and had no timeout, so `/stop` itself could hang
|
|
2539
|
+
// on a wedged server. It now resolves to the same single exec path.
|
|
2331
2540
|
// Verify a paneId still exists. `display-message -t <paneId>` succeeds iff
|
|
2332
2541
|
// the pane is alive — and tmux pane ids monotonically increment within a
|
|
2333
2542
|
// server lifetime, so a freed id will not be silently reused. We don't need
|
|
@@ -2549,6 +2758,26 @@ export const startMirror = (deps) => {
|
|
|
2549
2758
|
return new Set();
|
|
2550
2759
|
}
|
|
2551
2760
|
};
|
|
2761
|
+
// Every `/clear` the daemon injects, keyed by the project dir it rotates in.
|
|
2762
|
+
// Two chats mirroring panes in the SAME dir (the norm — one repo, many chats)
|
|
2763
|
+
// both produce an indistinguishable post-clear transcript; whichever watcher
|
|
2764
|
+
// ticks first would otherwise claim whichever file landed first and the two
|
|
2765
|
+
// chats stay cross-wired forever (persisted to the store). Recorded BEFORE
|
|
2766
|
+
// the inject so a sibling armed earlier still sees the overlap.
|
|
2767
|
+
const CLEAR_WINDOW_MS = 5 * 60_000; // == migration watcher TIMEOUT_MS
|
|
2768
|
+
const clearInjects = [];
|
|
2769
|
+
const noteClearInject = (a) => {
|
|
2770
|
+
const cut = Date.now() - CLEAR_WINDOW_MS;
|
|
2771
|
+
for (let i = clearInjects.length - 1; i >= 0; i--)
|
|
2772
|
+
if (clearInjects[i].at < cut)
|
|
2773
|
+
clearInjects.splice(i, 1);
|
|
2774
|
+
clearInjects.push({ dir: dirname(a.jsonlPath), target: a.target, at: Date.now() });
|
|
2775
|
+
};
|
|
2776
|
+
const siblingClearedSameDir = (a) => {
|
|
2777
|
+
const cut = Date.now() - CLEAR_WINDOW_MS;
|
|
2778
|
+
const dir = dirname(a.jsonlPath);
|
|
2779
|
+
return clearInjects.some((r) => r.at >= cut && r.dir === dir && r.target !== a.target);
|
|
2780
|
+
};
|
|
2552
2781
|
// Post-/clear identification: claude rotates the session IMMEDIATELY on
|
|
2553
2782
|
// /clear (not on next user input as the original design assumed) and writes
|
|
2554
2783
|
// the `/clear` command itself as the first non-meta user line of the brand-
|
|
@@ -2620,6 +2849,14 @@ export const startMirror = (deps) => {
|
|
|
2620
2849
|
// migration omits it (→ tail from EOF) because the fork file is seeded with
|
|
2621
2850
|
// the FULL prior transcript and replaying from 0 would re-dump it to WeCom.
|
|
2622
2851
|
const migrateAttachment = (a, newSessionId, newJsonlPath, startOffset) => {
|
|
2852
|
+
// Guard: if newSessionId is already bound to a DIFFERENT principal, abort —
|
|
2853
|
+
// prevents silent cross-wire (path B: /clear rotation or drift landing on
|
|
2854
|
+
// a sibling's live session).
|
|
2855
|
+
const incumbent = bySessionId.get(newSessionId);
|
|
2856
|
+
if (incumbent && incumbent.target !== a.target) {
|
|
2857
|
+
log.warn({ target: a.target, newSessionId, incumbentTarget: incumbent.target }, "migrateAttachment: target sid owned by another principal, aborting migration");
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2623
2860
|
const oldSessionId = a.sessionId;
|
|
2624
2861
|
const oldJsonlPath = a.jsonlPath;
|
|
2625
2862
|
// 迁移是所有会话轮换的唯一漏斗 (注入的 /clear、TUI 里手打的 /clear、resume fork、
|
|
@@ -2678,7 +2915,9 @@ export const startMirror = (deps) => {
|
|
|
2678
2915
|
// (predicate = first user line is /clear; replay from 0) and dead-pane resume
|
|
2679
2916
|
// (predicate = file has real user content; tail from EOF — the fork is seeded
|
|
2680
2917
|
// with full history). `startOffset` flows through to migrateAttachment.
|
|
2681
|
-
|
|
2918
|
+
// `exclusive` (the /clear flavor) refuses to claim on ambiguous evidence —
|
|
2919
|
+
// see claimIsAttributable.
|
|
2920
|
+
const startMigrationWatcher = (a, baseline, isChild, startOffset, exclusive) => {
|
|
2682
2921
|
if (a.migrationWatcher)
|
|
2683
2922
|
a.migrationWatcher.cancel(); // re-armed (e.g. /clear twice, or respawn during a pending watch)
|
|
2684
2923
|
const projectDir = dirname(a.jsonlPath);
|
|
@@ -2720,16 +2959,28 @@ export const startMirror = (deps) => {
|
|
|
2720
2959
|
return 0;
|
|
2721
2960
|
} })() }))
|
|
2722
2961
|
.sort((x, y) => y.mtime - x.mtime);
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
}
|
|
2962
|
+
const matches = ranked.filter((c) => isChild(join(projectDir, c.n)));
|
|
2963
|
+
// A `/clear` rotation is identified only by "first user line is /clear",
|
|
2964
|
+
// which every chat's /clear produces — the file carries nothing tying it
|
|
2965
|
+
// to a pane. So claim only on unambiguous evidence: exactly one match,
|
|
2966
|
+
// and no sibling attachment in this dir cleared inside the same window.
|
|
2967
|
+
// Otherwise stand down and let the next inject's text fingerprint decide
|
|
2968
|
+
// (see armSilentForkRebind + a.clearRebind) — a wrong pick here would
|
|
2969
|
+
// permanently wire this chat onto another chat's pane.
|
|
2970
|
+
if (exclusive && matches.length > 0 && (matches.length > 1 || siblingClearedSameDir(a))) {
|
|
2971
|
+
log.warn({ target: a.target, candidates: matches.map((c) => c.n) }, "mirror migration: ambiguous /clear rotation, deferring to inject fingerprint");
|
|
2972
|
+
timer = setTimeout(tick, POLL_MS);
|
|
2973
|
+
return;
|
|
2974
|
+
}
|
|
2975
|
+
const hit = matches[0];
|
|
2976
|
+
if (hit) {
|
|
2977
|
+
stopped = true;
|
|
2978
|
+
a.migrationWatcher = undefined;
|
|
2979
|
+
a.clearRebind = undefined;
|
|
2980
|
+
const newSid = hit.n.replace(/\.jsonl$/, "");
|
|
2981
|
+
if (newSid !== a.sessionId)
|
|
2982
|
+
migrateAttachment(a, newSid, join(projectDir, hit.n), startOffset);
|
|
2983
|
+
return;
|
|
2733
2984
|
}
|
|
2734
2985
|
timer = setTimeout(tick, POLL_MS);
|
|
2735
2986
|
};
|
|
@@ -2760,13 +3011,26 @@ export const startMirror = (deps) => {
|
|
|
2760
3011
|
// not our fork. A genuine TUI fork is a brand-new, unowned session.
|
|
2761
3012
|
// The fingerprint itself is lengthened (and its min-length raised) so short /
|
|
2762
3013
|
// common messages can't collide onto a stranger's transcript in the first place.
|
|
3014
|
+
//
|
|
3015
|
+
// It doubles as the resolver for a `/clear` rotation the dir-scan watcher
|
|
3016
|
+
// refused to attribute (a.clearRebind): same mechanism, but the candidate set
|
|
3017
|
+
// narrows to transcripts born after the clear, which makes a short
|
|
3018
|
+
// fingerprint safe enough to accept.
|
|
2763
3019
|
const armSilentForkRebind = (a, text) => {
|
|
2764
|
-
|
|
3020
|
+
const pendingClear = a.clearRebind;
|
|
3021
|
+
if (pendingClear) {
|
|
3022
|
+
// Hand over: the fingerprint is pane-certain, the dir-scan is not.
|
|
3023
|
+
a.migrationWatcher?.cancel();
|
|
3024
|
+
a.migrationWatcher = undefined;
|
|
3025
|
+
a.clearRebind = undefined; // one-shot — this inject is the evidence
|
|
3026
|
+
}
|
|
3027
|
+
else if (a.migrationWatcher) {
|
|
2765
3028
|
return; // don't race an in-flight watcher
|
|
3029
|
+
}
|
|
2766
3030
|
if (!a.tmuxPane)
|
|
2767
3031
|
return; // spawn-mode: no pane to fork under
|
|
2768
3032
|
const stripped = text.replace(/\s+/gu, "");
|
|
2769
|
-
if (stripped.length < 16)
|
|
3033
|
+
if (stripped.length < (pendingClear ? 4 : 16))
|
|
2770
3034
|
return; // too short to fingerprint safely
|
|
2771
3035
|
const fp = stripped.slice(0, 120); // long contiguous run → collision-resistant
|
|
2772
3036
|
const dir = dirname(a.jsonlPath);
|
|
@@ -2821,6 +3085,9 @@ export const startMirror = (deps) => {
|
|
|
2821
3085
|
const boundName = boundPath.slice(dir.length + 1);
|
|
2822
3086
|
const ranked = names
|
|
2823
3087
|
.filter((n) => n !== boundName)
|
|
3088
|
+
// Post-/clear: only transcripts that didn't exist before the rotation
|
|
3089
|
+
// can be ours, which is what licenses the shorter fingerprint above.
|
|
3090
|
+
.filter((n) => !pendingClear || !pendingClear.baseline.has(n))
|
|
2824
3091
|
.map((n) => ({ n, m: (() => { try {
|
|
2825
3092
|
return statSync(join(dir, n)).mtimeMs;
|
|
2826
3093
|
}
|
|
@@ -2918,6 +3185,9 @@ export const startMirror = (deps) => {
|
|
|
2918
3185
|
// from the running cwd). Decoupling means /pwd can show truth even after
|
|
2919
3186
|
// the AI sets a new path but the user hasn't /new'd yet.
|
|
2920
3187
|
const expandedDefaultCwd = expandHome(cfg.wrc.cwd);
|
|
3188
|
+
// Long absolute paths wrap in the WeCom bubble — show only the trailing
|
|
3189
|
+
// three segments, which is enough to identify the project.
|
|
3190
|
+
const shortCwd = (p) => p.split("/").filter(Boolean).slice(-3).join("/");
|
|
2921
3191
|
const renderProjectInfo = (target) => {
|
|
2922
3192
|
const a = byTarget.get(target);
|
|
2923
3193
|
const rec = a ? undefined : deps.store.get(target);
|
|
@@ -2926,17 +3196,20 @@ export const startMirror = (deps) => {
|
|
|
2926
3196
|
// in this chat, so read it from the shared base slot rather than the
|
|
2927
3197
|
// caller's own (now-empty) attachment record.
|
|
2928
3198
|
const pending = chatCwdFallback(target).pending;
|
|
2929
|
-
const lines = [`📂
|
|
3199
|
+
const lines = [`📂 cwd: \`${shortCwd(running)}\``];
|
|
2930
3200
|
if (pending && pending !== running) {
|
|
2931
|
-
lines.push(`下次切换: \`${pending}\` (使用 /new 或 /clear 生效)`);
|
|
3201
|
+
lines.push(`下次切换: \`${shortCwd(pending)}\` (使用 /new 或 /clear 生效)`);
|
|
2932
3202
|
}
|
|
2933
3203
|
// Session-boundary footer — `/new` and `/clear` are the only two callers,
|
|
2934
3204
|
// so the tip lands exactly once per fresh context, never mid-conversation.
|
|
2935
3205
|
lines.push(randomTip());
|
|
2936
3206
|
return lines.join("\n");
|
|
2937
3207
|
};
|
|
2938
|
-
|
|
2939
|
-
|
|
3208
|
+
// `header` folds the caller's ack ("created") into this same bubble — /new
|
|
3209
|
+
// must land as exactly ONE WeCom message, not card + separate reply.
|
|
3210
|
+
const pushProjectInfo = (target, header) => {
|
|
3211
|
+
const info = renderProjectInfo(target);
|
|
3212
|
+
const md = header ? `${header}\n\n${info}` : info;
|
|
2940
3213
|
const a = byTarget.get(target);
|
|
2941
3214
|
if (a) {
|
|
2942
3215
|
sendStandalone(a, md);
|
|
@@ -3066,7 +3339,7 @@ export const startMirror = (deps) => {
|
|
|
3066
3339
|
}
|
|
3067
3340
|
}
|
|
3068
3341
|
if (!opts?.silent)
|
|
3069
|
-
pushProjectInfo(target);
|
|
3342
|
+
pushProjectInfo(target, "created");
|
|
3070
3343
|
return { ok: true, sessionId: r.sessionId, cwd: r.cwd };
|
|
3071
3344
|
};
|
|
3072
3345
|
const getCwd = (target) => {
|
|
@@ -3180,7 +3453,7 @@ export const startMirror = (deps) => {
|
|
|
3180
3453
|
return {
|
|
3181
3454
|
target: t,
|
|
3182
3455
|
tag,
|
|
3183
|
-
label: tag ? labelFor(tag) : "
|
|
3456
|
+
label: tag ? labelFor(tag) : "🧙",
|
|
3184
3457
|
sessionId: a?.sessionId || rec?.sessionId || "",
|
|
3185
3458
|
jsonlPath,
|
|
3186
3459
|
cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
|
|
@@ -3207,6 +3480,17 @@ export const startMirror = (deps) => {
|
|
|
3207
3480
|
const raw = await capturePaneTail(pane, Math.max(8, rows) + 12);
|
|
3208
3481
|
return { ok: true, pane: compactPane(raw, rows), busy: paneIsBusy(raw) };
|
|
3209
3482
|
};
|
|
3483
|
+
// Reading a peer's conversation is a transcript job, not a terminal job: the
|
|
3484
|
+
// jsonl holds whole role-tagged messages, while a pane capture is ANSI-laden
|
|
3485
|
+
// and clipped at the viewport edge. Only `busy` still comes from the pane.
|
|
3486
|
+
const peekTurns = async (target, n = 6) => {
|
|
3487
|
+
const jsonl = jsonlOf(target);
|
|
3488
|
+
const busy = await isBusy(target);
|
|
3489
|
+
if (!jsonl || !existsSync(jsonl))
|
|
3490
|
+
return { ok: false, reason: "no transcript bound for target", busy };
|
|
3491
|
+
const dialog = renderDialog(tailTurns(jsonl, n));
|
|
3492
|
+
return dialog ? { ok: true, dialog, busy } : { ok: false, reason: "transcript has no turns yet", busy };
|
|
3493
|
+
};
|
|
3210
3494
|
// ── Prompt-cache keepalive ──────────────────────────────────────────
|
|
3211
3495
|
// Anthropic's prompt cache expires ~5min after the last request. A pane that
|
|
3212
3496
|
// goes idle (agent parked on a peer, background task running) lets the whole
|
|
@@ -3216,7 +3500,6 @@ export const startMirror = (deps) => {
|
|
|
3216
3500
|
// anchored to the last REAL (non-ping) activity, NOT to our own pings — so a
|
|
3217
3501
|
// dead session is never kept warm forever, and once real work is older than
|
|
3218
3502
|
// `maxIdleSec` we stop and let the cache die.
|
|
3219
|
-
const fmtTokens = (n) => n >= 1000 ? `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` : String(n);
|
|
3220
3503
|
// After `/stop`, ignore pane-busy as a resume signal for this long — long
|
|
3221
3504
|
// enough for an in-flight ping (interrupted by the same /stop's Esc) to settle,
|
|
3222
3505
|
// so it can't self-resume the pause. A genuine new turn after this window does.
|
|
@@ -3226,25 +3509,31 @@ export const startMirror = (deps) => {
|
|
|
3226
3509
|
// bump mtime without being a model turn, which would fake a long-dead session
|
|
3227
3510
|
// back to "just active" and pin it in an endless ping loop. lastRealMs stays
|
|
3228
3511
|
// anchored on genuine work, so the tick's realIdle guard can finally let it die.
|
|
3229
|
-
const initKeepalive = (jsonlPath, mtime,
|
|
3512
|
+
const initKeepalive = (jsonlPath, mtime, pingSigs) => {
|
|
3230
3513
|
let lastMs = 0;
|
|
3231
3514
|
let lastRealMs = 0;
|
|
3232
3515
|
try {
|
|
3233
|
-
const s = keepaliveStamps(jsonlPath,
|
|
3516
|
+
const s = keepaliveStamps(jsonlPath, pingSigs);
|
|
3234
3517
|
lastMs = s.lastMs;
|
|
3235
3518
|
lastRealMs = s.lastRealMs;
|
|
3236
3519
|
}
|
|
3237
3520
|
catch { /* unreadable tail */ }
|
|
3238
|
-
return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round: 0 };
|
|
3521
|
+
return { lastMs, lastRealMs, seenMtime: mtime, pinging: false, pingMtime: 0, round: 0, settledAt: 0 };
|
|
3239
3522
|
};
|
|
3240
|
-
const fireKeepalive = async (a) => {
|
|
3523
|
+
const fireKeepalive = async (a, stalled) => {
|
|
3241
3524
|
const kc = cfg.wrc.mirror.keepalive;
|
|
3242
|
-
//
|
|
3243
|
-
//
|
|
3244
|
-
//
|
|
3245
|
-
//
|
|
3246
|
-
//
|
|
3247
|
-
|
|
3525
|
+
// Stalled (error/limit banner + idle pane): send the full resume instruction
|
|
3526
|
+
// — inviting the model to finish genuinely-unfinished work, or to reply
|
|
3527
|
+
// "pong" if it was legitimately parked. Otherwise the plain warmer, the SAME
|
|
3528
|
+
// full instruction every round: a bare "ping" used to be sent from round 2 on
|
|
3529
|
+
// (the instruction was already in context), but the model reads a bare "ping"
|
|
3530
|
+
// as an open turn and answers it however it likes — anything other than
|
|
3531
|
+
// "pong" un-swallows the heartbeat into chat and re-anchors the clocks as
|
|
3532
|
+
// real activity. Restating the instruction is a few tokens of cache-write and
|
|
3533
|
+
// buys a deterministic reply. keepaliveStamps matches every form, so none
|
|
3534
|
+
// reads as real activity. (round is incremented below, after the inject lands.)
|
|
3535
|
+
const text = stalled ? kc.resumePing : kc.ping;
|
|
3536
|
+
a.keepalivePongBuf = ""; // fresh reply accumulator for the swallow/release gate
|
|
3248
3537
|
// Suppress the pane→WeCom echo of the ping user line, then swallow the whole
|
|
3249
3538
|
// ping turn (reply included). The 60s fail-safe clears the quiet window if
|
|
3250
3539
|
// the turn somehow never emits turn_end, so a later real turn is never muted;
|
|
@@ -3270,10 +3559,8 @@ export const startMirror = (deps) => {
|
|
|
3270
3559
|
return;
|
|
3271
3560
|
}
|
|
3272
3561
|
const tokens = lastContextTokens(a.jsonlPath);
|
|
3273
|
-
const size = tokens > 0 ? `~${fmtTokens(tokens)} tokens` : "未知";
|
|
3274
|
-
const totalRounds = Math.max(1, kc.rounds);
|
|
3275
3562
|
const round = a.keepalive ? (a.keepalive.round += 1) : 1;
|
|
3276
|
-
log.info({ target: a.target, tokens, round, totalRounds }, "keepalive: ping injected");
|
|
3563
|
+
log.info({ target: a.target, tokens, round, totalRounds: Math.max(1, kc.rounds) }, "keepalive: ping injected");
|
|
3277
3564
|
// Open a chat-detail turn for the real heartbeat exchange: userQuery is the
|
|
3278
3565
|
// actual ping we injected; the assistant reply (expected: just "pong"), any
|
|
3279
3566
|
// tool calls, and usage are grafted on from the swallowed items in onItem;
|
|
@@ -3281,8 +3568,6 @@ export const startMirror = (deps) => {
|
|
|
3281
3568
|
const turnId = newTurnId();
|
|
3282
3569
|
a.keepaliveTurnId = turnId;
|
|
3283
3570
|
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
|
|
3284
|
-
if (kc.notify)
|
|
3285
|
-
sendStandalone(a, `KeepAlive ${round}/${totalRounds} · ${size}`);
|
|
3286
3571
|
};
|
|
3287
3572
|
let keepaliveTicking = false;
|
|
3288
3573
|
const keepaliveTick = async () => {
|
|
@@ -3293,7 +3578,7 @@ export const startMirror = (deps) => {
|
|
|
3293
3578
|
try {
|
|
3294
3579
|
const idleTriggerMs = Math.max(30, kc.ttlSec - kc.marginSec) * 1000;
|
|
3295
3580
|
const ttlMs = kc.ttlSec * 1000;
|
|
3296
|
-
const
|
|
3581
|
+
const pingSigs = [normAssistant(kc.ping).slice(0, 40), normAssistant(kc.resumePing).slice(0, 40)].filter((s) => s.length > 0);
|
|
3297
3582
|
const now = Date.now();
|
|
3298
3583
|
for (const a of byTarget.values()) {
|
|
3299
3584
|
if (!a.tmuxPane)
|
|
@@ -3313,14 +3598,14 @@ export const startMirror = (deps) => {
|
|
|
3313
3598
|
}
|
|
3314
3599
|
if (!mtime)
|
|
3315
3600
|
continue;
|
|
3316
|
-
const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime,
|
|
3601
|
+
const k = (a.keepalive ??= initKeepalive(a.jsonlPath, mtime, pingSigs));
|
|
3317
3602
|
// File mtime is only a cheap "did anything change → re-read the tail" gate.
|
|
3318
3603
|
// The clocks themselves come from message turns, so metadata churn (which
|
|
3319
3604
|
// bumps mtime but is not a turn) can never move them. `grewSinceLast` now
|
|
3320
3605
|
// means a NEW message turn appeared — not that the file grew.
|
|
3321
3606
|
const prevRealMs = k.lastRealMs;
|
|
3322
3607
|
if (mtime > k.seenMtime + 1000) {
|
|
3323
|
-
const s = keepaliveStamps(a.jsonlPath,
|
|
3608
|
+
const s = keepaliveStamps(a.jsonlPath, pingSigs);
|
|
3324
3609
|
if (s.stamped) {
|
|
3325
3610
|
k.lastMs = s.lastMs;
|
|
3326
3611
|
k.lastRealMs = s.lastRealMs;
|
|
@@ -3335,13 +3620,15 @@ export const startMirror = (deps) => {
|
|
|
3335
3620
|
// keepalive ping+pong advances lastMs but NOT lastRealMs, so the heartbeat
|
|
3336
3621
|
// no longer reads as activity to itself (which was pinning round at 1/N).
|
|
3337
3622
|
const grewSinceLast = k.lastRealMs > prevRealMs + 1000;
|
|
3338
|
-
const
|
|
3623
|
+
const paneTail = await capturePaneTail(a.tmuxPane, 16);
|
|
3624
|
+
const busy = paneIsBusy(paneTail);
|
|
3339
3625
|
if (k.pinging) {
|
|
3340
3626
|
// Waiting for our own ping to land and settle: until a NEWER message turn
|
|
3341
3627
|
// (the ping's own user line) appears and the pane goes idle, keep waiting.
|
|
3342
3628
|
if (busy || k.lastMs <= k.pingMtime)
|
|
3343
3629
|
continue;
|
|
3344
3630
|
k.pinging = false;
|
|
3631
|
+
k.settledAt = now;
|
|
3345
3632
|
continue;
|
|
3346
3633
|
}
|
|
3347
3634
|
// A new real message turn (or a busy pane) re-anchors: reset the round
|
|
@@ -3349,8 +3636,12 @@ export const startMirror = (deps) => {
|
|
|
3349
3636
|
// message-based now, so `/stop`'s Esc settling writes (metadata, no new
|
|
3350
3637
|
// turn) can no longer self-resume the pause — only a busy pane past grace,
|
|
3351
3638
|
// or a WeCom inbound in dispatch, does.
|
|
3352
|
-
|
|
3353
|
-
|
|
3639
|
+
// Suppress the grewSinceLast reset for 30s after pinging settles — the
|
|
3640
|
+
// pong's tail flush can lag a tick and would otherwise fake real activity.
|
|
3641
|
+
const justSettled = k.settledAt > 0 && now - k.settledAt < 30_000;
|
|
3642
|
+
if (busy || (grewSinceLast && !justSettled)) {
|
|
3643
|
+
k.round = 0;
|
|
3644
|
+
k.settledAt = 0;
|
|
3354
3645
|
if (a.keepaliveOff && busy && now - (a.keepaliveOffAt ?? 0) > RESUME_GRACE_MS) {
|
|
3355
3646
|
a.keepaliveOff = false;
|
|
3356
3647
|
persistPause(a);
|
|
@@ -3367,9 +3658,14 @@ export const startMirror = (deps) => {
|
|
|
3367
3658
|
continue; // cache already cold — a ping would cold-rewrite for nothing
|
|
3368
3659
|
if (k.round >= kc.rounds)
|
|
3369
3660
|
continue; // budget spent — let it go cold
|
|
3661
|
+
// Stall recovery, decided by RULE only (no model self-judgment): the last
|
|
3662
|
+
// transcript turn is a synthetic API-error/limit line, or the idle pane
|
|
3663
|
+
// still shows an error banner ⇒ a turn died mid-work. Send the resume
|
|
3664
|
+
// instruction instead of the plain warmer.
|
|
3665
|
+
const stalled = kc.resumeOnStall && (transcriptStalled(a.jsonlPath) || paneIsStalled(paneTail));
|
|
3370
3666
|
k.pinging = true;
|
|
3371
3667
|
k.pingMtime = k.lastMs; // settles when a newer turn (the ping's own) appears
|
|
3372
|
-
await fireKeepalive(a);
|
|
3668
|
+
await fireKeepalive(a, stalled);
|
|
3373
3669
|
}
|
|
3374
3670
|
}
|
|
3375
3671
|
catch (e) {
|
|
@@ -3384,6 +3680,7 @@ export const startMirror = (deps) => {
|
|
|
3384
3680
|
attach,
|
|
3385
3681
|
peers,
|
|
3386
3682
|
peekPane,
|
|
3683
|
+
peekTurns,
|
|
3387
3684
|
isBusy,
|
|
3388
3685
|
lastText,
|
|
3389
3686
|
status: () => {
|
|
@@ -3543,7 +3840,7 @@ export const startMirror = (deps) => {
|
|
|
3543
3840
|
// 队列内单条失败已在 sendStandalone 里 warn 过, 这里吞掉, 不阻塞发卡。
|
|
3544
3841
|
}
|
|
3545
3842
|
},
|
|
3546
|
-
injectText: async (target, text) => {
|
|
3843
|
+
injectText: async (target, text, origin) => {
|
|
3547
3844
|
// Lazy restore, same as dispatch: a peer session that hasn't been talked
|
|
3548
3845
|
// to in this process lifetime (or any session after a reload) has an empty
|
|
3549
3846
|
// in-memory slot but a perfectly good persisted binding. Without this, an
|
|
@@ -3557,6 +3854,10 @@ export const startMirror = (deps) => {
|
|
|
3557
3854
|
// recentInjects dedupe (otherwise the user sees their own demo prompt
|
|
3558
3855
|
// echoed back as a quoted bubble).
|
|
3559
3856
|
rememberInject(text);
|
|
3857
|
+
// 印章要早于 inject 落地 —— tail 是独立轮询的, 它可能在 inject 的 promise
|
|
3858
|
+
// resolve 之前就看到 user 行并开出 turn, 那时归因必须已经在位。
|
|
3859
|
+
if (origin)
|
|
3860
|
+
a.pendingOrigin = { origin, at: Date.now() };
|
|
3560
3861
|
const sid = a.sessionId;
|
|
3561
3862
|
const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
|
|
3562
3863
|
if (!paneAlive) {
|
|
@@ -3576,23 +3877,40 @@ export const startMirror = (deps) => {
|
|
|
3576
3877
|
}
|
|
3577
3878
|
// freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
|
|
3578
3879
|
// is still warming up so the verifier in injectViaTmux needs the slack.
|
|
3579
|
-
|
|
3880
|
+
const r = await inject({
|
|
3580
3881
|
text, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
3581
3882
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: true,
|
|
3582
3883
|
});
|
|
3884
|
+
// 与 dispatch 同一约定: 首条 inject 落地 = 解除新 pane 的初始输出静默。
|
|
3885
|
+
// 漏掉这一步的后果不只是少几条气泡 —— onItem 在 mute 分支整体早退, 连
|
|
3886
|
+
// recordTurnStart 都不会跑, 于是 graph/send_peer 拉起来的节点在 chat 列表
|
|
3887
|
+
// 和 chat 详情里彻底不存在(它从来没被 WeCom 侧 dispatch 过, 没有第二次机会)。
|
|
3888
|
+
if (r.ok) {
|
|
3889
|
+
a.muteUntilInject = false;
|
|
3890
|
+
a.justSpawned = false;
|
|
3891
|
+
}
|
|
3892
|
+
return r;
|
|
3583
3893
|
},
|
|
3584
|
-
interruptPane: async (target) => {
|
|
3894
|
+
interruptPane: async (target, opts) => {
|
|
3585
3895
|
const a = byTarget.get(target);
|
|
3586
3896
|
if (!a)
|
|
3587
3897
|
return { ok: false, reason: "no mirror attached for target" };
|
|
3588
|
-
|
|
3898
|
+
// `/stop` (teardown: true) must work when tmux itself is the problem, so
|
|
3899
|
+
// the order is: local state first, pane second. The old implementation
|
|
3900
|
+
// probed the pane FIRST and returned early on failure — meaning a wedged
|
|
3901
|
+
// tmux server left the user with a "…" bubble that /stop could not close
|
|
3902
|
+
// and an inject queue no message could get past. Callers that only want
|
|
3903
|
+
// an Esc (the `.claude/**` guard's cancel) pass no opts and keep the old
|
|
3904
|
+
// pane-only behavior, bubbles untouched.
|
|
3905
|
+
let torndown = 0;
|
|
3906
|
+
if (opts?.teardown) {
|
|
3907
|
+
a.injectGen = (a.injectGen ?? 0) + 1; // disarm a zombie inject's paste
|
|
3908
|
+
abortInjectQueue(a.sessionId); // unblock the next message
|
|
3909
|
+
torndown = teardownOutbound(a); // close every hanging bubble/stream
|
|
3910
|
+
}
|
|
3911
|
+
else if (!a.tmuxPane) {
|
|
3589
3912
|
return { ok: false, reason: "no live tmux pane (spawn-mode attachment)" };
|
|
3590
|
-
|
|
3591
|
-
if (!alive)
|
|
3592
|
-
return { ok: false, reason: "tmux pane no longer alive" };
|
|
3593
|
-
const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
|
|
3594
|
-
if (r.code !== 0)
|
|
3595
|
-
return { ok: false, reason: `send-keys Escape failed: ${r.stdout.slice(-200) || r.code}` };
|
|
3913
|
+
}
|
|
3596
3914
|
// /stop also pauses keepalive: the user is deliberately quieting this
|
|
3597
3915
|
// session, so stop poking it too. A real turn (WeCom inbound, or the pane
|
|
3598
3916
|
// going busy AFTER the resume grace) lifts the pause and re-earns the
|
|
@@ -3602,9 +3920,56 @@ export const startMirror = (deps) => {
|
|
|
3602
3920
|
a.keepaliveOffAt = Date.now();
|
|
3603
3921
|
a.keepalive = undefined; // re-anchors cleanly on resume
|
|
3604
3922
|
persistPause(a); // survive a reload — don't resurrect a quieted session
|
|
3923
|
+
// Best-effort Esc. Every failure mode here is reported, never fatal: the
|
|
3924
|
+
// teardown above already gave the user their chat back.
|
|
3925
|
+
let escOk = false;
|
|
3926
|
+
let escReason = "";
|
|
3927
|
+
if (!a.tmuxPane) {
|
|
3928
|
+
escReason = "spawn-mode attachment (no live tmux pane)";
|
|
3929
|
+
}
|
|
3930
|
+
else if (!(await tmuxPaneAlive(a.tmuxPane))) {
|
|
3931
|
+
escReason = "tmux pane no longer alive";
|
|
3932
|
+
}
|
|
3933
|
+
else {
|
|
3934
|
+
const r = await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
|
|
3935
|
+
escOk = r.ok;
|
|
3936
|
+
if (!r.ok)
|
|
3937
|
+
escReason = `send-keys Escape failed: ${(r.stderr || r.stdout).slice(-200) || r.code}`;
|
|
3938
|
+
}
|
|
3605
3939
|
// Any in-flight ping's quiet window + detail turn are left to close on their
|
|
3606
3940
|
// own turn_end (or the 60s fail-safe), so the interrupted pong stays out of chat.
|
|
3607
|
-
log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /stop —
|
|
3941
|
+
log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane, torndown, escOk, escReason, gen: a.injectGen }, "mirror /stop — teardown + Esc");
|
|
3942
|
+
// Esc failure is not an overall failure when we tore down: the user asked
|
|
3943
|
+
// for quiet and got it. Without teardown there's nothing else to report,
|
|
3944
|
+
// so keep the old strict contract.
|
|
3945
|
+
if (!opts?.teardown && !escOk)
|
|
3946
|
+
return { ok: false, reason: escReason };
|
|
3947
|
+
return { ok: true, torndown, escOk, escReason: escReason || undefined };
|
|
3948
|
+
},
|
|
3949
|
+
killPane: async (target) => {
|
|
3950
|
+
// paneOf (not byTarget.get) so a binding surviving only in the persisted
|
|
3951
|
+
// store — e.g. after a reload that couldn't re-attach — is still killable.
|
|
3952
|
+
const a = byTarget.get(target);
|
|
3953
|
+
const pane = a?.tmuxPane || paneOf(target);
|
|
3954
|
+
if (!a && !pane)
|
|
3955
|
+
return { ok: false, reason: "no session bound to target" };
|
|
3956
|
+
if (pane && (await tmuxPaneAlive(pane))) {
|
|
3957
|
+
// Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
|
|
3958
|
+
// flush its transcript before the TTY is yanked out from under it.
|
|
3959
|
+
await tmuxRun(["send-keys", "-t", pane, "Escape"]);
|
|
3960
|
+
await sleepMs(250);
|
|
3961
|
+
const r = await tmuxRun(["kill-pane", "-t", pane]);
|
|
3962
|
+
if (r.code !== 0)
|
|
3963
|
+
return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
|
|
3964
|
+
}
|
|
3965
|
+
if (a)
|
|
3966
|
+
detach(a, "/kill");
|
|
3967
|
+
// Drop the persisted record too — keeping it would let the next inbound
|
|
3968
|
+
// resurrect this very session via the dead-pane `--resume` self-heal,
|
|
3969
|
+
// which is the opposite of what /kill means. The chat then auto-spawns a
|
|
3970
|
+
// fresh session on its next message.
|
|
3971
|
+
deps.store.drop(target);
|
|
3972
|
+
log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
|
|
3608
3973
|
return { ok: true };
|
|
3609
3974
|
},
|
|
3610
3975
|
submitPane: async (target) => {
|
|
@@ -3651,6 +4016,50 @@ export const startMirror = (deps) => {
|
|
|
3651
4016
|
log.info({ target, pane, client: best }, "mirror /reveal — tmux client switched");
|
|
3652
4017
|
return { ok: true };
|
|
3653
4018
|
},
|
|
4019
|
+
hasLivePane: (sessionId) => Boolean(bySessionId.get(sessionId)?.tmuxPane),
|
|
4020
|
+
answerNativeModal: async (sessionId, opts) => {
|
|
4021
|
+
const a = bySessionId.get(sessionId);
|
|
4022
|
+
if (!a?.tmuxPane)
|
|
4023
|
+
return { status: "no_pane", reason: "no live tmux pane for session" };
|
|
4024
|
+
if (!(await tmuxPaneAlive(a.tmuxPane)))
|
|
4025
|
+
return { status: "no_pane", reason: "tmux pane no longer alive" };
|
|
4026
|
+
const pane = a.tmuxPane;
|
|
4027
|
+
const lg = log.child({ sessionId, pane, sub: "native-modal" });
|
|
4028
|
+
// CC 在 hook 返回**之后**才渲染这个框, 所以进来时它通常还没出现 —— 轮询等它。
|
|
4029
|
+
const POLL_MS = 200;
|
|
4030
|
+
const deadline = Date.now() + Math.max(0, opts.waitMs);
|
|
4031
|
+
let v = await detectModalPicker(pane);
|
|
4032
|
+
while (!v.modal && Date.now() < deadline) {
|
|
4033
|
+
await sleepMs(POLL_MS);
|
|
4034
|
+
v = await detectModalPicker(pane);
|
|
4035
|
+
}
|
|
4036
|
+
// 没等到: 这次 CC 没弹框(会话内已授权过 / 命中盲点判断失误)。无事可做。
|
|
4037
|
+
if (!v.modal)
|
|
4038
|
+
return { status: "no_modal" };
|
|
4039
|
+
const pick = pickModalAnswer(parseModalOptions(v.screen), v.title);
|
|
4040
|
+
if (!pick) {
|
|
4041
|
+
lg.warn({ title: v.title }, "native modal: no trustworthy one-shot option, not pressing");
|
|
4042
|
+
return { status: "unparsable", title: v.title, screen: v.screen };
|
|
4043
|
+
}
|
|
4044
|
+
// 数字键在 CC 的 picker 里即选即确认。按完等一拍回读: 框还在且**标题没变**才
|
|
4045
|
+
// 补一个 Enter(个别布局要显式确认); 标题变了说明弹的已是另一个框 —— 绝不盲按,
|
|
4046
|
+
// 交给兜底路径, 免得替用户确认了他没看过的东西。
|
|
4047
|
+
const SETTLE_MS = 400;
|
|
4048
|
+
await tmuxRun(["send-keys", "-t", pane, String(pick.index)]);
|
|
4049
|
+
await sleepMs(SETTLE_MS);
|
|
4050
|
+
let after = await detectModalPicker(pane);
|
|
4051
|
+
if (after.modal && after.title === v.title) {
|
|
4052
|
+
await tmuxRun(["send-keys", "-t", pane, "Enter"]);
|
|
4053
|
+
await sleepMs(SETTLE_MS);
|
|
4054
|
+
after = await detectModalPicker(pane);
|
|
4055
|
+
}
|
|
4056
|
+
if (after.modal) {
|
|
4057
|
+
lg.warn({ title: after.title, pressed: pick.index }, "native modal: still on pane after answer");
|
|
4058
|
+
return { status: "still_modal", title: after.title, index: pick.index, label: pick.label };
|
|
4059
|
+
}
|
|
4060
|
+
lg.info({ title: v.title, pressed: `${pick.index}. ${pick.label}` }, "native modal answered");
|
|
4061
|
+
return { status: "answered", title: v.title, index: pick.index, label: pick.label };
|
|
4062
|
+
},
|
|
3654
4063
|
getCwd,
|
|
3655
4064
|
setPendingCwd,
|
|
3656
4065
|
newSession,
|
|
@@ -3742,6 +4151,11 @@ export const startMirror = (deps) => {
|
|
|
3742
4151
|
// immediately — capturing baseline post-inject would already include it,
|
|
3743
4152
|
// and the watcher would never see a "new" candidate (the bug this fixes).
|
|
3744
4153
|
const preClearBaseline = armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
4154
|
+
// Registered here, not after the inject: the inject's paste verification
|
|
4155
|
+
// can take ~10s, and a sibling chat's watcher armed earlier must already
|
|
4156
|
+
// see this clear as an overlap by the time it evaluates a candidate.
|
|
4157
|
+
if (armMigration)
|
|
4158
|
+
noteClearInject(a);
|
|
3745
4159
|
// 记下断点, 由下一轮 (第一轮读不到上文的轮次) 领走。/clear 自己不建 turn,
|
|
3746
4160
|
// 否则这次清空在 chat 详情里毫无痕迹, 前后两轮看着还是连续的。
|
|
3747
4161
|
if (armMigration)
|
|
@@ -3794,7 +4208,8 @@ export const startMirror = (deps) => {
|
|
|
3794
4208
|
enterDeferred(a, frame, streamId);
|
|
3795
4209
|
}
|
|
3796
4210
|
const sid = a.sessionId;
|
|
3797
|
-
|
|
4211
|
+
const myGen = a.injectGen ?? 0;
|
|
4212
|
+
await enqueue(sid, () => withInjectWatchdog(a, myGen, async () => {
|
|
3798
4213
|
if (s && s.closed)
|
|
3799
4214
|
return; // (eager path) superseded by a newer dispatch
|
|
3800
4215
|
// Always reincarnate when no live pane: covers (a) pane closed between
|
|
@@ -3804,6 +4219,9 @@ export const startMirror = (deps) => {
|
|
|
3804
4219
|
// If respawn fails, fall through to spawn-mode inject for THIS turn
|
|
3805
4220
|
// but DON'T erase tmuxSession from store — next inbound will retry,
|
|
3806
4221
|
// making the system self-healing instead of one-failure-permanent.
|
|
4222
|
+
// First step of the job and the historical hang point — log BEFORE the
|
|
4223
|
+
// probe so a stall is attributable to it next time, not invisible.
|
|
4224
|
+
log.info({ target: a.target, sessionId: sid, pane: a.tmuxPane, gen: myGen }, "inject job start");
|
|
3807
4225
|
const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
|
|
3808
4226
|
let freshSpawn = a.justSpawned === true;
|
|
3809
4227
|
a.justSpawned = false;
|
|
@@ -3855,6 +4273,18 @@ export const startMirror = (deps) => {
|
|
|
3855
4273
|
// surfaces the user's text, and without a pre-recorded entry the
|
|
3856
4274
|
// dedupe filter wouldn't suppress it — user's chat msg gets echoed
|
|
3857
4275
|
// back as a quoted bubble. Recording up front fixes that.
|
|
4276
|
+
// Last checkpoint before we touch the pane: a `/stop` (or a watchdog
|
|
4277
|
+
// fire) during the respawn above means this text is no longer wanted.
|
|
4278
|
+
// Without this, a tmux server that unwedges after the user gave up would
|
|
4279
|
+
// paste a minutes-old message into a session that has moved on.
|
|
4280
|
+
if ((a.injectGen ?? 0) !== myGen) {
|
|
4281
|
+
// Lift the spawn mute here too: this inject will never land, so the
|
|
4282
|
+
// pane's output (including whatever /stop interrupted) would otherwise
|
|
4283
|
+
// stay swallowed by onItem until some later message injects cleanly.
|
|
4284
|
+
a.muteUntilInject = false;
|
|
4285
|
+
log.warn({ target: a.target, sessionId: sid, myGen, gen: a.injectGen }, "inject aborted — superseded by /stop or watchdog");
|
|
4286
|
+
return;
|
|
4287
|
+
}
|
|
3858
4288
|
rememberInject(text);
|
|
3859
4289
|
const r = await inject({
|
|
3860
4290
|
text, images, cfg, log: log.child({ principal, sessionId: sid }),
|
|
@@ -3926,13 +4356,14 @@ export const startMirror = (deps) => {
|
|
|
3926
4356
|
// feedback (the skip-stream path otherwise leaves WeCom silent).
|
|
3927
4357
|
if (armMigration) {
|
|
3928
4358
|
sendStandalone(a, `cleared\n\n${renderProjectInfo(a.target)}`);
|
|
3929
|
-
|
|
4359
|
+
a.clearRebind = { baseline: preClearBaseline };
|
|
4360
|
+
startMigrationWatcher(a, preClearBaseline, jsonlIsPostClearChild, 0, true);
|
|
3930
4361
|
}
|
|
3931
4362
|
// Don't await the stream's lifetime — it stays open until next inbound
|
|
3932
4363
|
// or hard timeout. Releasing the inject queue here lets the next
|
|
3933
4364
|
// dispatch start its inject promptly while tail content keeps flowing
|
|
3934
4365
|
// into the (still-open) stream until superseded.
|
|
3935
|
-
});
|
|
4366
|
+
}));
|
|
3936
4367
|
},
|
|
3937
4368
|
};
|
|
3938
4369
|
};
|