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.
- package/CLAUDE.md +1 -1
- package/dist/daemon/approval.js +7 -6
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/inbound.js +1 -1
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +226 -67
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/jobs.js +7 -4
- package/dist/daemon/jobs.js.map +1 -1
- package/dist/daemon/message-events.js +220 -23
- package/dist/daemon/message-events.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +439 -51
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +256 -0
- package/dist/daemon/peers.js.map +1 -1
- package/dist/daemon/route.js +274 -49
- package/dist/daemon/route.js.map +1 -1
- package/dist/daemon/router.js +93 -28
- package/dist/daemon/router.js.map +1 -1
- package/dist/daemon/spawn-tmux.js +6 -7
- package/dist/daemon/spawn-tmux.js.map +1 -1
- package/dist/daemon/wizard.js +11 -11
- package/dist/daemon/wizard.js.map +1 -1
- package/dist/mcp/server.js +8 -6
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/codex-hooks.js +60 -0
- package/dist/shared/codex-hooks.js.map +1 -1
- package/dist/shared/message-footer.js +16 -1
- package/dist/shared/message-footer.js.map +1 -1
- package/dist/shared/modal-pane.js +18 -2
- package/dist/shared/modal-pane.js.map +1 -1
- package/dist/shared/tool-hints.js +2 -2
- package/dist/shared/tool-hints.js.map +1 -1
- package/package.json +1 -1
|
@@ -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 (
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
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
|
|
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 &&
|
|
3484
|
-
//
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
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
|
-
|
|
3491
|
-
|
|
3492
|
-
if (
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
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 (
|
|
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)
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|