上一版只改了 app 显示名,Swift 里的字面量还写着旧名 —— 而那批是 **用户直接看得到的**:验收守望的提示头、审批被拦时的理由、 岛上作答回给 Claude 的那句、验收页的空状态。 正文用短名 Vibe Foreman,app 显示名保持 Vibe Foreman Free: 全名带 Free 夹在句子中间很别扭,而且以后真出付费版时正文不用再改一遍。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
200 lines
7.5 KiB
Swift
200 lines
7.5 KiB
Swift
import Foundation
|
||
import HubIPC
|
||
|
||
/// 一条待作答的交互请求(选择题 / 计划审批)。
|
||
public struct AgentPromptRequest: Identifiable, Sendable {
|
||
public let id: String
|
||
public let sessionId: String
|
||
public let projectName: String
|
||
public let cwd: String
|
||
public let payload: AgentPromptPayload
|
||
/// 发起这次调用的 hook 进程,用于 orphan 检测(同 ApprovalRequest.clientPid)。
|
||
public let clientPid: pid_t
|
||
public let createdAt: Date
|
||
|
||
public init(
|
||
id: String, sessionId: String, projectName: String, cwd: String,
|
||
payload: AgentPromptPayload, clientPid: pid_t = 0, createdAt: Date = Date()
|
||
) {
|
||
self.id = id
|
||
self.sessionId = sessionId
|
||
self.projectName = projectName
|
||
self.cwd = cwd
|
||
self.payload = payload
|
||
self.clientPid = clientPid
|
||
self.createdAt = createdAt
|
||
}
|
||
}
|
||
|
||
/// 岛上作答(选择题 / 计划审批)的协调器。
|
||
///
|
||
/// 结构照 `ApprovalCoordinator`(queue + continuation + 超时 + orphan sweep),
|
||
/// 但**故意不合并进去**:两边的超时方向相反 ——
|
||
///
|
||
/// - 审批:超时 **deny**。它是唯一的安全刹车,放行不可逆。
|
||
/// - 交互卡:超时 **allow(不输出决策)**。这不是放行危险操作,而是把问题
|
||
/// 交还给终端的原生对话框:Claude 走正常权限流程,终端照常弹框,
|
||
/// 用户什么都没损失。给安全刹车塞一条 fail-open 分支才是真风险。
|
||
@Observable
|
||
@MainActor
|
||
public final class AgentPromptCoordinator {
|
||
|
||
public private(set) var queue: [AgentPromptRequest] = []
|
||
public var current: AgentPromptRequest? { queue.first }
|
||
|
||
private var pending: [String: CheckedContinuation<HookDecision, Never>] = [:]
|
||
private var timeoutTasks: [String: Task<Void, Never>] = [:]
|
||
private var orphanSweep: Task<Void, Never>?
|
||
|
||
/// 可注入:测试要用 0.1s 验证"超时方向是 allow"。
|
||
private let timeout: TimeInterval
|
||
|
||
public init(timeout: TimeInterval = HookTimeouts.promptDecision) {
|
||
self.timeout = timeout
|
||
}
|
||
|
||
// MARK: - hook 侧入口
|
||
|
||
/// 提交一次交互请求并等待作答。**会阻塞调用方直到有结果。**
|
||
public func requestDecision(for request: AgentPromptRequest) async -> HookDecision {
|
||
queue.append(request)
|
||
|
||
let task = Task { [weak self, timeout] in
|
||
try? await Task.sleep(for: .seconds(timeout))
|
||
guard !Task.isCancelled else { return }
|
||
// 到点交还终端:不输出决策,原生对话框正常出现。
|
||
await self?.resolve(id: request.id, decision: .allow)
|
||
}
|
||
timeoutTasks[request.id] = task
|
||
|
||
return await withCheckedContinuation { continuation in
|
||
pending[request.id] = continuation
|
||
}
|
||
}
|
||
|
||
// MARK: - UI 侧动作
|
||
|
||
/// 选择题:点选一个选项(单题快捷通道)。用 deny + reason 把答案回传给
|
||
/// Claude —— PreToolUse 没有"替用户作答"的通道,deny 的 reason 是唯一能
|
||
/// 把文本送回模型的口子。文案写成指令式,防止 Claude 把它当失败重新提问。
|
||
public func choose(_ request: AgentPromptRequest, option: AgentQuestion.Option) {
|
||
resolve(
|
||
id: request.id,
|
||
decision: HookDecision(
|
||
verdict: .deny,
|
||
reason: "用户已在 Vibe Foreman 上选择:\(option.label)。请按这个选择继续,不要重复提问。"
|
||
)
|
||
)
|
||
}
|
||
|
||
/// 多题 / 多选:把每道题的作答一并回传。
|
||
public func submitAnswers(_ request: AgentPromptRequest, answers: [AgentAnswer]) {
|
||
let joined = answers.map(\.line).joined(separator: ";")
|
||
resolve(
|
||
id: request.id,
|
||
decision: HookDecision(
|
||
verdict: .deny,
|
||
reason: "用户已在 Vibe Foreman 上作答 —— \(joined)。请按这些选择继续,不要重复提问。"
|
||
)
|
||
)
|
||
}
|
||
|
||
/// 计划审批 / 进入计划模式:同意。显式输出 allow,跳过终端确认直接生效。
|
||
public func approvePlan(_ request: AgentPromptRequest) {
|
||
resolve(
|
||
id: request.id,
|
||
decision: HookDecision(verdict: .allow, explicitAllow: true)
|
||
)
|
||
}
|
||
|
||
/// 计划审批:驳回。
|
||
public func rejectPlan(_ request: AgentPromptRequest) {
|
||
resolve(
|
||
id: request.id,
|
||
decision: HookDecision(
|
||
verdict: .deny,
|
||
reason: "用户在 Vibe Foreman 上驳回了这份计划,请调整后重新提出。"
|
||
)
|
||
)
|
||
}
|
||
|
||
/// 进入计划模式:拒绝。
|
||
public func rejectEnterPlan(_ request: AgentPromptRequest) {
|
||
resolve(
|
||
id: request.id,
|
||
decision: HookDecision(
|
||
verdict: .deny,
|
||
reason: "用户不同意进入计划模式,请按当前方式继续。"
|
||
)
|
||
)
|
||
}
|
||
|
||
/// 「去终端回答」/ 降级卡:不输出决策,终端原生对话框接管。
|
||
public func passthrough(_ request: AgentPromptRequest) {
|
||
resolve(id: request.id, decision: .allow)
|
||
}
|
||
|
||
// MARK: - 通知侧入口(权限确认框)
|
||
|
||
/// 免等待入队:权限确认框来自 Notification hook,没有阻塞方在等结果,
|
||
/// 卡片的"结果"是往终端注入按键。超时到点只是把卡收掉,终端框还在。
|
||
public func enqueue(_ request: AgentPromptRequest) {
|
||
queue.append(request)
|
||
let task = Task { [weak self, timeout] in
|
||
try? await Task.sleep(for: .seconds(timeout))
|
||
guard !Task.isCancelled else { return }
|
||
await self?.resolve(id: request.id, decision: .allow)
|
||
}
|
||
timeoutTasks[request.id] = task
|
||
}
|
||
|
||
/// 收掉一张卡(免等待卡答完 / 会话自己恢复忙碌时用)。对阻塞卡等价于 passthrough。
|
||
public func dismiss(_ request: AgentPromptRequest) {
|
||
resolve(id: request.id, decision: .allow)
|
||
}
|
||
|
||
/// 同一个会话的权限框已经在排队了吗?(通知可能重发,别弹两张一样的卡。)
|
||
public func hasPermissionCard(for sessionId: String) -> Bool {
|
||
queue.contains {
|
||
guard case .permission = $0.payload else { return false }
|
||
return $0.sessionId == sessionId
|
||
}
|
||
}
|
||
|
||
// MARK: - 僵尸清理
|
||
|
||
/// 同 `ApprovalCoordinator.startOrphanSweep`:发起方(hubctl)死了就清卡。
|
||
/// 结果用 allow —— 反正没人收,中性的那侧。
|
||
public func startOrphanSweep() {
|
||
orphanSweep?.cancel()
|
||
orphanSweep = Task { [weak self] in
|
||
while !Task.isCancelled {
|
||
try? await Task.sleep(for: .seconds(2))
|
||
await self?.dropOrphans()
|
||
}
|
||
}
|
||
}
|
||
|
||
public func stopOrphanSweep() {
|
||
orphanSweep?.cancel()
|
||
orphanSweep = nil
|
||
}
|
||
|
||
private func dropOrphans() {
|
||
for request in queue where request.clientPid > 0 {
|
||
guard kill(request.clientPid, 0) != 0, errno == ESRCH else { continue }
|
||
resolve(id: request.id, decision: .allow)
|
||
}
|
||
}
|
||
|
||
private func resolve(id: String, decision: HookDecision) {
|
||
timeoutTasks[id]?.cancel()
|
||
timeoutTasks[id] = nil
|
||
|
||
// 先出队再看有没有等待方:权限卡(enqueue 进来的)没有 continuation,
|
||
// 但同样要能被收掉 —— 出队不能藏在 continuation 的 guard 后面。
|
||
queue.removeAll { $0.id == id }
|
||
pending.removeValue(forKey: id)?.resume(returning: decision)
|
||
}
|
||
}
|