wezard 2.0.0 → 2.0.2

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 (59) 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 +20 -32
  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 +2 -3
  14. package/dist/daemon/detail.js.map +1 -1
  15. package/dist/daemon/inbound.js +90 -110
  16. package/dist/daemon/inbound.js.map +1 -1
  17. package/dist/daemon/index.js +130 -111
  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 +201 -62
  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 +54 -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 +44 -16
  34. package/dist/shared/chat-http.js.map +1 -1
  35. package/dist/shared/chat-render.js +10 -8
  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 +10 -12
  40. package/dist/shared/config.js.map +1 -1
  41. package/dist/shared/detail-render.js +35 -16
  42. package/dist/shared/detail-render.js.map +1 -1
  43. package/dist/shared/detail-store.js +14 -3
  44. package/dist/shared/detail-store.js.map +1 -1
  45. package/dist/shared/role-render.js +4 -1
  46. package/dist/shared/role-render.js.map +1 -1
  47. package/dist/shared/role-view.js +49 -11
  48. package/dist/shared/role-view.js.map +1 -1
  49. package/dist/shared/world.js +11 -1
  50. package/dist/shared/world.js.map +1 -1
  51. package/hooks/pre-tool-use.sh +4 -4
  52. package/package.json +1 -2
  53. package/web/chat.css +103 -58
  54. package/web/chat.js +153 -82
  55. package/commands/wrc.md +0 -7
  56. package/dist/daemon/cc-bridge.js +0 -183
  57. package/dist/daemon/cc-bridge.js.map +0 -1
  58. package/dist/daemon/sessions.js +0 -4
  59. 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).
