From 415f2e7caf4b3ca8c515495d552d44affad66741 Mon Sep 17 00:00:00 2001 From: wuguangan Date: Sat, 8 Aug 2026 02:50:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BC=9A=E8=AF=9D=E8=A7=82=E5=AF=9F?= =?UTF-8?q?=E8=80=85=E5=81=9A=E8=BF=9B=20Hub=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E5=81=9C=E4=BA=86=E5=B0=B1=E6=8C=89=E6=B8=85=E5=8D=95=E8=BF=BD?= =?UTF-8?q?=E9=97=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原来是个飘在后台的 bash 脚本:关机就没了,也没人知道它在不在跑。 现在是 app 的一部分,配置落盘、开关在界面上、每次追问都留痕。 ## 它解决的不是「停了」,是「做浅了」 只会说「继续」的盯梢防不了它每次都继续、每次都做表面功夫,最后交一份 看起来很完整的报告。所以追问是一份轮换的清单:落到哪些文件了、 发布后还在不在、有哪些卡点没解决、勾了框但没动代码的直接说 —— 每次问一条,问完从头再来。默认清单有一条测试专门守着"不能只有继续"。 ## 三条硬约束,都是实机撞出来的 1. 只有 busy 算在干活。shell 表示"挂着后台 shell",和 Claude 在不在思考 无关 —— bash 版把它当成在干活,会话停了 20 分钟一次没催到, 是用户先发现的,不是程序。 2. 用户在输入框里打字时绝不发。注入是往当前光标位置敲字,会拼到他没发完的 那句后面 —— 真发生过,把用户的话和追问接成了一句。 3. 发送分两步(先文字、停一下、再回车)。合在一起时终端偶尔会把回车吃进 上一段输入,消息卡在输入框发不出去,看起来催过了其实一个字没送。 前两条的判断抽成了 PaneActivity 的纯函数,16 条测试。这是整套里唯一 容易写错、而且写错了不报错只静默失灵的部分 —— 转圈检测刻意只匹配 "(数字s · "这个计时格式,不匹配 Wandering/Boondoggling 那些动词: 那是一份永远补不完的名单,漏一个就是一次误催。 排队提示("Press up to edit queued messages")不算草稿 —— 当成草稿的话一旦有排队盯梢就再也不敢发言,而排队恰恰说明它正忙, 忙完就会读,这时候补一条完全没问题。 截图模式下不启动:那会往演示会话里敲字。 441 个测试。 Co-Authored-By: Claude Opus 5 (1M context) --- HubKit/Sources/ClaudeHubApp/main.swift | 8 + HubKit/Sources/HubProbe/PaneActivity.swift | 91 +++++++ HubKit/Sources/HubUI/AcceptancePane.swift | 67 +++++ HubKit/Sources/HubUI/MainWindow.swift | 6 +- HubKit/Sources/HubUI/SessionWatchdog.swift | 236 ++++++++++++++++++ .../HubProbeTests/PaneActivityTests.swift | 148 +++++++++++ .../HubUITests/SessionWatchdogTests.swift | 139 +++++++++++ 7 files changed, 694 insertions(+), 1 deletion(-) create mode 100644 HubKit/Sources/HubProbe/PaneActivity.swift create mode 100644 HubKit/Sources/HubUI/SessionWatchdog.swift create mode 100644 HubKit/Tests/HubProbeTests/PaneActivityTests.swift create mode 100644 HubKit/Tests/HubUITests/SessionWatchdogTests.swift diff --git a/HubKit/Sources/ClaudeHubApp/main.swift b/HubKit/Sources/ClaudeHubApp/main.swift index d77f7dd..30d237f 100644 --- a/HubKit/Sources/ClaudeHubApp/main.swift +++ b/HubKit/Sources/ClaudeHubApp/main.swift @@ -30,6 +30,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private let verifierSettings = VerifierSettings() /// 唯一会执行命令的组件。默认关着,由 `verifierSettings` 驱动。 private let verifier = AcceptanceVerifier() + /// 会话观察者:盯着开着的项目,会话停下来就按清单追问。 + private lazy var watchdog = SessionWatchdog(store: store, projects: projects) private lazy var island = IslandController( store: store, approvals: approvals, prompts: prompts, projects: projects, @@ -117,6 +119,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate { && ProcessInfo.processInfo.environment["HUB_ISLAND_STATE"] != nil if !screenshotMode { stalls.start() } + // 盯梢本身没开销(没开着的项目直接跳过),但它会往终端注入文字 —— + // 截图模式下绝不能起,那会往演示会话里敲字。 + if !screenshotMode { watchdog.start() } + island.show() installStatusItem() observeApprovalQueue() @@ -190,6 +196,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { func applicationWillTerminate(_ notification: Notification) { stalls.stop() + watchdog.stop() approvalObserver?.cancel() approvals.stopOrphanSweep() prompts.stopOrphanSweep() @@ -273,6 +280,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { acceptance: acceptance, verifierSettings: verifierSettings, verifier: verifier, + watchdog: watchdog, channels: hooks.channels, onJump: { [weak self] sessionId in self?.store.jump(to: sessionId) diff --git a/HubKit/Sources/HubProbe/PaneActivity.swift b/HubKit/Sources/HubProbe/PaneActivity.swift new file mode 100644 index 0000000..e1d46da --- /dev/null +++ b/HubKit/Sources/HubProbe/PaneActivity.swift @@ -0,0 +1,91 @@ +import Foundation + +/// 从终端画面判断一个会话是不是真的停下来了。 +/// +/// ## 为什么光看状态文件不够 +/// +/// `~/.claude/sessions/.json` 里的 `status` 是权威的,但它回答的问题 +/// 和我们要问的不完全是一件事: +/// +/// - `busy` = Claude 在思考 → 确实在干活; +/// - `shell` = **挂着一个后台 shell** —— 和 Claude 自己在不在干活无关。 +/// 实测它空在提示符上等输入时,状态照样是 `shell`。 +/// +/// 只信状态文件的话,`shell` 这一档会让盯梢彻底失效:实机上它停了 20 分钟 +/// 一次都没被催到,是用户先发现的,不是程序。 +/// +/// 所以补一个屏幕信号。**这不是退回去靠截屏猜** —— 状态文件在 `shell` 这一点上 +/// 信息不足,两个信号合起来才准,各自单独都不够。 +/// +/// ## 为什么这些函数是纯的 +/// +/// 它们是整套盯梢里唯一容易写错、而且**写错了不会报错只会静默失灵**的部分。 +/// 做成纯字符串函数之后,每一种画面形态都能钉一条测试,不用去起真的终端。 +public enum PaneActivity { + + /// 画面上有没有"转圈计时"。 + /// + /// Claude Code 在干活时底部会有一行像 + /// `✻ Boondoggling… (17s · ↓ 201 tokens)` —— 关键特征是那个 + /// `(数字s · ` 或 `(数字m 数字s · `。动词和字符会变(Wandering / + /// Boondoggling / ✻ / ✽ / ✳),**别去匹配它们**,那是一份永远补不完的名单。 + public static func isWorking(pane: String) -> Bool { + for line in pane.split(separator: "\n") { + if durationPattern.firstMatch( + in: String(line), range: NSRange(line.startIndex..., in: line) + ) != nil { + return true + } + } + return false + } + + /// `(17s · ` / `(21m 51s · ` —— 计时加中点,是转圈行的稳定特征。 + private static let durationPattern = try! NSRegularExpression( + pattern: #"\([0-9]+(m [0-9]+)?s · "# + ) + + /// 输入框里有没有用户还没发完的字。 + /// + /// **有就绝对不能发追问。** `tmux send-keys` 是往当前输入位置敲字, + /// 用户正在打字时会直接拼到他那句后面 —— 实机上真发生过,把用户的话 + /// 和追问接成了一句。轻则语义变了,重则改变他本来要说的意思。 + /// + /// 取的是**最后一行** `❯` 开头的:画面上方的历史消息里也有 `❯`, + /// 只有最后那个是当前输入框。 + public static func draft(pane: String) -> String? { + let prompts = pane.split(separator: "\n", omittingEmptySubsequences: false) + .filter { $0.hasPrefix("❯") } + guard let last = prompts.last else { return nil } + + let text = last.dropFirst() // 去掉 ❯ + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !text.isEmpty else { return nil } + + // Claude Code 在有排队消息时会把这行显示成提示语,那不是用户的草稿。 + guard !placeholders.contains(where: text.hasPrefix) else { return nil } + return text + } + + /// 输入框位置上会出现的、**不是用户输入**的提示语。 + /// + /// 把它们当成草稿的话,一旦有消息排队盯梢就再也不敢发言了 —— + /// 而排队恰恰说明它正忙,忙完就会读,这时候补一条完全没问题。 + private static let placeholders = [ + "Press up to edit queued messages", + "Try ", "Ask ", + ] + + /// 综合判断:这个会话现在能不能被打扰。 + /// + /// - Parameter status: 状态文件里的 `status`。 + /// - Returns: nil = 可以打扰;非 nil = 不能,值是原因(写进日志用)。 + public static func doNotDisturb(status: String?, pane: String) -> String? { + if status == "busy" { return "在思考" } + if isWorking(pane: pane) { return "屏幕在转圈" } + if let draft = draft(pane: pane) { + return "输入框里有没发完的字:\(draft.prefix(20))" + } + return nil + } +} diff --git a/HubKit/Sources/HubUI/AcceptancePane.swift b/HubKit/Sources/HubUI/AcceptancePane.swift index 69662d2..4c13ffa 100644 --- a/HubKit/Sources/HubUI/AcceptancePane.swift +++ b/HubKit/Sources/HubUI/AcceptancePane.swift @@ -24,8 +24,11 @@ struct AcceptancePane: View { let store: SessionStore let settings: VerifierSettings let verifier: AcceptanceVerifier + @Bindable var watchdog: SessionWatchdog @Binding var selection: String? + @State private var showProbes = false + /// 正在跑验证的那一条。同时只允许跑一条 —— 并发跑 `swift build` /// 会互相抢构建目录锁,结果全是假失败。 @State private var running: String? @@ -98,6 +101,7 @@ struct AcceptancePane: View { ) if !pickerPaths.isEmpty { controls } + watchdogBar sessionPromises if ledger?.items.isEmpty ?? true { @@ -220,6 +224,69 @@ struct AcceptancePane: View { .padding(.bottom, 10) } + // MARK: - 盯梢 + + /// 会话观察者的开关和最近一次追问。 + /// + /// 放在验收页而不是设置页:它和这一页是同一件事的两面 —— + /// 清单管「要做什么」,盯梢管「有没有一直做下去」。而且它天然按项目走, + /// 这一页本来就是项目作用域的。 + /// + /// **最近一次追问必须显示出来。** 一个"在盯着"但从没说过话的盯梢, + /// 和没开是一样的,而用户没法从开关上区分这两种。 + @ViewBuilder + private var watchdogBar: some View { + if let path = currentPath { + let on = watchdog.isWatching(path) + let recent = watchdog.history.first + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Toggle("盯梢:停了就追问", isOn: Binding( + get: { on }, + set: { watchdog.setWatching($0, path) } + )) + .toggleStyle(.switch) + .controlSize(.mini) + .font(.system(size: 12, weight: .medium)) + + if on { + Text("清单 \(watchdog.probeList(for: path).count) 条 · 轮着问") + .font(.system(size: 11)).monospacedDigit() + .foregroundStyle(.secondary) + } + Spacer() + Button(showProbes ? "收起清单" : "看/改清单") { showProbes.toggle() } + .buttonStyle(.borderless) + .font(.system(size: 11)) + } + + if on, let recent { + Text("上次追问 \(recent.at, style: .relative)前 · \(recent.sessionName):\(recent.probe)") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + .lineLimit(1) + } + + if showProbes { + TextEditor(text: Binding( + get: { watchdog.probeList(for: path).joined(separator: "\n") }, + set: { watchdog.setProbes($0.components(separatedBy: "\n"), for: path) } + )) + .font(.system(size: 11)) + .frame(height: 140) + .overlay( + RoundedRectangle(cornerRadius: 6).stroke(.quaternary) + ) + Text("一行一条。它每次停下来就问下一条,问完从头再来。") + .font(.system(size: 10)) + .foregroundStyle(.tertiary) + } + } + .padding(.horizontal, 20) + .padding(.bottom, 8) + } + } + // MARK: - AI 答应的事做全了没 /// 这个项目下每个在跑的会话,AI 自己列的 todo 完成情况。 diff --git a/HubKit/Sources/HubUI/MainWindow.swift b/HubKit/Sources/HubUI/MainWindow.swift index 5036570..9ffef34 100644 --- a/HubKit/Sources/HubUI/MainWindow.swift +++ b/HubKit/Sources/HubUI/MainWindow.swift @@ -45,6 +45,7 @@ public struct MainWindowView: View { @Bindable var acceptance: AcceptanceStore @Bindable var verifierSettings: VerifierSettings let verifier: AcceptanceVerifier + @Bindable var watchdog: SessionWatchdog let channels: HookChannelMonitor let onJump: (String) -> Void let onLaunch: (Project, LaunchMode) -> Void @@ -65,6 +66,7 @@ public struct MainWindowView: View { acceptance: AcceptanceStore, verifierSettings: VerifierSettings, verifier: AcceptanceVerifier, + watchdog: SessionWatchdog, channels: HookChannelMonitor, onJump: @escaping (String) -> Void, onLaunch: @escaping (Project, LaunchMode) -> Void @@ -76,6 +78,7 @@ public struct MainWindowView: View { self.acceptance = acceptance self.verifierSettings = verifierSettings self.verifier = verifier + self.watchdog = watchdog self.channels = channels self.onJump = onJump self.onLaunch = onLaunch @@ -112,7 +115,8 @@ public struct MainWindowView: View { case .acceptance: AcceptancePane( acceptance: acceptance, projects: projects, store: store, - settings: verifierSettings, verifier: verifier, selection: $ledgerPath + settings: verifierSettings, verifier: verifier, + watchdog: watchdog, selection: $ledgerPath ) case .projects: ProjectsPane(projects: projects, store: store, git: git, onLaunch: onLaunch) diff --git a/HubKit/Sources/HubUI/SessionWatchdog.swift b/HubKit/Sources/HubUI/SessionWatchdog.swift new file mode 100644 index 0000000..d14b899 --- /dev/null +++ b/HubKit/Sources/HubUI/SessionWatchdog.swift @@ -0,0 +1,236 @@ +import Foundation +import HubCore +import HubProbe +import HubProjects +import Observation + +/// 一次追问的记录。UI 上要看得见 —— 一个"在盯着"但从没说过话的盯梢, +/// 和没开是一样的,而用户没法区分这两种。 +public struct NudgeRecord: Codable, Sendable, Equatable { + public let at: Date + public let sessionName: String + public let probe: String + + public init(at: Date, sessionName: String, probe: String) { + self.at = at + self.sessionName = sessionName + self.probe = probe + } +} + +/// 会话观察者:盯着指定项目的会话,停下来就追问,直到活真的干透。 +/// +/// ## 它解决的不是「停了」,是「做浅了」 +/// +/// 只会说「继续」的盯梢能防它停,防不了它每次都继续、每次都做表面功夫, +/// 最后交一份看起来很完整的报告。所以追问是一份**轮换的清单**: +/// 落到哪些文件了、发布后还在不在、测了几轮、调过什么参数、 +/// 哪些卡点没解决 —— 每次问一条,问完从头再来。 +/// +/// ## 三条硬约束(都是实机撞出来的) +/// +/// 1. **只有 `busy` 算在干活。** `shell` 表示"挂着后台 shell",和 Claude +/// 在不在思考无关 —— 早期版本把它当成在干活,会话停了 20 分钟一次没催到。 +/// 2. **用户在输入框里打字时绝不发。** 注入是往当前光标位置敲字, +/// 会直接拼到他没发完的那句后面,改变他本来要说的意思。 +/// 3. **发送分两步**(先文字、停一下、再回车)。合在一起时终端偶尔会把回车 +/// 吃进上一段输入里,消息卡在输入框发不出去 —— 看起来催过了,其实一个字没送。 +/// +/// 前两条的判断在 `PaneActivity` 里,是纯函数,有测试钉着。 +@Observable +@MainActor +public final class SessionWatchdog { + + /// 正在盯的项目路径。 + public private(set) var watching: Set = [] + /// 每个项目的追问清单。空 = 用默认那份。 + public private(set) var probes: [String: [String]] = [:] + /// 最近的追问记录,最新的在前。 + public private(set) var history: [NudgeRecord] = [] + + @ObservationIgnored private let store: SessionStore + @ObservationIgnored private let projects: ProjectStore + @ObservationIgnored private let reply = TerminalReply() + @ObservationIgnored private let url: URL? + @ObservationIgnored private var timer: Timer? + + /// 连续几次判定"停着"才真的开口。一次就发的话,两次工具调用之间的 + /// 那点空档也会被当成停了。 + private let needStreak = 2 + /// 两次追问的最小间隔。它读完一条要想一会儿,催太密就是打断。 + private let cooldown: TimeInterval = 180 + private let interval: TimeInterval = 45 + + @ObservationIgnored private var streak: [String: Int] = [:] + @ObservationIgnored private var lastNudgeAt: [String: Date] = [:] + @ObservationIgnored private var probeIndex: [String: Int] = [:] + + public init( + store: SessionStore, + projects: ProjectStore, + url: URL? = SessionWatchdog.defaultURL + ) { + self.store = store + self.projects = projects + self.url = url + load() + } + + public nonisolated static var defaultURL: URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("Library/Application Support/claude-hub/watchdog.json") + } + + // MARK: - 开关 + + public func isWatching(_ projectPath: String) -> Bool { watching.contains(projectPath) } + + public func setWatching(_ on: Bool, _ projectPath: String) { + if on { watching.insert(projectPath) } else { watching.remove(projectPath) } + persist() + } + + public func probeList(for projectPath: String) -> [String] { + let custom = probes[projectPath] ?? [] + return custom.isEmpty ? Self.defaultProbes : custom + } + + public func setProbes(_ list: [String], for projectPath: String) { + probes[projectPath] = list.filter { !$0.trimmingCharacters(in: .whitespaces).isEmpty } + persist() + } + + public func start() { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + Task { @MainActor in self?.tick() } + } + } + + public func stop() { + timer?.invalidate() + timer = nil + } + + // MARK: - 每一轮 + + func tick() { + guard !watching.isEmpty else { return } + + for session in store.sessions { + let path = AcceptanceStore.projectPath(forCWD: session.cwd, projects: projects) + guard watching.contains(path) else { continue } + evaluate(session, projectPath: path) + } + } + + private func evaluate(_ session: AgentSession, projectPath: String) { + guard let pane = reply.locate(sessionId: session.sessionId, session: session) else { + // 找不到终端就别猜。往一个定位不到的窗口敲字比不敲危险。 + streak[session.sessionId] = 0 + return + } + + let text = Self.capture(pane: pane) + if let reason = PaneActivity.doNotDisturb(status: session.rawStatus, pane: text) { + if (streak[session.sessionId] ?? 0) > 0 { + HubLog.app.notice("盯梢:\(session.sessionId.prefix(8), privacy: .public) 又动了(\(reason, privacy: .public))") + } + streak[session.sessionId] = 0 + return + } + + let count = (streak[session.sessionId] ?? 0) + 1 + streak[session.sessionId] = count + guard count >= needStreak else { return } + + let now = Date() + if let last = lastNudgeAt[session.sessionId], now.timeIntervalSince(last) < cooldown { + return + } + + let list = probeList(for: projectPath) + guard !list.isEmpty else { return } + let index = (probeIndex[projectPath] ?? 0) % list.count + let probe = list[index] + probeIndex[projectPath] = (index + 1) % list.count + + streak[session.sessionId] = 0 + lastNudgeAt[session.sessionId] = now + let name = session.name ?? String(session.sessionId.prefix(8)) + history.insert(NudgeRecord(at: now, sessionName: name, probe: probe), at: 0) + // 只留最近这些。历史是给人看"它到底说过什么"的,不是审计日志。 + if history.count > 50 { history.removeLast(history.count - 50) } + + Task { [reply] in + _ = await reply.send(text: probe, to: session.sessionId) + } + HubLog.app.notice(""" + 盯梢:向 \(name, privacy: .public) 追问第 \(index + 1, privacy: .public)/\ + \(list.count, privacy: .public) 条 + """) + } + + /// 抓终端画面。只要最后 14 行 —— 转圈行和输入框都在底部, + /// 抓多了只是把历史消息里的 `❯` 一起带进来,反而增加误判。 + static func capture(pane: String) -> String { + guard let tmux = tmuxPath else { return "" } + return Shell.run( + tmux, ["capture-pane", "-t", pane, "-p", "-S", "-14"], timeout: 5 + ).stdout + } + + /// 找 tmux。**不能靠 PATH** —— Hub 是 GUI app,从 launchd 起, + /// PATH 只有 `/usr/bin:/bin:/usr/sbin:/sbin`,homebrew 装的 tmux 不在里面。 + /// 同 `StallJudge.locateClaude` 的理由。 + private static let tmuxPath: String? = [ + "/opt/homebrew/bin/tmux", "/usr/local/bin/tmux", "/usr/bin/tmux", + ].first { FileManager.default.isExecutableFile(atPath: $0) } + + // MARK: - 默认追问清单 + + /// 通用版。项目可以在设置里换成自己的。 + /// + /// 每一条都是一个**它不想被问的角度** —— 这就是观察者的全部价值。 + /// 「继续」只在第一条,因为防停是最低要求,不是目的。 + public static let defaultProbes: [String] = [ + "继续,不要停。把手上的活干完再停,遇到卡点自己想办法绕过去。", + "这轮的产出具体落到了哪些文件?给完整路径。答不上路径就说明只写了结论没落地。", + "你落的那些改动,下一次构建/发布之后还在不在?不确定就去看构建脚本,别猜。", + "现在有哪些卡点还没解决、哪些落点还没落?别报喜不报忧——只说做成的部分等于把问题留给我。", + "你自己列的 todo 逐条对一遍:哪些真做完了、哪些只是勾了框?勾了但没动代码的直接说。", + "回头把需求原文再看一遍。你的上下文可能被压缩过,对照文档确认,不要凭记忆。", + "如果现在把这些改动交给真实用户用,最可能在哪一步翻车?说实话。", + "同一件事你重复从头做了几次?可以复用的部分固化下来,每轮只跑增量——每次都重头来是在烧时间。", + ] + + // MARK: - 持久化 + + private struct Payload: Codable { + var watching: [String] + var probes: [String: [String]] + var history: [NudgeRecord] + } + + private func load() { + guard let url, + let data = try? Data(contentsOf: url), + let payload = try? JSONDecoder().decode(Payload.self, from: data) + else { return } + watching = Set(payload.watching) + probes = payload.probes + history = payload.history + } + + private func persist() { + guard let url else { return } + let payload = Payload( + watching: watching.sorted(), probes: probes, history: Array(history.prefix(50)) + ) + guard let data = try? JSONEncoder().encode(payload) else { return } + try? FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), withIntermediateDirectories: true + ) + try? data.write(to: url, options: .atomic) + } +} diff --git a/HubKit/Tests/HubProbeTests/PaneActivityTests.swift b/HubKit/Tests/HubProbeTests/PaneActivityTests.swift new file mode 100644 index 0000000..21a1b3f --- /dev/null +++ b/HubKit/Tests/HubProbeTests/PaneActivityTests.swift @@ -0,0 +1,148 @@ +import XCTest +@testable import HubProbe + +/// 判断一个会话是不是真停了。 +/// +/// 这是整套盯梢里唯一容易写错、而且**写错了不会报错只会静默失灵**的部分。 +/// bash 版在这上面栽过两次,两次都是实机上被用户先发现的: +/// 一次把 `shell` 当成在干活(停了 20 分钟没催到), +/// 一次在用户打字时把追问拼到了他那句后面。 +final class PaneActivityTests: XCTestCase { + + // MARK: - 转圈检测 + + /// 本机真实画面。 + func testDetectsTheSpinnerLine() { + let pane = """ + ⏺ 继续。先把 T31 台账补齐,然后按训练包逐项推进: + + Running 1 shell command… + + ✻ Boondoggling… (17s · ↓ 201 tokens) + """ + XCTAssertTrue(PaneActivity.isWorking(pane: pane)) + } + + /// 分钟级的计时也要认。 + func testDetectsLongRunningSpinner() { + XCTAssertTrue(PaneActivity.isWorking(pane: "✽ Wandering… (39m 48s · ↓ 35.8k tokens)")) + } + + /// **不能去匹配那些动词和符号。** + /// + /// Wandering / Boondoggling / ✻ / ✽ / ✳ 是一份永远补不完的名单, + /// 漏一个就是一次误催。稳定的特征是 `(数字s · ` 这个计时格式。 + func testRecognizesUnfamiliarSpinnerWords() { + XCTAssertTrue(PaneActivity.isWorking(pane: "✶ Flibbertigibbeting… (3s · ↓ 12 tokens)")) + XCTAssertTrue(PaneActivity.isWorking(pane: "* Doing Something New… (1m 2s · ↑ 5 tokens)")) + } + + /// 空在提示符上 = 没在干活。 + func testIdlePaneIsNotWorking() { + let pane = """ + ❯ + ───────────────────────────────────── + # dev @ MacBook-Pro in ~/code [02:35:47] + ⏵⏵ bypass permissions on · 1 shell + """ + XCTAssertFalse(PaneActivity.isWorking(pane: pane)) + } + + /// 历史输出里的括号数字不能误判成转圈。 + func testPlainNumbersDoNotLookLikeASpinner() { + XCTAssertFalse(PaneActivity.isWorking(pane: "改了 12 行 (共 30s 的构建)")) + XCTAssertFalse(PaneActivity.isWorking(pane: "耗时 (3s)")) + } + + // MARK: - 输入框草稿 + + /// **用户正在打字时绝不能发。** + /// + /// send-keys 往当前输入位置敲字,会直接拼到他那句后面。 + /// 实机上真发生过:把用户的「你该操作代码的操作代码…」和追问接成了一句。 + func testDetectsUserDraft() { + let pane = """ + ⏺ 上一轮的输出 + ❯ 你既可以开发 也可以训练 + ───────────────────────────────────── + """ + XCTAssertEqual(PaneActivity.draft(pane: pane), "你既可以开发 也可以训练") + } + + /// 取最后一个 `❯` —— 上方历史消息里也有,只有最后那个是输入框。 + func testUsesTheLastPromptNotTheHistory() { + let pane = """ + ❯ 这是十分钟前发过的一句话 + ⏺ 回复 + ❯ + """ + XCTAssertNil(PaneActivity.draft(pane: pane), "历史里的 ❯ 不算草稿") + } + + func testEmptyPromptIsNotADraft() { + XCTAssertNil(PaneActivity.draft(pane: "❯ ")) + XCTAssertNil(PaneActivity.draft(pane: "❯")) + } + + /// **排队提示不是草稿。** + /// + /// 有消息排队时输入框位置会显示 "Press up to edit queued messages"。 + /// 把它当成草稿的话,一旦有排队盯梢就再也不敢发言了 —— + /// 而排队恰恰说明它正忙,忙完就会读,这时候补一条完全没问题。 + func testQueuedMessageHintIsNotADraft() { + XCTAssertNil(PaneActivity.draft(pane: "❯ Press up to edit queued messages")) + } + + func testNoPromptAtAll() { + XCTAssertNil(PaneActivity.draft(pane: "什么都没有")) + } + + // MARK: - 综合判断 + + /// `busy` 是唯一能单独证明"在干活"的状态。 + func testBusyIsLeftAlone() { + XCTAssertNotNil(PaneActivity.doNotDisturb(status: "busy", pane: "❯")) + } + + /// **`shell` 不等于在干活。** + /// + /// 它表示"挂着一个后台 shell",和 Claude 自己在不在思考无关。 + /// 实机上它空在提示符等输入时状态就是 shell —— bash 版把它当成在干活, + /// 于是停了 20 分钟一次都没催到。这条测试就是那次事故的护栏。 + func testShellStatusWithIdleScreenCanBeInterrupted() { + let idle = """ + ❯ + ───────────────────────────────────── + ⏵⏵ bypass permissions on · 1 shell + """ + XCTAssertNil( + PaneActivity.doNotDisturb(status: "shell", pane: idle), + "shell + 屏幕没转圈 + 输入框空着 = 真的停了,必须能催" + ) + } + + /// `shell` 但屏幕在转圈 —— 那是真在跑命令,别打扰。 + func testShellStatusWithSpinnerIsLeftAlone() { + XCTAssertNotNil( + PaneActivity.doNotDisturb(status: "shell", pane: "✻ Running… (8s · ↓ 3 tokens)") + ) + } + + func testDraftBlocksTheNudge() { + XCTAssertNotNil(PaneActivity.doNotDisturb(status: "idle", pane: "❯ 我正在打字")) + } + + func testTrulyIdleCanBeNudged() { + XCTAssertNil(PaneActivity.doNotDisturb(status: "idle", pane: "❯")) + } + + /// 状态读不出来但屏幕明显停着 —— 可以催。 + /// + /// 状态文件缺失的常见原因是刚 resume 换了 pid,那时候屏幕是可信的。 + func testMissingStatusFallsBackToTheScreen() { + XCTAssertNil(PaneActivity.doNotDisturb(status: nil, pane: "❯")) + XCTAssertNotNil( + PaneActivity.doNotDisturb(status: nil, pane: "✻ Thinking… (2s · ↓ 1 tokens)") + ) + } +} diff --git a/HubKit/Tests/HubUITests/SessionWatchdogTests.swift b/HubKit/Tests/HubUITests/SessionWatchdogTests.swift new file mode 100644 index 0000000..e78d5fb --- /dev/null +++ b/HubKit/Tests/HubUITests/SessionWatchdogTests.swift @@ -0,0 +1,139 @@ +import HubCore +import HubProjects +import XCTest +@testable import HubUI + +/// 会话观察者的状态与配置。 +/// +/// 真正容易出错的判断(在不在干活、有没有草稿)在 `PaneActivity` 里, +/// 是纯函数、单独测。这里测的是配置、轮换和持久化。 +@MainActor +final class SessionWatchdogTests: XCTestCase { + + private var tempURL: URL! + + override func setUpWithError() throws { + tempURL = FileManager.default.temporaryDirectory + .appendingPathComponent("watchdog-\(UUID().uuidString).json") + } + + override func tearDownWithError() throws { + try? FileManager.default.removeItem(at: tempURL) + } + + private func make(url: URL?) -> SessionWatchdog { + SessionWatchdog(store: SessionStore(), projects: ProjectStore(yamlURL: nil, pinURL: nil), url: url) + } + + /// **默认必须是关的。** + /// + /// 它会往用户的终端里敲字。装上 Hub 的人不该在不知情时被自动插话。 + func testNothingIsWatchedByDefault() { + XCTAssertTrue(make(url: nil).watching.isEmpty) + } + + func testWatchingSurvivesRestart() { + let first = make(url: tempURL) + first.setWatching(true, "/tmp/proj") + + XCTAssertTrue(make(url: tempURL).isWatching("/tmp/proj")) + } + + func testCanStopWatching() { + let watchdog = make(url: tempURL) + watchdog.setWatching(true, "/tmp/proj") + watchdog.setWatching(false, "/tmp/proj") + + XCTAssertFalse(make(url: tempURL).isWatching("/tmp/proj")) + } + + func testNilURLDoesNotTouchTheDisk() throws { + let watchdog = make(url: nil) + watchdog.setWatching(true, "/tmp/proj") + + XCTAssertTrue(watchdog.isWatching("/tmp/proj"), "内存里要生效") + XCTAssertFalse(FileManager.default.fileExists(atPath: tempURL.path)) + } + + // MARK: - 追问清单 + + /// 没配过就用默认那份 —— 不能是空的。 + /// + /// 空清单的盯梢会「在盯着」但永远不说话,用户从开关上看不出区别。 + func testFallsBackToDefaultProbes() { + let watchdog = make(url: nil) + + XCTAssertFalse(watchdog.probeList(for: "/tmp/proj").isEmpty) + XCTAssertEqual(watchdog.probeList(for: "/tmp/proj"), SessionWatchdog.defaultProbes) + } + + func testCustomProbesWin() { + let watchdog = make(url: nil) + watchdog.setProbes(["问题一", "问题二"], for: "/tmp/proj") + + XCTAssertEqual(watchdog.probeList(for: "/tmp/proj"), ["问题一", "问题二"]) + } + + /// 空行要滤掉 —— 界面上是个多行文本框,用户敲回车很自然。 + /// 不滤的话会往终端发一条空消息。 + func testBlankLinesAreDropped() { + let watchdog = make(url: nil) + watchdog.setProbes(["问题一", "", " ", "问题二"], for: "/tmp/proj") + + XCTAssertEqual(watchdog.probeList(for: "/tmp/proj"), ["问题一", "问题二"]) + } + + /// 全删光时退回默认,而不是变成"永不说话"。 + func testClearingProbesRestoresTheDefault() { + let watchdog = make(url: nil) + watchdog.setProbes(["只有一条"], for: "/tmp/proj") + watchdog.setProbes([], for: "/tmp/proj") + + XCTAssertEqual(watchdog.probeList(for: "/tmp/proj"), SessionWatchdog.defaultProbes) + } + + func testProbesSurviveRestart() { + let first = make(url: tempURL) + first.setProbes(["记住我"], for: "/tmp/proj") + + XCTAssertEqual(make(url: tempURL).probeList(for: "/tmp/proj"), ["记住我"]) + } + + /// 每个项目一份清单,互不干扰。 + func testProbesAreScopedPerProject() { + let watchdog = make(url: nil) + watchdog.setProbes(["A 的问题"], for: "/tmp/a") + + XCTAssertEqual(watchdog.probeList(for: "/tmp/b"), SessionWatchdog.defaultProbes) + } + + // MARK: - 默认清单本身 + + /// **默认清单不能只有「继续」。** + /// + /// 「继续」能防它停,防不了它每次都继续、每次都做表面功夫。 + /// 观察者的价值在于问它不想被问的那些:落到哪些文件、有哪些卡点没解决。 + func testDefaultProbesAskMoreThanJustContinue() { + XCTAssertGreaterThan(SessionWatchdog.defaultProbes.count, 3) + XCTAssertTrue( + SessionWatchdog.defaultProbes.contains { $0.contains("哪些文件") }, + "必须有一条追问落盘位置——只写结论不落地是最常见的失败形态" + ) + XCTAssertTrue( + SessionWatchdog.defaultProbes.contains { $0.contains("卡点") }, + "必须有一条逼它说没做成的部分" + ) + } + + // MARK: - 历史 + + /// 坏掉的配置文件不能让盯梢起不来。 + func testCorruptConfigFallsBackToNothingWatched() throws { + try "这不是 json".data(using: .utf8)!.write(to: tempURL) + + let watchdog = make(url: tempURL) + + XCTAssertTrue(watchdog.watching.isEmpty) + XCTAssertFalse(watchdog.probeList(for: "/tmp/proj").isEmpty) + } +}