wezard 2.5.1 → 2.6.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 (115) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CLAUDE.md +3 -0
  3. package/README.md +43 -1
  4. package/cli/wezard.sh +5 -0
  5. package/config.example.jsonc +22 -5
  6. package/dist/cli/codex-hook.js +91 -0
  7. package/dist/cli/codex-hook.js.map +1 -0
  8. package/dist/cli/codex-sync.js +64 -0
  9. package/dist/cli/codex-sync.js.map +1 -0
  10. package/dist/cli/init.js +7 -3
  11. package/dist/cli/init.js.map +1 -1
  12. package/dist/cli/sync.js +16 -5
  13. package/dist/cli/sync.js.map +1 -1
  14. package/dist/daemon/aibot-media.js +6 -2
  15. package/dist/daemon/aibot-media.js.map +1 -1
  16. package/dist/daemon/approval.js +44 -21
  17. package/dist/daemon/approval.js.map +1 -1
  18. package/dist/daemon/bot-mux.js +157 -0
  19. package/dist/daemon/bot-mux.js.map +1 -0
  20. package/dist/daemon/cc-session.js +8 -1
  21. package/dist/daemon/cc-session.js.map +1 -1
  22. package/dist/daemon/cfg-sync.js +1 -0
  23. package/dist/daemon/cfg-sync.js.map +1 -1
  24. package/dist/daemon/claim.js +45 -1
  25. package/dist/daemon/claim.js.map +1 -1
  26. package/dist/daemon/config-api.js +59 -23
  27. package/dist/daemon/config-api.js.map +1 -1
  28. package/dist/daemon/detail.js +4 -0
  29. package/dist/daemon/detail.js.map +1 -1
  30. package/dist/daemon/http.js +9 -0
  31. package/dist/daemon/http.js.map +1 -1
  32. package/dist/daemon/inbound.js +54 -28
  33. package/dist/daemon/inbound.js.map +1 -1
  34. package/dist/daemon/index.js +181 -139
  35. package/dist/daemon/index.js.map +1 -1
  36. package/dist/daemon/jobs.js +1 -1
  37. package/dist/daemon/jobs.js.map +1 -1
  38. package/dist/daemon/last-response.js +12 -6
  39. package/dist/daemon/last-response.js.map +1 -1
  40. package/dist/daemon/mirror-bridge.js +331 -148
  41. package/dist/daemon/mirror-bridge.js.map +1 -1
  42. package/dist/daemon/peers.js +12 -16
  43. package/dist/daemon/peers.js.map +1 -1
  44. package/dist/daemon/picker-policy.js +46 -0
  45. package/dist/daemon/picker-policy.js.map +1 -0
  46. package/dist/daemon/quota.js +10 -7
  47. package/dist/daemon/quota.js.map +1 -1
  48. package/dist/daemon/reach.js +5 -1
  49. package/dist/daemon/reach.js.map +1 -1
  50. package/dist/daemon/receipts.js +1 -1
  51. package/dist/daemon/receipts.js.map +1 -1
  52. package/dist/daemon/route.js +67 -26
  53. package/dist/daemon/route.js.map +1 -1
  54. package/dist/daemon/session-scan.js +31 -7
  55. package/dist/daemon/session-scan.js.map +1 -1
  56. package/dist/daemon/spawn-tmux.js +167 -73
  57. package/dist/daemon/spawn-tmux.js.map +1 -1
  58. package/dist/daemon/steward-model.js +24 -0
  59. package/dist/daemon/steward-model.js.map +1 -0
  60. package/dist/daemon/subagent-tail.js +5 -0
  61. package/dist/daemon/subagent-tail.js.map +1 -1
  62. package/dist/daemon/task-registry.js +6 -1
  63. package/dist/daemon/task-registry.js.map +1 -1
  64. package/dist/daemon/tasks.js +1 -1
  65. package/dist/daemon/tasks.js.map +1 -1
  66. package/dist/daemon/tips.js +1 -1
  67. package/dist/daemon/tips.js.map +1 -1
  68. package/dist/daemon/tmux-buffer.js +50 -0
  69. package/dist/daemon/tmux-buffer.js.map +1 -0
  70. package/dist/daemon/wizard-memory.js +12 -6
  71. package/dist/daemon/wizard-memory.js.map +1 -1
  72. package/dist/daemon/wizard.js +7 -5
  73. package/dist/daemon/wizard.js.map +1 -1
  74. package/dist/daemon/ws.js +79 -10
  75. package/dist/daemon/ws.js.map +1 -1
  76. package/dist/mcp/server.js +19 -18
  77. package/dist/mcp/server.js.map +1 -1
  78. package/dist/shared/chat-http.js +63 -29
  79. package/dist/shared/chat-http.js.map +1 -1
  80. package/dist/shared/chat-view.js +10 -3
  81. package/dist/shared/chat-view.js.map +1 -1
  82. package/dist/shared/cli-backends.js +11 -0
  83. package/dist/shared/cli-backends.js.map +1 -1
  84. package/dist/shared/codex-approval.js +57 -0
  85. package/dist/shared/codex-approval.js.map +1 -0
  86. package/dist/shared/codex-hooks.js +134 -0
  87. package/dist/shared/codex-hooks.js.map +1 -0
  88. package/dist/shared/codex.js +125 -0
  89. package/dist/shared/codex.js.map +1 -0
  90. package/dist/shared/config.js +85 -21
  91. package/dist/shared/config.js.map +1 -1
  92. package/dist/shared/detail-render.js +24 -16
  93. package/dist/shared/detail-render.js.map +1 -1
  94. package/dist/shared/detail-store.js +11 -10
  95. package/dist/shared/detail-store.js.map +1 -1
  96. package/dist/shared/modal-pane.js +19 -1
  97. package/dist/shared/modal-pane.js.map +1 -1
  98. package/dist/shared/model-tier-migration.js +43 -0
  99. package/dist/shared/model-tier-migration.js.map +1 -0
  100. package/dist/shared/model-tiers.js +35 -0
  101. package/dist/shared/model-tiers.js.map +1 -0
  102. package/dist/shared/reminder.js +19 -4
  103. package/dist/shared/reminder.js.map +1 -1
  104. package/dist/shared/session-label.js +27 -2
  105. package/dist/shared/session-label.js.map +1 -1
  106. package/dist/shared/tool-hints.js +144 -0
  107. package/dist/shared/tool-hints.js.map +1 -0
  108. package/dist/shared/workspace-trust.js +69 -0
  109. package/dist/shared/workspace-trust.js.map +1 -0
  110. package/dist/shared/world.js +5 -4
  111. package/dist/shared/world.js.map +1 -1
  112. package/dist/svr/index.js +3 -0
  113. package/dist/svr/index.js.map +1 -1
  114. package/hooks/pre-tool-use.sh +1 -1
  115. package/package.json +6 -1
@@ -1,3 +1,6 @@
1
+ import { codexSessions, codexSessionForPane } from "../shared/codex.js";
2
+ import { codexHookLaunchArgs, codexHookCommand } from "../shared/codex-hooks.js";
3
+ import { fileURLToPath } from "node:url";
1
4
  // Mirror-mode bridge.
2
5
  //
3
6
  // Strategy: a running interactive `claude` exposes no local IPC. Its only
@@ -14,31 +17,33 @@
14
17
  // while a `--resume` injection is in flight; Claude Code locks aren't strict.
15
18
  import { spawn } from "node:child_process";
16
19
  import { randomUUID } from "node:crypto";
20
+ import { pasteTmuxBuffer } from "./tmux-buffer.js";
17
21
  import { existsSync, readdirSync, readFileSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
18
22
  import { join, dirname } from "node:path";
23
+ import { newStewardSpec, resumedStewardSpec } from "./steward-model.js";
19
24
  import { expandHome, sanitizeId } from "../shared/paths.js";
20
25
  import { augmentedPath } from "../shared/exec-path.js";
21
26
  import { sleep, truncateWithCount, mapLimit } from "../shared/std.js";
22
- import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
23
- import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } from "../shared/modal-pane.js";
24
- import { bindCardChannel, hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
27
+ import { activeBackends, resolveCliBackend, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
28
+ import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, modalFingerprint, looksLikePicker } from "../shared/modal-pane.js";
29
+ import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
25
30
  import { isAutoWindowActive } from "./session-cache.js";
26
31
  import { noticeSuffixFor } from "./notices.js";
27
- import { dangerOf } from "./danger.js";
32
+ import { dispatchPickerPolicy, pickerPolicy } from "./picker-policy.js";
28
33
  import { renameWindow, runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
29
34
  import { selectEffort, selectModel } from "./model-select.js";
30
35
  import { parseEffort } from "../shared/effort.js";
31
- import { hasRegistry, markTranscript, probeOf, sessionOnPane, sessionPanes, submittedSince, watchRegistry } from "./cc-session.js";
36
+ import { hasRegistry, liveSessionPids, markTranscript, probeOf, sessionOnPane, submittedSince, watchRegistry } from "./cc-session.js";
32
37
  import { waitForIdle } from "./graph.js";
33
38
  import { wizardStore } from "./wizard.js";
34
39
  import { startSubagentWatch } from "./subagent-tail.js";
35
40
  import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
36
41
  import { errText } from "./last-response.js";
37
- import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey } from "../shared/session-label.js";
42
+ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, withTagHeader, withLinkedTagHeader, chatHead, headed, tagBadge, linkTags, parseTagHeader, MAX_BODY_LINKS, isInternalKey, sameBot } from "../shared/session-label.js";
38
43
  import { splitMarkdown } from "../shared/md-chunk.js";