@@ -3910,6 +4023,9 @@ export const startMirror = (deps) => {
3910
4023
  // 由 index.ts 在启动时装上 (注册表活在那边); 没装 = 退回无身份行为, 全链路无回归。
3911
4024
  let charterOf;
3912
4025
  const setCharterProvider = (fn) => { charterOf = fn; };
4026
+ // 新 pane 落地后的钩子 —— pane 上限在这里数 (由 index.ts 装上; 没装 = 不数)。
4027
+ let afterSpawn = () => { };
4028
+ const onSpawn = (fn) => { afterSpawn = fn; };
3913
4029
  // `cwd` = 这个 pane 正要启动的目录。attach 在 spawn 之后才发生, 所以此刻 getCwd
3914
4030
  // 给的还是上一个 pane 的目录 —— 换目录重开时照它渲染, 新 wizard 会以为自己还在
3915
4031
  // 旧工作区里。
@@ -3993,7 +4109,7 @@ export const startMirror = (deps) => {
3993
4109
  if (spawned) {
3994
4110
  spawned.justSpawned = true;
3995
4111
  spawned.muteUntilInject = true;
3996
- // 只有换过 pane 才是断点 —— 首次 /wrc 建会话时 prev 为空, 那不是"不连续", 是开局。
4112
+ // 只有换过 pane 才是断点 —— 首次建会话时 prev 为空, 那不是"不连续", 是开局。
3997
4113
  if (prev)
3998
4114
  markCut(target, spawned.sessionId, "new");
3999
4115
  // A freshly spawned session is empty — nothing in the cache to keep warm.
@@ -4030,6 +4146,7 @@ export const startMirror = (deps) => {
4030
4146
  }
4031
4147
  if (!opts?.silent)
4032
4148
  pushProjectInfo(target, "created");
4149
+ afterSpawn();
4033
4150
  return { ok: true, sessionId: r.sessionId, cwd: r.cwd, model: r.model, modelWarning: r.modelWarning };
4034
4151
  };
4035
4152
  // ── Clone ───────────────────────────────────────────────────────────
@@ -4140,6 +4257,7 @@ export const startMirror = (deps) => {
4140
4257
  persistPause(spawned);
4141
4258
  }
4142
4259
  lg.info({ parent: args.parent, sessionId: fork.sessionId }, "clone: forked");
4260
+ afterSpawn();
4143
4261
  return { ok: true, sessionId: fork.sessionId, cwd: r.cwd, inherited: true, model: r.model, modelWarning: r.modelWarning };
4144
4262
  };
4145
4263
  /** 一个会话此刻的硬事实 —— whoami / 交接判断要用的那几个数。 */
@@ -4988,7 +5106,7 @@ export const startMirror = (deps) => {
4988
5106
  if (opts?.channel)
4989
5107
  a.turnFromChat = true;
4990
5108
  const sid = a.sessionId;
4991
- const paneAlive = a.tmuxPane ? await tmuxPaneAlive(a.tmuxPane) : false;
5109
+ const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
4992
5110
  if (!paneAlive) {
4993
5111
  log.warn({ target, sessionId: sid, oldPane: a.tmuxPane }, "injectText: pane not alive, respawning");
4994
5112
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
@@ -5096,45 +5214,64 @@ export const startMirror = (deps) => {
5096
5214
  log.info({ target, sessionId: a?.sessionId, pane }, "mirror /kill — pane killed, binding dropped");
5097
5215
  return { ok: true };
5098
5216
  },
5099
- reapIdle: async (ttlMs, skip) => {
5217
+ lastActivity: (target) => {
5218
+ const jsonl = jsonlOf(target);
5219
+ try {
5220
+ return jsonl ? statSync(jsonl).mtimeMs : 0;
5221
+ }
5222
+ catch {
5223
+ return 0;
5224
+ }
5225
+ },
5226
+ reapOverflow: async (max, skip) => {
5100
5227
  const now = Date.now();
5101
5228
  const reaped = [];
5102
5229
  const skipped = {};
5103
- for (const target of allTargets()) {
5104
- const a = byTarget.get(target);
5105
- const sessionId = a?.sessionId || deps.store.get(target)?.sessionId || "";
5106
- // 便宜闸门先行: transcript mtime 一次 statSync, 不 spawn —— 跑了几个
5107
- // 月的机器上 allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd
5108
- // (同 worldPeers 的 WORLD_PROBE 取舍)。还没写过 transcript 的 spawn-mode
5109
- // 绑定没有活动证据, 不动它。
5110
- const jsonl = jsonlOf(target);
5111
- let mtime = 0;
5230
+ // 一次 list-panes 同时回答「活着吗」和「有人盯着吗」—— 跑了几个月的机器上
5231
+ // allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd (同 worldPeers 的
5232
+ // WORLD_PROBE 取舍)。
5233
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}"]);
5234
+ if (snap.code !== 0)
5235
+ return { reaped, skipped };
5236
+ const watchedBy = new Map(snap.stdout.split("\n").filter(Boolean).map((l) => {
5237
+ const [id, w = ""] = l.split("\t");
5238
+ const [wActive, sAttached] = w.split(":");
5239
+ return [id, wActive === "1" && sAttached !== "0"];
5240
+ }));
5241
+ const live = allTargets().filter((t) => watchedBy.has(paneOf(t)));
5242
+ let over = live.length - max;
5243
+ if (over <= 0)
5244
+ return { reaped, skipped };
5245
+ // 超了才 stat: 最久没动的先收。还没写过 transcript 的是刚起的 pane, 按"此刻"算。
5246
+ const mtimeOf = (target) => {
5112
5247
  try {
5113
- if (jsonl)
5114
- mtime = statSync(jsonl).mtimeMs;
5248
+ return statSync(jsonlOf(target)).mtimeMs;
5115
5249
  }
5116
- catch { /* 文件没了 */ }
5117
- if (!mtime || now - mtime < ttlMs)
5118
- continue;
5250
+ catch {
5251
+ return now;
5252
+ }
5253
+ };
5254
+ const oldestFirst = live.map((target) => ({ target, mtime: mtimeOf(target) })).sort((x, y) => x.mtime - y.mtime);
5255
+ for (const { target, mtime } of oldestFirst) {
5256
+ if (over <= 0)
5257
+ break;
5258
+ const a = byTarget.get(target);
5259
+ const sessionId = a?.sessionId || deps.store.get(target)?.sessionId || "";
5119
5260
  const veto = skip?.(target, sessionId);
5120
5261
  if (veto) {
5121
5262
  skipped[target] = veto;
5122
5263
  continue;
5123
5264
  }
5124
5265
  const pane = paneOf(target);
5125
- if (!pane || !(await tmuxPaneAlive(pane)))
5126
- continue; // pane 已死, 没什么可收的
5127
- if (paneIsBusy(await capturePaneTail(pane, 12))) {
5128
- skipped[target] = "busy";
5129
- continue;
5130
- }
5131
5266
  // 人正盯着这个 window —— 收掉他的视野比省下那点内存更讨嫌。
5132
- const w = await runTmux(["display-message", "-p", "-t", pane, "#{window_active}:#{session_attached}"]);
5133
- const [wActive, sAttached] = w.stdout.trim().split(":");
5134
- if (wActive === "1" && sAttached !== "0") {
5267
+ if (watchedBy.get(pane)) {
5135
5268
  skipped[target] = "watched";
5136
5269
  continue;
5137
5270
  }
5271
+ if (paneIsBusy(await capturePaneTail(pane, 12))) {
5272
+ skipped[target] = "busy";
5273
+ continue;
5274
+ }
5138
5275
  // 与 killPane 同款前戏: Esc 给生成中的 CLI 一个收束落盘的机会。
5139
5276
  await runTmux(["send-keys", "-t", pane, "Escape"]);
5140
5277
  await sleep(250);
@@ -5144,10 +5281,11 @@ export const startMirror = (deps) => {
5144
5281
  continue;
5145
5282
  }
5146
5283
  if (a)
5147
- detach(a, "idle reap");
5284
+ detach(a, "pane cap");
5148
5285
  // 不碰 store —— 绑定留着, 下一条 inbound 走 dead-pane `--resume` 复活。
5149
5286
  reaped.push(target);
5150
- log.info({ target, sessionId, pane, idleHours: Math.round((now - mtime) / 3.6e6) }, "mirror idle reap — pane killed, binding kept");
5287
+ over -= 1;
5288
+ log.info({ target, sessionId, pane, idleHours: Math.round((now - mtime) / 3.6e6), max }, "mirror pane cap — pane killed, binding kept");
5151
5289
  }
5152
5290
  return { reaped, skipped };
5153
5291
  },
@@ -5238,6 +5376,7 @@ export const startMirror = (deps) => {
5238
5376
  cloneSession,
5239
5377
  sessionInfo,
5240
5378
  setCharterProvider,
5379
+ onSpawn,
5241
5380
  shutdown: () => {
5242
5381
  clearInterval(paneDriftTimer);
5243
5382
  clearInterval(keepaliveTimer);
@@ -5268,7 +5407,7 @@ export const startMirror = (deps) => {
5268
5407
  a = await restoreFromStore(principal);
5269
5408
  if (!a) {
5270
5409
  try {
5271
- await client.replyStream(frame, streamId, withTagHeader(principal, "[wezard] wecom remote control not attached — run `/wrc` inside the target Claude session"), true);
5410
+ await client.replyStream(frame, streamId, withTagHeader(principal, "[wezard] 本聊天还没有会话 — 发 `/new` 开一个"), true);
5272
5411
  }
5273
5412
  catch {
5274
5413
  /* ignore */
@@ -5400,7 +5539,7 @@ export const startMirror = (deps) => {
5400
5539
  return; // (eager path) superseded by a newer dispatch
5401
5540
  // Always reincarnate when no live pane: covers (a) pane closed between
5402
5541
  // 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) —
5542
+ // (c) a binding made from a non-tmux context (no tmuxSession ever stored) —
5404
5543
  // that last case used to permanently lock the chat into spawn-mode.
5405
5544
  // If respawn fails, fall through to spawn-mode inject for THIS turn
5406
5545
  // but DON'T erase tmuxSession from store — next inbound will retry,
@@ -5408,7 +5547,7 @@ export const startMirror = (deps) => {
5408
5547
  // First step of the job and the historical hang point — log BEFORE the
5409
5548
  // probe so a stall is attributable to it next time, not invisible.
5410
5549
  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;
5550
+ const paneAlive = a.tmuxPane ? await paneUsable(a.tmuxPane, a.jsonlPath) : false;
5412
5551
  let freshSpawn = a.justSpawned === true;
5413
5552
  a.justSpawned = false;
5414
5553
  if (!paneAlive) {