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) + } +}