wezard 1.2.18 → 1.2.21

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/README.md CHANGED
@@ -194,6 +194,44 @@ tagged session 的每条回复自带 `emoji #tag` 前缀(emoji 由 tag 名 has
194
194
 
195
195
  ---
196
196
 
197
+ ## 跨聊天:给聊天命名
198
+
199
+ 一个 WeCom 聊天的身份是 `chat:wrkS…` 这种既读不出也打不进去的 id。所以在给聊天起名之前,跨聊天叫人只有一条路:**赌 tag 全局唯一**——两个群各有一个 `#fix`,就谁也叫不动谁,唯一的出路是回去改别人的 tag。
200
+
201
+ 起个名字,这个聊天就有了能写进消息、也能传给工具的地址。
202
+
203
+ ```
204
+ /name daily 给本聊天起名为 daily
205
+ /name 查看当前名字
206
+ /name - 取消命名
207
+ /chats 列出所有已知聊天、各自跑着哪些会话
208
+ ```
209
+
210
+ **名字规则**:1–32 个字符,字母 / 数字 / `_` / `-`(不能有空格、`#`、`/`、`:`)。全机唯一、大小写不敏感,重名会被拒;改名即覆盖,一个聊天只留一个名字。名字写在 `~/.wezard/config.jsonc` 的 `chats` 里,手改也行。
211
+
212
+ **地址空间**随之变成两级,老写法一字不改:
213
+
214
+ | 写法 | 指向 |
215
+ |---|---|
216
+ | `fix` | 本聊天的 `#fix`;本聊天没有,才回退去找全机唯一的 `#fix` |
217
+ | `daily#fix` | daily 这个聊天的 `#fix`——不问 tag 全不全局唯一 |
218
+ | `daily#` | daily 的默认(无 tag)会话 |
219
+ | `chat:wr…#fix` | 全量 key,`/chats`、`list_peers` 吐出来的原样也能用 |
220
+
221
+ 起完名之后,在**别的群**里直接说人话即可:
222
+
223
+ ```
224
+ 让 daily#fix 看一眼这个报错 → AI 调 send_peer("daily#fix", …)
225
+ 在 daily 里开个 #ingest 跑 ~/repo → AI 调 new_claude_session({ chat: "daily", tag: "ingest", cwd })
226
+ 别的群还有谁在跑 → AI 调 list_chats
227
+ ```
228
+
229
+ 最后一块是**跨群建会话**:`new_claude_session` 的 `chat` 参数可以直接在另一个已命名的聊天里拉起 peer,「目标 peer 还不存在」不再需要找个人去那个群手打 `/new`。跨群的每一次 `send_peer`,两边聊天都会收到 relay 气泡——被叫的那侧不会莫名其妙冒出一句话。
230
+
231
+ > **未命名的聊天既不可寻址、也不可被建入**,这是刻意的:往一个谁也读不出的 `chat:wr…` id 里塞会话,等于把它扔进一个调用方根本不该进的群。想让某个群能被叫到,就在那个群里发一次 `/name`。
232
+
233
+ ---
234
+
197
235
  ## 多 CLI 后端(`claude` / `claude-internal` / `codebuddy`)
198
236
 
199
237
  daemon 同时挂载所有已安装的 CLI,不是二选一:你可以一个 tmux 窗口跑 `claude`、另一个跑 `codebuddy`,各自绑不同的 IM 聊天。**会话身份就是它的 jsonl 路径**,daemon 由路径反推是哪个 CLI 写的,`--resume` 用哪个二进制、jsonl 用哪套 schema 解析、project-dir 怎么编码,全部由此派生。
@@ -256,6 +294,8 @@ IM 里发 `/help` 可随时拉出完整命令表;每次 `/new`、`/clear` 之
256
294
  /new · /clear · /stop · /n 会话控制
257
295
  /sessions [emoji|id] 列出 / 切换 live 会话
258
296
  /new <cli> [#tag] 切换 CLI 后端 / 开并行会话
297
+ /peers 本聊天的会话及忙闲状态
298
+ /name [名字|-] · /chats 给本聊天起名 / 跨聊天目录
259
299
  /id · /pwd · /usage · /cost · /audit 信息查询(免授权)
260
300
  /help 全部命令
261
301
  ```
@@ -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: windowLabel(a.windowMinutes), style: 1, key: encodeKey(a.reqId, "allow_window") },
249
+ { text: windowLabel(a.windowMinutes), style: 4, 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") },
@@ -288,7 +290,8 @@ const buildCard = (a) => {
288
290
  };
289
291
  const fmtWindow = (min) => min % 60 === 0 ? `${min / 60}h` : `${min}min`;
290
292
  // 按钮上的时间窗文案: 裸「10h」看不出是放行, 补 ⏱ + 「自动过」点明语义。
291
- // (style 也从 3 改成 1 企微 3 渲染成红色, 与「放行」相悖。)
293
+ // (style ❌/✅总是/✅ 一律 4 —— 3 渲染成红色, 1 渲染成蓝色, 都让这颗按钮
294
+ // 在一排里显得像另一类操作; 它和其余三颗是同级选项, 不该被强调。)
292
295
  const windowLabel = (min) => `⏱${fmtWindow(min)}自动过`;
293
296
  const verbOf = (d, windowMinutes) => {
294
297
  switch (d) {
@@ -416,7 +419,7 @@ const buildBatchCard = (batch, transcriptTail) => ({
416
419
  task_id: batch.batchId,
417
420
  button_list: [
418
421
  { text: "❌", style: 4, key: encodeBatchKey(batch.batchId, "deny") },
419
- { text: windowLabel(batch.windowMinutes), style: 1, key: encodeBatchKey(batch.batchId, "allow_window") },
422
+ { text: windowLabel(batch.windowMinutes), style: 4, key: encodeBatchKey(batch.batchId, "allow_window") },
420
423
  // 批量卡同样给「总是」: 合流的成员是同一个工具的 N 次调用, 逐个点「总是」与
421
424
  // 点一次的结果相同(每位成员各自走一遍规则生成, 已被现有规则覆盖的不重复加)。
422
425
  { text: "✅总是", style: 4, key: encodeBatchKey(batch.batchId, "allow_always") },
@@ -553,7 +556,7 @@ export const buildAskqCard = (reqId, q, transcriptTail, approver) => {
553
556
  const tail = oneLine(transcriptTail).trim();
554
557
  return {
555
558
  card_type: "vote_interaction",
556
- source: buildSource(tail),
559
+ source: buildSource(undefined, tail),
557
560
  main_title: { title: TRUNC(`🤔 ${emojiFor(approver)}${q.header || "请选择"}`, ASKQ_TITLE_MAX) },
558
561
  task_id: reqId,
559
562
  checkbox: {
@@ -586,7 +589,7 @@ const buildAskqResolvedCard = (reqId, q, outcome, transcriptTail, approver) => {
586
589
  const tail = oneLine(transcriptTail).trim();
587
590
  return {
588
591
  card_type: "button_interaction",
589
- source: buildSource(tail),
592
+ source: buildSource(undefined, tail),
590
593
  main_title: { title: TRUNC(`🤔 ${emojiFor(approver)}${q.header || "已回答"}`, ASKQ_TITLE_MAX) },
591
594
  sub_title_text: TRUNC(q.question, ASKQ_SUB_MAX),
592
595
  task_id: reqId,
@@ -686,7 +689,7 @@ const buildPlanCard = (reqId, _sessionId, cwd, transcriptTail, approver, hasPlan
686
689
  const dir = dirName(cwd);
687
690
  return {
688
691
  card_type: "button_interaction",
689
- source: buildSource(tail),
692
+ source: buildSource(undefined, tail),
690
693
  main_title: { title: TRUNC(`📋 计划审批 · ${tag}${dir}/`, PLAN_TITLE_MAX + 12) },
691
694
  sub_title_text: hasPlan
692
695
  ? "审阅上方计划后选择:同意开始执行,或让 Claude 继续完善。"
@@ -704,7 +707,7 @@ const buildPlanResolvedCard = (reqId, action, cwd, transcriptTail, approver) =>
704
707
  const summary = action === "approve" ? "✅ 已同意 · 开始执行" : "✏️ 继续完善计划";
705
708
  return {
706
709
  card_type: "button_interaction",
707
- source: buildSource(tail),
710
+ source: buildSource(undefined, tail),
708
711
  main_title: { title: TRUNC(`📋 计划 · ${emojiFor(approver)}${dir}/`, PLAN_TITLE_MAX + 12) },
709
712
  task_id: reqId,
710
713
  button_list: [{ text: summary, style: 4, key: encodePlanNoopKey(reqId) }],
@@ -1238,9 +1241,14 @@ const handleAskUserQuestion = async ({ cfg, log, client, body, getMirrorTarget,
1238
1241
  return { decision: "deny", reason };
1239
1242
  };
1240
1243
  const decisionToHook = (d) => (d === "deny" ? "deny" : "allow");
1241
- // 必发卡的请求 (危险名单 / askRules / `.claude/**` 守卫) 永不走 fallbackOnError:
1242
- // "allow" — 超时/断线时降级为 ask, 交回本地 CLI 由人来确认, 而不是静默放行一次
1243
- // rm。只判 danger 的话, 用户自己配的 askRules 在 daemon 挂掉时反而失效。
1244
+ // 必发卡的请求 (危险名单 / askRules) 永不走 fallbackOnError: "allow" — 超时/断线时
1245
+ // 降级为 ask, 交回本地 CLI 由人来确认, 而不是静默放行一次 rm。只判 danger 的话,
1246
+ // 用户自己配的 askRules 在 daemon 挂掉时反而失效。
1247
+ //
1248
+ // `.claude/**` 守卫虽然不进 mustCard, 但在**错误面**也要走同一条降级: 正常出口靠
1249
+ // settleGuard 事后按框, 而这三条 (no_approver / ws_disconnected / approver_timeout)
1250
+ // 要么没有 approver 可按、要么 pane 早已在等框 —— 静默 allow 就是把死锁原样放回来。
1251
+ // 所以调用点传的是 `mustCard || guardActive`, 见 handler 里那三处。
1244
1252
  const fallback = (cfg, reason, forceSingle) => ({
1245
1253
  decision: forceSingle && cfg.approval.fallbackOnError === "allow" ? "ask" : cfg.approval.fallbackOnError,
1246
1254
  reason: forceSingle ? `${reason}:force_single` : reason,
@@ -1297,7 +1305,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1297
1305
  // 按不掉时不干等: Esc 取消掉这次调用, 再把原因作为一条用户消息注入 pane。模型
1298
1306
  // 收到的就是明确的"这条路走不通 + 该怎么绕", 而不是静默卡住 —— 等价于预拦截
1299
1307
  // deny + reason, 只是走镜像通道传达。
1300
- const settleClaudeConfigModal = async (sessionId, approver, hit) => {
1308
+ const settleOne = async (sessionId, approver, hit) => {
1301
1309
  const nm = nativeModal;
1302
1310
  if (!nm)
1303
1311
  return;
@@ -1326,6 +1334,22 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1326
1334
  await notify(approver, `⚠️ 原生确认框未能代按(${r.status})。已${cancelled.ok ? "" : "尝试"}发 Esc 取消本次调用`
1327
1335
  + `${told.ok ? ",并把原因告知了会话" : `(原因注入失败:${told.reason ?? "unknown"},请到 tmux 里看一眼)`}。`);
1328
1336
  };
1337
+ // 同一 pane 上的收尾必须串行。守卫退出 mustCard 之后, 一轮里 N 个 `.claude/**` 写
1338
+ // 会一起走窗口/缓存/规则的快路径、同刻返回 —— 以前每张卡等人点, 天然串成一列。
1339
+ // 并发进 answer() 的话两个协程会看见同一个框都按下去, 第二下落到下一个框或输入框上。
1340
+ // 按 sessionId 串一条 promise 链: 每次收尾等前一次跑完, 失败也不断链。
1341
+ const settleChains = new Map();
1342
+ const settleClaudeConfigModal = (sessionId, approver, hit) => {
1343
+ const next = (settleChains.get(sessionId) ?? Promise.resolve())
1344
+ .then(() => settleOne(sessionId, approver, hit));
1345
+ // 链尾自清: 只有还是自己时才删, 避免把后来者的链一起抹掉。
1346
+ const tail = next.catch(() => { }).finally(() => {
1347
+ if (settleChains.get(sessionId) === tail)
1348
+ settleChains.delete(sessionId);
1349
+ });
1350
+ settleChains.set(sessionId, tail);
1351
+ return next;
1352
+ };
1329
1353
  // Flush 一个 batch: 单成员 → 普通卡 (与未启用聚合一致); 多成员 → 批量卡。
1330
1354
  // 发送失败时调用 failPending 让每位成员的 handler 走 fallbackOnError 路径,
1331
1355
  // 与单卡路径上 sendMessage 抛错时的语义一致。
@@ -1354,6 +1378,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1354
1378
  transcriptTail: m.transcriptTail,
1355
1379
  windowMinutes: batch.windowMinutes,
1356
1380
  danger: batch.danger,
1381
+ forceSingle: batch.forceSingle,
1357
1382
  sessionName: batch.sessionName,
1358
1383
  denyReason: batch.denyReason,
1359
1384
  detailUrl: detailUrlFor(m.reqId, batch.approver),
@@ -1446,8 +1471,24 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1446
1471
  const guardHit = cfg.approval.claudeConfigGuard ? claudeConfigWrite(toolName, toolInput) : undefined;
1447
1472
  const guardActive = Boolean(guardHit && nativeModal?.hasPane(sessionId));
1448
1473
  if (guardHit) {
1449
- 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)");
1474
+ 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)");
1450
1475
  }
1476
+ const approver = resolveApprover(cfg, sessionId, getMirrorTarget);
1477
+ // 守卫的收尾 = 把 CC 那个不过 hook 的原生确认框按掉。它挂在**每一条 allow 出口**
1478
+ // 上, 而不是靠"强制发卡 + 人工点"来触发 —— 死锁的成因是没人按框, 不是没人点卡。
1479
+ //
1480
+ // 这条区别是有代价的历史: 守卫曾经进 mustCard, 于是 ⏱窗口/缓存/合流/sweep 全被
1481
+ // 它短路, 卡上那颗「⏱10h自动过」点了等于一次性放行, 窗口从来没开过。现在窗口
1482
+ // 照常生效, 框照样有人按。
1483
+ //
1484
+ // 必须在 json(res) 之后调用: CC 要等 hook 进程退出才会把框渲染出来。
1485
+ const settleGuard = () => {
1486
+ if (!guardActive || !guardHit || !approver)
1487
+ return;
1488
+ void settleClaudeConfigModal(sessionId, approver, guardHit).catch((e) => {
1489
+ log.warn({ err: e.message, sessionId }, "settleClaudeConfigModal failed");
1490
+ });
1491
+ };
1451
1492
  // 危险名单 (daemon/danger.ts): 内置的 rm / 强推 / DROP / 敏感路径等。语义上
1452
1493
  // 是「出厂自带的 askRules」—— 所以它必须和用户写的 askRules 站在同一层, 排在
1453
1494
  // allowRules 之前。放在后面的话, 一条 `Bash(git *)` 这样的宽 allow 规则 (尤其
@@ -1455,8 +1496,9 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1455
1496
  const danger = dangerOf(cfg, toolName, toolInput);
1456
1497
  if (danger)
1457
1498
  log.info({ toolName, sessionId, rule: danger.rule }, "danger hit — forcing single approval");
1458
- // 必发卡 = 危险名单 或 askRules 命中 或 守卫生效。三者都要压过放行/窗口/缓存。
1459
- const mustCard = Boolean(danger) || Boolean(askHit) || guardActive;
1499
+ // 必发卡 = 危险名单 或 askRules 命中。这两者的语义是「每次都要人单独看一眼」,
1500
+ // 所以压过放行/窗口/缓存。守卫不在其列 —— 见上面 settleGuard 的注释。
1501
+ const mustCard = Boolean(danger) || Boolean(askHit);
1460
1502
  // allowRules: matcher 拦下的工具里再挖细粒度豁免 (Bash 可按命令前缀区分)。
1461
1503
  // 交互卡工具 (AskUserQuestion 等) 在引擎内部硬保护, 规则写了也不放行。
1462
1504
  // 用 evaluateAllow 而非 ruleAllows: 未命中时要把"因为哪一段"带到卡上,
@@ -1466,6 +1508,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1466
1508
  const ruleHit = [...new Set(verdict.hits)].join(" + ");
1467
1509
  log.info({ toolName, sessionId, rule: ruleHit }, "allow-rule skip");
1468
1510
  json(res, 200, { decision: "allow", reason: `allow_rule:${ruleHit}` });
1511
+ settleGuard();
1469
1512
  return;
1470
1513
  }
1471
1514
  const denyReason = verdict.allowed ? undefined : verdict.reason;
@@ -1529,21 +1572,18 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1529
1572
  json(res, 200, resp);
1530
1573
  return;
1531
1574
  }
1532
- // Approver 提前解析: auto-window 现在按 chat 维度生效, isAutoWindowActive
1533
- // 检查要拿到 chatKey (= approver principal) 才能查。approver 缺失时 window
1534
- // 检查跳过 (window 需要一个 chat 才存在), 走后面的 no_approver fallback。
1535
- const approver = resolveApprover(cfg, sessionId, getMirrorTarget);
1536
1575
  // danger.skip / danger 模式的早退。第三个参数是「除 danger 外还有没有别的必发卡
1537
- // 理由」—— askRules `.claude/**` 守卫不能被 danger 的开关顺带关掉, 判定与
1538
- // 理由见 dangerEarlyExit 的注释 (守卫被绕过会让 pane 死锁)。
1539
- const earlyExit = dangerEarlyExit(cfg, danger, Boolean(askHit) || guardActive);
1576
+ // 理由」—— askRules 不能被 danger 的开关顺带关掉。守卫不在其列: 它要的是事后按框,
1577
+ // 早退照样能给 (settleGuard), 拿它挡早退等于把 danger 的开关废掉。
1578
+ const earlyExit = dangerEarlyExit(cfg, danger, Boolean(askHit));
1540
1579
  if (earlyExit) {
1541
1580
  log.info({ toolName, sessionId, reason: earlyExit }, "danger switch early exit");
1542
1581
  json(res, 200, { decision: "allow", reason: earlyExit });
1582
+ settleGuard();
1543
1583
  return;
1544
1584
  }
1545
1585
  // Auto-approve window: while active for THIS chat, requests short-circuit to allow.
1546
- // mustCard (危险名单 / askRules / `.claude/**` 守卫) 的请求不吃窗口 — 即使开着 ⏱ 也逐条确认。
1586
+ // mustCard (危险名单 / askRules) 的请求不吃窗口 — 即使开着 ⏱ 也逐条确认。
1547
1587
  if (!mustCard && approver && isAutoWindowActive(approver)) {
1548
1588
  const remainSec = Math.ceil(autoWindowRemainingMs(approver) / 1000);
1549
1589
  log.info({ toolName, sessionId, chatKey: approver, remainSec }, "auto-window allow");
@@ -1551,11 +1591,12 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1551
1591
  decision: "allow",
1552
1592
  reason: `auto_window:${remainSec}s`,
1553
1593
  });
1594
+ settleGuard();
1554
1595
  return;
1555
1596
  }
1556
1597
  // Session cache (mustCard 的请求同样不吃缓存)
1557
1598
  const ck = cacheKey(sessionId, toolName, toolInput);
1558
- // mustCard(危险名单 / askRules / 守卫)一律不吃缓存 —— 缓存的语义是「这个调用批过一次
1599
+ // mustCard(危险名单 / askRules)一律不吃缓存 —— 缓存的语义是「这个调用批过一次
1559
1600
  // 就不再问」, 与「每次都要单独确认」直接冲突。
1560
1601
  const cached = mustCard ? undefined : cacheGet(ck);
1561
1602
  if (cached) {
@@ -1564,16 +1605,18 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1564
1605
  decision: decisionToHook(cached),
1565
1606
  reason: `cached:${cached}`,
1566
1607
  });
1608
+ if (decisionToHook(cached) === "allow")
1609
+ settleGuard();
1567
1610
  return;
1568
1611
  }
1569
1612
  if (!approver) {
1570
1613
  log.warn("no approver configured");
1571
- json(res, 200, fallback(cfg, "no_approver", mustCard));
1614
+ json(res, 200, fallback(cfg, "no_approver", mustCard || guardActive));
1572
1615
  return;
1573
1616
  }
1574
1617
  if (!client.isConnected) {
1575
1618
  log.warn("ws not connected");
1576
- json(res, 200, fallback(cfg, "ws_disconnected", mustCard));
1619
+ json(res, 200, fallback(cfg, "ws_disconnected", mustCard || guardActive));
1577
1620
  return;
1578
1621
  }
1579
1622
  // Build pending + card
@@ -1644,6 +1687,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1644
1687
  approver,
1645
1688
  windowMinutes: cfg.approval.windowMinutes,
1646
1689
  danger: danger?.rule,
1690
+ forceSingle: mustCard,
1647
1691
  sessionName,
1648
1692
  denyReason,
1649
1693
  members: [member],
@@ -1673,7 +1717,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1673
1717
  return;
1674
1718
  }
1675
1719
  log.warn({ err: e.message, reqId }, "approval timed out");
1676
- json(res, 200, fallback(cfg, "approver_timeout", mustCard));
1720
+ json(res, 200, fallback(cfg, "approver_timeout", mustCard || guardActive));
1677
1721
  return;
1678
1722
  }
1679
1723
  // 必发卡的决策一律不留痕: 不写 session cache、不开自动窗口 (卡上本就没这两个
@@ -1696,14 +1740,9 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1696
1740
  await notify(approver, `⚠️ 该操作命中危险名单「${danger.rule}」,不支持「总是」:这类操作每次都要单独确认。`
1697
1741
  + `本次已放行。如确要长期免审,请在 config.jsonc 的 \`approval.danger.allowPatterns\` 里加豁免正则。`);
1698
1742
  }
1699
- // `.claude/**` 守卫生效的调用同理, 但理由更硬: 规则存了不只是"永远被压过"——
1700
- // 万一日后关掉守卫, 这条 allow 就会把静默死锁原样放回来(不发卡 + pane 阻塞)。
1701
- // 所以这里只做一次性放行, 并说清为什么「总是」在这个场景不成立。
1702
- if (!askHit && !danger && guardActive) {
1703
- await notify(approver, `⚠️ \`.claude/**\` 写操作不支持「总是」:这类改动会触发 CLI 原生确认框,`
1704
- + `而那个框只有在你点过卡之后才能被代按 —— 免审就等于回到静默死锁。本次已放行。`);
1705
- }
1706
- const gen = askHit || danger || guardActive
1743
+ // `.claude/**` 守卫命中的调用现在可以正常存规则: 守卫不再靠"强制发卡"兜底,
1744
+ // settleGuard 挂在每条 allow 出口上 —— 规则放行的那一次同样会去按原生框。
1745
+ const gen = askHit || danger
1707
1746
  ? []
1708
1747
  : alwaysAllowRulesFor(toolName, toolInput, cfg.approval.allowRules);
1709
1748
  const added = gen.filter((r) => !cfg.approval.allowRules.includes(r));
@@ -1721,7 +1760,7 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1721
1760
  log.info({ toolName, added, persisted: Boolean(sourcePath) }, "allow_always rules saved");
1722
1761
  await notify(approver, `📌 已保存永久放行规则:${added.map((r) => `\`${r}\``).join("、")}`);
1723
1762
  }
1724
- else if (!askHit && !danger && !guardActive && gen.length === 0) {
1763
+ else if (!askHit && !danger && gen.length === 0) {
1725
1764
  // 提炼不出可靠字面规则 —— 本次一次性放行并告知。文案必须指对排查方向:
1726
1765
  // 成因有四种, 笼统说"引号/结构有问题"会把用户带偏 (例如真凶是 fd 重定向
1727
1766
  // 的 `&` 被当成后台执行符时, 用户会去查引号)。
@@ -1776,11 +1815,8 @@ export const makeApproveHandler = ({ cfg, log, client, sourcePath, getMirrorTarg
1776
1815
  // 放行 `.claude/**` 写操作后, CC 会在 hook 退出后立起自己的原生确认框 —— 必须
1777
1816
  // 等响应发出去才能去按 (框此刻还不存在)。fire-and-forget: 这条 HTTP 请求已经
1778
1817
  // 结束, 失败也只能靠告知, 不能再改判决。
1779
- if (guardActive && guardHit && decisionToHook(decision) === "allow") {
1780
- void settleClaudeConfigModal(sessionId, approver, guardHit).catch((e) => {
1781
- log.warn({ err: e.message, sessionId }, "settleClaudeConfigModal failed");
1782
- });
1783
- }
1818
+ if (decisionToHook(decision) === "allow")
1819
+ settleGuard();
1784
1820
  };
1785
1821
  };
1786
1822
  // ── Card click event → resolvePending + update card in place ────────────