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 +40 -0
- package/dist/daemon/approval.js +79 -43
- package/dist/daemon/approval.js.map +1 -1
- package/dist/daemon/chat-name.js +86 -0
- package/dist/daemon/chat-name.js.map +1 -0
- package/dist/daemon/inbound.js +143 -7
- package/dist/daemon/inbound.js.map +1 -1
- package/dist/daemon/index.js +177 -48
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/mirror-bridge.js +162 -93
- package/dist/daemon/mirror-bridge.js.map +1 -1
- package/dist/daemon/peers.js +33 -7
- package/dist/daemon/peers.js.map +1 -1
- package/dist/mcp/server.js +60 -26
- package/dist/mcp/server.js.map +1 -1
- package/dist/shared/claude-config-path.js +12 -2
- package/dist/shared/claude-config-path.js.map +1 -1
- package/dist/shared/config.js +6 -0
- package/dist/shared/config.js.map +1 -1
- package/dist/shared/session-label.js +51 -0
- package/dist/shared/session-label.js.map +1 -1
- package/launchd/com.wezard.daemon.plist.template +0 -2
- package/package.json +1 -1
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
|
```
|
package/dist/daemon/approval.js
CHANGED
|
@@ -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
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
1538
|
-
//
|
|
1539
|
-
const earlyExit = dangerEarlyExit(cfg, danger, Boolean(askHit)
|
|
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
|
|
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
|
-
//
|
|
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 &&
|
|
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 (
|
|
1780
|
-
|
|
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 ────────────
|