wezard 1.4.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +12 -2
- package/README.md +154 -58
- package/cli/wezard.sh +6 -3
- package/dist/cli/init.js +1 -1
- package/dist/cli/init.js.map +1 -1
- package/dist/daemon/approval.js +0 -6
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/cc-bridge.js +1 -22
- package/dist/daemon/cc-bridge.js.map +1 -1
- package/dist/daemon/cfg-sync.js +3 -6
- package/dist/daemon/cfg-sync.js.map +1 -1
- package/dist/daemon/chat-name.js +6 -2
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/config-api.js +0 -1
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/detail.js +53 -9
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/graph.js +2 -6
- package/dist/daemon/graph.js.map +1 -1
- package/dist/daemon/inbound.js +4 -4
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +119 -28
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +17 -35
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +262 -197
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js +2 -33
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/quota.js +1 -1
- package/dist/daemon/quota.js.map +1 -1
- package/dist/daemon/session-cache.js +0 -1
- package/dist/daemon/session-cache.js.map +1 -1
- package/dist/daemon/session-scan.js +4 -22
- package/dist/daemon/session-scan.js.map +1 -1
- package/dist/daemon/sessions.js +2 -33
- package/dist/daemon/sessions.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +2 -10
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/subagent-tail.js +2 -2
- package/dist/daemon/subagent-tail.js.map +1 -1
- package/dist/daemon/tasks.js +1 -1
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/wizard.js +9 -30
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +32 -55
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +25 -5
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +16 -1
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/cli-backends.js +0 -11
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/detail-render.js +5 -4
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +12 -5
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/exec-path.js +21 -0
- package/dist/shared/exec-path.js.map +1 -0
- package/dist/shared/json-map-store.js +41 -0
- package/dist/shared/json-map-store.js.map +1 -0
- package/dist/shared/std.js +26 -0
- package/dist/shared/std.js.map +1 -0
- package/dist/shared/world.js +191 -0
- package/dist/shared/world.js.map +1 -0
- package/dist/svr/index.js +1 -1
- package/dist/svr/index.js.map +1 -1
- package/package.json +1 -1
- package/web/chat.css +267 -0
- package/web/chat.js +454 -6
|
@@ -16,13 +16,16 @@ import { spawn } from "node:child_process";
|
|
|
16
16
|
import { existsSync, readdirSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
|
|
17
17
|
import { join, dirname } from "node:path";
|
|
18
18
|
import { expandHome, sanitizeId } from "../shared/paths.js";
|
|
19
|
+
import { augmentedPath } from "../shared/exec-path.js";
|
|
20
|
+
import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
|
|
19
21
|
import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
|
|
20
22
|
import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } from "../shared/modal-pane.js";
|
|
21
23
|
import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
|
|
22
24
|
import { isAutoWindowActive } from "./session-cache.js";
|
|
23
25
|
import { noticeSuffixFor } from "./notices.js";
|
|
24
26
|
import { dangerOf } from "./danger.js";
|
|
25
|
-
import { runTmux
|
|
27
|
+
import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
28
|
+
import { wizardStore } from "./wizard.js";
|
|
26
29
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
27
30
|
import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
|
|
28
31
|
import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, headSep, parseTagHeader } from "../shared/session-label.js";
|
|
@@ -32,25 +35,6 @@ import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peer
|
|
|
32
35
|
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
|
|
33
36
|
// Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
|
|
34
37
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
35
|
-
const NODE_BIN_DIR = dirname(process.execPath);
|
|
36
|
-
const augmentedPath = (orig) => {
|
|
37
|
-
const extras = [
|
|
38
|
-
NODE_BIN_DIR,
|
|
39
|
-
"/opt/homebrew/bin",
|
|
40
|
-
"/usr/local/bin",
|
|
41
|
-
`${process.env.HOME ?? ""}/.local/bin`,
|
|
42
|
-
].filter(Boolean);
|
|
43
|
-
const seen = new Set();
|
|
44
|
-
return [orig ?? "", ...extras]
|
|
45
|
-
.flatMap((p) => p.split(":"))
|
|
46
|
-
.filter((p) => {
|
|
47
|
-
if (!p || seen.has(p))
|
|
48
|
-
return false;
|
|
49
|
-
seen.add(p);
|
|
50
|
-
return true;
|
|
51
|
-
})
|
|
52
|
-
.join(":");
|
|
53
|
-
};
|
|
54
38
|
// Backend resolution is per-transcript, not global: `backendForPath` recovers
|
|
55
39
|
// which CLI wrote a jsonl from its projects root, so claude / claude-internal /
|
|
56
40
|
// codebuddy sessions can be mirrored side by side. `projectDirFor` re-encodes a
|
|
@@ -264,15 +248,6 @@ const stripPrincipalPrefix = (s) => {
|
|
|
264
248
|
const h = rest.indexOf("#");
|
|
265
249
|
return h >= 0 ? rest.slice(0, h) : rest;
|
|
266
250
|
};
|
|
267
|
-
// Extract the `#tag` suffix from a target key, "" if untagged.
|
|
268
|
-
const tagOfTarget = tagOfKey;
|
|
269
|
-
// Drop the `#tag` suffix — collapses tagged session keys to the chat-scoped
|
|
270
|
-
// base principal. Shared with the peer/graph layer via session-label.
|
|
271
|
-
const basePrincipalOf = baseOfKey;
|
|
272
|
-
// Prefix outbound content with `<emoji> #tag` header (blank line separator)
|
|
273
|
-
// when the target carries a `#tag` suffix. Untagged targets pass through
|
|
274
|
-
// unchanged — default session keeps its plain-bubble UX.
|
|
275
|
-
const withSessionTag = withTagHeader;
|
|
276
251
|
// Claude Code wraps slash-command invocations into the user message as
|
|
277
252
|
// <command-message>name</command-message>
|
|
278
253
|
// <command-name>/name</command-name>
|
|
@@ -330,7 +305,6 @@ const cleanUserText = (raw) => {
|
|
|
330
305
|
}
|
|
331
306
|
return stripped;
|
|
332
307
|
};
|
|
333
|
-
const truncate = (s, max) => s.length <= max ? s : `${s.slice(0, max)}…(+${s.length - max})`;
|
|
334
308
|
// 后台子agent 完成通知的无标签形态 —— codebuddy 平台在子agent 跑完后注入
|
|
335
309
|
// `[Framework Auto-Notification] <agent> completed` 的纯文本 user 行 (不套
|
|
336
310
|
// <task-notification>)。只认"整条消息以它开头", 免得把正文里提到它的消息吞掉。
|
|
@@ -371,7 +345,7 @@ const textSimilarity = (x, y) => {
|
|
|
371
345
|
const renderToolInput = (input) => {
|
|
372
346
|
try {
|
|
373
347
|
const json = JSON.stringify(input ?? {}, null, 0);
|
|
374
|
-
return
|
|
348
|
+
return truncateWithCount(json, 600);
|
|
375
349
|
}
|
|
376
350
|
catch {
|
|
377
351
|
return "{}";
|
|
@@ -393,7 +367,7 @@ const extractToolResultText = (block) => {
|
|
|
393
367
|
const DETAIL_RESULT_MAX = 64 * 1024;
|
|
394
368
|
const oneLineSummary = (s, max = 40) => {
|
|
395
369
|
const flat = s.replace(/\s+/g, " ").trim();
|
|
396
|
-
return
|
|
370
|
+
return truncateWithCount(flat, max);
|
|
397
371
|
};
|
|
398
372
|
// WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
|
|
399
373
|
// code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
|
|
@@ -537,7 +511,7 @@ const renderLine = (raw, deps) => {
|
|
|
537
511
|
if (!raw)
|
|
538
512
|
continue;
|
|
539
513
|
const toolUseId = b.tool_use_id ?? "";
|
|
540
|
-
const full =
|
|
514
|
+
const full = truncateWithCount(raw, DETAIL_RESULT_MAX);
|
|
541
515
|
// 始终把完整 result 落 detail 库 + 作为 tool_result item 发出 — 与
|
|
542
516
|
// includeToolResults(气泡推送开关)彻底解耦: 关掉气泡时 detail 页与 brief
|
|
543
517
|
// turn 页(handleBriefItem 消费本 item 写 turn store)都仍要看到 result。
|
|
@@ -704,7 +678,6 @@ const renderLine = (raw, deps) => {
|
|
|
704
678
|
};
|
|
705
679
|
// Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
|
|
706
680
|
// fenced block or table in a way that breaks rendering — see splitMarkdown.
|
|
707
|
-
const splitChunks = splitMarkdown;
|
|
708
681
|
// Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
|
|
709
682
|
// (up to ~110 bytes in its linked `[🧙 #tag](url)` form).
|
|
710
683
|
const TAG_HEADER_BUDGET = 64;
|
|
@@ -827,13 +800,10 @@ export const startMirrorTail = (deps) => {
|
|
|
827
800
|
livePath: resolveLive,
|
|
828
801
|
};
|
|
829
802
|
};
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
// silently killed a whole chat. See TMUX_TIMEOUT_MS.)
|
|
835
|
-
const tmuxRun = (args, opts) => runTmuxCmd(args, opts);
|
|
836
|
-
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
803
|
+
const parsePaneList = (out) => new Map(out.split("\n").flatMap((line) => {
|
|
804
|
+
const [id, ...rest] = line.trim().split("\t");
|
|
805
|
+
return id?.startsWith("%") ? [[id, rest.join("\t")]] : [];
|
|
806
|
+
}));
|
|
837
807
|
// Stable JSON: sort object keys recursively. Used to fingerprint a tool_use's
|
|
838
808
|
// `input` so the hook-side and the jsonl-side compute the same signature even
|
|
839
809
|
// when the model emits keys in arbitrary order.
|
|
@@ -955,7 +925,7 @@ const setMacClipboardImage = async (imgPath) => {
|
|
|
955
925
|
// the input box and would otherwise re-trigger the fingerprint, falsely
|
|
956
926
|
// flagging "Enter not honored".
|
|
957
927
|
const capturePaneTail = async (target, rows = 12) => {
|
|
958
|
-
const r = await
|
|
928
|
+
const r = await runTmux(["capture-pane", "-t", target, "-p", "-S", `-${rows}`]);
|
|
959
929
|
return r.ok ? r.stdout : "";
|
|
960
930
|
};
|
|
961
931
|
// Trim slash-command stdout to the useful section. TUI panels prepend a run
|
|
@@ -1043,7 +1013,7 @@ const MODAL_SCAN_ROWS = 15;
|
|
|
1043
1013
|
// `screen` = 实际参与判定的那一屏文本, 一并返回给调用方做选项解析 —— 再 capture
|
|
1044
1014
|
// 一次会拿到"下一瞬间"的屏幕, 与 verdict 不同源(框可能已被本地按掉), 按键就按错了。
|
|
1045
1015
|
const detectModalPicker = async (target) => {
|
|
1046
|
-
const r = await
|
|
1016
|
+
const r = await runTmux(["capture-pane", "-t", target, "-p"]);
|
|
1047
1017
|
if (!r.ok)
|
|
1048
1018
|
return { modal: false, screen: "" };
|
|
1049
1019
|
const lines = r.stdout.replace(/\s+$/u, "").split("\n");
|
|
@@ -1060,8 +1030,8 @@ const MODAL_PRESS_SETTLE_MS = 400;
|
|
|
1060
1030
|
const MODAL_ARROW_SETTLE_MS = 150;
|
|
1061
1031
|
const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
|
|
1062
1032
|
const pressModalIndex = async (pane, before, index) => {
|
|
1063
|
-
await
|
|
1064
|
-
await
|
|
1033
|
+
await runTmux(["send-keys", "-t", pane, String(index)]);
|
|
1034
|
+
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
1065
1035
|
let after = await detectModalPicker(pane);
|
|
1066
1036
|
if (!after.modal || after.title !== before.title)
|
|
1067
1037
|
return after;
|
|
@@ -1070,16 +1040,16 @@ const pressModalIndex = async (pane, before, index) => {
|
|
|
1070
1040
|
const cur = highlightedIndex(after.screen);
|
|
1071
1041
|
if (cur === undefined || cur === index)
|
|
1072
1042
|
break;
|
|
1073
|
-
await
|
|
1074
|
-
await
|
|
1043
|
+
await runTmux(["send-keys", "-t", pane, cur < index ? "Down" : "Up"]);
|
|
1044
|
+
await sleep(MODAL_ARROW_SETTLE_MS);
|
|
1075
1045
|
after = await detectModalPicker(pane);
|
|
1076
1046
|
if (!after.modal || after.title !== before.title)
|
|
1077
1047
|
return after;
|
|
1078
1048
|
}
|
|
1079
1049
|
if (highlightedIndex(after.screen) !== index)
|
|
1080
1050
|
return after; // 核不准, 绝不盲按
|
|
1081
|
-
await
|
|
1082
|
-
await
|
|
1051
|
+
await runTmux(["send-keys", "-t", pane, "Enter"]);
|
|
1052
|
+
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
1083
1053
|
return detectModalPicker(pane);
|
|
1084
1054
|
};
|
|
1085
1055
|
// 「聊聊这个」收尾: 文本 inject 已把引导语贴进自定义文本行 (❯ N. <text>)。codebuddy
|
|
@@ -1097,7 +1067,7 @@ const ASKQ_CONFIRM_STAGE_MS = 2500; // 每阶段 (到提交页 / 面板关闭)
|
|
|
1097
1067
|
const ASKQ_SECONDARY_ENTERS = 3;
|
|
1098
1068
|
const confirmAskqSubmit = async (target, paneAlive) => {
|
|
1099
1069
|
const capture = async () => {
|
|
1100
|
-
const r = await
|
|
1070
|
+
const r = await runTmux(["capture-pane", "-t", target, "-p"]);
|
|
1101
1071
|
return r.ok ? r.stdout.replace(/\s+$/u, "") : "";
|
|
1102
1072
|
};
|
|
1103
1073
|
// 轮询到 pred 为真; 期间 pane 死了立即失败。
|
|
@@ -1110,10 +1080,10 @@ const confirmAskqSubmit = async (target, paneAlive) => {
|
|
|
1110
1080
|
return true;
|
|
1111
1081
|
if (Date.now() - t0 >= timeoutMs)
|
|
1112
1082
|
return false;
|
|
1113
|
-
await
|
|
1083
|
+
await sleep(ASKQ_CONFIRM_POLL_MS);
|
|
1114
1084
|
}
|
|
1115
1085
|
};
|
|
1116
|
-
const sendKey = (key) =>
|
|
1086
|
+
const sendKey = (key) => runTmux(["send-keys", "-t", target, key]);
|
|
1117
1087
|
for (let attempt = 0; attempt < ASKQ_CONFIRM_ENTERS; attempt++) {
|
|
1118
1088
|
if (!(await paneAlive()))
|
|
1119
1089
|
return { ok: false, reason: "pane_dead" };
|
|
@@ -1135,7 +1105,7 @@ const confirmAskqSubmit = async (target, paneAlive) => {
|
|
|
1135
1105
|
const d = await sendKey("Down");
|
|
1136
1106
|
if (!d.ok)
|
|
1137
1107
|
return { ok: false, reason: `send-keys Down: ${d.stderr.slice(-200) || d.code}` };
|
|
1138
|
-
await
|
|
1108
|
+
await sleep(ASKQ_CONFIRM_POLL_MS); // 等光标移动重绘
|
|
1139
1109
|
const e = await sendKey("Enter");
|
|
1140
1110
|
if (!e.ok)
|
|
1141
1111
|
return { ok: false, reason: `send-keys Enter: ${e.stderr.slice(-200) || e.code}` };
|
|
@@ -1165,14 +1135,46 @@ const fingerprints = (text) => {
|
|
|
1165
1135
|
const stripped = text.replace(/\s+/gu, "").trim();
|
|
1166
1136
|
return { headFp: stripped.slice(0, 8), tailFp: stripped.slice(-8) };
|
|
1167
1137
|
};
|
|
1138
|
+
// 输入框本体 = 两条 ruler 之间那几行, 首行以 `❯ `/`> ` 打头:
|
|
1139
|
+
//
|
|
1140
|
+
// ────────────────────────
|
|
1141
|
+
// ❯ [Pasted text #1 +5 lines]
|
|
1142
|
+
// ────────────────────────
|
|
1143
|
+
// <cwd> <status>
|
|
1144
|
+
//
|
|
1145
|
+
// 读整块、跟「贴之前」的快照比对, 判据就与文本内容无关了 —— 这恰恰是指纹法的
|
|
1146
|
+
// 盲区: Claude Code 把多行粘贴折叠成 `[Pasted text #N +M lines]`, headFp/tailFp
|
|
1147
|
+
// 双双不在屏上, 于是「没贴进去」被误判 → 盲目重贴 → 两份提示叠在一个输入框里
|
|
1148
|
+
// (实测第三次粘贴渲染成 `…OMEGATAIL[Pasted text #2 +5 lines]`)。同一个盲区也让
|
|
1149
|
+
// 回车后的「框清空了吗」永远立刻为真, 等于根本没校验过提交。
|
|
1150
|
+
const BOX_RULER_RE = /^[\u2500-\u257F\u2014-]{8,}\s*$/u;
|
|
1151
|
+
const BOX_PROMPT_RE = /^\s*[❯>]\s?/u;
|
|
1152
|
+
/** 底 ruler 之下只该剩 cwd/模式两行状态; 多于此说明框到的不是输入框。 */
|
|
1153
|
+
const BOX_FOOTER_ROWS = 6;
|
|
1154
|
+
const UNKNOWN_BOX = { known: false, body: "" };
|
|
1155
|
+
export const parseInputBox = (cap) => {
|
|
1156
|
+
const lines = cap.split("\n");
|
|
1157
|
+
while (lines.length && lines[lines.length - 1].trim() === "")
|
|
1158
|
+
lines.pop();
|
|
1159
|
+
const rulers = lines.reduce((acc, l, i) => (BOX_RULER_RE.test(l) ? [...acc, i] : acc), []);
|
|
1160
|
+
const bottom = rulers[rulers.length - 1];
|
|
1161
|
+
const top = rulers[rulers.length - 2];
|
|
1162
|
+
if (top === undefined || bottom === undefined || bottom - top < 1)
|
|
1163
|
+
return UNKNOWN_BOX;
|
|
1164
|
+
if (lines.length - bottom > BOX_FOOTER_ROWS)
|
|
1165
|
+
return UNKNOWN_BOX;
|
|
1166
|
+
const rows = lines.slice(top + 1, bottom);
|
|
1167
|
+
if (!rows.length || !BOX_PROMPT_RE.test(rows[0]))
|
|
1168
|
+
return UNKNOWN_BOX;
|
|
1169
|
+
const body = [rows[0].replace(BOX_PROMPT_RE, ""), ...rows.slice(1)].join("\n").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
|
|
1170
|
+
return { known: true, body };
|
|
1171
|
+
};
|
|
1172
|
+
// 24 行足够容纳展开后的多行粘贴 (折叠态只占一行)。
|
|
1173
|
+
const readInputBox = async (target) => parseInputBox(await capturePaneTail(target, 24));
|
|
1168
1174
|
// Self-verifying inject. The cold-spawn race we guard against: paste lands
|
|
1169
1175
|
// but the trailing Enter is eaten while the TUI is still initializing, so
|
|
1170
|
-
// the prompt sits typed-but-unsent.
|
|
1171
|
-
//
|
|
1172
|
-
// 2. settle; send Enter.
|
|
1173
|
-
// 3. narrow-window poll (last 5 rows = just the input box, NOT the echo
|
|
1174
|
-
// above) for tailFp absence → submit was honored.
|
|
1175
|
-
// 4. on stuck-after-Enter, retry Enter once with extra settle.
|
|
1176
|
+
// the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
|
|
1177
|
+
// 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
|
|
1176
1178
|
const injectViaTmux = async (target, text, images, log, freshSpawn, backendName, bypassModalGuard = false) => {
|
|
1177
1179
|
log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
|
|
1178
1180
|
// Never type into a modal picker (see detectModalPicker). Checked before the
|
|
@@ -1219,10 +1221,10 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName,
|
|
|
1219
1221
|
log.warn({ imgPath, reason: cb.reason }, "mirror inject: clipboard set failed, skipping image");
|
|
1220
1222
|
continue;
|
|
1221
1223
|
}
|
|
1222
|
-
const cv = await
|
|
1224
|
+
const cv = await runTmux(["send-keys", "-t", target, "C-v"]);
|
|
1223
1225
|
if (!cv.ok)
|
|
1224
1226
|
return { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
|
|
1225
|
-
await
|
|
1227
|
+
await sleep(IMG_SETTLE_MS);
|
|
1226
1228
|
}
|
|
1227
1229
|
// Image-only message: TUI input box now holds the attached images; press
|
|
1228
1230
|
// Enter and we're done. No fingerprint to verify (the input itself is binary
|
|
@@ -1230,13 +1232,20 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName,
|
|
|
1230
1232
|
if (!text) {
|
|
1231
1233
|
if (images.length === 0)
|
|
1232
1234
|
return { ok: true };
|
|
1233
|
-
const e = await
|
|
1235
|
+
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1234
1236
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
|
|
1235
1237
|
}
|
|
1236
1238
|
return injectViaTmuxText(target, text, log, freshSpawn);
|
|
1237
1239
|
};
|
|
1238
1240
|
// 文本 paste + Enter 提交 + 自校验。从 injectViaTmux 抽出来,让 codebuddy
|
|
1239
1241
|
// 后端的 @<path> 回退路径也能复用同样的 paste-verify 逻辑。
|
|
1242
|
+
//
|
|
1243
|
+
// 两个判据都以**输入框整块**为准 (parseInputBox), 指纹只是认不出布局时的退路:
|
|
1244
|
+
// 1. 贴完轮询「框变了」→ 粘贴已被 TUI 消化 (折叠成占位符也算)。这是个**正**
|
|
1245
|
+
// 信号, 只有确凿看见框仍是贴之前的样子才重贴 —— 存疑一律不重贴, 多贴一次
|
|
1246
|
+
// 的代价 (两份提示叠进同一个输入框) 远高于少贴一次。
|
|
1247
|
+
// 2. 回车后轮询「框回到贴之前的样子」→ 提交被吃下了。框认得出来时这条判据可
|
|
1248
|
+
// 信, 于是允许多补一次回车 (空框上的回车在 CLI 里是 no-op)。
|
|
1240
1249
|
const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
1241
1250
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1242
1251
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
@@ -1247,13 +1256,24 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1247
1256
|
const POST_PASTE_SETTLE_FALLBACK_MS = freshSpawn ? 2500 : 700;
|
|
1248
1257
|
const CLEARED_TIMEOUT_MS = freshSpawn ? 4000 : 1500;
|
|
1249
1258
|
const RETRY_SETTLE_MS = freshSpawn ? 1500 : 800;
|
|
1259
|
+
const { headFp, tailFp } = fingerprints(text);
|
|
1260
|
+
const POLL_MS = 100;
|
|
1261
|
+
const stripWs = (s) => s.replace(/\s+/gu, "");
|
|
1262
|
+
// 贴之前框里是什么: 空框、幽灵提示 (`Try "how do I log an error?"`)、甚至用户
|
|
1263
|
+
// 自己敲了一半的字 —— 一律作为基线, 免得去维护一张「哪些文案算空」的清单。
|
|
1264
|
+
const preBox = await readInputBox(target);
|
|
1265
|
+
// 基线读不出来 (框被模态顶掉 / 正在重绘) 就整条退回指纹法: 没有基线,「框变了」
|
|
1266
|
+
// 无从谈起, 而把幽灵提示 (`Try "…"`) 误当成「贴进去了」会让这条消息静静消失。
|
|
1267
|
+
const boxVerify = preBox.known;
|
|
1268
|
+
/** 框里还是贴之前那样 (或彻底空了) = 我们的文本不在框里。 */
|
|
1269
|
+
const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === preBox.body);
|
|
1250
1270
|
const loadAndPaste = async () => {
|
|
1251
1271
|
// stdin variant of the shared exec path — it carries the same hard timeout,
|
|
1252
1272
|
// which a hand-rolled spawn here did not.
|
|
1253
|
-
const loaded = await
|
|
1273
|
+
const loaded = await runTmux(["load-buffer", "-"], { stdin: text });
|
|
1254
1274
|
if (!loaded.ok)
|
|
1255
1275
|
return { ok: false, reason: `tmux load-buffer failed: ${loaded.stderr.slice(-200) || loaded.code}` };
|
|
1256
|
-
const pasted = await
|
|
1276
|
+
const pasted = await runTmux(["paste-buffer", "-p", "-d", "-t", target]);
|
|
1257
1277
|
if (!pasted.ok)
|
|
1258
1278
|
return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
|
|
1259
1279
|
return { ok: true };
|
|
@@ -1261,100 +1281,88 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1261
1281
|
let r = await loadAndPaste();
|
|
1262
1282
|
if (!r.ok)
|
|
1263
1283
|
return r;
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
const
|
|
1267
|
-
// Wide window catches a wrapped paste's head whether it's row -3 or row -10.
|
|
1268
|
-
const sawHead = async (timeoutMs) => {
|
|
1284
|
+
// 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
|
|
1285
|
+
// 头部可能在框顶, 所以窗口要宽)。
|
|
1286
|
+
const sawLanded = async (timeoutMs) => {
|
|
1269
1287
|
const t0 = Date.now();
|
|
1270
1288
|
while (Date.now() - t0 < timeoutMs) {
|
|
1271
|
-
const
|
|
1272
|
-
|
|
1289
|
+
const cap = await capturePaneTail(target, 24);
|
|
1290
|
+
const box = parseInputBox(cap);
|
|
1291
|
+
if (boxVerify && box.known ? !boxIntact(box) : Boolean(headFp) && stripWs(cap).includes(headFp))
|
|
1273
1292
|
return true;
|
|
1274
|
-
await
|
|
1293
|
+
await sleep(POLL_MS);
|
|
1275
1294
|
}
|
|
1276
1295
|
return false;
|
|
1277
1296
|
};
|
|
1278
|
-
//
|
|
1279
|
-
//
|
|
1297
|
+
// 窄窗口 = 只有输入框本身。tailFp 紧挨光标, 提交前必在窗内、提交后消失。
|
|
1298
|
+
// 仅用于 parseInputBox 认不出布局的后端。
|
|
1280
1299
|
const inputBoxStillHasTail = async () => {
|
|
1281
1300
|
const pane = await capturePaneTail(target, 5);
|
|
1282
1301
|
return Boolean(tailFp) && stripWs(pane).includes(tailFp);
|
|
1283
1302
|
};
|
|
1284
|
-
let pasteSeen = await
|
|
1303
|
+
let pasteSeen = await sawLanded(PASTE_VERIFY_MS);
|
|
1285
1304
|
if (!pasteSeen) {
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
// re-paste only if the box genuinely lacks our tail; otherwise the first
|
|
1291
|
-
// paste landed and we just proceed to submit.
|
|
1292
|
-
if (await inputBoxStillHasTail()) {
|
|
1293
|
-
log.warn({ target, headFp }, "mirror inject: headFp not seen but tail present in box — first paste landed, skipping re-paste");
|
|
1305
|
+
const box = await readInputBox(target);
|
|
1306
|
+
const empty = boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
|
|
1307
|
+
if (!empty) {
|
|
1308
|
+
log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 粘贴未验证但输入框非空 — 首次粘贴已落地, 不重贴");
|
|
1294
1309
|
pasteSeen = true;
|
|
1295
1310
|
}
|
|
1296
1311
|
else {
|
|
1297
|
-
log.warn({ target, headFp }, "mirror inject:
|
|
1298
|
-
await
|
|
1312
|
+
log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 输入框确认为空, 重贴一次");
|
|
1313
|
+
await sleep(RETRY_SETTLE_MS);
|
|
1299
1314
|
r = await loadAndPaste();
|
|
1300
1315
|
if (!r.ok)
|
|
1301
1316
|
return r;
|
|
1302
|
-
pasteSeen = await
|
|
1317
|
+
pasteSeen = await sawLanded(PASTE_VERIFY_MS);
|
|
1303
1318
|
}
|
|
1304
1319
|
}
|
|
1305
1320
|
// Bracketed-paste end + TUI catch-up. Warm pane: 400ms is invisible.
|
|
1306
1321
|
// Fresh respawn: 1500ms+ — claude --resume is still loading the transcript.
|
|
1307
|
-
await
|
|
1322
|
+
await sleep(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
|
|
1308
1323
|
const sendEnter = async () => {
|
|
1309
|
-
const e = await
|
|
1324
|
+
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1310
1325
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
|
|
1311
1326
|
};
|
|
1327
|
+
const cleared = async () => {
|
|
1328
|
+
const box = await readInputBox(target);
|
|
1329
|
+
return boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
|
|
1330
|
+
};
|
|
1312
1331
|
const waitForCleared = async (timeoutMs) => {
|
|
1313
1332
|
const t0 = Date.now();
|
|
1314
1333
|
while (Date.now() - t0 < timeoutMs) {
|
|
1315
|
-
await
|
|
1316
|
-
if (
|
|
1334
|
+
await sleep(POLL_MS);
|
|
1335
|
+
if (await cleared())
|
|
1317
1336
|
return true;
|
|
1318
1337
|
}
|
|
1319
1338
|
return false;
|
|
1320
1339
|
};
|
|
1321
|
-
const e1 = await sendEnter();
|
|
1322
|
-
if (!e1.ok)
|
|
1323
|
-
return e1;
|
|
1324
1340
|
if (!pasteSeen)
|
|
1325
1341
|
log.warn({ target }, "mirror inject: submitted without paste verification (capture-pane lag)");
|
|
1326
|
-
if (!tailFp)
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
return { ok: true };
|
|
1330
|
-
|
|
1331
|
-
//
|
|
1332
|
-
//
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
//
|
|
1349
|
-
|
|
1350
|
-
// `[mirror] ✗` to the user when the prompt actually landed is worse than
|
|
1351
|
-
// accepting an extra no-op Enter on the rare true-stuck case.
|
|
1352
|
-
log.warn({ target, tailFp, freshSpawn }, "mirror inject: clear not observed, trusting submit");
|
|
1353
|
-
// Input box still held our text after two Enters. Usually the prompt landed
|
|
1354
|
-
// late (verifier false-positive), but it can also mean the target session is
|
|
1355
|
-
// busy / not consuming input (e.g. running a long task, or context full) —
|
|
1356
|
-
// the user's message would then silently go nowhere. Flag it uncertain so the
|
|
1357
|
-
// caller can hint the user, without reporting a hard failure.
|
|
1342
|
+
if (!tailFp) {
|
|
1343
|
+
// empty/whitespace text — nothing to verify, fire once and go.
|
|
1344
|
+
const e = await sendEnter();
|
|
1345
|
+
return e.ok ? { ok: true } : e;
|
|
1346
|
+
}
|
|
1347
|
+
// 回车可能被冷启动中的 TUI 吞掉 (框里躺着没提交的提示)。框认得出来时判据可信,
|
|
1348
|
+
// 多补一次; 认不出来时维持原来的「只补一次」, 免得判据出错往真实对话里灌回车。
|
|
1349
|
+
const boxTrusted = boxVerify && (await readInputBox(target)).known;
|
|
1350
|
+
const attempts = boxTrusted ? 3 : 2;
|
|
1351
|
+
for (let i = 0; i < attempts; i++) {
|
|
1352
|
+
if (i > 0) {
|
|
1353
|
+
log.warn({ target, tailFp, attempt: i + 1 }, "mirror inject: 回车后输入框未清空, 再补一次");
|
|
1354
|
+
await sleep(RETRY_SETTLE_MS);
|
|
1355
|
+
}
|
|
1356
|
+
const e = await sendEnter();
|
|
1357
|
+
if (!e.ok)
|
|
1358
|
+
return e;
|
|
1359
|
+
if (await waitForCleared(CLEARED_TIMEOUT_MS))
|
|
1360
|
+
return { ok: true };
|
|
1361
|
+
}
|
|
1362
|
+
// 补到头还没清空。通常是渲染滞后 (回车其实落了), 但也可能是目标会话正忙 /
|
|
1363
|
+
// 上下文满了不消费输入 —— 那样用户这条消息会悄无声息地消失。不报硬失败 (用户
|
|
1364
|
+
// 多半已经被服务到了), 标 uncertain 让调用方按需提示。
|
|
1365
|
+
log.warn({ target, tailFp, freshSpawn, boxTrusted }, "mirror inject: clear not observed, trusting submit");
|
|
1358
1366
|
return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
|
|
1359
1367
|
};
|
|
1360
1368
|
const injectViaSpawn = (args) => {
|
|
@@ -1573,7 +1581,7 @@ export const startMirror = (deps) => {
|
|
|
1573
1581
|
const detailCardFor = (s, target) => {
|
|
1574
1582
|
if (s.tools.length === 0)
|
|
1575
1583
|
return undefined;
|
|
1576
|
-
const tag =
|
|
1584
|
+
const tag = tagOfKey(target);
|
|
1577
1585
|
const titlePrefix = tag ? `${labelFor(tag)} #${tag} · ` : "";
|
|
1578
1586
|
return {
|
|
1579
1587
|
card_type: "button_interaction",
|
|
@@ -1590,7 +1598,7 @@ export const startMirror = (deps) => {
|
|
|
1590
1598
|
const content = s.acc;
|
|
1591
1599
|
s.lastSent = content;
|
|
1592
1600
|
try {
|
|
1593
|
-
await client.replyStream(s.frame, s.streamId,
|
|
1601
|
+
await client.replyStream(s.frame, s.streamId, withTagHeader(s.target, content || " "), false);
|
|
1594
1602
|
log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
|
|
1595
1603
|
}
|
|
1596
1604
|
catch (e) {
|
|
@@ -1672,7 +1680,7 @@ export const startMirror = (deps) => {
|
|
|
1672
1680
|
// Standalone fallback (no live stream / stream dead). Per-attachment FIFO so
|
|
1673
1681
|
// pushes from a single mirror stay ordered; different mirrors run in parallel.
|
|
1674
1682
|
// Linked tag prefix: emoji+tag becomes a chat-detail link. Falls back to
|
|
1675
|
-
// plain
|
|
1683
|
+
// plain withTagHeader when no turnId is available (no active turn to link).
|
|
1676
1684
|
const linkedTagPrefix = (target, turnId) => {
|
|
1677
1685
|
if (!turnId)
|
|
1678
1686
|
return "";
|
|
@@ -1686,7 +1694,7 @@ export const startMirror = (deps) => {
|
|
|
1686
1694
|
const seqBit = seq ? ` ${seq}` : "";
|
|
1687
1695
|
return `${prefix}${seqBit}${headSep(content)}${content}`;
|
|
1688
1696
|
}
|
|
1689
|
-
return
|
|
1697
|
+
return withTagHeader(a.target, content, seq);
|
|
1690
1698
|
};
|
|
1691
1699
|
// 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`🦊 #tag` / `[🧙](url)`)
|
|
1692
1700
|
// 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
|
|
@@ -1697,7 +1705,7 @@ export const startMirror = (deps) => {
|
|
|
1697
1705
|
if (!hasVisibleBody(content))
|
|
1698
1706
|
return;
|
|
1699
1707
|
const chatId = stripPrincipalPrefix(a.target);
|
|
1700
|
-
const pieces =
|
|
1708
|
+
const pieces = splitMarkdown(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
1701
1709
|
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
1702
1710
|
a.standalonePending = a.standalonePending
|
|
1703
1711
|
.then(async () => {
|
|
@@ -1712,7 +1720,7 @@ export const startMirror = (deps) => {
|
|
|
1712
1720
|
})
|
|
1713
1721
|
.catch(() => undefined);
|
|
1714
1722
|
};
|
|
1715
|
-
// Like sendStandalone but skips
|
|
1723
|
+
// Like sendStandalone but skips withTagHeader — content already contains the tag header (e.g. as a link).
|
|
1716
1724
|
const sendRaw = (a, content) => {
|
|
1717
1725
|
if (!hasVisibleBody(content))
|
|
1718
1726
|
return;
|
|
@@ -1906,7 +1914,7 @@ export const startMirror = (deps) => {
|
|
|
1906
1914
|
// sees the bubble; subsequent items grow it as today.
|
|
1907
1915
|
void (async () => {
|
|
1908
1916
|
try {
|
|
1909
|
-
await client.replyStream(frame, streamId,
|
|
1917
|
+
await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
|
|
1910
1918
|
}
|
|
1911
1919
|
catch (e) {
|
|
1912
1920
|
log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
|
|
@@ -2050,7 +2058,7 @@ export const startMirror = (deps) => {
|
|
|
2050
2058
|
// 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
|
|
2051
2059
|
const turnSilent = (a) => cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false;
|
|
2052
2060
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
2053
|
-
// raw=true skips
|
|
2061
|
+
// raw=true skips withTagHeader (used when content already contains the linked tag header).
|
|
2054
2062
|
// WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
|
|
2055
2063
|
// standalone (sendMessage), 用户会看到 standalone 抢在气泡动画结束之前出现。
|
|
2056
2064
|
// 把 finishBubble 的 replyStream promise 链入 standalonePending, 让后续
|
|
@@ -2110,6 +2118,12 @@ export const startMirror = (deps) => {
|
|
|
2110
2118
|
a.pendingOrigin = undefined;
|
|
2111
2119
|
return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.origin : undefined;
|
|
2112
2120
|
};
|
|
2121
|
+
/** 同上, 出处那一枚印章。 */
|
|
2122
|
+
const consumeFrom = (a) => {
|
|
2123
|
+
const p = a.pendingFrom;
|
|
2124
|
+
a.pendingFrom = undefined;
|
|
2125
|
+
return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.from : undefined;
|
|
2126
|
+
};
|
|
2113
2127
|
// 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
|
|
2114
2128
|
// 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
|
|
2115
2129
|
const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
|
|
@@ -2118,7 +2132,7 @@ export const startMirror = (deps) => {
|
|
|
2118
2132
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
2119
2133
|
a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
|
|
2120
2134
|
a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
|
|
2121
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a) });
|
|
2135
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a), from: consumeFrom(a) });
|
|
2122
2136
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2123
2137
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
2124
2138
|
const q = { turnId, bubble, isSlash };
|
|
@@ -2164,7 +2178,7 @@ export const startMirror = (deps) => {
|
|
|
2164
2178
|
const fromCli = a.pendingFromCli === true;
|
|
2165
2179
|
a.pendingFromCli = false;
|
|
2166
2180
|
a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
|
|
2167
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a) });
|
|
2181
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a), from: consumeFrom(a) });
|
|
2168
2182
|
a.briefTurnId = turnId;
|
|
2169
2183
|
a.briefBubble = undefined;
|
|
2170
2184
|
a.briefIsSlash = false;
|
|
@@ -2836,7 +2850,7 @@ export const startMirror = (deps) => {
|
|
|
2836
2850
|
return { ok: false, reason: "no_live_pane" };
|
|
2837
2851
|
if (!(await tmuxPaneAlive(a.tmuxPane)))
|
|
2838
2852
|
return { ok: false, reason: "pane_dead" };
|
|
2839
|
-
const r = await
|
|
2853
|
+
const r = await runTmux(["send-keys", "-t", a.tmuxPane, key]);
|
|
2840
2854
|
return r.code === 0 ? { ok: true } : { ok: false, reason: `send-keys ${key} failed: ${r.stdout.slice(-200) || r.code}` };
|
|
2841
2855
|
},
|
|
2842
2856
|
});
|
|
@@ -2887,13 +2901,13 @@ export const startMirror = (deps) => {
|
|
|
2887
2901
|
}
|
|
2888
2902
|
else {
|
|
2889
2903
|
for (const key of act.keys) {
|
|
2890
|
-
const r = await
|
|
2904
|
+
const r = await runTmux(["send-keys", "-t", a.tmuxPane, key]);
|
|
2891
2905
|
if (r.code !== 0)
|
|
2892
2906
|
return { ok: false, reason: `send-keys ${key}: ${r.stdout.slice(-200) || r.code}` };
|
|
2893
|
-
await
|
|
2907
|
+
await sleep(120); // 键间距 — 等 TUI 重渲染, 防吞键
|
|
2894
2908
|
}
|
|
2895
2909
|
}
|
|
2896
|
-
await
|
|
2910
|
+
await sleep(300); // 题间/阶段间隔 — 等面板翻页
|
|
2897
2911
|
}
|
|
2898
2912
|
return { ok: true };
|
|
2899
2913
|
},
|
|
@@ -3162,7 +3176,7 @@ export const startMirror = (deps) => {
|
|
|
3162
3176
|
const tmuxPaneAlive = async (paneId) => {
|
|
3163
3177
|
if (!paneId)
|
|
3164
3178
|
return false;
|
|
3165
|
-
const r = await
|
|
3179
|
+
const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
|
|
3166
3180
|
return r.code === 0 && r.stdout.trim() === paneId;
|
|
3167
3181
|
};
|
|
3168
3182
|
// Re-attach a stored binding for `principal`. Returns the resulting state, or
|
|
@@ -3185,7 +3199,9 @@ export const startMirror = (deps) => {
|
|
|
3185
3199
|
s.add(a.sessionId);
|
|
3186
3200
|
return s;
|
|
3187
3201
|
};
|
|
3188
|
-
|
|
3202
|
+
// `panes` = boot 快照; 给了就不再为每个 principal 单独问 tmux。lazy 调用方
|
|
3203
|
+
// 不传, 维持原来的逐个探活。
|
|
3204
|
+
const restoreFromStore = async (principal, panes) => {
|
|
3189
3205
|
const rec = deps.store.get(principal);
|
|
3190
3206
|
if (!rec)
|
|
3191
3207
|
return undefined;
|
|
@@ -3202,7 +3218,7 @@ export const startMirror = (deps) => {
|
|
|
3202
3218
|
// jsonl means "no input yet", NOT "rotated away" — healing it onto some
|
|
3203
3219
|
// unrelated newest-in-cwd file is exactly the cross-wire we must avoid.
|
|
3204
3220
|
const storedPane = (rec.tmuxPane ?? "").trim();
|
|
3205
|
-
const livePane = storedPane && (await tmuxPaneAlive(storedPane)) ? storedPane : "";
|
|
3221
|
+
const livePane = storedPane && (panes ? panes.has(storedPane) : await tmuxPaneAlive(storedPane)) ? storedPane : "";
|
|
3206
3222
|
if (!existsSync(jsonlAbs)) {
|
|
3207
3223
|
// First: the SAME-sid transcript may have merely relocated to a sibling
|
|
3208
3224
|
// project dir (Claude Code EnterWorktree/ExitWorktree moved it while the
|
|
@@ -3255,8 +3271,9 @@ export const startMirror = (deps) => {
|
|
|
3255
3271
|
// is silently detached; that detached chat then never mirrors again. Runs at
|
|
3256
3272
|
// boot; the 3s drift follower maintains it thereafter.
|
|
3257
3273
|
if (livePane) {
|
|
3258
|
-
const
|
|
3259
|
-
|
|
3274
|
+
const paneCwd = panes
|
|
3275
|
+
? (panes.get(livePane) ?? "")
|
|
3276
|
+
: (await runTmux(["display-message", "-p", "-t", livePane, "#{pane_current_path}"])).stdout.trim();
|
|
3260
3277
|
if (paneCwd) {
|
|
3261
3278
|
// Encode under the backend that owns the bound transcript — comparing
|
|
3262
3279
|
// with the primary dialect would report a phantom drift for every
|
|
@@ -3310,12 +3327,20 @@ export const startMirror = (deps) => {
|
|
|
3310
3327
|
// hasMirrorTarget), but eager boot-restore makes outbound (tail → push) work
|
|
3311
3328
|
// even before any inbound arrives — e.g. claude finishes a long-running
|
|
3312
3329
|
// background task and writes assistant content to the jsonl while idle.
|
|
3330
|
+
const RESTORE_CONC = 8;
|
|
3313
3331
|
const persisted = deps.store.all();
|
|
3314
3332
|
const persistedKeys = Object.keys(persisted);
|
|
3315
3333
|
if (persistedKeys.length > 0) {
|
|
3316
|
-
|
|
3317
|
-
|
|
3318
|
-
|
|
3334
|
+
// 裸 fan-out 会把 2N 次 tmux 同时压下去 —— 几百个绑定就是七百个 client 一起
|
|
3335
|
+
// 撞 10s 超时, spawn 风暴顺带把事件循环压住, :17890 要 25s 才 bind, 而 CLI 的
|
|
3336
|
+
// reload 探测早放弃了 (「reload issued but /status not responding」的真身)。
|
|
3337
|
+
// 一张快照 + 封顶并发; await 立刻让出, startHttp 先绑端口。
|
|
3338
|
+
void (async () => {
|
|
3339
|
+
const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{pane_current_path}"]);
|
|
3340
|
+
const panes = snap.ok ? parsePaneList(snap.stdout) : undefined;
|
|
3341
|
+
await mapLimit(persistedKeys, RESTORE_CONC, (principal) => restoreFromStore(principal, panes));
|
|
3342
|
+
log.info({ restored: persistedKeys.length, panes: panes?.size ?? -1 }, "mirror: boot restore done");
|
|
3343
|
+
})();
|
|
3319
3344
|
}
|
|
3320
3345
|
else if (cfg.wrc.mirror.sessionId.trim()) {
|
|
3321
3346
|
// Pinned-sessionId fallback: only honor when the store is empty (otherwise
|
|
@@ -3348,10 +3373,10 @@ export const startMirror = (deps) => {
|
|
|
3348
3373
|
}
|
|
3349
3374
|
})();
|
|
3350
3375
|
const result = t.result ?? "(no result captured)";
|
|
3351
|
-
parts.push(`### ${i}. 🔧 ${t.name}\n\n**input**\n\`\`\`json\n${
|
|
3376
|
+
parts.push(`### ${i}. 🔧 ${t.name}\n\n**input**\n\`\`\`json\n${truncateWithCount(inputJson, 4000)}\n\`\`\`\n\n**result**\n\`\`\`\n${truncateWithCount(result, 4000)}\n\`\`\``);
|
|
3352
3377
|
}
|
|
3353
3378
|
const merged = parts.join("\n\n---\n\n");
|
|
3354
|
-
return
|
|
3379
|
+
return splitMarkdown(merged, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
3355
3380
|
};
|
|
3356
3381
|
const resolveToolDetail = (turnId) => {
|
|
3357
3382
|
evictTurns();
|
|
@@ -3696,7 +3721,7 @@ export const startMirror = (deps) => {
|
|
|
3696
3721
|
// Guard 1+2: pane must be alive AND still in the bound project dir. One
|
|
3697
3722
|
// display-message gives both — it fails on a dead pane, and its cwd tells
|
|
3698
3723
|
// us whether the pane still belongs here.
|
|
3699
|
-
const r = await
|
|
3724
|
+
const r = await runTmux(["display-message", "-p", "-t", a.tmuxPane, "#{pane_current_path}"]);
|
|
3700
3725
|
if (stopped)
|
|
3701
3726
|
return;
|
|
3702
3727
|
const paneCwd = r.code === 0 ? r.stdout.trim() : "";
|
|
@@ -3774,7 +3799,7 @@ export const startMirror = (deps) => {
|
|
|
3774
3799
|
const attachments = Array.from(byTarget.values()).filter((a) => a.tmuxPane);
|
|
3775
3800
|
if (attachments.length === 0)
|
|
3776
3801
|
return;
|
|
3777
|
-
const r = await
|
|
3802
|
+
const r = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{pane_current_path}"]);
|
|
3778
3803
|
if (r.code !== 0)
|
|
3779
3804
|
return;
|
|
3780
3805
|
const paneCwd = new Map();
|
|
@@ -3833,7 +3858,7 @@ export const startMirror = (deps) => {
|
|
|
3833
3858
|
// Send via plain sendMessage so the user still gets the info.
|
|
3834
3859
|
const chatId = stripPrincipalPrefix(target);
|
|
3835
3860
|
void client
|
|
3836
|
-
.sendMessage(chatId, { msgtype: "markdown", markdown: { content:
|
|
3861
|
+
.sendMessage(chatId, { msgtype: "markdown", markdown: { content: withTagHeader(target, md) } })
|
|
3837
3862
|
.catch((e) => log.warn({ err: e.message, target }, "pushProjectInfo (no attach) failed"));
|
|
3838
3863
|
};
|
|
3839
3864
|
// Cwd is CHAT-SCOPED, not session-scoped: all sessions in the same chat
|
|
@@ -3842,7 +3867,7 @@ export const startMirror = (deps) => {
|
|
|
3842
3867
|
// own `runningCwd` (the tmux pane's actual working dir at spawn time), but
|
|
3843
3868
|
// cwd fallbacks and `set_workspace` pendingCwd writes always resolve against the base.
|
|
3844
3869
|
const chatCwdFallback = (target) => {
|
|
3845
|
-
const base =
|
|
3870
|
+
const base = baseOfKey(target);
|
|
3846
3871
|
const baseA = byTarget.get(base);
|
|
3847
3872
|
const baseRec = deps.store.get(base);
|
|
3848
3873
|
return {
|
|
@@ -3894,13 +3919,13 @@ export const startMirror = (deps) => {
|
|
|
3894
3919
|
// is chat-scoped like cwd — a FIRST `/new #tag` has no record of its own,
|
|
3895
3920
|
// so it falls back to the base session's binding instead of `defaultCli`
|
|
3896
3921
|
// (otherwise a tagged sibling silently forks onto a different CLI).
|
|
3897
|
-
const base =
|
|
3922
|
+
const base = baseOfKey(target);
|
|
3898
3923
|
const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
|
|
3899
3924
|
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
|
|
3900
3925
|
const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
|
|
3901
3926
|
if (prev?.tmuxPane) {
|
|
3902
3927
|
// Best-effort kill; ignore errors (pane may already be dead).
|
|
3903
|
-
void
|
|
3928
|
+
void runTmux(["kill-pane", "-t", prev.tmuxPane]);
|
|
3904
3929
|
}
|
|
3905
3930
|
if (prev)
|
|
3906
3931
|
detach(prev, "/new respawn");
|
|
@@ -4002,7 +4027,7 @@ export const startMirror = (deps) => {
|
|
|
4002
4027
|
const hit = born[0];
|
|
4003
4028
|
if (hit)
|
|
4004
4029
|
return { sessionId: hit.n.replace(/\.jsonl$/, ""), jsonlPath: hit.path };
|
|
4005
|
-
await
|
|
4030
|
+
await sleep(FORK_POLL_MS);
|
|
4006
4031
|
}
|
|
4007
4032
|
return undefined;
|
|
4008
4033
|
};
|
|
@@ -4047,7 +4072,7 @@ export const startMirror = (deps) => {
|
|
|
4047
4072
|
tmuxTarget: r.tmuxPane, freshSpawn: true,
|
|
4048
4073
|
});
|
|
4049
4074
|
if (!boot.ok) {
|
|
4050
|
-
await
|
|
4075
|
+
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4051
4076
|
return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
|
|
4052
4077
|
}
|
|
4053
4078
|
const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target));
|
|
@@ -4055,7 +4080,7 @@ export const startMirror = (deps) => {
|
|
|
4055
4080
|
// 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
|
|
4056
4081
|
// 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
|
|
4057
4082
|
if (!fork) {
|
|
4058
|
-
await
|
|
4083
|
+
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4059
4084
|
return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
|
|
4060
4085
|
}
|
|
4061
4086
|
const att = attach({
|
|
@@ -4126,7 +4151,7 @@ export const startMirror = (deps) => {
|
|
|
4126
4151
|
const expanded = expandHome(trimmed);
|
|
4127
4152
|
if (!expanded.startsWith("/"))
|
|
4128
4153
|
return { ok: false, reason: "cwd must be absolute (or start with ~)", runningCwd: "", pendingCwd: "" };
|
|
4129
|
-
const base =
|
|
4154
|
+
const base = baseOfKey(target);
|
|
4130
4155
|
const callerA = byTarget.get(target);
|
|
4131
4156
|
const callerRunning = callerA?.runningCwd?.trim() || deps.store.get(target)?.cwd?.trim() || expandedDefaultCwd;
|
|
4132
4157
|
const baseA = byTarget.get(base);
|
|
@@ -4184,8 +4209,8 @@ export const startMirror = (deps) => {
|
|
|
4184
4209
|
return Array.from(keys).sort();
|
|
4185
4210
|
};
|
|
4186
4211
|
const chatTargets = (target) => {
|
|
4187
|
-
const base =
|
|
4188
|
-
return allTargets().filter((k) =>
|
|
4212
|
+
const base = baseOfKey(target);
|
|
4213
|
+
return allTargets().filter((k) => baseOfKey(k) === base);
|
|
4189
4214
|
};
|
|
4190
4215
|
// 名字解析:`daily` / `chat:wrxxx` → base principal。认不出就把已知名字一并回给
|
|
4191
4216
|
// 调用方 —— 跨 chat 出错时"我该写什么"比"你写错了"有用得多。
|
|
@@ -4205,7 +4230,8 @@ export const startMirror = (deps) => {
|
|
|
4205
4230
|
// `fix` 本 chat 优先,本 chat 没有再全局兜底(全 host 唯一才认)——
|
|
4206
4231
|
// 命名之前唯一的跨 chat 路子,老调用方不能因为引入命名而断掉;
|
|
4207
4232
|
// `daily#fix` daily 这个 chat 里的 `#fix`,精确到点,不问 tag 全不全局唯一;
|
|
4208
|
-
// `daily
|
|
4233
|
+
// `daily` daily 这个聊天的 default 会话 —— 默认 wizard 的名字就是聊天名,
|
|
4234
|
+
// 所以裸聊天名就是它的地址 (`daily#` 这种老写法继续认);
|
|
4209
4235
|
// `chat:wr…#fix` 全量 key 同理(list_peers 吐的就是它)。
|
|
4210
4236
|
// 裸 tag 的 0 命中 / ≥2 命中依旧拒绝,但出路从"回去改 tag 名"变成"用带 chat 名
|
|
4211
4237
|
// 的全称地址"——后者不需要动别人的会话。
|
|
@@ -4218,23 +4244,23 @@ export const startMirror = (deps) => {
|
|
|
4218
4244
|
return c;
|
|
4219
4245
|
const target = keyOf(c.base, t);
|
|
4220
4246
|
if (allTargets().includes(target)) {
|
|
4221
|
-
return { ok: true, target, foreign: c.base !==
|
|
4247
|
+
return { ok: true, target, foreign: c.base !== baseOfKey(self) };
|
|
4222
4248
|
}
|
|
4223
4249
|
return {
|
|
4224
4250
|
ok: false,
|
|
4225
4251
|
reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
|
|
4226
4252
|
// 候选给**地址**而不是裸 key —— 这份清单存在的意义就是让调用方照着改一个
|
|
4227
4253
|
// 能用的串; 隔壁两个分支早就这么做了, 只有这一条漏掉。
|
|
4228
|
-
candidates: allTargets().filter((k) =>
|
|
4254
|
+
candidates: allTargets().filter((k) => baseOfKey(k) === c.base).map((k) => peerAddress(cfg, self, k)),
|
|
4229
4255
|
};
|
|
4230
4256
|
}
|
|
4231
|
-
const local = keyOf(
|
|
4257
|
+
const local = keyOf(baseOfKey(self), t);
|
|
4232
4258
|
if (!t)
|
|
4233
4259
|
return { ok: true, target: local, foreign: false };
|
|
4234
4260
|
const all = allTargets();
|
|
4235
4261
|
if (all.includes(local))
|
|
4236
4262
|
return { ok: true, target: local, foreign: false };
|
|
4237
|
-
const foreignMatches = all.filter((k) => tagOfKey(k) === t &&
|
|
4263
|
+
const foreignMatches = all.filter((k) => tagOfKey(k) === t && baseOfKey(k) !== baseOfKey(self));
|
|
4238
4264
|
if (foreignMatches.length === 1)
|
|
4239
4265
|
return { ok: true, target: foreignMatches[0], foreign: true };
|
|
4240
4266
|
if (foreignMatches.length === 0) {
|
|
@@ -4242,10 +4268,16 @@ export const startMirror = (deps) => {
|
|
|
4242
4268
|
// 直接给出它的会话地址,比让人再查一次 list_chats 快。
|
|
4243
4269
|
const asChat = chatBaseOf(cfg, t);
|
|
4244
4270
|
if (asChat) {
|
|
4271
|
+
// 裸聊天名 = 那个聊天的**默认 wizard**: 它的名字就是聊天名, peerAddress
|
|
4272
|
+
// 印出来的地址也就是这个裸名字, 必须能原样喂回来 (此前这里一律报错, 于是
|
|
4273
|
+
// 「名册里读到的地址」与「send_peer 收得下的地址」对不上)。
|
|
4274
|
+
if (allTargets().includes(asChat)) {
|
|
4275
|
+
return { ok: true, target: asChat, foreign: asChat !== baseOfKey(self) };
|
|
4276
|
+
}
|
|
4245
4277
|
return {
|
|
4246
4278
|
ok: false,
|
|
4247
|
-
reason: `'${t}'
|
|
4248
|
-
candidates: allTargets().filter((k) =>
|
|
4279
|
+
reason: `chat '${t}' has no default session — address one of its tagged sessions, or create it with new_claude_session({ chat: '${t}', cwd })`,
|
|
4280
|
+
candidates: allTargets().filter((k) => baseOfKey(k) === asChat).map((k) => peerAddress(cfg, self, k)),
|
|
4249
4281
|
};
|
|
4250
4282
|
}
|
|
4251
4283
|
return { ok: false, reason: `no peer with tag '#${t}' — create one with new_claude_session, or address it in full as 'chatName#${t}' (list_chats shows the names)` };
|
|
@@ -4261,14 +4293,14 @@ export const startMirror = (deps) => {
|
|
|
4261
4293
|
const chatRoster = (self) => {
|
|
4262
4294
|
const bases = new Set([
|
|
4263
4295
|
...listChatNames(cfg).map((c) => c.base),
|
|
4264
|
-
...allTargets().map(
|
|
4265
|
-
|
|
4296
|
+
...allTargets().map(baseOfKey),
|
|
4297
|
+
baseOfKey(self),
|
|
4266
4298
|
]);
|
|
4267
4299
|
return [...bases].filter(Boolean).sort().map((base) => ({
|
|
4268
4300
|
base,
|
|
4269
4301
|
name: chatNameOf(cfg, base),
|
|
4270
|
-
self: base ===
|
|
4271
|
-
targets: allTargets().filter((k) =>
|
|
4302
|
+
self: base === baseOfKey(self),
|
|
4303
|
+
targets: allTargets().filter((k) => baseOfKey(k) === base),
|
|
4272
4304
|
}));
|
|
4273
4305
|
};
|
|
4274
4306
|
const paneOf = (target) => byTarget.get(target)?.tmuxPane || deps.store.get(target)?.tmuxPane || "";
|
|
@@ -4318,7 +4350,7 @@ export const startMirror = (deps) => {
|
|
|
4318
4350
|
const jsonlPath = jsonlOf(t);
|
|
4319
4351
|
const pane = paneOf(t);
|
|
4320
4352
|
const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
|
|
4321
|
-
const tag =
|
|
4353
|
+
const tag = tagOfKey(t);
|
|
4322
4354
|
let lastActivity = 0;
|
|
4323
4355
|
try {
|
|
4324
4356
|
if (jsonlPath)
|
|
@@ -4352,12 +4384,42 @@ export const startMirror = (deps) => {
|
|
|
4352
4384
|
// `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
|
|
4353
4385
|
// 只要给群起个名,不必回去改别人的 tag。
|
|
4354
4386
|
const foreignPeers = async (self) => {
|
|
4355
|
-
const selfBase =
|
|
4356
|
-
const foreign = allTargets().filter((k) => tagOfKey(k) &&
|
|
4387
|
+
const selfBase = baseOfKey(self);
|
|
4388
|
+
const foreign = allTargets().filter((k) => tagOfKey(k) && baseOfKey(k) !== selfBase);
|
|
4357
4389
|
const tagCount = foreign.reduce((acc, k) => acc.set(tagOfKey(k), (acc.get(tagOfKey(k)) ?? 0) + 1), new Map());
|
|
4358
4390
|
const reachable = foreign.filter((k) => tagCount.get(tagOfKey(k)) === 1 || chatNameOf(cfg, k));
|
|
4359
4391
|
return (await Promise.all(reachable.map((t) => peerInfoOf(t, self)))).sort(byRecent);
|
|
4360
4392
|
};
|
|
4393
|
+
// peerInfoOf 每个 target 要两次 tmux (pane 存活 + capture tail)。跑了几个月的
|
|
4394
|
+
// 机器上 allTargets() 是几百个 —— 一次全量探活就是上千次 spawn, 足够打穿 fd
|
|
4395
|
+
// 上限、把 tmux server 拖垮, 守护进程跟着崩进重启循环 (CLAUDE.md 的 fd 那一条
|
|
4396
|
+
// 记的就是这个失效模式)。所以两道闸:
|
|
4397
|
+
// 1. 先用**不 spawn 的**证据排序 —— transcript 的 mtime, 一次 statSync;
|
|
4398
|
+
// 注册表里登记过的 wizard 无条件入选 (它们是有身份的那些, 也就那么几个)。
|
|
4399
|
+
// 2. 探活并发封顶, 且只探前 WORLD_PROBE 个。
|
|
4400
|
+
// 没被探到的照旧出现在名册里, 只是 busy/alive 按冷处理 —— 图上少一盏呼吸灯,
|
|
4401
|
+
// 远好过把整台机器上的会话一起搞停。
|
|
4402
|
+
const WORLD_PROBE = 48;
|
|
4403
|
+
const WORLD_CONC = 6;
|
|
4404
|
+
const worldPeers = async (self) => {
|
|
4405
|
+
const mtimeOf = (t) => {
|
|
4406
|
+
const p = jsonlOf(t);
|
|
4407
|
+
if (!p)
|
|
4408
|
+
return 0;
|
|
4409
|
+
try {
|
|
4410
|
+
return statSync(p).mtimeMs;
|
|
4411
|
+
}
|
|
4412
|
+
catch {
|
|
4413
|
+
return 0;
|
|
4414
|
+
}
|
|
4415
|
+
};
|
|
4416
|
+
const named = new Set(wizardStore()?.all().map((w) => w.target) ?? []);
|
|
4417
|
+
const ranked = allTargets()
|
|
4418
|
+
.map((t) => ({ t, ts: mtimeOf(t), named: named.has(t) }))
|
|
4419
|
+
.sort((a, b) => (a.named !== b.named ? (a.named ? -1 : 1) : b.ts - a.ts));
|
|
4420
|
+
const hot = ranked.slice(0, WORLD_PROBE).map((x) => x.t);
|
|
4421
|
+
return (await mapLimit(hot, WORLD_CONC, (t) => peerInfoOf(t, self))).sort(byRecent);
|
|
4422
|
+
};
|
|
4361
4423
|
// Capture a few extra rows then compact away the TUI's blank padding, so
|
|
4362
4424
|
// `rows` counts lines the user actually cares about.
|
|
4363
4425
|
const peekPane = async (target, rows = 24) => {
|
|
@@ -4686,8 +4748,8 @@ export const startMirror = (deps) => {
|
|
|
4686
4748
|
return { ok: false, reason: `当前确认框已是另一个调用(${now.toolName}),不代按` };
|
|
4687
4749
|
}
|
|
4688
4750
|
if (what === "no") {
|
|
4689
|
-
await
|
|
4690
|
-
await
|
|
4751
|
+
await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
|
|
4752
|
+
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
4691
4753
|
const after = await detectModalPicker(a.tmuxPane);
|
|
4692
4754
|
return after.modal ? { ok: false, reason: "Escape 后确认框仍在" } : { ok: true, label: "Escape" };
|
|
4693
4755
|
}
|
|
@@ -4707,6 +4769,7 @@ export const startMirror = (deps) => {
|
|
|
4707
4769
|
chatTargets,
|
|
4708
4770
|
peers,
|
|
4709
4771
|
foreignPeers,
|
|
4772
|
+
worldPeers,
|
|
4710
4773
|
resolvePeerTag,
|
|
4711
4774
|
chatRoster,
|
|
4712
4775
|
peekPane,
|
|
@@ -4817,7 +4880,7 @@ export const startMirror = (deps) => {
|
|
|
4817
4880
|
if (a.recentToolSigs.has(expectSig))
|
|
4818
4881
|
break;
|
|
4819
4882
|
polls++;
|
|
4820
|
-
await
|
|
4883
|
+
await sleep(50);
|
|
4821
4884
|
}
|
|
4822
4885
|
if (!a.recentToolSigs.has(expectSig)) {
|
|
4823
4886
|
log.warn({ sessionId, polls, toolName: expect?.toolName }, "flushBeforeCard wait timed out — sending card without sig confirm");
|
|
@@ -4896,6 +4959,8 @@ export const startMirror = (deps) => {
|
|
|
4896
4959
|
// resolve 之前就看到 user 行并开出 turn, 那时归因必须已经在位。
|
|
4897
4960
|
if (origin)
|
|
4898
4961
|
a.pendingOrigin = { origin, at: Date.now() };
|
|
4962
|
+
if (opts?.from)
|
|
4963
|
+
a.pendingFrom = { from: opts.from, at: Date.now() };
|
|
4899
4964
|
const sid = a.sessionId;
|
|
4900
4965
|
const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
|
|
4901
4966
|
if (!paneAlive) {
|
|
@@ -4903,7 +4968,7 @@ export const startMirror = (deps) => {
|
|
|
4903
4968
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
4904
4969
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
4905
4970
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
4906
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName:
|
|
4971
|
+
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: tagOfKey(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(target) });
|
|
4907
4972
|
if (!r.ok || !r.tmuxPane)
|
|
4908
4973
|
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
4909
4974
|
a.tmuxPane = r.tmuxPane;
|
|
@@ -4964,7 +5029,7 @@ export const startMirror = (deps) => {
|
|
|
4964
5029
|
escReason = "tmux pane no longer alive";
|
|
4965
5030
|
}
|
|
4966
5031
|
else {
|
|
4967
|
-
const r = await
|
|
5032
|
+
const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
|
|
4968
5033
|
escOk = r.ok;
|
|
4969
5034
|
if (!r.ok)
|
|
4970
5035
|
escReason = `send-keys Escape failed: ${(r.stderr || r.stdout).slice(-200) || r.code}`;
|
|
@@ -4989,9 +5054,9 @@ export const startMirror = (deps) => {
|
|
|
4989
5054
|
if (pane && (await tmuxPaneAlive(pane))) {
|
|
4990
5055
|
// Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
|
|
4991
5056
|
// flush its transcript before the TTY is yanked out from under it.
|
|
4992
|
-
await
|
|
4993
|
-
await
|
|
4994
|
-
const r = await
|
|
5057
|
+
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
5058
|
+
await sleep(250);
|
|
5059
|
+
const r = await runTmux(["kill-pane", "-t", pane]);
|
|
4995
5060
|
if (r.code !== 0)
|
|
4996
5061
|
return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
|
|
4997
5062
|
}
|
|
@@ -5014,7 +5079,7 @@ export const startMirror = (deps) => {
|
|
|
5014
5079
|
const alive = await tmuxPaneAlive(a.tmuxPane);
|
|
5015
5080
|
if (!alive)
|
|
5016
5081
|
return { ok: false, reason: "tmux pane no longer alive" };
|
|
5017
|
-
const r = await
|
|
5082
|
+
const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Enter"]);
|
|
5018
5083
|
if (r.code !== 0)
|
|
5019
5084
|
return { ok: false, reason: `send-keys Enter failed: ${r.stdout.slice(-200) || r.code}` };
|
|
5020
5085
|
log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /n — Enter sent to pane");
|
|
@@ -5032,9 +5097,9 @@ export const startMirror = (deps) => {
|
|
|
5032
5097
|
// switching a client that sits on an unrelated session would yank the
|
|
5033
5098
|
// user's other terminal window into wezard. Most-recently-active wins —
|
|
5034
5099
|
// switch-client with no -c is ambiguous under multiple clients.
|
|
5035
|
-
const s = await
|
|
5100
|
+
const s = await runTmux(["display-message", "-p", "-t", pane, "#{session_name}"]);
|
|
5036
5101
|
const sess = s.stdout.trim() || cfg.wrc.tmuxPrefix;
|
|
5037
|
-
const clients = await
|
|
5102
|
+
const clients = await runTmux(["list-clients", "-t", sess, "-F", "#{client_activity} #{client_name}"]);
|
|
5038
5103
|
const best = clients.stdout.split("\n").filter(Boolean)
|
|
5039
5104
|
.map((l) => { const i = l.indexOf(" "); return { act: Number(l.slice(0, i)), name: l.slice(i + 1) }; })
|
|
5040
5105
|
.sort((x, y) => y.act - x.act)[0]?.name;
|
|
@@ -5043,7 +5108,7 @@ export const startMirror = (deps) => {
|
|
|
5043
5108
|
return { ok: false, reason: `没有 attach 到 \`${sess}\` 的 tmux 客户端。先在终端执行: \`tmux attach -t ${sess}\`` };
|
|
5044
5109
|
}
|
|
5045
5110
|
// A pane target pulls session + window selection along with it.
|
|
5046
|
-
const r = await
|
|
5111
|
+
const r = await runTmux(["switch-client", "-c", best, "-t", pane]);
|
|
5047
5112
|
if (r.code !== 0)
|
|
5048
5113
|
return { ok: false, reason: `switch-client failed: ${r.stdout.slice(-200) || r.code}` };
|
|
5049
5114
|
log.info({ target, pane, client: best }, "mirror /reveal — tmux client switched");
|
|
@@ -5063,7 +5128,7 @@ export const startMirror = (deps) => {
|
|
|
5063
5128
|
const deadline = Date.now() + Math.max(0, opts.waitMs);
|
|
5064
5129
|
let v = await detectModalPicker(pane);
|
|
5065
5130
|
while (!v.modal && Date.now() < deadline) {
|
|
5066
|
-
await
|
|
5131
|
+
await sleep(POLL_MS);
|
|
5067
5132
|
v = await detectModalPicker(pane);
|
|
5068
5133
|
}
|
|
5069
5134
|
// 没等到: 这次 CC 没弹框(会话内已授权过 / 命中盲点判断失误)。无事可做。
|
|
@@ -5116,7 +5181,7 @@ export const startMirror = (deps) => {
|
|
|
5116
5181
|
a = await restoreFromStore(principal);
|
|
5117
5182
|
if (!a) {
|
|
5118
5183
|
try {
|
|
5119
|
-
await client.replyStream(frame, streamId,
|
|
5184
|
+
await client.replyStream(frame, streamId, withTagHeader(principal, "[wezard] wecom remote control not attached — run `/wrc` inside the target Claude session"), true);
|
|
5120
5185
|
}
|
|
5121
5186
|
catch {
|
|
5122
5187
|
/* ignore */
|
|
@@ -5164,11 +5229,11 @@ export const startMirror = (deps) => {
|
|
|
5164
5229
|
log.info({ target: a.target, runningCwd: a.runningCwd, pendingCwd: pending }, "/clear upgraded to /new (cwd switch)");
|
|
5165
5230
|
// Prefer the tag suffix as tmux window name when present (matches
|
|
5166
5231
|
// /new #tag behavior), fall back to the full target for untagged.
|
|
5167
|
-
const tag =
|
|
5232
|
+
const tag = tagOfKey(a.target);
|
|
5168
5233
|
const r = await newSession(a.target, tag || a.target);
|
|
5169
5234
|
if (!r.ok) {
|
|
5170
5235
|
try {
|
|
5171
|
-
await client.replyStream(frame, streamId,
|
|
5236
|
+
await client.replyStream(frame, streamId, withTagHeader(a.target, `[mirror] 切换失败: ${r.reason ?? "unknown"}`), true);
|
|
5172
5237
|
}
|
|
5173
5238
|
catch { /* ignore */ }
|
|
5174
5239
|
}
|
|
@@ -5226,7 +5291,7 @@ export const startMirror = (deps) => {
|
|
|
5226
5291
|
if (s) {
|
|
5227
5292
|
a.liveStream = s;
|
|
5228
5293
|
try {
|
|
5229
|
-
await client.replyStream(frame, streamId,
|
|
5294
|
+
await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
|
|
5230
5295
|
}
|
|
5231
5296
|
catch (e) {
|
|
5232
5297
|
log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
|
|
@@ -5271,7 +5336,7 @@ export const startMirror = (deps) => {
|
|
|
5271
5336
|
const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
5272
5337
|
// Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
|
|
5273
5338
|
// mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
|
|
5274
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName:
|
|
5339
|
+
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: tagOfKey(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(a.target) });
|
|
5275
5340
|
if (r.ok && r.tmuxPane && r.tmuxSession) {
|
|
5276
5341
|
a.tmuxPane = r.tmuxPane;
|
|
5277
5342
|
a.tmuxSession = r.tmuxSession;
|
|
@@ -5341,7 +5406,7 @@ export const startMirror = (deps) => {
|
|
|
5341
5406
|
// /clear path has no live stream — surface failure as a one-shot
|
|
5342
5407
|
// terse reply ("clean" per project convention).
|
|
5343
5408
|
try {
|
|
5344
|
-
await client.replyStream(frame, streamId,
|
|
5409
|
+
await client.replyStream(frame, streamId, withTagHeader(a.target, "clean"), true);
|
|
5345
5410
|
}
|
|
5346
5411
|
catch { /* ignore */ }
|
|
5347
5412
|
}
|
|
@@ -5384,8 +5449,8 @@ export const startMirror = (deps) => {
|
|
|
5384
5449
|
// "Set model to …" as <local-command-stdout> — the tail mirrors that
|
|
5385
5450
|
// back to the chat as a ⚙️ bubble, which IS the readback.
|
|
5386
5451
|
if (isModelSwitch && a.tmuxPane) {
|
|
5387
|
-
await
|
|
5388
|
-
const e = await
|
|
5452
|
+
await sleep(1000);
|
|
5453
|
+
const e = await runTmux(["send-keys", "-t", a.tmuxPane, "Enter"]);
|
|
5389
5454
|
if (e.code !== 0)
|
|
5390
5455
|
log.warn({ pane: a.tmuxPane, reason: e.stdout.slice(-200) || e.code }, "/model confirm Enter failed — user can send /n manually");
|
|
5391
5456
|
}
|
|
@@ -5430,7 +5495,7 @@ export const installMirrorEventListener = (client, bridge, log) => {
|
|
|
5430
5495
|
const n = detail.markdown.length;
|
|
5431
5496
|
for (const [i, md] of detail.markdown.entries()) {
|
|
5432
5497
|
try {
|
|
5433
|
-
const content =
|
|
5498
|
+
const content = withTagHeader(detail.target, md, n > 1 ? `${i + 1}/${n}` : undefined);
|
|
5434
5499
|
await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
|
|
5435
5500
|
}
|
|
5436
5501
|
catch (e) {
|