39
44
  import { isWxFrame } from "../shared/wx-text.js";
40
45
  import { randomTip } from "./tips.js";
41
- import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress, stewardTierOf } from "./chat-name.js";
46
+ import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
42
47
  import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
43
48
  import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
44
49
  // PATH augmentation: launchd / systemd start the daemon
@@ -92,7 +97,9 @@ const readCwdFromJsonl = (path) => {
92
97
  // parse (wrong jsonl dialect). Only genuine discovery searches the union.
93
98
  const rankedJsonlsForCwd = (cwd, only, exclude) => projectDirsFor(expandHome(cwd))
94
99
  .filter(({ backend }) => !only || backend.name === only.name)
95
- .flatMap(({ dir }) => {
100
+ .flatMap(({ backend, dir }) => {
101
+ if (backend.name === "codex")
102
+ return codexSessions(dir, expandHome(cwd)).filter((x) => !exclude?.has(x.sessionId)).map((x) => ({ ...x, mtime: statSync(x.jsonlPath).mtimeMs }));
96
103
  let names;
97
104
  try {
98
105
  names = readdirSync(dir).filter((n) => n.endsWith(".jsonl"));
@@ -133,6 +140,8 @@ const findJsonlBySid = (sid, only) => {
133
140
  return (only ? [only] : activeBackends())
134
141
  .flatMap((b) => {
135
142
  const root = expandHome(b.projectsDir);
143
+ if (b.name === "codex")
144
+ return codexSessions(root).filter((x) => x.sessionId === sid).map((x) => x.jsonlPath);
136
145
  try {
137
146
  return readdirSync(root).map((d) => join(root, d, sidFile));
138
147
  }
@@ -719,6 +728,11 @@ const renderLine = (raw, deps) => {
719
728
  },
720
729
  });
721
730
  }
731
+ // Codex turn_context reports the real tier before any token usage.
732
+ // Forward metadata without inventing an API call or resetting context counts.
733
+ if (!u && (line.message?.model || line.effort)) {
734
+ out.push({ kind: "turn_usage", model: line.message?.model, effort: line.effort });
735
+ }
722
736
  // Terminal stop_reason → emit turn_end so onItem closes the live bubble.
723
737
  // `tool_use` is intentionally excluded — more turns will follow once the
724
738
  // tool result lands; finalizing now would split a single logical reply.
@@ -730,7 +744,7 @@ const renderLine = (raw, deps) => {
730
744
  // 且此后整轮的 tool/text 全部退化成散装气泡。终态消息里必然有 text 块 (含
731
745
  // tool_use 的消息 stop_reason 恒为 "tool_use"), 且实测终态消息从不拆出第二个
732
746
  // text 行 —— 所以"本行带 text 块"精确等价于"本消息的最后一行"。
733
- if (isFinal && blocks.some((b) => b?.type === "text"))
747
+ if (isFinal && !line.awaitTurnEnd && blocks.some((b) => b?.type === "text"))
734
748
  out.push({ kind: "turn_end" });
735
749
  // 软收口 (codebuddy): 这条消息写完了, 但说不出后面还有没有 function_call。
736
750
  else if (line.softTurnEnd)
@@ -774,6 +788,23 @@ const renderLine = (raw, deps) => {
774
788
  const TAG_HEADER_BUDGET = 256 * (2 + MAX_BODY_LINKS);
775
789
  export const startMirrorTail = (deps) => {
776
790
  const { log } = deps;
791
+ // Codex records model/effort separately from per-response usage. Keep that
792
+ // context local to this tail (never share it between sessions/normalizers).
793
+ if (backendForPath(deps.jsonlPath).name === "codex" && deps.normalizeLine) {
794
+ const normalize = deps.normalizeLine;
795
+ let model = deps.startOffset === 0 ? "" : lastModel(deps.jsonlPath);
796
+ let effort;
797
+ deps = { ...deps, normalizeLine: (raw) => {
798
+ const line = normalize(raw);
799
+ if (line?.message?.model) {
800
+ model = line.message.model;
801
+ effort = line.effort;
802
+ }
803
+ return line?.message?.usage
804
+ ? { ...line, effort, message: { ...line.message, model } }
805
+ : line;
806
+ } };
807
+ }
777
808
  // A single logical tail follows ONE sessionId whose transcript file may move
778
809
  // between sibling project dirs — Claude Code's EnterWorktree/ExitWorktree
779
810
  // rename <sid>.jsonl into/out of <cwd>/.claude/worktrees/<name>, each cwd
@@ -827,6 +858,11 @@ export const startMirrorTail = (deps) => {
827
858
  watchedPath = path;
828
859
  try {
829
860
  watcher = watch(path, { persistent: false }, () => drain());
861
+ const active = watcher;
862
+ active.on("error", (err) => {
863
+ active.close();
864
+ log.warn({ err: err.message }, "fs.watch failed; relying on poll");
865
+ });
830
866
  }
831
867
  catch (e) {
832
868
  watcher = undefined;
@@ -1100,19 +1136,19 @@ const extractPaneAssistantTail = (pane) => {
1100
1136
  // long after the pane went back to accepting input — wedging the mirror shut
1101
1137
  // permanently. Bare `-p` captures only what is currently on screen.
1102
1138
  //
1103
- // Trailing blank rows are trimmed first so the 15-row window lands on the
1104
- // bottom-most *content* (a picker's footer is its last line); 15 rows spans
1105
- // "title → options → footer" on every confirm layout we've seen.
1106
- const MODAL_SCAN_ROWS = 15;
1107
- // `screen` = 实际参与判定的那一屏文本, 一并返回给调用方做选项解析 —— 再 capture
1108
- // 一次会拿到"下一瞬间"的屏幕, 与 verdict 不同源(框可能已被本地按掉), 按键就按错了。
1139
+ // Trim trailing blank rows so the bottom 15 rows locate the active picker.
1140
+ // Keep the full current screen from that same capture for operation context;
1141
+ // a second capture might already be a different invocation. -J joins terminal
1142
+ // wraps without reading scrollback or dropping long command arguments.
1109
1143
  const detectModalPicker = async (target) => {
1110
- const r = await runTmux(["capture-pane", "-t", target, "-p"]);
1144
+ const r = await runTmux(["capture-pane", "-t", target, "-p", "-J"]);
1111
1145
  if (!r.ok)
1112
1146
  return { modal: false, screen: "" };
1113
1147
  const lines = r.stdout.replace(/\s+$/u, "").split("\n");
1114
- const screen = lines.slice(-MODAL_SCAN_ROWS).join("\n");
1115
- return { ...isModalPane(screen), screen };
1148
+ const screen = lines.join("\n");
1149
+ // Only the bottom picker may block input. Earlier completed pickers on the
1150
+ // current screen remain useful context, but must not look like a live modal.
1151
+ return { ...isModalPane(lines.slice(-15).join("\n")), screen };
1116
1152
  };
1117
1153
  // 按下确认框的第 index 号选项。Claude Code 的 picker 数字键即选即确认; CodeBuddy
1118
1154
  // 的框数字键不一定生效, 且高亮可能停在别的选项上 (实测见过默认高亮在 "Yes, and
@@ -1124,10 +1160,14 @@ const MODAL_PRESS_SETTLE_MS = 400;
1124
1160
  const MODAL_ARROW_SETTLE_MS = 150;
1125
1161
  const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
1126
1162
  const pressModalIndex = async (pane, before, index) => {
1163
+ const fingerprint = modalFingerprint(before.screen);
1164
+ const current = await detectModalPicker(pane);
1165
+ if (!current.modal || modalFingerprint(current.screen) !== fingerprint)
1166
+ return current;
1127
1167
  await runTmux(["send-keys", "-t", pane, String(index)]);
1128
1168
  await sleep(MODAL_PRESS_SETTLE_MS);
1129
1169
  let after = await detectModalPicker(pane);
1130
- if (!after.modal || after.title !== before.title)
1170
+ if (!after.modal || modalFingerprint(after.screen) !== fingerprint)
1131
1171
  return after;
1132
1172
  // 数字键没关掉框: 把高亮挪到目标项。guard 上限盖过任何确认框的选项数。
1133
1173
  for (let guard = 0; guard < 8; guard++) {
@@ -1137,7 +1177,7 @@ const pressModalIndex = async (pane, before, index) => {
1137
1177
  await runTmux(["send-keys", "-t", pane, cur < index ? "Down" : "Up"]);
1138
1178
  await sleep(MODAL_ARROW_SETTLE_MS);
1139
1179
  after = await detectModalPicker(pane);
1140
- if (!after.modal || after.title !== before.title)
1180
+ if (!after.modal || modalFingerprint(after.screen) !== fingerprint)
1141
1181
  return after;
1142
1182
  }
1143
1183
  if (highlightedIndex(after.screen) !== index)
@@ -1246,10 +1286,39 @@ const BOX_PROMPT_RE = /^\s*[❯>]\s?/u;
1246
1286
  /** 底 ruler 之下只该剩 cwd/模式两行状态; 多于此说明框到的不是输入框。 */
1247
1287
  const BOX_FOOTER_ROWS = 6;
1248
1288
  const UNKNOWN_BOX = { known: false, body: "" };
1249
- export const parseInputBox = (cap) => {
1289
+ export const parseInputBox = (cap, styledLayout = cap) => {
1250
1290
  const lines = cap.split("\n");
1251
1291
  while (lines.length && lines[lines.length - 1].trim() === "")
1252
1292
  lines.pop();
1293
+ // Codex has no rulers. Anchor the composer to its bottom shortcuts footer,
1294
+ // then use the LAST › prompt: earlier prompts belong to the transcript.
1295
+ // Requiring a status row and a blank separator avoids treating arbitrary
1296
+ // output containing › as editable input. Collapsed pastes remain visible
1297
+ // as a body change even though the original text fingerprints disappear.
1298
+ const styledLines = styledLayout.split("\n");
1299
+ const layout = styledLines.map((line) => line.replace(/\x1b\[[0-9;:]*m/gu, "").replace(OTHER_ESC_RE, ""));
1300
+ while (layout.length && !layout[layout.length - 1].trim())
1301
+ layout.pop();
1302
+ const footer = layout.length - 1;
1303
+ // Codex hides the shortcuts row while the composer contains text.
1304
+ const shortcuts = /\? for shortcuts\s*$/u.test(layout[footer] ?? "");
1305
+ if (shortcuts || (layout[footer] ?? "").includes("·")) {
1306
+ let status = shortcuts ? footer - 1 : footer;
1307
+ while (status >= 0 && !layout[status].trim())
1308
+ status--;
1309
+ if (status >= 0 && footer - status <= BOX_FOOTER_ROWS && layout[status].includes("·") && status > 0 && !layout[status - 1].trim()) {
1310
+ let prompt = status - 2;
1311
+ // Continuation rows are indented; a › inside pasted content is not a
1312
+ // new composer prompt.
1313
+ while (prompt >= 0 && !/^›(?:\s|$)/u.test(layout[prompt]))
1314
+ prompt--;
1315
+ if (prompt >= 0 && (shortcuts || /\x1b\[[0-9;:]*48[;:]/u.test(styledLines[prompt]))) {
1316
+ const body = dropDim(styledLines.slice(prompt, status).join("\n"))
1317
+ .replace(/^\s*›\s?/u, "").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
1318
+ return { known: true, body };
1319
+ }
1320
+ }
1321
+ }
1253
1322
  const rulers = lines.reduce((acc, l, i) => (BOX_RULER_RE.test(l) ? [...acc, i] : acc), []);
1254
1323
  const bottom = rulers[rulers.length - 1];
1255
1324
  const top = rulers[rulers.length - 2];
@@ -1287,10 +1356,18 @@ export const dropDim = (styled) => {
1287
1356
  };
1288
1357
  const captureUndimmed = async (target, rows) => {
1289
1358
  const r = await runTmux(["capture-pane", "-t", target, "-p", "-e", "-S", `-${rows}`]);
1290
- return r.ok ? dropDim(r.stdout) : "";
1359
+ // Negative -S includes scrollback plus the entire visible pane, not its
1360
+ // last N rows. Crop before removing dim text so row boundaries survive.
1361
+ return r.ok ? dropDim(r.stdout.replace(/\n$/u, "").split("\n").slice(-rows).join("\n")) : "";
1291
1362
  };
1292
1363
  // 24 行足够容纳展开后的多行粘贴 (折叠态只占一行)。
1293
- const readInputBox = async (target) => parseInputBox(await captureUndimmed(target, 24));
1364
+ const readPaneInput = async (target) => {
1365
+ const r = await runTmux(["capture-pane", "-t", target, "-p", "-e", "-S", "-24"]);
1366
+ const raw = r.ok ? r.stdout : "";
1367
+ const cap = dropDim(raw);
1368
+ return { cap, box: parseInputBox(cap, raw) };
1369
+ };
1370
+ const readInputBox = async (target) => (await readPaneInput(target)).box;
1294
1371
  // ── 注入门控的会话证人 (cc-session) ─────────────────────────────────
1295
1372
  // 能从会话自己写的 json 读到的判据, 就不读 pane: 就绪看注册表的 status, 提交看
1296
1373
  // 注册表的 busy 翻转或 jsonl 里新落的那一行。输入框里「贴进去没有」是唯一只有
@@ -1445,8 +1522,6 @@ const withImageRefs = (text, images) => {
1445
1522
  // 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
1446
1523
  // 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
1447
1524
  // 每次补之前再问一遍证人, 已提交就不补。
1448
- /** 具名粘贴 buffer 的序号 (见 loadAndPaste)。 */
1449
- let pasteSeq = 0;
1450
1525
  const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
1451
1526
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1452
1527
  // started, transcript still loading): extended timings — bracketed-paste
@@ -1484,29 +1559,16 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1484
1559
  const baseBody = alreadyTyped ? "" : preBox.body;
1485
1560
  /** 框里还是贴之前那样 (或彻底空了) = 我们的文本不在框里。 */
1486
1561
  const boxIntact = (b) => boxVerify && b.known && (b.body === "" || b.body === baseBody);
1487
- const loadAndPaste = async () => {
1488
- // stdin variant of the shared exec path — it carries the same hard timeout,
1489
- // which a hand-rolled spawn here did not.
1490
- // 每次注入用自己的具名 buffer: 不同 pane 的注入并发时, 共用 tmux 的默认 buffer 会让
1491
- // 一方 load 之后、paste 之前被另一方覆盖 —— 两段文本互换 pane (实测: 同一毫秒投的两份
1492
- // 回执, 发给 A 的进了 B)。同一 pane 的串行管不到跨 pane。
1493
- const buf = `wezard-${process.pid}-${++pasteSeq}`;
1494
- const loaded = await runTmux(["load-buffer", "-b", buf, "-"], { stdin: text });
1495
- if (!loaded.ok)
1496
- return { ok: false, reason: `tmux load-buffer failed: ${loaded.stderr.slice(-200) || loaded.code}` };
1497
- const pasted = await runTmux(["paste-buffer", "-b", buf, "-p", "-d", "-t", target]);
1498
- if (!pasted.ok)
1499
- return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
1500
- return { ok: true };
1501
- };
1562
+ const loadAndPaste = () => pasteTmuxBuffer(target, text);
1502
1563
  // 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
1503
1564
  // 头部可能在框顶, 所以窗口要宽)。
1504
1565
  const sawLanded = async (timeoutMs) => {
1505
1566
  const t0 = Date.now();
1506
1567
  while (Date.now() - t0 < timeoutMs) {
1507
- const cap = await captureUndimmed(target, 24);
1508
- const box = parseInputBox(cap);
1509
- if (boxVerify && box.known ? !boxIntact(box) : Boolean(headFp) && stripWs(cap).includes(headFp))
1568
+ const { cap, box } = await readPaneInput(target);
1569
+ // A redraw can hide the baseline. Once the composer reappears, its
1570
+ // nonempty body also proves landing of a collapsed paste.
1571
+ if (box.known ? (boxVerify ? !boxIntact(box) : box.body !== "") : Boolean(headFp) && stripWs(cap).includes(headFp))
1510
1572
  return true;
1511
1573
  await sleep(POLL_MS);
1512
1574
  }
@@ -1519,6 +1581,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1519
1581
  return Boolean(tailFp) && stripWs(pane).includes(tailFp);
1520
1582
  };
1521
1583
  let pasteSeen = alreadyTyped;
1584
+ let pastedAt = Date.now();
1522
1585
  if (alreadyTyped) {
1523
1586
  log.warn({ target, headFp }, "mirror inject: 输入框里已有这段文本 (上次未提交) — 不重贴, 直接提交");
1524
1587
  }
@@ -1526,7 +1589,11 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1526
1589
  const r = await loadAndPaste();
1527
1590
  if (!r.ok)
1528
1591
  return r;
1529
- pasteSeen = await sawLanded(PASTE_VERIFY_MS);
1592
+ pastedAt = Date.now();
1593
+ // An unknown layout cannot verify collapsed text by fingerprint. Bound
1594
+ // that inconclusive probe by the catch-up budget instead of waiting 6s
1595
+ // and then sleeping another 2.5s before the first Enter.
1596
+ pasteSeen = await sawLanded(boxVerify ? PASTE_VERIFY_MS : POST_PASTE_SETTLE_FALLBACK_MS);
1530
1597
  }
1531
1598
  if (!pasteSeen) {
1532
1599
  const box = await readInputBox(target);
@@ -1540,7 +1607,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1540
1607
  log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 粘贴未验证但输入框非空 — 首次粘贴已落地, 不重贴");
1541
1608
  pasteSeen = true;
1542
1609
  }
1543
- else {
1610
+ else if (boxVerify && box.known) {
1544
1611
  log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 输入框确认为空, 重贴一次");
1545
1612
  await sleep(RETRY_SETTLE_MS);
1546
1613
  if (submitted())
@@ -1548,8 +1615,14 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1548
1615
  const r = await loadAndPaste();
1549
1616
  if (!r.ok)
1550
1617
  return r;
1618
+ pastedAt = Date.now();
1551
1619
  pasteSeen = await sawLanded(PASTE_VERIFY_MS);
1552
1620
  }
1621
+ else {
1622
+ // Missing fingerprints are not proof of an empty composer: other TUIs
1623
+ // may collapse pasted text. Never duplicate a paste on that evidence.
1624
+ log.warn({ target, headFp }, "mirror inject: 输入框布局未知, 不重贴未确认的粘贴");
1625
+ }
1553
1626
  }
1554
1627
  // 粘贴落定 = 框里是我们的内容, 且连续两次读到同一个样子 —— TUI 已把整段粘贴
1555
1628
  // (bracketed-paste end 在内) 消化完, 紧跟的回车不会被并进粘贴。实测冷启动的
@@ -1568,10 +1641,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1568
1641
  // Bracketed-paste end + TUI catch-up. 框认不出来 / 粘贴没被看见时没有条件可查,
1569
1642
  // 维持固定等待 (fresh respawn: claude --resume is still loading the transcript)。
1570
1643
  if (!alreadyTyped) {
1571
- if (pasteSeen && boxVerify)
1644
+ if (pasteSeen)
1572
1645
  await pasteSettled(POST_PASTE_SETTLE_MS);
1573
1646
  else
1574
- await sleep(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
1647
+ await sleep(Math.max(0, POST_PASTE_SETTLE_FALLBACK_MS - (Date.now() - pastedAt)));
1575
1648
  }
1576
1649
  const sendEnter = async () => {
1577
1650
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
@@ -1579,7 +1652,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1579
1652
  };
1580
1653
  const cleared = async () => {
1581
1654
  const box = await readInputBox(target);
1582
- return boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
1655
+ return box.known ? (boxVerify ? boxIntact(box) : box.body === "") : !(await inputBoxStillHasTail());
1583
1656
  };
1584
1657
  /** 证人先答; 它的窗口过了还不作声, 才每轮加看一眼框。 */
1585
1658
  const waitForSubmit = async (timeoutMs) => {
@@ -1612,8 +1685,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1612
1685
  return { ok: true }; // 迟到的确认 —— 补的这一下会落在空框上, 省了
1613
1686
  log.warn({ target, tailFp, attempt: i + 1 }, "mirror inject: 回车后输入框未清空, 再补一次");
1614
1687
  }
1615
- if (i === 0)
1688
+ if (i === 0) {
1616
1689
  enterAt = Date.now();
1690
+ log.info({ target, pasteToEnterMs: enterAt - pastedAt, pasteSeen, boxTrusted, freshSpawn }, "mirror inject: first Enter");
1691
+ }
1617
1692
  const e = await sendEnter();
1618
1693
  if (!e.ok)
1619
1694
  return e;
@@ -1628,14 +1703,42 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1628
1703
  log.warn({ target, tailFp, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: clear not observed, trusting submit");
1629
1704
  return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
1630
1705
  };
1631
- const injectViaSpawn = (args) => {
1706
+ const injectViaSpawn = async (args) => {
1632
1707
  const { text, images = [], cfg, log, sessionId, jsonlPath } = args;
1633
- const bin = backendForPath(jsonlPath).bin;
1708
+ const backend = backendForPath(jsonlPath);
1709
+ const bin = backend.bin;
1710
+ let cwd = expandHome(cfg.wrc.cwd);
1711
+ let codexArgs = [];
1712
+ if (backend.name === "codex") {
1713
+ try {
1714
+ const fd = openSync(jsonlPath, "r");
1715
+ let head;
1716
+ try {
1717
+ const buf = Buffer.alloc(256 * 1024);
1718
+ head = buf.subarray(0, readSync(fd, buf, 0, buf.length, 0)).toString("utf8");
1719
+ }
1720
+ finally {
1721
+ closeSync(fd);
1722
+ }
1723
+ const meta = JSON.parse(head.split("\n", 1)[0]).payload;
1724
+ if (typeof meta?.cwd === "string")
1725
+ cwd = meta.cwd;
1726
+ const hookEntry = fileURLToPath(new URL("../cli/codex-hook.js", import.meta.url));
1727
+ const { loadConfig } = await import("../shared/config.js");
1728
+ const command = codexHookCommand(loadConfig().sourcePath, `http://127.0.0.1:${cfg.daemon.port}`, hookEntry);
1729
+ codexArgs = await codexHookLaunchArgs(bin, cwd, command, cfg.approval.hookTimeoutSec + 10, cfg.approval.enabled);
1730
+ }
1731
+ catch (e) {
1732
+ return { ok: false, reason: `Codex resume setup failed: ${e.message}` };
1733
+ }
1734
+ }
1634
1735
  // Spawn-mode (no live TTY) can't do clipboard+C-v — fall back to `@<path>`,
1635
1736
  // which Claude parses at submit time and inlines as image content blocks
1636
1737
  // without a model-decided Read tool turn.
1637
1738
  const finalText = withImageRefs(text, images);
1638
- const cliArgs = [
1739
+ const cliArgs = backend.name === "codex" ? ["exec", "resume", "--json", ...codexArgs,
1740
+ ...cfg.wrc.extraArgs.filter((arg) => !arg.startsWith("--disallowedTools")),
1741
+ ...images.flatMap((path) => ["--image", path]), sessionId, text] : [
1639
1742
  "-p",
1640
1743
  finalText,
1641
1744
  "--resume",
@@ -1648,7 +1751,7 @@ const injectViaSpawn = (args) => {
1648
1751
  log.info({ sessionId, bin, len: text.length }, "mirror inject");
1649
1752
  return new Promise((resolve) => {
1650
1753
  const proc = spawn(bin, cliArgs, {
1651
- cwd: expandHome(cfg.wrc.cwd),
1754
+ cwd,
1652
1755
  env: { ...process.env, PATH: augmentedPath(process.env.PATH) },
1653
1756
  stdio: ["ignore", "ignore", "pipe"],
1654
1757
  });
@@ -1693,6 +1796,30 @@ const INJECT_JOB_TIMEOUT_MS = 90_000;
1693
1796
  /** 没有绑定的 pane 静默多久才算孤儿。刚 spawn、还没 attach 的 pane (并发 fan-out 时
1694
1797
  * 别人的 sweep 会撞见它) 在这段时间里一直有输出, 远大于一次 spawn → attach 的耗时。 */
1695
1798
  const ORPHAN_GRACE_MS = 10 * 60_000;
1799
+ const psSnap = () => new Promise((resolve) => {
1800
+ const p = spawn("ps", ["-axo", "pid=,ppid=,etimes="], { env: { ...process.env, PATH: augmentedPath(process.env.PATH) }, stdio: ["ignore", "pipe", "ignore"] });
1801
+ let out = "";
1802
+ p.stdout?.on("data", (c) => (out += c.toString("utf8")));
1803
+ const done = () => {
1804
+ const kids = new Map();
1805
+ const etimes = new Map();
1806
+ for (const line of out.split("\n")) {
1807
+ const [pid, ppid, et] = line.trim().split(/\s+/).map(Number);
1808
+ if (!pid || !ppid)
1809
+ continue;
1810
+ const arr = kids.get(ppid);
1811
+ if (arr)
1812
+ arr.push(pid);
1813
+ else
1814
+ kids.set(ppid, [pid]);
1815
+ if (et !== undefined && !Number.isNaN(et))
1816
+ etimes.set(pid, et);
1817
+ }
1818
+ resolve({ kids, etimes });
1819
+ };
1820
+ p.on("close", done);
1821
+ p.on("error", () => resolve({ kids: new Map(), etimes: new Map() }));
1822
+ });
1696
1823
  /** Drop a session's queue chain. A hung job can't be cancelled (it's parked in a
1697
1824
  * tmux read), but detaching the chain means the NEXT message doesn't inherit
1698
1825
  * its deadlock. Pair with an injectGen bump so the zombie can't act when it
@@ -2401,11 +2528,11 @@ export const startMirror = (deps) => {
2401
2528
  if (!env)
2402
2529
  return undefined;
2403
2530
  const home = baseOfKey(a.target);
2404
- const from = env.kind === "peer" ? { kind: "peer", from: wizardStore()?.byName(stripSigil(env.from))?.target, ...(env.private ? {} : { public: true }), ...(env.receipt ? { receipt: true } : {}), ...(env.status ? { status: env.status } : {}), ...(env.turn ? { turn: env.turn } : {}) }
2531
+ const from = env.kind === "peer" ? { kind: "peer", from: wizardStore()?.byName(stripSigil(env.from))?.target, ...(env.private ? {} : { public: true }), ...(env.receipt ? { receipt: true } : {}), ...(env.status ? { status: env.status } : {}), ...(env.turn ? { turn: env.turn } : {}), ...(env.asker ? { asker: env.asker } : {}) }
2405
2532
  : env.kind === "task" ? { kind: "task", taskId: env.from.replace(/^定时 /, "") }
2406
2533
  : undefined;
2407
2534
  return {
2408
- channel: env.private ? "" : (env.chat && chatBaseOf(cfg, env.chat)) || home,
2535
+ channel: env.private ? "" : env.chatKey || (env.chat && chatBaseOf(cfg, env.chat)) || home,
2409
2536
  speaker: env.kind === "human" ? `user:${env.from}` : undefined,
2410
2537
  from,
2411
2538
  query: t.text,
@@ -2464,8 +2591,6 @@ export const startMirror = (deps) => {
2464
2591
  const isPrivate = (a) => a.channel === "";
2465
2592
  /** 本轮回复该推去的 chatId。 */
2466
2593
  const replyChatId = (a) => stripPrincipalPrefix(a.channel || a.target);
2467
- // 审批/提问卡同样回本轮的频道; 私聊轮 ("") 的卡回 home —— 要点它的人总得在某个群里。
2468
- bindCardChannel((key) => byTarget.get(key)?.channel || "");
2469
2594
  // 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
2470
2595
  // 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
2471
2596
  const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
@@ -2520,7 +2645,7 @@ export const startMirror = (deps) => {
2520
2645
  a.pendingBriefQuery = undefined;
2521
2646
  a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
2522
2647
  consumeChannel(a, true, undefined);
2523
- recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: at, ...channelFields(a) });
2648
+ recordTurnStart({ id: q.turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: s.query.trim() || undefined, createdAt: at, ...channelFields(a) });
2524
2649
  if (a.briefTurnId)
2525
2650
  closeBriefTurn(a);
2526
2651
  openBriefTurn(a, q);
@@ -2585,7 +2710,7 @@ export const startMirror = (deps) => {
2585
2710
  const queued = !isSlash && idleVerdict(a.target) === false;
2586
2711
  pushSeal(a, seal);
2587
2712
  if (queued)
2588
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, channel: channel ?? baseOfKey(a.target), speaker });
2713
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: userQuery.trim() || undefined, channel: channel ?? baseOfKey(a.target), speaker });
2589
2714
  else
2590
2715
  activateDispatch(a, seal);
2591
2716
  // 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
@@ -2657,7 +2782,7 @@ export const startMirror = (deps) => {
2657
2782
  const fresh = consumeFrom(a);
2658
2783
  const opened = consumeChannel(a, fromCli, fresh);
2659
2784
  const from = fresh ?? a.convFrom;
2660
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), cwd: a.runningCwd || undefined, userQuery: query || opened || undefined, origin: consumeOrigin(a), from, ...channelFields(a), ...(query && at ? { createdAt: at } : {}) });
2785
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, cwd: a.runningCwd || undefined, userQuery: query || opened || undefined, origin: consumeOrigin(a), from, ...channelFields(a), ...(query && at ? { createdAt: at } : {}) });
2661
2786
  a.briefTurnId = turnId;
2662
2787
  a.briefBubble = undefined;
2663
2788
  a.briefIsSlash = false;
@@ -3164,7 +3289,7 @@ export const startMirror = (deps) => {
3164
3289
  if (!a.keepaliveTurnId) {
3165
3290
  const turnId = newTurnId();
3166
3291
  a.keepaliveTurnId = turnId;
3167
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
3292
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
3168
3293
  }
3169
3294
  };
3170
3295
  // A keepalive ping turn is swallowed wholesale — the reply content doesn't
@@ -3817,8 +3942,9 @@ export const startMirror = (deps) => {
3817
3942
  // with the primary dialect would report a phantom drift for every
3818
3943
  // session belonging to a non-primary CLI.
3819
3944
  const paneDir = projectDirFor(jsonlAbs, expandHome(paneCwd));
3820
- if (paneDir !== dirname(jsonlAbs)) {
3821
- const live = liveSessionForCwd(paneCwd, backendForPath(jsonlAbs), sidsClaimedByOthers(principal));
3945
+ const codexPane = backendForPath(jsonlAbs).name === "codex" ? codexSessionForPane(livePane) : undefined;
3946
+ if (codexPane ? codexPane.sessionId !== rec.sessionId : backendForPath(jsonlAbs).name !== "codex" && paneDir !== dirname(jsonlAbs)) {
3947
+ const live = codexPane?.cwd === expandHome(paneCwd) ? codexPane : backendForPath(jsonlAbs).name === "codex" ? undefined : liveSessionForCwd(paneCwd, backendForPath(jsonlAbs), sidsClaimedByOthers(principal));
3822
3948
  if (live && live.sessionId !== rec.sessionId) {
3823
3949
  log.info({ principal, from: rec.sessionId, to: live.sessionId, paneCwd }, "mirror restore: pane in worktree, re-homed to live session");
3824
3950
  rec.sessionId = live.sessionId;
@@ -4131,6 +4257,8 @@ export const startMirror = (deps) => {
4131
4257
  // `exclusive` (the /clear flavor) refuses to claim on ambiguous evidence —
4132
4258
  // see claimIsAttributable.
4133
4259
  const startMigrationWatcher = (a, baseline, isChild, startOffset, exclusive) => {
4260
+ if (backendForPath(a.jsonlPath).name === "codex")
4261
+ return;
4134
4262
  if (a.migrationWatcher)
4135
4263
  a.migrationWatcher.cancel(); // re-armed (e.g. /clear twice, or respawn during a pending watch)
4136
4264
  const projectDir = dirname(a.jsonlPath);
@@ -4230,6 +4358,8 @@ export const startMirror = (deps) => {
4230
4358
  // narrows to transcripts born after the clear, which makes a short
4231
4359
  // fingerprint safe enough to accept.
4232
4360
  const armSilentForkRebind = (a, text) => {
4361
+ if (backendForPath(a.jsonlPath).name === "codex")
4362
+ return; // SessionStart owns exact pane identity.
4233
4363
  const pendingClear = a.clearRebind;
4234
4364
  if (pendingClear) {
4235
4365
  // Hand over: the fingerprint is pane-certain, the dir-scan is not.
@@ -4375,8 +4505,16 @@ export const startMirror = (deps) => {
4375
4505
  const cwd = paneCwd.get(a.tmuxPane);
4376
4506
  if (!cwd)
4377
4507
  continue; // pane gone
4508
+ if (backendForPath(a.jsonlPath).name === "codex") {
4509
+ const registered = codexSessionForPane(a.tmuxPane);
4510
+ if (registered && registered.cwd === expandHome(cwd) && registered.sessionId !== a.sessionId && !sidsClaimedByOthers(a.target).has(registered.sessionId)) {
4511
+ a.runningCwd = registered.cwd;
4512
+ migrateAttachment(a, registered.sessionId, registered.jsonlPath);
4513
+ }
4514
+ continue;
4515
+ }
4378
4516
  const paneDir = projectDirFor(a.jsonlPath, expandHome(cwd));
4379
- if (paneDir === dirname(a.jsonlPath))
4517
+ if (backendForPath(a.jsonlPath).name === "codex" ? expandHome(cwd) === a.runningCwd : paneDir === dirname(a.jsonlPath))
4380
4518
  continue; // same project dir — no drift
4381
4519
  const live = liveSessionForCwd(cwd, backendForPath(a.jsonlPath), sidsClaimedByOthers(a.target));
4382
4520
  if (!live || live.sessionId === a.sessionId)
@@ -4468,14 +4606,11 @@ export const startMirror = (deps) => {
4468
4606
  return undefined;
4469
4607
  }
4470
4608
  };
4471
- /** 管家该跑的那一档 (模型 + effort); 不是管家 = undefined。 */
4472
- const stewardSpec = (target) => {
4473
- const tier = stewardTierOf(cfg, target);
4474
- return tier ? { model: cfg.models.tiers[tier].model || undefined, effort: cfg.models.tiers[tier].effort } : undefined;
4475
- };
4476
4609
  /** 死 pane 重生 (`--resume` 同一个会话) 的模型: 绑定里记着的优先 —— set_model 换上的也记在那;
4477
4610
  * 没记 (老绑定 / CLI 默认) 的管家补上管家档。 */
4478
- const respawnSpec = (a) => a.model ? { model: a.model, effort: parseEffort(a.effort) } : stewardSpec(a.target) ?? { effort: parseEffort(a.effort) };
4611
+ const respawnSpec = (a) => {
4612
+ return resumedStewardSpec(cfg, a.target, backendForPath(a.jsonlPath).name, a);
4613
+ };
4479
4614
  const newSession = async (target, windowName, cli, opts) => {
4480
4615
  const prev = byTarget.get(target);
4481
4616
  // Resolution precedence (all chat-scoped except the running-cwd fallback):
@@ -4497,22 +4632,16 @@ export const startMirror = (deps) => {
4497
4632
  (rec?.cwd?.trim()) ||
4498
4633
  chat.running ||
4499
4634
  expandedDefaultCwd;
4500
- // Inherit the outgoing session's CLI when the caller didn't name one: a
4501
- // `/new` (or a /clear upgraded to /new) on a codebuddy-bound chat must stay
4502
- // on codebuddy rather than silently reverting to `defaultCli`. Inheritance
4503
- // is chat-scoped like cwd — a FIRST `/new #tag` has no record of its own,
4504
- // so it falls back to the base session's binding instead of `defaultCli`
4505
- // (otherwise a tagged sibling silently forks onto a different CLI).
4635
+ // Fresh panes always use the configured backend, regardless of old bindings
4636
+ // or per-call / tier CLI values. Keep the old path only for model/effort reset.
4637
+ const boundPath = prev?.jsonlPath ?? rec?.jsonlPath;
4506
4638
  const base = baseOfKey(target);
4507
- const baseBound = base === target ? undefined : byTarget.get(base)?.jsonlPath ?? deps.store.get(base)?.jsonlPath;
4508
- const boundPath = prev?.jsonlPath ?? rec?.jsonlPath ?? baseBound;
4509
- const effCli = cli ?? (boundPath ? backendForPath(expandHome(boundPath)).name : undefined);
4639
+ const effCli = resolveCliBackend(cfg.wrc).name;
4510
4640
  // 管家 (群的默认会话) 新起的会话按管家档起 (models.steward / chatPolicy), 档压过绑定里记下的
4511
4641
  // effort —— `/new`、换目录、交接重开都是让改过的档位生效的那一下。点名了模型就整档让开。
4512
4642
  // 已在跑的管家不动 (换模型会让缓存整份重读); 死 pane 重生见 respawnSpec。
4513
- const steward = opts?.model?.trim() ? undefined : stewardSpec(target);
4514
- const effModel = opts?.model?.trim() || steward?.model || undefined;
4515
- const effEffort = opts?.effort ?? steward?.effort ?? parseEffort(prev?.effort ?? deps.store.get(target)?.effort);
4643
+ const backendChanged = Boolean(boundPath && effCli && backendForPath(expandHome(boundPath)).name !== effCli);
4644
+ const { model: effModel, effort: effEffort } = newStewardSpec(cfg, target, effCli, opts, backendChanged ? undefined : prev?.effort ?? deps.store.get(target)?.effort);
4516
4645
  if (prev?.tmuxPane) {
4517
4646
  // Best-effort kill; ignore errors (pane may already be dead).
4518
4647
  void runTmux(["kill-pane", "-t", prev.tmuxPane]);
@@ -4531,7 +4660,7 @@ export const startMirror = (deps) => {
4531
4660
  cli: effCli,
4532
4661
  model: effModel,
4533
4662
  effort: effEffort,
4534
- systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
4663
+ systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff, cli: effCli }),
4535
4664
  }).catch((e) => { inFlightSids.delete(freshSid); throw e; });
4536
4665
  if (!r.ok) {
4537
4666
  inFlightSids.delete(freshSid);
@@ -4695,14 +4824,14 @@ export const startMirror = (deps) => {
4695
4824
  rememberInject(hello);
4696
4825
  const boot = await inject({
4697
4826
  text: hello, images: [], cfg, log: lg,
4698
- sessionId: parent.sessionId, jsonlPath: parent.jsonlPath,
4827
+ sessionId: parentCli === "codex" ? r.sessionId : parent.sessionId, jsonlPath: parentCli === "codex" ? r.jsonlPath : parent.jsonlPath,
4699
4828
  tmuxTarget: r.tmuxPane, freshSpawn: true,
4700
4829
  });
4701
4830
  if (!boot.ok) {
4702
4831
  await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
4703
4832
  return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
4704
4833
  }
4705
- const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
4834
+ const fork = parentCli === "codex" ? { sessionId: r.sessionId, jsonlPath: r.jsonlPath } : await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
4706
4835
  homeDir: backendForPath(parent.jsonlPath).homeDir, pane: r.tmuxPane ?? "", parentSid: parent.sessionId,
4707
4836
  });
4708
4837
  // 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
@@ -4921,14 +5050,16 @@ export const startMirror = (deps) => {
4921
5050
  // `daily#fix` 改名前的老写法 —— 名字取自 tag 迁移而来, 照旧认得。
4922
5051
  const resolvePeerTag = (self, ref) => {
4923
5052
  const raw = stripSigil(ref);
4924
- const all = allTargets();
5053
+ // 多机器人隔离: 别的机器人名下的 wizard 在这里与「不存在」同形 —— 不说它在、不进 candidates。
5054
+ // 单机器人时 sameBot 恒真, 这一行什么也不滤。
5055
+ const all = allTargets().filter((k) => sameBot(self, k));
4925
5056
  const hit = (target) => ({ ok: true, target, foreign: baseOfKey(target) !== baseOfKey(self) });
4926
5057
  if (!raw)
4927
5058
  return hit(baseOfKey(self));
4928
5059
  if (/^(user|chat|group):/.test(raw)) {
4929
5060
  return all.includes(raw) ? hit(raw) : { ok: false, reason: `no running session '${raw}'` };
4930
5061
  }
4931
- const named = wizardStore()?.byName(raw)?.target;
5062
+ const named = ((t) => (t && sameBot(self, t) ? t : undefined))(wizardStore()?.byName(raw)?.target);
4932
5063
  if (named && all.includes(named))
4933
5064
  return hit(named);
4934
5065
  if (named)
@@ -5121,7 +5252,7 @@ export const startMirror = (deps) => {
5121
5252
  const foreignPeers = async (self) => {
5122
5253
  const selfBase = baseOfKey(self);
5123
5254
  // 只列活着的 / 有身份的 —— 冷绑定可能有几百个, 每个探活是两次 tmux。
5124
- const foreign = allTargets().filter((k) => baseOfKey(k) !== selfBase && (byTarget.has(k) || !!wizardStore()?.get(k)));
5255
+ const foreign = allTargets().filter((k) => baseOfKey(k) !== selfBase && sameBot(self, k) && (byTarget.has(k) || !!wizardStore()?.get(k)));
5125
5256
  return (await Promise.all(foreign.map((t) => peerInfoOf(t, self)))).sort(byRecent);
5126
5257
  };
5127
5258
  // peerInfoOf 每个 target 要两次 tmux (pane 存活 + capture tail)。跑了几个月的
@@ -5255,7 +5386,7 @@ export const startMirror = (deps) => {
5255
5386
  if (!a.keepaliveTurnId) {
5256
5387
  const turnId = newTurnId();
5257
5388
  a.keepaliveTurnId = turnId;
5258
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
5389
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
5259
5390
  }
5260
5391
  };
5261
5392
  let keepaliveTicking = false;
@@ -5376,10 +5507,10 @@ export const startMirror = (deps) => {
5376
5507
  // 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
5377
5508
  // 里经 ToolSearch→DeferExecuteTool 派发的 MCP 工具)。hook 链路对这类调用完全
5378
5509
  // 失明 —— daemon 零日志、无卡可发, pane 就地阻塞。解法与 askq/plan 同源:
5379
- // mirror 读屏兜底, 按三件套形状 (pickAutoAllowAnswer) 认权限确认框, 分三路:
5380
- // - danger.skipAll 开 → 代按一次性裸 "Yes" (放宽静态权限的变体永不自动选中);
5381
- // - 本 chat 的 ⏱自动窗口内、且内层工具不踩危险名单 → 同上代按;
5382
- // - 否则 → 把框转成标准审批卡发到本 chat (runMirrorPickerFlow), 点击后代按
5510
+ // mirror 读屏兜底, 按三件套形状 (pickAutoAllowAnswer) 认权限确认框:
5511
+ // - 上下文完整且现有工具策略放行 → 只代按一次性裸 Yes;
5512
+ // - denyRules 命中 → 拒绝; 未知参数 / askRules / 强制审批不吃窗口;
5513
+ // - 其它 → 把框转成标准审批卡发到本 chat (runMirrorPickerFlow), 点击后代按
5383
5514
  // 对应选项; 本地先按掉则 moot 作废卡。代按/发卡都发 chat 回执留痕。
5384
5515
  // WEZARD_PICKER_SENTINEL=0 整体关闭。
5385
5516
  let pickerTicking = false;
@@ -5393,6 +5524,10 @@ export const startMirror = (deps) => {
5393
5524
  for (const a of byTarget.values()) {
5394
5525
  if (!a.tmuxPane || a.migrationWatcher)
5395
5526
  continue;
5527
+ // Codex PermissionRequest has its own independent native gate. Screen
5528
+ // options, tool windows and skipAll never authorize its sandbox.
5529
+ if (cliOf(a) === "codex")
5530
+ continue;
5396
5531
  if (!(await tmuxPaneAlive(a.tmuxPane)))
5397
5532
  continue;
5398
5533
  const v = await detectModalPicker(a.tmuxPane);
@@ -5423,48 +5558,54 @@ export const startMirror = (deps) => {
5423
5558
  continue;
5424
5559
  }
5425
5560
  const ctx = parseConfirmContext(v.screen);
5426
- const danger = dangerOf(cfg, ctx.toolName, ctx.toolInput);
5427
- // 自动代按的两个授权源。窗口路径要过危险名单 (窗口语义不覆盖 mustCard);
5428
- // skipAll 本就压过危险名单 (与 daemon 审批链一致), 不检。
5429
- const auto = cfg.approval.danger.skipAll
5430
- ? "skipAll"
5431
- : isAutoWindowActive(a.target) && !danger ? "⏱自动窗口" : undefined;
5432
- if (auto) {
5433
- const after = await pressModalIndex(a.tmuxPane, v, pick.index);
5434
- if (after.modal) {
5435
- // 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
5436
- a.pickerSentinelFp = after.screen;
5437
- log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
5438
- continue;
5439
- }
5440
- a.pickerSentinelFp = undefined;
5441
- log.info({ target: a.target, title: v.title, via: auto, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
5442
- sendStandalone(a, `[mirror] 🔓 ${auto} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
5443
- continue;
5561
+ const policy = pickerPolicy(cfg, cliOf(a), ctx, isAutoWindowActive(a.target));
5562
+ if (policy.decision === "leave") {
5563
+ log.info({ target: a.target, reason: policy.reason }, "picker sentinel: independent approval left on CLI");
5444
5564
  }
5445
- // 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
5446
- // 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
5447
- log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, danger: danger?.rule }, "picker sentinel: forwarding native picker as approval card");
5448
- runMirrorPickerFlow({
5449
- log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
5450
- client,
5451
- cfg,
5452
- sessionId: a.sessionId,
5453
- chatKey: a.target,
5454
- cwd: a.runningCwd,
5455
- jsonlPath: a.jsonlPath,
5456
- toolName: ctx.toolName,
5457
- toolInput: ctx.toolInput,
5458
- agent: ctx.agent,
5459
- danger: danger?.rule,
5460
- voteTimeoutMs: cfg.approval.longPollSec * 1000,
5461
- press: (what) => pressNativePicker(a, ctx.toolName, what),
5462
- }).then((sent) => {
5463
- // 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
5464
- // 否则这只框会被指纹永久静默。
5465
- if (!sent)
5565
+ await dispatchPickerPolicy(policy, {
5566
+ deny: async () => {
5567
+ const result = await pressNativePicker(a, modalFingerprint(v.screen), "no");
5568
+ log.info({ target: a.target, reason: policy.reason, result }, "picker sentinel: deny rule");
5569
+ sendStandalone(a, `[mirror] 🚫 ${policy.reason},${result.ok ? "已拒绝 CLI 确认" : "确认框已变更,请回 CLI 处理"}`);
5570
+ },
5571
+ allow: async () => {
5572
+ const after = await pressModalIndex(a.tmuxPane, v, pick.index);
5573
+ if (after.modal) {
5574
+ // 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
5575
+ a.pickerSentinelFp = after.screen;
5576
+ log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
5577
+ return;
5578
+ }
5466
5579
  a.pickerSentinelFp = undefined;
5467
- }).catch((e) => log.warn({ err: e.message, target: a.target }, "mirror picker flow failed"));
5580
+ log.info({ target: a.target, title: v.title, via: policy.reason, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
5581
+ sendStandalone(a, `[mirror] 🔓 ${policy.reason} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
5582
+ },
5583
+ manual: () => {
5584
+ // 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
5585
+ // 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
5586
+ log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, reason: policy.reason }, "picker sentinel: forwarding native picker as approval card");
5587
+ runMirrorPickerFlow({
5588
+ log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
5589
+ client,
5590
+ cfg,
5591
+ sessionId: a.sessionId,
5592
+ chatKey: a.target,
5593
+ cwd: a.runningCwd,
5594
+ jsonlPath: a.jsonlPath,
5595
+ toolName: ctx.toolName,
5596
+ toolInput: ctx.toolInput,
5597
+ agent: ctx.agent,
5598
+ danger: policy.forceSingle,
5599
+ voteTimeoutMs: cfg.approval.longPollSec * 1000,
5600
+ press: (what) => pressNativePicker(a, modalFingerprint(v.screen), what),
5601
+ }).then((sent) => {
5602
+ // 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
5603
+ // 否则这只框会被指纹永久静默。
5604
+ if (!sent)
5605
+ a.pickerSentinelFp = undefined;
5606
+ }).catch((e) => log.warn({ err: e.message, target: a.target }, "mirror picker flow failed"));
5607
+ },
5608
+ });
5468
5609
  }
5469
5610
  }
5470
5611
  catch (e) {
@@ -5478,15 +5619,19 @@ export const startMirror = (deps) => {
5478
5619
  // 审批卡点击后的代按回调。点击与本地按键之间隔着人的反应时间, 必须重读屏并
5479
5620
  // 校验仍是**同一个调用**的确认框 —— 期间本地按掉旧框又弹了新框的话, 盲按就是
5480
5621
  // 替用户批准了他没看过的东西。
5481
- const pressNativePicker = async (a, expectToolName, what) => {
5622
+ const pressNativePicker = async (a, expectFingerprint, what) => {
5482
5623
  if (!a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
5483
5624
  return { ok: false, reason: "pane 已不在" };
5625
+ if (cliOf(a) === "codex")
5626
+ return { ok: false, reason: "Codex 原生沙箱审批必须单独处理" };
5484
5627
  const v = await detectModalPicker(a.tmuxPane);
5485
5628
  if (!v.modal)
5486
5629
  return { ok: false, reason: "确认框已不在(可能已在本地处理)" };
5487
- const now = parseConfirmContext(v.screen);
5488
- if (expectToolName && now.toolName !== expectToolName) {
5489
- return { ok: false, reason: `当前确认框已是另一个调用(${now.toolName}),不代按` };
5630
+ if (pickerPolicy(cfg, cliOf(a), parseConfirmContext(v.screen), false).decision === "leave") {
5631
+ return { ok: false, reason: "独立原生或交互审批,请在 CLI 中处理" };
5632
+ }
5633
+ if (modalFingerprint(v.screen) !== expectFingerprint) {
5634
+ return { ok: false, reason: "当前确认框的操作或参数已变更,不代按" };
5490
5635
  }
5491
5636
  if (what === "no") {
5492
5637
  await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
@@ -5523,6 +5668,8 @@ export const startMirror = (deps) => {
5523
5668
  const a = byTarget.get(target);
5524
5669
  if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
5525
5670
  return { ok: false, reason: "它没有活着的 pane —— 模型是在 pane 里切的, 先叫醒它" };
5671
+ if (backendForPath(a.jsonlPath).name === "codex")
5672
+ return { ok: false, reason: "Codex 运行中的模型请在终端 /model 切换;新开会话可指定 model" };
5526
5673
  const r = await selectModel(a.tmuxPane, wanted, log.child({ sub: "set-model", target }), scope);
5527
5674
  if (r.ok && r.applied) {
5528
5675
  a.model = r.applied;
@@ -5536,6 +5683,8 @@ export const startMirror = (deps) => {
5536
5683
  const a = byTarget.get(target);
5537
5684
  if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
5538
5685
  return { ok: false, reason: "它没有活着的 pane —— 档位是在 pane 里切的, 先叫醒它" };
5686
+ if (backendForPath(a.jsonlPath).name === "codex")
5687
+ return { ok: false, reason: "Codex 运行中的推理强度请在终端 /model 切换;新开会话可指定 effort" };
5539
5688
  const r = await selectEffort(a.tmuxPane, effort, log.child({ sub: "set-effort", target }));
5540
5689
  if (r.ok && r.applied) {
5541
5690
  a.effort = r.applied;
@@ -5761,7 +5910,7 @@ export const startMirror = (deps) => {
5761
5910
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
5762
5911
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
5763
5912
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
5764
- 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, ...respawnSpec(a), systemPrompt: charterFor(target, { cwd: a.runningCwd }) });
5913
+ 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, ...respawnSpec(a), systemPrompt: charterFor(target, { cwd: a.runningCwd, cli: backendForPath(a.jsonlPath).name }) });
5765
5914
  if (!r.ok || !r.tmuxPane)
5766
5915
  return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
5767
5916
  a.tmuxPane = r.tmuxPane;
@@ -5892,7 +6041,7 @@ export const startMirror = (deps) => {
5892
6041
  // allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd (同 worldPeers 的
5893
6042
  // WORLD_PROBE 取舍)。
5894
6043
  const orphans = [];
5895
- const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}\t#{session_name}\t#{window_activity}"]);
6044
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}\t#{session_name}\t#{pane_pid}"]);
5896
6045
  if (snap.code !== 0)
5897
6046
  return { reaped, orphans, skipped };
5898
6047
  const rows = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"));
@@ -5918,15 +6067,38 @@ export const startMirror = (deps) => {
5918
6067
  // 丢了 pane id (restore 认不出 → 下一条消息另起新 pane, 旧 pane 不杀) 之后留下的。
5919
6068
  // 没有地址能再叫到它, 却不在下面 `live` 的账上, 攒多少都触不到上限; 所以不占
5920
6069
  // 名额、不等超限, 见到就收。刚起的 pane 在 attach 之前也长这样 (并发 spawn),
5921
- // 用 window_activity 留一段宽限。只收会话 pane: 注册表认得里面住着活的 AI 会话
5922
- // 才算; 普通 pane (人自己开的 shell / 编辑器, 会话已退回 shell 的, 没有注册表的
5923
- // 后端) 一律跳过 —— 认不出就不动。
6070
+ // 用 window_activity 留一段宽限。只收会话 pane: pane 的进程树里有注册表登记且
6071
+ // 还活着的 CLI 进程才算 (pane_pid 是 shell, CLI 是它的子孙, 所以沿 ps 快照往下
6072
+ // 找)。不看注册表的 `tmux` 字段 —— codebuddy 的注册表没有它, 那条路对 codebuddy
6073
+ // 全盲。普通 pane (人自己开的 shell / ssh / 编辑器, 会话已退回 shell 的) 树里
6074
+ // 没有注册表 pid, 一律跳过 —— 认不出就不动。
5924
6075
  const bound = new Set(targets.map(paneOf).filter(Boolean));
5925
- const hosting = new Set(activeBackends().flatMap((b) => [...sessionPanes(b.homeDir)]));
5926
- for (const [pane = "", , session, activity] of rows) {
5927
- if (session !== cfg.wrc.tmuxPrefix || bound.has(pane) || !hosting.has(pane))
6076
+ const regPids = new Set(activeBackends().flatMap((b) => [...liveSessionPids(b.homeDir)]));
6077
+ const ps = await psSnap();
6078
+ // 沿进程树找注册表 pid; 找到返回那个 pid (它的 etimes 供宽限判), 找不到返回 0。
6079
+ const hostedBy = (pid) => {
6080
+ const seen = new Set();
6081
+ const stack = [pid];
6082
+ while (stack.length) {
6083
+ const p = stack.pop() ?? 0;
6084
+ if (regPids.has(p))
6085
+ return p;
6086
+ if (seen.has(p))
6087
+ continue;
6088
+ seen.add(p);
6089
+ for (const k of ps.kids.get(p) ?? [])
6090
+ stack.push(k);
6091
+ }
6092
+ return 0;
6093
+ };
6094
+ for (const [pane = "", , session, pid] of rows) {
6095
+ if (session !== cfg.wrc.tmuxPrefix || bound.has(pane))
6096
+ continue;
6097
+ const cliPid = hostedBy(Number(pid) || 0);
6098
+ if (!cliPid)
5928
6099
  continue;
5929
- if (now - Number(activity) * 1000 < ORPHAN_GRACE_MS)
6100
+ // 宽限按 CLI 进程年龄, 不按 window_activity: TUI 重绘会让它永远「刚动过」。
6101
+ if ((ps.etimes.get(cliPid) ?? Number.POSITIVE_INFINITY) * 1000 < ORPHAN_GRACE_MS)
5930
6102
  continue;
5931
6103
  const why = await putDown(pane);
5932
6104
  if (why) {
@@ -5934,7 +6106,7 @@ export const startMirror = (deps) => {
5934
6106
  continue;
5935
6107
  }
5936
6108
  orphans.push(pane);
5937
- log.info({ pane, idleHours: Math.round((now - Number(activity) * 1000) / 3.6e6) }, "mirror pane cap — orphan pane killed (no binding)");
6109
+ log.info({ pane, cliPid, cliAgeHours: Math.round((ps.etimes.get(cliPid) ?? 0) / 3600) }, "mirror pane cap — orphan pane killed (no binding)");
5938
6110
  }
5939
6111
  const live = targets.filter((t) => watchedBy.has(paneOf(t)));
5940
6112
  let over = live.length - max;
@@ -6029,6 +6201,8 @@ export const startMirror = (deps) => {
6029
6201
  hasLivePane: (sessionId) => Boolean(bySessionId.get(sessionId)?.tmuxPane),
6030
6202
  answerNativeModal: async (sessionId, opts) => {
6031
6203
  const a = bySessionId.get(sessionId);
6204
+ if (a && cliOf(a) === "codex")
6205
+ return { status: "no_modal", reason: "Codex native approval is independent" };
6032
6206
  if (!a?.tmuxPane)
6033
6207
  return { status: "no_pane", reason: "no live tmux pane for session" };
6034
6208
  if (!(await tmuxPaneAlive(a.tmuxPane)))
@@ -6046,6 +6220,9 @@ export const startMirror = (deps) => {
6046
6220
  // 没等到: 这次 CC 没弹框(会话内已授权过 / 命中盲点判断失误)。无事可做。
6047
6221
  if (!v.modal)
6048
6222
  return { status: "no_modal" };
6223
+ if (pickerPolicy(cfg, cliOf(a), parseConfirmContext(v.screen), false).decision === "leave") {
6224
+ return { status: "no_modal", reason: "independent native approval left on CLI" };
6225
+ }
6049
6226
  const pick = pickModalAnswer(parseModalOptions(v.screen), v.title);
6050
6227
  if (!pick) {
6051
6228
  lg.warn({ title: v.title }, "native modal: no trustworthy one-shot option, not pressing");
@@ -6086,6 +6263,12 @@ export const startMirror = (deps) => {
6086
6263
  bySessionId.clear();
6087
6264
  byTarget.clear();
6088
6265
  },
6266
+ currentAsker: (target) => {
6267
+ const a = byTarget.get(target);
6268
+ if (a?.convFrom)
6269
+ return a.convFrom.asker;
6270
+ return a?.speaker ? { who: a.speaker, chat: a.channel ?? baseOfKey(target) } : undefined;
6271
+ },
6089
6272
  currentChannel: (target) => {
6090
6273
  const a = byTarget.get(target);
6091
6274
  return a?.channel ?? baseOfKey(target);
@@ -6268,7 +6451,7 @@ export const startMirror = (deps) => {
6268
6451
  const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
6269
6452
  // Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
6270
6453
  // mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
6271
- 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, ...respawnSpec(a), systemPrompt: charterFor(a.target, { cwd: a.runningCwd }) });
6454
+ 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, ...respawnSpec(a), systemPrompt: charterFor(a.target, { cwd: a.runningCwd, cli: backendForPath(a.jsonlPath).name }) });
6272
6455
  if (r.ok && r.tmuxPane && r.tmuxSession) {
6273
6456
  a.tmuxPane = r.tmuxPane;
6274
6457
  a.tmuxSession = r.tmuxSession;