wezard 1.4.2 → 2.0.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-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CLAUDE.md +17 -13
- package/README.md +130 -77
- package/cli/wezard.sh +0 -62
- package/config.example.jsonc +3 -9
- package/dist/cli/init.js +12 -21
- package/dist/cli/init.js.map +1 -1
- package/dist/daemon/approval.js +11 -7
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/cc-session.js +136 -0
- package/dist/daemon/cc-session.js.map +1 -0
- package/dist/daemon/chat-name.js +8 -18
- package/dist/daemon/chat-name.js.map +1 -1
- package/dist/daemon/detail.js +54 -13
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/graph.js +5 -7
- package/dist/daemon/graph.js.map +1 -1
- package/dist/daemon/inbound.js +188 -254
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +459 -258
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +2 -2
- package/dist/daemon/memory-steward.js +82 -0
- package/dist/daemon/memory-steward.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +370 -170
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/mirror-store.js.map +1 -1
- package/dist/daemon/model-select.js +74 -0
- package/dist/daemon/model-select.js.map +1 -0
- package/dist/daemon/peers.js +10 -16
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/session-name.js +4 -27
- package/dist/daemon/session-name.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +61 -16
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/task-gate.js +97 -0
- package/dist/daemon/task-gate.js.map +1 -0
- package/dist/daemon/task-registry.js +132 -0
- package/dist/daemon/task-registry.js.map +1 -0
- package/dist/daemon/tasks.js +126 -54
- package/dist/daemon/tasks.js.map +1 -1
- package/dist/daemon/wizard-memory.js +52 -0
- package/dist/daemon/wizard-memory.js.map +1 -0
- package/dist/daemon/wizard.js +107 -41
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +181 -209
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +187 -108
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +25 -34
- package/dist/shared/chat-render.js.map +1 -1
- package/dist/shared/chat-view.js +14 -2
- package/dist/shared/chat-view.js.map +1 -1
- package/dist/shared/cli-backends.js +2 -7
- package/dist/shared/cli-backends.js.map +1 -1
- package/dist/shared/config.js +26 -9
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/detail-render.js +12 -6
- package/dist/shared/detail-render.js.map +1 -1
- package/dist/shared/detail-store.js +1 -1
- package/dist/shared/detail-store.js.map +1 -1
- package/dist/shared/role-render.js +55 -0
- package/dist/shared/role-render.js.map +1 -0
- package/dist/shared/role-view.js +228 -0
- package/dist/shared/role-view.js.map +1 -0
- package/dist/shared/schedule-spec.js +92 -116
- package/dist/shared/schedule-spec.js.map +1 -1
- package/dist/shared/session-label.js +103 -72
- package/dist/shared/session-label.js.map +1 -1
- package/dist/shared/std.js +3 -0
- package/dist/shared/std.js.map +1 -1
- package/dist/shared/task-file.js +120 -0
- package/dist/shared/task-file.js.map +1 -0
- package/dist/shared/trigger.js +232 -0
- package/dist/shared/trigger.js.map +1 -0
- package/dist/shared/world.js +17 -5
- package/dist/shared/world.js.map +1 -1
- package/dist/svr/index.js +67 -2
- package/dist/svr/index.js.map +1 -1
- package/hooks/pre-tool-use.sh +4 -4
- package/package.json +1 -2
- package/web/chat.css +424 -212
- package/web/chat.js +953 -323
- package/commands/wrc.md +0 -7
- package/dist/daemon/cc-bridge.js +0 -183
- package/dist/daemon/cc-bridge.js.map +0 -1
- package/dist/daemon/sessions.js +0 -4
- package/dist/daemon/sessions.js.map +0 -1
|
@@ -20,20 +20,21 @@ import { augmentedPath } from "../shared/exec-path.js";
|
|
|
20
20
|
import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
|
|
21
21
|
import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
|
|
22
22
|
import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } from "../shared/modal-pane.js";
|
|
23
|
-
import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
|
|
23
|
+
import { bindCardChannel, hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
|
|
24
24
|
import { isAutoWindowActive } from "./session-cache.js";
|
|
25
25
|
import { noticeSuffixFor } from "./notices.js";
|
|
26
26
|
import { dangerOf } from "./danger.js";
|
|
27
27
|
import { runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
|
|
28
|
+
import { hasRegistry, markTranscript, probeOf, sessionOnPane, submittedSince } from "./cc-session.js";
|
|
28
29
|
import { wizardStore } from "./wizard.js";
|
|
29
30
|
import { startSubagentWatch } from "./subagent-tail.js";
|
|
30
31
|
import { recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
|
|
31
|
-
import { labelFor, tagOfKey, baseOfKey, keyOf, withTagHeader,
|
|
32
|
+
import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, linkedTagHead, linkTags, parseTagHeader, MAX_BODY_LINKS } from "../shared/session-label.js";
|
|
32
33
|
import { splitMarkdown } from "../shared/md-chunk.js";
|
|
33
34
|
import { randomTip } from "./tips.js";
|
|
34
|
-
import { chatBaseOf, chatNameOf, listChatNames,
|
|
35
|
+
import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
|
|
35
36
|
import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, summarizeTail, lastAssistantText, lastContextTokens, keepaliveStamps, tailTurns, renderDialog } from "./peers.js";
|
|
36
|
-
//
|
|
37
|
+
// PATH augmentation: launchd / systemd start the daemon
|
|
37
38
|
// with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
|
|
38
39
|
// Backend resolution is per-transcript, not global: `backendForPath` recovers
|
|
39
40
|
// which CLI wrote a jsonl from its projects root, so claude / claude-internal /
|
|
@@ -44,7 +45,7 @@ import { stripAnsi, compactPane, paneIsBusy, paneIsStalled, transcriptStalled, s
|
|
|
44
45
|
// jsonl line carries a `cwd` field; the encoded directory name is lossy (both
|
|
45
46
|
// `/` and `.` collapse to `-`) so it can't be reversed — reading the file is
|
|
46
47
|
// the only faithful path. Used as the middle tier in attach()'s cwd resolution
|
|
47
|
-
// so
|
|
48
|
+
// so sessions attached without an explicit cwd (switch / restore) reflect their real project in /pwd, /clear, /new,
|
|
48
49
|
// instead of falling back to the global cfg.wrc.cwd.
|
|
49
50
|
const readCwdFromJsonl = (path) => {
|
|
50
51
|
try {
|
|
@@ -678,9 +679,10 @@ const renderLine = (raw, deps) => {
|
|
|
678
679
|
};
|
|
679
680
|
// Block-wise packing (shared/md-chunk): never cuts mid-line, and never cuts a
|
|
680
681
|
// fenced block or table in a way that breaks rendering — see splitMarkdown.
|
|
681
|
-
// Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line
|
|
682
|
-
//
|
|
683
|
-
|
|
682
|
+
// Room reserved in every chunk for the `emoji \`#tag\` \`2/5\`` header line and
|
|
683
|
+
// the chat-detail links linkTags hangs on it and on up to MAX_BODY_LINKS body
|
|
684
|
+
// mentions — each `(url)` runs ~200-250 bytes.
|
|
685
|
+
const TAG_HEADER_BUDGET = 256 * (1 + MAX_BODY_LINKS);
|
|
684
686
|
export const startMirrorTail = (deps) => {
|
|
685
687
|
const { log } = deps;
|
|
686
688
|
// A single logical tail follows ONE sessionId whose transcript file may move
|
|
@@ -1171,12 +1173,80 @@ export const parseInputBox = (cap) => {
|
|
|
1171
1173
|
};
|
|
1172
1174
|
// 24 行足够容纳展开后的多行粘贴 (折叠态只占一行)。
|
|
1173
1175
|
const readInputBox = async (target) => parseInputBox(await capturePaneTail(target, 24));
|
|
1176
|
+
// ── 注入门控的会话证人 (cc-session) ─────────────────────────────────
|
|
1177
|
+
// 能从会话自己写的 json 读到的判据, 就不读 pane: 就绪看注册表的 status, 提交看
|
|
1178
|
+
// 注册表的 busy 翻转或 jsonl 里新落的那一行。输入框里「贴进去没有」是唯一只有
|
|
1179
|
+
// pane 知道的事 (未提交的输入不落任何盘), 那一段仍旧 capture。
|
|
1180
|
+
const SHELL_RE = /^-?(zsh|bash|sh|fish|dash|ksh|tcsh|csh)$/;
|
|
1181
|
+
/** pane 前台是个 shell = 里面的 AI 会话已经退出 (或从没起来)。只读 tmux 元数据, 不读屏。 */
|
|
1182
|
+
const paneRunsShell = async (target) => {
|
|
1183
|
+
const r = await runTmux(["display-message", "-p", "-t", target, "#{pane_current_command}"]);
|
|
1184
|
+
return r.ok && SHELL_RE.test(r.stdout.trim());
|
|
1185
|
+
};
|
|
1186
|
+
/** pane 活着且里面住着 AI 会话。注册表认得它就是; 不认得时只在前台确是 shell 才判否 ——
|
|
1187
|
+
* 注册表缺席 (codebuddy / 旧版 CC / 进程不在 tmux 里记的行) 不能被当成会话不在。 */
|
|
1188
|
+
const paneHostsSession = async (target, homeDir) => !hasRegistry(homeDir) || sessionOnPane(homeDir, target) !== undefined || !(await paneRunsShell(target));
|
|
1189
|
+
const SESSION_READY_TIMEOUT_MS = 15_000;
|
|
1190
|
+
/** 冷 pane 等注册表报 idle (会话建好、输入框等人); 热 pane 只核一次会话还在 —— 忙也照贴,
|
|
1191
|
+
* CC 会把它排进队列。等不到 idle 不算失败 (贴后校验会兜), 前台退回 shell 才算:
|
|
1192
|
+
* 往 shell 里贴 = 把用户的话当命令执行。 */
|
|
1193
|
+
const awaitSession = async (target, homeDir, fresh, log) => {
|
|
1194
|
+
if (!hasRegistry(homeDir))
|
|
1195
|
+
return { ok: true };
|
|
1196
|
+
const deadline = Date.now() + (fresh ? SESSION_READY_TIMEOUT_MS : 0);
|
|
1197
|
+
const poll = async () => {
|
|
1198
|
+
const s = sessionOnPane(homeDir, target);
|
|
1199
|
+
if ((s && (!fresh || s.status === "idle")) || Date.now() >= deadline)
|
|
1200
|
+
return s;
|
|
1201
|
+
await sleep(200);
|
|
1202
|
+
return poll();
|
|
1203
|
+
};
|
|
1204
|
+
const live = await poll();
|
|
1205
|
+
if (live) {
|
|
1206
|
+
if (fresh && live.status !== "idle")
|
|
1207
|
+
log.warn({ target, status: live.status }, "mirror inject: 会话未报 idle, 按贴后校验兜底继续");
|
|
1208
|
+
return { ok: true, live };
|
|
1209
|
+
}
|
|
1210
|
+
if (await paneRunsShell(target)) {
|
|
1211
|
+
log.warn({ target }, "mirror inject: pane 前台是 shell, AI 会话不在, 拒绝注入");
|
|
1212
|
+
return { ok: false, reason: "目标 pane 里的 AI 会话已退出 (前台是 shell), 消息未送达。重发即会在新 pane 里恢复该会话。" };
|
|
1213
|
+
}
|
|
1214
|
+
return { ok: true };
|
|
1215
|
+
};
|
|
1216
|
+
const NO_WITNESS = { status: "", submitted: () => false };
|
|
1217
|
+
/** 贴之前给会话可能落盘的 jsonl 打书签: 绑定的那份, 加上注册表报的当前 sid 那份
|
|
1218
|
+
* (resume / fork 会换 sid)。提交后 sid 若又变了, 新文件从 0 读 —— 时间门挡掉 fork 里的旧话。 */
|
|
1219
|
+
const witnessFor = (target, homeDir, jsonlPath, live) => {
|
|
1220
|
+
if (!live)
|
|
1221
|
+
return NO_WITNESS;
|
|
1222
|
+
const dir = dirname(jsonlPath);
|
|
1223
|
+
const marks = new Map([jsonlPath, join(dir, `${live.sessionId}.jsonl`)].map((p) => [p, markTranscript(p)]));
|
|
1224
|
+
return {
|
|
1225
|
+
status: live.status,
|
|
1226
|
+
submitted: (probe, since, enterAt) => {
|
|
1227
|
+
const now = sessionOnPane(homeDir, target);
|
|
1228
|
+
if (now && live.status !== "busy" && now.status === "busy" && now.statusUpdatedAt >= enterAt)
|
|
1229
|
+
return true;
|
|
1230
|
+
const p = now && join(dir, `${now.sessionId}.jsonl`);
|
|
1231
|
+
if (p && !marks.has(p))
|
|
1232
|
+
marks.set(p, { path: p, offset: 0 });
|
|
1233
|
+
return submittedSince([...marks.values()], probe, since);
|
|
1234
|
+
},
|
|
1235
|
+
};
|
|
1236
|
+
};
|
|
1174
1237
|
// Self-verifying inject. The cold-spawn race we guard against: paste lands
|
|
1175
1238
|
// but the trailing Enter is eaten while the TUI is still initializing, so
|
|
1176
1239
|
// the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
|
|
1177
1240
|
// 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
|
|
1178
|
-
const injectViaTmux = async (target, text, images, log, freshSpawn,
|
|
1241
|
+
const injectViaTmux = async (target, text, images, log, freshSpawn, backend, jsonlPath, bypassModalGuard = false) => {
|
|
1242
|
+
const backendName = backend.name;
|
|
1179
1243
|
log.info({ target, len: text.length, images: images.length, freshSpawn, backendName }, "mirror inject (tmux)");
|
|
1244
|
+
// 就绪门: pane 里得真有一个进入了交互态的 AI 会话, 才轮得到贴。先于 modal guard ——
|
|
1245
|
+
// 这一步只读注册表, 会话都不在时也就不必去 capture pane 找对话框了。
|
|
1246
|
+
const gate = await awaitSession(target, backend.homeDir, freshSpawn, log);
|
|
1247
|
+
if (!gate.ok)
|
|
1248
|
+
return gate;
|
|
1249
|
+
const witness = witnessFor(target, backend.homeDir, jsonlPath, gate.live);
|
|
1180
1250
|
// Never type into a modal picker (see detectModalPicker). Checked before the
|
|
1181
1251
|
// image pump too — a C-v into a picker is just as destructive as a paste.
|
|
1182
1252
|
// 必须在按后端分流之前: 每个后端都有自己的原生确认框, 往任何一个里打字都会
|
|
@@ -1209,7 +1279,7 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName,
|
|
|
1209
1279
|
if (backendName === "codebuddy") {
|
|
1210
1280
|
const refs = images.map((p) => `@${p}`);
|
|
1211
1281
|
const textWithRefs = refs.length ? (text ? `${refs.join("\n")}\n${text}` : refs.join("\n")) : text;
|
|
1212
|
-
return injectViaTmuxText(target, textWithRefs, log, freshSpawn);
|
|
1282
|
+
return injectViaTmuxText(target, textWithRefs, log, freshSpawn, witness);
|
|
1213
1283
|
}
|
|
1214
1284
|
// Pump images first via clipboard+C-v so each one is attached as a separate
|
|
1215
1285
|
// image content block. Each C-v needs a brief settle for Claude Code's TUI
|
|
@@ -1235,18 +1305,22 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName,
|
|
|
1235
1305
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1236
1306
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
|
|
1237
1307
|
}
|
|
1238
|
-
return injectViaTmuxText(target, text, log, freshSpawn);
|
|
1308
|
+
return injectViaTmuxText(target, text, log, freshSpawn, witness);
|
|
1239
1309
|
};
|
|
1240
1310
|
// 文本 paste + Enter 提交 + 自校验。从 injectViaTmux 抽出来,让 codebuddy
|
|
1241
1311
|
// 后端的 @<path> 回退路径也能复用同样的 paste-verify 逻辑。
|
|
1242
1312
|
//
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
//
|
|
1249
|
-
|
|
1313
|
+
// 贴 —— 只有 pane 知道 (未提交的输入不落任何盘), 以**输入框整块**为准 (parseInputBox),
|
|
1314
|
+
// 指纹只是认不出布局时的退路:
|
|
1315
|
+
// 1. 框里已经躺着这段文本 (上一次注入回车被吞 / 被 watchdog 掐断) → 不再贴第二份, 直接提交。
|
|
1316
|
+
// 2. 贴完轮询「框变了」→ 粘贴已被 TUI 消化 (折叠成占位符也算)。这是个**正**信号,
|
|
1317
|
+
// 只有确凿看见框仍是贴之前的样子、且会话也没收到这段话, 才重贴 —— 存疑一律不重贴,
|
|
1318
|
+
// 多贴一次的代价 (两份提示叠进同一个输入框) 远高于少贴一次。
|
|
1319
|
+
// 交 —— 先问会话证人 (注册表 busy 翻转 / jsonl 新落的提交行), 确认了就完事, 不碰 pane;
|
|
1320
|
+
// 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
|
|
1321
|
+
// 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
|
|
1322
|
+
// 每次补之前再问一遍证人, 已提交就不补。
|
|
1323
|
+
const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
|
|
1250
1324
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1251
1325
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
1252
1326
|
// end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
|
|
@@ -1256,17 +1330,30 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1256
1330
|
const POST_PASTE_SETTLE_FALLBACK_MS = freshSpawn ? 2500 : 700;
|
|
1257
1331
|
const CLEARED_TIMEOUT_MS = freshSpawn ? 4000 : 1500;
|
|
1258
1332
|
const RETRY_SETTLE_MS = freshSpawn ? 1500 : 800;
|
|
1333
|
+
// 回车后先只问证人这么久: CC 提交即落盘, 通常 <200ms。证人注定不作声的场合
|
|
1334
|
+
// (会话正忙 → 只是排队; slash 命令 → 不一定落 user 行) 不空等, 直接看框。
|
|
1335
|
+
const witnessQuiet = witness.status === "" || witness.status === "busy" || text.trimStart().startsWith("/");
|
|
1336
|
+
const WITNESS_ONLY_MS = witnessQuiet ? 0 : freshSpawn ? 2500 : 1000;
|
|
1259
1337
|
const { headFp, tailFp } = fingerprints(text);
|
|
1338
|
+
const probe = probeOf(text);
|
|
1260
1339
|
const POLL_MS = 100;
|
|
1261
1340
|
const stripWs = (s) => s.replace(/\s+/gu, "");
|
|
1341
|
+
// 时间门往前放 1s: jsonl 的 timestamp 与本机时钟同源, 只差写盘的毫秒。
|
|
1342
|
+
const since = Date.now() - 1000;
|
|
1343
|
+
let enterAt = Number.POSITIVE_INFINITY;
|
|
1344
|
+
const submitted = () => witness.submitted(probe, since, enterAt);
|
|
1262
1345
|
// 贴之前框里是什么: 空框、幽灵提示 (`Try "how do I log an error?"`)、甚至用户
|
|
1263
1346
|
// 自己敲了一半的字 —— 一律作为基线, 免得去维护一张「哪些文案算空」的清单。
|
|
1264
1347
|
const preBox = await readInputBox(target);
|
|
1265
1348
|
// 基线读不出来 (框被模态顶掉 / 正在重绘) 就整条退回指纹法: 没有基线,「框变了」
|
|
1266
1349
|
// 无从谈起, 而把幽灵提示 (`Try "…"`) 误当成「贴进去了」会让这条消息静静消失。
|
|
1267
1350
|
const boxVerify = preBox.known;
|
|
1351
|
+
// 去重: 框里已经是这段文本 —— 上一次注入贴上了却没交出去。再贴就是两份。
|
|
1352
|
+
const alreadyTyped = boxVerify && Boolean(probe) && stripWs(preBox.body).includes(probe);
|
|
1353
|
+
// 提交后框该回到的样子: 正常是贴之前的基线; 文本本就在框里时基线就是它, 只能以「空」为准。
|
|
1354
|
+
const baseBody = alreadyTyped ? "" : preBox.body;
|
|
1268
1355
|
/** 框里还是贴之前那样 (或彻底空了) = 我们的文本不在框里。 */
|
|
1269
|
-
const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body ===
|
|
1356
|
+
const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === baseBody);
|
|
1270
1357
|
const loadAndPaste = async () => {
|
|
1271
1358
|
// stdin variant of the shared exec path — it carries the same hard timeout,
|
|
1272
1359
|
// which a hand-rolled spawn here did not.
|
|
@@ -1278,9 +1365,6 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1278
1365
|
return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
|
|
1279
1366
|
return { ok: true };
|
|
1280
1367
|
};
|
|
1281
|
-
let r = await loadAndPaste();
|
|
1282
|
-
if (!r.ok)
|
|
1283
|
-
return r;
|
|
1284
1368
|
// 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
|
|
1285
1369
|
// 头部可能在框顶, 所以窗口要宽)。
|
|
1286
1370
|
const sawLanded = async (timeoutMs) => {
|
|
@@ -1300,10 +1384,24 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1300
1384
|
const pane = await capturePaneTail(target, 5);
|
|
1301
1385
|
return Boolean(tailFp) && stripWs(pane).includes(tailFp);
|
|
1302
1386
|
};
|
|
1303
|
-
let pasteSeen =
|
|
1387
|
+
let pasteSeen = alreadyTyped;
|
|
1388
|
+
if (alreadyTyped) {
|
|
1389
|
+
log.warn({ target, headFp }, "mirror inject: 输入框里已有这段文本 (上次未提交) — 不重贴, 直接提交");
|
|
1390
|
+
}
|
|
1391
|
+
else {
|
|
1392
|
+
const r = await loadAndPaste();
|
|
1393
|
+
if (!r.ok)
|
|
1394
|
+
return r;
|
|
1395
|
+
pasteSeen = await sawLanded(PASTE_VERIFY_MS);
|
|
1396
|
+
}
|
|
1304
1397
|
if (!pasteSeen) {
|
|
1305
1398
|
const box = await readInputBox(target);
|
|
1306
1399
|
const empty = boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
|
|
1400
|
+
if (submitted()) {
|
|
1401
|
+
// 框看着空, 是因为粘贴连同某次回车一起被收下了 —— 再贴就是第二份。
|
|
1402
|
+
log.info({ target }, "mirror inject: 会话已收到这段文本, 不重贴");
|
|
1403
|
+
return { ok: true };
|
|
1404
|
+
}
|
|
1307
1405
|
if (!empty) {
|
|
1308
1406
|
log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 粘贴未验证但输入框非空 — 首次粘贴已落地, 不重贴");
|
|
1309
1407
|
pasteSeen = true;
|
|
@@ -1311,7 +1409,9 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1311
1409
|
else {
|
|
1312
1410
|
log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 输入框确认为空, 重贴一次");
|
|
1313
1411
|
await sleep(RETRY_SETTLE_MS);
|
|
1314
|
-
|
|
1412
|
+
if (submitted())
|
|
1413
|
+
return { ok: true };
|
|
1414
|
+
const r = await loadAndPaste();
|
|
1315
1415
|
if (!r.ok)
|
|
1316
1416
|
return r;
|
|
1317
1417
|
pasteSeen = await sawLanded(PASTE_VERIFY_MS);
|
|
@@ -1319,7 +1419,8 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1319
1419
|
}
|
|
1320
1420
|
// Bracketed-paste end + TUI catch-up. Warm pane: 400ms is invisible.
|
|
1321
1421
|
// Fresh respawn: 1500ms+ — claude --resume is still loading the transcript.
|
|
1322
|
-
|
|
1422
|
+
if (!alreadyTyped)
|
|
1423
|
+
await sleep(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
|
|
1323
1424
|
const sendEnter = async () => {
|
|
1324
1425
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1325
1426
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
|
|
@@ -1328,11 +1429,15 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1328
1429
|
const box = await readInputBox(target);
|
|
1329
1430
|
return boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
|
|
1330
1431
|
};
|
|
1331
|
-
|
|
1432
|
+
/** 证人先答; 它的窗口过了还不作声, 才每轮加看一眼框。 */
|
|
1433
|
+
const waitForSubmit = async (timeoutMs) => {
|
|
1332
1434
|
const t0 = Date.now();
|
|
1333
|
-
|
|
1435
|
+
const budget = Math.max(timeoutMs, WITNESS_ONLY_MS + 500);
|
|
1436
|
+
while (Date.now() - t0 < budget) {
|
|
1334
1437
|
await sleep(POLL_MS);
|
|
1335
|
-
if (
|
|
1438
|
+
if (submitted())
|
|
1439
|
+
return true;
|
|
1440
|
+
if (Date.now() - t0 >= WITNESS_ONLY_MS && (await cleared()))
|
|
1336
1441
|
return true;
|
|
1337
1442
|
}
|
|
1338
1443
|
return false;
|
|
@@ -1350,19 +1455,25 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1350
1455
|
const attempts = boxTrusted ? 3 : 2;
|
|
1351
1456
|
for (let i = 0; i < attempts; i++) {
|
|
1352
1457
|
if (i > 0) {
|
|
1353
|
-
log.warn({ target, tailFp, attempt: i + 1 }, "mirror inject: 回车后输入框未清空, 再补一次");
|
|
1354
1458
|
await sleep(RETRY_SETTLE_MS);
|
|
1459
|
+
if (submitted())
|
|
1460
|
+
return { ok: true }; // 迟到的确认 —— 补的这一下会落在空框上, 省了
|
|
1461
|
+
log.warn({ target, tailFp, attempt: i + 1 }, "mirror inject: 回车后输入框未清空, 再补一次");
|
|
1355
1462
|
}
|
|
1463
|
+
if (i === 0)
|
|
1464
|
+
enterAt = Date.now();
|
|
1356
1465
|
const e = await sendEnter();
|
|
1357
1466
|
if (!e.ok)
|
|
1358
1467
|
return e;
|
|
1359
|
-
if (await
|
|
1468
|
+
if (await waitForSubmit(CLEARED_TIMEOUT_MS))
|
|
1360
1469
|
return { ok: true };
|
|
1361
1470
|
}
|
|
1471
|
+
if (submitted())
|
|
1472
|
+
return { ok: true };
|
|
1362
1473
|
// 补到头还没清空。通常是渲染滞后 (回车其实落了), 但也可能是目标会话正忙 /
|
|
1363
1474
|
// 上下文满了不消费输入 —— 那样用户这条消息会悄无声息地消失。不报硬失败 (用户
|
|
1364
1475
|
// 多半已经被服务到了), 标 uncertain 让调用方按需提示。
|
|
1365
|
-
log.warn({ target, tailFp, freshSpawn, boxTrusted }, "mirror inject: clear not observed, trusting submit");
|
|
1476
|
+
log.warn({ target, tailFp, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: clear not observed, trusting submit");
|
|
1366
1477
|
return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
|
|
1367
1478
|
};
|
|
1368
1479
|
const injectViaSpawn = (args) => {
|
|
@@ -1417,8 +1528,7 @@ const inject = (args) => {
|
|
|
1417
1528
|
const target = (args.tmuxTarget ?? "").trim();
|
|
1418
1529
|
if (!target)
|
|
1419
1530
|
return injectViaSpawn(args);
|
|
1420
|
-
|
|
1421
|
-
return injectViaTmux(target, args.text, args.images ?? [], args.log, args.freshSpawn ?? false, backendName, args.bypassModalGuard ?? false);
|
|
1531
|
+
return injectViaTmux(target, args.text, args.images ?? [], args.log, args.freshSpawn ?? false, backendForPath(args.jsonlPath), args.jsonlPath, args.bypassModalGuard ?? false);
|
|
1422
1532
|
};
|
|
1423
1533
|
const queues = new Map();
|
|
1424
1534
|
/** Ceiling for one whole inject job (respawn + paste + submit verification).
|
|
@@ -1581,8 +1691,8 @@ export const startMirror = (deps) => {
|
|
|
1581
1691
|
const detailCardFor = (s, target) => {
|
|
1582
1692
|
if (s.tools.length === 0)
|
|
1583
1693
|
return undefined;
|
|
1584
|
-
const
|
|
1585
|
-
const titlePrefix =
|
|
1694
|
+
const name = displayName(target);
|
|
1695
|
+
const titlePrefix = name ? `${labelFor(name)} .${name} · ` : "";
|
|
1586
1696
|
return {
|
|
1587
1697
|
card_type: "button_interaction",
|
|
1588
1698
|
main_title: { title: `${titlePrefix}本轮工具调用` },
|
|
@@ -1598,7 +1708,7 @@ export const startMirror = (deps) => {
|
|
|
1598
1708
|
const content = s.acc;
|
|
1599
1709
|
s.lastSent = content;
|
|
1600
1710
|
try {
|
|
1601
|
-
await client.replyStream(s.frame, s.streamId,
|
|
1711
|
+
await client.replyStream(s.frame, s.streamId, withLinkedTagHeader(s.target, content || " ", chatTurnUrl(s.target, s.turnId)), false);
|
|
1602
1712
|
log.debug({ turnId: s.turnId, len: content.length }, "stream flush ok");
|
|
1603
1713
|
}
|
|
1604
1714
|
catch (e) {
|
|
@@ -1679,32 +1789,26 @@ export const startMirror = (deps) => {
|
|
|
1679
1789
|
};
|
|
1680
1790
|
// Standalone fallback (no live stream / stream dead). Per-attachment FIFO so
|
|
1681
1791
|
// pushes from a single mirror stay ordered; different mirrors run in parallel.
|
|
1682
|
-
//
|
|
1683
|
-
//
|
|
1684
|
-
const
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
const tag = tagOfKey(target);
|
|
1689
|
-
return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
|
|
1690
|
-
};
|
|
1691
|
-
const withLinkedTag = (a, content, seq, turnId) => {
|
|
1692
|
-
const prefix = linkedTagPrefix(a.target, turnId ?? a.briefTurnId);
|
|
1693
|
-
if (prefix) {
|
|
1694
|
-
const seqBit = seq ? ` ${seq}` : "";
|
|
1695
|
-
return `${prefix}${seqBit}${headSep(content)}${content}`;
|
|
1696
|
-
}
|
|
1697
|
-
return withTagHeader(a.target, content, seq);
|
|
1698
|
-
};
|
|
1792
|
+
// 头挂 chat 详情页的票据: 本轮那条 turn。没有活 turn 可挂 → undefined, 头退回裸
|
|
1793
|
+
// `emoji #tag` (withLinkedTagHeader 自己兜)。
|
|
1794
|
+
const chatTurnUrl = (target, turnId) => turnId
|
|
1795
|
+
? buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target))
|
|
1796
|
+
: undefined;
|
|
1797
|
+
const withLinkedTag = (a, content, seq, turnId) => withLinkedTagHeader(a.target, content, chatTurnUrl(a.target, turnId ?? a.briefTurnId), seq);
|
|
1699
1798
|
// 空正文一票否决 (近源拦截): 剥掉可能存在的路由头 (`🦊 #tag` / `[🧙](url)`)
|
|
1700
1799
|
// 后没有可见内容就不发。中央 chat-gate (last-response 的 SDK 包装) 是最后一道
|
|
1701
1800
|
// 防线; 这里拦在源头 —— 空内容不进 standalonePending FIFO、不占防抖 buf、
|
|
1702
1801
|
// 不重置计时器, 免得"空 part 入队 → flush 时 join 出空串"的死角。
|
|
1703
1802
|
const hasVisibleBody = (content) => parseTagHeader(content).body.length > 0;
|
|
1803
|
+
// 推到本轮的频道 (人从别的群叫它 → 回那个群)。私聊轮的正文 brief 下在上游
|
|
1804
|
+
// turnSilent 已被拦下; 非 brief 的逐条推送在这里兜一道 —— 只放 `[mirror]` /
|
|
1805
|
+
// `[wezard]` 系统提示 (它们回 home)。
|
|
1704
1806
|
const sendStandalone = (a, content) => {
|
|
1705
1807
|
if (!hasVisibleBody(content))
|
|
1706
1808
|
return;
|
|
1707
|
-
|
|
1809
|
+
if (isPrivate(a) && !/^\[(mirror|wezard)\]/.test(content.trim()))
|
|
1810
|
+
return;
|
|
1811
|
+
const chatId = replyChatId(a);
|
|
1708
1812
|
const pieces = splitMarkdown(content, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
|
|
1709
1813
|
const chunks = pieces.map((p, i) => withLinkedTag(a, p, pieces.length > 1 ? `${i + 1}/${pieces.length}` : undefined));
|
|
1710
1814
|
a.standalonePending = a.standalonePending
|
|
@@ -1724,7 +1828,7 @@ export const startMirror = (deps) => {
|
|
|
1724
1828
|
const sendRaw = (a, content) => {
|
|
1725
1829
|
if (!hasVisibleBody(content))
|
|
1726
1830
|
return;
|
|
1727
|
-
const chatId =
|
|
1831
|
+
const chatId = replyChatId(a);
|
|
1728
1832
|
a.standalonePending = a.standalonePending
|
|
1729
1833
|
.then(async () => {
|
|
1730
1834
|
try {
|
|
@@ -2045,18 +2149,15 @@ export const startMirror = (deps) => {
|
|
|
2045
2149
|
// 链接落到 chat 视图: 默认选中本 turn 所属的 #tag, 贴底显示整条会话。turnId 依旧
|
|
2046
2150
|
// 是凭据 (不可枚举), 只是页面从"一个 turn"扩成"这个 chat 的全部会话"。
|
|
2047
2151
|
// target 作为 ww_uniq 传下去, 让同 chat 的所有 turn 详情都复用一个 WeCom 窗口。
|
|
2048
|
-
const briefDetailLink = (turnId, target) =>
|
|
2049
|
-
const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
|
|
2050
|
-
const tag = tagOfKey(target);
|
|
2051
|
-
return tag ? `[${labelFor(tag)} #${tag}](${url})` : `[🧙](${url})`;
|
|
2052
|
-
};
|
|
2152
|
+
const briefDetailLink = (turnId, target) => linkedTagHead(target, buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target)));
|
|
2053
2153
|
// ── 出处门 (chatOriginOnly) ───────────────────────────────────────────
|
|
2054
2154
|
// 人在 CLI 里手敲的一轮, 镜像只发生在两处: 他眼前的终端, 和 chat 详情页 (turn
|
|
2055
2155
|
// store 全量记录 + SSE 实时刷新)。聊天里不再下发它的气泡 —— 那份内容他本来就
|
|
2056
2156
|
// 在看。群里保留的是需要在群里发生的事: WeCom 发起的轮次、审批/提问卡、
|
|
2057
2157
|
// `[mirror]` 系统提示 —— 它们都不走这道门。
|
|
2058
2158
|
// 只在 brief 下生效: 抑制的前提是详情页兜得住内容, 而 turn store 只有 brief 在写。
|
|
2059
|
-
|
|
2159
|
+
// 私聊轮 (wizard 之间, 非 public) 同理: 它的回复属于那场私聊, 只进双方的 rolepage。
|
|
2160
|
+
const turnSilent = (a) => isPrivate(a) || (cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false);
|
|
2060
2161
|
// 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
|
|
2061
2162
|
// raw=true skips withTagHeader (used when content already contains the linked tag header).
|
|
2062
2163
|
// WeCom 客户端收到 finish=true 后仍有打字机动画要播放, 如果紧接着就下发
|
|
@@ -2124,6 +2225,28 @@ export const startMirror = (deps) => {
|
|
|
2124
2225
|
a.pendingFrom = undefined;
|
|
2125
2226
|
return p && Date.now() - p.at <= ORIGIN_TTL_MS ? p.from : undefined;
|
|
2126
2227
|
};
|
|
2228
|
+
/** 频道印章: 有新印章就换频道; 没有则 `reset` (CLI 手敲 / 人从聊天发起) 回到
|
|
2229
|
+
* home, 其余续写 (收口后的补写) 沿用上一轮。 */
|
|
2230
|
+
const consumeChannel = (a, reset) => {
|
|
2231
|
+
const p = a.pendingChannel;
|
|
2232
|
+
a.pendingChannel = undefined;
|
|
2233
|
+
if (p && Date.now() - p.at <= ORIGIN_TTL_MS) {
|
|
2234
|
+
a.channel = p.channel === baseOfKey(a.target) ? undefined : p.channel;
|
|
2235
|
+
a.speaker = p.speaker;
|
|
2236
|
+
}
|
|
2237
|
+
else if (reset) {
|
|
2238
|
+
a.channel = undefined;
|
|
2239
|
+
a.speaker = undefined;
|
|
2240
|
+
}
|
|
2241
|
+
};
|
|
2242
|
+
/** 本轮落 turn 记录的频道字段。 */
|
|
2243
|
+
const channelFields = (a) => ({ channel: a.channel ?? baseOfKey(a.target), speaker: a.speaker });
|
|
2244
|
+
/** 私聊轮: 回复只落 turn 记录。 */
|
|
2245
|
+
const isPrivate = (a) => a.channel === "";
|
|
2246
|
+
/** 本轮回复该推去的 chatId。 */
|
|
2247
|
+
const replyChatId = (a) => stripPrincipalPrefix(a.channel || a.target);
|
|
2248
|
+
// 审批/提问卡同样回本轮的频道; 私聊轮 ("") 的卡回 home —— 要点它的人总得在某个群里。
|
|
2249
|
+
bindCardChannel((key) => byTarget.get(key)?.channel || "");
|
|
2127
2250
|
// 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
|
|
2128
2251
|
// 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
|
|
2129
2252
|
const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
|
|
@@ -2132,7 +2255,8 @@ export const startMirror = (deps) => {
|
|
|
2132
2255
|
a.queryEpoch = (a.queryEpoch ?? 0) + 1; // WeCom 侧的新一轮同样是 query 边界
|
|
2133
2256
|
a.turnFromChat = true; // 出处确凿: 这一轮有 frame, 群里就是它的主场
|
|
2134
2257
|
a.pendingFromCli = false; // 人改从聊天里说话了, 之前那条 CLI 输入不再是出处
|
|
2135
|
-
|
|
2258
|
+
consumeChannel(a, true);
|
|
2259
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, origin: consumeOrigin(a), from: consumeFrom(a), ...channelFields(a) });
|
|
2136
2260
|
// hardTimer 兜底: turn 若无终句 / turn_end 收口 (卡死/漏收), 到点仍收气泡。
|
|
2137
2261
|
const bubble = { frame, streamId, hardTimer: undefined, done: false };
|
|
2138
2262
|
const q = { turnId, bubble, isSlash };
|
|
@@ -2178,7 +2302,8 @@ export const startMirror = (deps) => {
|
|
|
2178
2302
|
const fromCli = a.pendingFromCli === true;
|
|
2179
2303
|
a.pendingFromCli = false;
|
|
2180
2304
|
a.turnFromChat = fromCli ? false : a.turnFromChat ?? true;
|
|
2181
|
-
|
|
2305
|
+
consumeChannel(a, fromCli);
|
|
2306
|
+
recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || undefined, origin: consumeOrigin(a), from: consumeFrom(a), ...channelFields(a) });
|
|
2182
2307
|
a.briefTurnId = turnId;
|
|
2183
2308
|
a.briefBubble = undefined;
|
|
2184
2309
|
a.briefIsSlash = false;
|
|
@@ -2187,7 +2312,7 @@ export const startMirror = (deps) => {
|
|
|
2187
2312
|
a.briefLastText = undefined;
|
|
2188
2313
|
clearCot(a);
|
|
2189
2314
|
// 详情链接不在这里单独发 — 本轮的每条 standalone 都由 withLinkedTag 自带
|
|
2190
|
-
// (
|
|
2315
|
+
// (chatTurnUrl 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
|
|
2191
2316
|
// 一条都不发: 群里只该出现有正文的消息, 纯入口提示是噪音。
|
|
2192
2317
|
log.info({ sessionId: a.sessionId, turnId, fromCli, silent: turnSilent(a) }, "brief: turn started (CLI-side, no bubble)");
|
|
2193
2318
|
};
|
|
@@ -2232,7 +2357,7 @@ export const startMirror = (deps) => {
|
|
|
2232
2357
|
a.lastConcludedEpoch = a.queryEpoch ?? 0;
|
|
2233
2358
|
a.lastConcludedAt = Date.now();
|
|
2234
2359
|
if (a.briefBubble && !a.briefBubble.done) {
|
|
2235
|
-
void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${body}`, true);
|
|
2360
|
+
void finishBriefBubble(a, `${briefDetailLink(turnId, a.target)} ${linkTags(a.target, body)}`, true);
|
|
2236
2361
|
}
|
|
2237
2362
|
else if (turnSilent(a)) {
|
|
2238
2363
|
// CLI 手敲的一轮: 终稿只留在 turn store, 详情页照常实时可见。
|
|
@@ -2838,6 +2963,7 @@ export const startMirror = (deps) => {
|
|
|
2838
2963
|
&& backendForPath(a.jsonlPath).name === "codebuddy"
|
|
2839
2964
|
&& !hasMirrorPlan(a.sessionId)) {
|
|
2840
2965
|
void runMirrorPlanFlow({
|
|
2966
|
+
cfg,
|
|
2841
2967
|
log: log.child({ sessionId: a.sessionId }),
|
|
2842
2968
|
client,
|
|
2843
2969
|
sessionId: a.sessionId,
|
|
@@ -2864,6 +2990,7 @@ export const startMirror = (deps) => {
|
|
|
2864
2990
|
&& backendForPath(a.jsonlPath).name === "codebuddy"
|
|
2865
2991
|
&& !hasMirrorAskq(a.sessionId)) {
|
|
2866
2992
|
void runMirrorAskqFlow({
|
|
2993
|
+
cfg,
|
|
2867
2994
|
log: log.child({ sessionId: a.sessionId }),
|
|
2868
2995
|
client,
|
|
2869
2996
|
sessionId: a.sessionId,
|
|
@@ -3076,7 +3203,7 @@ export const startMirror = (deps) => {
|
|
|
3076
3203
|
const detailUrlFor = (id, principal) => cfg.daemon.detailLinksInMirror && id
|
|
3077
3204
|
? buildDetailUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, id, principal ? stripPrincipalPrefix(principal) : undefined)
|
|
3078
3205
|
: "";
|
|
3079
|
-
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd }) => {
|
|
3206
|
+
const attach = ({ sessionId, jsonlPath, target: targetOverride, tmuxPane, tmuxSession, cwd, model, pendingCwd, keepaliveDisabled }) => {
|
|
3080
3207
|
const target = resolveTarget(targetOverride);
|
|
3081
3208
|
if (!target)
|
|
3082
3209
|
return { ok: false, reason: "no target chat (set wrc.mirror.pushChat or defaultChat, or pass target)" };
|
|
@@ -3085,7 +3212,7 @@ export const startMirror = (deps) => {
|
|
|
3085
3212
|
// missing file (existsSync gate at start, try/catch in drain) and the 1s
|
|
3086
3213
|
// poll picks it up the moment claude writes the first line.
|
|
3087
3214
|
// Replace any existing attach with the same sessionId or same target. The
|
|
3088
|
-
// sessionId clash is the "
|
|
3215
|
+
// sessionId clash is the "same session attached again" case; the target
|
|
3089
3216
|
// clash is "different window steals my WeCom chat" — both end the previous.
|
|
3090
3217
|
// Guard: if a DIFFERENT principal already owns this sessionId, refuse rather
|
|
3091
3218
|
// than detaching it — prevents cross-wire when concurrent restoreFromStore
|
|
@@ -3106,6 +3233,9 @@ export const startMirror = (deps) => {
|
|
|
3106
3233
|
// model it asked for (possibly "" = CLI default) and says so; a re-attach
|
|
3107
3234
|
// onto an already-running pane doesn't, so it keeps what was recorded.
|
|
3108
3235
|
const carryModel = model !== undefined ? model.trim() : (prevByTarget?.model ?? deps.store.get(target)?.model ?? "");
|
|
3236
|
+
// Same carry rule again: an explicit spawn-time choice wins, otherwise this
|
|
3237
|
+
// wizard's existing policy (in-memory, or on disk across a reload) sticks.
|
|
3238
|
+
const carryKeepaliveDisabled = keepaliveDisabled !== undefined ? keepaliveDisabled : (prevByTarget?.keepaliveDisabled ?? deps.store.get(target)?.keepaliveDisabled ?? false);
|
|
3109
3239
|
if (prevByTarget)
|
|
3110
3240
|
detach(prevByTarget, "target reassigned");
|
|
3111
3241
|
// Build the attachment first so the tail's onItem closure can capture it.
|
|
@@ -3116,11 +3246,12 @@ export const startMirror = (deps) => {
|
|
|
3116
3246
|
tmuxPane: (tmuxPane ?? "").trim(),
|
|
3117
3247
|
tmuxSession: (tmuxSession ?? "").trim(),
|
|
3118
3248
|
// Explicit cwd (spawn path) wins; otherwise derive from jsonl head so
|
|
3119
|
-
//
|
|
3249
|
+
// cwd-less attaches inherit the bound session's real project dir instead of
|
|
3120
3250
|
// collapsing to cfg.wrc.cwd (which would mislabel /pwd, /clear, /new).
|
|
3121
3251
|
runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
|
|
3122
3252
|
pendingCwd: carryPending,
|
|
3123
3253
|
model: carryModel,
|
|
3254
|
+
keepaliveDisabled: carryKeepaliveDisabled,
|
|
3124
3255
|
tail: { stop: () => undefined, drain: () => undefined, livePath: () => undefined }, // placeholder; replaced below
|
|
3125
3256
|
standalonePending: Promise.resolve(),
|
|
3126
3257
|
recentToolSigs: new Map(),
|
|
@@ -3159,8 +3290,10 @@ export const startMirror = (deps) => {
|
|
|
3159
3290
|
cwd: a.runningCwd || undefined,
|
|
3160
3291
|
model: a.model || undefined,
|
|
3161
3292
|
pendingCwd: a.pendingCwd || undefined,
|
|
3293
|
+
cwdConfirmed: prevRec?.cwdConfirmed,
|
|
3162
3294
|
keepaliveOff: prevRec?.keepaliveOff,
|
|
3163
3295
|
keepaliveOffAt: prevRec?.keepaliveOffAt,
|
|
3296
|
+
keepaliveDisabled: carryKeepaliveDisabled || undefined,
|
|
3164
3297
|
});
|
|
3165
3298
|
log.info({ sessionId, jsonlPath, target, tmuxSession: a.tmuxSession, runningCwd: a.runningCwd, pendingCwd: a.pendingCwd, mirrors: bySessionId.size }, "mirror attached");
|
|
3166
3299
|
return { ok: true, sessionId, jsonlPath, target };
|
|
@@ -3172,13 +3305,17 @@ export const startMirror = (deps) => {
|
|
|
3172
3305
|
// Verify a paneId still exists. `display-message -t <paneId>` succeeds iff
|
|
3173
3306
|
// the pane is alive — and tmux pane ids monotonically increment within a
|
|
3174
3307
|
// server lifetime, so a freed id will not be silently reused. We don't need
|
|
3175
|
-
// the session name, which matters because
|
|
3308
|
+
// the session name, which matters because some attaches carry only a pane id.
|
|
3176
3309
|
const tmuxPaneAlive = async (paneId) => {
|
|
3177
3310
|
if (!paneId)
|
|
3178
3311
|
return false;
|
|
3179
3312
|
const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
|
|
3180
3313
|
return r.code === 0 && r.stdout.trim() === paneId;
|
|
3181
3314
|
};
|
|
3315
|
+
// 注入前的存活判定要多问一句「里面还有 AI 会话吗」: pane 在、claude 却退回了 shell,
|
|
3316
|
+
// 照旧往里贴就是把用户的话当 shell 命令跑。判否即走 respawn (旧 pane 不杀 —— 它可能是
|
|
3317
|
+
// 用户自己的终端, 经 switch 绑进来的)。
|
|
3318
|
+
const paneUsable = async (paneId, jsonlPath) => (await tmuxPaneAlive(paneId)) && (await paneHostsSession(paneId, backendForPath(jsonlPath).homeDir));
|
|
3182
3319
|
// Re-attach a stored binding for `principal`. Returns the resulting state, or
|
|
3183
3320
|
// undefined if the on-disk transcript is gone (in which case the entry is
|
|
3184
3321
|
// dropped so the next inbound flows through /new auto-spawn).
|
|
@@ -3308,6 +3445,8 @@ export const startMirror = (deps) => {
|
|
|
3308
3445
|
}
|
|
3309
3446
|
// Re-hydrate the `/stop` pause so a reload doesn't resume pinging a quieted
|
|
3310
3447
|
// session (attach preserved it on disk; this puts it back in memory).
|
|
3448
|
+
// keepaliveDisabled is carried by attach() itself (same rule as `model`);
|
|
3449
|
+
// only the transient `/stop` pause needs manual re-hydration here.
|
|
3311
3450
|
const restored = byTarget.get(principal);
|
|
3312
3451
|
if (restored && rec.keepaliveOff) {
|
|
3313
3452
|
restored.keepaliveOff = true;
|
|
@@ -3321,7 +3460,7 @@ export const startMirror = (deps) => {
|
|
|
3321
3460
|
const persistPause = (a) => {
|
|
3322
3461
|
const rec = deps.store.get(a.target);
|
|
3323
3462
|
if (rec)
|
|
3324
|
-
deps.store.set(a.target, { ...rec, keepaliveOff: a.keepaliveOff, keepaliveOffAt: a.keepaliveOffAt });
|
|
3463
|
+
deps.store.set(a.target, { ...rec, keepaliveOff: a.keepaliveOff, keepaliveOffAt: a.keepaliveOffAt, keepaliveDisabled: a.keepaliveDisabled });
|
|
3325
3464
|
};
|
|
3326
3465
|
// We re-attach lazily on demand too (see restoreFromStore in dispatch /
|
|
3327
3466
|
// hasMirrorTarget), but eager boot-restore makes outbound (tail → push) work
|
|
@@ -3353,7 +3492,7 @@ export const startMirror = (deps) => {
|
|
|
3353
3492
|
}
|
|
3354
3493
|
}
|
|
3355
3494
|
else {
|
|
3356
|
-
log.info("mirror: no persisted attachments and no pinned sessionId — waiting for
|
|
3495
|
+
log.info("mirror: no persisted attachments and no pinned sessionId — waiting for the first inbound or /new");
|
|
3357
3496
|
}
|
|
3358
3497
|
// Render full tool details for a finalized turn into one-or-more markdown
|
|
3359
3498
|
// chunks (split at chunkBytes boundaries).
|
|
@@ -3884,9 +4023,12 @@ export const startMirror = (deps) => {
|
|
|
3884
4023
|
// 由 index.ts 在启动时装上 (注册表活在那边); 没装 = 退回无身份行为, 全链路无回归。
|
|
3885
4024
|
let charterOf;
|
|
3886
4025
|
const setCharterProvider = (fn) => { charterOf = fn; };
|
|
3887
|
-
|
|
4026
|
+
// `cwd` = 这个 pane 正要启动的目录。attach 在 spawn 之后才发生, 所以此刻 getCwd
|
|
4027
|
+
// 给的还是上一个 pane 的目录 —— 换目录重开时照它渲染, 新 wizard 会以为自己还在
|
|
4028
|
+
// 旧工作区里。
|
|
4029
|
+
const charterFor = (target, ctx) => {
|
|
3888
4030
|
try {
|
|
3889
|
-
return charterOf?.(target);
|
|
4031
|
+
return charterOf?.(target, ctx);
|
|
3890
4032
|
}
|
|
3891
4033
|
catch {
|
|
3892
4034
|
return undefined;
|
|
@@ -3936,7 +4078,7 @@ export const startMirror = (deps) => {
|
|
|
3936
4078
|
cwdOverride: eff,
|
|
3937
4079
|
cli: effCli,
|
|
3938
4080
|
model: opts?.model,
|
|
3939
|
-
systemPrompt: opts?.systemPrompt ?? charterFor(target),
|
|
4081
|
+
systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
|
|
3940
4082
|
});
|
|
3941
4083
|
if (!r.ok)
|
|
3942
4084
|
return { ok: false, reason: r.reason };
|
|
@@ -3950,8 +4092,11 @@ export const startMirror = (deps) => {
|
|
|
3950
4092
|
// Explicit "" clears any carried-over pending — it has just been applied.
|
|
3951
4093
|
pendingCwd: "",
|
|
3952
4094
|
// Likewise explicit: `/new` starts a NEW session, so an unnamed model
|
|
3953
|
-
// means this CLI's default, not the dead pane's model.
|
|
3954
|
-
model
|
|
4095
|
+
// means this CLI's default, not the dead pane's model. Store what
|
|
4096
|
+
// spawnTmuxClaude actually confirmed via `/model` (r.model), not the
|
|
4097
|
+
// raw request — a later respawn's fast path then matches on try one.
|
|
4098
|
+
model: r.model ?? (opts?.model?.trim() ?? ""),
|
|
4099
|
+
keepaliveDisabled: opts?.keepalive === undefined ? undefined : !opts.keepalive,
|
|
3955
4100
|
});
|
|
3956
4101
|
if (!att.ok)
|
|
3957
4102
|
return { ok: false, reason: att.reason };
|
|
@@ -3961,7 +4106,7 @@ export const startMirror = (deps) => {
|
|
|
3961
4106
|
if (spawned) {
|
|
3962
4107
|
spawned.justSpawned = true;
|
|
3963
4108
|
spawned.muteUntilInject = true;
|
|
3964
|
-
// 只有换过 pane 才是断点 ——
|
|
4109
|
+
// 只有换过 pane 才是断点 —— 首次建会话时 prev 为空, 那不是"不连续", 是开局。
|
|
3965
4110
|
if (prev)
|
|
3966
4111
|
markCut(target, spawned.sessionId, "new");
|
|
3967
4112
|
// A freshly spawned session is empty — nothing in the cache to keep warm.
|
|
@@ -3986,6 +4131,7 @@ export const startMirror = (deps) => {
|
|
|
3986
4131
|
tmuxSession: baseA.tmuxSession || undefined,
|
|
3987
4132
|
tmuxPane: baseA.tmuxPane || undefined,
|
|
3988
4133
|
cwd: baseA.runningCwd || undefined,
|
|
4134
|
+
cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
|
|
3989
4135
|
pendingCwd: undefined,
|
|
3990
4136
|
});
|
|
3991
4137
|
}
|
|
@@ -3997,7 +4143,7 @@ export const startMirror = (deps) => {
|
|
|
3997
4143
|
}
|
|
3998
4144
|
if (!opts?.silent)
|
|
3999
4145
|
pushProjectInfo(target, "created");
|
|
4000
|
-
return { ok: true, sessionId: r.sessionId, cwd: r.cwd };
|
|
4146
|
+
return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning };
|
|
4001
4147
|
};
|
|
4002
4148
|
// ── Clone ───────────────────────────────────────────────────────────
|
|
4003
4149
|
// 分身 = `--resume <父 sid> --fork-session` 起的新 pane: CLI 把父亲的 transcript
|
|
@@ -4039,7 +4185,7 @@ export const startMirror = (deps) => {
|
|
|
4039
4185
|
const canInherit = args.inherit !== false && !!p?.sessionId && !!p.jsonlPath && existsSync(p.jsonlPath) && sameCwd;
|
|
4040
4186
|
if (!canInherit) {
|
|
4041
4187
|
const r = await newSession(args.target, args.windowName, args.cli, {
|
|
4042
|
-
model: args.model, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true,
|
|
4188
|
+
model: args.model, cwd: args.cwd, systemPrompt: args.systemPrompt, silent: true, keepalive: args.keepalive,
|
|
4043
4189
|
});
|
|
4044
4190
|
return { ...r, inherited: false };
|
|
4045
4191
|
}
|
|
@@ -4091,7 +4237,8 @@ export const startMirror = (deps) => {
|
|
|
4091
4237
|
tmuxSession: r.tmuxSession,
|
|
4092
4238
|
cwd: r.cwd,
|
|
4093
4239
|
pendingCwd: "",
|
|
4094
|
-
model: args.model?.trim() ?? "",
|
|
4240
|
+
model: r.model ?? (args.model?.trim() ?? ""),
|
|
4241
|
+
keepaliveDisabled: args.keepalive === undefined ? undefined : !args.keepalive,
|
|
4095
4242
|
});
|
|
4096
4243
|
if (!att.ok)
|
|
4097
4244
|
return { ok: false, reason: att.reason, inherited: false };
|
|
@@ -4106,7 +4253,7 @@ export const startMirror = (deps) => {
|
|
|
4106
4253
|
persistPause(spawned);
|
|
4107
4254
|
}
|
|
4108
4255
|
lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
|
|
4109
|
-
return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true };
|
|
4256
|
+
return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning };
|
|
4110
4257
|
};
|
|
4111
4258
|
/** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
|
|
4112
4259
|
const sessionInfo = (target) => {
|
|
@@ -4163,6 +4310,7 @@ export const startMirror = (deps) => {
|
|
|
4163
4310
|
tmuxSession: baseA.tmuxSession || undefined,
|
|
4164
4311
|
tmuxPane: baseA.tmuxPane || undefined,
|
|
4165
4312
|
cwd: baseA.runningCwd || undefined,
|
|
4313
|
+
cwdConfirmed: deps.store.get(base)?.cwdConfirmed,
|
|
4166
4314
|
pendingCwd: baseA.pendingCwd || undefined,
|
|
4167
4315
|
});
|
|
4168
4316
|
log.info({ target, base, runningCwd: callerRunning, pendingCwd: expanded }, "setPendingCwd (chat-scoped, live base)");
|
|
@@ -4186,6 +4334,7 @@ export const startMirror = (deps) => {
|
|
|
4186
4334
|
tmuxSession: callerA.tmuxSession || undefined,
|
|
4187
4335
|
tmuxPane: callerA.tmuxPane || undefined,
|
|
4188
4336
|
cwd: callerA.runningCwd || undefined,
|
|
4337
|
+
cwdConfirmed: deps.store.get(target)?.cwdConfirmed,
|
|
4189
4338
|
pendingCwd: callerA.pendingCwd || undefined,
|
|
4190
4339
|
});
|
|
4191
4340
|
log.info({ target, runningCwd: callerA.runningCwd, pendingCwd: callerA.pendingCwd }, "setPendingCwd (fallback: no base, wrote to caller)");
|
|
@@ -4199,6 +4348,32 @@ export const startMirror = (deps) => {
|
|
|
4199
4348
|
}
|
|
4200
4349
|
return { ok: false, reason: "no mirror binding for target — send a message in the WeCom chat first", runningCwd: "", pendingCwd: "" };
|
|
4201
4350
|
};
|
|
4351
|
+
// 「人说了就用这个目录」。一个新聊天的第一个 wizard 落在默认兜底目录里 —— 那不是
|
|
4352
|
+
// 选择, 只是没人说过。所以默认按「还要换」办 (charter 让它开工前先问一句), 直到人
|
|
4353
|
+
// 明说不用换。**只有这一个写入点**: 换去别的目录本身就是选择, 不必记一笔, 那时
|
|
4354
|
+
// runningCwd 已经不等于默认目录, cwdUnconfirmed 自然为假。
|
|
4355
|
+
// 寻址与 pendingCwd 同规 —— 写在 base 上 (聊天级), 没有 base 记录 (只有 tagged
|
|
4356
|
+
// 会话的聊天) 就退回调用方自己的那条。
|
|
4357
|
+
const confirmCwd = (target) => {
|
|
4358
|
+
const base = baseOfKey(target);
|
|
4359
|
+
const key = deps.store.get(base) ? base : deps.store.get(target) ? target : "";
|
|
4360
|
+
const rec = key ? deps.store.get(key) : undefined;
|
|
4361
|
+
if (!rec)
|
|
4362
|
+
return { ok: false, reason: "no mirror binding for target — send a message in the WeCom chat first", cwd: "" };
|
|
4363
|
+
deps.store.set(key, { ...rec, cwdConfirmed: true });
|
|
4364
|
+
const cwd = getCwd(target).runningCwd;
|
|
4365
|
+
log.info({ target, key, cwd }, "cwd confirmed by user");
|
|
4366
|
+
return { ok: true, cwd };
|
|
4367
|
+
};
|
|
4368
|
+
/** 该不该问人「要在哪个项目下干活」: 还蹲在默认兜底目录里, 而且人没说过就用它。 */
|
|
4369
|
+
const cwdUnconfirmed = (target, cwd) => {
|
|
4370
|
+
const base = baseOfKey(target);
|
|
4371
|
+
if (deps.store.get(base)?.cwdConfirmed || deps.store.get(target)?.cwdConfirmed)
|
|
4372
|
+
return false;
|
|
4373
|
+
// `cwd` 是调用方已经知道的「即将生效的目录」(spawn 前的宪章渲染)。没给就问现状。
|
|
4374
|
+
const eff = cwd?.trim() ? expandHome(cwd.trim()) : getCwd(target).runningCwd;
|
|
4375
|
+
return eff === expandedDefaultCwd;
|
|
4376
|
+
};
|
|
4202
4377
|
// ── Peer graph (sibling sessions of one chat) ────────────────────────
|
|
4203
4378
|
// A chat's sessions are exactly the keys sharing its base principal: the
|
|
4204
4379
|
// untagged default plus every `#tag`. Live attachments are the truth; the
|
|
@@ -4212,80 +4387,36 @@ export const startMirror = (deps) => {
|
|
|
4212
4387
|
const base = baseOfKey(target);
|
|
4213
4388
|
return allTargets().filter((k) => baseOfKey(k) === base);
|
|
4214
4389
|
};
|
|
4215
|
-
//
|
|
4216
|
-
//
|
|
4217
|
-
|
|
4218
|
-
|
|
4219
|
-
|
|
4220
|
-
return { ok: true, base };
|
|
4221
|
-
const known = listChatNames(cfg).map((c) => c.name);
|
|
4222
|
-
return {
|
|
4223
|
-
ok: false,
|
|
4224
|
-
reason: `unknown chat '${ref}' — give that chat a name first (\`/name ${normChatName(ref) || "<name>"}\` inside it)`,
|
|
4225
|
-
candidates: known,
|
|
4226
|
-
};
|
|
4227
|
-
};
|
|
4228
|
-
// peer 寻址。地址是两级的(见 chat-name.ts):
|
|
4229
|
-
// "" 本 chat 的 default —— 自身语义不变;
|
|
4230
|
-
// `fix` 本 chat 优先,本 chat 没有再全局兜底(全 host 唯一才认)——
|
|
4231
|
-
// 命名之前唯一的跨 chat 路子,老调用方不能因为引入命名而断掉;
|
|
4232
|
-
// `daily#fix` daily 这个 chat 里的 `#fix`,精确到点,不问 tag 全不全局唯一;
|
|
4233
|
-
// `daily` daily 这个聊天的 default 会话 —— 默认 wizard 的名字就是聊天名,
|
|
4234
|
-
// 所以裸聊天名就是它的地址 (`daily#` 这种老写法继续认);
|
|
4235
|
-
// `chat:wr…#fix` 全量 key 同理(list_peers 吐的就是它)。
|
|
4236
|
-
// 裸 tag 的 0 命中 / ≥2 命中依旧拒绝,但出路从"回去改 tag 名"变成"用带 chat 名
|
|
4237
|
-
// 的全称地址"——后者不需要动别人的会话。
|
|
4390
|
+
// peer 寻址。地址就是 wizard 的**全局名字** (`.fix` / `fix`), 与它住在哪个聊天无关:
|
|
4391
|
+
// "" 本 chat 的默认 wizard —— 自身语义不变;
|
|
4392
|
+
// `fix` / `.fix` 名册里叫这个名字的 wizard, 全局唯一, 不分聊天;
|
|
4393
|
+
// `chat:wr…#k` 全量 key (调试 / 老调用方);
|
|
4394
|
+
// `daily#fix` 改名前的老写法 —— 名字取自 tag 迁移而来, 照旧认得。
|
|
4238
4395
|
const resolvePeerTag = (self, ref) => {
|
|
4239
|
-
const
|
|
4240
|
-
const t = tag.trim();
|
|
4241
|
-
if (chat) {
|
|
4242
|
-
const c = resolveChatRef(chat);
|
|
4243
|
-
if (!c.ok)
|
|
4244
|
-
return c;
|
|
4245
|
-
const target = keyOf(c.base, t);
|
|
4246
|
-
if (allTargets().includes(target)) {
|
|
4247
|
-
return { ok: true, target, foreign: c.base !== baseOfKey(self) };
|
|
4248
|
-
}
|
|
4249
|
-
return {
|
|
4250
|
-
ok: false,
|
|
4251
|
-
reason: `chat '${chat}' has no ${t ? `'#${t}'` : "default"} session — create it with new_claude_session({ chat: '${chat}'${t ? `, tag: '${t}'` : ""}, cwd })`,
|
|
4252
|
-
// 候选给**地址**而不是裸 key —— 这份清单存在的意义就是让调用方照着改一个
|
|
4253
|
-
// 能用的串; 隔壁两个分支早就这么做了, 只有这一条漏掉。
|
|
4254
|
-
candidates: allTargets().filter((k) => baseOfKey(k) === c.base).map((k) => peerAddress(cfg, self, k)),
|
|
4255
|
-
};
|
|
4256
|
-
}
|
|
4257
|
-
const local = keyOf(baseOfKey(self), t);
|
|
4258
|
-
if (!t)
|
|
4259
|
-
return { ok: true, target: local, foreign: false };
|
|
4396
|
+
const raw = stripSigil(ref);
|
|
4260
4397
|
const all = allTargets();
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
if (
|
|
4265
|
-
return { ok:
|
|
4266
|
-
|
|
4267
|
-
|
|
4268
|
-
|
|
4269
|
-
|
|
4270
|
-
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
|
|
4274
|
-
|
|
4275
|
-
|
|
4276
|
-
|
|
4277
|
-
return
|
|
4278
|
-
ok: false,
|
|
4279
|
-
reason: `chat '${t}' has no default session — address one of its tagged sessions, or create it with new_claude_session({ chat: '${t}', cwd })`,
|
|
4280
|
-
candidates: allTargets().filter((k) => baseOfKey(k) === asChat).map((k) => peerAddress(cfg, self, k)),
|
|
4281
|
-
};
|
|
4282
|
-
}
|
|
4283
|
-
return { ok: false, reason: `no peer with tag '#${t}' — create one with new_claude_session, or address it in full as 'chatName#${t}' (list_chats shows the names)` };
|
|
4398
|
+
const hit = (target) => ({ ok: true, target, foreign: baseOfKey(target) !== baseOfKey(self) });
|
|
4399
|
+
if (!raw)
|
|
4400
|
+
return hit(baseOfKey(self));
|
|
4401
|
+
if (/^(user|chat|group):/.test(raw)) {
|
|
4402
|
+
return all.includes(raw) ? hit(raw) : { ok: false, reason: `no running session '${raw}'` };
|
|
4403
|
+
}
|
|
4404
|
+
const named = wizardStore()?.byName(raw)?.target;
|
|
4405
|
+
if (named && all.includes(named))
|
|
4406
|
+
return hit(named);
|
|
4407
|
+
if (named)
|
|
4408
|
+
return { ok: false, reason: `wizard '.${raw}' exists but its session is not running — respawn it (new_claude_session) or pick another` };
|
|
4409
|
+
const { chat, tag } = parsePeerRef(raw);
|
|
4410
|
+
if (chat) {
|
|
4411
|
+
const base = chatBaseOf(cfg, chat);
|
|
4412
|
+
const legacy = base ? keyOf(base, tag) : "";
|
|
4413
|
+
if (legacy && all.includes(legacy))
|
|
4414
|
+
return hit(legacy);
|
|
4284
4415
|
}
|
|
4285
4416
|
return {
|
|
4286
4417
|
ok: false,
|
|
4287
|
-
reason: `
|
|
4288
|
-
candidates:
|
|
4418
|
+
reason: `no wizard named '.${raw}' — wizard_roster lists every name`,
|
|
4419
|
+
candidates: all.map((k) => peerAddress(cfg, self, k)),
|
|
4289
4420
|
};
|
|
4290
4421
|
};
|
|
4291
4422
|
/** 已知的每个 chat:命名的 + 有会话在跑的。`name` 为空即"还没起名",那就是
|
|
@@ -4351,6 +4482,7 @@ export const startMirror = (deps) => {
|
|
|
4351
4482
|
const pane = paneOf(t);
|
|
4352
4483
|
const paneAlive = pane ? await tmuxPaneAlive(pane) : false;
|
|
4353
4484
|
const tag = tagOfKey(t);
|
|
4485
|
+
const name = displayName(t);
|
|
4354
4486
|
let lastActivity = 0;
|
|
4355
4487
|
try {
|
|
4356
4488
|
if (jsonlPath)
|
|
@@ -4362,7 +4494,7 @@ export const startMirror = (deps) => {
|
|
|
4362
4494
|
tag,
|
|
4363
4495
|
chat: chatNameOf(cfg, t),
|
|
4364
4496
|
address: peerAddress(cfg, self, t),
|
|
4365
|
-
label:
|
|
4497
|
+
label: name ? labelFor(name) : "🧙",
|
|
4366
4498
|
sessionId: a?.sessionId || rec?.sessionId || "",
|
|
4367
4499
|
jsonlPath,
|
|
4368
4500
|
cwd: a?.runningCwd || rec?.cwd || expandedDefaultCwd,
|
|
@@ -4379,16 +4511,13 @@ export const startMirror = (deps) => {
|
|
|
4379
4511
|
};
|
|
4380
4512
|
const byRecent = (x, y) => y.lastActivity - x.lastActivity;
|
|
4381
4513
|
const peers = async (target) => (await Promise.all(chatTargets(target).map((t) => peerInfoOf(t, target)))).sort(byRecent);
|
|
4382
|
-
// list_peers 的跨 chat
|
|
4383
|
-
//
|
|
4384
|
-
// `daily#fix` 命中)。后者是命名带来的新增量:同名 tag 不再互相遮蔽,想被找到
|
|
4385
|
-
// 只要给群起个名,不必回去改别人的 tag。
|
|
4514
|
+
// list_peers 的跨 chat 补充: 其他 chat 里的 wizard。名字全局唯一, 所以它们
|
|
4515
|
+
// 全都叫得动 —— 地址就是 `.name`, 与住在哪个聊天无关。
|
|
4386
4516
|
const foreignPeers = async (self) => {
|
|
4387
4517
|
const selfBase = baseOfKey(self);
|
|
4388
|
-
|
|
4389
|
-
const
|
|
4390
|
-
|
|
4391
|
-
return (await Promise.all(reachable.map((t) => peerInfoOf(t, self)))).sort(byRecent);
|
|
4518
|
+
// 只列活着的 / 有身份的 —— 冷绑定可能有几百个, 每个探活是两次 tmux。
|
|
4519
|
+
const foreign = allTargets().filter((k) => baseOfKey(k) !== selfBase && (byTarget.has(k) || !!wizardStore()?.get(k)));
|
|
4520
|
+
return (await Promise.all(foreign.map((t) => peerInfoOf(t, self)))).sort(byRecent);
|
|
4392
4521
|
};
|
|
4393
4522
|
// peerInfoOf 每个 target 要两次 tmux (pane 存活 + capture tail)。跑了几个月的
|
|
4394
4523
|
// 机器上 allTargets() 是几百个 —— 一次全量探活就是上千次 spawn, 足够打穿 fd
|
|
@@ -4535,6 +4664,8 @@ export const startMirror = (deps) => {
|
|
|
4535
4664
|
const pingSigs = [normAssistant(kc.ping).slice(0, 40), normAssistant(kc.resumePing).slice(0, 40)].filter((s) => s.length > 0);
|
|
4536
4665
|
const now = Date.now();
|
|
4537
4666
|
for (const a of byTarget.values()) {
|
|
4667
|
+
if (a.keepaliveDisabled)
|
|
4668
|
+
continue; // sticky spawn-time opt-out — never pings, never auto-resumes
|
|
4538
4669
|
if (!a.tmuxPane)
|
|
4539
4670
|
continue; // spawn-mode: no live pane to warm
|
|
4540
4671
|
if (a.migrationWatcher)
|
|
@@ -4961,14 +5092,22 @@ export const startMirror = (deps) => {
|
|
|
4961
5092
|
a.pendingOrigin = { origin, at: Date.now() };
|
|
4962
5093
|
if (opts?.from)
|
|
4963
5094
|
a.pendingFrom = { from: opts.from, at: Date.now() };
|
|
5095
|
+
if (opts?.channel !== undefined)
|
|
5096
|
+
a.pendingChannel = { channel: opts.channel, at: Date.now() };
|
|
5097
|
+
// 注入的那一行会被 recentInjects 当回显吞掉, 走不到 user_text —— 本轮的 query
|
|
5098
|
+
// (rolepage 上「谁对它说了什么」那一半) 只能在这里记。
|
|
5099
|
+
a.pendingBriefQuery = text;
|
|
5100
|
+
// 公开频道里说的话, 回复就该在群里看得见 —— 哪怕上一轮是人在 CLI 里敲的。
|
|
5101
|
+
if (opts?.channel)
|
|
5102
|
+
a.turnFromChat = true;
|
|
4964
5103
|
const sid = a.sessionId;
|
|
4965
|
-
const paneAlive = a.tmuxPane ? await
|
|
5104
|
+
const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
|
|
4966
5105
|
if (!paneAlive) {
|
|
4967
5106
|
log.warn({ target, sessionId: sid, oldPane: a.tmuxPane }, "injectText: pane not alive, respawning");
|
|
4968
5107
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
4969
5108
|
// onto the forked jsonl once it appears (EOF offset — fork is seeded).
|
|
4970
5109
|
const resumeBaseline = listJsonls(dirname(a.jsonlPath));
|
|
4971
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName:
|
|
5110
|
+
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn-init", sessionId: sid }), resumeSessionId: sid, windowName: displayName(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(target, { cwd: a.runningCwd }) });
|
|
4972
5111
|
if (!r.ok || !r.tmuxPane)
|
|
4973
5112
|
return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
|
|
4974
5113
|
a.tmuxPane = r.tmuxPane;
|
|
@@ -5070,6 +5209,61 @@ export const startMirror = (deps) => {
|
|
|
5070
5209
|
log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
|
|
5071
5210
|
return { ok: true };
|
|
5072
5211
|
},
|
|
5212
|
+
reapIdle: async (ttlMs, skip) => {
|
|
5213
|
+
const now = Date.now();
|
|
5214
|
+
const reaped = [];
|
|
5215
|
+
const skipped = {};
|
|
5216
|
+
for (const target of allTargets()) {
|
|
5217
|
+
const a = byTarget.get(target);
|
|
5218
|
+
const sessionId = a?.sessionId || deps.store.get(target)?.sessionId || "";
|
|
5219
|
+
// 便宜闸门先行: transcript mtime 一次 statSync, 不 spawn —— 跑了几个
|
|
5220
|
+
// 月的机器上 allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd
|
|
5221
|
+
// (同 worldPeers 的 WORLD_PROBE 取舍)。还没写过 transcript 的 spawn-mode
|
|
5222
|
+
// 绑定没有活动证据, 不动它。
|
|
5223
|
+
const jsonl = jsonlOf(target);
|
|
5224
|
+
let mtime = 0;
|
|
5225
|
+
try {
|
|
5226
|
+
if (jsonl)
|
|
5227
|
+
mtime = statSync(jsonl).mtimeMs;
|
|
5228
|
+
}
|
|
5229
|
+
catch { /* 文件没了 */ }
|
|
5230
|
+
if (!mtime || now - mtime < ttlMs)
|
|
5231
|
+
continue;
|
|
5232
|
+
const veto = skip?.(target, sessionId);
|
|
5233
|
+
if (veto) {
|
|
5234
|
+
skipped[target] = veto;
|
|
5235
|
+
continue;
|
|
5236
|
+
}
|
|
5237
|
+
const pane = paneOf(target);
|
|
5238
|
+
if (!pane || !(await tmuxPaneAlive(pane)))
|
|
5239
|
+
continue; // pane 已死, 没什么可收的
|
|
5240
|
+
if (paneIsBusy(await capturePaneTail(pane, 12))) {
|
|
5241
|
+
skipped[target] = "busy";
|
|
5242
|
+
continue;
|
|
5243
|
+
}
|
|
5244
|
+
// 人正盯着这个 window —— 收掉他的视野比省下那点内存更讨嫌。
|
|
5245
|
+
const w = await runTmux(["display-message", "-p", "-t", pane, "#{window_active}:#{session_attached}"]);
|
|
5246
|
+
const [wActive, sAttached] = w.stdout.trim().split(":");
|
|
5247
|
+
if (wActive === "1" && sAttached !== "0") {
|
|
5248
|
+
skipped[target] = "watched";
|
|
5249
|
+
continue;
|
|
5250
|
+
}
|
|
5251
|
+
// 与 killPane 同款前戏: Esc 给生成中的 CLI 一个收束落盘的机会。
|
|
5252
|
+
await runTmux(["send-keys", "-t", pane, "Escape"]);
|
|
5253
|
+
await sleep(250);
|
|
5254
|
+
const r = await runTmux(["kill-pane", "-t", pane]);
|
|
5255
|
+
if (r.code !== 0) {
|
|
5256
|
+
skipped[target] = `kill-pane failed: ${r.stdout.slice(-120) || r.code}`;
|
|
5257
|
+
continue;
|
|
5258
|
+
}
|
|
5259
|
+
if (a)
|
|
5260
|
+
detach(a, "idle reap");
|
|
5261
|
+
// 不碰 store —— 绑定留着, 下一条 inbound 走 dead-pane `--resume` 复活。
|
|
5262
|
+
reaped.push(target);
|
|
5263
|
+
log.info({ target, sessionId, pane, idleHours: Math.round((now - mtime) / 3.6e6) }, "mirror idle reap — pane killed, binding kept");
|
|
5264
|
+
}
|
|
5265
|
+
return { reaped, skipped };
|
|
5266
|
+
},
|
|
5073
5267
|
submitPane: async (target) => {
|
|
5074
5268
|
const a = byTarget.get(target);
|
|
5075
5269
|
if (!a)
|
|
@@ -5151,6 +5345,8 @@ export const startMirror = (deps) => {
|
|
|
5151
5345
|
},
|
|
5152
5346
|
getCwd,
|
|
5153
5347
|
setPendingCwd,
|
|
5348
|
+
cwdUnconfirmed,
|
|
5349
|
+
confirmCwd,
|
|
5154
5350
|
newSession,
|
|
5155
5351
|
cloneSession,
|
|
5156
5352
|
sessionInfo,
|
|
@@ -5170,7 +5366,11 @@ export const startMirror = (deps) => {
|
|
|
5170
5366
|
bySessionId.clear();
|
|
5171
5367
|
byTarget.clear();
|
|
5172
5368
|
},
|
|
5173
|
-
|
|
5369
|
+
currentChannel: (target) => {
|
|
5370
|
+
const a = byTarget.get(target);
|
|
5371
|
+
return a?.channel ?? baseOfKey(target);
|
|
5372
|
+
},
|
|
5373
|
+
dispatch: async ({ principal, text, images, frame, streamId, channel, speaker }) => {
|
|
5174
5374
|
// Route by inbound principal — the WeCom chat the user just messaged us
|
|
5175
5375
|
// from is the same string we registered as `target` on attach. After a
|
|
5176
5376
|
// daemon reload the in-memory map is empty; restore from the persisted
|
|
@@ -5181,7 +5381,7 @@ export const startMirror = (deps) => {
|
|
|
5181
5381
|
a = await restoreFromStore(principal);
|
|
5182
5382
|
if (!a) {
|
|
5183
5383
|
try {
|
|
5184
|
-
await client.replyStream(frame, streamId, withTagHeader(principal, "[wezard]
|
|
5384
|
+
await client.replyStream(frame, streamId, withTagHeader(principal, "[wezard] 本聊天还没有会话 — 发 `/new` 开一个"), true);
|
|
5185
5385
|
}
|
|
5186
5386
|
catch {
|
|
5187
5387
|
/* ignore */
|
|
@@ -5206,6 +5406,8 @@ export const startMirror = (deps) => {
|
|
|
5206
5406
|
}
|
|
5207
5407
|
}
|
|
5208
5408
|
persistPause(a); // persist the pause/resume too, else a reload would revert it
|
|
5409
|
+
// 频道印章要早于任何开 turn 的路径 (startBriefTurn 同步消费)。
|
|
5410
|
+
a.pendingChannel = { channel: channel ?? baseOfKey(principal), speaker, at: Date.now() };
|
|
5209
5411
|
// Finalize prior live stream (if any) so this new turn renders into its
|
|
5210
5412
|
// own message bubble. Then open a fresh stream tied to the new frame and
|
|
5211
5413
|
// ack immediately so WeCom doesn't time out while inject queues.
|
|
@@ -5227,10 +5429,8 @@ export const startMirror = (deps) => {
|
|
|
5227
5429
|
if (a.liveStream && !a.liveStream.closed)
|
|
5228
5430
|
await finalizeStream(a, a.liveStream);
|
|
5229
5431
|
log.info({ target: a.target, runningCwd: a.runningCwd, pendingCwd: pending }, "/clear upgraded to /new (cwd switch)");
|
|
5230
|
-
//
|
|
5231
|
-
|
|
5232
|
-
const tag = tagOfKey(a.target);
|
|
5233
|
-
const r = await newSession(a.target, tag || a.target);
|
|
5432
|
+
// The wizard's global name is the tmux window name (matches /new).
|
|
5433
|
+
const r = await newSession(a.target, displayName(a.target) || a.target);
|
|
5234
5434
|
if (!r.ok) {
|
|
5235
5435
|
try {
|
|
5236
5436
|
await client.replyStream(frame, streamId, withTagHeader(a.target, `[mirror] 切换失败: ${r.reason ?? "unknown"}`), true);
|
|
@@ -5313,7 +5513,7 @@ export const startMirror = (deps) => {
|
|
|
5313
5513
|
return; // (eager path) superseded by a newer dispatch
|
|
5314
5514
|
// Always reincarnate when no live pane: covers (a) pane closed between
|
|
5315
5515
|
// turns, (b) daemon reload restored a binding without a live pane, AND
|
|
5316
|
-
// (c)
|
|
5516
|
+
// (c) a binding made from a non-tmux context (no tmuxSession ever stored) —
|
|
5317
5517
|
// that last case used to permanently lock the chat into spawn-mode.
|
|
5318
5518
|
// If respawn fails, fall through to spawn-mode inject for THIS turn
|
|
5319
5519
|
// but DON'T erase tmuxSession from store — next inbound will retry,
|
|
@@ -5321,7 +5521,7 @@ export const startMirror = (deps) => {
|
|
|
5321
5521
|
// First step of the job and the historical hang point — log BEFORE the
|
|
5322
5522
|
// probe so a stall is attributable to it next time, not invisible.
|
|
5323
5523
|
log.info({ target: a.target, sessionId: sid, pane: a.tmuxPane, gen: myGen }, "inject job start");
|
|
5324
|
-
const paneAlive = a.tmuxPane ? await
|
|
5524
|
+
const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
|
|
5325
5525
|
let freshSpawn = a.justSpawned === true;
|
|
5326
5526
|
a.justSpawned = false;
|
|
5327
5527
|
if (!paneAlive) {
|
|
@@ -5336,7 +5536,7 @@ export const startMirror = (deps) => {
|
|
|
5336
5536
|
const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
|
|
5337
5537
|
// Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
|
|
5338
5538
|
// mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
|
|
5339
|
-
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName:
|
|
5539
|
+
const r = await spawnTmuxClaude({ cfg, log: log.child({ sub: "respawn", sessionId: sid }), resumeSessionId: sid, windowName: displayName(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(a.target, { cwd: a.runningCwd }) });
|
|
5340
5540
|
if (r.ok && r.tmuxPane && r.tmuxSession) {
|
|
5341
5541
|
a.tmuxPane = r.tmuxPane;
|
|
5342
5542
|
a.tmuxSession = r.tmuxSession;
|