## 岛上能直接作答 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>
134 lines
5.3 KiB
Swift
134 lines
5.3 KiB
Swift
import Foundation
|
||
|
||
/// AskUserQuestion / ExitPlanMode / EnterPlanMode 的结构化载荷。
|
||
///
|
||
/// 这几个工具是 Claude 的"交互请求":选择题、计划审批、模式切换。
|
||
/// 它们的 tool_input 里没有 command / file_path,`RiskClassifier` 提不出
|
||
/// summary,走风险链路会被当成无风险直接放行 —— 岛上什么都不弹。
|
||
/// 所以它们在 HookCoordinator 里按 toolName 分流,走这条独立的解析路径。
|
||
public enum AgentPromptPayload: Equatable, Sendable {
|
||
/// 一组问题(1..N 题,含多选题)—— 岛上逐题作答后一并回传。
|
||
case questions([AgentQuestion])
|
||
/// 计划审批(ExitPlanMode)。批准即切出 plan 模式开始执行。
|
||
/// 关联值是计划全文(markdown)。
|
||
case plan(String)
|
||
/// 请求进入计划模式(EnterPlanMode)。
|
||
case enterPlan
|
||
/// 终端里的权限确认框("Do you want to create xxx?")。
|
||
///
|
||
/// **不来自 PreToolUse**:这是 CLI 自己弹的框,hub 只在 Notification hook
|
||
/// 里收到 `permission_prompt`。答案没法从 hook 回传(hook 早已返回),
|
||
/// 要靠往对应终端注入按键(1 = 允许 / Esc = 拒绝)。
|
||
case permission(message: String)
|
||
/// 解析失败 / 超长被裁 / 题目缺选项 —— 岛上只提供「去终端回答」。
|
||
case complex(hint: String)
|
||
|
||
/// 需要走交互分流的工具名。
|
||
public static let interactiveTools: Set<String> = [
|
||
"AskUserQuestion", "ExitPlanMode", "EnterPlanMode",
|
||
]
|
||
|
||
/// 从 tool_input 的 JSON 原文解析。非交互工具返回 nil。
|
||
public static func parse(toolName: String, inputJSON: String?) -> AgentPromptPayload? {
|
||
guard interactiveTools.contains(toolName) else { return nil }
|
||
|
||
// EnterPlanMode 的 input 就是空对象,不依赖 JSON 内容。
|
||
if toolName == "EnterPlanMode" { return .enterPlan }
|
||
|
||
guard let inputJSON,
|
||
let data = inputJSON.data(using: .utf8),
|
||
let object = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any]
|
||
else {
|
||
// 旧 hubctl 不带 toolInputJSON、或超长被裁 —— 降级但不能不弹。
|
||
return .complex(hint: toolName == "ExitPlanMode" ? "Claude 提出了一份计划" : "Claude 有问题要问你")
|
||
}
|
||
|
||
switch toolName {
|
||
case "ExitPlanMode":
|
||
guard let plan = object["plan"] as? String, !plan.isEmpty else {
|
||
return .complex(hint: "Claude 提出了一份计划")
|
||
}
|
||
return .plan(plan)
|
||
|
||
case "AskUserQuestion":
|
||
guard let raw = object["questions"] as? [[String: Any]], !raw.isEmpty
|
||
else { return .complex(hint: "Claude 有问题要问你") }
|
||
|
||
var questions: [AgentQuestion] = []
|
||
for item in raw {
|
||
guard let text = item["question"] as? String, !text.isEmpty else {
|
||
return .complex(hint: "Claude 有问题要问你")
|
||
}
|
||
let options = ((item["options"] as? [[String: Any]]) ?? []).compactMap {
|
||
option -> AgentQuestion.Option? in
|
||
guard let label = option["label"] as? String, !label.isEmpty else { return nil }
|
||
return AgentQuestion.Option(
|
||
label: label, description: option["description"] as? String
|
||
)
|
||
}
|
||
guard !options.isEmpty else {
|
||
// 有一题没有可点的选项,整组都答不完整,只能去终端。
|
||
return .complex(hint: "Claude 有问题要问你")
|
||
}
|
||
questions.append(
|
||
AgentQuestion(
|
||
question: text,
|
||
header: item["header"] as? String,
|
||
options: options,
|
||
multiSelect: (item["multiSelect"] as? Bool) ?? false
|
||
)
|
||
)
|
||
}
|
||
return .questions(questions)
|
||
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 一道选择题(单选或多选)。
|
||
public struct AgentQuestion: Equatable, Sendable {
|
||
public struct Option: Equatable, Sendable, Identifiable {
|
||
public let label: String
|
||
public let description: String?
|
||
public var id: String { label }
|
||
|
||
public init(label: String, description: String? = nil) {
|
||
self.label = label
|
||
self.description = description
|
||
}
|
||
}
|
||
|
||
public let question: String
|
||
public let header: String?
|
||
public let options: [Option]
|
||
public let multiSelect: Bool
|
||
|
||
public init(
|
||
question: String, header: String? = nil,
|
||
options: [Option], multiSelect: Bool = false
|
||
) {
|
||
self.question = question
|
||
self.header = header
|
||
self.options = options
|
||
self.multiSelect = multiSelect
|
||
}
|
||
|
||
/// 回传答案时给这道题用的短标签:header 优先,没有就用题干。
|
||
public var answerKey: String { header ?? question }
|
||
}
|
||
|
||
/// 一道题的作答结果,用于拼回传文本。
|
||
public struct AgentAnswer: Equatable, Sendable {
|
||
public let key: String
|
||
public let labels: [String]
|
||
|
||
public init(key: String, labels: [String]) {
|
||
self.key = key
|
||
self.labels = labels
|
||
}
|
||
|
||
public var line: String { "\(key):\(labels.joined(separator: "、"))" }
|
||
}
|