Files
vibe-foreman/HubKit/Sources/HubUI/HookCoordinator.swift
T
wuguanganandClaude Opus 5 fe3febbfd2 feat: Stop 拦截注入 —— 收工时拿清单逼它逐条报证据
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>
2026-08-07 18:10:35 +08:00

565 lines
25 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)) + "…"
}
}