wezard 2.7.4 → 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
- import { finalId } from "./message-events.js";
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";
@@ -48,7 +48,7 @@ import { labelFor, tagOfKey, baseOfKey, keyOf, stripSigil, displayName, windowLa
48
48
  import { splitMarkdown } from "../shared/md-chunk.js";
49
49
  import { isWxFrame } from "../shared/wx-text.js";
50
50
  import { chatBaseOf, chatNameOf, listChatNames, parsePeerRef, peerAddress } from "./chat-name.js";
51
- import { stripAnsi, paneIsBusy, codexTurnBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, lastUserTurn, talkRounds, openToolUses, turnClosed, renderDialog, stripForChat } from "./peers.js";
51
+ import { stripAnsi, paneIsBusy, codexTurnBusy, summarizeTail, lastAssistantText, lastReply, replyToPeer as replyToPeerIn, unwrapPasted, lastContextTokens, lastModel, lastUserTurn, talkRounds, openToolUses, turnClosed, renderDialog, stripForChat, parseClosing } from "./peers.js";
52
52
  // PATH augmentation: launchd / systemd start the daemon
53
53
  // with a stripped PATH that often lacks nvm / homebrew, breaking spawn(claudeBin).
54
54
  // Backend resolution is per-transcript, not global: `backendForPath` recovers
@@ -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;
@@ -2372,13 +2375,14 @@ export const startMirror = (deps) => {
2372
2375
  };
2373
2376
  const TOOL_DETAIL_PREFIX = "TOOL_DETAIL|";
2374
2377
  let eventFinalGate = () => true;
2378
+ let eventProgressGate = () => false;
2375
2379
  let eventOwnerGate = () => true;
2376
2380
  const eventOwned = (id) => {
2377
2381
  const event = id ? deps.events?.get(id) : undefined;
2378
2382
  return !event || eventOwnerGate(event);
2379
2383
  };
2380
2384
  const briefMode = (a) => cfg.wrc.mirror.brief
2381
- || !!(a.activeEventId && deps.events?.get(a.activeEventId)?.payload.peer?.transferred);
2385
+ || !!(a.activeEventId && (deps.events?.get(a.activeEventId)?.payload.peer?.transferred || deps.events?.get(a.activeEventId)?.payload.peer?.from.receipt));
2382
2386
  const bubbleOwned = (a, b) => {
2383
2387
  const event = b.owner ? deps.events?.get(b.owner) : undefined;
2384
2388
  return !event || event.target === a.target && eventOwnerGate(event);
@@ -2502,7 +2506,7 @@ export const startMirror = (deps) => {
2502
2506
  return;
2503
2507
  s.flushTimer = setTimeout(() => void flushStream(s), FLUSH_MS);
2504
2508
  };
2505
- const finalizeStream = async (a, s, timedOut = false) => {
2509
+ const finalizeStream = async (a, s, timedOut = false, stillWanted) => {
2506
2510
  if (s.closed)
2507
2511
  return;
2508
2512
  s.closed = true;
@@ -2533,14 +2537,16 @@ export const startMirror = (deps) => {
2533
2537
  await s.pending?.catch(() => undefined);
2534
2538
  if (!eventOwned(s.eventId))
2535
2539
  return;
2536
- if (card) {
2537
- s.cardSent = true;
2538
- await client.replyStreamWithCard(s.frame, s.streamId, withLinkedTag(a, body, undefined, s.turnId), true, { templateCard: card });
2539
- }
2540
- else {
2541
- 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");
2542
2549
  }
2543
- log.info({ sessionId: a.sessionId, turnId: s.turnId, accLen: s.acc.length, tools: s.tools.length, withCard: !!card, timedOut }, "stream finalize");
2544
2550
  }
2545
2551
  catch (e) {
2546
2552
  log.warn({ sessionId: a.sessionId, turnId: s.turnId, err: e.message }, "stream finalize failed");
@@ -2600,7 +2606,7 @@ export const startMirror = (deps) => {
2600
2606
  // 推到本轮的频道 (人从别的群叫它 → 回那个群)。私聊轮的正文 brief 下在上游
2601
2607
  // turnSilent 已被拦下; 非 brief 的逐条推送在这里兜一道 —— 只放 `[mirror]` /
2602
2608
  // `[wezard]` 系统提示 (它们回 home)。
2603
- const sendStandalone = (a, content) => {
2609
+ const sendStandalone = (a, content, stillWanted) => {
2604
2610
  const eventId = a.activeEventId;
2605
2611
  if (!hasVisibleBody(content) || !eventOwned(eventId))
2606
2612
  return;
@@ -2613,7 +2619,7 @@ export const startMirror = (deps) => {
2613
2619
  a.standalonePending = a.standalonePending
2614
2620
  .then(async () => {
2615
2621
  for (const c of chunks) {
2616
- if (!eventOwned(eventId))
2622
+ if (!eventOwned(eventId) || stillWanted?.() === false)
2617
2623
  return;
2618
2624
  try {
2619
2625
  const ack = await client.sendMessage(chatId, { msgtype: "markdown", markdown: { content: c } });
@@ -2935,6 +2941,10 @@ export const startMirror = (deps) => {
2935
2941
  a.cotText = undefined;
2936
2942
  a.cotTool = undefined;
2937
2943
  };
2944
+ const receiptProgress = (a, text) => {
2945
+ const e = a.activeEventId ? deps.events?.get(a.activeEventId) : undefined;
2946
+ return !e?.payload.peer?.from.receipt || (!e.quiet && !(text && receiptQuiet(e, text)) && eventProgressGate(e));
2947
+ };
2938
2948
  const flushCot = async (a) => {
2939
2949
  a.cotTimer = undefined;
2940
2950
  // 展示选取: assistant text (含 thinking) 优先; 没有新 text 时才落最新 tool_use。
@@ -2944,12 +2954,12 @@ export const startMirror = (deps) => {
2944
2954
  const b = a.briefBubble;
2945
2955
  const turnId = a.briefTurnId;
2946
2956
  // 收口后这一路全部失效: 气泡要么已 done, 要么 turn 已经换人/清空。
2947
- if (!text || !b || b.done || !turnId || a.briefConcluded || !bubbleOwned(a, b))
2957
+ if (!text || !b || b.done || !turnId || a.briefConcluded || !bubbleOwned(a, b) || !receiptProgress(a, text))
2948
2958
  return;
2949
2959
  a.cotLastSent = text;
2950
2960
  try {
2951
2961
  await (b.pending = (b.pending ?? Promise.resolve()).catch(() => undefined).then(async () => {
2952
- if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || !bubbleOwned(a, b))
2962
+ if (b.done || a.briefBubble !== b || a.briefTurnId !== turnId || !bubbleOwned(a, b) || !receiptProgress(a, text))
2953
2963
  return;
2954
2964
  await client.replyStream(b.frame, b.streamId, briefLine(a, turnId, text), false);
2955
2965
  }));
@@ -2961,7 +2971,7 @@ export const startMirror = (deps) => {
2961
2971
  /** 推进气泡里的 CoT 进度。节流窗口内到达的多条只留最后一条 —— 进度永远只展示"最新"。 */
2962
2972
  const updateBriefProgress = (a, raw) => {
2963
2973
  const b = a.briefBubble;
2964
- if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b))
2974
+ if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b) || !receiptProgress(a, raw))
2965
2975
  return;
2966
2976
  const line = cotLine(raw);
2967
2977
  if (!line)
@@ -2975,7 +2985,7 @@ export const startMirror = (deps) => {
2975
2985
  * 只有没有新 text 的窗口才把最新 tool 行刷上气泡。节流与 text 共用一个计时器。 */
2976
2986
  const updateBriefToolProgress = (a, raw) => {
2977
2987
  const b = a.briefBubble;
2978
- if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b))
2988
+ if (!b || b.done || a.briefConcluded || !bubbleOwned(a, b) || !receiptProgress(a, raw))
2979
2989
  return;
2980
2990
  const line = cotLine(raw);
2981
2991
  if (!line)
@@ -3007,7 +3017,7 @@ export const startMirror = (deps) => {
3007
3017
  await finalProgress.get(id)?.catch(() => undefined);
3008
3018
  finalProgress.delete(id);
3009
3019
  };
3010
- const queueFinal = (target, turn, channel, body, eventId, bubble) => {
3020
+ const queueFinal = (target, turn, channel, body, eventId, bubble, reportKind = parseClosing(body).kind === "need" ? "need" : "done") => {
3011
3021
  // 投递出口剥掉行首收口标记与 ARTIFACT 行 (peers.stripForChat): 判定读原文, 群里只看结论。
3012
3022
  const text = stripForChat(body);
3013
3023
  if (!deps.events || !channel || !text.trim())
@@ -3017,16 +3027,19 @@ export const startMirror = (deps) => {
3017
3027
  const chunks = formatFinal(target, turn, channel, text);
3018
3028
  if (bubble?.pending)
3019
3029
  finalProgress.set(id, bubble.pending);
3020
- deps.events.delivery({ id, eventId, target, turn, channel, body: text, revision, chunks,
3030
+ const claimed = deps.events.delivery({ id, eventId, target, turn, channel, body: text, revision, chunks, reportKind,
3031
+ ...(eventId && deps.events.get(eventId) ? { coverage: receiptCoverage(deps.events.get(eventId), channel, parseClosing(body).kind === "need", body) } : {}),
3021
3032
  ...(bubble && !bubble.done ? { stream: { frame: bubble.frame, id: bubble.streamId, expires: bubble.expires ?? Date.now() } } : {}) });
3033
+ if (claimed !== id || deps.events.deliveryStatus(id)?.every((s) => s === "delivered"))
3034
+ finalProgress.delete(id);
3022
3035
  if (bubble) {
3023
3036
  bubble.done = true;
3024
3037
  clearTimeout(bubble.hardTimer);
3025
3038
  }
3026
- return id;
3039
+ return claimed;
3027
3040
  };
3028
3041
  const recoverEventFinal = (event, bubble) => {
3029
- if ((deps.events?.get(event.id) ?? event).handoffCut || !event.result || event.interjected || event.canceled || !eventOwnerGate(event))
3042
+ if ((deps.events?.get(event.id) ?? event).handoffCut || event.quiet || !event.result || event.interjected || event.canceled || !eventOwnerGate(event))
3030
3043
  return;
3031
3044
  // 事件泵的 evidence 探针 (observe → io.recovered) 与 brief 软收口赛跑: 两者同为
3032
3045
  // ~1s 周期, 探针常先跑到, 而它调这里不带气泡 —— queueFinal 拿不到 stream, 终句
@@ -3057,14 +3070,14 @@ export const startMirror = (deps) => {
3057
3070
  if (event.channel && eventFinalGate(event))
3058
3071
  queueFinal(event.target, turn, event.channel, event.result.body, event.id, bubble);
3059
3072
  };
3060
- const deliverTransferredFinal = (event, body) => {
3073
+ const deliverTransferredFinal = (event, body, status = "error") => {
3061
3074
  if (!eventOwnerGate(event) || !event.channel)
3062
3075
  return;
3063
3076
  const turn = event.turn || event.id;
3064
3077
  if (!getDetail(turn))
3065
3078
  recordTurnStart({ id: turn, eventId: event.id, target: event.target,
3066
3079
  userQuery: event.payload.text, createdAt: event.at, channel: event.channel, from: event.payload.peer?.from });
3067
- queueFinal(event.target, turn, event.channel, body, event.id, claimReplyBubble(event));
3080
+ queueFinal(event.target, turn, event.channel, body, event.id, claimReplyBubble(event), status);
3068
3081
  };
3069
3082
  // 收口一条 loading 气泡: finish=true 写入最终内容, 只生效一次。发送失败退回 standalone。
3070
3083
  // raw=true skips withTagHeader (used when content already contains the linked tag header).
@@ -3480,19 +3493,30 @@ export const startMirror = (deps) => {
3480
3493
  else
3481
3494
  queueFinal(a.target, turnId, a.channel ?? baseOfKey(a.target), body, undefined, a.briefBubble);
3482
3495
  }
3483
- else if (gated && eventOwned(a.activeEventId) && !turnSilent(a)) {
3484
- // 链式收口 (eventFinalGate: 子活未落定) 延后的是 closing 的释放, 不该扣住气泡:
3485
- // queueFinal 没跑 = 没人收气泡, 它会定格在最后一行 CoT 进度上 (看着像卡在 tool
3486
- // use), hardTimer 到点的空 finish 又被 chat-gate 当 empty push 丢掉。气泡还在
3487
- // stream 窗口内就把终句刷进气泡 (`链接 正文` 覆盖, 与正常收口同形); 气泡已收 /
3488
- // 过期 / 终句超单条上限则 standalone 展示终句。
3496
+ else if (event && gated && !turnSilent(a) && eventProgressGate(deps.events.get(event.id))) {
3497
+ // A live parent may report progress; settled/canceled rows never use this outlet.
3498
+ const text = stripForChat(body);
3499
+ const channel = event.channel;
3500
+ const id = finalId(event.id, channel, `progress:${text}`);
3489
3501
  const b = a.briefBubble;
3490
- const text = stripForChat(body); // 同 queueFinal: 群气泡不带收口标记 / ARTIFACT 行
3491
- const fitsOneChunk = splitMarkdown(text, Math.max(200, cfg.wrc.mirror.chunkBytes - TAG_HEADER_BUDGET)).length === 1;
3492
- if (b && !b.done && fitsOneChunk && Date.now() < (b.expires ?? 0))
3493
- void finishBriefBubble(a, text);
3494
- else if (text.trim())
3495
- sendStandalone(a, text);
3502
+ if (b?.pending)
3503
+ finalProgress.set(id, b.pending);
3504
+ if (text && channel)
3505
+ deps.events.delivery({ id, eventId: event.id, target: event.target, turn: turnId,
3506
+ channel, body: text, revision: id, purpose: "progress", chunks: formatFinal(event.target, turnId, channel, text),
3507
+ ...(b && !b.done ? { stream: { frame: b.frame, id: b.streamId, expires: b.expires ?? Date.now() } } : {}) });
3508
+ if (b) {
3509
+ b.done = true;
3510
+ clearTimeout(b.hardTimer);
3511
+ }
3512
+ }
3513
+ const current = event ? deps.events.get(event.id) : undefined;
3514
+ if (current && (current.quiet || gated && !eventProgressGate(current))) {
3515
+ const b = a.briefBubble;
3516
+ if (b) {
3517
+ b.done = true;
3518
+ clearTimeout(b.hardTimer);
3519
+ }
3496
3520
  }
3497
3521
  // The durable result/outbox must exist before this in-memory dedup bit advances.
3498
3522
  a.briefConcluded = true;
@@ -5980,8 +6004,339 @@ export const startMirror = (deps) => {
5980
6004
  // One pool for the picker sentinel's pane probes: slow panes do not stall
5981
6005
  // other panes, and scans on the same pane never overlap.
5982
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
+ });
5983
6254
  const maintenanceValid = (a, pane, sid, gen, signal) => !signal.aborted && byTarget.get(a.target) === a && a.tmuxPane === pane && a.sessionId === sid
5984
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
+ };
5985
6340
  // ── 原生权限 picker 哨兵 ──────────────────────────────────────────────
5986
6341
  // CodeBuddy 对一部分调用先弹自家权限 picker、PreToolUse hook 在人点掉之后才
5987
6342
  // 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
@@ -5994,7 +6349,7 @@ export const startMirror = (deps) => {
5994
6349
  // 对应选项; 本地先按掉则 moot 作废卡。代按/发卡都发 chat 回执留痕。
5995
6350
  // WEZARD_PICKER_SENTINEL=0 整体关闭。
5996
6351
  const pickerOne = async (a, pane, sid, gen, signal) => {
5997
- 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)
5998
6353
  && process.env.WEZARD_PICKER_SENTINEL !== "0";
5999
6354
  if (!guard())
6000
6355
  return;
@@ -6002,12 +6357,14 @@ export const startMirror = (deps) => {
6002
6357
  const valid = () => guard() && live();
6003
6358
  if (!a.tmuxPane || a.migrationWatcher)
6004
6359
  return;
6005
- // Codex PermissionRequest has its own independent native gate. Screen
6006
- // options, tool windows and skipAll never authorize its sandbox.
6007
- if (cliOf(a) === "codex")
6008
- return;
6009
6360
  if (!(await tmuxPaneAlive(pane)) || !valid())
6010
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
+ }
6011
6368
  const v = await detectModalPicker(pane);
6012
6369
  if (!valid())
6013
6370
  return;
@@ -6104,10 +6461,10 @@ export const startMirror = (deps) => {
6104
6461
  }, { guard });
6105
6462
  };
6106
6463
  const pickerTick = () => {
6107
- if (!cfg.approval.enabled || process.env.WEZARD_PICKER_SENTINEL === "0")
6464
+ if (process.env.WEZARD_PICKER_SENTINEL === "0")
6108
6465
  return;
6109
6466
  for (const a of byTarget.values()) {
6110
- if (!a.tmuxPane || cliOf(a) === "codex")
6467
+ if (!a.tmuxPane || (cliOf(a) !== "codex" && !cfg.approval.enabled))
6111
6468
  continue;
6112
6469
  const pane = a.tmuxPane, sid = a.sessionId, gen = a.injectGen ?? 0;
6113
6470
  void maintenance.schedule(`picker:${a.target}`, pane, (signal) => pickerOne(a, pane, sid, gen, signal))
@@ -6616,7 +6973,7 @@ export const startMirror = (deps) => {
6616
6973
  const resumeBaseline = listJsonls(dirname(a.jsonlPath));
6617
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 }) });
6618
6975
  if (!r.ok || !r.tmuxPane)
6619
- 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"}` };
6620
6977
  if (!permitted() || a.tmuxPane !== probedPane) {
6621
6978
  // This pane was born for an unsubmitted injection. Never kill the old
6622
6979
  // executor or bind the withdrawn birth. A failed cleanup is explicitly
@@ -6683,6 +7040,8 @@ export const startMirror = (deps) => {
6683
7040
  interruptPane: async (target, opts) => {
6684
7041
  if (opts?.guard && !opts.guard())
6685
7042
  return { ok: false, reason: "interrupt authority changed" };
7043
+ if (opts?.teardown)
7044
+ deps.events?.cancel((e) => e.target === target);
6686
7045
  const a = byTarget.get(target);
6687
7046
  if (!a)
6688
7047
  return { ok: false, reason: "no mirror attached for target" };
@@ -7001,9 +7360,15 @@ export const startMirror = (deps) => {
7001
7360
  onSpawn,
7002
7361
  onContextCut,
7003
7362
  shutdown: () => {
7363
+ hookReviewStopping = true;
7004
7364
  clearInterval(paneDriftTimer);
7005
7365
  clearInterval(pickerTimer);
7006
7366
  maintenance.stop();
7367
+ for (const review of hookReviews.values())
7368
+ review.active = false;
7369
+ hookReviews.clear();
7370
+ hookReviewBySession.clear();
7371
+ hookCardFailures.clear();
7007
7372
  for (const a of bySessionId.values()) {
7008
7373
  if (a.outbound?.kind === "deferred")
7009
7374
  clearTimeout(a.outbound.timer);
@@ -7030,6 +7395,7 @@ export const startMirror = (deps) => {
7030
7395
  deliverTransferredFinal,
7031
7396
  transferReply,
7032
7397
  setEventFinalGate: (gate) => { eventFinalGate = gate; },
7398
+ setEventProgressGate: (gate) => { eventProgressGate = gate; },
7033
7399
  setEventOwnerGate: (gate) => { eventOwnerGate = gate; },
7034
7400
  settleFinalProgress,
7035
7401
  dispatch: async ({ principal, text, images, frame, streamId, channel, speaker, eventId, guard, onSubmitted }) => {
@@ -7223,6 +7589,13 @@ export const startMirror = (deps) => {
7223
7589
  // Respawn in the binding's runningCwd (pendingCwd doesn't apply to a
7224
7590
  // mid-turn reincarnation — only /new and /clear-with-pending swap cwd).
7225
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
+ }
7226
7599
  if (r.ok && r.tmuxPane && r.tmuxSession) {
7227
7600
  a.tmuxPane = r.tmuxPane;
7228
7601
  a.tmuxSession = r.tmuxSession;
@@ -7255,7 +7628,7 @@ export const startMirror = (deps) => {
7255
7628
  // store still reflects "user wanted tmux" — next turn retries.
7256
7629
  log.error({ reason: r.reason }, "mirror: tmux respawn failed");
7257
7630
  if (eventId) {
7258
- 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"}` });
7259
7632
  if (s)
7260
7633
  await finalizeStream(a, s);
7261
7634
  if (a.outbound?.kind === "deferred")
@@ -7298,12 +7671,23 @@ export const startMirror = (deps) => {
7298
7671
  // 不许退化成 spawn-mode (`--resume -p`) 的无窗口注入。忙闲交给 CLI 自己的输入队列:
7299
7672
  // 贴进运行中的一轮会被记成 queued 轮, 等它在 transcript 露面再接上这条 event。
7300
7673
  // slash 命令要等闲, 那一道在上面 (dispatch 开头) 已经拦过了。
7301
- const r = await inject({
7674
+ let r = await inject({
7302
7675
  text: effText, images: effImages, cfg, log: log.child({ principal, sessionId: sid }),
7303
7676
  sessionId: sid, eventId, jsonlPath: a.jsonlPath, tmuxTarget: a.tmuxPane, freshSpawn,
7304
7677
  requireIdle: eventId ? () => true : undefined,
7305
7678
  guard: (phase, identity) => byTarget.get(a.target) === a && a.tmuxPane === injectPane && (a.injectGen ?? 0) === myGen && (!guard || guard(phase, identity)),
7306
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
+ }
7307
7691
  onSubmitted?.(r);
7308
7692
  if (!r.ok) {
7309
7693
  // retryable = 一个字都没进输入框, 消息事件层会稍后重投 —— 不是失败, 别报 ✗
@@ -7316,10 +7700,14 @@ export const startMirror = (deps) => {
7316
7700
  a.outbound = undefined;
7317
7701
  return;
7318
7702
  }
7319
- 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();
7320
7708
  if (s) {
7321
7709
  s.acc = s.acc ? `${s.acc}\n\n${note}` : note;
7322
- await finalizeStream(a, s);
7710
+ await finalizeStream(a, s, false, stillWanted);
7323
7711
  }
7324
7712
  else if (armMigration) {
7325
7713
  // /clear path has no live stream — surface failure as a one-shot
@@ -7336,7 +7724,7 @@ export const startMirror = (deps) => {
7336
7724
  if (a.outbound?.kind === "deferred")
7337
7725
  clearTimeout(a.outbound.timer);
7338
7726
  a.outbound = undefined;
7339
- sendStandalone(a, note);
7727
+ sendStandalone(a, note, stillWanted);
7340
7728
  }
7341
7729
  return;
7342
7730
  }