wezard 2.7.7 → 2.8.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.
@@ -1,7 +1,7 @@
1
1
  import { finalId, receiptCoverage, receiptQuiet } from "./message-events.js";
2
2
  import { parseEnvelope, renderTail } from "../shared/reminder.js";
3
3
  import { codexSessions, codexSessionForPane, recordCodexPaneSession } from "../shared/codex.js";
4
- import { codexHookLaunchArgs, codexHookCommand } from "../shared/codex-hooks.js";
4
+ import { codexHookLaunchArgs, codexHookCommand, inspectCodexHooks, trustCodexHookSet } from "../shared/codex-hooks.js";
5
5
  import { fileURLToPath } from "node:url";
6
6
  // Mirror-mode bridge.
7
7
  //
@@ -30,7 +30,7 @@ import { withImageClipboard, pasteVerifiedImage } from "./image-input.js";
30
30
  import { createKeyedScheduler } from "../shared/keyed-scheduler.js";
31
31
  import { withPaneInput } from "./pane-input.js";
32
32
  import { activeBackends, resolveCliBackend, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
33
- import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, modalFingerprint, looksLikePicker } from "../shared/modal-pane.js";
33
+ import { isModalPane, isCodexHookReviewPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, modalFingerprint, looksLikePicker } from "../shared/modal-pane.js";
34
34
  import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
35
35
  import { isAutoWindowActive } from "./session-cache.js";
36
36
  import { noticeSuffixFor } from "./notices.js";
@@ -2046,8 +2046,11 @@ const injectViaTmuxText = async (target, text, log, freshSpawn, witness = NO_WIT
2046
2046
  }
2047
2047
  // 补到头仍没证据: 可能只是渲染/落盘迟到, 也可能根本没提交。
2048
2048
  // 保留 uncertain 阻止重贴, 但不能让只看 ok 的调用方把它当成已送达。
2049
+ // 冷 TUI 的框布局未知时也可能已经提交;同样等原生事件见证,期间不再输入。
2050
+ if (requireEventEvidence && await lateEvidence(LATE_LAND_MS))
2051
+ return { ok: true };
2049
2052
  log.warn({ target, tailFp, freshSpawn, boxTrusted, witness: witness.status }, "mirror inject: submission unconfirmed, stopping input");
2050
- return { ok: false, uncertain: true, reason: "目标会话未确认收下消息,停止输入以避免误提交或重复投递" };
2053
+ return { ok: false, uncertain: true, reason: "暂时无法确认目标会话是否收到消息,已停止继续输入;为避免重复执行,不会自动重发" };
2051
2054
  };
2052
2055
  const injectViaSpawn = async (args) => {
2053
2056
  const { text, images = [], cfg, log, sessionId, jsonlPath } = args;
@@ -2503,7 +2506,7 @@ export const startMirror = (deps) => {
2503
2506
  return;
2504
2507
  s.flushTimer = setTimeout(() => void flushStream(s), FLUSH_MS);
2505
2508
  };
2506
- const finalizeStream = async (a, s, timedOut = false) => {
2509
+ const finalizeStream = async (a, s, timedOut = false, stillWanted) => {
2507
2510
  if (s.closed)
2508
2511
  return;
2509
2512
  s.closed = true;
@@ -2534,14 +2537,16 @@ export const startMirror = (deps) => {
2534
2537
  await s.pending?.catch(() => undefined);
2535
2538
  if (!eventOwned(s.eventId))
2536
2539
  return;
2537
- if (card) {
2538
- s.cardSent = true;
2539
- await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
2540
- }
2541
- else {
2542
- await client.replyStream(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true);
2540
+ if (stillWanted?.() !== false) {
2541
+ if (card) {
2542
+ s.cardSent = true;
2543
+ await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
2544
+ }
2545
+ else {
2546
+ await client.replyStream(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true);
2547
+ }
2548
+ log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, withCard: !!card, timedOut }, "stream finalize");
2543
2549
  }
2544
- log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, withCard: !!card, timedOut }, "stream finalize");
2545
2550
  }
