wezard 1.4.0 → 1.4.1
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 +40 -5
- 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/config-api.js +0 -1
- package/dist/daemon/config-api.js.map +1 -1
- package/dist/daemon/graph.js +2 -6
- package/dist/daemon/graph.js.map +1 -1
- package/dist/daemon/inbound.js +3 -3
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +2 -18
- 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 +190 -182
- 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/wizard.js +7 -30
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +31 -54
- package/dist/mcp/server.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 +3 -3
- package/dist/shared/detail-render.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 +9 -0
- package/dist/shared/std.js.map +1 -0
- package/package.json +1 -1
|
@@ -16,13 +16,15 @@ 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 } 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";
|
|
26
28
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
27
29
|
import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
|
|
28
30
|
import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, headSep, parseTagHeader } from "../shared/session-label.js";
|
|
@@ -32,25 +34,6 @@ import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peer
|
|
|
32
34
|
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
|
|
33
35
|
// Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
|
|
34
36
|
// 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
37
|
// Backend resolution is per-transcript, not global: `backendForPath` recovers
|
|
55
38
|
// which CLI wrote a jsonl from its projects root, so claude / claude-internal /
|
|
56
39
|
// codebuddy sessions can be mirrored side by side. `projectDirFor` re-encodes a
|
|
@@ -264,15 +247,6 @@ const stripPrincipalPrefix = (s) => {
|
|
|
264
247
|
const h = rest.indexOf("#");
|
|
265
248
|
return h >= 0 ? rest.slice(0, h) : rest;
|
|
266
249
|
};
|
|
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
250
|
// Claude Code wraps slash-command invocations into the user message as
|
|
277
251
|
// <command-message>name</command-message>
|
|
278
252
|
// <command-name>/name</command-name>
|
|
@@ -330,7 +304,6 @@ const cleanUserText = (raw) => {
|
|
|
330
304
|
}
|
|
331
305
|
return stripped;
|
|
332
306
|
};
|
|
333
|
-
const truncate = (s, max) => s.length <= max ? s : `${s.slice(0, max)}…(+${s.length - max})`;
|
|
334
307
|
// 后台子agent 完成通知的无标签形态 —— codebuddy 平台在子agent 跑完后注入
|
|
335
308
|
// `[Framework Auto-Notification] <agent> completed` 的纯文本 user 行 (不套
|
|
336
309
|
// <task-notification>)。只认"整条消息以它开头", 免得把正文里提到它的消息吞掉。
|
|
@@ -371,7 +344,7 @@ const textSimilarity = (x, y) => {
|
|
|
371
344
|
const renderToolInput = (input) => {
|
|
372
345
|
try {
|
|
373
346
|
const json = JSON.stringify(input ?? {}, null, 0);
|
|
374
|
-
return
|
|
347
|
+
return truncateWithCount(json, 600);
|
|
375
348
|
}
|
|
376
349
|
catch {
|
|
377
350
|
return "{}";
|
|
@@ -393,7 +366,7 @@ const extractToolResultText = (block) => {
|
|
|
393
366
|
const DETAIL_RESULT_MAX = 64 * 1024;
|
|
394
367
|
const oneLineSummary = (s, max = 40) => {
|
|
395
368
|
const flat = s.replace(/\s+/g, " ").trim();
|
|
396
|
-
return
|
|
369
|
+
return truncateWithCount(flat, max);
|
|
397
370
|
};
|
|
398
371
|
// WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
|
|
399
372
|
// code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
|
|
@@ -537,7 +510,7 @@ const renderLine = (raw, deps) => {
|
|
|
537
510
|
if (!raw)
|
|
538
511
|
continue;
|
|
539
512
|
const toolUseId = b.tool_use_id ?? "";
|
|
540
|
-
const full =
|
|
513
|
+
const full = truncateWithCount(raw, DETAIL_RESULT_MAX);
|
|
541
514
|
// 始终把完整 result 落 detail 库 + 作为 tool_result item 发出 — 与
|
|
542
515
|
// includeToolResults(气泡推送开关)彻底解耦: 关掉气泡时 detail 页与 brief
|
|
543
516
|
// turn 页(handleBriefItem 消费本 item 写 turn store)都仍要看到 result。
|
|
@@ -704,7 +677,6 @@ const renderLine = (raw, deps) => {
|
|
|
704
677
|
};
|
|
705
678
|
// Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
|
|
706
679
|
// fenced block or table in a way that breaks rendering — see splitMarkdown.
|
|
707
|
-
const splitChunks = splitMarkdown;
|
|
708
680
|
// Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
|
|
709
681
|
// (up to ~110 bytes in its linked `[🧙 #tag](url)` form).
|
|
710
682
|
const TAG_HEADER_BUDGET = 64;
|
|
@@ -832,8 +804,6 @@ export const startMirrorTail = (deps) => {
|
|
|
832
804
|
// exactly one place where the hard timeout lives. (Two hand-rolled copies used
|
|
833
805
|
// to exist here; both could hang forever, which is how a wedged tmux server
|
|
834
806
|
// 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));
|
|
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, 让后续
|
|
@@ -2836,7 +2844,7 @@ export const startMirror = (deps) => {
|
|
|
2836
2844
|
return { ok: false, reason: "no_live_pane" };
|
|
2837
2845
|
if (!(await tmuxPaneAlive(a.tmuxPane)))
|
|
2838
2846
|
return { ok: false, reason: "pane_dead" };
|
|
2839
|
-
const r = await
|
|
2847
|
+
const r = await runTmux(["send-keys", "-t", a.tmuxPane, key]);
|
|
2840
2848
|
return r.code === 0 ? { ok: true } : { ok: false, reason: `send-keys ${key} failed: ${r.stdout.slice(-200) || r.code}` };
|
|
2841
2849
|
},
|
|
2842
2850
|
});
|
|
@@ -2887,13 +2895,13 @@ export const startMirror = (deps) => {
|
|
|
2887
2895
|
}
|
|
2888
2896
|
else {
|
|
2889
2897
|
for (const key of act.keys) {
|
|
2890
|
-
const r = await
|
|
2898
|
+
const r = await runTmux(["send-keys", "-t", a.tmuxPane, key]);
|
|
2891
2899
|
if (r.code !== 0)
|
|
2892
2900
|
return { ok: false, reason: `send-keys ${key}: ${r.stdout.slice(-200) || r.code}` };
|
|
2893
|
-
await
|
|
2901
|
+
await sleep(120); // 键间距 — 等 TUI 重渲染, 防吞键
|
|
2894
2902
|
}
|
|
2895
2903
|
}
|
|
2896
|
-
await
|
|
2904
|
+
await sleep(300); // 题间/阶段间隔 — 等面板翻页
|
|
2897
2905
|
}
|
|
2898
2906
|
return { ok: true };
|
|
2899
2907
|
},
|
|
@@ -3162,7 +3170,7 @@ export const startMirror = (deps) => {
|
|
|
3162
3170
|
const tmuxPaneAlive = async (paneId) => {
|
|
3163
3171
|
if (!paneId)
|
|
3164
3172
|
return false;
|
|
3165
|
-
const r = await
|
|
3173
|
+
const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
|
|
3166
3174
|
return r.code === 0 && r.stdout.trim() === paneId;
|
|
3167
3175
|
};
|
|
3168
3176
|
// Re-attach a stored binding for `principal`. Returns the resulting state, or
|
|
@@ -3255,7 +3263,7 @@ export const startMirror = (deps) => {
|
|
|
3255
3263
|
// is silently detached; that detached chat then never mirrors again. Runs at
|
|
3256
3264
|
// boot; the 3s drift follower maintains it thereafter.
|
|
3257
3265
|
if (livePane) {
|
|
3258
|
-
const cwdRes = await
|
|
3266
|
+
const cwdRes = await runTmux(["display-message", "-p", "-t", livePane, "#{pane_current_path}"]);
|
|
3259
3267
|
const paneCwd = cwdRes.stdout.trim();
|
|
3260
3268
|
if (paneCwd) {
|
|
3261
3269
|
// Encode under the backend that owns the bound transcript — comparing
|
|
@@ -3348,10 +3356,10 @@ export const startMirror = (deps) => {
|
|
|
3348
3356
|
}
|
|
3349
3357
|
})();
|
|
3350
3358
|
const result = t.result ?? "(no result captured)";
|
|
3351
|
-
parts.push(`### ${i}. 🔧 ${t.name}\n\n**input**\n\`\`\`json\n${
|
|
3359
|
+
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
3360
|
}
|
|
3353
3361
|
const merged = parts.join("\n\n---\n\n");
|
|
3354
|
-
return
|
|
3362
|
+
return splitMarkdown(merged, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
3355
3363
|
};
|
|
3356
3364
|
const resolveToolDetail = (turnId) => {
|
|
3357
3365
|
evictTurns();
|
|
@@ -3696,7 +3704,7 @@ export const startMirror = (deps) => {
|
|
|
3696
3704
|
// Guard 1+2: pane must be alive AND still in the bound project dir. One
|
|
3697
3705
|
// display-message gives both — it fails on a dead pane, and its cwd tells
|
|
3698
3706
|
// us whether the pane still belongs here.
|
|
3699
|
-
const r = await
|
|
3707
|
+
const r = await runTmux(["display-message", "-p", "-t", a.tmuxPane, "#{pane_current_path}"]);
|
|
3700
3708
|
if (stopped)
|
|
3701
3709
|
return;
|
|
3702
3710
|
const paneCwd = r.code === 0 ? r.stdout.trim() : "";
|
|
@@ -3774,7 +3782,7 @@ export const startMirror = (deps) => {
|
|
|
3774
3782
|
const attachments = Array.from(byTarget.values()).filter((a) => a.tmuxPane);
|
|
3775
3783
|
if (attachments.length === 0)
|
|
3776
3784
|
return;
|
|
3777
|
-
const r = await
|
|
3785
|
+
const r = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{pane_current_path}"]);
|
|
3778
3786
|
if (r.code !== 0)
|
|
3779
3787
|
return;
|
|
3780
3788
|
const paneCwd = new Map();
|
|
@@ -3833,7 +3841,7 @@ export const startMirror = (deps) => {
|
|
|
3833
3841
|
// Send via plain sendMessage so the user still gets the info.
|
|
3834
3842
|
const chatId = stripPrincipalPrefix(target);
|
|
3835
3843
|
void client
|
|
3836
|
-
.sendMessage(chatId, { msgtype: "markdown", markdown: { content:
|
|
3844
|
+
.sendMessage(chatId, { msgtype: "markdown", markdown: { content: withTagHeader(target, md) } })
|
|
3837
3845
|
.catch((e) => log.warn({ err: e.message, target }, "pushProjectInfo (no attach) failed"));
|
|
3838
3846
|
};
|
|
3839
3847
|
// Cwd is CHAT-SCOPED, not session-scoped: all sessions in the same chat
|
|
@@ -3842,7 +3850,7 @@ export const startMirror = (deps) => {
|
|
|
3842
3850
|
// own `runningCwd` (the tmux pane's actual working dir at spawn time), but
|
|
3843
3851
|
// cwd fallbacks and `set_workspace` pendingCwd writes always resolve against the base.
|
|
3844
3852
|
const chatCwdFallback = (target) => {
|
|
3845
|
-
const base =
|
|
3853
|
+
const base = baseOfKey(target);
|
|
3846
3854
|
const baseA = byTarget.get(base);
|
|
3847
3855
|
const baseRec = deps.store.get(base);
|
|
3848
3856
|
return {
|
|
@@ -3894,13 +3902,13 @@ export const startMirror = (deps) => {
|
|
|
3894
3902
|
// is chat-scoped like cwd — a FIRST `/new #tag` has no record of its own,
|
|
3895
3903
|
// so it falls back to the base session's binding instead of `defaultCli`
|
|
3896
3904
|
// (otherwise a tagged sibling silently forks onto a different CLI).
|
|
3897
|
-
const base =
|
|
3905
|
+
const base = baseOfKey(target);
|
|
3898
3906
|
const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
|
|
3899
3907
|
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
|
|
3900
3908
|
const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
|
|
3901
3909
|
if (prev?.tmuxPane) {
|
|
3902
3910
|
// Best-effort kill; ignore errors (pane may already be dead).
|
|
3903
|
-
void
|
|
3911
|
+
void runTmux(["kill-pane", "-t", prev.tmuxPane]);
|
|
3904
3912
|
}
|
|
3905
3913
|
if (prev)
|
|
3906
3914
|
detach(prev, "/new respawn");
|
|
@@ -4002,7 +4010,7 @@ export const startMirror = (deps) => {
|
|
|
4002
4010
|
const hit = born[0];
|
|
4003
4011
|
if (hit)
|
|
4004
4012
|
return { sessionId: hit.n.replace(/\.jsonl$/, ""), jsonlPath: hit.path };
|
|
4005
|
-
await
|
|
4013
|
+
await sleep(FORK_POLL_MS);
|
|
4006
4014
|
}
|
|
4007
4015
|
return undefined;
|
|
4008
4016
|
};
|
|
@@ -4047,7 +4055,7 @@ export const startMirror = (deps) => {
|
|
|
4047
4055
|
tmuxTarget: r.tmuxPane, freshSpawn: true,
|
|
4048
4056
|
});
|
|
4049
4057
|
if (!boot.ok) {
|
|
4050
|
-
await
|
|
4058
|
+
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4051
4059
|
return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
|
|
4052
4060
|
}
|
|
4053
4061
|
const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target));
|
|
@@ -4055,7 +4063,7 @@ export const startMirror = (deps) => {
|
|
|
4055
4063
|
// 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
|
|
4056
4064
|
// 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
|
|
4057
4065
|
if (!fork) {
|
|
4058
|
-
await
|
|
4066
|
+
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4059
4067
|
return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
|
|
4060
4068
|
}
|
|
4061
4069
|
const att = attach({
|
|
@@ -4126,7 +4134,7 @@ export const startMirror = (deps) => {
|
|
|
4126
4134
|
const expanded = expandHome(trimmed);
|
|
4127
4135
|
if (!expanded.startsWith("/"))
|
|
4128
4136
|
return { ok: false, reason: "cwd must be absolute (or start with ~)", runningCwd: "", pendingCwd: "" };
|
|
4129
|
-
const base =
|
|
4137
|
+
const base = baseOfKey(target);
|
|
4130
4138
|
const callerA = byTarget.get(target);
|
|
4131
4139
|
const callerRunning = callerA?.runningCwd?.trim() || deps.store.get(target)?.cwd?.trim() || expandedDefaultCwd;
|
|
4132
4140
|
const baseA = byTarget.get(base);
|
|
@@ -4184,8 +4192,8 @@ export const startMirror = (deps) => {
|
|
|
4184
4192
|
return Array.from(keys).sort();
|
|
4185
4193
|
};
|
|
4186
4194
|
const chatTargets = (target) => {
|
|
4187
|
-
const base =
|
|
4188
|
-
return allTargets().filter((k) =>
|
|
4195
|
+
const base = baseOfKey(target);
|
|
4196
|
+
return allTargets().filter((k) => baseOfKey(k) === base);
|
|
4189
4197
|
};
|
|
4190
4198
|
// 名字解析:`daily` / `chat:wrxxx` → base principal。认不出就把已知名字一并回给
|
|
4191
4199
|
// 调用方 —— 跨 chat 出错时"我该写什么"比"你写错了"有用得多。
|
|
@@ -4218,23 +4226,23 @@ export const startMirror = (deps) => {
|
|
|
4218
4226
|
return c;
|
|
4219
4227
|
const target = keyOf(c.base, t);
|
|
4220
4228
|
if (allTargets().includes(target)) {
|
|
4221
|
-
return { ok: true, target, foreign: c.base !==
|
|
4229
|
+
return { ok: true, target, foreign: c.base !== baseOfKey(self) };
|
|
4222
4230
|
}
|
|
4223
4231
|
return {
|
|
4224
4232
|
ok: false,
|
|
4225
4233
|
reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
|
|
4226
4234
|
// 候选给**地址**而不是裸 key —— 这份清单存在的意义就是让调用方照着改一个
|
|
4227
4235
|
// 能用的串; 隔壁两个分支早就这么做了, 只有这一条漏掉。
|
|
4228
|
-
candidates: allTargets().filter((k) =>
|
|
4236
|
+
candidates: allTargets().filter((k) => baseOfKey(k) === c.base).map((k) => peerAddress(cfg, self, k)),
|
|
4229
4237
|
};
|
|
4230
4238
|
}
|
|
4231
|
-
const local = keyOf(
|
|
4239
|
+
const local = keyOf(baseOfKey(self), t);
|
|
4232
4240
|
if (!t)
|
|
4233
4241
|
return { ok: true, target: local, foreign: false };
|
|
4234
4242
|
const all = allTargets();
|
|
4235
4243
|
if (all.includes(local))
|
|
4236
4244
|
return { ok: true, target: local, foreign: false };
|
|
4237
|
-
const foreignMatches = all.filter((k) => tagOfKey(k) === t &&
|
|
4245
|
+
const foreignMatches = all.filter((k) => tagOfKey(k) === t && baseOfKey(k) !== baseOfKey(self));
|
|
4238
4246
|
if (foreignMatches.length === 1)
|
|
4239
4247
|
return { ok: true, target: foreignMatches[0], foreign: true };
|
|
4240
4248
|
if (foreignMatches.length === 0) {
|
|
@@ -4245,7 +4253,7 @@ export const startMirror = (deps) => {
|
|
|
4245
4253
|
return {
|
|
4246
4254
|
ok: false,
|
|
4247
4255
|
reason: `'${t}' is a CHAT, not a tag — address one of its sessions, e.g. '${t}#' for its default`,
|
|
4248
|
-
candidates: allTargets().filter((k) =>
|
|
4256
|
+
candidates: allTargets().filter((k) => baseOfKey(k) === asChat).map((k) => peerAddress(cfg, self, k)),
|
|
4249
4257
|
};
|
|
4250
4258
|
}
|
|
4251
4259
|
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 +4269,14 @@ export const startMirror = (deps) => {
|
|
|
4261
4269
|
const chatRoster = (self) => {
|
|
4262
4270
|
const bases = new Set([
|
|
4263
4271
|
...listChatNames(cfg).map((c) => c.base),
|
|
4264
|
-
...allTargets().map(
|
|
4265
|
-
|
|
4272
|
+
...allTargets().map(baseOfKey),
|
|
4273
|
+
baseOfKey(self),
|
|
4266
4274
|
]);
|
|
4267
4275
|
return [...bases].filter(Boolean).sort().map((base) => ({
|
|
4268
4276
|
base,
|
|
4269
4277
|
name: chatNameOf(cfg, base),
|
|
4270
|
-
self: base ===
|
|
4271
|
-
targets: allTargets().filter((k) =>
|
|
4278
|
+
self: base === baseOfKey(self),
|
|
4279
|
+
targets: allTargets().filter((k) => baseOfKey(k) === base),
|
|
4272
4280
|
}));
|
|
4273
4281
|
};
|
|
4274
4282
|
const paneOf = (target) => byTarget.get(target)?.tmuxPane || deps.store.get(target)?.tmuxPane || "";
|
|
@@ -4318,7 +4326,7 @@ export const startMirror = (deps) => {
|
|
|
4318
4326
|
const jsonlPath = jsonlOf(t);
|
|
4319
4327
|
const pane = paneOf(t);
|
|
4320
4328
|
const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
|
|
4321
|
-
const tag =
|
|
4329
|
+
const tag = tagOfKey(t);
|
|
4322
4330
|
let lastActivity = 0;
|
|
4323
4331
|
try {
|
|
4324
4332
|
if (jsonlPath)
|
|
@@ -4352,8 +4360,8 @@ export const startMirror = (deps) => {
|
|
|
4352
4360
|
// `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
|
|
4353
4361
|
// 只要给群起个名,不必回去改别人的 tag。
|
|
4354
4362
|
const foreignPeers = async (self) => {
|
|
4355
|
-
const selfBase =
|
|
4356
|
-
const foreign = allTargets().filter((k) => tagOfKey(k) &&
|
|
4363
|
+
const selfBase = baseOfKey(self);
|
|
4364
|
+
const foreign = allTargets().filter((k) => tagOfKey(k) && baseOfKey(k) !== selfBase);
|
|
4357
4365
|
const tagCount = foreign.reduce((acc, k) => acc.set(tagOfKey(k), (acc.get(tagOfKey(k)) ?? 0) + 1), new Map());
|
|
4358
4366
|
const reachable = foreign.filter((k) => tagCount.get(tagOfKey(k)) === 1 || chatNameOf(cfg, k));
|
|
4359
4367
|
return (await Promise.all(reachable.map((t) => peerInfoOf(t, self)))).sort(byRecent);
|
|
@@ -4686,8 +4694,8 @@ export const startMirror = (deps) => {
|
|
|
4686
4694
|
return { ok: false, reason: `当前确认框已是另一个调用(${now.toolName}),不代按` };
|
|
4687
4695
|
}
|
|
4688
4696
|
if (what === "no") {
|
|
4689
|
-
await
|
|
4690
|
-
await
|
|
4697
|
+
await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
|
|
4698
|
+
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
4691
4699
|
const after = await detectModalPicker(a.tmuxPane);
|
|
4692
4700
|
return after.modal ? { ok: false, reason: "Escape 后确认框仍在" } : { ok: true, label: "Escape" };
|
|
4693
4701
|
}
|
|
@@ -4817,7 +4825,7 @@ export const startMirror = (deps) => {
|
|
|
4817
4825
|
if (a.recentToolSigs.has(expectSig))
|
|
4818
4826
|
break;
|
|
4819
4827
|
polls++;
|
|
4820
|
-
await
|
|
4828
|
+
await sleep(50);
|
|
4821
4829
|
}
|
|
4822
4830
|
if (!a.recentToolSigs.has(expectSig)) {
|
|
4823
4831
|
log.warn({ sessionId, polls, toolName: expect?.toolName }, "flushBeforeCard wait timed out — sending card without sig confirm");
|
|
@@ -4903,7 +4911,7 @@ export const startMirror = (deps) => {
|
|
|
4903
4911
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
4904
4912
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
4905
4913
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
4906
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName:
|
|
4914
|
+
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
4915
|
if (!r.ok || !r.tmuxPane)
|
|
4908
4916
|
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
4909
4917
|
a.tmuxPane = r.tmuxPane;
|
|
@@ -4964,7 +4972,7 @@ export const startMirror = (deps) => {
|
|
|
4964
4972
|
escReason = "tmux pane no longer alive";
|
|
4965
4973
|
}
|
|
4966
4974
|
else {
|
|
4967
|
-
const r = await
|
|
4975
|
+
const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
|
|
4968
4976
|
escOk = r.ok;
|
|
4969
4977
|
if (!r.ok)
|
|
4970
4978
|
escReason = `send-keys Escape failed: ${(r.stderr || r.stdout).slice(-200) || r.code}`;
|
|
@@ -4989,9 +4997,9 @@ export const startMirror = (deps) => {
|
|
|
4989
4997
|
if (pane && (await tmuxPaneAlive(pane))) {
|
|
4990
4998
|
// Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
|
|
4991
4999
|
// flush its transcript before the TTY is yanked out from under it.
|
|
4992
|
-
await
|
|
4993
|
-
await
|
|
4994
|
-
const r = await
|
|
5000
|
+
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
5001
|
+
await sleep(250);
|
|
5002
|
+
const r = await runTmux(["kill-pane", "-t", pane]);
|
|
4995
5003
|
if (r.code !== 0)
|
|
4996
5004
|
return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
|
|
4997
5005
|
}
|
|
@@ -5014,7 +5022,7 @@ export const startMirror = (deps) => {
|
|
|
5014
5022
|
const alive = await tmuxPaneAlive(a.tmuxPane);
|
|
5015
5023
|
if (!alive)
|
|
5016
5024
|
return { ok: false, reason: "tmux pane no longer alive" };
|
|
5017
|
-
const r = await
|
|
5025
|
+
const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Enter"]);
|
|
5018
5026
|
if (r.code !== 0)
|
|
5019
5027
|
return { ok: false, reason: `send-keys Enter failed: ${r.stdout.slice(-200) || r.code}` };
|
|
5020
5028
|
log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /n — Enter sent to pane");
|
|
@@ -5032,9 +5040,9 @@ export const startMirror = (deps) => {
|
|
|
5032
5040
|
// switching a client that sits on an unrelated session would yank the
|
|
5033
5041
|
// user's other terminal window into wezard. Most-recently-active wins —
|
|
5034
5042
|
// switch-client with no -c is ambiguous under multiple clients.
|
|
5035
|
-
const s = await
|
|
5043
|
+
const s = await runTmux(["display-message", "-p", "-t", pane, "#{session_name}"]);
|
|
5036
5044
|
const sess = s.stdout.trim() || cfg.wrc.tmuxPrefix;
|
|
5037
|
-
const clients = await
|
|
5045
|
+
const clients = await runTmux(["list-clients", "-t", sess, "-F", "#{client_activity} #{client_name}"]);
|
|
5038
5046
|
const best = clients.stdout.split("\n").filter(Boolean)
|
|
5039
5047
|
.map((l) => { const i = l.indexOf(" "); return { act: Number(l.slice(0, i)), name: l.slice(i + 1) }; })
|
|
5040
5048
|
.sort((x, y) => y.act - x.act)[0]?.name;
|
|
@@ -5043,7 +5051,7 @@ export const startMirror = (deps) => {
|
|
|
5043
5051
|
return { ok: false, reason: `没有 attach 到 \`${sess}\` 的 tmux 客户端。先在终端执行: \`tmux attach -t ${sess}\`` };
|
|
5044
5052
|
}
|
|
5045
5053
|
// A pane target pulls session + window selection along with it.
|
|
5046
|
-
const r = await
|
|
5054
|
+
const r = await runTmux(["switch-client", "-c", best, "-t", pane]);
|
|
5047
5055
|
if (r.code !== 0)
|
|
5048
5056
|
return { ok: false, reason: `switch-client failed: ${r.stdout.slice(-200) || r.code}` };
|
|
5049
5057
|
log.info({ target, pane, client: best }, "mirror /reveal — tmux client switched");
|
|
@@ -5063,7 +5071,7 @@ export const startMirror = (deps) => {
|
|
|
5063
5071
|
const deadline = Date.now() + Math.max(0, opts.waitMs);
|
|
5064
5072
|
let v = await detectModalPicker(pane);
|
|
5065
5073
|
while (!v.modal && Date.now() < deadline) {
|
|
5066
|
-
await
|
|
5074
|
+
await sleep(POLL_MS);
|
|
5067
5075
|
v = await detectModalPicker(pane);
|
|
5068
5076
|
}
|
|
5069
5077
|
// 没等到: 这次 CC 没弹框(会话内已授权过 / 命中盲点判断失误)。无事可做。
|
|
@@ -5116,7 +5124,7 @@ export const startMirror = (deps) => {
|
|
|
5116
5124
|
a = await restoreFromStore(principal);
|
|
5117
5125
|
if (!a) {
|
|
5118
5126
|
try {
|
|
5119
|
-
await client.replyStream(frame, streamId,
|
|
5127
|
+
await client.replyStream(frame, streamId, withTagHeader(principal, "[wezard] wecom remote control not attached — run `/wrc` inside the target Claude session"), true);
|
|
5120
5128
|
}
|
|
5121
5129
|
catch {
|
|
5122
5130
|
/* ignore */
|
|
@@ -5164,11 +5172,11 @@ export const startMirror = (deps) => {
|
|
|
5164
5172
|
log.info({ target: a.target, runningCwd: a.runningCwd, pendingCwd: pending }, "/clear upgraded to /new (cwd switch)");
|
|
5165
5173
|
// Prefer the tag suffix as tmux window name when present (matches
|
|
5166
5174
|
// /new #tag behavior), fall back to the full target for untagged.
|
|
5167
|
-
const tag =
|
|
5175
|
+
const tag = tagOfKey(a.target);
|
|
5168
5176
|
const r = await newSession(a.target, tag || a.target);
|
|
5169
5177
|
if (!r.ok) {
|
|
5170
5178
|
try {
|
|
5171
|
-
await client.replyStream(frame, streamId,
|
|
5179
|
+
await client.replyStream(frame, streamId, withTagHeader(a.target, `[mirror] 切换失败: ${r.reason ?? "unknown"}`), true);
|
|
5172
5180
|
}
|
|
5173
5181
|
catch { /* ignore */ }
|
|
5174
5182
|
}
|
|
@@ -5226,7 +5234,7 @@ export const startMirror = (deps) => {
|
|
|
5226
5234
|
if (s) {
|
|
5227
5235
|
a.liveStream = s;
|
|
5228
5236
|
try {
|
|
5229
|
-
await client.replyStream(frame, streamId,
|
|
5237
|
+
await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
|
|
5230
5238
|
}
|
|
5231
5239
|
catch (e) {
|
|
5232
5240
|
log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
|
|
@@ -5271,7 +5279,7 @@ export const startMirror = (deps) => {
|
|
|
5271
5279
|
const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
5272
5280
|
// Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
|
|
5273
5281
|
// 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:
|
|
5282
|
+
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
5283
|
if (r.ok && r.tmuxPane && r.tmuxSession) {
|
|
5276
5284
|
a.tmuxPane = r.tmuxPane;
|
|
5277
5285
|
a.tmuxSession = r.tmuxSession;
|
|
@@ -5341,7 +5349,7 @@ export const startMirror = (deps) => {
|
|
|
5341
5349
|
// /clear path has no live stream — surface failure as a one-shot
|
|
5342
5350
|
// terse reply ("clean" per project convention).
|
|
5343
5351
|
try {
|
|
5344
|
-
await client.replyStream(frame, streamId,
|
|
5352
|
+
await client.replyStream(frame, streamId, withTagHeader(a.target, "clean"), true);
|
|
5345
5353
|
}
|
|
5346
5354
|
catch { /* ignore */ }
|
|
5347
5355
|
}
|
|
@@ -5384,8 +5392,8 @@ export const startMirror = (deps) => {
|
|
|
5384
5392
|
// "Set model to …" as <local-command-stdout> — the tail mirrors that
|
|
5385
5393
|
// back to the chat as a ⚙️ bubble, which IS the readback.
|
|
5386
5394
|
if (isModelSwitch && a.tmuxPane) {
|
|
5387
|
-
await
|
|
5388
|
-
const e = await
|
|
5395
|
+
await sleep(1000);
|
|
5396
|
+
const e = await runTmux(["send-keys", "-t", a.tmuxPane, "Enter"]);
|
|
5389
5397
|
if (e.code !== 0)
|
|
5390
5398
|
log.warn({ pane: a.tmuxPane, reason: e.stdout.slice(-200) || e.code }, "/model confirm Enter failed — user can send /n manually");
|
|
5391
5399
|
}
|
|
@@ -5430,7 +5438,7 @@ export const installMirrorEventListener = (client, bridge, log) => {
|
|
|
5430
5438
|
const n = detail.markdown.length;
|
|
5431
5439
|
for (const [i, md] of detail.markdown.entries()) {
|
|
5432
5440
|
try {
|
|
5433
|
-
const content =
|
|
5441
|
+
const content = withTagHeader(detail.target, md, n > 1 ? `${i + 1}/${n}` : undefined);
|
|
5434
5442
|
await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
|
|
5435
5443
|
}
|
|
5436
5444
|
catch (e) {
|