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.
Files changed (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CLAUDE.md +17 -13
  4. package/README.md +130 -77
  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 +11 -7
  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/chat-name.js +8 -18
  14. package/dist/daemon/chat-name.js.map +1 -1
  15. package/dist/daemon/detail.js +54 -13
  16. package/dist/daemon/detail.js.map +1 -1
  17. package/dist/daemon/graph.js +5 -7
  18. package/dist/daemon/graph.js.map +1 -1
  19. package/dist/daemon/inbound.js +188 -254
  20. package/dist/daemon/inbound.js.map +1 -1
  21. package/dist/daemon/index.js +459 -258
  22. package/dist/daemon/index.js.map +1 -1
  23. package/dist/daemon/jobs.js +2 -2
  24. package/dist/daemon/memory-steward.js +82 -0
  25. package/dist/daemon/memory-steward.js.map +1 -0
  26. package/dist/daemon/mirror-bridge.js +370 -170
  27. package/dist/daemon/mirror-bridge.js.map +1 -1
  28. package/dist/daemon/mirror-store.js.map +1 -1
  29. package/dist/daemon/model-select.js +74 -0
  30. package/dist/daemon/model-select.js.map +1 -0
  31. package/dist/daemon/peers.js +10 -16
  32. package/dist/daemon/peers.js.map +1 -1
  33. package/dist/daemon/session-name.js +4 -27
  34. package/dist/daemon/session-name.js.map +1 -1
  35. package/dist/daemon/spawn-tmux.js +61 -16
  36. package/dist/daemon/spawn-tmux.js.map +1 -1
  37. package/dist/daemon/task-gate.js +97 -0
  38. package/dist/daemon/task-gate.js.map +1 -0
  39. package/dist/daemon/task-registry.js +132 -0
  40. package/dist/daemon/task-registry.js.map +1 -0
  41. package/dist/daemon/tasks.js +126 -54
  42. package/dist/daemon/tasks.js.map +1 -1
  43. package/dist/daemon/wizard-memory.js +52 -0
  44. package/dist/daemon/wizard-memory.js.map +1 -0
  45. package/dist/daemon/wizard.js +107 -41
  46. package/dist/daemon/wizard.js.map +1 -1
  47. package/dist/mcp/server.js +181 -209
  48. package/dist/mcp/server.js.map +1 -1
  49. package/dist/shared/chat-http.js +187 -108
  50. package/dist/shared/chat-http.js.map +1 -1
  51. package/dist/shared/chat-render.js +25 -34
  52. package/dist/shared/chat-render.js.map +1 -1
  53. package/dist/shared/chat-view.js +14 -2
  54. package/dist/shared/chat-view.js.map +1 -1
  55. package/dist/shared/cli-backends.js +2 -7
  56. package/dist/shared/cli-backends.js.map +1 -1
  57. package/dist/shared/config.js +26 -9
  58. package/dist/shared/config.js.map +1 -1
  59. package/dist/shared/detail-render.js +12 -6
  60. package/dist/shared/detail-render.js.map +1 -1
  61. package/dist/shared/detail-store.js +1 -1
  62. package/dist/shared/detail-store.js.map +1 -1
  63. package/dist/shared/role-render.js +55 -0
  64. package/dist/shared/role-render.js.map +1 -0
  65. package/dist/shared/role-view.js +228 -0
  66. package/dist/shared/role-view.js.map +1 -0
  67. package/dist/shared/schedule-spec.js +92 -116
  68. package/dist/shared/schedule-spec.js.map +1 -1
  69. package/dist/shared/session-label.js +103 -72
  70. package/dist/shared/session-label.js.map +1 -1
  71. package/dist/shared/std.js +3 -0
  72. package/dist/shared/std.js.map +1 -1
  73. package/dist/shared/task-file.js +120 -0
  74. package/dist/shared/task-file.js.map +1 -0
  75. package/dist/shared/trigger.js +232 -0
  76. package/dist/shared/trigger.js.map +1 -0
  77. package/dist/shared/world.js +17 -5
  78. package/dist/shared/world.js.map +1 -1
  79. package/dist/svr/index.js +67 -2
  80. package/dist/svr/index.js.map +1 -1
  81. package/hooks/pre-tool-use.sh +4 -4
  82. package/package.json +1 -2
  83. package/web/chat.css +424 -212
  84. package/web/chat.js +953 -323
  85. package/commands/wrc.md +0 -7
  86. package/dist/daemon/cc-bridge.js +0 -183
  87. package/dist/daemon/cc-bridge.js.map +0 -1
  88. package/dist/daemon/sessions.js +0 -4
  89. 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, headSep, parseTagHeader } from "../shared/session-label.js";
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, normChatName, parsePeerRef, peerAddress } from "./chat-name.js";
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 {
@@ -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
- // (up to ~110 bytes in its linked `[🧙 #tag](url)` form).
683
- const TAG_HEADER_BUDGET = 64;
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, backendName, bypassModalGuard = false) => {
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
- // 两个判据都以**输入框整块**为准 (parseInputBox), 指纹只是认不出布局时的退路:
1244
- // 1. 贴完轮询「框变了」→ 粘贴已被 TUI 消化 (折叠成占位符也算)。这是个**正**
1245
- // 信号, 只有确凿看见框仍是贴之前的样子才重贴 —— 存疑一律不重贴, 多贴一次
1246
- // 的代价 (两份提示叠进同一个输入框) 远高于少贴一次。
1247
- // 2. 回车后轮询「框回到贴之前的样子」→ 提交被吃下了。框认得出来时这条判据可
1248
- // 信, 于是允许多补一次回车 (空框上的回车在 CLI 里是 no-op)。
1249
- 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) => {
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 === preBox.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 = 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
+ }
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
- r = await loadAndPaste();
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
- 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);
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
- const waitForCleared = async (timeoutMs) => {
1432
+ /** 证人先答; 它的窗口过了还不作声, 才每轮加看一眼框。 */
1433
+ const waitForSubmit = async (timeoutMs) => {
1332
1434
  const t0 = Date.now();
1333
- while (Date.now() - t0 < timeoutMs) {
1435
+ const budget = Math.max(timeoutMs, WITNESS_ONLY_MS + 500);
1436
+ while (Date.now() - t0 < budget) {
1334
1437
  await sleep(POLL_MS);
1335
- if (await cleared())
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 waitForCleared(CLEARED_TIMEOUT_MS))
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
- const backendName = backendForPath(args.jsonlPath).name;
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 tag = tagOfKey(target);
1585
- const titlePrefix = tag ? `${labelFor(tag)} #${tag} · ` : "";
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, withTagHeader(s.target, content || " "), false);
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
- // Linked tag prefix: emoji+tag becomes a chat-detail link. Falls back to
1683
- // plain withTagHeader when no turnId is available (no active turn to link).
1684
- const linkedTagPrefix = (target, turnId) => {
1685
- if (!turnId)
1686
- return "";
1687
- const url = buildChatUrl(cfg.daemon.detailPublicBase, cfg.daemon.host, cfg.daemon.port, turnId, stripPrincipalPrefix(target));
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
- const chatId = stripPrincipalPrefix(a.target);
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 = stripPrincipalPrefix(a.target);
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
- const turnSilent = (a) => cfg.wrc.mirror.brief && cfg.wrc.mirror.chatOriginOnly && a.turnFromChat === false;
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
- 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) });
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
- 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) });
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
- // (linkedTagPrefix 读的正是 a.briefTurnId)。静默轮没有 standalone 可搭车, 那就
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 "/wrc again from same window" case; the target
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
- // /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
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 /wrc attaches capture only $TMUX_PANE.
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 /new or /mirror/attach");
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
- const charterFor = (target) => {
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: opts?.model?.trim() ?? "",
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 才是断点 —— 首次 /wrc 建会话时 prev 为空, 那不是"不连续", 是开局。
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
- // 名字解析:`daily` / `chat:wrxxx` → base principal。认不出就把已知名字一并回给
4216
- // 调用方 —— 跨 chat 出错时"我该写什么"比"你写错了"有用得多。
4217
- const resolveChatRef = (ref) => {
4218
- const base = chatBaseOf(cfg, ref);
4219
- if (base)
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 { chat, tag } = parsePeerRef(ref ?? "");
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
- if (all.includes(local))
4262
- return { ok: true, target: local, foreign: false };
4263
- const foreignMatches = all.filter((k) => tagOfKey(k) === t && baseOfKey(k) !== baseOfKey(self));
4264
- if (foreignMatches.length === 1)
4265
- return { ok: true, target: foreignMatches[0], foreign: true };
4266
- if (foreignMatches.length === 0) {
4267
- // 裸 token 正好是个 chat 名字 —— 用户/agent 想说的是"那个群",不是"那个 tag"。
4268
- // 直接给出它的会话地址,比让人再查一次 list_chats 快。
4269
- const asChat = chatBaseOf(cfg, t);
4270
- if (asChat) {
4271
- // 裸聊天名 = 那个聊天的**默认 wizard**: 它的名字就是聊天名, peerAddress
4272
- // 印出来的地址也就是这个裸名字, 必须能原样喂回来 (此前这里一律报错, 于是
4273
- // 「名册里读到的地址」与「send_peer 收得下的地址」对不上)。
4274
- if (allTargets().includes(asChat)) {
4275
- return { ok: true, target: asChat, foreign: asChat !== baseOfKey(self) };
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: `tag '#${t}' exists in ${foreignMatches.length} chats — address it in full as 'chatName#${t}' (list_chats shows the names)`,
4288
- candidates: foreignMatches.map((k) => peerAddress(cfg, self, k)),
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: tag ? labelFor(tag) : "🧙",
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 补充:其他 chat 里**当前调用方叫得动**的 session。两条
4383
- // 入选路径 —— tag 全局唯一(裸 tag 就能命中),或者它所在的 chat 有名字(全称
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
- const foreign = allTargets().filter((k) => tagOfKey(k) && baseOfKey(k) !== selfBase);
4389
- const tagCount = foreign.reduce((acc, k) => acc.set(tagOfKey(k), (acc.get(tagOfKey(k)) ?? 0) + 1), new Map());
4390
- const reachable = foreign.filter((k) => tagCount.get(tagOfKey(k)) === 1 || chatNameOf(cfg, k));
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 tmuxPaneAlive(a.tmuxPane) : false;
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: tagOfKey(target) || target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(target) });
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
- dispatch: async ({ principal, text, images, frame, streamId }) => {
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] wecom remote control not attached — run `/wrc` inside the target Claude session"), true);
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
- // Prefer the tag suffix as tmux window name when present (matches
5231
- // /new #tag behavior), fall back to the full target for untagged.
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) /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) —
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 tmuxPaneAlive(a.tmuxPane) : false;
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: tagOfKey(a.target) || a.target, cwdOverride: a.runningCwd, cli: backendForPath(a.jsonlPath).name, model: a.model || undefined, systemPrompt: charterFor(a.target) });
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;