wezard 2.5.0 → 2.6.0

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 +9 -1
  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 +351 -144
  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 +34 -10
  55. package/dist/daemon/session-scan.js.map +1 -1
  56. package/dist/daemon/spawn-tmux.js +181 -76
  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/wizard-memory.js +12 -6
  69. package/dist/daemon/wizard-memory.js.map +1 -1
  70. package/dist/daemon/wizard.js +7 -5
  71. package/dist/daemon/wizard.js.map +1 -1
  72. package/dist/daemon/ws.js +79 -10
  73. package/dist/daemon/ws.js.map +1 -1
  74. package/dist/mcp/server.js +19 -18
  75. package/dist/mcp/server.js.map +1 -1
  76. package/dist/shared/chat-http.js +63 -29
  77. package/dist/shared/chat-http.js.map +1 -1
  78. package/dist/shared/chat-view.js +10 -3
  79. package/dist/shared/chat-view.js.map +1 -1
  80. package/dist/shared/cli-backends.js +11 -0
  81. package/dist/shared/cli-backends.js.map +1 -1
  82. package/dist/shared/codex-approval.js +57 -0
  83. package/dist/shared/codex-approval.js.map +1 -0
  84. package/dist/shared/codex-hooks.js +134 -0
  85. package/dist/shared/codex-hooks.js.map +1 -0
  86. package/dist/shared/codex.js +125 -0
  87. package/dist/shared/codex.js.map +1 -0
  88. package/dist/shared/config.js +85 -21
  89. package/dist/shared/config.js.map +1 -1
  90. package/dist/shared/detail-render.js +24 -16
  91. package/dist/shared/detail-render.js.map +1 -1
  92. package/dist/shared/detail-store.js +11 -10
  93. package/dist/shared/detail-store.js.map +1 -1
  94. package/dist/shared/exec-path.js +12 -0
  95. package/dist/shared/exec-path.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
@@ -16,29 +19,30 @@ import { spawn } from "node:child_process";
16
19
  import { randomUUID } from "node:crypto";
17
20
  import { existsSync, readdirSync, readFileSync, statSync, watch, openSync, readSync, closeSync } from "node:fs";
18
21
  import { join, dirname } from "node:path";
22
+ import { newStewardSpec, resumedStewardSpec } from "./steward-model.js";
19
23
  import { expandHome, sanitizeId } from "../shared/paths.js";
20
24
  import { augmentedPath } from "../shared/exec-path.js";
21
25
  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";
26
+ import { activeBackends, resolveCliBackend, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
27
+ import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, modalFingerprint, looksLikePicker } from "../shared/modal-pane.js";
28
+ import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
25
29
  import { isAutoWindowActive } from "./session-cache.js";
26
30
  import { noticeSuffixFor } from "./notices.js";
27
- import { dangerOf } from "./danger.js";
31
+ import { dispatchPickerPolicy, pickerPolicy } from "./picker-policy.js";
28
32
  import { renameWindow, runTmux, spawnTmuxClaude } from "./spawn-tmux.js";
29
33
  import { selectEffort, selectModel } from "./model-select.js";
30
34
  import { parseEffort } from "../shared/effort.js";
31
- import { hasRegistry, markTranscript, probeOf, sessionOnPane, sessionPanes, submittedSince, watchRegistry } from "./cc-session.js";
35
+ import { hasRegistry, liveSessionPids, markTranscript, probeOf, sessionOnPane, submittedSince, watchRegistry } from "./cc-session.js";
32
36
  import { waitForIdle } from "./graph.js";
33
37
  import { wizardStore } from "./wizard.js";
34
38
  import { startSubagentWatch } from "./subagent-tail.js";
35
39
  import { knowsToolUse, recordTool, recordToolResult, recordMark, recordTurnStart, recordTurnQuery, recordTurnInject, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, lastChannelOf, openTurnsOf, buildDetailUrl, buildChatUrl, roleUniq } from "./detail.js";
36
40
  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";
41
+ 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
42
  import { splitMarkdown } from "../shared/md-chunk.js";
39
43
  import { isWxFrame } from "../shared/wx-text.js";
40
44
  import { randomTip } from "./tips.js";
41
- import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress, stewardTierOf } from "./chat-name.js";
45
+ import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
42
46
  import { stripAnsi, paneIsBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, cacheTouchMs, cacheTtlSec, keepaliveStamps, openKeepalivePing, lastUserTurn, talkRounds, openToolUses, renderDialog } from "./peers.js";
43
47
  import { keepalivePingSigs, isKeepalivePingText } from "../shared/keepalive.js";
44
48
  // PATH augmentation: launchd / systemd start the daemon
