wezard 1.3.15 → 1.3.17

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, pickAutoAllowAnswer } from "../shared/modal-pane.js";
21
- import { hasMirrorAskq, runMirrorAskqFlow, hasMirrorPlan, mootMirrorPlan, runMirrorPlanFlow } from "./approval.js";
20
+ import { isModalPane, isAskqSubmitPage, parseModalOptions, pickModalAnswer, pickAutoAllowAnswer, parseConfirmContext, looksLikePicker } 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,38 @@ 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 号选项。Claude Code 的 picker 数字键即选即确认; CodeBuddy
1053
+ // 的框数字键不一定生效, 且高亮可能停在别的选项上 (实测见过默认高亮在 "Yes, and
1054
+ // don't ask again…" 上) —— 盲补 Enter 会把**高亮项**确认下去, 可能恰是那条放宽
1055
+ // 权限的选项。所以补 Enter 前必须核对高亮就在目标项; 不在就用方向键挪过去、复核
1056
+ // 后再按; 高亮读不出 / 挪不动 / 标题变了 (已是另一个框) 一律罢手, 交调用方兜底。
1057
+ // 返回按完后的屏面 verdict, 由调用方裁决成败。
1058
+ const MODAL_PRESS_SETTLE_MS = 400;
1059
+ const MODAL_ARROW_SETTLE_MS = 150;
1060
+ const highlightedIndex = (screen) => parseModalOptions(screen).find((o) => o.selected)?.index;
1061
+ const pressModalIndex = async (pane, before, index) => {
1062
+ await tmuxRun(["send-keys", "-t", pane, String(index)]);
1063
+ await sleepMs(MODAL_PRESS_SETTLE_MS);
1064
+ let after = await detectModalPicker(pane);
1065
+ if (!after.modal || after.title !== before.title)
1066
+ return after;
1067
+ // 数字键没关掉框: 把高亮挪到目标项。guard 上限盖过任何确认框的选项数。
1068
+ for (let guard = 0; guard < 8; guard++) {
1069
+ const cur = highlightedIndex(after.screen);
1070
+ if (cur === undefined || cur === index)
1071
+ break;
1072
+ await tmuxRun(["send-keys", "-t", pane, cur < index ? "Down" : "Up"]);
1073
+ await sleepMs(MODAL_ARROW_SETTLE_MS);
1074
+ after = await detectModalPicker(pane);
1075
+ if (!after.modal || after.title !== before.title)
1076
+ return after;
1077
+ }
1078
+ if (highlightedIndex(after.screen) !== index)
1079
+ return after; // 核不准, 绝不盲按
1080
+ await tmuxRun(["send-keys", "-t", pane, "Enter"]);
1081
+ await sleepMs(MODAL_PRESS_SETTLE_MS);
1082
+ return detectModalPicker(pane);
1083
+ };
1050
1084
  // 「聊聊这个」收尾: 文本 inject 已把引导语贴进自定义文本行 (❯ N. <text>)。codebuddy
1051
1085
  // 的 AskUserQuestion 面板把自定义行和 Submit 行做成两个独立行, 光标此刻停在自定义
1052
1086
  // 行, 直接 Enter 只是在文本里换行, 提交不了。这里读屏确认 Submit 行已渲染
@@ -4401,20 +4435,22 @@ export const startMirror = (deps) => {
4401
4435
  }
4402
4436
  };
4403
4437
  const limitResumeTimer = setInterval(() => void limitResumeTick(), 30_000);
4404
- // ── skipAll 原生权限 picker 哨兵 ──────────────────────────────────────
4438
+ // ── 原生权限 picker 哨兵 ──────────────────────────────────────────────
4405
4439
  // CodeBuddy 对一部分调用先弹自家权限 picker、PreToolUse hook 在人点掉之后才
4406
4440
  // 触发 / 根本不触发 (与 AskUserQuestion 的「本地面板先行」同源; 实测命中: 子代理
4407
4441
  // 里经 ToolSearch→DeferExecuteTool 派发的 MCP 工具)。hook 链路对这类调用完全
