wezard 1.3.13 → 1.3.16

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.
@@ -17,8 +17,10 @@ import { existsSync, readdirSync, statSync, watch, openSync, readSync, closeSync
17
17
  import { join, dirname } from "node:path";
18
18
  import { expandHome, sanitizeId } from "../shared/paths.js";
19
19
  import { activeBackends, backendForPath, projectDirFor, projectDirsFor, } from "../shared/cli-backends.js";
20
- import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer } from "../shared/modal-pane.js";
21
- import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow } from "./approval.js";
20
+ import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext } from "../shared/modal-pane.js";
21
+ import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow, hasMirrorPicker, mootMirrorPicker, runMirrorPickerFlow } from "./approval.js";
22
+ import { isAutoWindowActive } from "./session-cache.js";
23
+ import { dangerOf } from "./danger.js";
22
24
  import { runTmux as runTmuxCmd, spawnTmuxClaude } from "./spawn-tmux.js";
23
25
  import { startSubagentWatch } from "./subagent-tail.js";
24
26
  import { recordTool, recordToolResult, recordTurnStart, recordTurnItem, recordTurnUsage, recordTurnClose, recordCloseOpenTurns, buildDetailUrl, buildChatUrl } from "./detail.js";
@@ -1047,6 +1049,21 @@ const detectModalPicker = async (target) => {
1047
1049
  const screen = lines.slice(-MODAL_SCAN_ROWS).join("\n");
1048
1050
  return { ...isModalPane(screen), screen };
1049
1051
  };
1052
+ // 按下确认框的第 index 号选项: 数字键即选即确认; 一拍后**同标题**的框还在才补
1053
+ // Enter (个别布局要显式确认), 标题变了说明弹的已是另一个框 —— 绝不盲按, 免得替
1054
+ // 用户确认了他没看过的东西。返回按完后的屏面 verdict, 由调用方裁决成败。
1055
+ const MODAL_PRESS_SETTLE_MS = 400;
1056
+ const pressModalIndex = async (pane, before, index) => {
1057
+ await tmuxRun(["send-keys", "-t", pane, String(index)]);
1058
+ await sleepMs(MODAL_PRESS_SETTLE_MS);
1059
+ let after = await detectModalPicker(pane);
1060
+ if (after.modal && after.title === before.title) {
1061
+ await tmuxRun(["send-keys", "-t", pane, "Enter"]);
1062
+ await sleepMs(MODAL_PRESS_SETTLE_MS);
1063
+ after = await detectModalPicker(pane);
1064
+ }
1065
+ return after;
1066
+ };
1050
1067
  // 「聊聊这个」收尾: 文本 inject 已把引导语贴进自定义文本行 (❯ N. <text>)。codebuddy
1051
1068
  // 的 AskUserQuestion 面板把自定义行和 Submit 行做成两个独立行, 光标此刻停在自定义
1052
1069
  // 行, 直接 Enter 只是在文本里换行, 提交不了。这里读屏确认 Submit 行已渲染
@@ -4401,6 +4418,125 @@ export const startMirror = (deps) => {
4401
4418
  }
4402
4419
  };
4403
4420
  const limitResumeTimer = setInterval(() => void limitResumeTick(), 30_000);
