wezard 1.2.16 → 1.2.19

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.
@@ -235,16 +235,18 @@ const bodyBlocks = (a, r) => {
235
235
  const quoteArea = (text, url) => (url
236
236
  ? { type: 1, url, quote_text: text }
237
237
  : { type: 0, quote_text: text });
238
- // 危险卡: 只有 ❌ / ✅ — 不给「N 分钟全过」「✅总是」的入口, 否则一次点击就把
239
- // 后续所有危险操作也放行了, 名单等于失效。
240
- const approveButtons = (a) => a.danger
238
+ // 必发卡 (危险名单 / askRules / `.claude/**` 守卫): 只有 ❌ / ✅ — 不给「N 分钟
239
+ // 全过」「✅总是」的入口, 否则一次点击就把后续所有这类操作也放行了, 名单等于失效。
240
+ // 判据是 forceSingle 而非 danger: 另两个来源的卡同样吃 handler 侧的 `!mustCard`
241
+ // 短路, 只按 danger 收按钮的话它们会画出点了没反应、也不 sweep 同批的死按钮。
242
+ const approveButtons = (a) => a.danger || a.forceSingle
241
243
  ? [
242
244
  { text: "❌", style: 4, key: encodeKey(a.reqId, "deny") },
243
245
  { text: "✅ 确认执行", style: 4, key: encodeKey(a.reqId, "allow") },
244
246
  ]
245
247
  : [
246
248
  { text: "❌", style: 4, key: encodeKey(a.reqId, "deny") },
247
- { text: fmtWindow(a.windowMinutes), style: 3, key: encodeKey(a.reqId, "allow_window") },
249
+ { text: windowLabel(a.windowMinutes), style: 1, key: encodeKey(a.reqId, "allow_window") },
248
250
  // 「总是」= 由本次调用生成一条 allowRules 并落盘, 对齐 Claude Code 原生
249
251
  // 弹窗的 Always allow。危险卡上没有这个入口 (与「⏱全过」同理)。
250
252
  { text: "✅总是", style: 4, key: encodeKey(a.reqId, "allow_always") },
@@ -287,6 +289,9 @@ const buildCard = (a) => {
287
289
  };
288
290
  };
289
291
  const fmtWindow = (min) => min % 60 === 0 ? `${min / 60}h` : `${min}min`;
292
+ // 按钮上的时间窗文案: 裸「10h」看不出是放行, 补 ⏱ + 「自动过」点明语义。
293
+ // (style 也从 3 改成 1 — 企微 3 渲染成红色, 与「放行」相悖。)
294
+ const windowLabel = (min) => `⏱${fmtWindow(min)}自动过`;
290
295
  const verbOf = (d, windowMinutes) => {
291
296
  switch (d) {
292
297
  case "deny": return "已拒绝";
@@ -413,7 +418,10 @@ const buildBatchCard = (batch, transcriptTail) => ({
413
418
  task_id: batch.batchId,
414
419
  button_list: [
415
420
  { text: "❌", style: 4, key: encodeBatchKey(batch.batchId, "deny") },
416
- { text: fmtWindow(batch.windowMinutes), style: 3, key: encodeBatchKey(batch.batchId, "allow_window") },
421
+ { text: windowLabel(batch.windowMinutes), style: 1, key: encodeBatchKey(batch.batchId, "allow_window") },
422
+ // 批量卡同样给「总是」: 合流的成员是同一个工具的 N 次调用, 逐个点「总是」与
423
+ // 点一次的结果相同(每位成员各自走一遍规则生成, 已被现有规则覆盖的不重复加)。
424
+ { text: "✅总是", style: 4, key: encodeBatchKey(batch.batchId, "allow_always") },
417
425
  { text: `✅ ×${batch.members.length}`, style: 4, key: encodeBatchKey(batch.batchId, "allow") },
418
426
  ],
419
427
  });
@@ -1232,9 +1240,14 @@ const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget,
1232
1240
  return { decision: "deny", reason };
1233
1241
  };
1234
1242
  const decisionToHook = (d) => (d === "deny" ? "deny" : "allow");
1235
- // 必发卡的请求 (危险名单 / askRules / `.claude/**` 守卫) 永不走 fallbackOnError:
1236
- // "allow" — 超时/断线时降级为 ask, 交回本地 CLI 由人来确认, 而不是静默放行一次
1237
- // rm。只判 danger 的话, 用户自己配的 askRules 在 daemon 挂掉时反而失效。
1243
+ // 必发卡的请求 (危险名单 / askRules) 永不走 fallbackOnError: "allow" — 超时/断线时
1244
+ // 降级为 ask, 交回本地 CLI 由人来确认, 而不是静默放行一次 rm。只判 danger 的话,
1245
+ // 用户自己配的 askRules 在 daemon 挂掉时反而失效。
1246
+ //
1247
+ // `.claude/**` 守卫虽然不进 mustCard, 但在**错误面**也要走同一条降级: 正常出口靠
1248
+ // settleGuard 事后按框, 而这三条 (no_approver / ws_disconnected / approver_timeout)
1249
+ // 要么没有 approver 可按、要么 pane 早已在等框 —— 静默 allow 就是把死锁原样放回来。
1250
+ // 所以调用点传的是 `mustCard || guardActive`, 见 handler 里那三处。
1238
1251
  const fallback = (cfg, reason, forceSingle) => ({
1239
1252
  decision: forceSingle && cfg.approval.fallbackOnError === "allow" ? "ask" : cfg.approval.fallbackOnError,
1240
1253
  reason: forceSingle ? `${reason}:force_single` : reason,
@@ -1291,7 +1304,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1291
1304
  // 按不掉时不干等: Esc 取消掉这次调用, 再把原因作为一条用户消息注入 pane。模型
1292
1305
  // 收到的就是明确的"这条路走不通 + 该怎么绕", 而不是静默卡住 —— 等价于预拦截
1293
1306
  // deny + reason, 只是走镜像通道传达。
1294
- const settleClaudeConfigModal = async (sessionId, approver, hit) => {
1307
+ const settleOne = async (sessionId, approver, hit) => {
1295
1308
  const nm = nativeModal;
1296
1309
  if (!nm)
1297
1310
  return;
@@ -1320,6 +1333,22 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1320
1333
  await notify(approver, `⚠️ 原生确认框未能代按(${r.status})。已${cancelled.ok ? "" : "尝试"}发 Esc 取消本次调用`
1321
1334
  + `${told.ok ? ",并把原因告知了会话" : `(原因注入失败:${told.reason ?? "unknown"},请到 tmux 里看一眼)`}。`);
1322
1335
  };
1336
+ // 同一 pane 上的收尾必须串行。守卫退出 mustCard 之后, 一轮里 N 个 `.claude/**` 写
1337
+ // 会一起走窗口/缓存/规则的快路径、同刻返回 —— 以前每张卡等人点, 天然串成一列。
1338
+ // 并发进 answer() 的话两个协程会看见同一个框都按下去, 第二下落到下一个框或输入框上。
1339
+ // 按 sessionId 串一条 promise 链: 每次收尾等前一次跑完, 失败也不断链。
1340
+ const settleChains = new Map();
1341
+ const settleClaudeConfigModal = (sessionId, approver, hit) => {
1342
+ const next = (settleChains.get(sessionId) ?? Promise.resolve())
1343
+ .then(() => settleOne(sessionId, approver, hit));
1344
+ // 链尾自清: 只有还是自己时才删, 避免把后来者的链一起抹掉。
1345
+ const tail = next.catch(() => { }).finally(() => {
1346
+ if (settleChains.get(sessionId) === tail)
1347
+ settleChains.delete(sessionId);
1348
+ });
1349
+ settleChains.set(sessionId, tail);
1350
+ return next;
1351
+ };
1323
1352
  // Flush 一个 batch: 单成员 → 普通卡 (与未启用聚合一致); 多成员 → 批量卡。
1324
1353
  // 发送失败时调用 failPending 让每位成员的 handler 走 fallbackOnError 路径,
1325
1354
  // 与单卡路径上 sendMessage 抛错时的语义一致。
@@ -1348,6 +1377,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1348
1377
  transcriptTail: m.transcriptTail,
1349
1378
  windowMinutes: batch.windowMinutes,
1350
1379
  danger: batch.danger,
1380
+ forceSingle: batch.forceSingle,
1351
1381
  sessionName: batch.sessionName,
1352
1382
  denyReason: batch.denyReason,
1353
1383
  detailUrl: detailUrlFor(m.reqId, batch.approver),
@@ -1440,8 +1470,24 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1440
1470
  const guardHit = cfg.approval.claudeConfigGuard ? claudeConfigWrite(toolName, toolInput) : undefined;
1441
1471
  const guardActive = Boolean(guardHit && nativeModal?.hasPane(sessionId));
1442
1472
  if (guardHit) {
1443
- log.info({ toolName, sessionId, path: guardHit.path, why: guardHit.why, guardActive }, guardActive ? "claude-config guard force card" : "claude-config write detected (no live pane, passing through)");
1473
+ log.info({ toolName, sessionId, path: guardHit.path, why: guardHit.why, guardActive }, guardActive ? "claude-config guard armed" : "claude-config write detected (no live pane, passing through)");
1444
1474
  }
1475
+ const approver = resolveApprover(cfg, sessionId, getMirrorTarget);
1476
+ // 守卫的收尾 = 把 CC 那个不过 hook 的原生确认框按掉。它挂在**每一条 allow 出口**
1477
+ // 上, 而不是靠"强制发卡 + 人工点"来触发 —— 死锁的成因是没人按框, 不是没人点卡。
1478
+ //
1479
+ // 这条区别是有代价的历史: 守卫曾经进 mustCard, 于是 ⏱窗口/缓存/合流/sweep 全被
1480
+ // 它短路, 卡上那颗「⏱10h自动过」点了等于一次性放行, 窗口从来没开过。现在窗口
1481
+ // 照常生效, 框照样有人按。
1482
+ //
1483
+ // 必须在 json(res) 之后调用: CC 要等 hook 进程退出才会把框渲染出来。
1484
+ const settleGuard = () => {
1485
+ if (!guardActive || !guardHit || !approver)
1486
+ return;
1487
+ void settleClaudeConfigModal(sessionId, approver, guardHit).catch((e) => {
1488
+ log.warn({ err: e.message, sessionId }, "settleClaudeConfigModal failed");
1489
+ });
1490
+ };
1445
1491
  // 危险名单 (daemon/danger.ts): 内置的 rm / 强推 / DROP / 敏感路径等。语义上
1446
1492
  // 是「出厂自带的 askRules」—— 所以它必须和用户写的 askRules 站在同一层, 排在
1447
1493
  // allowRules 之前。放在后面的话, 一条 `Bash(git *)` 这样的宽 allow 规则 (尤其
@@ -1449,8 +1495,9 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1449
1495
  const danger = dangerOf(cfg, toolName, toolInput);
1450
1496
  if (danger)
1451
1497
  log.info({ toolName, sessionId, rule: danger.rule }, "danger hit — forcing single approval");
1452
- // 必发卡 = 危险名单 或 askRules 命中 或 守卫生效。三者都要压过放行/窗口/缓存。
1453
- const mustCard = Boolean(danger) || Boolean(askHit) || guardActive;
1498
+ // 必发卡 = 危险名单 或 askRules 命中。这两者的语义是「每次都要人单独看一眼」,
1499
+ // 所以压过放行/窗口/缓存。守卫不在其列 —— 见上面 settleGuard 的注释。
1500
+ const mustCard = Boolean(danger) || Boolean(askHit);
1454
1501
  // allowRules: matcher 拦下的工具里再挖细粒度豁免 (Bash 可按命令前缀区分)。
1455
1502
  // 交互卡工具 (AskUserQuestion 等) 在引擎内部硬保护, 规则写了也不放行。
1456
1503
  // 用 evaluateAllow 而非 ruleAllows: 未命中时要把"因为哪一段"带到卡上,
@@ -1460,6 +1507,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1460
1507
  const ruleHit = [...new Set(verdict.hits)].join(" + ");
1461
1508
  log.info({ toolName, sessionId, rule: ruleHit }, "allow-rule skip");
1462
1509
  json(res, 200, { decision: "allow", reason: `allow_rule:${ruleHit}` });
1510
+ settleGuard();
1463
1511
  return;
1464
1512
  }
1465
1513
  const denyReason = verdict.allowed ? undefined : verdict.reason;
@@ -1523,21 +1571,18 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1523
1571
  json(res, 200, resp);
1524
1572
  return;
1525
1573
  }
1526
- // Approver 提前解析: auto-window 现在按 chat 维度生效, isAutoWindowActive
1527
- // 检查要拿到 chatKey (= approver principal) 才能查。approver 缺失时 window
1528
- // 检查跳过 (window 需要一个 chat 才存在), 走后面的 no_approver fallback。
1529
- const approver = resolveApprover(cfg, sessionId, getMirrorTarget);
1530
1574
  // danger.skip / danger 模式的早退。第三个参数是「除 danger 外还有没有别的必发卡
1531
- // 理由」—— askRules `.claude/**` 守卫不能被 danger 的开关顺带关掉, 判定与
1532
- // 理由见 dangerEarlyExit 的注释 (守卫被绕过会让 pane 死锁)。
1533
- const earlyExit = dangerEarlyExit(cfg, danger, Boolean(askHit) || guardActive);
1575
+ // 理由」—— askRules 不能被 danger 的开关顺带关掉。守卫不在其列: 它要的是事后按框,
1576
+ // 早退照样能给 (settleGuard), 拿它挡早退等于把 danger 的开关废掉。
1577
+ const earlyExit = dangerEarlyExit(cfg, danger, Boolean(askHit));
1534
1578
  if (earlyExit) {
1535
1579
  log.info({ toolName, sessionId, reason: earlyExit }, "danger switch early exit");
1536
1580
  json(res, 200, { decision: "allow", reason: earlyExit });
1581
+ settleGuard();
1537
1582
  return;
1538
1583
  }
1539
1584
  // Auto-approve window: while active for THIS chat, requests short-circuit to allow.
1540
- // mustCard (危险名单 / askRules / `.claude/**` 守卫) 的请求不吃窗口 — 即使开着 ⏱ 也逐条确认。
1585
+ // mustCard (危险名单 / askRules) 的请求不吃窗口 — 即使开着 ⏱ 也逐条确认。
1541
1586
  if (!mustCard && approver && isAutoWindowActive(approver)) {
1542
1587
  const remainSec = Math.ceil(autoWindowRemainingMs(approver) / 1000);
1543
1588
  log.info({ toolName, sessionId, chatKey: approver, remainSec }, "auto-window allow");
@@ -1545,11 +1590,12 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1545
1590
  decision: "allow",
1546
1591
  reason: `auto_window:${remainSec}s`,
1547
1592
  });
1593
+ settleGuard();
1548
1594
  return;
1549
1595
  }
1550
1596
  // Session cache (mustCard 的请求同样不吃缓存)
1551
1597
  const ck = cacheKey(sessionId, toolName, toolInput);
1552
- // mustCard(危险名单 / askRules / 守卫)一律不吃缓存 —— 缓存的语义是「这个调用批过一次
1598
+ // mustCard(危险名单 / askRules)一律不吃缓存 —— 缓存的语义是「这个调用批过一次
1553
1599
  // 就不再问」, 与「每次都要单独确认」直接冲突。
1554
1600
  const cached = mustCard ? undefined : cacheGet(ck);
1555
1601
  if (cached) {
@@ -1558,16 +1604,18 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1558
1604
  decision: decisionToHook(cached),
1559
1605
  reason: `cached:${cached}`,
1560
1606
  });