4408
- // 失明 —— daemon 零日志、无卡可发、danger.skipAll 落空, pane 就地阻塞。解法也
4409
- // 与 askq 同源: mirror 读屏兜底。仅当用户已用 danger.skipAll 声明「全部放行,
4410
- // 别问」时代按, 且只按一次性裸 "Yes" (pickAutoAllowAnswer 的三件套形状判据) ——
4411
- // 「Yes, and don't ask again」这类放宽静态权限的选项永不选中, 挑不出就不碰,
4412
- // 代按后发一条 chat 回执留痕。WEZARD_PICKER_SENTINEL=0 关闭。
4442
+ // 失明 —— daemon 零日志、无卡可发, pane 就地阻塞。解法与 askq/plan 同源:
4443
+ // mirror 读屏兜底, 按三件套形状 (pickAutoAllowAnswer) 认权限确认框, 分三路:
4444
+ // - danger.skipAll 开 → 代按一次性裸 "Yes" (放宽静态权限的变体永不自动选中);
4445
+ // - 本 chat 的 ⏱自动窗口内、且内层工具不踩危险名单 → 同上代按;
4446
+ // - 否则 → 把框转成标准审批卡发到本 chat (runMirrorPickerFlow), 点击后代按
4447
+ // 对应选项; 本地先按掉则 moot 作废卡。代按/发卡都发 chat 回执留痕。
4448
+ // WEZARD_PICKER_SENTINEL=0 整体关闭。
4413
4449
  let pickerTicking = false;
