Stop 从「非阻塞、输出被忽略」改成阻塞式,有待验收项时输出
{"decision":"block","reason":<清单>} 让 Claude 不停下来,先逐条核对。
下一轮的 lastAssistantMessage 里的自查 JSON 被收回来,把 open 推到 claimed。
Stop 的超时阶梯和审批那条**完全分开**,方向也相反:
3s HookCoordinator / 5s hubctl / 10s settings.json,超时一律 = 不拦。
审批超时 = 拒绝(安全刹车);这里超时 = 放行。写反的话 Hub 一出问题
所有会话就再也收不了工,比"少提醒一次验收"严重得多。实测 hook 本身
6-11ms,Hub 在不在都一样。
查出来三个真问题,都不是靠读代码发现的:
1. 服务端从不给 stop 回写应答。我改了 hubctl 让它等,却没动
HubSocketServer 里写死的 guard kind == .preToolUse —— 拦截一次都没生效
过,而且不报错、不超时、10ms 就返回,表现得和"清单里没有待办"
一模一样。端到端测了三轮才找到。修法不是补一个 if,是加
Kind.expectsDecision 让两边共用同一个判断。
这和上次「grep activeScreen() 漏了一个调用点」是同一类错误:
改了协议的一端,假设另一端会跟上。
2. SIGPIPE 会杀掉 Hub。服务端开始给 stop 回写之后,遇到不等应答就关连接
的客户端(旧版 hubctl、被 Ctrl-C 的 hook),那次 write 直接终止进程 ——
不是报错,是进程没了。测试崩在 signal 13 上才暴露。
先加了 per-socket 的 SO_NOSIGPIPE(看起来更讲究),结果没生效,
照样崩;改成进程级 signal(SIGPIPE, SIG_IGN) 才好。
更讲究的写法如果不生效,就只是更讲究的 bug。
3. stop 事件不能去重。缓存决策只有在决策是事件的纯函数时才成立,
而 stop 的结论取决于会变的上膛状态:同样内容的两条 stop,
一条在用户说话前、一条在之后,结论截然相反。实测被这个坑到过。
4. {"done":1} 被判成做完了。JSONSerialization 把数字 1 桥接成 NSNumber,
as? Bool 对它成立 —— 模型只要把 done 写成数字就能绕过"含糊按没做算"。
改用 CFBooleanGetTypeID 严格判定。
端到端实测(真实 hubctl 二进制):未上膛不拦、上膛后拦下 13 条要点、
连着两次收工都放行、Hub 存活、自查回答(带散文和围栏)正确回收成
claimed 且 hasProof 仍为 false。
346 个测试。
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
565 lines
25 KiB
Swift
565 lines
25 KiB
Swift
import Foundation
|
||
import HubCore
|
||
import HubIPC
|
||
import HubProbe
|
||
import HubProjects
|
||
|
||
/// 把 hook 事件接到 UI 上。
|
||
///
|
||
/// 链路:`hubctl` → Unix socket → 这里 → 通知 / 灵动岛闯入 / 审批。
|
||
@MainActor
|
||
public final class HookCoordinator {
|
||
|
||
private let store: SessionStore
|
||
private let approvals: ApprovalCoordinator
|
||
private let prompts: AgentPromptCoordinator
|
||
private let notifications: HubNotificationCenter
|
||
private let projects: ProjectStore
|
||
private let acceptance: AcceptanceStore
|
||
private var server: HubSocketServer?
|
||
|
||
/// 每类 hook 最后一次收到事件的时间。设置页用它显示通道健康度 ——
|
||
/// 「配好了」和「真的在通」是两回事,只有后者能证明链路活着。
|
||
public let channels = HookChannelMonitor()
|
||
|
||
private let dedup = HookDedup()
|
||
private let extractor = AcceptanceExtractor()
|
||
/// 正在拆解中的项目。同一个项目的拆解不并发跑 —— 两次并发会拿到同一份
|
||
/// 原话缓冲,把同一批要点拆两遍(去重能挡住重复入库,但白烧一次额度)。
|
||
private var extracting: Set<String> = []
|
||
|
||
/// 触发灵动岛闯入。
|
||
public var onIntrusion: ((IntrusionEvent) -> Void)?
|
||
/// 需要弹审批面板。
|
||
public var onApprovalNeeded: (() -> Void)?
|
||
/// 需要弹交互作答卡(选择题 / 计划审批)。
|
||
public var onPromptNeeded: (() -> Void)?
|
||
|
||
/// 闯入防轰炸:记录每个会话上次闯入的时间。
|
||
///
|
||
/// 9 个会话同时收工的时候这条至关重要 —— 不限流的话岛会连续膨胀九次,
|
||
/// 从"有用的提醒"变成"必须关掉的骚扰"。
|
||
private var lastIntrusionAt: [String: Date] = [:]
|
||
private let intrusionCooldown: TimeInterval = 60
|
||
|
||
private let classifier = RiskClassifier()
|
||
|
||
public init(
|
||
store: SessionStore,
|
||
approvals: ApprovalCoordinator,
|
||
prompts: AgentPromptCoordinator,
|
||
notifications: HubNotificationCenter,
|
||
projects: ProjectStore,
|
||
acceptance: AcceptanceStore
|
||
) {
|
||
self.store = store
|
||
self.approvals = approvals
|
||
self.prompts = prompts
|
||
self.notifications = notifications
|
||
self.projects = projects
|
||
self.acceptance = acceptance
|
||
}
|
||
|
||
public func start() {
|
||
let server = HubSocketServer { [weak self] event in
|
||
// 这个闭包跑在 socket 的工作线程上。preToolUse 要同步返回决策,
|
||
// 而决策来自 MainActor 上的 UI,所以用信号量把异步结果桥回来。
|
||
// 阻塞的是这条连接自己的线程,不影响其他连接。
|
||
guard let self else { return .allow }
|
||
return self.handle(event)
|
||
}
|
||
|
||
do {
|
||
try server.start()
|
||
self.server = server
|
||
NSLog("[ClaudeHub] hook socket 已监听:\(HubSocket.path)")
|
||
} catch {
|
||
NSLog("[ClaudeHub] hook socket 启动失败:\(error) —— hook 将全部放行")
|
||
}
|
||
}
|
||
|
||
public func stop() {
|
||
server?.stop()
|
||
server = nil
|
||
}
|
||
|
||
// MARK: - 事件分发
|
||
|
||
/// 去重 → 分发。
|
||
///
|
||
/// 去重必须包在最外层:hook 配置是叠加的(全局 + 项目级都会跑一遍,本机实测确认),
|
||
/// 同一个事件进来两次。详见 `HookDedup`。
|
||
private nonisolated func handle(_ event: HookEvent) -> HookDecision {
|
||
Task { @MainActor in self.channels.record(event.kind) }
|
||
|
||
guard let key = HookDedup.key(for: event) else { return dispatch(event) }
|
||
|
||
switch dedup.begin(key, waitForDecision: event.kind == .preToolUse) {
|
||
case .duplicate(let decision):
|
||
HubLog.ipc.notice("去重:\(event.kind.rawValue, privacy: .public) 重复事件")
|
||
return decision ?? .allow
|
||
case .first:
|
||
let decision = dispatch(event)
|
||
dedup.finish(key, decision: decision)
|
||
return decision
|
||
}
|
||
}
|
||
|
||
private nonisolated func dispatch(_ event: HookEvent) -> HookDecision {
|
||
switch event.kind {
|
||
case .stop:
|
||
return handleStopSynchronously(event)
|
||
|
||
case .notification:
|
||
Task { @MainActor in self.handleNotification(event) }
|
||
return .allow
|
||
|
||
case .sessionEnd:
|
||
Task { @MainActor in self.store.refresh() }
|
||
return .allow
|
||
|
||
case .userPromptSubmit:
|
||
Task { @MainActor in self.handleUserPrompt(event) }
|
||
return .allow
|
||
|
||
case .sessionStart:
|
||
Task { @MainActor in self.store.refresh() }
|
||
return .allow
|
||
|
||
case .postToolUse:
|
||
Task { @MainActor in self.handlePostToolUse(event) }
|
||
return .allow
|
||
|
||
case .subagentStop, .preCompact:
|
||
// 目前只用来点亮通道健康度(上面 handle 里已经记过)。
|
||
// preCompact 之后 Claude 会忘掉一大段上下文 —— 而清单不会,
|
||
// 这正是它存在的意义,不需要额外动作。
|
||
return .allow
|
||
|
||
case .preToolUse:
|
||
return handlePreToolUseSynchronously(event)
|
||
}
|
||
}
|
||
|
||
/// 收工。**阻塞式** —— hubctl 在等"要不要把验收清单塞回去"。
|
||
///
|
||
/// ## 兜底方向和审批链路相反,这是本方法最要紧的一条
|
||
///
|
||
/// `handlePreToolUseSynchronously` 超时兜底是 **deny**(安全刹车)。
|
||
/// 这里超时兜底必须是 **allow = 不拦**。写反的话 Hub 一出问题,
|
||
/// 所有会话就再也收不了工 —— 比"少提醒一次验收"严重得多。
|
||
///
|
||
/// 3 秒足够:该不该拦是查内存里的清单,毫秒级就有答案,
|
||
/// 这 3 秒纯粹是给 MainActor 排队留的余量。
|
||
private nonisolated func handleStopSynchronously(_ event: HookEvent) -> HookDecision {
|
||
let semaphore = DispatchSemaphore(value: 0)
|
||
// 同 handlePreToolUseSynchronously:只有下面的 Task 写、wait 之后才读。
|
||
nonisolated(unsafe) var result = HookDecision.allow
|
||
|
||
Task { @MainActor in
|
||
result = self.handleStop(event)
|
||
semaphore.signal()
|
||
}
|
||
|
||
if semaphore.wait(timeout: .now() + HookTimeouts.stopBridge) == .timedOut {
|
||
HubLog.ipc.error("收工桥接超时 —— 放行(绝不能因为 Hub 慢就让会话停不下来)")
|
||
return .allow
|
||
}
|
||
return result
|
||
}
|
||
|
||
@MainActor
|
||
private func handleStop(_ event: HookEvent) -> HookDecision {
|
||
store.refresh()
|
||
|
||
let path = AcceptanceStore.projectPath(forCWD: event.cwd, projects: projects)
|
||
|
||
// 先收自查回答,再决定拦不拦。
|
||
//
|
||
// 顺序不能反:上一轮拦下来之后 Claude 的回答就在这条事件的
|
||
// lastAssistantMessage 里,先收下来,这一轮的清单才是最新的。
|
||
collectClaims(from: event, projectPath: path)
|
||
|
||
let decision = interceptDecision(for: event, projectPath: path)
|
||
|
||
let project = projects.projectName(forPath: event.cwd) ?? event.fallbackProjectName
|
||
let session = store.sessions.first { $0.sessionId == event.sessionId }
|
||
let title = session?.name ?? project
|
||
|
||
// 正文用 Claude 自己的最终回复,替掉旧实现那 5 条随机中文文案
|
||
// ("搞定了,来看看" 之类,信息量为零,用户还得切过去才知道干了什么)。
|
||
let body = Self.summarize(event.lastAssistantMessage) ?? "完成了一轮回复"
|
||
|
||
// 被拦下来的这一轮不是"收工",别发完成通知 —— Claude 马上还要再跑一轮,
|
||
// 现在弹「✅ 完成了」是在骗用户。
|
||
guard decision.verdict != .deny else {
|
||
HubLog.app.notice("验收守望:拦下 \(title, privacy: .public) 的收工,要求逐条核对")
|
||
return decision
|
||
}
|
||
|
||
notifications.post(title: "✅ \(title)", body: body, sessionId: event.sessionId)
|
||
|
||
if allowIntrusion(for: event.sessionId) {
|
||
onIntrusion?(
|
||
IntrusionEvent(
|
||
kind: .finished, sessionId: event.sessionId,
|
||
title: title, detail: "\(project) · \(body)"
|
||
)
|
||
)
|
||
}
|
||
return decision
|
||
}
|
||
|
||
/// 该不该拦这一次收工。
|
||
@MainActor
|
||
private func interceptDecision(for event: HookEvent, projectPath: String) -> HookDecision {
|
||
// Claude 已经因为 Stop hook 在续跑了 —— 绝不能再拦。
|
||
//
|
||
// 这是**第二道**。真正的保证是下面 disarmAndShouldIntercept 里的上膛机制,
|
||
// 因为这个字段在本机没验证过,不同 CLI 版本给不给都不确定。
|
||
guard event.stopHookActive != true else { return .allow }
|
||
|
||
guard acceptance.disarmAndShouldIntercept(
|
||
sessionId: event.sessionId, projectPath: projectPath
|
||
) else { return .allow }
|
||
|
||
guard let text = acceptance.injectionText(for: projectPath) else { return .allow }
|
||
|
||
return HookDecision(verdict: .deny, reason: text)
|
||
}
|
||
|
||
/// 收下 Claude 上一轮的逐条自查回答。
|
||
///
|
||
/// 解析不出来就什么都不做 —— 它很可能是直接去补做遗漏项了(那其实是好事),
|
||
/// 或者干脆没按格式答。这两种情况都退回旁路复核,不该报错也不该清状态。
|
||
@MainActor
|
||
private func collectClaims(from event: HookEvent, projectPath: String) {
|
||
guard let message = event.lastAssistantMessage,
|
||
let claims = Self.parseClaims(message), !claims.isEmpty
|
||
else { return }
|
||
acceptance.applyClaims(claims, in: projectPath)
|
||
HubLog.app.notice("验收守望:收下 \(claims.count, privacy: .public) 条自查回答")
|
||
}
|
||
|
||
/// 从 Claude 的回复里挖出自查 JSON。
|
||
///
|
||
/// 走 `ModelOutput.extractJSONObject` 而不是直接解析:模型在 JSON 前后
|
||
/// 各写一段话是常态("我核对了一遍:{…} 需要我继续吗?"),
|
||
/// 而这里它是在一段长对话的末尾被要求输出 JSON 的,更容易加客套话。
|
||
nonisolated static func parseClaims(_ message: String) -> [AcceptanceClaim]? {
|
||
guard let object = ModelOutput.extractJSONObject(message),
|
||
let raw = object["items"] as? [[String: Any]]
|
||
else { return nil }
|
||
|
||
return raw.compactMap { item in
|
||
guard let id = item["id"] as? String, !id.isEmpty else { return nil }
|
||
return AcceptanceClaim(
|
||
id: id,
|
||
// done 缺失或不是**真正的布尔**时按没做算。方向是刻意的:
|
||
// 把含糊不清当成"做完了",等于给虚报开了个免检通道。
|
||
done: Self.strictlyTrue(item["done"]),
|
||
evidence: (item["evidence"] as? String) ?? ""
|
||
)
|
||
}
|
||
}
|
||
|
||
/// 只有 JSON 里字面写的 `true` 才算数。
|
||
///
|
||
/// **不能用 `as? Bool`。** JSONSerialization 把 `true` 和 `1` 都解成 NSNumber,
|
||
/// 而 NSNumber 到 Bool 的桥接对 `1` 是成立的 —— 于是 `{"done":1}` 会被判成
|
||
/// 做完了。测试抓到过:模型只要把 done 写成数字就能绕过"含糊按没做算"。
|
||
///
|
||
/// CFBoolean 和 CFNumber 是不同的 CF 类型,按类型 ID 判才分得开。
|
||
nonisolated static func strictlyTrue(_ value: Any?) -> Bool {
|
||
guard let number = value as? NSNumber,
|
||
CFGetTypeID(number) == CFBooleanGetTypeID()
|
||
else { return false }
|
||
return number.boolValue
|
||
}
|
||
|
||
/// 用户提交了一句话。
|
||
///
|
||
/// 这个 handler 干两件事,**都必须极快**(这条 hook 挡在用户和 Claude 之间):
|
||
/// 1. 把原话原样记进清单缓冲 —— 零成本,不调模型;
|
||
/// 2. 给这个会话的拦截「上膛」。
|
||
///
|
||
/// 第 2 件是全案防死循环的结构性保证:`UserPromptSubmit` 是**唯一**的上膛入口,
|
||
/// 而它只在用户敲回车时触发。Claude 没有任何路径能给自己上膛,
|
||
/// 所以「拦一次 → 它回一轮 → 又拦」这个循环构造不出来。详见 `AcceptanceStore`。
|
||
///
|
||
/// 拆解是异步的:它要花几秒到几十秒,绝不能挡在这里。等 Claude 干完活
|
||
/// (通常几分钟),拆解早就完成了。
|
||
@MainActor
|
||
private func handleUserPrompt(_ event: HookEvent) {
|
||
acceptance.arm(sessionId: event.sessionId)
|
||
|
||
guard let text = event.promptText?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||
!text.isEmpty
|
||
else { return }
|
||
|
||
let path = AcceptanceStore.projectPath(forCWD: event.cwd, projects: projects)
|
||
acceptance.recordPrompt(
|
||
RawPrompt(text: text, sessionId: event.sessionId), in: path
|
||
)
|
||
scheduleExtraction(projectPath: path, plan: nil)
|
||
}
|
||
|
||
/// 工具跑完了。
|
||
///
|
||
/// **这是"实际功能证明"最便宜的一半。** Write / Edit 之后 Hub 第一手知道
|
||
/// 哪个文件被改了 —— 不用回头问 Claude"你改了什么",而转述那一步正是
|
||
/// 失真发生的地方。这些文件后面会交给旁路复核去和清单对号。
|
||
@MainActor
|
||
private func handlePostToolUse(_ event: HookEvent) {
|
||
guard let toolName = event.toolName, Self.editingTools.contains(toolName),
|
||
let path = event.toolSummary, path.hasPrefix("/")
|
||
else { return }
|
||
acceptance.recordTouchedFile(path, sessionId: event.sessionId)
|
||
}
|
||
|
||
/// 会改代码的工具。`toolSummary` 对这几个提取的正是 `file_path`。
|
||
static let editingTools: Set<String> = ["Write", "Edit", "MultiEdit", "NotebookEdit"]
|
||
|
||
/// 把缓冲里的原话(和可选的计划全文)拆成要点,合并进清单。
|
||
@MainActor
|
||
private func scheduleExtraction(projectPath: String, plan: String?) {
|
||
guard !extracting.contains(projectPath) else { return }
|
||
|
||
let ledger = acceptance.ledger(for: projectPath)
|
||
let prompts = ledger.rawPrompts
|
||
guard !prompts.isEmpty || plan != nil else { return }
|
||
let existing = ledger.items.map(\.text)
|
||
// 只清掉这次真正喂进去的那些。拆解期间用户可能又说了话,
|
||
// 按时间截断而不是整个清空,才不会把新说的一起丢掉。
|
||
let cutoff = prompts.last?.at ?? Date()
|
||
|
||
extracting.insert(projectPath)
|
||
let extractor = self.extractor
|
||
|
||
Task.detached(priority: .utility) {
|
||
let points: [ExtractedPoint]?
|
||
if await extractor.shouldExtract(prompts: prompts, plan: plan) {
|
||
points = await extractor.extract(
|
||
prompts: prompts, plan: plan, existing: existing
|
||
)
|
||
} else {
|
||
// 太短、或功能没开 —— 不是失败,原话继续攒着等下一次。
|
||
points = nil
|
||
}
|
||
|
||
await MainActor.run {
|
||
self.extracting.remove(projectPath)
|
||
// nil = 这次没跑成。**原话必须留着** —— 它是这个功能唯一的基线来源,
|
||
// 一次失败就丢掉的话,用户说过的要求就永远找不回来了。
|
||
guard let points else { return }
|
||
|
||
self.acceptance.clearPrompts(upTo: cutoff, in: projectPath)
|
||
guard !points.isEmpty else { return }
|
||
self.acceptance.merge(
|
||
points.map {
|
||
AcceptanceItem(
|
||
text: $0.text,
|
||
acceptance: $0.acceptance,
|
||
origin: $0.inferred ? .inferred : (plan != nil ? .plan : .userPrompt),
|
||
baselineCommit: nil
|
||
)
|
||
},
|
||
into: projectPath
|
||
)
|
||
HubLog.app.notice(
|
||
"验收清单:\(projectPath, privacy: .public) 新增 \(points.count, privacy: .public) 条"
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
@MainActor
|
||
private func handleNotification(_ event: HookEvent) {
|
||
store.refresh()
|
||
|
||
let project = projects.projectName(forPath: event.cwd) ?? event.fallbackProjectName
|
||
let session = store.sessions.first { $0.sessionId == event.sessionId }
|
||
let title = session?.name ?? project
|
||
let needsUser = event.notificationType == "permission_prompt"
|
||
|| event.notificationType == "idle_prompt"
|
||
|| event.notificationType == "elicitation_dialog"
|
||
|| event.notificationType == "agent_needs_input"
|
||
|
||
notifications.post(
|
||
title: needsUser ? "⚠️ \(title)" : title,
|
||
body: event.message ?? "需要你处理",
|
||
sessionId: event.sessionId
|
||
)
|
||
|
||
// 权限确认框("Do you want to create xxx?")弹**可作答的卡**而不是横幅:
|
||
// 横幅只能提醒"有个框在等你",卡能直接把 1 / Esc 发过去。
|
||
// 答案走终端按键注入,不走 hook 回传 —— 这个 hook 是 async 的,早就返回了。
|
||
if event.notificationType == "permission_prompt" {
|
||
guard !prompts.hasPermissionCard(for: event.sessionId) else { return }
|
||
prompts.enqueue(
|
||
AgentPromptRequest(
|
||
id: event.requestId,
|
||
sessionId: event.sessionId,
|
||
projectName: project,
|
||
cwd: event.cwd,
|
||
payload: .permission(message: event.message ?? "Claude 在等你授权"),
|
||
// 发起方 hubctl 发完通知就退出了,绝不能让 orphan sweep
|
||
// 按"发起方已死"把这张卡清掉 —— 0 = 不做存活检测。
|
||
clientPid: 0
|
||
)
|
||
)
|
||
onPromptNeeded?()
|
||
return
|
||
}
|
||
|
||
guard needsUser, allowIntrusion(for: event.sessionId) else { return }
|
||
onIntrusion?(
|
||
IntrusionEvent(
|
||
kind: .needsInput, sessionId: event.sessionId,
|
||
title: title, detail: "\(project) · \(event.message ?? "等待你的输入")"
|
||
)
|
||
)
|
||
}
|
||
|
||
/// PreToolUse 必须**同步**返回 —— hubctl 那头在等这条连接的应答。
|
||
///
|
||
/// 用信号量把 MainActor 上的异步审批结果桥回当前线程。这是少数几个
|
||
/// 用信号量阻塞是正确做法的场景:调用方本来就是一条专用的连接线程,
|
||
/// 它的全部职责就是等这个结果。
|
||
private nonisolated func handlePreToolUseSynchronously(_ event: HookEvent) -> HookDecision {
|
||
// 交互类工具(选择题 / 计划审批)在 risk 判定之前分流:它们的
|
||
// tool_input 里没有 command / path,走风险链路会被判成 normal
|
||
// 直接放行 —— 岛上什么都不弹,这正是要修的 bug。
|
||
if let toolName = event.toolName, AgentPromptPayload.interactiveTools.contains(toolName) {
|
||
return handleAgentPromptSynchronously(event, toolName: toolName)
|
||
}
|
||
|
||
let risk = classifier.classify(toolName: event.toolName, summary: event.toolSummary)
|
||
guard risk != .normal else { return .allow }
|
||
|
||
guard classifier.shouldIntercept(risk) else {
|
||
// dangerous 档默认只记录不拦截:用户全局开着 auto + skip-permissions,
|
||
// 拦上这一档每天要弹 3–8 次,结果必然是他把整个功能关掉。
|
||
Task { @MainActor in
|
||
let project = self.projects.projectName(forPath: event.cwd)
|
||
?? event.fallbackProjectName
|
||
self.approvals.recordWithoutIntercepting(
|
||
projectName: project,
|
||
toolName: event.toolName ?? "?",
|
||
command: event.toolSummary ?? "",
|
||
risk: risk
|
||
)
|
||
}
|
||
return .allow
|
||
}
|
||
|
||
let semaphore = DispatchSemaphore(value: 0)
|
||
// nonisolated(unsafe) 是安全的:只有下面的 Task 写、只有 wait 之后才读,
|
||
// 信号量提供了 happens-before 关系。
|
||
nonisolated(unsafe) var result = HookDecision.allow
|
||
|
||
Task { @MainActor in
|
||
let project = self.projects.projectName(forPath: event.cwd)
|
||
?? event.fallbackProjectName
|
||
let request = ApprovalRequest(
|
||
id: event.requestId,
|
||
sessionId: event.sessionId,
|
||
projectName: project,
|
||
cwd: event.cwd,
|
||
toolName: event.toolName ?? "?",
|
||
command: event.toolSummary ?? "(无参数)",
|
||
risk: risk,
|
||
clientPid: event.clientPid ?? 0
|
||
)
|
||
self.onApprovalNeeded?()
|
||
result = await self.approvals.requestDecision(for: request)
|
||
semaphore.signal()
|
||
}
|
||
|
||
// 见 HookTimeouts 的超时阶梯:这一层要比 ApprovalCoordinator 的
|
||
// 用户决策超时长,但比 hubctl 的读超时短。
|
||
if semaphore.wait(timeout: .now() + HookTimeouts.serverBridge) == .timedOut {
|
||
HubLog.ipc.error("审批桥接超时,按拒绝处理")
|
||
return HookDecision(verdict: .deny, reason: "Claude Hub 审批超时")
|
||
}
|
||
HubLog.ipc.notice("用户决策:\(result.verdict.rawValue, privacy: .public)")
|
||
return result
|
||
}
|
||
|
||
/// 交互作答(选择题 / 计划审批)的同步桥。结构同上,但**兜底方向相反**:
|
||
/// 这条链路的一切异常都必须落在 allow(不输出决策)—— 那只是把问题
|
||
/// 交还给终端的原生对话框,用户什么都没损失;落在 deny 会把 Claude 的
|
||
/// 正常提问变成一次莫名其妙的失败。
|
||
private nonisolated func handleAgentPromptSynchronously(
|
||
_ event: HookEvent, toolName: String
|
||
) -> HookDecision {
|
||
let semaphore = DispatchSemaphore(value: 0)
|
||
// 同 handlePreToolUseSynchronously:只有下面的 Task 写、wait 之后才读。
|
||
nonisolated(unsafe) var result = HookDecision.allow
|
||
|
||
Task { @MainActor in
|
||
let project = self.projects.projectName(forPath: event.cwd)
|
||
?? event.fallbackProjectName
|
||
let payload = AgentPromptPayload.parse(
|
||
toolName: toolName, inputJSON: event.toolInputJSON
|
||
) ?? .complex(hint: "Claude 在等你的输入")
|
||
let request = AgentPromptRequest(
|
||
id: event.requestId,
|
||
sessionId: event.sessionId,
|
||
projectName: project,
|
||
cwd: event.cwd,
|
||
payload: payload,
|
||
clientPid: event.clientPid ?? 0
|
||
)
|
||
self.onPromptNeeded?()
|
||
result = await self.prompts.requestDecision(for: request)
|
||
semaphore.signal()
|
||
|
||
// 计划全文进验收清单 —— 这是权威性最高的基线来源(用户明确批准过的)。
|
||
//
|
||
// 两个时机上的讲究:
|
||
// - **在 signal 之后**:拆解要起一个 claude 子进程,绝不能拖着 hubctl 等;
|
||
// - **只在没被驳回时**:驳回的计划不是基线,是被否掉的方案。
|
||
// passthrough(用户跑去终端自己确认)也算数 —— 那种情况 Hub 看不到
|
||
// 最终结果,但用户既然没在岛上按驳回,按批准处理比丢掉更合理。
|
||
if case .plan(let text) = payload, result.verdict != .deny {
|
||
self.scheduleExtraction(
|
||
projectPath: AcceptanceStore.projectPath(
|
||
forCWD: event.cwd, projects: self.projects
|
||
),
|
||
plan: text
|
||
)
|
||
}
|
||
}
|
||
|
||
if semaphore.wait(timeout: .now() + HookTimeouts.serverBridge) == .timedOut {
|
||
HubLog.ipc.error("交互卡桥接超时,交还终端处理")
|
||
return .allow
|
||
}
|
||
HubLog.ipc.notice("交互作答:\(result.verdict.rawValue, privacy: .public)")
|
||
return result
|
||
}
|
||
|
||
// MARK: - 辅助
|
||
|
||
private func allowIntrusion(for sessionId: String) -> Bool {
|
||
let now = Date()
|
||
if let last = lastIntrusionAt[sessionId], now.timeIntervalSince(last) < intrusionCooldown {
|
||
return false
|
||
}
|
||
lastIntrusionAt[sessionId] = now
|
||
return true
|
||
}
|
||
|
||
/// 把 Claude 的最终回复压成一行通知正文。
|
||
static func summarize(_ message: String?) -> String? {
|
||
guard let message else { return nil }
|
||
let cleaned = message
|
||
.replacingOccurrences(of: "\n", with: " ")
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !cleaned.isEmpty else { return nil }
|
||
if cleaned.count <= 90 { return cleaned }
|
||
return String(cleaned.prefix(88)) + "…"
|
||
}
|
||
}
|