Files
vibe-foreman/HubKit/Sources/HubUI/AgentPromptModel.swift
T
078f6f5e46 feat: 岛上作答交互卡 + 项目置顶/排序 + 建仓与克隆 (#6)
## 岛上能直接作答 Claude 的交互请求

AskUserQuestion / ExitPlanMode 的 tool_input 里没有 command/path,
RiskClassifier 提不出 summary,风险判定为 normal 直接放行 ——
岛上从来不弹任何东西。这是这次要修的核心 bug。

现在按 toolName 在风险判定**之前**分流,岛上弹可作答的卡:

- 选择题:单题单选点选即答;多题表单逐题作答,多选勾完提交,
  最后合成一条回传。答案借 deny 的 reason 送回模型(PreToolUse
  没有「替用户作答」的通道,这是唯一的口子)。
- 计划审批:显式 allow 压不掉 plan 状态机自己的确认框(实测
  2.1.220),所以「批准」= 放行后替用户在终端框里按 1/2,带重试
  等对话框渲染出来;驳回走 hook deny 有效。
- 进入计划模式(EnterPlanMode)同样接管。
- 终端权限框("Do you want to...")走 Notification hook 弹卡,
  允许=注入 1、拒绝=Esc,复用 TerminalReply 的全套护栏。

超时方向和审批卡**相反**:审批超时必须 deny(唯一的安全刹车),
交互卡超时 allow(不输出决策 = 交还终端原生对话框,用户零损失)。
这条镜像关系有专门的测试钉住。

## 项目列表

- 置顶:岛和主窗口菜单里都能置顶,落盘 pinned.json。
- 排序统一成 置顶 > 正在开发 > 最近提交 > 名字。GitInfo 加
  lastCommitAt(38 个仓库合计约 300ms,后台队列)—— 三十多个项目
  按 yaml 顺序排,找一个要翻半屏。
- 岛上无会话的行点击弹启动菜单,不再直接起 claude(误触成本太高)。
  用原生 NSMenu:SwiftUI Menu 的两种整行方案都翻过车(label 被
  压扁 / 透明盖层收不到点击)。

## 建仓与克隆

- scripts/new-project.sh:目录 + git + GitHub 远程 + 防污染
  settings.json + 注册 projects.yaml,一条命令。app、/new-project
  skill、命令行三个入口共用同一份逻辑。
- scripts/clone-project.sh + 克隆表单:列出账号可见的全部仓库
  (含组织仓,之前 gh repo list 只列个人仓),按 owner 分组筛选,
  多账号可切换(gh 只用活跃账号的凭据,不切就拉不了另一个账号的
  私有仓)。列表按账号分开缓存 + 后台静默刷新。
- 防污染 deny 刻意**不禁** ~/.claude/plans/:plan mode 的计划文件
  写在那里,禁了所有 plan 会话都会报 Error writing file(真踩过)。

## 顺带修掉

add-project.sh 最后一行是 `[ ${#ALIASES[@]} -gt 0 ] && echo ...`,
没有别名时测试为假,整个脚本退出码变成 1 —— 明明写进去了,调用方
却报「注册 projects.yaml 失败」。改成显式 exit 0。

Co-authored-by: wuguangan <anjaihd0206@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:19:39 +08:00

200 lines
7.5 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 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: "用户已在 Claude Hub 上选择:\(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: "用户已在 Claude Hub 上作答 —— \(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: "用户在 Claude Hub 上驳回了这份计划,请调整后重新提出。"
)
)
}
/// 进入计划模式:拒绝。
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)
}
}