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.
- 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 +92 -20
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/message-events.js +75 -9
- package/dist/daemon/message-events.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +394 -26
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +251 -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/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
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 (
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
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 (
|
|
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)
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|