wezard 2.0.0 → 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 +7 -8
- package/README.md +10 -11
- 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 +2 -4
- 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/detail.js +1 -1
- package/dist/daemon/detail.js.map +1 -1
- package/dist/daemon/inbound.js +59 -102
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +98 -86
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/memory-steward.js +82 -0
- package/dist/daemon/memory-steward.js.map +1 -0
- package/dist/daemon/mirror-bridge.js +149 -36
- package/dist/daemon/mirror-bridge.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 +35 -13
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/wizard-memory.js +21 -26
- package/dist/daemon/wizard-memory.js.map +1 -1
- package/dist/daemon/wizard.js +31 -11
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +63 -153
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/chat-http.js +3 -1
- package/dist/shared/chat-http.js.map +1 -1
- package/dist/shared/chat-render.js +0 -1
- package/dist/shared/chat-render.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 +4 -6
- package/dist/shared/config.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 +4 -1
- package/dist/shared/role-render.js.map +1 -1
- package/dist/shared/role-view.js +23 -5
- package/dist/shared/role-view.js.map +1 -1
- package/dist/shared/world.js +11 -1
- package/dist/shared/world.js.map +1 -1
- package/hooks/pre-tool-use.sh +4 -4
- package/package.json +1 -2
- package/web/chat.css +45 -28
- package/web/chat.js +45 -26
- 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
|
@@ -25,6 +25,7 @@ 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";
|
|
@@ -33,7 +34,7 @@ import { splitMarkdown } from "../shared/md-chunk.js";
|
|
|
33
34
|
import { randomTip } from "./tips.js";
|
|
34
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 {
|
|
@@ -1172,12 +1173,80 @@ export const parseInputBox = (cap) => {
|
|
|
1172
1173
|
};
|
|
1173
1174
|
// 24 行足够容纳展开后的多行粘贴 (折叠态只占一行)。
|
|
1174
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
|
+
};
|
|
1175
1237
|
// Self-verifying inject. The cold-spawn race we guard against: paste lands
|
|
1176
1238
|
// but the trailing Enter is eaten while the TUI is still initializing, so
|
|
1177
1239
|
// the prompt sits typed-but-unsent. 校验策略见 injectViaTmuxText —— 以输入框
|
|
1178
1240
|
// 整块为判据, 粘贴只在「确凿看见框仍是空的」时才重来。
|
|
1179
|
-
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;
|
|
1180
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);
|
|
1181
1250
|
// Never type into a modal picker (see detectModalPicker). Checked before the
|
|
1182
1251
|
// image pump too — a C-v into a picker is just as destructive as a paste.
|
|
1183
1252
|
// 必须在按后端分流之前: 每个后端都有自己的原生确认框, 往任何一个里打字都会
|
|
@@ -1210,7 +1279,7 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName,
|
|
|
1210
1279
|
if (backendName === "codebuddy") {
|
|
1211
1280
|
const refs = images.map((p) => `@${p}`);
|
|
1212
1281
|
const textWithRefs = refs.length ? (text ? `${refs.join("\n")}\n${text}` : refs.join("\n")) : text;
|
|
1213
|
-
return injectViaTmuxText(target, textWithRefs, log, freshSpawn);
|
|
1282
|
+
return injectViaTmuxText(target, textWithRefs, log, freshSpawn, witness);
|
|
1214
1283
|
}
|
|
1215
1284
|
// Pump images first via clipboard+C-v so each one is attached as a separate
|
|
1216
1285
|
// image content block. Each C-v needs a brief settle for Claude Code's TUI
|
|
@@ -1236,18 +1305,22 @@ const injectViaTmux = async (target, text, images, log, freshSpawn, backendName,
|
|
|
1236
1305
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1237
1306
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys Enter failed: ${e.stderr.slice(-200)}` };
|
|
1238
1307
|
}
|
|
1239
|
-
return injectViaTmuxText(target, text, log, freshSpawn);
|
|
1308
|
+
return injectViaTmuxText(target, text, log, freshSpawn, witness);
|
|
1240
1309
|
};
|
|
1241
1310
|
// 文本 paste + Enter 提交 + 自校验。从 injectViaTmux 抽出来,让 codebuddy
|
|
1242
1311
|
// 后端的 @<path> 回退路径也能复用同样的 paste-verify 逻辑。
|
|
1243
1312
|
//
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
//
|
|
1249
|
-
//
|
|
1250
|
-
|
|
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) => {
|
|
1251
1324
|
// Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
|
|
1252
1325
|
// started, transcript still loading): extended timings — bracketed-paste
|
|
1253
1326
|
// end can take 4-7s to be honored on a cold TUI. Only the fresh-spawn path
|
|
@@ -1257,17 +1330,30 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1257
1330
|
const POST_PASTE_SETTLE_FALLBACK_MS = freshSpawn ? 2500 : 700;
|
|
1258
1331
|
const CLEARED_TIMEOUT_MS = freshSpawn ? 4000 : 1500;
|
|
1259
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;
|
|
1260
1337
|
const { headFp, tailFp } = fingerprints(text);
|
|
1338
|
+
const probe = probeOf(text);
|
|
1261
1339
|
const POLL_MS = 100;
|
|
1262
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);
|
|
1263
1345
|
// 贴之前框里是什么: 空框、幽灵提示 (`Try "how do I log an error?"`)、甚至用户
|
|
1264
1346
|
// 自己敲了一半的字 —— 一律作为基线, 免得去维护一张「哪些文案算空」的清单。
|
|
1265
1347
|
const preBox = await readInputBox(target);
|
|
1266
1348
|
// 基线读不出来 (框被模态顶掉 / 正在重绘) 就整条退回指纹法: 没有基线,「框变了」
|
|
1267
1349
|
// 无从谈起, 而把幽灵提示 (`Try "…"`) 误当成「贴进去了」会让这条消息静静消失。
|
|
1268
1350
|
const boxVerify = preBox.known;
|
|
1351
|
+
// 去重: 框里已经是这段文本 —— 上一次注入贴上了却没交出去。再贴就是两份。
|
|
1352
|
+
const alreadyTyped = boxVerify && Boolean(probe) && stripWs(preBox.body).includes(probe);
|
|
1353
|
+
// 提交后框该回到的样子: 正常是贴之前的基线; 文本本就在框里时基线就是它, 只能以「空」为准。
|
|
1354
|
+
const baseBody = alreadyTyped ? "" : preBox.body;
|
|
1269
1355
|
/** 框里还是贴之前那样 (或彻底空了) = 我们的文本不在框里。 */
|
|
1270
|
-
const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body ===
|
|
1356
|
+
const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === baseBody);
|
|
1271
1357
|
const loadAndPaste = async () => {
|
|
1272
1358
|
// stdin variant of the shared exec path — it carries the same hard timeout,
|
|
1273
1359
|
// which a hand-rolled spawn here did not.
|
|
@@ -1279,9 +1365,6 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1279
1365
|
return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
|
|
1280
1366
|
return { ok: true };
|
|
1281
1367
|
};
|
|
1282
|
-
let r = await loadAndPaste();
|
|
1283
|
-
if (!r.ok)
|
|
1284
|
-
return r;
|
|
1285
1368
|
// 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
|
|
1286
1369
|
// 头部可能在框顶, 所以窗口要宽)。
|
|
1287
1370
|
const sawLanded = async (timeoutMs) => {
|
|
@@ -1301,10 +1384,24 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1301
1384
|
const pane = await capturePaneTail(target, 5);
|
|
1302
1385
|
return Boolean(tailFp) && stripWs(pane).includes(tailFp);
|
|
1303
1386
|
};
|
|
1304
|
-
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
|
+
}
|
|
1305
1397
|
if (!pasteSeen) {
|
|
1306
1398
|
const box = await readInputBox(target);
|
|
1307
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
|
+
}
|
|
1308
1405
|
if (!empty) {
|
|
1309
1406
|
log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 粘贴未验证但输入框非空 — 首次粘贴已落地, 不重贴");
|
|
1310
1407
|
pasteSeen = true;
|
|
@@ -1312,7 +1409,9 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1312
1409
|
else {
|
|
1313
1410
|
log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 输入框确认为空, 重贴一次");
|
|
1314
1411
|
await sleep(RETRY_SETTLE_MS);
|
|
1315
|
-
|
|
1412
|
+
if (submitted())
|
|
1413
|
+
return { ok: true };
|
|
1414
|
+
const r = await loadAndPaste();
|
|
1316
1415
|
if (!r.ok)
|
|
1317
1416
|
return r;
|
|
1318
1417
|
pasteSeen = await sawLanded(PASTE_VERIFY_MS);
|
|
@@ -1320,7 +1419,8 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1320
1419
|
}
|
|
1321
1420
|
// Bracketed-paste end + TUI catch-up. Warm pane: 400ms is invisible.
|
|
1322
1421
|
// Fresh respawn: 1500ms+ — claude --resume is still loading the transcript.
|
|
1323
|
-
|
|
1422
|
+
if (!alreadyTyped)
|
|
1423
|
+
await sleep(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
|
|
1324
1424
|
const sendEnter = async () => {
|
|
1325
1425
|
const e = await runTmux(["send-keys", "-t", target, "Enter"]);
|
|
1326
1426
|
return e.ok ? { ok: true } : { ok: false, reason: `tmux send-keys failed: ${e.stderr.slice(-200)}` };
|
|
@@ -1329,11 +1429,15 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1329
1429
|
const box = await readInputBox(target);
|
|
1330
1430
|
return boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
|
|
1331
1431
|
};
|
|
1332
|
-
|
|
1432
|
+
/** 证人先答; 它的窗口过了还不作声, 才每轮加看一眼框。 */
|
|
1433
|
+
const waitForSubmit = async (timeoutMs) => {
|
|
1333
1434
|
const t0 = Date.now();
|
|
1334
|
-
|
|
1435
|
+
const budget = Math.max(timeoutMs, WITNESS_ONLY_MS + 500);
|
|
1436
|
+
while (Date.now() - t0 < budget) {
|
|
1335
1437
|
await sleep(POLL_MS);
|
|
1336
|
-
if (
|
|
1438
|
+
if (submitted())
|
|
1439
|
+
return true;
|
|
1440
|
+
if (Date.now() - t0 >= WITNESS_ONLY_MS && (await cleared()))
|
|
1337
1441
|
return true;
|
|
1338
1442
|
}
|
|
1339
1443
|
return false;
|
|
@@ -1351,19 +1455,25 @@ const injectViaTmuxText = async (target, text, log, freshSpawn) => {
|
|
|
1351
1455
|
const attempts = boxTrusted ? 3 : 2;
|
|
1352
1456
|
for (let i = 0; i < attempts; i++) {
|
|
1353
1457
|
if (i > 0) {
|
|
1354
|
-
log.warn({ target, tailFp, attempt: i + 1 }, "mirror inject: 回车后输入框未清空, 再补一次");
|
|
1355
1458
|
await sleep(RETRY_SETTLE_MS);
|
|
1459
|
+
if (submitted())
|
|
1460
|
+
return { ok: true }; // 迟到的确认 —— 补的这一下会落在空框上, 省了
|
|
1461
|
+
log.warn({ target, tailFp, attempt: i + 1 }, "mirror inject: 回车后输入框未清空, 再补一次");
|
|
1356
1462
|
}
|
|
1463
|
+
if (i === 0)
|
|
1464
|
+
enterAt = Date.now();
|
|
1357
1465
|
const e = await sendEnter();
|
|
1358
1466
|
if (!e.ok)
|
|
1359
1467
|
return e;
|
|
1360
|
-
if (await
|
|
1468
|
+
if (await waitForSubmit(CLEARED_TIMEOUT_MS))
|
|
1361
1469
|
return { ok: true };
|
|
1362
1470
|
}
|
|
1471
|
+
if (submitted())
|
|
1472
|
+
return { ok: true };
|
|
1363
1473
|
// 补到头还没清空。通常是渲染滞后 (回车其实落了), 但也可能是目标会话正忙 /
|
|
1364
1474
|
// 上下文满了不消费输入 —— 那样用户这条消息会悄无声息地消失。不报硬失败 (用户
|
|
1365
1475
|
// 多半已经被服务到了), 标 uncertain 让调用方按需提示。
|
|
1366
|
-
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");
|
|
1367
1477
|
return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
|
|
1368
1478
|
};
|
|
1369
1479
|
const injectViaSpawn = (args) => {
|
|
@@ -1418,8 +1528,7 @@ const inject = (args) => {
|
|
|
1418
1528
|
const target = (args.tmuxTarget ?? "").trim();
|
|
1419
1529
|
if (!target)
|
|
1420
1530
|
return injectViaSpawn(args);
|
|
1421
|
-
|
|
1422
|
-
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);
|
|
1423
1532
|
};
|
|
1424
1533
|
const queues = new Map();
|
|
1425
1534
|
/** Ceiling for one whole inject job (respawn + paste + submit verification).
|
|
@@ -3103,7 +3212,7 @@ export const startMirror = (deps) => {
|
|
|
3103
3212
|
// missing file (existsSync gate at start, try/catch in drain) and the 1s
|
|
3104
3213
|
// poll picks it up the moment claude writes the first line.
|
|
3105
3214
|
// Replace any existing attach with the same sessionId or same target. The
|
|
3106
|
-
// sessionId clash is the "
|
|
3215
|
+
// sessionId clash is the "same session attached again" case; the target
|
|
3107
3216
|
// clash is "different window steals my WeCom chat" — both end the previous.
|
|
3108
3217
|
// Guard: if a DIFFERENT principal already owns this sessionId, refuse rather
|
|
3109
3218
|
// than detaching it — prevents cross-wire when concurrent restoreFromStore
|
|
@@ -3137,7 +3246,7 @@ export const startMirror = (deps) => {
|
|
|
3137
3246
|
tmuxPane: (tmuxPane ?? "").trim(),
|
|
3138
3247
|
tmuxSession: (tmuxSession ?? "").trim(),
|
|
3139
3248
|
// Explicit cwd (spawn path) wins; otherwise derive from jsonl head so
|
|
3140
|
-
//
|
|
3249
|
+
// cwd-less attaches inherit the bound session's real project dir instead of
|
|
3141
3250
|
// collapsing to cfg.wrc.cwd (which would mislabel /pwd, /clear, /new).
|
|
3142
3251
|
runningCwd: expandHome(((cwd ?? "").trim()) || readCwdFromJsonl(jsonlPath) || cfg.wrc.cwd),
|
|
3143
3252
|
pendingCwd: carryPending,
|
|
@@ -3196,13 +3305,17 @@ export const startMirror = (deps) => {
|
|
|
3196
3305
|
// Verify a paneId still exists. `display-message -t <paneId>` succeeds iff
|
|
3197
3306
|
// the pane is alive — and tmux pane ids monotonically increment within a
|
|
3198
3307
|
// server lifetime, so a freed id will not be silently reused. We don't need
|
|
3199
|
-
// the session name, which matters because
|
|
3308
|
+
// the session name, which matters because some attaches carry only a pane id.
|
|
3200
3309
|
const tmuxPaneAlive = async (paneId) => {
|
|
3201
3310
|
if (!paneId)
|
|
3202
3311
|
return false;
|
|
3203
3312
|
const r = await runTmux(["display-message", "-p", "-t", paneId, "#{pane_id}"]);
|
|
3204
3313
|
return r.code === 0 && r.stdout.trim() === paneId;
|
|
3205
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));
|
|
3206
3319
|
// Re-attach a stored binding for `principal`. Returns the resulting state, or
|
|
3207
3320
|
// undefined if the on-disk transcript is gone (in which case the entry is
|
|
3208
3321
|
// dropped so the next inbound flows through /new auto-spawn).
|
|
@@ -3379,7 +3492,7 @@ export const startMirror = (deps) => {
|
|
|
3379
3492
|
}
|
|
3380
3493
|
}
|
|
3381
3494
|
else {
|
|
3382
|
-
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");
|
|
3383
3496
|
}
|
|
3384
3497
|
// Render full tool details for a finalized turn into one-or-more markdown
|
|
3385
3498
|
// chunks (split at chunkBytes boundaries).
|
|
@@ -3993,7 +4106,7 @@ export const startMirror = (deps) => {
|
|
|
3993
4106
|
if (spawned) {
|
|
3994
4107
|
spawned.justSpawned = true;
|
|
3995
4108
|
spawned.muteUntilInject = true;
|
|
3996
|
-
// 只有换过 pane 才是断点 ——
|
|
4109
|
+
// 只有换过 pane 才是断点 —— 首次建会话时 prev 为空, 那不是"不连续", 是开局。
|
|
3997
4110
|
if (prev)
|
|
3998
4111
|
markCut(target, spawned.sessionId, "new");
|
|
3999
4112
|
// A freshly spawned session is empty — nothing in the cache to keep warm.
|
|
@@ -4988,7 +5101,7 @@ export const startMirror = (deps) => {
|
|
|
4988
5101
|
if (opts?.channel)
|
|
4989
5102
|
a.turnFromChat = true;
|
|
4990
5103
|
const sid = a.sessionId;
|
|
4991
|
-
const paneAlive = a.tmuxPane ? await
|
|
5104
|
+
const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
|
|
4992
5105
|
if (!paneAlive) {
|
|
4993
5106
|
log.warn({ target, sessionId: sid, oldPane: a.tmuxPane }, "injectText: pane not alive, respawning");
|
|
4994
5107
|
// Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
|
|
@@ -5268,7 +5381,7 @@ export const startMirror = (deps) => {
|
|
|
5268
5381
|
a = await restoreFromStore(principal);
|
|
5269
5382
|
if (!a) {
|
|
5270
5383
|
try {
|
|
5271
|
-
await client.replyStream(frame, streamId, withTagHeader(principal, "[wezard]
|
|
5384
|
+
await client.replyStream(frame, streamId, withTagHeader(principal, "[wezard] 本聊天还没有会话 — 发 `/new` 开一个"), true);
|
|
5272
5385
|
}
|
|
5273
5386
|
catch {
|
|
5274
5387
|
/* ignore */
|
|
@@ -5400,7 +5513,7 @@ export const startMirror = (deps) => {
|
|
|
5400
5513
|
return; // (eager path) superseded by a newer dispatch
|
|
5401
5514
|
// Always reincarnate when no live pane: covers (a) pane closed between
|
|
5402
5515
|
// turns, (b) daemon reload restored a binding without a live pane, AND
|
|
5403
|
-
// (c)
|
|
5516
|
+
// (c) a binding made from a non-tmux context (no tmuxSession ever stored) —
|
|
5404
5517
|
// that last case used to permanently lock the chat into spawn-mode.
|
|
5405
5518
|
// If respawn fails, fall through to spawn-mode inject for THIS turn
|
|
5406
5519
|
// but DON'T erase tmuxSession from store — next inbound will retry,
|
|
@@ -5408,7 +5521,7 @@ export const startMirror = (deps) => {
|
|
|
5408
5521
|
// First step of the job and the historical hang point — log BEFORE the
|
|
5409
5522
|
// probe so a stall is attributable to it next time, not invisible.
|
|
5410
5523
|
log.info({ target: a.target, sessionId: sid, pane: a.tmuxPane, gen: myGen }, "inject job start");
|
|
5411
|
-
const paneAlive = a.tmuxPane ? await
|
|
5524
|
+
const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
|
|
5412
5525
|
let freshSpawn = a.justSpawned === true;
|
|
5413
5526
|
a.justSpawned = false;
|
|
5414
5527
|
if (!paneAlive) {
|