4414
4450
  const pickerTick = async () => {
4415
4451
  if (pickerTicking || process.env.WEZARD_PICKER_SENTINEL === "0")
4416
4452
  return;
4417
- if (!cfg.approval.enabled || !cfg.approval.danger.skipAll)
4453
+ if (!cfg.approval.enabled)
4418
4454
  return;
4419
4455
  pickerTicking = true;
4420
4456
  try {
@@ -4425,9 +4461,23 @@ export const startMirror = (deps) => {
4425
4461
  continue;
4426
4462
  const v = await detectModalPicker(a.tmuxPane);
4427
4463
  if (!v.modal) {
4428
- a.pickerSentinelFp = undefined;
4464
+ // 审批卡在途而框已消失 = 本地先按掉了 → 作废卡等待 (无卡则 no-op)。
4465
+ mootMirrorPicker(a.sessionId);
4466
+ // 诊断: 屏上有高亮编号行却没判成 modal = 布局盲区候选 (askq/plan 面板
4467
+ // 也长这样, 属正常)。每屏记一次日志, 供远端排查"哨兵为何没反应"。
4468
+ if (looksLikePicker(v.screen)) {
4469
+ if (a.pickerSentinelFp !== v.screen) {
4470
+ a.pickerSentinelFp = v.screen;
4471
+ log.info({ target: a.target, tail: v.screen.split("\n").slice(-8).join("\\n").slice(-600) }, "picker sentinel: numbered picker shape on pane but not judged modal");
4472
+ }
4473
+ }
4474
+ else {
4475
+ a.pickerSentinelFp = undefined;
4476
+ }
4429
4477
  continue;
4430
4478
  }
4479
+ if (hasMirrorPicker(a.sessionId))
4480
+ continue; // 审批卡在途, 等点击 / moot
4431
4481
  if (a.pickerSentinelFp === v.screen)
4432
4482
  continue; // 同一屏已处理过, 等它变化
4433
4483
  a.pickerSentinelFp = v.screen;
@@ -4436,25 +4486,49 @@ export const startMirror = (deps) => {
4436
4486
  log.info({ target: a.target, title: v.title }, "picker sentinel: modal on pane but not a trustworthy permission picker — leaving it");
4437
4487
  continue;
4438
4488
  }
4439
- // 与 answerNativeModal 同款按法: 数字键即选即确认; 一拍后同标题的框还在
4440
- // 才补 Enter (个别布局要显式确认), 标题变了说明已是另一个框, 绝不盲按。
4441
- await tmuxRun(["send-keys", "-t", a.tmuxPane, String(pick.index)]);
4442
- await sleepMs(400);
4443
- let after = await detectModalPicker(a.tmuxPane);
4444
- if (after.modal && after.title === v.title) {
4445
- await tmuxRun(["send-keys", "-t", a.tmuxPane, "Enter"]);
4446
- await sleepMs(400);
4447
- after = await detectModalPicker(a.tmuxPane);
4448
- }
4449
- if (after.modal) {
4450
- // 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
4451
- a.pickerSentinelFp = after.screen;
4452
- log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
4489
+ const ctx = parseConfirmContext(v.screen);
4490
+ const danger = dangerOf(cfg, ctx.toolName, ctx.toolInput);
4491
+ // 自动代按的两个授权源。窗口路径要过危险名单 (窗口语义不覆盖 mustCard);
4492
+ // skipAll 本就压过危险名单 (与 daemon 审批链一致), 不检。
4493
+ const auto = cfg.approval.danger.skipAll
4494
+ ? "skipAll"
4495
+ : isAutoWindowActive(a.target) && !danger ? "⏱自动窗口" : undefined;
4496
+ if (auto) {
4497
+ const after = await pressModalIndex(a.tmuxPane, v, pick.index);
4498
+ if (after.modal) {
4499
+ // 没按掉: 指纹换成当前屏 — pane 静止就不再重试, 免得往一个不认识的框里刷数字键。
4500
+ a.pickerSentinelFp = after.screen;
4501
+ log.warn({ target: a.target, title: after.title, pressed: pick.index }, "picker sentinel: modal still on pane after press");
4502
+ continue;
4503
+ }
4504
+ a.pickerSentinelFp = undefined;
4505
+ log.info({ target: a.target, title: v.title, via: auto, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed");
4506
+ sendStandalone(a, `[mirror] 🔓 ${auto} 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
4453
4507
  continue;
4454
4508
  }
4455
- a.pickerSentinelFp = undefined;
4456
- log.info({ target: a.target, title: v.title, pressed: `${pick.index}. ${pick.label}` }, "picker sentinel: native permission picker auto-allowed (danger.skipAll)");
4457
- sendStandalone(a, `[mirror] 🔓 skipAll 代按了 CLI 原生权限确认${v.title ? `「${v.title}」` : ""} → ${pick.index}. ${pick.label}`);
4509
+ // 卡片路径: fire-and-forget — 长轮询在 flow 里, 哨兵循环不能被一张卡挂住;
4510
+ // 防重靠 hasMirrorPicker 槽位 (含代按窗口期, 见 flow 内 finally)。
4511
+ log.info({ target: a.target, toolName: ctx.toolName, agent: ctx.agent, danger: danger?.rule }, "picker sentinel: forwarding native picker as approval card");
4512
+ runMirrorPickerFlow({
4513
+ log: log.child({ target: a.target, sessionId: a.sessionId, sub: "picker-card" }),
4514
+ client,
4515
+ cfg,
4516
+ sessionId: a.sessionId,
4517
+ chatKey: a.target,
4518
+ cwd: a.runningCwd,
4519
+ jsonlPath: a.jsonlPath,
4520
+ toolName: ctx.toolName,
4521
+ toolInput: ctx.toolInput,
4522
+ agent: ctx.agent,
4523
+ danger: danger?.rule,
4524
+ voteTimeoutMs: cfg.approval.longPollSec * 1000,
4525
+ press: (what) => pressNativePicker(a, ctx.toolName, what),
4526
+ }).then((sent) => {
4527
+ // 卡没发出去 (WS 断/发送失败): 清指纹, 让下一 tick 对同屏重试发卡,
4528
+ // 否则这只框会被指纹永久静默。
4529
+ if (!sent)
4530
+ a.pickerSentinelFp = undefined;
4531
+ }).catch((e) => log.warn({ err: e.message, target: a.target }, "mirror picker flow failed"));
4458
4532
  }
4459
4533
  }
4460
4534
  catch (e) {
@@ -4465,6 +4539,36 @@ export const startMirror = (deps) => {
4465
4539
  }
4466
4540
  };
4467
4541
  const pickerTimer = setInterval(() => void pickerTick(), 5_000);
4542
+ // 审批卡点击后的代按回调。点击与本地按键之间隔着人的反应时间, 必须重读屏并
4543
+ // 校验仍是**同一个调用**的确认框 —— 期间本地按掉旧框又弹了新框的话, 盲按就是
4544
+ // 替用户批准了他没看过的东西。
4545
+ const pressNativePicker = async (a, expectToolName, what) => {
4546
+ if (!a.tmuxPane || !(await tmuxPaneAlive(a.tmuxPane)))
4547
+ return { ok: false, reason: "pane 已不在" };
4548
+ const v = await detectModalPicker(a.tmuxPane);
4549
+ if (!v.modal)
4550
+ return { ok: false, reason: "确认框已不在(可能已在本地处理)" };
4551
+ const now = parseConfirmContext(v.screen);
4552
+ if (expectToolName && now.toolName !== expectToolName) {
4553
+ return { ok: false, reason: `当前确认框已是另一个调用(${now.toolName}),不代按` };
4554
+ }
4555
+ if (what === "no") {
4556
+ await tmuxRun(["send-keys", "-t", a.tmuxPane, "Escape"]);
4557
+ await sleepMs(MODAL_PRESS_SETTLE_MS);
4558
+ const after = await detectModalPicker(a.tmuxPane);
4559
+ return after.modal ? { ok: false, reason: "Escape 后确认框仍在" } : { ok: true, label: "Escape" };
4560
+ }
4561
+ const opts = parseModalOptions(v.screen);
4562
+ const bare = opts.find((o) => /^yes\s*$/iu.test(o.label));
4563
+ const sess = what === "yes_session" ? opts.find((o) => /^yes,\s/iu.test(o.label)) : undefined;
4564
+ const pick = sess ?? bare;
4565
+ if (!pick)
4566
+ return { ok: false, reason: "确认框上找不到可按的 Yes 选项" };
4567
+ const after = await pressModalIndex(a.tmuxPane, v, pick.index);
4568
+ return after.modal
4569
+ ? { ok: false, reason: `按下 ${pick.index} 后确认框仍在` }
4570
+ : { ok: true, label: `${pick.index}. ${pick.label}` };
4571
+ };
4468
4572
  return {
4469
4573
  attach,
4470
4574
  chatTargets,
@@ -4830,18 +4934,9 @@ export const startMirror = (deps) => {
4830
4934
  lg.warn({ title: v.title }, "native modal: no trustworthy one-shot option, not pressing");
4831
4935
  return { status: "unparsable", title: v.title, screen: v.screen };
4832
4936
  }
4833
- // 数字键在 CC 的 picker 里即选即确认。按完等一拍回读: 框还在且**标题没变**才
4834
- // 补一个 Enter(个别布局要显式确认); 标题变了说明弹的已是另一个框 —— 绝不盲按,
4835
- // 交给兜底路径, 免得替用户确认了他没看过的东西。
4836
- const SETTLE_MS = 400;
4837
- await tmuxRun(["send-keys", "-t", pane, String(pick.index)]);
4838
- await sleepMs(SETTLE_MS);
4839
- let after = await detectModalPicker(pane);
4840
- if (after.modal && after.title === v.title) {
4841
- await tmuxRun(["send-keys", "-t", pane, "Enter"]);
4842
- await sleepMs(SETTLE_MS);
4843
- after = await detectModalPicker(pane);
4844
- }
4937
+ // 数字键在 CC 的 picker 里即选即确认; 补 Enter 与"标题没变才按"的守卫在
4938
+ // pressModalIndex 里 —— 按不掉交给兜底路径, 免得替用户确认了他没看过的东西。
4939
+ const after = await pressModalIndex(pane, v, pick.index);
4845
4940
  if (after.modal) {
4846
4941
  lg.warn({ title: after.title, pressed: pick.index }, "native modal: still on pane after answer");
4847
4942
  return { status: "still_modal", title: after.title, index: pick.index, label: pick.label };