2546
2551
  catch (e) {
2547
2552
  log.warn({ sessionId: a.sessionId, turnId: s.turnId, err: e.message }, "stream finalize failed");
@@ -2601,7 +2606,7 @@ export const startMirror = (deps) => {
2601
2606
  // 推到本轮的频道 (人从别的群叫它 → 回那个群)。私聊轮的正文 brief 下在上游
2602
2607
  // turnSilent 已被拦下; 非 brief 的逐条推送在这里兜一道 —— 只放 `[mirror]` /
2603
2608
  // `[wezard]` 系统提示 (它们回 home)。
2604
- const sendStandalone = (a, content) => {
2609
+ const sendStandalone = (a, content, stillWanted) => {
2605
2610
  const eventId = a.activeEventId;
2606
2611
  if (!hasVisibleBody(content) || !eventOwned(eventId))
2607
2612
  return;
@@ -2614,7 +2619,7 @@ export const startMirror = (deps) => {
2614
2619
  a.standalonePending = a.standalonePending
2615
2620
  .then(async () => {
2616
2621
  for (const c of chunks) {
2617
- if (!eventOwned(eventId))
2622
+ if (!eventOwned(eventId) || stillWanted?.() === false)
2618
2623
  return;
2619
2624
  try {
2620
2625
  const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
@@ -5999,8 +6004,339 @@ export const startMirror = (deps) => {
5999
6004
  // One pool for the picker sentinel's pane probes: slow panes do not stall
6000
6005
  // other panes, and scans on the same pane never overlap.
6001
6006
  const maintenance = createKeyedScheduler(4);
6007
+ let hookReviewStopping = false;
6008
+ const hookReviews = new Map();
6009
+ const hookReviewBySession = new Map();
6010
+ const hookCardFailures = new Map();
6011
+ const hookDetails = (hooks) => {
6012
+ const allEnabled = hooks.every((hook) => hook.enabled);
6013
+ const items = hooks.map((hook, index) => [
6014
+ `${index + 1}. Codex hook`,
6015
+ `source: ${hook.sourcePath}`,
6016
+ `event: ${hook.eventName}`,
6017
+ `matcher: ${hook.matcher}`,
6018
+ `key: ${hook.key}`,
6019
+ `enabled: ${hook.enabled}`,
6020
+ `trust: ${hook.trustStatus}`,
6021
+ `hash: ${hook.currentHash}`,
6022
+ `command: ${hook.command}`,
6023
+ ].join("\n")).join("\n\n");
6024
+ return `Codex 待审 hooks 清单(${allEnabled ? "以下 hooks 均已启用" : "逐项审查"}):\n\n${items}`;
6025
+ };
6026
+ const renderHookReviewCard = (id, hooks, status = "选择操作") => ({
6027
+ card_type: "vote_interaction",
6028
+ source: { desc: "Codex hook 信任审查" },
6029
+ main_title: { title: `${hooks.length} 个 Codex hooks 需要审查` },
6030
+ task_id: id,
6031
+ checkbox: { question_key: "action", mode: 0, option_list: [
6032
+ { id: "review", text: "查看 hooks" },
6033
+ { id: "trust", text: "精确授信并继续" },
6034
+ { id: "skip", text: "不授信并继续" },
6035
+ ] },
6036
+ submit_button: { text: status, key: `codex-hook-review:${id}:submit` },
6037
+ });
6038
+ const renderHookReviewProgress = (id, status) => ({
6039
+ card_type: "vote_interaction", source: { desc: "Codex hook 信任审查" },
6040
+ main_title: { title: status }, task_id: id,
6041
+ checkbox: { question_key: "status", mode: 0, option_list: [{ id: "pending", text: "请稍候" }] },
6042
+ submit_button: { text: "处理中…", key: `codex-hook-review:${id}:pending` },
6043
+ });
6044
+ const sendHookDetails = async (review) => {
6045
+ const content = hookDetails(review.hooks);
6046
+ const chatId = replyChatId(review.a);
6047
+ const chunks = splitMarkdown(withLinkedTag(review.a, content), Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
6048
+ for (const chunk of chunks) {
6049
+ if (!review.valid())
6050
+ return;
6051
+ await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: chunk } });
6052
+ }
6053
+ };
6054
+ const sendHookResult = async (review, text) => {
6055
+ for (let attempt = 0; attempt <= 5; attempt++) {
6056
+ if (!review.canNotify())
6057
+ return false;
6058
+ try {
6059
+ await client.sendMessage(replyChatId(review.a), { msgtype: "markdown", markdown: { content: withLinkedTag(review.a, text) } });
6060
+ return true;
6061
+ }
6062
+ catch (error) {
6063
+ log.warn({ err: errText(error), id: review.id, attempt }, "Codex hook result delivery failed; retrying");
6064
+ if (attempt < 5)
6065
+ await sleep(Math.min(60_000, 2_000 * 2 ** attempt));
6066
+ }
6067
+ }
6068
+ if (review.canNotify() && review.frame) {
6069
+ try {
6070
+ await client.updateTemplateCard(review.frame, renderHookReviewProgress(review.id, "结果消息发送失败,请查看本机 CLI"));
6071
+ }
6072
+ catch (fallbackError) {
6073
+ log.error({ err: errText(fallbackError), id: review.id }, "Codex hook result and fallback card delivery failed");
6074
+ }
6075
+ }
6076
+ return false;
6077
+ };
6078
+ const codexPid = async (pane) => {
6079
+ const root = await panePidOf(pane);
6080
+ const ps = await psSnap();
6081
+ const pids = new Set([...ps.commands].filter(([, command]) => basename(command) === "codex").map(([pid]) => pid));
6082
+ return root ? registeredPidUnder(root, pids, ps.kids) : undefined;
6083
+ };
6084
+ const chooseCodexHookOption = async (review, v, index, live) => {
6085
+ if (!isCodexHookReviewPane(v.screen) || modalFingerprint(v.screen) !== review.fingerprint)
6086
+ return false;
6087
+ const options = parseModalOptions(v.screen);
6088
+ const target = options.find((option) => option.index === index);
6089
+ if ((index === 2 && target?.label !== "Trust all and continue")
6090
+ || (index === 3 && !/^Continue without trusting\b/u.test(target?.label ?? "")))
6091
+ return false;
6092
+ let selected = options.find((option) => option.selected)?.index;
6093
+ if (!target || selected === undefined)
6094
+ return false;
6095
+ for (let n = 0; selected !== index && n < options.length; n++) {
6096
+ if (!(await live()))
6097
+ return false;
6098
+ await runTmux(["send-keys", "-t", review.pane, selected < index ? "Down" : "Up"]);
6099
+ await sleep(MODAL_ARROW_SETTLE_MS);
6100
+ const next = await detectModalPicker(review.pane);
6101
+ if (!(await live()) || !isCodexHookReviewPane(next.screen) || modalFingerprint(next.screen) !== review.fingerprint)
6102
+ return false;
6103
+ selected = parseModalOptions(next.screen).find((option) => option.selected)?.index;
6104
+ if (selected === undefined)
6105
+ return false;
6106
+ }
6107
+ const current = await detectModalPicker(review.pane);
6108
+ if (!(await live()) || !isCodexHookReviewPane(current.screen) || modalFingerprint(current.screen) !== review.fingerprint
6109
+ || parseModalOptions(current.screen).find((option) => option.selected)?.index !== index)
6110
+ return false;
6111
+ if (!(await runTmux(["send-keys", "-t", review.pane, "Enter"])).ok)
6112
+ return false;
6113
+ for (let end = Date.now() + 3000; Date.now() < end; await sleep(100)) {
6114
+ const after = await detectModalPicker(review.pane);
6115
+ if (!(await live()))
6116
+ return false;
6117
+ if (!isCodexHookReviewPane(after.screen))
6118
+ return Boolean(after.screen.trim());
6119
+ }
6120
+ return false;
6121
+ };
6122
+ client.on("event.template_card_event", async (frame) => {
6123
+ const ev = frame.body?.event;
6124
+ const key = ev?.template_card_event?.event_key ?? ev?.event_key ?? "";
6125
+ const match = /^codex-hook-review:([^:]+):submit$/u.exec(key);
6126
+ const review = match ? hookReviews.get(match[1]) : undefined;
6127
+ if (!review || !review.valid())
6128
+ return;
6129
+ const selected = ev?.template_card_event?.selected_items;
6130
+ const first = Array.isArray(selected) ? selected[0] : selected?.selected_item?.[0];
6131
+ const optionIds = first?.option_ids;
6132
+ const action = (Array.isArray(optionIds) ? optionIds : optionIds?.option_id ?? [])[0];
6133
+ if (action === "review") {
6134
+ try {
6135
+ await client.updateTemplateCard(frame, renderHookReviewCard(review.id, review.hooks));
6136
+ }
6137
+ catch (error) {
6138
+ log.warn({ err: errText(error), id: review.id }, "Codex hook review card reset failed");
6139
+ }
6140
+ try {
6141
+ await sendHookDetails(review);
6142
+ }
6143
+ catch (error) {
6144
+ log.warn({ err: errText(error), pane: review.pane }, "Codex hook detail delivery failed");
6145
+ await sendHookResult(review, "完整 hook 清单发送失败;请稍后重试查看,未执行任何授权。");
6146
+ }
6147
+ return;
6148
+ }
6149
+ if (action !== "trust" && action !== "skip")
6150
+ return;
6151
+ if (review.busy)
6152
+ return;
6153
+ review.busy = true;
6154
+ review.frame = frame;
6155
+ try {
6156
+ await client.updateTemplateCard(frame, renderHookReviewProgress(review.id, "正在处理 Codex hooks"));
6157
+ }
6158
+ catch (error) {
6159
+ log.warn({ err: errText(error), id: review.id }, "Codex hook processing card update failed");
6160
+ }
6161
+ if (!review.valid()) {
6162
+ review.busy = false;
6163
+ return;
6164
+ }
6165
+ let status = "授权未完成;确认卡已失效";
6166
+ let terminal = false;
6167
+ try {
6168
+ const result = await withPaneInput(review.pane, async (live) => {
6169
+ const valid = async () => {
6170
+ if (!review.valid() || !live() || await panePidOf(review.pane) !== review.rootPid)
6171
+ return false;
6172
+ const currentSession = codexSessionForPane(review.pane, cfg.daemon.stateDir)?.sessionId;
6173
+ if (currentSession && currentSession !== review.sid)
6174
+ return false;
6175
+ return await codexPid(review.pane) === review.pid;
6176
+ };
6177
+ if (!(await valid()))
6178
+ return "pane 身份已变化,未执行操作";
6179
+ let before = await detectModalPicker(review.pane);
6180
+ if (!isCodexHookReviewPane(before.screen) || modalFingerprint(before.screen) !== review.fingerprint)
6181
+ return "弹窗已变化或已由本地处理,未执行操作";
6182
+ const backend = backendForPath(review.a.jsonlPath);
6183
+ const homeDir = expandHome(backend.homeDir);
6184
+ const current = await inspectCodexHooks(backend.bin, review.a.runningCwd, [], homeDir);
6185
+ if (!(await valid()))
6186
+ return "daemon 已停止或 pane 身份改变;未执行操作";
6187
+ const currentByKey = new Map(current.map((hook) => [hook.key, hook]));
6188
+ if (review.hooks.some((hook) => {
6189
+ const now = currentByKey.get(hook.key);
6190
+ return !now || !now.enabled || now.sourcePath !== hook.sourcePath || now.currentHash !== hook.currentHash
6191
+ || now.eventName !== hook.eventName || now.command !== hook.command || now.matcher !== hook.matcher;
6192
+ }))
6193
+ return "Codex hooks changed since review; 卡片已失效,未继续";
6194
+ const pending = current.filter((hook) => hook.enabled && hook.trustStatus !== "trusted");
6195
+ const reviewedKeys = new Set(review.hooks.map((hook) => hook.key));
6196
+ if (pending.some((hook) => !reviewedKeys.has(hook.key)))
6197
+ return "出现未展示的待审 hook;卡片已失效,未继续";
6198
+ // “Trust all” can authorize hooks outside this card. Exact hashes have
6199
+ // been persisted and verified above; continue via the native skip path.
6200
+ const option = 3;
6201
+ if (action === "trust") {
6202
+ if (pending.length) {
6203
+ await trustCodexHookSet(backend.bin, review.a.runningCwd, homeDir, pending.map(({ key, currentHash, sourcePath }) => ({ key, currentHash, sourcePath })), valid);
6204
+ }
6205
+ if (!(await valid()))
6206
+ return "hash 已写入,但 pane 身份改变;请在 CLI 核对继续状态";
6207
+ const verified = await inspectCodexHooks(backend.bin, review.a.runningCwd, [], homeDir);
6208
+ if (!(await valid()))
6209
+ return "daemon 已停止或 pane 身份改变;未继续操作";
6210
+ if (review.hooks.some((hook) => {
6211
+ const now = verified.find((item) => item.key === hook.key);
6212
+ return !now || !now.enabled || now.sourcePath !== hook.sourcePath || now.currentHash !== hook.currentHash
6213
+ || now.eventName !== hook.eventName || now.command !== hook.command || now.matcher !== hook.matcher;
6214
+ }) || verified.some((hook) => hook.enabled && hook.trustStatus !== "trusted"
6215
+ && !review.hooks.some((item) => item.key === hook.key))) {
6216
+ return "hash 验证后 hook 集发生变化;弹窗已失效,未继续";
6217
+ }
6218
+ const afterTrust = await detectModalPicker(review.pane);
6219
+ if (!afterTrust.modal)
6220
+ return "审查清单中的 hook 已精确授信;弹窗已消失,未代为继续";
6221
+ if (!isCodexHookReviewPane(afterTrust.screen) || modalFingerprint(afterTrust.screen) !== review.fingerprint) {
6222
+ return "hash 已验证,但弹窗已变化;请在 CLI 核对继续状态";
6223
+ }
6224
+ before = afterTrust;
6225
+ }
6226
+ const liveGuard = valid;
6227
+ const pressed = await chooseCodexHookOption(review, before, option, liveGuard);
6228
+ if (!pressed)
6229
+ return action === "trust"
6230
+ ? "hash 已按审查清单写入并验证;CLI 确认未完成,请在本地继续"
6231
+ : "未信任任何 hook;CLI 确认未完成,请在本地继续";
6232
+ return action === "trust"
6233
+ ? "审查清单中的 hook 已精确授信;本次启动选择了不信任继续,hooks 本次不会运行"
6234
+ : "未信任 hook,Codex 已继续;hooks 本次不会运行";
6235
+ }, { guard: review.valid });
6236
+ status = result;
6237
+ terminal = /Codex 已继续|本次启动选择了不信任继续/u.test(result);
6238
+ }
6239
+ catch (error) {
6240
+ status = `授权未完成:${errText(error)}`;
6241
+ log.warn({ err: errText(error), pane: review.pane }, "Codex hook trust action failed");
6242
+ }
6243
+ review.resultPending = true;
6244
+ await sendHookResult(review, status);
6245
+ review.resultPending = false;
6246
+ review.busy = false;
6247
+ if (terminal || /弹窗已变化|pane 身份已变化|卡片已失效|未展示的待审 hook|hook 集发生变化/u.test(status)) {
6248
+ review.active = false;
6249
+ hookReviews.delete(review.id);
6250
+ hookReviewBySession.delete(review.sid);
6251
+ review.valid = () => false;
6252
+ }
6253
+ });
6002
6254
  const maintenanceValid = (a, pane, sid, gen, signal) => !signal.aborted && byTarget.get(a.target) === a && a.tmuxPane === pane && a.sessionId === sid
6003
6255
  && (a.injectGen ?? 0) === gen && !a.migrationWatcher;
6256
+ const codexHookReviewOne = async (a, pane, sid, gen, signal) => {
6257
+ const v = await detectModalPicker(pane);
6258
+ if (!isCodexHookReviewPane(v.screen)) {
6259
+ const stale = hookReviewBySession.get(sid);
6260
+ if (stale) {
6261
+ const previous = hookReviews.get(stale);
6262
+ if (previous?.busy || previous?.resultPending)
6263
+ return;
6264
+ if (previous)
6265
+ previous.active = false;
6266
+ hookReviewBySession.delete(sid);
6267
+ hookReviews.delete(stale);
6268
+ }
6269
+ a.pickerSentinelFp = undefined;
6270
+ return;
6271
+ }
6272
+ if (hookReviewBySession.has(sid))
6273
+ return;
6274
+ const fingerprint = modalFingerprint(v.screen);
6275
+ const failure = hookCardFailures.get(sid);
6276
+ if (failure?.fingerprint === fingerprint && Date.now() < failure.retryAt)
6277
+ return;
6278
+ if (failure && failure.fingerprint !== fingerprint)
6279
+ hookCardFailures.delete(sid);
6280
+ const rootPid = await panePidOf(pane);
6281
+ const pid = await codexPid(pane);
6282
+ const currentSession = codexSessionForPane(pane, cfg.daemon.stateDir)?.sessionId;
6283
+ if (!rootPid || !pid || (currentSession && currentSession !== sid)
6284
+ || byTarget.get(a.target) !== a || a.tmuxPane !== pane || a.sessionId !== sid || (a.injectGen ?? 0) !== gen)
6285
+ return;
6286
+ const backend = backendForPath(a.jsonlPath);
6287
+ let hooks;
6288
+ try {
6289
+ hooks = await inspectCodexHooks(backend.bin, a.runningCwd, [], expandHome(backend.homeDir));
6290
+ }
6291
+ catch (error) {
6292
+ log.warn({ err: errText(error), target: a.target }, "Codex hook review enumeration failed");
6293
+ return;
6294
+ }
6295
+ const pending = hooks.filter((hook) => hook.enabled && hook.trustStatus !== "trusted");
6296
+ const summary = /(?:^|\n)\s*(\d+)\s+hooks?\s+(?:is|are)\s+new or changed\./iu.exec(v.screen);
6297
+ if (!summary || Number(summary[1]) !== pending.length || !pending.length || pending.some((hook) => !hook.key || !hook.eventName || !hook.sourcePath
6298
+ || !hook.currentHash || !/^sha256:[0-9a-f]{64}$/iu.test(hook.currentHash) || !hook.command)) {
6299
+ log.info({ target: a.target, screenCount: summary?.[1], count: pending.length }, "Codex hook review not forwarded: incomplete or empty review set");
6300
+ return;
6301
+ }
6302
+ const id = randomUUID();
6303
+ const review = {
6304
+ id, a, pane, sid, gen, pid, rootPid, fingerprint, screen: v.screen,
6305
+ hooks: pending, busy: false, active: true, signal,
6306
+ valid: () => review.active && !hookReviewStopping && !maintenance.signal.aborted && !signal.aborted
6307
+ && byTarget.get(a.target) === a && a.tmuxPane === pane && a.sessionId === sid
6308
+ && (a.injectGen ?? 0) === gen && !a.migrationWatcher,
6309
+ canNotify: () => !hookReviewStopping && !maintenance.signal.aborted && !signal.aborted,
6310
+ };
6311
+ try {
6312
+ const chatId = replyChatId(a);
6313
+ const chunks = splitMarkdown(withLinkedTag(a, hookDetails(pending)), Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET));
6314
+ for (const content of chunks) {
6315
+ if (!review.valid())
6316
+ return;
6317
+ await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content } });
6318
+ }
6319
+ if (!review.valid())
6320
+ return;
6321
+ hookReviews.set(id, review);
6322
+ hookReviewBySession.set(sid, id);
6323
+ await client.sendMessage(chatId, { msgtype: "template_card", template_card: renderHookReviewCard(id, pending) });
6324
+ hookCardFailures.delete(sid);
6325
+ a.pickerSentinelFp = v.screen;
6326
+ log.info({ target: a.target, sessionId: sid, id, count: pending.length }, "Codex hook review card sent");
6327
+ }
6328
+ catch (error) {
6329
+ review.active = false;
6330
+ hookReviews.delete(id);
6331
+ hookReviewBySession.delete(sid);
6332
+ const previous = hookCardFailures.get(sid);
6333
+ const attempts = previous?.fingerprint === fingerprint ? previous.attempts + 1 : 1;
6334
+ const retryMs = Math.min(5 * 60_000, 5_000 * 2 ** (attempts - 1));
6335
+ hookCardFailures.set(sid, { fingerprint, attempts, retryAt: Date.now() + retryMs });
6336
+ a.pickerSentinelFp = v.screen;
6337
+ log.warn({ err: errText(error), target: a.target, attempts, retryMs }, "Codex hook review card send failed; retry backed off");
6338
+ }
6339
+ };
6004
6340
  // ── 原生权限 picker 哨兵 ──────────────────────────────────────────────
6005
6341
  // CodeBuddy 对一部分调用先弹自家权限 picker、PreToolUse hook 在人点掉之后才
6006
6342
  // 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
@@ -6013,7 +6349,7 @@ export const startMirror = (deps) => {
6013
6349
  // 对应选项; 本地先按掉则 moot 作废卡。代按/发卡都发 chat 回执留痕。
6014
6350
  // WEZARD_PICKER_SENTINEL=0 整体关闭。
6015
6351
  const pickerOne = async (a, pane, sid, gen, signal) => {
6016
- const guard = () => maintenanceValid(a, pane, sid, gen, signal) && cfg.approval.enabled
6352
+ const guard = () => maintenanceValid(a, pane, sid, gen, signal) && (cliOf(a) === "codex" || cfg.approval.enabled)
6017
6353
  && process.env.WEZARD_PICKER_SENTINEL !== "0";
6018
6354
  if (!guard())
6019
6355
  return;
@@ -6021,12 +6357,14 @@ export const startMirror = (deps) => {
6021
6357
  const valid = () => guard() && live();
6022
6358
  if (!a.tmuxPane || a.migrationWatcher)
6023
6359
  return;
6024
- // Codex PermissionRequest has its own independent native gate. Screen
6025
- // options, tool windows and skipAll never authorize its sandbox.
6026
- if (cliOf(a) === "codex")
6027
- return;
6028
6360
  if (!(await tmuxPaneAlive(pane)) || !valid())
6029
6361
  return;
6362
+ // Hook trust is a distinct startup gate: enumerate and display its exact
6363
+ // hash set; ordinary tool approval rules have no authority over it.
6364
+ if (cliOf(a) === "codex") {
6365
+ await codexHookReviewOne(a, pane, sid, gen, signal);
6366
+ return;
6367
+ }
6030
6368
  const v = await detectModalPicker(pane);
6031
6369
  if (!valid())
6032
6370
  return;
@@ -6123,10 +6461,10 @@ export const startMirror = (deps) => {
6123
6461
  }, { guard });
6124
6462
  };
6125
6463
  const pickerTick = () => {
6126
- if (!cfg.approval.enabled || process.env.WEZARD_PICKER_SENTINEL === "0")
6464
+ if (process.env.WEZARD_PICKER_SENTINEL === "0")
6127
6465
  return;
6128
6466
  for (const a of byTarget.values()) {
6129
- if (!a.tmuxPane || cliOf(a) === "codex")
6467
+ if (!a.tmuxPane || (cliOf(a) !== "codex" && !cfg.approval.enabled))
6130
6468
  continue;
6131
6469
  const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
6132
6470
  void maintenance.schedule(`picker:${a.target}`, pane, (signal) => pickerOne(a, pane, sid, gen, signal))
@@ -6635,7 +6973,7 @@ export const startMirror = (deps) => {
6635
6973
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
6636
6974
  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 }) });
6637
6975
  if (!r.ok || !r.tmuxPane)
6638
- return { ok: false, retryable: true, reason: `respawn failed: ${r.reason ?? "unknown"}` };
6976
+ return { ok: false, retryable: r.retryable !== false, reason: `respawn failed: ${r.reason ?? "unknown"}` };
6639
6977
  if (!permitted() || a.tmuxPane !== probedPane) {
6640
6978
  // This pane was born for an unsubmitted injection. Never kill the old
6641
6979
  // executor or bind the withdrawn birth. A failed cleanup is explicitly
@@ -6702,6 +7040,8 @@ export const startMirror = (deps) => {
6702
7040
  interruptPane: async (target, opts) => {
6703
7041
  if (opts?.guard && !opts.guard())
6704
7042
  return { ok: false, reason: "interrupt authority changed" };
7043
+ if (opts?.teardown)
7044
+ deps.events?.cancel((e) => e.target === target);
6705
7045
  const a = byTarget.get(target);
6706
7046
  if (!a)
6707
7047
  return { ok: false, reason: "no mirror attached for target" };
@@ -7020,9 +7360,15 @@ export const startMirror = (deps) => {
7020
7360
  onSpawn,
7021
7361
  onContextCut,
7022
7362
  shutdown: () => {
7363
+ hookReviewStopping = true;
7023
7364
  clearInterval(paneDriftTimer);
7024
7365
  clearInterval(pickerTimer);
7025
7366
  maintenance.stop();
7367
+ for (const review of hookReviews.values())
7368
+ review.active = false;
7369
+ hookReviews.clear();
7370
+ hookReviewBySession.clear();
7371
+ hookCardFailures.clear();
7026
7372
  for (const a of bySessionId.values()) {
7027
7373
  if (a.outbound?.kind === "deferred")
7028
7374
  clearTimeout(a.outbound.timer);
@@ -7243,6 +7589,13 @@ export const startMirror = (deps) => {
7243
7589
  // Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
7244
7590
  // mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
7245
7591
  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 }) });
7592
+ if (byTarget.get(a.target) !== a || a.sessionId !== sid || (a.injectGen ?? 0) !== myGen || (guard && !guard())) {
7593
+ // A stop or replacement during startup revokes this birth before binding.
7594
+ if (r.tmuxPane)
7595
+ await runTmux(["kill-pane", "-t", r.tmuxPane]);
7596
+ onSubmitted?.({ ok: false, retryable: false, reason: "respawn permission withdrawn; binding withheld" });
7597
+ return;
7598
+ }
7246
7599
  if (r.ok && r.tmuxPane && r.tmuxSession) {
7247
7600
  a.tmuxPane = r.tmuxPane;
7248
7601
  a.tmuxSession = r.tmuxSession;
@@ -7275,7 +7628,7 @@ export const startMirror = (deps) => {
7275
7628
  // store still reflects "user wanted tmux" — next turn retries.
7276
7629
  log.error({ reason: r.reason }, "mirror: tmux respawn failed");
7277
7630
  if (eventId) {
7278
- onSubmitted?.({ ok: false, retryable: true, reason: `respawn failed: ${r.reason ?? "unknown"}` });
7631
+ onSubmitted?.({ ok: false, retryable: r.retryable !== false, reason: `respawn failed: ${r.reason ?? "unknown"}` });
7279
7632
  if (s)
7280
7633
  await finalizeStream(a, s);
7281
7634
  if (a.outbound?.kind === "deferred")
@@ -7318,12 +7671,23 @@ export const startMirror = (deps) => {
7318
7671
  // 不许退化成 spawn-mode (`--resume -p`) 的无窗口注入。忙闲交给 CLI 自己的输入队列:
7319
7672
  // 贴进运行中的一轮会被记成 queued 轮, 等它在 transcript 露面再接上这条 event。
7320
7673
  // slash 命令要等闲, 那一道在上面 (dispatch 开头) 已经拦过了。
7321
- const r = await inject({
7674
+ let r = await inject({
7322
7675
  text: effText, images: effImages, cfg, log: log.child({ principal, sessionId: sid }),
7323
7676
  sessionId: sid, eventId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
7324
7677
  requireIdle: eventId ? () => true : undefined,
7325
7678
  guard: (phase, identity) => byTarget.get(a.target) === a && a.tmuxPane === injectPane && (a.injectGen ?? 0) === myGen && (!guard || guard(phase, identity)),
7326
7679
  });
7680
+ // 注入失败可以迟于转录消费;只认这一笔在原绑定转录里的标记,不用别轮产出猜 ACK。
7681
+ const submissionConfirmed = () => {
7682
+ const event = eventId ? deps.events?.get(eventId) : undefined;
7683
+ return !!event && !event.canceled
7684
+ && (event.exposure === "consumed" || event.execution === "complete"
7685
+ || !!event.transcript && submittedEventSince([{ path: event.transcript, offset: 0 }], event.id, event.at));
7686
+ };
7687
+ if (!r.ok && r.uncertain && submissionConfirmed()) {
7688
+ log.info({ target: a.target, eventId, reason: r.reason }, "mirror inject: late failure superseded by event submission evidence");
7689
+ r = { ok: true };
7690
+ }
7327
7691
  onSubmitted?.(r);
7328
7692
  if (!r.ok) {
7329
7693
  // retryable = 一个字都没进输入框, 消息事件层会稍后重投 —— 不是失败, 别报 ✗
@@ -7336,10 +7700,14 @@ export const startMirror = (deps) => {
7336
7700
  a.outbound = undefined;
7337
7701
  return;
7338
7702
  }
7339
- const note = `[mirror] ✗ ${r.reason ?? "failed"}`;
7703
+ const note = r.uncertain
7704
+ ? "[mirror] ⚠ 暂时无法确认目标会话是否收到消息,已停止继续输入;为避免重复执行,不会自动重发"
7705
+ : `[mirror] ✗ ${r.reason ?? "failed"}`;
7706
+ // 提示也会排发送队列;真正发出前再认一次同事件证据,撤销过期告警。
7707
+ const stillWanted = () => !r.uncertain || !submissionConfirmed();
7340
7708
  if (s) {
7341
7709
  s.acc = s.acc ? `${s.acc}\n\n${note}` : note;
7342
- await finalizeStream(a, s);
7710
+ await finalizeStream(a, s, false, stillWanted);
7343
7711
  }
7344
7712
  else if (armMigration) {
7345
7713
  // /clear path has no live stream — surface failure as a one-shot
@@ -7356,7 +7724,7 @@ export const startMirror = (deps) => {
7356
7724
  if (a.outbound?.kind === "deferred")
7357
7725
  clearTimeout(a.outbound.timer);
7358
7726
  a.outbound = undefined;
7359
- sendStandalone(a, note);
7727
+ sendStandalone(a, note, stillWanted);
7360
7728
  }
7361
7729
  return;
7362
7730
  }