@@ -92,7 +96,9 @@ const readCwdFromJsonl = (path) => {
92
96
  // parse (wrong jsonl dialect). Only genuine discovery searches the union.
93
97
  const rankedJsonlsForCwd = (cwd, only, exclude) => projectDirsFor(expandHome(cwd))
94
98
  .filter(({ backend }) => !only || backend.name === only.name)
95
- .flatMap(({ dir }) => {
99
+ .flatMap(({ backend, dir }) => {
100
+ if (backend.name === "codex")
101
+ return codexSessions(dir, expandHome(cwd)).filter((x) => !exclude?.has(x.sessionId)).map((x) => ({ ...x, mtime: statSync(x.jsonlPath).mtimeMs }));
96
102
  let names;
97
103
  try {
98
104
  names = readdirSync(dir).filter((n) => n.endsWith(".jsonl"));
@@ -133,6 +139,8 @@ const findJsonlBySid = (sid, only) => {
133
139
  return (only ? [only] : activeBackends())
134
140
  .flatMap((b) => {
135
141
  const root = expandHome(b.projectsDir);
142
+ if (b.name === "codex")
143
+ return codexSessions(root).filter((x) => x.sessionId === sid).map((x) => x.jsonlPath);
136
144
  try {
137
145
  return readdirSync(root).map((d) => join(root, d, sidFile));
138
146
  }
@@ -719,6 +727,11 @@ const renderLine = (raw, deps) => {
719
727
  },
720
728
  });
721
729
  }
730
+ // Codex turn_context reports the real tier before any token usage.
731
+ // Forward metadata without inventing an API call or resetting context counts.
732
+ if (!u && (line.message?.model || line.effort)) {
733
+ out.push({ kind: "turn_usage", model: line.message?.model, effort: line.effort });
734
+ }
722
735
  // Terminal stop_reason → emit turn_end so onItem closes the live bubble.
723
736
  // `tool_use` is intentionally excluded — more turns will follow once the
724
737
  // tool result lands; finalizing now would split a single logical reply.
@@ -730,7 +743,7 @@ const renderLine = (raw, deps) => {
730
743
  // 且此后整轮的 tool/text 全部退化成散装气泡。终态消息里必然有 text 块 (含
731
744
  // tool_use 的消息 stop_reason 恒为 "tool_use"), 且实测终态消息从不拆出第二个
732
745
  // text 行 —— 所以"本行带 text 块"精确等价于"本消息的最后一行"。
733
- if (isFinal && blocks.some((b) => b?.type === "text"))
746
+ if (isFinal && !line.awaitTurnEnd && blocks.some((b) => b?.type === "text"))
734
747
  out.push({ kind: "turn_end" });
735
748
  // 软收口 (codebuddy): 这条消息写完了, 但说不出后面还有没有 function_call。
736
749
  else if (line.softTurnEnd)
@@ -774,6 +787,23 @@ const renderLine = (raw, deps) => {
774
787
  const TAG_HEADER_BUDGET = 256 * (2 + MAX_BODY_LINKS);
775
788
  export const startMirrorTail = (deps) => {
776
789
  const { log } = deps;
790
+ // Codex records model/effort separately from per-response usage. Keep that
791
+ // context local to this tail (never share it between sessions/normalizers).
792
+ if (backendForPath(deps.jsonlPath).name === "codex" && deps.normalizeLine) {
793
+ const normalize = deps.normalizeLine;
794
+ let model = deps.startOffset === 0 ? "" : lastModel(deps.jsonlPath);
795
+ let effort;
796
+ deps = { ...deps, normalizeLine: (raw) => {
797
+ const line = normalize(raw);
798
+ if (line?.message?.model) {
799
+ model = line.message.model;
800
+ effort = line.effort;
801
+ }
802
+ return line?.message?.usage
803
+ ? { ...line, effort, message: { ...line.message, model } }
804
+ : line;
805
+ } };
806
+ }
777
807
  // A single logical tail follows ONE sessionId whose transcript file may move
778
808
  // between sibling project dirs — Claude Code's EnterWorktree/ExitWorktree
779
809
  // rename <sid>.jsonl into/out of <cwd>/.claude/worktrees/<name>, each cwd
@@ -827,6 +857,11 @@ export const startMirrorTail = (deps) => {
827
857
  watchedPath = path;
828
858
  try {
829
859
  watcher = watch(path, { persistent: false }, () => drain());
860
+ const active = watcher;
861
+ active.on("error", (err) => {
862
+ active.close();
863
+ log.warn({ err: err.message }, "fs.watch failed; relying on poll");
864
+ });
830
865
  }
831
866
  catch (e) {
832
867
  watcher = undefined;
@@ -1100,19 +1135,19 @@ const extractPaneAssistantTail = (pane) => {
1100
1135
  // long after the pane went back to accepting input — wedging the mirror shut
1101
1136
  // permanently. Bare `-p` captures only what is currently on screen.
1102
1137
  //
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 不同源(框可能已被本地按掉), 按键就按错了。
1138
+ // Trim trailing blank rows so the bottom 15 rows locate the active picker.
1139
+ // Keep the full current screen from that same capture for operation context;
1140
+ // a second capture might already be a different invocation. -J joins terminal
1141
+ // wraps without reading scrollback or dropping long command arguments.
1109
1142
  const detectModalPicker = async (target) => {
1110
- const r = await runTmux(["capture-pane", "-t", target, "-p"]);
1143
+ const r = await runTmux(["capture-pane", "-t", target, "-p", "-J"]);
1111
1144
  if (!r.ok)
1112
1145
  return { modal: false, screen: "" };
1113
1146
  const lines = r.stdout.replace(/\s+$/u, "").split("\n");
1114
- const screen = lines.slice(-MODAL_SCAN_ROWS).join("\n");
1115
- return { ...isModalPane(screen), screen };
1147
+ const screen = lines.join("\n");
1148
+ // Only the bottom picker may block input. Earlier completed pickers on the
1149
+ // current screen remain useful context, but must not look like a live modal.
1150
+ return { ...isModalPane(lines.slice(-15).join("\n")), screen };
1116
1151
  };
1117
1152
  // 按下确认框的第 index 号选项。Claude Code 的 picker 数字键即选即确认; CodeBuddy
1118
1153
  // 的框数字键不一定生效, 且高亮可能停在别的选项上 (实测见过默认高亮在 "Yes, and
@@ -1124,10 +1159,14 @@ const MODAL_PRESS_SETTLE_MS = 400;
1124
1159
  const MODAL_ARROW_SETTLE_MS = 150;
1125
1160
  const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
1126
1161
  const pressModalIndex = async (pane, before, index) => {
1162
+ const fingerprint = modalFingerprint(before.screen);
1163
+ const current = await detectModalPicker(pane);
1164
+ if (!current.modal || modalFingerprint(current.screen) !== fingerprint)
1165
+ return current;
1127
1166
  await runTmux(["send-keys", "-t", pane, String(index)]);
1128
1167
  await sleep(MODAL_PRESS_SETTLE_MS);
1129
1168
  let after = await detectModalPicker(pane);
1130
- if (!after.modal || after.title !== before.title)
1169
+ if (!after.modal || modalFingerprint(after.screen) !== fingerprint)
1131
1170
  return after;
1132
1171
  // 数字键没关掉框: 把高亮挪到目标项。guard 上限盖过任何确认框的选项数。
1133
1172
  for (let guard = 0; guard < 8; guard++) {
@@ -1137,7 +1176,7 @@ const pressModalIndex = async (pane, before, index) => {
1137
1176
  await runTmux(["send-keys", "-t", pane, cur < index ? "Down" : "Up"]);
1138
1177
  await sleep(MODAL_ARROW_SETTLE_MS);
1139
1178
  after = await detectModalPicker(pane);
1140
- if (!after.modal || after.title !== before.title)
1179
+ if (!after.modal || modalFingerprint(after.screen) !== fingerprint)
1141
1180
  return after;
1142
1181
  }
1143
1182
  if (highlightedIndex(after.screen) !== index)
@@ -1246,10 +1285,39 @@ const BOX_PROMPT_RE = /^\s*[❯>]\s?/u;
1246
1285
  /** 底 ruler 之下只该剩 cwd/模式两行状态; 多于此说明框到的不是输入框。 */
1247
1286
  const BOX_FOOTER_ROWS = 6;
1248
1287
  const UNKNOWN_BOX = { known: false, body: "" };
1249
- export const parseInputBox = (cap) => {
1288
+ export const parseInputBox = (cap, styledLayout = cap) => {
1250
1289
  const lines = cap.split("\n");
1251
1290
  while (lines.length && lines[lines.length - 1].trim() === "")
1252
1291
  lines.pop();
1292
+ // Codex has no rulers. Anchor the composer to its bottom shortcuts footer,
1293
+ // then use the LAST › prompt: earlier prompts belong to the transcript.
1294
+ // Requiring a status row and a blank separator avoids treating arbitrary
1295
+ // output containing › as editable input. Collapsed pastes remain visible
1296
+ // as a body change even though the original text fingerprints disappear.
1297
+ const styledLines = styledLayout.split("\n");
1298
+ const layout = styledLines.map((line) => line.replace(/\x1b\[[0-9;:]*m/gu, "").replace(OTHER_ESC_RE, ""));
1299
+ while (layout.length && !layout[layout.length - 1].trim())
1300
+ layout.pop();
1301
+ const footer = layout.length - 1;
1302
+ // Codex hides the shortcuts row while the composer contains text.
1303
+ const shortcuts = /\? for shortcuts\s*$/u.test(layout[footer] ?? "");
1304
+ if (shortcuts || (layout[footer] ?? "").includes("·")) {
1305
+ let status = shortcuts ? footer - 1 : footer;
1306
+ while (status >= 0 && !layout[status].trim())
1307
+ status--;
1308
+ if (status >= 0 && footer - status <= BOX_FOOTER_ROWS && layout[status].includes("·") && status > 0 && !layout[status - 1].trim()) {
1309
+ let prompt = status - 2;
1310
+ // Continuation rows are indented; a › inside pasted content is not a
1311
+ // new composer prompt.
1312
+ while (prompt >= 0 && !/^›(?:\s|$)/u.test(layout[prompt]))
1313
+ prompt--;
1314
+ if (prompt >= 0 && (shortcuts || /\x1b\[[0-9;:]*48[;:]/u.test(styledLines[prompt]))) {
1315
+ const body = dropDim(styledLines.slice(prompt, status).join("\n"))
1316
+ .replace(/^\s*›\s?/u, "").replace(/[\s\u200b\ufeff]+/gu, " ").trim();
1317
+ return { known: true, body };
1318
+ }
1319
+ }
1320
+ }
1253
1321
  const rulers = lines.reduce((acc, l, i) => (BOX_RULER_RE.test(l) ? [...acc, i] : acc), []);
1254
1322
  const bottom = rulers[rulers.length - 1];
1255
1323
  const top = rulers[rulers.length - 2];
@@ -1287,10 +1355,18 @@ export const dropDim = (styled) => {
1287
1355
  };
1288
1356
  const captureUndimmed = async (target, rows) => {
1289
1357
  const r = await runTmux(["capture-pane", "-t", target, "-p", "-e", "-S", `-${rows}`]);
1290
- return r.ok ? dropDim(r.stdout) : "";
1358
+ // Negative -S includes scrollback plus the entire visible pane, not its
1359
+ // last N rows. Crop before removing dim text so row boundaries survive.
1360
+ return r.ok ? dropDim(r.stdout.replace(/\n$/u, "").split("\n").slice(-rows).join("\n")) : "";
1291
1361
  };
1292
1362
  // 24 行足够容纳展开后的多行粘贴 (折叠态只占一行)。
1293
- const readInputBox = async (target) => parseInputBox(await captureUndimmed(target, 24));
1363
+ const readPaneInput = async (target) => {
1364
+ const r = await runTmux(["capture-pane", "-t", target, "-p", "-e", "-S", "-24"]);
1365
+ const raw = r.ok ? r.stdout : "";
1366
+ const cap = dropDim(raw);
1367
+ return { cap, box: parseInputBox(cap, raw) };
1368
+ };
1369
+ const readInputBox = async (target) => (await readPaneInput(target)).box;
1294
1370
  // ── 注入门控的会话证人 (cc-session) ─────────────────────────────────
1295
1371
  // 能从会话自己写的 json 读到的判据, 就不读 pane: 就绪看注册表的 status, 提交看
1296
1372
  // 注册表的 busy 翻转或 jsonl 里新落的那一行。输入框里「贴进去没有」是唯一只有
@@ -1445,8 +1521,18 @@ const withImageRefs = (text, images) => {
1445
1521
  // 证人不作声 (忙时排队、slash 命令、无注册表的后端) 才看框回没回到贴之前的样子。
1446
1522
  // 框认得出来时这条判据可信, 于是允许多补回车 (空框上的回车在 CLI 里是 no-op);
1447
1523
  // 每次补之前再问一遍证人, 已提交就不补。
1448
- /** 具名粘贴 buffer 的序号 (见 loadAndPaste)。 */
1449
- let pasteSeq = 0;
1524
+ // tmux < 2.0 的 buffer 命令只认内部名 (bufferNNNN): 自定义名在 load/show/delete 上
1525
+ // 一律 "buffer invalid" (devcloud tmux 1.8 实测, 与内容无关), 默认 buffer (不带 -b)
1526
+ // 正常 —— 跨 pane 并发隔离不能靠具名 buffer。改用默认 buffer + 进程内串行: load→paste
1527
+ // 这段临界区里不许第二个注入插进来, 否则共用 buffer 会让两段文本互换 pane (具名 buffer
1528
+ // 防的正是这个实测坑; 守护进程单进程, 一把 promise 锁就够)。临界区只有两条 tmux 命令
1529
+ // (<100ms), 串行不构成分延迟。
1530
+ let pasteQueue = Promise.resolve();
1531
+ const withPasteLock = (fn) => {
1532
+ const r = pasteQueue.then(fn);
1533
+ pasteQueue = r.then(() => undefined, () => undefined);
1534
+ return r;
1535
+ };
1450
1536
  const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WITNESS) => {
1451
1537
  // Warm pane: tight timings, low latency. Fresh spawn (claude --resume just
1452
1538
  // started, transcript still loading): extended timings — bracketed-paste
@@ -1487,26 +1573,26 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1487
1573
  const loadAndPaste = async () => {
1488
1574
  // stdin variant of the shared exec path — it carries the same hard timeout,
1489
1575
  // 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 };
1576
+ // 默认 buffer (不带 -b) 是新旧 tmux 都支持的唯一公共形式; 隔离由 withPasteLock 保证。
1577
+ return withPasteLock(async () => {
1578
+ const loaded = await runTmux(["load-buffer", "-"], { stdin: text });
1579
+ if (!loaded.ok)
1580
+ return { ok: false, reason: `tmux load-buffer failed: ${loaded.stderr.slice(-200) || loaded.code}` };
1581
+ const pasted = await runTmux(["paste-buffer", "-p", "-d", "-t", target]);
1582
+ if (!pasted.ok)
1583
+ return { ok: false, reason: `tmux paste-buffer failed: ${pasted.stderr.slice(-200)}` };
1584
+ return { ok: true };
1585
+ });
1501
1586
  };
1502
1587
  // 框认得出来 → 看它变没变; 认不出来 → 退回宽窗口找 headFp (长文本会换行,
1503
1588
  // 头部可能在框顶, 所以窗口要宽)。
1504
1589
  const sawLanded = async (timeoutMs) => {
1505
1590
  const t0 = Date.now();
1506
1591
  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))
1592
+ const { cap, box } = await readPaneInput(target);
1593
+ // A redraw can hide the baseline. Once the composer reappears, its
1594
+ // nonempty body also proves landing of a collapsed paste.
1595
+ if (box.known ? (boxVerify ? !boxIntact(box) : box.body !== "") : Boolean(headFp) && stripWs(cap).includes(headFp))
1510
1596
  return true;
1511
1597
  await sleep(POLL_MS);
1512
1598
  }
@@ -1519,6 +1605,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1519
1605
  return Boolean(tailFp) && stripWs(pane).includes(tailFp);
1520
1606
  };
1521
1607
  let pasteSeen = alreadyTyped;
1608
+ let pastedAt = Date.now();
1522
1609
  if (alreadyTyped) {
1523
1610
  log.warn({ target, headFp }, "mirror inject: 输入框里已有这段文本 (上次未提交) — 不重贴, 直接提交");
1524
1611
  }
@@ -1526,7 +1613,11 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1526
1613
  const r = await loadAndPaste();
1527
1614
  if (!r.ok)
1528
1615
  return r;
1529
- pasteSeen = await sawLanded(PASTE_VERIFY_MS);
1616
+ pastedAt = Date.now();
1617
+ // An unknown layout cannot verify collapsed text by fingerprint. Bound
1618
+ // that inconclusive probe by the catch-up budget instead of waiting 6s
1619
+ // and then sleeping another 2.5s before the first Enter.
1620
+ pasteSeen = await sawLanded(boxVerify ? PASTE_VERIFY_MS : POST_PASTE_SETTLE_FALLBACK_MS);
1530
1621
  }
1531
1622
  if (!pasteSeen) {
1532
1623
  const box = await readInputBox(target);
@@ -1540,7 +1631,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1540
1631
  log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 粘贴未验证但输入框非空 — 首次粘贴已落地, 不重贴");
1541
1632
  pasteSeen = true;
1542
1633
  }
1543
- else {
1634
+ else if (boxVerify && box.known) {
1544
1635
  log.warn({ target, headFp, boxKnown: box.known }, "mirror inject: 输入框确认为空, 重贴一次");
1545
1636
  await sleep(RETRY_SETTLE_MS);
1546
1637
  if (submitted())
@@ -1548,8 +1639,14 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1548
1639
  const r = await loadAndPaste();
1549
1640
  if (!r.ok)
1550
1641
  return r;
1642
+ pastedAt = Date.now();
1551
1643
  pasteSeen = await sawLanded(PASTE_VERIFY_MS);
1552
1644
  }
1645
+ else {
1646
+ // Missing fingerprints are not proof of an empty composer: other TUIs
1647
+ // may collapse pasted text. Never duplicate a paste on that evidence.
1648
+ log.warn({ target, headFp }, "mirror inject: 输入框布局未知, 不重贴未确认的粘贴");
1649
+ }
1553
1650
  }
1554
1651
  // 粘贴落定 = 框里是我们的内容, 且连续两次读到同一个样子 —— TUI 已把整段粘贴
1555
1652
  // (bracketed-paste end 在内) 消化完, 紧跟的回车不会被并进粘贴。实测冷启动的
@@ -1568,10 +1665,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1568
1665
  // Bracketed-paste end + TUI catch-up. 框认不出来 / 粘贴没被看见时没有条件可查,
1569
1666
  // 维持固定等待 (fresh respawn: claude --resume is still loading the transcript)。
1570
1667
  if (!alreadyTyped) {
1571
- if (pasteSeen && boxVerify)
1668
+ if (pasteSeen)
1572
1669
  await pasteSettled(POST_PASTE_SETTLE_MS);
1573
1670
  else
1574
- await sleep(pasteSeen ? POST_PASTE_SETTLE_MS : POST_PASTE_SETTLE_FALLBACK_MS);
1671
+ await sleep(Math.max(0, POST_PASTE_SETTLE_FALLBACK_MS - (Date.now() - pastedAt)));
1575
1672
  }
1576
1673
  const sendEnter = async () => {
1577
1674
  const e = await runTmux(["send-keys", "-t", target, "Enter"]);
@@ -1579,7 +1676,7 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1579
1676
  };
1580
1677
  const cleared = async () => {
1581
1678
  const box = await readInputBox(target);
1582
- return boxVerify && box.known ? boxIntact(box) : !(await inputBoxStillHasTail());
1679
+ return box.known ? (boxVerify ? boxIntact(box) : box.body === "") : !(await inputBoxStillHasTail());
1583
1680
  };
1584
1681
  /** 证人先答; 它的窗口过了还不作声, 才每轮加看一眼框。 */
1585
1682
  const waitForSubmit = async (timeoutMs) => {
@@ -1612,8 +1709,10 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1612
1709
  return { ok: true }; // 迟到的确认 —— 补的这一下会落在空框上, 省了
1613
1710
  log.warn({ target, tailFp, attempt: i + 1 }, "mirror inject: 回车后输入框未清空, 再补一次");
1614
1711
  }
1615
- if (i === 0)
1712
+ if (i === 0) {
1616
1713
  enterAt = Date.now();
1714
+ log.info({ target, pasteToEnterMs: enterAt - pastedAt, pasteSeen, boxTrusted, freshSpawn }, "mirror inject: first Enter");
1715
+ }
1617
1716
  const e = await sendEnter();
1618
1717
  if (!e.ok)
1619
1718
  return e;
@@ -1628,14 +1727,42 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
1628
1727
  log.warn({ target, tailFp, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: clear not observed, trusting submit");
1629
1728
  return { ok: true, uncertain: true, reason: "目标会话可能正忙或未消费输入(回车后输入框未清空)" };
1630
1729
  };
1631
- const injectViaSpawn = (args) => {
1730
+ const injectViaSpawn = async (args) => {
1632
1731
  const { text, images = [], cfg, log, sessionId, jsonlPath } = args;
1633
- const bin = backendForPath(jsonlPath).bin;
1732
+ const backend = backendForPath(jsonlPath);
1733
+ const bin = backend.bin;
1734
+ let cwd = expandHome(cfg.wrc.cwd);
1735
+ let codexArgs = [];
1736
+ if (backend.name === "codex") {
1737
+ try {
1738
+ const fd = openSync(jsonlPath, "r");
1739
+ let head;
1740
+ try {
1741
+ const buf = Buffer.alloc(256 * 1024);
1742
+ head = buf.subarray(0, readSync(fd, buf, 0, buf.length, 0)).toString("utf8");
1743
+ }
1744
+ finally {
1745
+ closeSync(fd);
1746
+ }
1747
+ const meta = JSON.parse(head.split("\n", 1)[0]).payload;
1748
+ if (typeof meta?.cwd === "string")
1749
+ cwd = meta.cwd;
1750
+ const hookEntry = fileURLToPath(new URL("../cli/codex-hook.js", import.meta.url));
1751
+ const { loadConfig } = await import("../shared/config.js");
1752
+ const command = codexHookCommand(loadConfig().sourcePath, `http://127.0.0.1:${cfg.daemon.port}`, hookEntry);
1753
+ codexArgs = await codexHookLaunchArgs(bin, cwd, command, cfg.approval.hookTimeoutSec + 10, cfg.approval.enabled);
1754
+ }
1755
+ catch (e) {
1756
+ return { ok: false, reason: `Codex resume setup failed: ${e.message}` };
1757
+ }
1758
+ }
1634
1759
  // Spawn-mode (no live TTY) can't do clipboard+C-v — fall back to `@<path>`,
1635
1760
  // which Claude parses at submit time and inlines as image content blocks
1636
1761
  // without a model-decided Read tool turn.
1637
1762
  const finalText = withImageRefs(text, images);
1638
- const cliArgs = [
1763
+ const cliArgs = backend.name === "codex" ? ["exec", "resume", "--json", ...codexArgs,
1764
+ ...cfg.wrc.extraArgs.filter((arg) => !arg.startsWith("--disallowedTools")),
1765
+ ...images.flatMap((path) => ["--image", path]), sessionId, text] : [
1639
1766
  "-p",
1640
1767
  finalText,
1641
1768
  "--resume",
@@ -1648,7 +1775,7 @@ const injectViaSpawn = (args) => {
1648
1775
  log.info({ sessionId, bin, len: text.length }, "mirror inject");
1649
1776
  return new Promise((resolve) => {
1650
1777
  const proc = spawn(bin, cliArgs, {
1651
- cwd: expandHome(cfg.wrc.cwd),
1778
+ cwd,
1652
1779
  env: { ...process.env, PATH: augmentedPath(process.env.PATH) },
1653
1780
  stdio: ["ignore", "ignore", "pipe"],
1654
1781
  });
@@ -1693,6 +1820,30 @@ const INJECT_JOB_TIMEOUT_MS = 90_000;
1693
1820
  /** 没有绑定的 pane 静默多久才算孤儿。刚 spawn、还没 attach 的 pane (并发 fan-out 时
1694
1821
  * 别人的 sweep 会撞见它) 在这段时间里一直有输出, 远大于一次 spawn → attach 的耗时。 */
1695
1822
  const ORPHAN_GRACE_MS = 10 * 60_000;
1823
+ const psSnap = () => new Promise((resolve) => {
1824
+ const p = spawn("ps", ["-axo", "pid=,ppid=,etimes="], { env: { ...process.env, PATH: augmentedPath(process.env.PATH) }, stdio: ["ignore", "pipe", "ignore"] });
1825
+ let out = "";
1826
+ p.stdout?.on("data", (c) => (out += c.toString("utf8")));
1827
+ const done = () => {
1828
+ const kids = new Map();
1829
+ const etimes = new Map();
1830
+ for (const line of out.split("\n")) {
1831
+ const [pid, ppid, et] = line.trim().split(/\s+/).map(Number);
1832
+ if (!pid || !ppid)
1833
+ continue;
1834
+ const arr = kids.get(ppid);
1835
+ if (arr)
1836
+ arr.push(pid);
1837
+ else
1838
+ kids.set(ppid, [pid]);
1839
+ if (et !== undefined && !Number.isNaN(et))
1840
+ etimes.set(pid, et);
1841
+ }
1842
+ resolve({ kids, etimes });
1843
+ };
1844
+ p.on("close", done);
1845
+ p.on("error", () => resolve({ kids: new Map(), etimes: new Map() }));
1846
+ });
1696
1847
  /** Drop a session's queue chain. A hung job can't be cancelled (it's parked in a
1697
1848
  * tmux read), but detaching the chain means the NEXT message doesn't inherit
1698
1849
  * its deadlock. Pair with an injectGen bump so the zombie can't act when it
@@ -2401,11 +2552,11 @@ export const startMirror = (deps) => {
2401
2552
  if (!env)
2402
2553
  return undefined;
2403
2554
  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 } : {}) }
2555
+ 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
2556
  : env.kind === "task" ? { kind: "task", taskId: env.from.replace(/^定时 /, "") }
2406
2557
  : undefined;
2407
2558
  return {
2408
- channel: env.private ? "" : (env.chat && chatBaseOf(cfg, env.chat)) || home,
2559
+ channel: env.private ? "" : env.chatKey || (env.chat && chatBaseOf(cfg, env.chat)) || home,
2409
2560
  speaker: env.kind === "human" ? `user:${env.from}` : undefined,
2410
2561
  from,
2411
2562
  query: t.text,
@@ -2464,8 +2615,6 @@ export const startMirror = (deps) => {
2464
2615
  const isPrivate = (a) => a.channel === "";
2465
2616
  /** 本轮回复该推去的 chatId。 */
2466
2617
  const replyChatId = (a) => stripPrincipalPrefix(a.channel || a.target);
2467
- // 审批/提问卡同样回本轮的频道; 私聊轮 ("") 的卡回 home —— 要点它的人总得在某个群里。
2468
- bindCardChannel((key) => byTarget.get(key)?.channel || "");
2469
2618
  // 详情页要按后端的名字称呼它 ("Claude 正在思考" / "CodeBuddy 正在思考") —— 后端
2470
2619
  // 由 transcript 落盘路径唯一确定, 所以每次开 turn 时现算, 不必额外记在 AttachState 上。
2471
2620
  const cliOf = (a) => a.jsonlPath ? backendForPath(a.jsonlPath).name : undefined;
@@ -2520,7 +2669,7 @@ export const startMirror = (deps) => {
2520
2669
  a.pendingBriefQuery = undefined;
2521
2670
  a.pendingChannel = { channel: s.channel ?? baseOfKey(a.target), speaker: s.speaker, at: Date.now() };
2522
2671
  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) });
2672
+ 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
2673
  if (a.briefTurnId)
2525
2674
  closeBriefTurn(a);
2526
2675
  openBriefTurn(a, q);
@@ -2585,7 +2734,7 @@ export const startMirror = (deps) => {
2585
2734
  const queued = !isSlash && idleVerdict(a.target) === false;
2586
2735
  pushSeal(a, seal);
2587
2736
  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 });
2737
+ 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
2738
  else
2590
2739
  activateDispatch(a, seal);
2591
2740
  // 新消息 = 对话边界: 立刻收掉上一 turn, 新 turn 直接激活、不排队。收口语义见
@@ -2657,7 +2806,7 @@ export const startMirror = (deps) => {
2657
2806
  const fresh = consumeFrom(a);
2658
2807
  const opened = consumeChannel(a, fromCli, fresh);
2659
2808
  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 } : {}) });
2809
+ 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
2810
  a.briefTurnId = turnId;
2662
2811
  a.briefBubble = undefined;
2663
2812
  a.briefIsSlash = false;
@@ -3164,7 +3313,7 @@ export const startMirror = (deps) => {
3164
3313
  if (!a.keepaliveTurnId) {
3165
3314
  const turnId = newTurnId();
3166
3315
  a.keepaliveTurnId = turnId;
3167
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
3316
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
3168
3317
  }
3169
3318
  };
3170
3319
  // A keepalive ping turn is swallowed wholesale — the reply content doesn't
@@ -3817,8 +3966,9 @@ export const startMirror = (deps) => {
3817
3966
  // with the primary dialect would report a phantom drift for every
3818
3967
  // session belonging to a non-primary CLI.
3819
3968
  const paneDir = projectDirFor(jsonlAbs, expandHome(paneCwd));
3820
- if (paneDir !== dirname(jsonlAbs)) {
3821
- const live = liveSessionForCwd(paneCwd, backendForPath(jsonlAbs), sidsClaimedByOthers(principal));
3969
+ const codexPane = backendForPath(jsonlAbs).name === "codex" ? codexSessionForPane(livePane) : undefined;
3970
+ if (codexPane ? codexPane.sessionId !== rec.sessionId : backendForPath(jsonlAbs).name !== "codex" && paneDir !== dirname(jsonlAbs)) {
3971
+ const live = codexPane?.cwd === expandHome(paneCwd) ? codexPane : backendForPath(jsonlAbs).name === "codex" ? undefined : liveSessionForCwd(paneCwd, backendForPath(jsonlAbs), sidsClaimedByOthers(principal));
3822
3972
  if (live && live.sessionId !== rec.sessionId) {
3823
3973
  log.info({ principal, from: rec.sessionId, to: live.sessionId, paneCwd }, "mirror restore: pane in worktree, re-homed to live session");
3824
3974
  rec.sessionId = live.sessionId;
@@ -4131,6 +4281,8 @@ export const startMirror = (deps) => {
4131
4281
  // `exclusive` (the /clear flavor) refuses to claim on ambiguous evidence —
4132
4282
  // see claimIsAttributable.
4133
4283
  const startMigrationWatcher = (a, baseline, isChild, startOffset, exclusive) => {
4284
+ if (backendForPath(a.jsonlPath).name === "codex")
4285
+ return;
4134
4286
  if (a.migrationWatcher)
4135
4287
  a.migrationWatcher.cancel(); // re-armed (e.g. /clear twice, or respawn during a pending watch)
4136
4288
  const projectDir = dirname(a.jsonlPath);
@@ -4230,6 +4382,8 @@ export const startMirror = (deps) => {
4230
4382
  // narrows to transcripts born after the clear, which makes a short
4231
4383
  // fingerprint safe enough to accept.
4232
4384
  const armSilentForkRebind = (a, text) => {
4385
+ if (backendForPath(a.jsonlPath).name === "codex")
4386
+ return; // SessionStart owns exact pane identity.
4233
4387
  const pendingClear = a.clearRebind;
4234
4388
  if (pendingClear) {
4235
4389
  // Hand over: the fingerprint is pane-certain, the dir-scan is not.
@@ -4375,8 +4529,16 @@ export const startMirror = (deps) => {
4375
4529
  const cwd = paneCwd.get(a.tmuxPane);
4376
4530
  if (!cwd)
4377
4531
  continue; // pane gone
4532
+ if (backendForPath(a.jsonlPath).name === "codex") {
4533
+ const registered = codexSessionForPane(a.tmuxPane);
4534
+ if (registered && registered.cwd === expandHome(cwd) && registered.sessionId !== a.sessionId && !sidsClaimedByOthers(a.target).has(registered.sessionId)) {
4535
+ a.runningCwd = registered.cwd;
4536
+ migrateAttachment(a, registered.sessionId, registered.jsonlPath);
4537
+ }
4538
+ continue;
4539
+ }
4378
4540
  const paneDir = projectDirFor(a.jsonlPath, expandHome(cwd));
4379
- if (paneDir === dirname(a.jsonlPath))
4541
+ if (backendForPath(a.jsonlPath).name === "codex" ? expandHome(cwd) === a.runningCwd : paneDir === dirname(a.jsonlPath))
4380
4542
  continue; // same project dir — no drift
4381
4543
  const live = liveSessionForCwd(cwd, backendForPath(a.jsonlPath), sidsClaimedByOthers(a.target));
4382
4544
  if (!live || live.sessionId === a.sessionId)
@@ -4468,14 +4630,11 @@ export const startMirror = (deps) => {
4468
4630
  return undefined;
4469
4631
  }
4470
4632
  };
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
4633
  /** 死 pane 重生 (`--resume` 同一个会话) 的模型: 绑定里记着的优先 —— set_model 换上的也记在那;
4477
4634
  * 没记 (老绑定 / CLI 默认) 的管家补上管家档。 */
4478
- const respawnSpec = (a) => a.model ? { model: a.model, effort: parseEffort(a.effort) } : stewardSpec(a.target) ?? { effort: parseEffort(a.effort) };
4635
+ const respawnSpec = (a) => {
4636
+ return resumedStewardSpec(cfg, a.target, backendForPath(a.jsonlPath).name, a);
4637
+ };
4479
4638
  const newSession = async (target, windowName, cli, opts) => {
4480
4639
  const prev = byTarget.get(target);
4481
4640
  // Resolution precedence (all chat-scoped except the running-cwd fallback):
@@ -4497,22 +4656,16 @@ export const startMirror = (deps) => {
4497
4656
  (rec?.cwd?.trim()) ||
4498
4657
  chat.running ||
4499
4658
  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).
4659
+ // Fresh panes always use the configured backend, regardless of old bindings
4660
+ // or per-call / tier CLI values. Keep the old path only for model/effort reset.
4661
+ const boundPath = prev?.jsonlPath ?? rec?.jsonlPath;
4506
4662
  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);
4663
+ const effCli = resolveCliBackend(cfg.wrc).name;
4510
4664
  // 管家 (群的默认会话) 新起的会话按管家档起 (models.steward / chatPolicy), 档压过绑定里记下的
4511
4665
  // effort —— `/new`、换目录、交接重开都是让改过的档位生效的那一下。点名了模型就整档让开。
4512
4666
  // 已在跑的管家不动 (换模型会让缓存整份重读); 死 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);
4667
+ const backendChanged = Boolean(boundPath && effCli && backendForPath(expandHome(boundPath)).name !== effCli);
4668
+ const { model: effModel, effort: effEffort } = newStewardSpec(cfg, target, effCli, opts, backendChanged ? undefined : prev?.effort ?? deps.store.get(target)?.effort);
4516
4669
  if (prev?.tmuxPane) {
4517
4670
  // Best-effort kill; ignore errors (pane may already be dead).
4518
4671
  void runTmux(["kill-pane", "-t", prev.tmuxPane]);
@@ -4531,7 +4684,7 @@ export const startMirror = (deps) => {
4531
4684
  cli: effCli,
4532
4685
  model: effModel,
4533
4686
  effort: effEffort,
4534
- systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff }),
4687
+ systemPrompt: opts?.systemPrompt ?? charterFor(target, { cwd: eff, cli: effCli }),
4535
4688
  }).catch((e) => { inFlightSids.delete(freshSid); throw e; });
4536
4689
  if (!r.ok) {
4537
4690
  inFlightSids.delete(freshSid);
@@ -4695,14 +4848,14 @@ export const startMirror = (deps) => {
4695
4848
  rememberInject(hello);
4696
4849
  const boot = await inject({
4697
4850
  text: hello, images: [], cfg, log: lg,
4698
- sessionId: parent.sessionId, jsonlPath: parent.jsonlPath,
4851
+ sessionId: parentCli === "codex" ? r.sessionId : parent.sessionId, jsonlPath: parentCli === "codex" ? r.jsonlPath : parent.jsonlPath,
4699
4852
  tmuxTarget: r.tmuxPane, freshSpawn: true,
4700
4853
  });
4701
4854
  if (!boot.ok) {
4702
4855
  await runTmux(["kill-pane", "-t", r.tmuxPane ?? ""]);
4703
4856
  return { ok: false, reason: `分身开场白注入失败: ${boot.reason ?? "unknown"}`, inherited: false };
4704
4857
  }
4705
- const fork = await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
4858
+ const fork = parentCli === "codex" ? { sessionId: r.sessionId, jsonlPath: r.jsonlPath } : await awaitFork(projectDir, baseline, sidsClaimedByOthers(args.target), parent.jsonlPath, {
4706
4859
  homeDir: backendForPath(parent.jsonlPath).homeDir, pane: r.tmuxPane ?? "", parentSid: parent.sessionId,
4707
4860
  });
4708
4861
  // 等不到分叉就必须收手: 此刻唯一已知的 sid 是父亲的, 认下去两个 target 会指向
@@ -4921,14 +5074,16 @@ export const startMirror = (deps) => {
4921
5074
  // `daily#fix` 改名前的老写法 —— 名字取自 tag 迁移而来, 照旧认得。
4922
5075
  const resolvePeerTag = (self, ref) => {
4923
5076
  const raw = stripSigil(ref);
4924
- const all = allTargets();
5077
+ // 多机器人隔离: 别的机器人名下的 wizard 在这里与「不存在」同形 —— 不说它在、不进 candidates。
5078
+ // 单机器人时 sameBot 恒真, 这一行什么也不滤。
5079
+ const all = allTargets().filter((k) => sameBot(self, k));
4925
5080
  const hit = (target) => ({ ok: true, target, foreign: baseOfKey(target) !== baseOfKey(self) });
4926
5081
  if (!raw)
4927
5082
  return hit(baseOfKey(self));
4928
5083
  if (/^(user|chat|group):/.test(raw)) {
4929
5084
  return all.includes(raw) ? hit(raw) : { ok: false, reason: `no running session '${raw}'` };
4930
5085
  }
4931
- const named = wizardStore()?.byName(raw)?.target;
5086
+ const named = ((t) => (t && sameBot(self, t) ? t : undefined))(wizardStore()?.byName(raw)?.target);
4932
5087
  if (named && all.includes(named))
4933
5088
  return hit(named);
4934
5089
  if (named)
@@ -5121,7 +5276,7 @@ export const startMirror = (deps) => {
5121
5276
  const foreignPeers = async (self) => {
5122
5277
  const selfBase = baseOfKey(self);
5123
5278
  // 只列活着的 / 有身份的 —— 冷绑定可能有几百个, 每个探活是两次 tmux。
5124
- const foreign = allTargets().filter((k) => baseOfKey(k) !== selfBase && (byTarget.has(k) || !!wizardStore()?.get(k)));
5279
+ const foreign = allTargets().filter((k) => baseOfKey(k) !== selfBase && sameBot(self, k) && (byTarget.has(k) || !!wizardStore()?.get(k)));
5125
5280
  return (await Promise.all(foreign.map((t) => peerInfoOf(t, self)))).sort(byRecent);
5126
5281
  };
5127
5282
  // peerInfoOf 每个 target 要两次 tmux (pane 存活 + capture tail)。跑了几个月的
@@ -5255,7 +5410,7 @@ export const startMirror = (deps) => {
5255
5410
  if (!a.keepaliveTurnId) {
5256
5411
  const turnId = newTurnId();
5257
5412
  a.keepaliveTurnId = turnId;
5258
- recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), userQuery: text });
5413
+ recordTurnStart({ id: turnId, target: a.target, sessionId: a.sessionId, cli: cliOf(a), initialModel: a.model || undefined, initialEffort: a.effort || undefined, userQuery: text });
5259
5414
  }
5260
5415
  };
5261
5416
  let keepaliveTicking = false;
@@ -5376,10 +5531,10 @@ export const startMirror = (deps) => {
5376
5531
  // 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
5377
5532
  // 里经 ToolSearch→DeferExecuteTool 派发的 MCP 工具)。hook 链路对这类调用完全
5378
5533
  // 失明 —— daemon 零日志、无卡可发, pane 就地阻塞。解法与 askq/plan 同源:
5379
- // mirror 读屏兜底, 按三件套形状 (pickAutoAllowAnswer) 认权限确认框, 分三路:
5380
- // - danger.skipAll 开 → 代按一次性裸 "Yes" (放宽静态权限的变体永不自动选中);
5381
- // - 本 chat 的 ⏱自动窗口内、且内层工具不踩危险名单 → 同上代按;
5382
- // - 否则 → 把框转成标准审批卡发到本 chat (runMirrorPickerFlow), 点击后代按
5534
+ // mirror 读屏兜底, 按三件套形状 (pickAutoAllowAnswer) 认权限确认框:
5535
+ // - 上下文完整且现有工具策略放行 → 只代按一次性裸 Yes;
5536
+ // - denyRules 命中 → 拒绝; 未知参数 / askRules / 强制审批不吃窗口;
5537
+ // - 其它 → 把框转成标准审批卡发到本 chat (runMirrorPickerFlow), 点击后代按
5383
5538
  // 对应选项; 本地先按掉则 moot 作废卡。代按/发卡都发 chat 回执留痕。
5384
5539
  // WEZARD_PICKER_SENTINEL=0 整体关闭。
5385
5540
  let pickerTicking = false;
@@ -5393,6 +5548,10 @@ export const startMirror = (deps) => {
5393
5548
  for (const a of byTarget.values()) {
5394
5549
  if (!a.tmuxPane || a.migrationWatcher)
5395
5550
  continue;
5551
+ // Codex PermissionRequest has its own independent native gate. Screen
5552
+ // options, tool windows and skipAll never authorize its sandbox.
5553
+ if (cliOf(a) === "codex")
5554
+ continue;
5396
5555
  if (!(await tmuxPaneAlive(a.tmuxPane)))
5397
5556
  continue;
5398
5557
  const v = await detectModalPicker(a.tmuxPane);
@@ -5423,48 +5582,54 @@ export const startMirror = (deps) => {
5423
5582
  continue;
5424
5583
  }
5425
5584
  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;
5585
+ const policy = pickerPolicy(cfg, cliOf(a), ctx, isAutoWindowActive(a.target));
5586
+ if (policy.decision === "leave") {
5587
+ log.info({ target: a.target, reason: policy.reason }, "picker sentinel: independent approval left on CLI");
5444
5588
  }
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)
5589
+ await dispatchPickerPolicy(policy, {
5590
+ deny: async () => {
5591
+ const result = await pressNativePicker(a, modalFingerprint(v.screen), "no");
5592
+ log.info({ target: a.target, reason: policy.reason, result }, "picker sentinel: deny rule");
5593
+ sendStandalone(a, `[mirror] 🚫 ${policy.reason},${result.ok ? "已拒绝 CLI 确认" : "确认框已变更,请回 CLI 处理"}`);
5594
+ },
5595
+ allow: async () => {
5596
+ const after = await pressModalIndex(a.tmuxPane, v, pick.index);
5597
+ if (after.modal) {
5598
+ // 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
5599
+ a.pickerSentinelFp = after.screen;
5600
+ log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
5601
+ return;
5602
+ }
5466
5603
  a.pickerSentinelFp = undefined;
5467
- }).catch((e) => log.warn({ err: e.message, target: a.target }, "mirror picker flow failed"));
5604
+ log.info({ target: a.target, title: v.title, via: policy.reason, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
5605
+ sendStandalone(a, `[mirror] 🔓 ${policy.reason} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
5606
+ },
5607
+ manual: () => {
5608
+ // 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
5609
+ // 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
5610
+ log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, reason: policy.reason }, "picker sentinel: forwarding native picker as approval card");
5611
+ runMirrorPickerFlow({
5612
+ log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
5613
+ client,
5614
+ cfg,
5615
+ sessionId: a.sessionId,
5616
+ chatKey: a.target,
5617
+ cwd: a.runningCwd,
5618
+ jsonlPath: a.jsonlPath,
5619
+ toolName: ctx.toolName,
5620
+ toolInput: ctx.toolInput,
5621
+ agent: ctx.agent,
5622
+ danger: policy.forceSingle,
5623
+ voteTimeoutMs: cfg.approval.longPollSec * 1000,
5624
+ press: (what) => pressNativePicker(a, modalFingerprint(v.screen), what),
5625
+ }).then((sent) => {
5626
+ // 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
5627
+ // 否则这只框会被指纹永久静默。
5628
+ if (!sent)
5629
+ a.pickerSentinelFp = undefined;
5630
+ }).catch((e) => log.warn({ err: e.message, target: a.target }, "mirror picker flow failed"));
5631
+ },
5632
+ });
5468
5633
  }
5469
5634
  }
5470
5635
  catch (e) {
@@ -5478,15 +5643,19 @@ export const startMirror = (deps) => {
5478
5643
  // 审批卡点击后的代按回调。点击与本地按键之间隔着人的反应时间, 必须重读屏并
5479
5644
  // 校验仍是**同一个调用**的确认框 —— 期间本地按掉旧框又弹了新框的话, 盲按就是
5480
5645
  // 替用户批准了他没看过的东西。
5481
- const pressNativePicker = async (a, expectToolName, what) => {
5646
+ const pressNativePicker = async (a, expectFingerprint, what) => {
5482
5647
  if (!a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
5483
5648
  return { ok: false, reason: "pane 已不在" };
5649
+ if (cliOf(a) === "codex")
5650
+ return { ok: false, reason: "Codex 原生沙箱审批必须单独处理" };
5484
5651
  const v = await detectModalPicker(a.tmuxPane);
5485
5652
  if (!v.modal)
5486
5653
  return { ok: false, reason: "确认框已不在(可能已在本地处理)" };
5487
- const now = parseConfirmContext(v.screen);
5488
- if (expectToolName && now.toolName !== expectToolName) {
5489
- return { ok: false, reason: `当前确认框已是另一个调用(${now.toolName}),不代按` };
5654
+ if (pickerPolicy(cfg, cliOf(a), parseConfirmContext(v.screen), false).decision === "leave") {
5655
+ return { ok: false, reason: "独立原生或交互审批,请在 CLI 中处理" };
5656
+ }
5657
+ if (modalFingerprint(v.screen) !== expectFingerprint) {
5658
+ return { ok: false, reason: "当前确认框的操作或参数已变更,不代按" };
5490
5659
  }
5491
5660
  if (what === "no") {
5492
5661
  await runTmux(["send-keys", "-t", a.tmuxPane, "Escape"]);
@@ -5523,6 +5692,8 @@ export const startMirror = (deps) => {
5523
5692
  const a = byTarget.get(target);
5524
5693
  if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
5525
5694
  return { ok: false, reason: "它没有活着的 pane —— 模型是在 pane 里切的, 先叫醒它" };
5695
+ if (backendForPath(a.jsonlPath).name === "codex")
5696
+ return { ok: false, reason: "Codex 运行中的模型请在终端 /model 切换;新开会话可指定 model" };
5526
5697
  const r = await selectModel(a.tmuxPane, wanted, log.child({ sub: "set-model", target }), scope);
5527
5698
  if (r.ok && r.applied) {
5528
5699
  a.model = r.applied;
@@ -5536,6 +5707,8 @@ export const startMirror = (deps) => {
5536
5707
  const a = byTarget.get(target);
5537
5708
  if (!a?.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
5538
5709
  return { ok: false, reason: "它没有活着的 pane —— 档位是在 pane 里切的, 先叫醒它" };
5710
+ if (backendForPath(a.jsonlPath).name === "codex")
5711
+ return { ok: false, reason: "Codex 运行中的推理强度请在终端 /model 切换;新开会话可指定 effort" };
5539
5712
  const r = await selectEffort(a.tmuxPane, effort, log.child({ sub: "set-effort", target }));
5540
5713
  if (r.ok && r.applied) {
5541
5714
  a.effort = r.applied;
@@ -5761,7 +5934,7 @@ export const startMirror = (deps) => {
5761
5934
  // Same resume-fork hazard as dispatch: snapshot before spawn, re-bind
5762
5935
  // onto the forked jsonl once it appears (EOF offset — fork is seeded).
5763
5936
  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 }) });
5937
+ 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
5938
  if (!r.ok || !r.tmuxPane)
5766
5939
  return { ok: false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
5767
5940
  a.tmuxPane = r.tmuxPane;
@@ -5892,7 +6065,7 @@ export const startMirror = (deps) => {
5892
6065
  // allTargets 是几百个 key, 见 target 就问 tmux 会打穿 fd (同 worldPeers 的
5893
6066
  // WORLD_PROBE 取舍)。
5894
6067
  const orphans = [];
5895
- const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}\t#{session_name}\t#{window_activity}"]);
6068
+ const snap = await runTmux(["list-panes", "-a", "-F", "#{pane_id}\t#{window_active}:#{session_attached}\t#{session_name}\t#{pane_pid}"]);
5896
6069
  if (snap.code !== 0)
5897
6070
  return { reaped, orphans, skipped };
5898
6071
  const rows = snap.stdout.split("\n").filter(Boolean).map((l) => l.split("\t"));
@@ -5918,15 +6091,38 @@ export const startMirror = (deps) => {
5918
6091
  // 丢了 pane id (restore 认不出 → 下一条消息另起新 pane, 旧 pane 不杀) 之后留下的。
5919
6092
  // 没有地址能再叫到它, 却不在下面 `live` 的账上, 攒多少都触不到上限; 所以不占
5920
6093
  // 名额、不等超限, 见到就收。刚起的 pane 在 attach 之前也长这样 (并发 spawn),
5921
- // 用 window_activity 留一段宽限。只收会话 pane: 注册表认得里面住着活的 AI 会话
5922
- // 才算; 普通 pane (人自己开的 shell / 编辑器, 会话已退回 shell 的, 没有注册表的
5923
- // 后端) 一律跳过 —— 认不出就不动。
6094
+ // 用 window_activity 留一段宽限。只收会话 pane: pane 的进程树里有注册表登记且
6095
+ // 还活着的 CLI 进程才算 (pane_pid 是 shell, CLI 是它的子孙, 所以沿 ps 快照往下
6096
+ // 找)。不看注册表的 `tmux` 字段 —— codebuddy 的注册表没有它, 那条路对 codebuddy
6097
+ // 全盲。普通 pane (人自己开的 shell / ssh / 编辑器, 会话已退回 shell 的) 树里
6098
+ // 没有注册表 pid, 一律跳过 —— 认不出就不动。
5924
6099
  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))
6100
+ const regPids = new Set(activeBackends().flatMap((b) => [...liveSessionPids(b.homeDir)]));
6101
+ const ps = await psSnap();
6102
+ // 沿进程树找注册表 pid; 找到返回那个 pid (它的 etimes 供宽限判), 找不到返回 0。
6103
+ const hostedBy = (pid) => {
6104
+ const seen = new Set();
6105
+ const stack = [pid];
6106
+ while (stack.length) {
6107
+ const p = stack.pop() ?? 0;
6108
+ if (regPids.has(p))
6109
+ return p;
6110
+ if (seen.has(p))
6111
+ continue;
6112
+ seen.add(p);
6113
+ for (const k of ps.kids.get(p) ?? [])
6114
+ stack.push(k);
6115
+ }
6116
+ return 0;
6117
+ };
6118
+ for (const [pane = "", , session, pid] of rows) {
6119
+ if (session !== cfg.wrc.tmuxPrefix || bound.has(pane))
6120
+ continue;
6121
+ const cliPid = hostedBy(Number(pid) || 0);
6122
+ if (!cliPid)
5928
6123
  continue;
5929
- if (now - Number(activity) * 1000 < ORPHAN_GRACE_MS)
6124
+ // 宽限按 CLI 进程年龄, 不按 window_activity: TUI 重绘会让它永远「刚动过」。
6125
+ if ((ps.etimes.get(cliPid) ?? Number.POSITIVE_INFINITY) * 1000 < ORPHAN_GRACE_MS)
5930
6126
  continue;
5931
6127
  const why = await putDown(pane);
5932
6128
  if (why) {
@@ -5934,7 +6130,7 @@ export const startMirror = (deps) => {
5934
6130
  continue;
5935
6131
  }
5936
6132
  orphans.push(pane);
5937
- log.info({ pane, idleHours: Math.round((now - Number(activity) * 1000) / 3.6e6) }, "mirror pane cap — orphan pane killed (no binding)");
6133
+ log.info({ pane, cliPid, cliAgeHours: Math.round((ps.etimes.get(cliPid) ?? 0) / 3600) }, "mirror pane cap — orphan pane killed (no binding)");
5938
6134
  }
5939
6135
  const live = targets.filter((t) => watchedBy.has(paneOf(t)));
5940
6136
  let over = live.length - max;
@@ -6029,6 +6225,8 @@ export const startMirror = (deps) => {
6029
6225
  hasLivePane: (sessionId) => Boolean(bySessionId.get(sessionId)?.tmuxPane),
6030
6226
  answerNativeModal: async (sessionId, opts) => {
6031
6227
  const a = bySessionId.get(sessionId);
6228
+ if (a && cliOf(a) === "codex")
6229
+ return { status: "no_modal", reason: "Codex native approval is independent" };
6032
6230
  if (!a?.tmuxPane)
6033
6231
  return { status: "no_pane", reason: "no live tmux pane for session" };
6034
6232
  if (!(await tmuxPaneAlive(a.tmuxPane)))
@@ -6046,6 +6244,9 @@ export const startMirror = (deps) => {
6046
6244
  // 没等到: 这次 CC 没弹框(会话内已授权过 / 命中盲点判断失误)。无事可做。
6047
6245
  if (!v.modal)
6048
6246
  return { status: "no_modal" };
6247
+ if (pickerPolicy(cfg, cliOf(a), parseConfirmContext(v.screen), false).decision === "leave") {
6248
+ return { status: "no_modal", reason: "independent native approval left on CLI" };
6249
+ }
6049
6250
  const pick = pickModalAnswer(parseModalOptions(v.screen), v.title);
6050
6251
  if (!pick) {
6051
6252
  lg.warn({ title: v.title }, "native modal: no trustworthy one-shot option, not pressing");
@@ -6086,6 +6287,12 @@ export const startMirror = (deps) => {
6086
6287
  bySessionId.clear();
6087
6288
  byTarget.clear();
6088
6289
  },
6290
+ currentAsker: (target) => {
6291
+ const a = byTarget.get(target);
6292
+ if (a?.convFrom)
6293
+ return a.convFrom.asker;
6294
+ return a?.speaker ? { who: a.speaker, chat: a.channel ?? baseOfKey(target) } : undefined;
6295
+ },
6089
6296
  currentChannel: (target) => {
6090
6297
  const a = byTarget.get(target);
6091
6298
  return a?.channel ?? baseOfKey(target);
@@ -6268,7 +6475,7 @@ export const startMirror = (deps) => {
6268
6475
  const resumeBaseline = !armMigration ? listJsonls(dirname(a.jsonlPath)) : undefined;
6269
6476
  // Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
6270
6477
  // 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 }) });
6478
+ 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
6479
  if (r.ok && r.tmuxPane && r.tmuxSession) {
6273
6480
  a.tmuxPane = r.tmuxPane;
6274
6481
  a.tmuxSession = r.tmuxSession;