1607
+ if (decisionToHook(cached) === "allow")
1608
+ settleGuard();
1561
1609
  return;
1562
1610
  }
1563
1611
  if (!approver) {
1564
1612
  log.warn("no approver configured");
1565
- json(res, 200, fallback(cfg, "no_approver", mustCard));
1613
+ json(res, 200, fallback(cfg, "no_approver", mustCard || guardActive));
1566
1614
  return;
1567
1615
  }
1568
1616
  if (!client.isConnected) {
1569
1617
  log.warn("ws not connected");
1570
- json(res, 200, fallback(cfg, "ws_disconnected", mustCard));
1618
+ json(res, 200, fallback(cfg, "ws_disconnected", mustCard || guardActive));
1571
1619
  return;
1572
1620
  }
1573
1621
  // Build pending + card
@@ -1638,6 +1686,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1638
1686
  approver,
1639
1687
  windowMinutes: cfg.approval.windowMinutes,
1640
1688
  danger: danger?.rule,
1689
+ forceSingle: mustCard,
1641
1690
  sessionName,
1642
1691
  denyReason,
1643
1692
  members: [member],
@@ -1667,7 +1716,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1667
1716
  return;
1668
1717
  }
1669
1718
  log.warn({ err: e.message, reqId }, "approval timed out");