4421
+ // ── 原生权限 picker 哨兵 ──────────────────────────────────────────────
4422
+ // CodeBuddy 对一部分调用先弹自家权限 picker、PreToolUse hook 在人点掉之后才
4423
+ // 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
4424
+ // 里经 ToolSearch→DeferExecuteTool 派发的 MCP 工具)。hook 链路对这类调用完全
4425
+ // 失明 —— daemon 零日志、无卡可发, pane 就地阻塞。解法与 askq/plan 同源:
4426
+ // mirror 读屏兜底, 按三件套形状 (pickAutoAllowAnswer) 认权限确认框, 分三路:
4427
+ // - danger.skipAll 开 → 代按一次性裸 "Yes" (放宽静态权限的变体永不自动选中);
4428
+ // - 本 chat 的 ⏱自动窗口内、且内层工具不踩危险名单 → 同上代按;
4429
+ // - 否则 → 把框转成标准审批卡发到本 chat (runMirrorPickerFlow), 点击后代按
4430
+ // 对应选项; 本地先按掉则 moot 作废卡。代按/发卡都发 chat 回执留痕。
4431
+ // WEZARD_PICKER_SENTINEL=0 整体关闭。
4432
+ let pickerTicking = false;
4433
+ const pickerTick = async () => {
4434
+ if (pickerTicking || process.env.WEZARD_PICKER_SENTINEL === "0")
4435
+ return;
4436
+ if (!cfg.approval.enabled)
4437
+ return;
4438
+ pickerTicking = true;
4439
+ try {
4440
+ for (const a of byTarget.values()) {
4441
+ if (!a.tmuxPane || a.migrationWatcher)
4442
+ continue;
4443
+ if (!(await tmuxPaneAlive(a.tmuxPane)))
4444
+ continue;
4445
+ const v = await detectModalPicker(a.tmuxPane);
4446
+ if (!v.modal) {
4447
+ a.pickerSentinelFp = undefined;
4448
+ // 审批卡在途而框已消失 = 本地先按掉了 → 作废卡等待 (无卡则 no-op)。
4449
+ mootMirrorPicker(a.sessionId);
4450
+ continue;
4451
+ }
4452
+ if (hasMirrorPicker(a.sessionId))
4453
+ continue; // 审批卡在途, 等点击 / moot
4454
+ if (a.pickerSentinelFp === v.screen)
4455
+ continue; // 同一屏已处理过, 等它变化
4456
+ a.pickerSentinelFp = v.screen;
4457
+ const pick = pickAutoAllowAnswer(parseModalOptions(v.screen));
4458
+ if (!pick) {
4459
+ log.info({ target: a.target, title: v.title }, "picker sentinel: modal on pane but not a trustworthy permission picker — leaving it");
4460
+ continue;
4461
+ }
4462
+ const ctx = parseConfirmContext(v.screen);
4463
+ const danger = dangerOf(cfg, ctx.toolName, ctx.toolInput);
4464
+ // 自动代按的两个授权源。窗口路径要过危险名单 (窗口语义不覆盖 mustCard);
4465
+ // skipAll 本就压过危险名单 (与 daemon 审批链一致), 不检。
4466
+ const auto = cfg.approval.danger.skipAll
4467
+ ? "skipAll"
4468
+ : isAutoWindowActive(a.target) && !danger ? "⏱自动窗口" : undefined;
4469
+ if (auto) {
4470
+ const after = await pressModalIndex(a.tmuxPane, v, pick.index);
4471
+ if (after.modal) {
4472
+ // 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
4473
+ a.pickerSentinelFp = after.screen;
4474
+ log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
4475
+ continue;
4476
+ }
4477
+ a.pickerSentinelFp = undefined;
4478
+ log.info({ target: a.target, title: v.title, via: auto, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
4479
+ sendStandalone(a, `[mirror] 🔓 ${auto} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
4480
+ continue;
4481
+ }
4482
+ // 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
4483
+ // 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
4484
+ log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, danger: danger?.rule }, "picker sentinel: forwarding native picker as approval card");
4485
+ runMirrorPickerFlow({
4486
+ log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
4487
+ client,
4488
+ cfg,
4489
+ sessionId: a.sessionId,
4490
+ chatKey: a.target,
4491
+ cwd: a.runningCwd,
4492
+ jsonlPath: a.jsonlPath,
4493
+ toolName: ctx.toolName,
4494
+ toolInput: ctx.toolInput,
4495
+ agent: ctx.agent,
4496
+ danger: danger?.rule,
4497
+ voteTimeoutMs: cfg.approval.longPollSec * 1000,
4498
+ press: (what) => pressNativePicker(a, ctx.toolName, what),
4499
+ }).catch((e) => log.warn({ err: e.message, target: a.target }, "mirror picker flow failed"));
4500
+ }
4501
+ }
4502
+ catch (e) {
4503
+ log.warn({ err: e.message }, "picker sentinel tick failed");
4504
+ }
4505
+ finally {
4506
+ pickerTicking = false;
4507
+ }
4508
+ };
4509
+ const pickerTimer = setInterval(() => void pickerTick(), 5_000);
4510
+ // 审批卡点击后的代按回调。点击与本地按键之间隔着人的反应时间, 必须重读屏并
4511
+ // 校验仍是**同一个调用**的确认框 —— 期间本地按掉旧框又弹了新框的话, 盲按就是
4512
+ // 替用户批准了他没看过的东西。
4513
+ const pressNativePicker = async (a, expectToolName, what) => {
4514
+ if (!a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
4515
+ return { ok: false, reason: "pane 已不在" };
4516
+ const v = await detectModalPicker(a.tmuxPane);
4517
+ if (!v.modal)
4518
+ return { ok: false, reason: "确认框已不在(可能已在本地处理)" };
4519
+ const now = parseConfirmContext(v.screen);
4520
+ if (expectToolName && now.toolName !== expectToolName) {
4521
+ return { ok: false, reason: `当前确认框已是另一个调用(${now.toolName}),不代按` };
4522
+ }
4523
+ if (what === "no") {
4524
+ await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
4525
+ await sleepMs(MODAL_PRESS_SETTLE_MS);
4526
+ const after = await detectModalPicker(a.tmuxPane);
4527
+ return after.modal ? { ok: false, reason: "Escape 后确认框仍在" } : { ok: true, label: "Escape" };
4528
+ }
4529
+ const opts = parseModalOptions(v.screen);
4530
+ const bare = opts.find((o) => /^yes\s*$/iu.test(o.label));
4531
+ const sess = what === "yes_session" ? opts.find((o) => /^yes,\s/iu.test(o.label)) : undefined;
4532
+ const pick = sess ?? bare;
4533
+ if (!pick)
4534
+ return { ok: false, reason: "确认框上找不到可按的 Yes 选项" };
4535
+ const after = await pressModalIndex(a.tmuxPane, v, pick.index);
4536
+ return after.modal
4537
+ ? { ok: false, reason: `按下 ${pick.index} 后确认框仍在` }
4538
+ : { ok: true, label: `${pick.index}. ${pick.label}` };
4539
+ };
4404
4540
  return {
4405
4541
  attach,
4406
4542
  chatTargets,
@@ -4766,18 +4902,9 @@ export const startMirror = (deps) => {
4766
4902
  lg.warn({ title: v.title }, "native modal: no trustworthy one-shot option, not pressing");
4767
4903
  return { status: "unparsable", title: v.title, screen: v.screen };
4768
4904
  }
4769
- // 数字键在 CC 的 picker 里即选即确认。按完等一拍回读: 框还在且**标题没变**才
4770
- // 补一个 Enter(个别布局要显式确认); 标题变了说明弹的已是另一个框 —— 绝不盲按,
4771
- // 交给兜底路径, 免得替用户确认了他没看过的东西。
4772
- const SETTLE_MS = 400;
4773
- await tmuxRun(["send-keys", "-t", pane, String(pick.index)]);
4774
- await sleepMs(SETTLE_MS);
4775
- let after = await detectModalPicker(pane);
4776
- if (after.modal && after.title === v.title) {
4777
- await tmuxRun(["send-keys", "-t", pane, "Enter"]);
4778
- await sleepMs(SETTLE_MS);
4779
- after = await detectModalPicker(pane);
4780
- }
4905
+ // 数字键在 CC 的 picker 里即选即确认; 补 Enter 与"标题没变才按"的守卫在
4906
+ // pressModalIndex 里 —— 按不掉交给兜底路径, 免得替用户确认了他没看过的东西。
4907
+ const after = await pressModalIndex(pane, v, pick.index);
4781
4908
  if (after.modal) {
4782
4909
  lg.warn({ title: after.title, pressed: pick.index }, "native modal: still on pane after answer");
4783
4910
  return { status: "still_modal", title: after.title, index: pick.index, label: pick.label };
@@ -4792,6 +4919,7 @@ export const startMirror = (deps) => {
4792
4919
  clearInterval(paneDriftTimer);
4793
4920
  clearInterval(keepaliveTimer);
4794
4921
  clearInterval(limitResumeTimer);
4922
+ clearInterval(pickerTimer);
4795
4923
  for (const a of bySessionId.values()) {
4796
4924
  if (a.outbound?.kind === "deferred")
4797
4925
  clearTimeout(a.outbound.timer);