wezard 1.3.23 → 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 +16 -4
- package/README.md +82 -41
- 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 +65 -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 +7 -0
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/graph.js +27 -9
- package/dist/daemon/graph.js.map +1 -1
- package/dist/daemon/inbound.js +116 -60
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +774 -142
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +73 -0
- package/dist/daemon/jobs.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +378 -190
- 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/notices.js +70 -0
- package/dist/daemon/notices.js.map +1 -0
- package/dist/daemon/peers.js +93 -61
- package/dist/daemon/peers.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 +48 -14
- 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 +90 -0
- package/dist/daemon/tasks.js.map +1 -0
- package/dist/daemon/wizard.js +128 -0
- package/dist/daemon/wizard.js.map +1 -0
- package/dist/mcp/server.js +267 -156
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/cli-backends.js +10 -11
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +50 -14
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +6 -5
- package/dist/shared/detail-render.js.map +1 -1
- 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/schedule-spec.js +240 -0
- package/dist/shared/schedule-spec.js.map +1 -0
- package/dist/shared/session-label.js +8 -4
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/std.js +9 -0
- package/dist/shared/std.js.map +1 -0
- package/package.json +1 -1
- package/dist/daemon/topics.js +0 -106
- package/dist/daemon/topics.js.map +0 -1
|
@@ -16,40 +16,24 @@ 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";
|
|
25
|
+
import { noticeSuffixFor } from "./notices.js";
|
|
23
26
|
import { dangerOf } from "./danger.js";
|
|
24
|
-
import { runTmux
|
|
27
|
+
import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
25
28
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
26
29
|
import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
|
|
27
|
-
import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, parseTagHeader } from "../shared/session-label.js";
|
|
30
|
+
import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader, headSep, parseTagHeader } from "../shared/session-label.js";
|
|
28
31
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
29
32
|
import { randomTip } from "./tips.js";
|
|
30
33
|
import { chatBaseOf, chatNameOf, listChatNames, normChatName, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
31
34
|
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
|
|
32
35
|
// Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
|
|
33
36
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
34
|
-
const NODE_BIN_DIR = dirname(process.execPath);
|
|
35
|
-
const augmentedPath = (orig) => {
|
|
36
|
-
const extras = [
|
|
37
|
-
NODE_BIN_DIR,
|
|
38
|
-
"/opt/homebrew/bin",
|
|
39
|
-
"/usr/local/bin",
|
|
40
|
-
`${process.env.HOME ?? ""}/.local/bin`,
|
|
41
|
-
].filter(Boolean);
|
|
42
|
-
const seen = new Set();
|
|
43
|
-
return [orig ?? "", ...extras]
|
|
44
|
-
.flatMap((p) => p.split(":"))
|
|
45
|
-
.filter((p) => {
|
|
46
|
-
if (!p || seen.has(p))
|
|
47
|
-
return false;
|
|
48
|
-
seen.add(p);
|
|
49
|
-
return true;
|
|
50
|
-
})
|
|
51
|
-
.join(":");
|
|
52
|
-
};
|
|
53
37
|
// Backend resolution is per-transcript, not global: `backendForPath` recovers
|
|
54
38
|
// which CLI wrote a jsonl from its projects root, so claude / claude-internal /
|
|
55
39
|
// codebuddy sessions can be mirrored side by side. `projectDirFor` re-encodes a
|
|
@@ -263,15 +247,6 @@ const stripPrincipalPrefix = (s) => {
|
|
|
263
247
|
const h = rest.indexOf("#");
|
|
264
248
|
return h >= 0 ? rest.slice(0, h) : rest;
|
|
265
249
|
};
|
|
266
|
-
// Extract the `#tag` suffix from a target key, "" if untagged.
|
|
267
|
-
const tagOfTarget = tagOfKey;
|
|
268
|
-
// Drop the `#tag` suffix — collapses tagged session keys to the chat-scoped
|
|
269
|
-
// base principal. Shared with the peer/graph layer via session-label.
|
|
270
|
-
const basePrincipalOf = baseOfKey;
|
|
271
|
-
// Prefix outbound content with `<emoji> #tag` header (blank line separator)
|
|
272
|
-
// when the target carries a `#tag` suffix. Untagged targets pass through
|
|
273
|
-
// unchanged — default session keeps its plain-bubble UX.
|
|
274
|
-
const withSessionTag = withTagHeader;
|
|
275
250
|
// Claude Code wraps slash-command invocations into the user message as
|
|
276
251
|
// <command-message>name</command-message>
|
|
277
252
|
// <command-name>/name</command-name>
|
|
@@ -329,7 +304,6 @@ const cleanUserText = (raw) => {
|
|
|
329
304
|
}
|
|
330
305
|
return stripped;
|
|
331
306
|
};
|
|
332
|
-
const truncate = (s, max) => s.length <= max ? s : `${s.slice(0, max)}…(+${s.length - max})`;
|
|
333
307
|
// 后台子agent 完成通知的无标签形态 —— codebuddy 平台在子agent 跑完后注入
|
|
334
308
|
// `[Framework Auto-Notification] <agent> completed` 的纯文本 user 行 (不套
|
|
335
309
|
// <task-notification>)。只认"整条消息以它开头", 免得把正文里提到它的消息吞掉。
|
|
@@ -370,7 +344,7 @@ const textSimilarity = (x, y) => {
|
|
|
370
344
|
const renderToolInput = (input) => {
|
|
371
345
|
try {
|
|
372
346
|
const json = JSON.stringify(input ?? {}, null, 0);
|
|
373
|
-
return
|
|
347
|
+
return truncateWithCount(json, 600);
|
|
374
348
|
}
|
|
375
349
|
catch {
|
|
376
350
|
return "{}";
|
|
@@ -392,7 +366,7 @@ const extractToolResultText = (block) => {
|
|
|
392
366
|
const DETAIL_RESULT_MAX = 64 * 1024;
|
|
393
367
|
const oneLineSummary = (s, max = 40) => {
|
|
394
368
|
const flat = s.replace(/\s+/g, " ").trim();
|
|
395
|
-
return
|
|
369
|
+
return truncateWithCount(flat, max);
|
|
396
370
|
};
|
|
397
371
|
// WeCom's markdown sanitizer strips HTML-like `<...>` runs even inside inline
|
|
398
372
|
// code spans — so a Bash command containing `<<'EOF'` (heredoc), `<file>`,
|
|
@@ -536,7 +510,7 @@ const renderLine = (raw, deps) => {
|
|
|
536
510
|
if (!raw)
|
|
537
511
|
continue;
|
|
538
512
|
const toolUseId = b.tool_use_id ?? "";
|
|
539
|
-
const full =
|
|
513
|
+
const full = truncateWithCount(raw, DETAIL_RESULT_MAX);
|
|
540
514
|
// 始终把完整 result 落 detail 库 + 作为 tool_result item 发出 — 与
|
|
541
515
|
// includeToolResults(气泡推送开关)彻底解耦: 关掉气泡时 detail 页与 brief
|
|
542
516
|
// turn 页(handleBriefItem 消费本 item 写 turn store)都仍要看到 result。
|
|
@@ -703,7 +677,6 @@ const renderLine = (raw, deps) => {
|
|
|
703
677
|
};
|
|
704
678
|
// Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
|
|
705
679
|
// fenced block or table in a way that breaks rendering — see splitMarkdown.
|
|
706
|
-
const splitChunks = splitMarkdown;
|
|
707
680
|
// Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
|
|
708
681
|
// (up to ~110 bytes in its linked `[🧙 #tag](url)` form).
|
|
709
682
|
const TAG_HEADER_BUDGET = 64;
|
|
@@ -831,8 +804,6 @@ export const startMirrorTail = (deps) => {
|
|
|
831
804
|
// exactly one place where the hard timeout lives. (Two hand-rolled copies used
|
|
832
805
|
// to exist here; both could hang forever, which is how a wedged tmux server
|
|
833
806
|
// silently killed a whole chat. See TMUX_TIMEOUT_MS.)
|
|
834
|
-
const tmuxRun = (args, opts) => runTmuxCmd(args, opts);
|
|
835
|
-
const sleepMs = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
836
807
|
// Stable JSON: sort object keys recursively. Used to fingerprint a tool_use's
|
|
837
808
|
// `input` so the hook-side and the jsonl-side compute the same signature even
|
|
838
809
|
// when the model emits keys in arbitrary order.
|
|
@@ -954,7 +925,7 @@ const setMacClipboardImage = async (imgPath) => {
|
|
|
954
925
|
// the input box and would otherwise re-trigger the fingerprint, falsely
|
|
955
926
|
// flagging "Enter not honored".
|
|
956
927
|
const capturePaneTail = async (target, rows = 12) => {
|
|
957
|
-
const r = await
|
|
928
|
+
const r = await runTmux(["capture-pane", "-t", target, "-p", "-S", `-${rows}`]);
|
|
958
929
|
return r.ok ? r.stdout : "";
|
|
959
930
|
};
|
|
960
931
|
// Trim slash-command stdout to the useful section. TUI panels prepend a run
|
|
@@ -1042,7 +1013,7 @@ const MODAL_SCAN_ROWS = 15;
|
|
|
1042
1013
|
// `screen` = 实际参与判定的那一屏文本, 一并返回给调用方做选项解析 —— 再 capture
|
|
1043
1014
|
// 一次会拿到"下一瞬间"的屏幕, 与 verdict 不同源(框可能已被本地按掉), 按键就按错了。
|
|
1044
1015
|
const detectModalPicker = async (target) => {
|
|
1045
|
-
const r = await
|
|
1016
|
+
const r = await runTmux(["capture-pane", "-t", target, "-p"]);
|
|
1046
1017
|
if (!r.ok)
|
|
1047
1018
|
return { modal: false, screen: "" };
|
|
1048
1019
|
const lines = r.stdout.replace(/\s+$/u, "").split("\n");
|
|
@@ -1059,8 +1030,8 @@ const MODAL_PRESS_SETTLE_MS = 400;
|
|
|
1059
1030
|
const MODAL_ARROW_SETTLE_MS = 150;
|
|
1060
1031
|
const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
|
|
1061
1032
|
const pressModalIndex = async (pane, before, index) => {
|
|
1062
|
-
await
|
|
1063
|
-
await
|
|
1033
|
+
await runTmux(["send-keys", "-t", pane, String(index)]);
|
|
1034
|
+
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
1064
1035
|
let after = await detectModalPicker(pane);
|
|
1065
1036
|
if (!after.modal || after.title !== before.title)
|
|
1066
1037
|
return after;
|
|
@@ -1069,16 +1040,16 @@ const pressModalIndex = async (pane, before, index) => {
|
|
|
1069
1040
|
const cur = highlightedIndex(after.screen);
|
|
1070
1041
|
if (cur === undefined || cur === index)
|
|
1071
1042
|
break;
|
|
1072
|
-
await
|
|
1073
|
-
await
|
|
1043
|
+
await runTmux(["send-keys", "-t", pane, cur < index ? "Down" : "Up"]);
|
|
1044
|
+
await sleep(MODAL_ARROW_SETTLE_MS);
|
|
1074
1045
|
after = await detectModalPicker(pane);
|
|
1075
1046
|
if (!after.modal || after.title !== before.title)
|
|
1076
1047
|
return after;
|
|
1077
1048
|
}
|
|
1078
1049
|
if (highlightedIndex(after.screen) !== index)
|
|
1079
1050
|
return after; // 核不准, 绝不盲按
|
|
1080
|
-
await
|
|
1081
|
-
await
|
|
1051
|
+
await runTmux(["send-keys", "-t", pane, "Enter"]);
|
|
1052
|
+
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
1082
1053
|
return detectModalPicker(pane);
|
|
1083
1054
|
};
|
|
1084
1055
|
// 「聊聊这个」收尾: 文本 inject 已把引导语贴进自定义文本行 (❯ N. <text>)。codebuddy
|
|
@@ -1096,7 +1067,7 @@ const ASKQ_CONFIRM_STAGE_MS = 2500; // 每阶段 (到提交页 / 面板关闭)
|
|
|
1096
1067
|
const ASKQ_SECONDARY_ENTERS = 3;
|
|
1097
1068
|
const confirmAskqSubmit = async (target, paneAlive) => {
|
|
1098
1069
|
const capture = async () => {
|
|
1099
|
-
const r = await
|
|
1070
|
+
const r = await runTmux(["capture-pane", "-t", target, "-p"]);
|
|
1100
1071
|
return r.ok ? r.stdout.replace(/\s+$/u, "") : "";
|
|
1101
1072
|
};
|
|
1102
1073
|
// 轮询到 pred 为真; 期间 pane 死了立即失败。
|
|
@@ -1109,10 +1080,10 @@ const confirmAskqSubmit = async (target, paneAlive) => {
|
|
|
1109
1080
|
return true;
|
|
1110
1081
|
if (Date.now() - t0 >= timeoutMs)
|
|
1111
1082
|
return false;
|
|
1112
|
-
await
|
|
1083
|
+
await sleep(ASKQ_CONFIRM_POLL_MS);
|
|
1113
1084
|
}
|
|
1114
1085
|
};
|
|
1115
|
-
const sendKey = (key) =>
|
|
1086
|
+
const sendKey = (key) => runTmux(["send-keys", "-t", target, key]);
|
|
1116
1087
|
for (let attempt = 0; attempt < ASKQ_CONFIRM_ENTERS; attempt++) {
|
|
1117
1088
|
if (!(await paneAlive()))
|
|
1118
1089
|
return { ok: false, reason: "pane_dead" };
|
|
@@ -1134,7 +1105,7 @@ const confirmAskqSubmit = async (target, paneAlive) => {
|
|
|
1134
1105
|
const d = await sendKey("Down");
|
|
1135
1106
|
if (!d.ok)
|
|
1136
1107
|
return { ok: false, reason: `send-keys Down: ${d.stderr.slice(-200) || d.code}` };
|
|
1137
|
-
await
|
|
1108
|
+
await sleep(ASKQ_CONFIRM_POLL_MS); // 等光标移动重绘
|
|
1138
1109
|
const e = await sendKey("Enter");
|
|
1139
1110
|
if (!e.ok)
|
|
1140
1111
|
return { ok: false, reason: `send-keys Enter: ${e.stderr.slice(-200) || e.code}` };
|
|
@@ -1164,14 +1135,46 @@ const fingerprints = (text) => {
|
|
|
1164
1135
|
const stripped = text.replace(/\s+/gu, "").trim();
|
|
1165
1136
|
return { headFp: stripped.slice(0, 8), tailFp: stripped.slice(-8) };
|
|
1166
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));
|
|
1167
1174
|
// Self-verifying inject. The cold-spawn race we guard against: paste lands
|
|
1168
1175
|
// but the trailing Enter is eaten while the TUI is still initializing, so
|
|
1169
|
-
// the prompt sits typed-but-unsent.
|
|
1170
|
-
//
|
|
1171
|
-
// 2. settle; send Enter.
|
|
1172
|
-
// 3. narrow-window poll (last 5 rows = just the input box, NOT the echo
|
|
1173
|
-
// above) for tailFp absence → submit was honored.
|
|
1174
|
-
// 4. on stuck-after-Enter, retry Enter once with extra settle.
|
|
1176
|
+
// the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
|
|
1177
|
+
// 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
|
|
1175
1178
|
const injectViaTmux = async (target, text, images, log, freshSpawn, backendName, bypassModalGuard = false) => {
|
|
1176
1179
|
log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
|
|
1177
1180
|
// Never type into a modal picker (see detectModalPicker). Checked before the
|
|
@@ -1218,10 +1221,10 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName,
|
|
|
1218
1221
|
log.warn({ imgPath, reason: cb.reason }, "mirror inject: clipboard set failed, skipping image");
|
|
1219
1222
|
continue;
|
|
1220
1223
|
}
|
|
1221
|
-
const cv = await
|
|
1224
|
+
const cv = await runTmux(["send-keys", "-t", target, "C-v"]);
|
|
1222
1225
|
if (!cv.ok)
|
|
1223
1226
|
return { ok: false, reason: `tmux send-keys C-v failed: ${cv.stderr.slice(-200)}` };
|
|
1224
|
-
await
|
|
1227
|
+
await sleep(IMG_SETTLE_MS);
|
|
1225
1228
|
}
|
|
1226
1229
|
// Image-only message: TUI input box now holds the attached images; press
|
|
1227
1230
|
// Enter and we're done. No fingerprint to verify (the input itself is binary
|
|
@@ -1229,13 +1232,20 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName,
|
|
|
1229
1232
|
if (!text) {
|
|
1230
1233
|
if (images.length === 0)
|
|
1231
1234
|
return { ok: true };
|
|
1232
|
-
const e = await
|
|
1235
|
+
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1233
1236
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
|
|
1234
1237
|
}
|
|
1235
1238
|
return injectViaTmuxText(target, text, log, freshSpawn);
|
|
1236
1239
|
};
|
|
1237
1240
|
// 文本 paste + Enter 提交 + 自校验。从 injectViaTmux 抽出来,让 codebuddy
|
|
1238
1241
|
// 后端的 @<path> 回退路径也能复用同样的 paste-verify 逻辑。
|
|
1242
|
+
//
|
|
1243
|
+
// 两个判据都以**输入框整块**为准 (parseInputBox), 指纹只是认不出布局时的退路:
|
|
1244
|
+
// 1. 贴完轮询「框变了」→ 粘贴已被 TUI 消化 (折叠成占位符也算)。这是个**正**
|
|
1245
|
+
// 信号, 只有确凿看见框仍是贴之前的样子才重贴 —— 存疑一律不重贴, 多贴一次
|
|
1246
|
+
// 的代价 (两份提示叠进同一个输入框) 远高于少贴一次。
|
|
1247
|
+
// 2. 回车后轮询「框回到贴之前的样子」→ 提交被吃下了。框认得出来时这条判据可
|
|
1248
|
+
// 信, 于是允许多补一次回车 (空框上的回车在 CLI 里是 no-op)。
|
|
1239
1249
|
const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
1240
1250
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1241
1251
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
@@ -1246,13 +1256,24 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1246
1256
|
const POST_PASTE_SETTLE_FALLBACK_MS = freshSpawn ? 2500 : 700;
|
|
1247
1257
|
const CLEARED_TIMEOUT_MS = freshSpawn ? 4000 : 1500;
|
|
1248
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);
|
|
1249
1270
|
const loadAndPaste = async () => {
|
|
1250
1271
|
// stdin variant of the shared exec path — it carries the same hard timeout,
|
|
1251
1272
|
// which a hand-rolled spawn here did not.
|
|
1252
|
-
const loaded = await
|
|
1273
|
+
const loaded = await runTmux(["load-buffer", "-"], { stdin: text });
|
|
1253
1274
|
if (!loaded.ok)
|
|
1254
1275
|
return { ok: false, reason: `tmux load-buffer failed: ${loaded.stderr.slice(-200) || loaded.code}` };
|
|
1255
|
-
const pasted = await
|
|
1276
|
+
const pasted = await runTmux(["paste-buffer", "-p", "-d", "-t", target]);
|
|
1256
1277
|
if (!pasted.ok)
|
|
1257
1278
|
return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
|
|
1258
1279
|
return { ok: true };
|
|
@@ -1260,100 +1281,88 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1260
1281
|
let r = await loadAndPaste();
|
|
1261
1282
|
if (!r.ok)
|
|
1262
1283
|
return r;
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
const
|
|
1266
|
-
// Wide window catches a wrapped paste's head whether it's row -3 or row -10.
|
|
1267
|
-
const sawHead = async (timeoutMs) => {
|
|
1284
|
+
// 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
|
|
1285
|
+
// 头部可能在框顶, 所以窗口要宽)。
|
|
1286
|
+
const sawLanded = async (timeoutMs) => {
|
|
1268
1287
|
const t0 = Date.now();
|
|
1269
1288
|
while (Date.now() - t0 < timeoutMs) {
|
|
1270
|
-
const
|
|
1271
|
-
|
|
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))
|
|
1272
1292
|
return true;
|
|
1273
|
-
await
|
|
1293
|
+
await sleep(POLL_MS);
|
|
1274
1294
|
}
|
|
1275
1295
|
return false;
|
|
1276
1296
|
};
|
|
1277
|
-
//
|
|
1278
|
-
//
|
|
1297
|
+
// 窄窗口 = 只有输入框本身。tailFp 紧挨光标, 提交前必在窗内、提交后消失。
|
|
1298
|
+
// 仅用于 parseInputBox 认不出布局的后端。
|
|
1279
1299
|
const inputBoxStillHasTail = async () => {
|
|
1280
1300
|
const pane = await capturePaneTail(target, 5);
|
|
1281
1301
|
return Boolean(tailFp) && stripWs(pane).includes(tailFp);
|
|
1282
1302
|
};
|
|
1283
|
-
let pasteSeen = await
|
|
1303
|
+
let pasteSeen = await sawLanded(PASTE_VERIFY_MS);
|
|
1284
1304
|
if (!pasteSeen) {
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
// re-paste only if the box genuinely lacks our tail; otherwise the first
|
|
1290
|
-
// paste landed and we just proceed to submit.
|
|
1291
|
-
if (await inputBoxStillHasTail()) {
|
|
1292
|
-
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: 粘贴未验证但输入框非空 — 首次粘贴已落地, 不重贴");
|
|
1293
1309
|
pasteSeen = true;
|
|
1294
1310
|
}
|
|
1295
1311
|
else {
|
|
1296
|
-
log.warn({ target, headFp }, "mirror inject:
|
|
1297
|
-
await
|
|
1312
|
+
log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 输入框确认为空, 重贴一次");
|
|
1313
|
+
await sleep(RETRY_SETTLE_MS);
|
|
1298
1314
|
r = await loadAndPaste();
|
|
1299
1315
|
if (!r.ok)
|
|
1300
1316
|
return r;
|
|
1301
|
-
pasteSeen = await
|
|
1317
|
+
pasteSeen = await sawLanded(PASTE_VERIFY_MS);
|
|
1302
1318
|
}
|
|
1303
1319
|
}
|
|
1304
1320
|
// Bracketed-paste end + TUI catch-up. Warm pane: 400ms is invisible.
|
|
1305
1321
|
// Fresh respawn: 1500ms+ — claude --resume is still loading the transcript.
|
|
1306
|
-
await
|
|
1322
|
+
await sleep(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
|
|
1307
1323
|
const sendEnter = async () => {
|
|
1308
|
-
const e = await
|
|
1324
|
+
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1309
1325
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
|
|
1310
1326
|
};
|
|
1327
|
+
const cleared = async () => {
|
|
1328
|
+
const box = await readInputBox(target);
|
|
1329
|
+
return boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
|
|
1330
|
+
};
|
|
1311
1331
|
const waitForCleared = async (timeoutMs) => {
|
|
1312
1332
|
const t0 = Date.now();
|
|
1313
1333
|
while (Date.now() - t0 < timeoutMs) {
|
|
1314
|
-
await
|
|
1315
|
-
if (
|
|
1334
|
+
await sleep(POLL_MS);
|
|
1335
|
+
if (await cleared())
|
|
1316
1336
|
return true;
|
|
1317
1337
|
}
|
|
1318
1338
|
return false;
|
|
1319
1339
|
};
|
|
1320
|
-
const e1 = await sendEnter();
|
|
1321
|
-
if (!e1.ok)
|
|
1322
|
-
return e1;
|
|
1323
1340
|
if (!pasteSeen)
|
|
1324
1341
|
log.warn({ target }, "mirror inject: submitted without paste verification (capture-pane lag)");
|
|
1325
|
-
if (!tailFp)
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
return { ok: true };
|
|
1329
|
-
|
|
1330
|
-
//
|
|
1331
|
-
//
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
//
|
|
1346
|
-
//
|
|
1347
|
-
//
|
|
1348
|
-
|
|
1349
|
-
// `[mirror] ✗` to the user when the prompt actually landed is worse than
|
|
1350
|
-
// accepting an extra no-op Enter on the rare true-stuck case.
|
|
1351
|
-
log.warn({ target, tailFp, freshSpawn }, "mirror inject: clear not observed, trusting submit");
|
|
1352
|
-
// Input box still held our text after two Enters. Usually the prompt landed
|
|
1353
|
-
// late (verifier false-positive), but it can also mean the target session is
|
|
1354
|
-
// busy / not consuming input (e.g. running a long task, or context full) —
|
|
1355
|
-
// the user's message would then silently go nowhere. Flag it uncertain so the
|
|
1356
|
-
// 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");
|
|
1357
1366
|
return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
|
|
1358
1367
|
};
|
|
1359
1368
|
const injectViaSpawn = (args) => {
|
|
@@ -1572,7 +1581,7 @@ export const startMirror = (deps) => {
|
|
|
1572
1581
|
const detailCardFor = (s, target) => {
|
|
1573
1582
|
if (s.tools.length === 0)
|
|
1574
1583
|
return undefined;
|
|
1575
|
-
const tag =
|
|
1584
|
+
const tag = tagOfKey(target);
|
|
1576
1585
|
const titlePrefix = tag ? `${labelFor(tag)} #${tag} · ` : "";
|
|
1577
1586
|
return {
|
|
1578
1587
|
card_type: "button_interaction",
|
|
@@ -1589,7 +1598,7 @@ export const startMirror = (deps) => {
|
|
|
1589
1598
|
const content = s.acc;
|
|
1590
1599
|
s.lastSent = content;
|
|
1591
1600
|
try {
|
|
1592
|
-
await client.replyStream(s.frame, s.streamId,
|
|
1601
|
+
await client.replyStream(s.frame, s.streamId, withTagHeader(s.target, content || " "), false);
|
|
1593
1602
|
log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
|
|
1594
1603
|
}
|
|
1595
1604
|
catch (e) {
|
|
@@ -1671,7 +1680,7 @@ export const startMirror = (deps) => {
|
|
|
1671
1680
|
// Standalone fallback (no live stream / stream dead). Per-attachment FIFO so
|
|
1672
1681
|
// pushes from a single mirror stay ordered; different mirrors run in parallel.
|
|
1673
1682
|
// Linked tag prefix: emoji+tag becomes a chat-detail link. Falls back to
|
|
1674
|
-
// plain
|
|
1683
|
+
// plain withTagHeader when no turnId is available (no active turn to link).
|
|
1675
1684
|
const linkedTagPrefix = (target, turnId) => {
|
|
1676
1685
|
if (!turnId)
|
|
1677
1686
|
return "";
|
|
@@ -1683,9 +1692,9 @@ export const startMirror = (deps) => {
|
|
|
1683
1692
|
const prefix = linkedTagPrefix(a.target, turnId ?? a.briefTurnId);
|
|
1684
1693
|
if (prefix) {
|
|
1685
1694
|
const seqBit = seq ? ` ${seq}` : "";
|
|
1686
|
-
return `${prefix}${seqBit}
|
|
1695
|
+
return `${prefix}${seqBit}${headSep(content)}${content}`;
|
|
1687
1696
|
}
|
|
1688
|
-
return
|
|
1697
|
+
return withTagHeader(a.target, content, seq);
|
|
1689
1698
|
};
|
|
1690
1699
|
// 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`🦊 #tag` / `[🧙](url)`)
|
|
1691
1700
|
// 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
|
|
@@ -1696,7 +1705,7 @@ export const startMirror = (deps) => {
|
|
|
1696
1705
|
if (!hasVisibleBody(content))
|
|
1697
1706
|
return;
|
|
1698
1707
|
const chatId = stripPrincipalPrefix(a.target);
|
|
1699
|
-
const pieces =
|
|
1708
|
+
const pieces = splitMarkdown(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
1700
1709
|
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
1701
1710
|
a.standalonePending = a.standalonePending
|
|
1702
1711
|
.then(async () => {
|
|
@@ -1711,7 +1720,7 @@ export const startMirror = (deps) => {
|
|
|
1711
1720
|
})
|
|
1712
1721
|
.catch(() => undefined);
|
|
1713
1722
|
};
|
|
1714
|
-
// Like sendStandalone but skips
|
|
1723
|
+
// Like sendStandalone but skips withTagHeader — content already contains the tag header (e.g. as a link).
|
|
1715
1724
|
const sendRaw = (a, content) => {
|
|
1716
1725
|
if (!hasVisibleBody(content))
|
|
1717
1726
|
return;
|
|
@@ -1905,7 +1914,7 @@ export const startMirror = (deps) => {
|
|
|
1905
1914
|
// sees the bubble; subsequent items grow it as today.
|
|
1906
1915
|
void (async () => {
|
|
1907
1916
|
try {
|
|
1908
|
-
await client.replyStream(frame, streamId,
|
|
1917
|
+
await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
|
|
1909
1918
|
}
|
|
1910
1919
|
catch (e) {
|
|
1911
1920
|
log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
|
|
@@ -2049,7 +2058,7 @@ export const startMirror = (deps) => {
|
|
|
2049
2058
|
// 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
|
|
2050
2059
|
const turnSilent = (a) => cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false;
|
|
2051
2060
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
2052
|
-
// raw=true skips
|
|
2061
|
+
// raw=true skips withTagHeader (used when content already contains the linked tag header).
|
|
2053
2062
|
// WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
|
|
2054
2063
|
// standalone (sendMessage), 用户会看到 standalone 抢在气泡动画结束之前出现。
|
|
2055
2064
|
// 把 finishBubble 的 replyStream promise 链入 standalonePending, 让后续
|
|
@@ -2109,12 +2118,15 @@ export const startMirror = (deps) => {
|
|
|
2109
2118
|
a.pendingOrigin = undefined;
|
|
2110
2119
|
return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.origin : undefined;
|
|
2111
2120
|
};
|
|
2121
|
+
// 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
|
|
2122
|
+
// 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
|
|
2123
|
+
const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
|
|
2112
2124
|
const startBriefTurn = async (a, frame, streamId, isSlash = false, userQuery = "") => {
|
|
2113
2125
|
const turnId = newTurnId();
|
|
2114
2126
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
2115
2127
|
a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
|
|
2116
2128
|
a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
|
|
2117
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a) });
|
|
2129
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a) });
|
|
2118
2130
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2119
2131
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
2120
2132
|
const q = { turnId, bubble, isSlash };
|
|
@@ -2160,7 +2172,7 @@ export const startMirror = (deps) => {
|
|
|
2160
2172
|
const fromCli = a.pendingFromCli === true;
|
|
2161
2173
|
a.pendingFromCli = false;
|
|
2162
2174
|
a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
|
|
2163
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a) });
|
|
2175
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a) });
|
|
2164
2176
|
a.briefTurnId = turnId;
|
|
2165
2177
|
a.briefBubble = undefined;
|
|
2166
2178
|
a.briefIsSlash = false;
|
|
@@ -2311,6 +2323,7 @@ export const startMirror = (deps) => {
|
|
|
2311
2323
|
id: turnId,
|
|
2312
2324
|
target: a.target,
|
|
2313
2325
|
sessionId: a.sessionId,
|
|
2326
|
+
cli: cliOf(a),
|
|
2314
2327
|
cwd: a.runningCwd || undefined,
|
|
2315
2328
|
userQuery: task,
|
|
2316
2329
|
agent: { id: agentId, type: resolved?.type, description: resolved?.description, parentTurnId: a.briefTurnId },
|
|
@@ -2665,7 +2678,7 @@ export const startMirror = (deps) => {
|
|
|
2665
2678
|
if (!a.keepaliveTurnId) {
|
|
2666
2679
|
const turnId = newTurnId();
|
|
2667
2680
|
a.keepaliveTurnId = turnId;
|
|
2668
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
|
|
2681
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
|
|
2669
2682
|
}
|
|
2670
2683
|
};
|
|
2671
2684
|
// A keepalive ping turn is swallowed wholesale — the reply content doesn't
|
|
@@ -2831,7 +2844,7 @@ export const startMirror = (deps) => {
|
|
|
2831
2844
|
return { ok: false, reason: "no_live_pane" };
|
|
2832
2845
|
if (!(await tmuxPaneAlive(a.tmuxPane)))
|
|
2833
2846
|
return { ok: false, reason: "pane_dead" };
|
|
2834
|
-
const r = await
|
|
2847
|
+
const r = await runTmux(["send-keys", "-t", a.tmuxPane, key]);
|
|
2835
2848
|
return r.code === 0 ? { ok: true } : { ok: false, reason: `send-keys ${key} failed: ${r.stdout.slice(-200) || r.code}` };
|
|
2836
2849
|
},
|
|
2837
2850
|
});
|
|
@@ -2882,13 +2895,13 @@ export const startMirror = (deps) => {
|
|
|
2882
2895
|
}
|
|
2883
2896
|
else {
|
|
2884
2897
|
for (const key of act.keys) {
|
|
2885
|
-
const r = await
|
|
2898
|
+
const r = await runTmux(["send-keys", "-t", a.tmuxPane, key]);
|
|
2886
2899
|
if (r.code !== 0)
|
|
2887
2900
|
return { ok: false, reason: `send-keys ${key}: ${r.stdout.slice(-200) || r.code}` };
|
|
2888
|
-
await
|
|
2901
|
+
await sleep(120); // 键间距 — 等 TUI 重渲染, 防吞键
|
|
2889
2902
|
}
|
|
2890
2903
|
}
|
|
2891
|
-
await
|
|
2904
|
+
await sleep(300); // 题间/阶段间隔 — 等面板翻页
|
|
2892
2905
|
}
|
|
2893
2906
|
return { ok: true };
|
|
2894
2907
|
},
|
|
@@ -3057,7 +3070,7 @@ export const startMirror = (deps) => {
|
|
|
3057
3070
|
const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
|
|
3058
3071
|
? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
|
|
3059
3072
|
: "";
|
|
3060
|
-
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, pendingCwd }) => {
|
|
3073
|
+
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd }) => {
|
|
3061
3074
|
const target = resolveTarget(targetOverride);
|
|
3062
3075
|
if (!target)
|
|
3063
3076
|
return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
|
|
@@ -3083,6 +3096,10 @@ export const startMirror = (deps) => {
|
|
|
3083
3096
|
// otherwise. `undefined` (omitted) → carry from prev (re-attach case);
|
|
3084
3097
|
// `""` → explicit clear (newSession just consumed it); `"/foo"` → set.
|
|
3085
3098
|
const carryPending = pendingCwd !== undefined ? pendingCwd : (prevByTarget?.pendingCwd ?? "");
|
|
3099
|
+
// Same carry rule as pendingCwd, and for the same reason: a spawn knows the
|
|
3100
|
+
// model it asked for (possibly "" = CLI default) and says so; a re-attach
|
|
3101
|
+
// onto an already-running pane doesn't, so it keeps what was recorded.
|
|
3102
|
+
const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
|
|
3086
3103
|
if (prevByTarget)
|
|
3087
3104
|
detach(prevByTarget, "target reassigned");
|
|
3088
3105
|
// Build the attachment first so the tail's onItem closure can capture it.
|
|
@@ -3097,6 +3114,7 @@ export const startMirror = (deps) => {
|
|
|
3097
3114
|
// collapsing to cfg.wrc.cwd (which would mislabel /pwd, /clear, /new).
|
|
3098
3115
|
runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
|
|
3099
3116
|
pendingCwd: carryPending,
|
|
3117
|
+
model: carryModel,
|
|
3100
3118
|
tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
|
|
3101
3119
|
standalonePending: Promise.resolve(),
|
|
3102
3120
|
recentToolSigs: new Map(),
|
|
@@ -3133,6 +3151,7 @@ export const startMirror = (deps) => {
|
|
|
3133
3151
|
tmuxSession: a.tmuxSession || undefined,
|
|
3134
3152
|
tmuxPane: a.tmuxPane || undefined,
|
|
3135
3153
|
cwd: a.runningCwd || undefined,
|
|
3154
|
+
model: a.model || undefined,
|
|
3136
3155
|
pendingCwd: a.pendingCwd || undefined,
|
|
3137
3156
|
keepaliveOff: prevRec?.keepaliveOff,
|
|
3138
3157
|
keepaliveOffAt: prevRec?.keepaliveOffAt,
|
|
@@ -3151,7 +3170,7 @@ export const startMirror = (deps) => {
|
|
|
3151
3170
|
const tmuxPaneAlive = async (paneId) => {
|
|
3152
3171
|
if (!paneId)
|
|
3153
3172
|
return false;
|
|
3154
|
-
const r = await
|
|
3173
|
+
const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
|
|
3155
3174
|
return r.code === 0 && r.stdout.trim() === paneId;
|
|
3156
3175
|
};
|
|
3157
3176
|
// Re-attach a stored binding for `principal`. Returns the resulting state, or
|
|
@@ -3244,7 +3263,7 @@ export const startMirror = (deps) => {
|
|
|
3244
3263
|
// is silently detached; that detached chat then never mirrors again. Runs at
|
|
3245
3264
|
// boot; the 3s drift follower maintains it thereafter.
|
|
3246
3265
|
if (livePane) {
|
|
3247
|
-
const cwdRes = await
|
|
3266
|
+
const cwdRes = await runTmux(["display-message", "-p", "-t", livePane, "#{pane_current_path}"]);
|
|
3248
3267
|
const paneCwd = cwdRes.stdout.trim();
|
|
3249
3268
|
if (paneCwd) {
|
|
3250
3269
|
// Encode under the backend that owns the bound transcript — comparing
|
|
@@ -3271,6 +3290,7 @@ export const startMirror = (deps) => {
|
|
|
3271
3290
|
tmuxPane: livePane,
|
|
3272
3291
|
tmuxSession: rec.tmuxSession ?? "",
|
|
3273
3292
|
cwd: rec.cwd,
|
|
3293
|
+
model: rec.model,
|
|
3274
3294
|
pendingCwd: rec.pendingCwd,
|
|
3275
3295
|
});
|
|
3276
3296
|
if (!r.ok) {
|
|
@@ -3336,10 +3356,10 @@ export const startMirror = (deps) => {
|
|
|
3336
3356
|
}
|
|
3337
3357
|
})();
|
|
3338
3358
|
const result = t.result ?? "(no result captured)";
|
|
3339
|
-
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\`\`\``);
|
|
3340
3360
|
}
|
|
3341
3361
|
const merged = parts.join("\n\n---\n\n");
|
|
3342
|
-
return
|
|
3362
|
+
return splitMarkdown(merged, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
3343
3363
|
};
|
|
3344
3364
|
const resolveToolDetail = (turnId) => {
|
|
3345
3365
|
evictTurns();
|
|
@@ -3357,6 +3377,15 @@ export const startMirror = (deps) => {
|
|
|
3357
3377
|
const t = text.trim();
|
|
3358
3378
|
return t === "/clear" || t.startsWith("/clear ") || t.startsWith("/clear\n");
|
|
3359
3379
|
};
|
|
3380
|
+
/** mtime(ms), 0 when unreadable — "newest transcript wins" 的排序键。 */
|
|
3381
|
+
const mtimeOf = (path) => {
|
|
3382
|
+
try {
|
|
3383
|
+
return statSync(path).mtimeMs;
|
|
3384
|
+
}
|
|
3385
|
+
catch {
|
|
3386
|
+
return 0;
|
|
3387
|
+
}
|
|
3388
|
+
};
|
|
3360
3389
|
const listJsonls = (dir) => {
|
|
3361
3390
|
try {
|
|
3362
3391
|
return new Set(readdirSync(dir).filter((n) => n.endsWith(".jsonl")));
|
|
@@ -3675,7 +3704,7 @@ export const startMirror = (deps) => {
|
|
|
3675
3704
|
// Guard 1+2: pane must be alive AND still in the bound project dir. One
|
|
3676
3705
|
// display-message gives both — it fails on a dead pane, and its cwd tells
|
|
3677
3706
|
// us whether the pane still belongs here.
|
|
3678
|
-
const r = await
|
|
3707
|
+
const r = await runTmux(["display-message", "-p", "-t", a.tmuxPane, "#{pane_current_path}"]);
|
|
3679
3708
|
if (stopped)
|
|
3680
3709
|
return;
|
|
3681
3710
|
const paneCwd = r.code === 0 ? r.stdout.trim() : "";
|
|
@@ -3753,7 +3782,7 @@ export const startMirror = (deps) => {
|
|
|
3753
3782
|
const attachments = Array.from(byTarget.values()).filter((a) => a.tmuxPane);
|
|
3754
3783
|
if (attachments.length === 0)
|
|
3755
3784
|
return;
|
|
3756
|
-
const r = await
|
|
3785
|
+
const r = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{pane_current_path}"]);
|
|
3757
3786
|
if (r.code !== 0)
|
|
3758
3787
|
return;
|
|
3759
3788
|
const paneCwd = new Map();
|
|
@@ -3812,7 +3841,7 @@ export const startMirror = (deps) => {
|
|
|
3812
3841
|
// Send via plain sendMessage so the user still gets the info.
|
|
3813
3842
|
const chatId = stripPrincipalPrefix(target);
|
|
3814
3843
|
void client
|
|
3815
|
-
.sendMessage(chatId, { msgtype: "markdown", markdown: { content:
|
|
3844
|
+
.sendMessage(chatId, { msgtype: "markdown", markdown: { content: withTagHeader(target, md) } })
|
|
3816
3845
|
.catch((e) => log.warn({ err: e.message, target }, "pushProjectInfo (no attach) failed"));
|
|
3817
3846
|
};
|
|
3818
3847
|
// Cwd is CHAT-SCOPED, not session-scoped: all sessions in the same chat
|
|
@@ -3821,7 +3850,7 @@ export const startMirror = (deps) => {
|
|
|
3821
3850
|
// own `runningCwd` (the tmux pane's actual working dir at spawn time), but
|
|
3822
3851
|
// cwd fallbacks and `set_workspace` pendingCwd writes always resolve against the base.
|
|
3823
3852
|
const chatCwdFallback = (target) => {
|
|
3824
|
-
const base =
|
|
3853
|
+
const base = baseOfKey(target);
|
|
3825
3854
|
const baseA = byTarget.get(base);
|
|
3826
3855
|
const baseRec = deps.store.get(base);
|
|
3827
3856
|
return {
|
|
@@ -3832,6 +3861,20 @@ export const startMirror = (deps) => {
|
|
|
3832
3861
|
// /new path: kill the old pane (so we don't leak orphan tmux windows) and
|
|
3833
3862
|
// spawn a fresh claude in pendingCwd ?? runningCwd ?? default. Returns the
|
|
3834
3863
|
// new sessionId/cwd so callers can render the user-facing reply.
|
|
3864
|
+
// 身份提供者。newSession / 重生 / clone 全都从这里取「这个 target 是谁」, 于是
|
|
3865
|
+
// 无论是群里手打的 /new、pane 死了的自愈重生, 还是编排生出来的分身, 新进程一
|
|
3866
|
+
// 睁眼就知道自己是谁 —— 而不是只有走 wizard 路由那一条路才有身份。
|
|
3867
|
+
// 由 index.ts 在启动时装上 (注册表活在那边); 没装 = 退回无身份行为, 全链路无回归。
|
|
3868
|
+
let charterOf;
|
|
3869
|
+
const setCharterProvider = (fn) => { charterOf = fn; };
|
|
3870
|
+
const charterFor = (target) => {
|
|
3871
|
+
try {
|
|
3872
|
+
return charterOf?.(target);
|
|
3873
|
+
}
|
|
3874
|
+
catch {
|
|
3875
|
+
return undefined;
|
|
3876
|
+
}
|
|
3877
|
+
};
|
|
3835
3878
|
const newSession = async (target, windowName, cli, opts) => {
|
|
3836
3879
|
const prev = byTarget.get(target);
|
|
3837
3880
|
// Resolution precedence (all chat-scoped except the running-cwd fallback):
|
|
@@ -3859,13 +3902,13 @@ export const startMirror = (deps) => {
|
|
|
3859
3902
|
// is chat-scoped like cwd — a FIRST `/new #tag` has no record of its own,
|
|
3860
3903
|
// so it falls back to the base session's binding instead of `defaultCli`
|
|
3861
3904
|
// (otherwise a tagged sibling silently forks onto a different CLI).
|
|
3862
|
-
const base =
|
|
3905
|
+
const base = baseOfKey(target);
|
|
3863
3906
|
const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
|
|
3864
3907
|
const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
|
|
3865
3908
|
const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
|
|
3866
3909
|
if (prev?.tmuxPane) {
|
|
3867
3910
|
// Best-effort kill; ignore errors (pane may already be dead).
|
|
3868
|
-
void
|
|
3911
|
+
void runTmux(["kill-pane", "-t", prev.tmuxPane]);
|
|
3869
3912
|
}
|
|
3870
3913
|
if (prev)
|
|
3871
3914
|
detach(prev, "/new respawn");
|
|
@@ -3876,6 +3919,7 @@ export const startMirror = (deps) => {
|
|
|
3876
3919
|
cwdOverride: eff,
|
|
3877
3920
|
cli: effCli,
|
|
3878
3921
|
model: opts?.model,
|
|
3922
|
+
systemPrompt: opts?.systemPrompt ?? charterFor(target),
|
|
3879
3923
|
});
|
|
3880
3924
|
if (!r.ok)
|
|
3881
3925
|
return { ok: false, reason: r.reason };
|
|
@@ -3888,6 +3932,9 @@ export const startMirror = (deps) => {
|
|
|
3888
3932
|
cwd: r.cwd,
|
|
3889
3933
|
// Explicit "" clears any carried-over pending — it has just been applied.
|
|
3890
3934
|
pendingCwd: "",
|
|
3935
|
+
// Likewise explicit: `/new` starts a NEW session, so an unnamed model
|
|
3936
|
+
// means this CLI's default, not the dead pane's model.
|
|
3937
|
+
model: opts?.model?.trim() ?? "",
|
|
3891
3938
|
});
|
|
3892
3939
|
if (!att.ok)
|
|
3893
3940
|
return { ok: false, reason: att.reason };
|
|
@@ -3935,6 +3982,133 @@ export const startMirror = (deps) => {
|
|
|
3935
3982
|
pushProjectInfo(target, "created");
|
|
3936
3983
|
return { ok: true, sessionId: r.sessionId, cwd: r.cwd };
|
|
3937
3984
|
};
|
|
3985
|
+
// ── Clone ───────────────────────────────────────────────────────────
|
|
3986
|
+
// 分身 = `--resume <父 sid> --fork-session` 起的新 pane: CLI 把父亲的 transcript
|
|
3987
|
+
// 复制成一份新的再继续写, 于是子进程开局就带着父亲此刻读过的一切 (规范、目录、
|
|
3988
|
+
// 刚啃完的那份文档), 而父亲毫发无损。这正是「先把公共材料载进一个基座, 再从它
|
|
3989
|
+
// 身上分出 N 个干活的」所需要的语义: 材料只读一遍, 却进了 N 份上下文。
|
|
3990
|
+
//
|
|
3991
|
+
// 两件事非做不可:
|
|
3992
|
+
// `--fork-session` —— 少了它, 这个 pane 会**续写父亲的 jsonl**。两个 pane 写
|
|
3993
|
+
// 同一个 transcript, 两个 target 指向同一个 sid, 群与群从此串线且无法回滚。
|
|
3994
|
+
// 先说话再 attach —— 分叉文件不是开机就有的, CLI 在收到第一条消息那一刻才写。
|
|
3995
|
+
// 所以 spawn 之后先注入开场白, 等分叉出现拿到它自己的 sid, 再 attach。等不到
|
|
3996
|
+
// 就把 pane 一起收掉: 那时唯一已知的 sid 是父亲的, 认下去就是上面那种串线
|
|
3997
|
+
// (attach 也会正当地拒绝), 一个干净的失败远比一条错绑好收拾。
|
|
3998
|
+
const FORK_POLL_MS = 400;
|
|
3999
|
+
const FORK_WAIT_MS = 45_000;
|
|
4000
|
+
const awaitFork = async (projectDir, baseline, claimed) => {
|
|
4001
|
+
const deadline = Date.now() + FORK_WAIT_MS;
|
|
4002
|
+
while (Date.now() < deadline) {
|
|
4003
|
+
const born = [...listJsonls(projectDir)]
|
|
4004
|
+
.filter((n) => !baseline.has(n) && !claimed.has(n.replace(/\.jsonl$/, "")))
|
|
4005
|
+
.map((n) => ({ n, path: join(projectDir, n) }))
|
|
4006
|
+
// 种子化的 fork 一落地就带着父亲的 user 行 —— 空文件 (刚 touch 出来的)
|
|
4007
|
+
// 还不能认, 认早了 tail 会从一个还没写完的复制过程中间开始读。
|
|
4008
|
+
.filter((c) => firstUserUuid(c.path) !== undefined)
|
|
4009
|
+
.sort((x, y) => mtimeOf(y.path) - mtimeOf(x.path));
|
|
4010
|
+
const hit = born[0];
|
|
4011
|
+
if (hit)
|
|
4012
|
+
return { sessionId: hit.n.replace(/\.jsonl$/, ""), jsonlPath: hit.path };
|
|
4013
|
+
await sleep(FORK_POLL_MS);
|
|
4014
|
+
}
|
|
4015
|
+
return undefined;
|
|
4016
|
+
};
|
|
4017
|
+
const cloneSession = async (args) => {
|
|
4018
|
+
const p = byTarget.get(args.parent) ?? (await restoreFromStore(args.parent));
|
|
4019
|
+
// 继承上下文要求父亲有一个活着的 transcript, 且分身必须待在同一个项目目录 ——
|
|
4020
|
+
// `--resume` 是按项目目录寻址 session 的。调用方点名换目录 = 明确放弃继承。
|
|
4021
|
+
const sameCwd = !args.cwd?.trim() || expandHome(args.cwd.trim()) === (p?.runningCwd ?? "");
|
|
4022
|
+
const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
|
|
4023
|
+
if (!canInherit) {
|
|
4024
|
+
const r = await newSession(args.target, args.windowName, args.cli, {
|
|
4025
|
+
model: args.model, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true,
|
|
4026
|
+
});
|
|
4027
|
+
return { ...r, inherited: false };
|
|
4028
|
+
}
|
|
4029
|
+
const parent = p;
|
|
4030
|
+
const projectDir = dirname(parent.jsonlPath);
|
|
4031
|
+
const baseline = listJsonls(projectDir);
|
|
4032
|
+
const lg = log.child({ sub: "clone", target: args.target });
|
|
4033
|
+
const r = await spawnTmuxClaude({
|
|
4034
|
+
cfg,
|
|
4035
|
+
log: lg,
|
|
4036
|
+
resumeSessionId: parent.sessionId,
|
|
4037
|
+
forkSession: true,
|
|
4038
|
+
windowName: args.windowName ?? args.target,
|
|
4039
|
+
cwdOverride: parent.runningCwd,
|
|
4040
|
+
cli: backendForPath(parent.jsonlPath).name,
|
|
4041
|
+
model: args.model,
|
|
4042
|
+
systemPrompt: args.systemPrompt,
|
|
4043
|
+
});
|
|
4044
|
+
if (!r.ok)
|
|
4045
|
+
return { ok: false, reason: r.reason, inherited: false };
|
|
4046
|
+
// 分叉文件不是开机就有的: CLI 在这个 pane **收到第一条消息**的那一刻才把父亲的
|
|
4047
|
+
// transcript 复制成新文件。所以必须先说一句话, 分身才会有自己的 sid —— 这句话
|
|
4048
|
+
// 顺便就是它的开场白 (「你是谁、继承了什么、准备接什么」), 群里看到的第一条也是它。
|
|
4049
|
+
const hello = args.bootstrap?.trim()
|
|
4050
|
+
|| "(wezard 分身初始化)用一句话说明: 你是谁、从谁那里继承了哪些上下文、准备接什么活。不要复述这条指令。";
|
|
4051
|
+
rememberInject(hello);
|
|
4052
|
+
const boot = await inject({
|
|
4053
|
+
text: hello, images: [], cfg, log: lg,
|
|
4054
|
+
sessionId: parent.sessionId, jsonlPath: parent.jsonlPath,
|
|
4055
|
+
tmuxTarget: r.tmuxPane, freshSpawn: true,
|
|
4056
|
+
});
|
|
4057
|
+
if (!boot.ok) {
|
|
4058
|
+
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4059
|
+
return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
|
|
4060
|
+
}
|
|
4061
|
+
const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target));
|
|
4062
|
+
// 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
|
|
4063
|
+
// 同一个会话 (attach 也会正当地拒绝)。留一个孤儿 pane 比留一条错绑更好收拾 ——
|
|
4064
|
+
// 所以连 pane 一起杀掉, 调用方拿到的是一个干净的失败。
|
|
4065
|
+
if (!fork) {
|
|
4066
|
+
await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
|
|
4067
|
+
return { ok: false, reason: "分身没能在超时内分叉出自己的会话 (CLI 可能不支持 --fork-session)", inherited: false };
|
|
4068
|
+
}
|
|
4069
|
+
const att = attach({
|
|
4070
|
+
sessionId: fork.sessionId,
|
|
4071
|
+
jsonlPath: fork.jsonlPath,
|
|
4072
|
+
target: args.target,
|
|
4073
|
+
tmuxPane: r.tmuxPane,
|
|
4074
|
+
tmuxSession: r.tmuxSession,
|
|
4075
|
+
cwd: r.cwd,
|
|
4076
|
+
pendingCwd: "",
|
|
4077
|
+
model: args.model?.trim() ?? "",
|
|
4078
|
+
});
|
|
4079
|
+
if (!att.ok)
|
|
4080
|
+
return { ok: false, reason: att.reason, inherited: false };
|
|
4081
|
+
const spawned = byTarget.get(args.target);
|
|
4082
|
+
if (spawned) {
|
|
4083
|
+
// muteUntilInject 是给"全新 pane 的 greeting 噪音"准备的; 分身的开场白是正经
|
|
4084
|
+
// 内容 (群里第一条就该是它), 所以这里不静音。
|
|
4085
|
+
spawned.justSpawned = true;
|
|
4086
|
+
spawned.keepaliveOff = true;
|
|
4087
|
+
spawned.keepaliveOffAt = Date.now();
|
|
4088
|
+
spawned.keepalive = undefined;
|
|
4089
|
+
persistPause(spawned);
|
|
4090
|
+
}
|
|
4091
|
+
lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
|
|
4092
|
+
return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true };
|
|
4093
|
+
};
|
|
4094
|
+
/** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
|
|
4095
|
+
const sessionInfo = (target) => {
|
|
4096
|
+
const a = byTarget.get(target);
|
|
4097
|
+
const rec = a ? undefined : deps.store.get(target);
|
|
4098
|
+
const sessionId = a?.sessionId ?? rec?.sessionId ?? "";
|
|
4099
|
+
const jsonlPath = a?.jsonlPath ?? (rec?.jsonlPath ? expandHome(rec.jsonlPath) : "");
|
|
4100
|
+
if (!sessionId || !jsonlPath)
|
|
4101
|
+
return undefined;
|
|
4102
|
+
return {
|
|
4103
|
+
sessionId,
|
|
4104
|
+
jsonlPath,
|
|
4105
|
+
cwd: a?.runningCwd || rec?.cwd || "",
|
|
4106
|
+
cli: backendForPath(jsonlPath).name,
|
|
4107
|
+
model: a?.model || rec?.model || "",
|
|
4108
|
+
tmuxPane: a?.tmuxPane ?? rec?.tmuxPane ?? "",
|
|
4109
|
+
contextTokens: lastContextTokens(jsonlPath),
|
|
4110
|
+
};
|
|
4111
|
+
};
|
|
3938
4112
|
const getCwd = (target) => {
|
|
3939
4113
|
// pendingCwd is chat-scoped — a `set_workspace` from any sibling session
|
|
3940
4114
|
// queues the switch for the whole chat. runningCwd stays per-session (each
|
|
@@ -3960,7 +4134,7 @@ export const startMirror = (deps) => {
|
|
|
3960
4134
|
const expanded = expandHome(trimmed);
|
|
3961
4135
|
if (!expanded.startsWith("/"))
|
|
3962
4136
|
return { ok: false, reason: "cwd must be absolute (or start with ~)", runningCwd: "", pendingCwd: "" };
|
|
3963
|
-
const base =
|
|
4137
|
+
const base = baseOfKey(target);
|
|
3964
4138
|
const callerA = byTarget.get(target);
|
|
3965
4139
|
const callerRunning = callerA?.runningCwd?.trim() || deps.store.get(target)?.cwd?.trim() || expandedDefaultCwd;
|
|
3966
4140
|
const baseA = byTarget.get(base);
|
|
@@ -4018,8 +4192,8 @@ export const startMirror = (deps) => {
|
|
|
4018
4192
|
return Array.from(keys).sort();
|
|
4019
4193
|
};
|
|
4020
4194
|
const chatTargets = (target) => {
|
|
4021
|
-
const base =
|
|
4022
|
-
return allTargets().filter((k) =>
|
|
4195
|
+
const base = baseOfKey(target);
|
|
4196
|
+
return allTargets().filter((k) => baseOfKey(k) === base);
|
|
4023
4197
|
};
|
|
4024
4198
|
// 名字解析:`daily` / `chat:wrxxx` → base principal。认不出就把已知名字一并回给
|
|
4025
4199
|
// 调用方 —— 跨 chat 出错时"我该写什么"比"你写错了"有用得多。
|
|
@@ -4052,21 +4226,23 @@ export const startMirror = (deps) => {
|
|
|
4052
4226
|
return c;
|
|
4053
4227
|
const target = keyOf(c.base, t);
|
|
4054
4228
|
if (allTargets().includes(target)) {
|
|
4055
|
-
return { ok: true, target, foreign: c.base !==
|
|
4229
|
+
return { ok: true, target, foreign: c.base !== baseOfKey(self) };
|
|
4056
4230
|
}
|
|
4057
4231
|
return {
|
|
4058
4232
|
ok: false,
|
|
4059
4233
|
reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
|
|
4060
|
-
|
|
4234
|
+
// 候选给**地址**而不是裸 key —— 这份清单存在的意义就是让调用方照着改一个
|
|
4235
|
+
// 能用的串; 隔壁两个分支早就这么做了, 只有这一条漏掉。
|
|
4236
|
+
candidates: allTargets().filter((k) => baseOfKey(k) === c.base).map((k) => peerAddress(cfg, self, k)),
|
|
4061
4237
|
};
|
|
4062
4238
|
}
|
|
4063
|
-
const local = keyOf(
|
|
4239
|
+
const local = keyOf(baseOfKey(self), t);
|
|
4064
4240
|
if (!t)
|
|
4065
4241
|
return { ok: true, target: local, foreign: false };
|
|
4066
4242
|
const all = allTargets();
|
|
4067
4243
|
if (all.includes(local))
|
|
4068
4244
|
return { ok: true, target: local, foreign: false };
|
|
4069
|
-
const foreignMatches = all.filter((k) => tagOfKey(k) === t &&
|
|
4245
|
+
const foreignMatches = all.filter((k) => tagOfKey(k) === t && baseOfKey(k) !== baseOfKey(self));
|
|
4070
4246
|
if (foreignMatches.length === 1)
|
|
4071
4247
|
return { ok: true, target: foreignMatches[0], foreign: true };
|
|
4072
4248
|
if (foreignMatches.length === 0) {
|
|
@@ -4077,7 +4253,7 @@ export const startMirror = (deps) => {
|
|
|
4077
4253
|
return {
|
|
4078
4254
|
ok: false,
|
|
4079
4255
|
reason: `'${t}' is a CHAT, not a tag — address one of its sessions, e.g. '${t}#' for its default`,
|
|
4080
|
-
candidates: allTargets().filter((k) =>
|
|
4256
|
+
candidates: allTargets().filter((k) => baseOfKey(k) === asChat).map((k) => peerAddress(cfg, self, k)),
|
|
4081
4257
|
};
|
|
4082
4258
|
}
|
|
4083
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)` };
|
|
@@ -4093,14 +4269,14 @@ export const startMirror = (deps) => {
|
|
|
4093
4269
|
const chatRoster = (self) => {
|
|
4094
4270
|
const bases = new Set([
|
|
4095
4271
|
...listChatNames(cfg).map((c) => c.base),
|
|
4096
|
-
...allTargets().map(
|
|
4097
|
-
|
|
4272
|
+
...allTargets().map(baseOfKey),
|
|
4273
|
+
baseOfKey(self),
|
|
4098
4274
|
]);
|
|
4099
4275
|
return [...bases].filter(Boolean).sort().map((base) => ({
|
|
4100
4276
|
base,
|
|
4101
4277
|
name: chatNameOf(cfg, base),
|
|
4102
|
-
self: base ===
|
|
4103
|
-
targets: allTargets().filter((k) =>
|
|
4278
|
+
self: base === baseOfKey(self),
|
|
4279
|
+
targets: allTargets().filter((k) => baseOfKey(k) === base),
|
|
4104
4280
|
}));
|
|
4105
4281
|
};
|
|
4106
4282
|
const paneOf = (target) => byTarget.get(target)?.tmuxPane || deps.store.get(target)?.tmuxPane || "";
|
|
@@ -4150,7 +4326,7 @@ export const startMirror = (deps) => {
|
|
|
4150
4326
|
const jsonlPath = jsonlOf(t);
|
|
4151
4327
|
const pane = paneOf(t);
|
|
4152
4328
|
const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
|
|
4153
|
-
const tag =
|
|
4329
|
+
const tag = tagOfKey(t);
|
|
4154
4330
|
let lastActivity = 0;
|
|
4155
4331
|
try {
|
|
4156
4332
|
if (jsonlPath)
|
|
@@ -4167,6 +4343,7 @@ export const startMirror = (deps) => {
|
|
|
4167
4343
|
jsonlPath,
|
|
4168
4344
|
cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
|
|
4169
4345
|
cli: jsonlPath ? backendForPath(jsonlPath).name : (activeBackends()[0]?.name ?? "claude"),
|
|
4346
|
+
model: a?.model || rec?.model || "",
|
|
4170
4347
|
tmuxPane: pane,
|
|
4171
4348
|
attached: !!a,
|
|
4172
4349
|
paneAlive,
|
|
@@ -4183,8 +4360,8 @@ export const startMirror = (deps) => {
|
|
|
4183
4360
|
// `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
|
|
4184
4361
|
// 只要给群起个名,不必回去改别人的 tag。
|
|
4185
4362
|
const foreignPeers = async (self) => {
|
|
4186
|
-
const selfBase =
|
|
4187
|
-
const foreign = allTargets().filter((k) => tagOfKey(k) &&
|
|
4363
|
+
const selfBase = baseOfKey(self);
|
|
4364
|
+
const foreign = allTargets().filter((k) => tagOfKey(k) && baseOfKey(k) !== selfBase);
|
|
4188
4365
|
const tagCount = foreign.reduce((acc, k) => acc.set(tagOfKey(k), (acc.get(tagOfKey(k)) ?? 0) + 1), new Map());
|
|
4189
4366
|
const reachable = foreign.filter((k) => tagCount.get(tagOfKey(k)) === 1 || chatNameOf(cfg, k));
|
|
4190
4367
|
return (await Promise.all(reachable.map((t) => peerInfoOf(t, self)))).sort(byRecent);
|
|
@@ -4289,7 +4466,7 @@ export const startMirror = (deps) => {
|
|
|
4289
4466
|
if (!a.keepaliveTurnId) {
|
|
4290
4467
|
const turnId = newTurnId();
|
|
4291
4468
|
a.keepaliveTurnId = turnId;
|
|
4292
|
-
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, userQuery: text });
|
|
4469
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
|
|
4293
4470
|
}
|
|
4294
4471
|
};
|
|
4295
4472
|
let keepaliveTicking = false;
|
|
@@ -4517,8 +4694,8 @@ export const startMirror = (deps) => {
|
|
|
4517
4694
|
return { ok: false, reason: `当前确认框已是另一个调用(${now.toolName}),不代按` };
|
|
4518
4695
|
}
|
|
4519
4696
|
if (what === "no") {
|
|
4520
|
-
await
|
|
4521
|
-
await
|
|
4697
|
+
await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
|
|
4698
|
+
await sleep(MODAL_PRESS_SETTLE_MS);
|
|
4522
4699
|
const after = await detectModalPicker(a.tmuxPane);
|
|
4523
4700
|
return after.modal ? { ok: false, reason: "Escape 后确认框仍在" } : { ok: true, label: "Escape" };
|
|
4524
4701
|
}
|
|
@@ -4648,7 +4825,7 @@ export const startMirror = (deps) => {
|
|
|
4648
4825
|
if (a.recentToolSigs.has(expectSig))
|
|
4649
4826
|
break;
|
|
4650
4827
|
polls++;
|
|
4651
|
-
await
|
|
4828
|
+
await sleep(50);
|
|
4652
4829
|
}
|
|
4653
4830
|
if (!a.recentToolSigs.has(expectSig)) {
|
|
4654
4831
|
log.warn({ sessionId, polls, toolName: expect?.toolName }, "flushBeforeCard wait timed out — sending card without sig confirm");
|
|
@@ -4702,7 +4879,7 @@ export const startMirror = (deps) => {
|
|
|
4702
4879
|
// 队列内单条失败已在 sendStandalone 里 warn 过, 这里吞掉, 不阻塞发卡。
|
|
4703
4880
|
}
|
|
4704
4881
|
},
|
|
4705
|
-
injectText: async (target, text, origin) => {
|
|
4882
|
+
injectText: async (target, text, origin, opts) => {
|
|
4706
4883
|
// Lazy restore, same as dispatch: a peer session that hasn't been talked
|
|
4707
4884
|
// to in this process lifetime (or any session after a reload) has an empty
|
|
4708
4885
|
// in-memory slot but a perfectly good persisted binding. Without this, an
|
|
@@ -4712,10 +4889,17 @@ export const startMirror = (deps) => {
|
|
|
4712
4889
|
return { ok: false, reason: "no mirror attached for target" };
|
|
4713
4890
|
if (!text.trim())
|
|
4714
4891
|
return { ok: false, reason: "empty text" };
|
|
4892
|
+
// 出处改写要早于 inject: tail 可能在 promise resolve 之前就开出 turn。
|
|
4893
|
+
if (opts?.fromChat)
|
|
4894
|
+
a.turnFromChat = true;
|
|
4895
|
+
// 名册增量搭这趟车进去 —— 同伴派活 / 定时任务 / graph 步骤都走这里, 所以
|
|
4896
|
+
// 一个从不被人直接说话的分身也能知道群里多了谁、少了谁。人说的话那一条
|
|
4897
|
+
// 路径在 inbound.send 上挂 (dispatch 有自己的 inject)。
|
|
4898
|
+
const full = text + noticeSuffixFor(target, text);
|
|
4715
4899
|
// Pre-record so the tail's user-line emission is suppressed by the
|
|
4716
4900
|
// recentInjects dedupe (otherwise the user sees their own demo prompt
|
|
4717
4901
|
// echoed back as a quoted bubble).
|
|
4718
|
-
rememberInject(
|
|
4902
|
+
rememberInject(full);
|
|
4719
4903
|
// 印章要早于 inject 落地 —— tail 是独立轮询的, 它可能在 inject 的 promise
|
|
4720
4904
|
// resolve 之前就看到 user 行并开出 turn, 那时归因必须已经在位。
|
|
4721
4905
|
if (origin)
|
|
@@ -4727,14 +4911,14 @@ export const startMirror = (deps) => {
|
|
|
4727
4911
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
4728
4912
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
4729
4913
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
4730
|
-
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) });
|
|
4731
4915
|
if (!r.ok || !r.tmuxPane)
|
|
4732
4916
|
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
4733
4917
|
a.tmuxPane = r.tmuxPane;
|
|
4734
4918
|
a.tmuxSession = r.tmuxSession ?? a.tmuxSession;
|
|
4735
4919
|
if (r.cwd)
|
|
4736
4920
|
a.runningCwd = r.cwd;
|
|
4737
|
-
deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, pendingCwd: a.pendingCwd || undefined });
|
|
4921
|
+
deps.store.set(target, { sessionId: sid, jsonlPath: a.jsonlPath, tmuxSession: a.tmuxSession, tmuxPane: a.tmuxPane, cwd: a.runningCwd || undefined, model: a.model || undefined, pendingCwd: a.pendingCwd || undefined });
|
|
4738
4922
|
startMigrationWatcher(a, resumeBaseline, (p) => firstUserUuid(p) !== undefined);
|
|
4739
4923
|
}
|
|
4740
4924
|
// freshSpawn: true — the pane was just minted by /mirror/spawn, the TUI
|
|
@@ -4743,7 +4927,7 @@ export const startMirror = (deps) => {
|
|
|
4743
4927
|
a.muteUntilInject = false;
|
|
4744
4928
|
a.justSpawned = false;
|
|
4745
4929
|
const r = await inject({
|
|
4746
|
-
text, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
4930
|
+
text: full, images: [], cfg, log: log.child({ principal: target, sessionId: sid, sub: "init-demo" }),
|
|
4747
4931
|
sessionId: sid, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn: true,
|
|
4748
4932
|
});
|
|
4749
4933
|
return r;
|
|
@@ -4788,7 +4972,7 @@ export const startMirror = (deps) => {
|
|
|
4788
4972
|
escReason = "tmux pane no longer alive";
|
|
4789
4973
|
}
|
|
4790
4974
|
else {
|
|
4791
|
-
const r = await
|
|
4975
|
+
const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
|
|
4792
4976
|
escOk = r.ok;
|
|
4793
4977
|
if (!r.ok)
|
|
4794
4978
|
escReason = `send-keys Escape failed: ${(r.stderr || r.stdout).slice(-200) || r.code}`;
|
|
@@ -4813,9 +4997,9 @@ export const startMirror = (deps) => {
|
|
|
4813
4997
|
if (pane && (await tmuxPaneAlive(pane))) {
|
|
4814
4998
|
// Esc first, like /stop: a mid-generation CLI gets a beat to unwind and
|
|
4815
4999
|
// flush its transcript before the TTY is yanked out from under it.
|
|
4816
|
-
await
|
|
4817
|
-
await
|
|
4818
|
-
const r = await
|
|
5000
|
+
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
5001
|
+
await sleep(250);
|
|
5002
|
+
const r = await runTmux(["kill-pane", "-t", pane]);
|
|
4819
5003
|
if (r.code !== 0)
|
|
4820
5004
|
return { ok: false, reason: `kill-pane failed: ${r.stdout.slice(-200) || r.code}` };
|
|
4821
5005
|
}
|
|
@@ -4838,7 +5022,7 @@ export const startMirror = (deps) => {
|
|
|
4838
5022
|
const alive = await tmuxPaneAlive(a.tmuxPane);
|
|
4839
5023
|
if (!alive)
|
|
4840
5024
|
return { ok: false, reason: "tmux pane no longer alive" };
|
|
4841
|
-
const r = await
|
|
5025
|
+
const r = await runTmux(["send-keys", "-t", a.tmuxPane, "Enter"]);
|
|
4842
5026
|
if (r.code !== 0)
|
|
4843
5027
|
return { ok: false, reason: `send-keys Enter failed: ${r.stdout.slice(-200) || r.code}` };
|
|
4844
5028
|
log.info({ target, sessionId: a.sessionId, pane: a.tmuxPane }, "mirror /n — Enter sent to pane");
|
|
@@ -4856,9 +5040,9 @@ export const startMirror = (deps) => {
|
|
|
4856
5040
|
// switching a client that sits on an unrelated session would yank the
|
|
4857
5041
|
// user's other terminal window into wezard. Most-recently-active wins —
|
|
4858
5042
|
// switch-client with no -c is ambiguous under multiple clients.
|
|
4859
|
-
const s = await
|
|
5043
|
+
const s = await runTmux(["display-message", "-p", "-t", pane, "#{session_name}"]);
|
|
4860
5044
|
const sess = s.stdout.trim() || cfg.wrc.tmuxPrefix;
|
|
4861
|
-
const clients = await
|
|
5045
|
+
const clients = await runTmux(["list-clients", "-t", sess, "-F", "#{client_activity} #{client_name}"]);
|
|
4862
5046
|
const best = clients.stdout.split("\n").filter(Boolean)
|
|
4863
5047
|
.map((l) => { const i = l.indexOf(" "); return { act: Number(l.slice(0, i)), name: l.slice(i + 1) }; })
|
|
4864
5048
|
.sort((x, y) => y.act - x.act)[0]?.name;
|
|
@@ -4867,7 +5051,7 @@ export const startMirror = (deps) => {
|
|
|
4867
5051
|
return { ok: false, reason: `没有 attach 到 \`${sess}\` 的 tmux 客户端。先在终端执行: \`tmux attach -t ${sess}\`` };
|
|
4868
5052
|
}
|
|
4869
5053
|
// A pane target pulls session + window selection along with it.
|
|
4870
|
-
const r = await
|
|
5054
|
+
const r = await runTmux(["switch-client", "-c", best, "-t", pane]);
|
|
4871
5055
|
if (r.code !== 0)
|
|
4872
5056
|
return { ok: false, reason: `switch-client failed: ${r.stdout.slice(-200) || r.code}` };
|
|
4873
5057
|
log.info({ target, pane, client: best }, "mirror /reveal — tmux client switched");
|
|
@@ -4887,7 +5071,7 @@ export const startMirror = (deps) => {
|
|
|
4887
5071
|
const deadline = Date.now() + Math.max(0, opts.waitMs);
|
|
4888
5072
|
let v = await detectModalPicker(pane);
|
|
4889
5073
|
while (!v.modal && Date.now() < deadline) {
|
|
4890
|
-
await
|
|
5074
|
+
await sleep(POLL_MS);
|
|
4891
5075
|
v = await detectModalPicker(pane);
|
|
4892
5076
|
}
|
|
4893
5077
|
// 没等到: 这次 CC 没弹框(会话内已授权过 / 命中盲点判断失误)。无事可做。
|
|
@@ -4911,6 +5095,9 @@ export const startMirror = (deps) => {
|
|
|
4911
5095
|
getCwd,
|
|
4912
5096
|
setPendingCwd,
|
|
4913
5097
|
newSession,
|
|
5098
|
+
cloneSession,
|
|
5099
|
+
sessionInfo,
|
|
5100
|
+
setCharterProvider,
|
|
4914
5101
|
shutdown: () => {
|
|
4915
5102
|
clearInterval(paneDriftTimer);
|
|
4916
5103
|
clearInterval(keepaliveTimer);
|
|
@@ -4937,7 +5124,7 @@ export const startMirror = (deps) => {
|
|
|
4937
5124
|
a = await restoreFromStore(principal);
|
|
4938
5125
|
if (!a) {
|
|
4939
5126
|
try {
|
|
4940
|
-
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);
|
|
4941
5128
|
}
|
|
4942
5129
|
catch {
|
|
4943
5130
|
/* ignore */
|
|
@@ -4985,11 +5172,11 @@ export const startMirror = (deps) => {
|
|
|
4985
5172
|
log.info({ target: a.target, runningCwd: a.runningCwd, pendingCwd: pending }, "/clear upgraded to /new (cwd switch)");
|
|
4986
5173
|
// Prefer the tag suffix as tmux window name when present (matches
|
|
4987
5174
|
// /new #tag behavior), fall back to the full target for untagged.
|
|
4988
|
-
const tag =
|
|
5175
|
+
const tag = tagOfKey(a.target);
|
|
4989
5176
|
const r = await newSession(a.target, tag || a.target);
|
|
4990
5177
|
if (!r.ok) {
|
|
4991
5178
|
try {
|
|
4992
|
-
await client.replyStream(frame, streamId,
|
|
5179
|
+
await client.replyStream(frame, streamId, withTagHeader(a.target, `[mirror] 切换失败: ${r.reason ?? "unknown"}`), true);
|
|
4993
5180
|
}
|
|
4994
5181
|
catch { /* ignore */ }
|
|
4995
5182
|
}
|
|
@@ -5047,7 +5234,7 @@ export const startMirror = (deps) => {
|
|
|
5047
5234
|
if (s) {
|
|
5048
5235
|
a.liveStream = s;
|
|
5049
5236
|
try {
|
|
5050
|
-
await client.replyStream(frame, streamId,
|
|
5237
|
+
await client.replyStream(frame, streamId, withTagHeader(a.target, "…"), false);
|
|
5051
5238
|
}
|
|
5052
5239
|
catch (e) {
|
|
5053
5240
|
log.warn({ sessionId: a.sessionId, err: e.message }, "stream initial ack failed");
|
|
@@ -5092,7 +5279,7 @@ export const startMirror = (deps) => {
|
|
|
5092
5279
|
const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
5093
5280
|
// Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
|
|
5094
5281
|
// mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
|
|
5095
|
-
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) });
|
|
5096
5283
|
if (r.ok && r.tmuxPane && r.tmuxSession) {
|
|
5097
5284
|
a.tmuxPane = r.tmuxPane;
|
|
5098
5285
|
a.tmuxSession = r.tmuxSession;
|
|
@@ -5105,6 +5292,7 @@ export const startMirror = (deps) => {
|
|
|
5105
5292
|
tmuxSession: a.tmuxSession,
|
|
5106
5293
|
tmuxPane: a.tmuxPane,
|
|
5107
5294
|
cwd: a.runningCwd || undefined,
|
|
5295
|
+
model: a.model || undefined,
|
|
5108
5296
|
pendingCwd: a.pendingCwd || undefined,
|
|
5109
5297
|
});
|
|
5110
5298
|
log.info({ target: a.target, sessionId: sid, newPane: a.tmuxPane, newSession: a.tmuxSession }, "mirror: tmux respawned");
|
|
@@ -5161,7 +5349,7 @@ export const startMirror = (deps) => {
|
|
|
5161
5349
|
// /clear path has no live stream — surface failure as a one-shot
|
|
5162
5350
|
// terse reply ("clean" per project convention).
|
|
5163
5351
|
try {
|
|
5164
|
-
await client.replyStream(frame, streamId,
|
|
5352
|
+
await client.replyStream(frame, streamId, withTagHeader(a.target, "clean"), true);
|
|
5165
5353
|
}
|
|
5166
5354
|
catch { /* ignore */ }
|
|
5167
5355
|
}
|
|
@@ -5204,8 +5392,8 @@ export const startMirror = (deps) => {
|
|
|
5204
5392
|
// "Set model to …" as <local-command-stdout> — the tail mirrors that
|
|
5205
5393
|
// back to the chat as a ⚙️ bubble, which IS the readback.
|
|
5206
5394
|
if (isModelSwitch && a.tmuxPane) {
|
|
5207
|
-
await
|
|
5208
|
-
const e = await
|
|
5395
|
+
await sleep(1000);
|
|
5396
|
+
const e = await runTmux(["send-keys", "-t", a.tmuxPane, "Enter"]);
|
|
5209
5397
|
if (e.code !== 0)
|
|
5210
5398
|
log.warn({ pane: a.tmuxPane, reason: e.stdout.slice(-200) || e.code }, "/model confirm Enter failed — user can send /n manually");
|
|
5211
5399
|
}
|
|
@@ -5250,7 +5438,7 @@ export const installMirrorEventListener = (client, bridge, log) => {
|
|
|
5250
5438
|
const n = detail.markdown.length;
|
|
5251
5439
|
for (const [i, md] of detail.markdown.entries()) {
|
|
5252
5440
|
try {
|
|
5253
|
-
const content =
|
|
5441
|
+
const content = withTagHeader(detail.target, md, n > 1 ? `${i + 1}/${n}` : undefined);
|
|
5254
5442
|
await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
|
|
5255
5443
|
}
|
|
5256
5444
|
catch (e) {
|