1670
- json(res, 200, fallback(cfg, "approver_timeout", mustCard));
1719
+ json(res, 200, fallback(cfg, "approver_timeout", mustCard || guardActive));
1671
1720
  return;
1672
1721
  }
1673
1722
  // 必发卡的决策一律不留痕: 不写 session cache、不开自动窗口 (卡上本就没这两个
@@ -1690,7 +1739,11 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1690
1739
  await notify(approver, `⚠️ 该操作命中危险名单「${danger.rule}」,不支持「总是」:这类操作每次都要单独确认。`
1691
1740
  + `本次已放行。如确要长期免审,请在 config.jsonc 的 \`approval.danger.allowPatterns\` 里加豁免正则。`);
1692
1741
  }
1693
- const gen = askHit || danger ? [] : alwaysAllowRulesFor(toolName, toolInput, cfg.approval.allowRules);
1742
+ // `.claude/**` 守卫命中的调用现在可以正常存规则: 守卫不再靠"强制发卡"兜底,
1743
+ // settleGuard 挂在每条 allow 出口上 —— 规则放行的那一次同样会去按原生框。
1744
+ const gen = askHit || danger
1745
+ ? []
1746
+ : alwaysAllowRulesFor(toolName, toolInput, cfg.approval.allowRules);
1694
1747
  const added = gen.filter((r) => !cfg.approval.allowRules.includes(r));
1695
1748
  if (added.length > 0) {
1696
1749
  cfg.approval.allowRules.push(...added); // 先热生效; 文件写失败也不回滚内存
@@ -1761,11 +1814,8 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1761
1814
  // 放行 `.claude/**` 写操作后, CC 会在 hook 退出后立起自己的原生确认框 —— 必须
1762
1815
  // 等响应发出去才能去按 (框此刻还不存在)。fire-and-forget: 这条 HTTP 请求已经
1763
1816
  // 结束, 失败也只能靠告知, 不能再改判决。
1764
- if (guardActive && guardHit && decisionToHook(decision) === "allow") {
1765
- void settleClaudeConfigModal(sessionId, approver, guardHit).catch((e) => {
1766
- log.warn({ err: e.message, sessionId }, "settleClaudeConfigModal failed");
1767
- });
1768
- }
1817
+ if (decisionToHook(decision) === "allow")
1818
+ settleGuard();
1769
1819
  };
1770
1820
  };
1771
1821
  // ── Card click event → resolvePending + update card in place ────────────