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.
Files changed (57) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CLAUDE.md +7 -8
  4. package/README.md +10 -11
  5. package/cli/wezard.sh +0 -62
  6. package/config.example.jsonc +3 -9
  7. package/dist/cli/init.js +12 -21
  8. package/dist/cli/init.js.map +1 -1
  9. package/dist/daemon/approval.js +2 -4
  10. package/dist/daemon/approval.js.map +1 -1
  11. package/dist/daemon/cc-session.js +136 -0
  12. package/dist/daemon/cc-session.js.map +1 -0
  13. package/dist/daemon/detail.js +1 -1
  14. package/dist/daemon/detail.js.map +1 -1
  15. package/dist/daemon/inbound.js +59 -102
  16. package/dist/daemon/inbound.js.map +1 -1
  17. package/dist/daemon/index.js +98 -86
  18. package/dist/daemon/index.js.map +1 -1
  19. package/dist/daemon/memory-steward.js +82 -0
  20. package/dist/daemon/memory-steward.js.map +1 -0
  21. package/dist/daemon/mirror-bridge.js +149 -36
  22. package/dist/daemon/mirror-bridge.js.map +1 -1
  23. package/dist/daemon/session-name.js +4 -27
  24. package/dist/daemon/session-name.js.map +1 -1
  25. package/dist/daemon/spawn-tmux.js +35 -13
  26. package/dist/daemon/spawn-tmux.js.map +1 -1
  27. package/dist/daemon/wizard-memory.js +21 -26
  28. package/dist/daemon/wizard-memory.js.map +1 -1
  29. package/dist/daemon/wizard.js +31 -11
  30. package/dist/daemon/wizard.js.map +1 -1
  31. package/dist/mcp/server.js +63 -153
  32. package/dist/mcp/server.js.map +1 -1
  33. package/dist/shared/chat-http.js +3 -1
  34. package/dist/shared/chat-http.js.map +1 -1
  35. package/dist/shared/chat-render.js +0 -1
  36. package/dist/shared/chat-render.js.map +1 -1
  37. package/dist/shared/cli-backends.js +2 -7
  38. package/dist/shared/cli-backends.js.map +1 -1
  39. package/dist/shared/config.js +4 -6
  40. package/dist/shared/config.js.map +1 -1
  41. package/dist/shared/detail-store.js +1 -1
  42. package/dist/shared/detail-store.js.map +1 -1
  43. package/dist/shared/role-render.js +4 -1
  44. package/dist/shared/role-render.js.map +1 -1
  45. package/dist/shared/role-view.js +23 -5
  46. package/dist/shared/role-view.js.map +1 -1
  47. package/dist/shared/world.js +11 -1
  48. package/dist/shared/world.js.map +1 -1
  49. package/hooks/pre-tool-use.sh +4 -4
  50. package/package.json +1 -2
  51. package/web/chat.css +45 -28
  52. package/web/chat.js +45 -26
  53. package/commands/wrc.md +0 -7
  54. package/dist/daemon/cc-bridge.js +0 -183
  55. package/dist/daemon/cc-bridge.js.map +0 -1
  56. package/dist/daemon/sessions.js +0 -4
  57. 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
- // Same PATH augmentation logic as cc-bridge: launchd / systemd start the daemon
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 /wrc-attached sessions reflect their real project in /pwd, /clear, /new,
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, backendName, bypassModalGuard = false) => {
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
- // 两个判据都以**输入框整块**为准 (parseInputBox), 指纹只是认不出布局时的退路:
1245
- // 1. 贴完轮询「框变了」→ 粘贴已被 TUI 消化 (折叠成占位符也算)。这是个**正**
1246
- // 信号, 只有确凿看见框仍是贴之前的样子才重贴 —— 存疑一律不重贴, 多贴一次
1247
- // 的代价 (两份提示叠进同一个输入框) 远高于少贴一次。
1248
- // 2. 回车后轮询「框回到贴之前的样子」→ 提交被吃下了。框认得出来时这条判据可
1249
- // 信, 于是允许多补一次回车 (空框上的回车在 CLI 里是 no-op)。
1250
- const injectViaTmuxText = async (target, text, log, freshSpawn) => {
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 === preBox.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 = await sawLanded(PASTE_VERIFY_MS);
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
- r = await loadAndPaste();
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
- await sleep(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
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
- const waitForCleared = async (timeoutMs) => {
1432
+ /** 证人先答; 它的窗口过了还不作声, 才每轮加看一眼框。 */
1433
+ const waitForSubmit = async (timeoutMs) => {
1333
1434
  const t0 = Date.now();
1334
- while (Date.now() - t0 < timeoutMs) {
1435
+ const budget = Math.max(timeoutMs, WITNESS_ONLY_MS + 500);
1436
+ while (Date.now() - t0 < budget) {
1335
1437
  await sleep(POLL_MS);
1336
- if (await cleared())
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 waitForCleared(CLEARED_TIMEOUT_MS))
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
- const backendName = backendForPath(args.jsonlPath).name;
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 "/wrc again from same window" case; the target
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
- // /wrc attaches inherit the bound session's real project dir instead of
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 /wrc attaches capture only $TMUX_PANE.
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 /new or /mirror/attach");
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 才是断点 —— 首次 /wrc 建会话时 prev 为空, 那不是"不连续", 是开局。
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 tmuxPaneAlive(a.tmuxPane) : false;
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] wecom remote control not attached — run `/wrc` inside the target Claude session"), true);
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) /wrc'd from a non-tmux context (no tmuxSession ever stored) —
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 tmuxPaneAlive(a.tmuxPane) : false;
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) {