From cc8c7e016047a867358512d707e660097e23be5e Mon Sep 17 00:00:00 2001 From: StuBehan Date: Mon, 8 Jun 2026 23:58:40 +0100 Subject: [PATCH] feat(agy-status): handle the agy activity status in the sessions tab --- build.sh | 1 + panel/AntigravityLocalServer.swift | 133 +++++++++++++++++++++++++++++ panel/AntigravityUsage.swift | 70 +-------------- panel/CompactView.swift | 6 +- panel/Panel.swift | 4 +- panel/SessionStore.swift | 60 ++++++++----- panel/Sessions.swift | 12 +-- 7 files changed, 189 insertions(+), 97 deletions(-) create mode 100644 panel/AntigravityLocalServer.swift diff --git a/build.sh b/build.sh index 6cf8fc4..94fa163 100755 --- a/build.sh +++ b/build.sh @@ -237,6 +237,7 @@ build_app "$APP" "stack-nudge" \ panel/SessionStore.swift \ panel/SessionUsage.swift \ panel/CodexUsage.swift \ + panel/AntigravityLocalServer.swift \ panel/AntigravityUsage.swift \ panel/Sessions.swift \ panel/CompactView.swift \ diff --git a/panel/AntigravityLocalServer.swift b/panel/AntigravityLocalServer.swift new file mode 100644 index 0000000..eb40c1d --- /dev/null +++ b/panel/AntigravityLocalServer.swift @@ -0,0 +1,133 @@ +import Foundation + +// Shared client for the running `agy` CLI's local loopback Connect-RPC +// (`exa.language_server_pb.LanguageServerService` — the Codeium/Windsurf +// "Cascade" language server; no public proto, ~256 methods embedded in the +// agy binary). agy serves it on two 127.0.0.1 ports (one HTTP, one HTTPS); +// we POST to the HTTP one — no TLS, no CSRF, no auth (the running agy is +// already signed in; loopback only, nothing leaves the machine). +enum AntigravityLocalServer { + + // Metadata envelope every method expects. + static let metadataBody = + #"{"metadata":{"ideName":"antigravity","extensionName":"antigravity","locale":"en"}}"# + + private static let session: URLSession = { + let cfg = URLSessionConfiguration.ephemeral + cfg.timeoutIntervalForRequest = 4 + cfg.timeoutIntervalForResource = 6 + return URLSession(configuration: cfg) + }() + + // POST a unary method and return the 200 JSON body, or nil. Synchronous + // (blocks via a semaphore) — only call off the main thread. + static func call(_ method: String, body: String = metadataBody) -> Data? { + for port in listeningPorts() { + if let data = request(method: method, body: body, port: port) { return data } + } + return nil + } + + private static func request(method: String, body: String, port: Int) -> Data? { + guard let url = URL(string: + "http://127.0.0.1:\(port)/exa.language_server_pb.LanguageServerService/\(method)") + else { return nil } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") + request.httpBody = body.data(using: .utf8) + + let semaphore = DispatchSemaphore(value: 0) + var payload: Data? + var status = 0 + session.dataTask(with: request) { data, response, _ in + payload = data + status = (response as? HTTPURLResponse)?.statusCode ?? 0 + semaphore.signal() + }.resume() + _ = semaphore.wait(timeout: .now() + 5) + return status == 200 ? payload : nil + } + + // Loopback listen ports of the running `agy` process. Empty when not running. + private static func listeningPorts() -> [Int] { + let output = ProcessOutput.read( + "/usr/sbin/lsof", ["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-c", "agy"]) + var ports: Set = [] + for line in output.split(separator: "\n") { + guard let range = line.range(of: #"127\.0\.0\.1:\d+"#, options: .regularExpression), + let port = Int(line[range].split(separator: ":").last ?? "") else { continue } + ports.insert(port) + } + return Array(ports) + } + + // MARK: - Session status + + struct LiveStatus: Equatable { + let status: String // "busy" | "idle" + let lastActivityAt: Date? + } + + // Live busy/idle per workspace, from GetAllCascadeTrajectories. Keyed by the + // absolute workspace path (file:// stripped) so SessionStore can match it to + // a running agy session by cwd. When a workspace has several trajectories, + // the most-recently-modified one wins (that's the active conversation). + static func liveStatusByWorkspace() -> [String: LiveStatus] { + guard let data = call("GetAllCascadeTrajectories"), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let summaries = json["trajectorySummaries"] as? [String: Any] + else { return [:] } + + var best: [String: (modified: Date, status: LiveStatus)] = [:] + for value in summaries.values { + guard let summary = value as? [String: Any], + let rawStatus = summary["status"] as? String, + let status = busyIdle(rawStatus), + let workspace = workspacePath(summary) else { continue } + let modified = date(summary["lastModifiedTime"]) ?? .distantPast + let activity = date(summary["lastUserInputTime"]) ?? date(summary["lastModifiedTime"]) + if let existing = best[workspace], existing.modified >= modified { continue } + best[workspace] = (modified, LiveStatus(status: status, lastActivityAt: activity)) + } + return best.mapValues { $0.status } + } + + // Map the cascade run-status enum to the same busy/idle vocabulary the + // Claude sidecar uses, so both flow through one rendering path. + private static func busyIdle(_ raw: String) -> String? { + switch raw { + case "CASCADE_RUN_STATUS_BUSY", + "CASCADE_RUN_STATUS_RUNNING", + "CASCADE_RUN_STATUS_CANCELING": + return "busy" + case "CASCADE_RUN_STATUS_IDLE": + return "idle" + default: + return nil + } + } + + private static func workspacePath(_ summary: [String: Any]) -> String? { + guard let workspaces = summary["workspaces"] as? [[String: Any]], + let uri = workspaces.first?["workspaceFolderAbsoluteUri"] as? String + else { return nil } + return uri.hasPrefix("file://") ? String(uri.dropFirst("file://".count)) : uri + } + + private static let iso: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + private static let isoNoFrac: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + private static func date(_ any: Any?) -> Date? { + guard let string = any as? String else { return nil } + return iso.date(from: string) ?? isoNoFrac.date(from: string) + } +} diff --git a/panel/AntigravityUsage.swift b/panel/AntigravityUsage.swift index b3bf1e9..78fe9c6 100644 --- a/panel/AntigravityUsage.swift +++ b/panel/AntigravityUsage.swift @@ -24,78 +24,14 @@ struct AntigravityQuotaSnapshot: Equatable { final class AntigravityUsageProbe { - private static let endpointPath = - "/exa.language_server_pb.LanguageServerService/GetUserStatus" - private static let requestBody = - #"{"metadata":{"ideName":"antigravity","extensionName":"antigravity","locale":"en"}}"# - - private let session: URLSession - - init() { - let cfg = URLSessionConfiguration.ephemeral - cfg.timeoutIntervalForRequest = 4 - cfg.timeoutIntervalForResource = 6 - session = URLSession(configuration: cfg) - } - - // Calls completion on the main queue. Discovery + request run off-main. + // Calls completion on the main queue. The loopback request runs off-main. func fetch(completion: @escaping (AntigravityQuotaSnapshot?) -> Void) { - DispatchQueue.global(qos: .utility).async { [weak self] in - let result = self?.probe() ?? nil + DispatchQueue.global(qos: .utility).async { + let result = AntigravityLocalServer.call("GetUserStatus").flatMap(Self.parse) DispatchQueue.main.async { completion(result) } } } - private func probe() -> AntigravityQuotaSnapshot? { - for port in Self.listeningPorts() { - if let data = requestStatus(port: port), let snapshot = Self.parse(data) { - return snapshot - } - } - return nil - } - - // agy listens on two loopback ports; the plaintext one answers our HTTP - // POST, the TLS one returns 400 — so try each over HTTP and take the first - // that returns a 200 we can parse. - private func requestStatus(port: Int) -> Data? { - guard let url = URL(string: "http://127.0.0.1:\(port)\(Self.endpointPath)") else { return nil } - var request = URLRequest(url: url) - request.httpMethod = "POST" - request.setValue("application/json", forHTTPHeaderField: "Content-Type") - request.setValue("1", forHTTPHeaderField: "Connect-Protocol-Version") - request.httpBody = Self.requestBody.data(using: .utf8) - - let semaphore = DispatchSemaphore(value: 0) - var payload: Data? - var status = 0 - let task = session.dataTask(with: request) { data, response, _ in - payload = data - status = (response as? HTTPURLResponse)?.statusCode ?? 0 - semaphore.signal() - } - task.resume() - _ = semaphore.wait(timeout: .now() + 5) - return status == 200 ? payload : nil - } - - // Loopback listen ports of the running `agy` process. Empty when agy isn't - // running (or has no listening socket yet). - private static func listeningPorts() -> [Int] { - let output = ProcessOutput.read( - "/usr/sbin/lsof", - ["-nP", "-a", "-iTCP", "-sTCP:LISTEN", "-c", "agy"] - ) - var ports: Set = [] - for line in output.split(separator: "\n") { - guard let range = line.range(of: #"127\.0\.0\.1:\d+"#, options: .regularExpression), - let port = Int(line[range].split(separator: ":").last ?? "") - else { continue } - ports.insert(port) - } - return Array(ports) - } - private static let iso: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime] diff --git a/panel/CompactView.swift b/panel/CompactView.swift index ab1827a..3e8f8db 100644 --- a/panel/CompactView.swift +++ b/panel/CompactView.swift @@ -291,7 +291,7 @@ struct CompactView: View { // MARK: - Data helpers private var anyBusy: Bool { - sessions.sessions.contains { $0.claudeStatus == "busy" } + sessions.sessions.contains { $0.liveStatus == "busy" } } private var recentEvent: NudgeEvent? { @@ -302,7 +302,7 @@ struct CompactView: View { } private var busiestSession: Session? { - sessions.sessions.first { $0.status == .active && $0.claudeStatus == "busy" } + sessions.sessions.first { $0.status == .active && $0.liveStatus == "busy" } } private var mostRecentActive: Session? { @@ -324,7 +324,7 @@ struct CompactView: View { private func displayName(_ s: Session) -> String { if let custom = s.customName, !custom.isEmpty { return custom } - if let name = s.claudeName, !name.isEmpty, name != "main-agent" { return name } + if let name = s.liveTitle, !name.isEmpty, name != "main-agent" { return name } return s.projectName ?? "session" } diff --git a/panel/Panel.swift b/panel/Panel.swift index df7ec04..d466257 100644 --- a/panel/Panel.swift +++ b/panel/Panel.swift @@ -1492,7 +1492,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, guard let session = sessions.sessions.first(where: { $0.claudeSessionID == id }) else { return "a session" } if let custom = session.customName, !custom.isEmpty { return custom } - if let name = session.claudeName, + if let name = session.liveTitle, !name.isEmpty, name != "main-agent" { return name } @@ -1629,7 +1629,7 @@ final class PanelController: NSObject, NSApplicationDelegate, PanelKeyDelegate, guard let session = sessions.sessions.first(where: { $0.claudeSessionID == id }) else { return event.title } let custom = session.customName?.trimmingCharacters(in: .whitespaces) - let claude = session.claudeName?.trimmingCharacters(in: .whitespaces) + let claude = session.liveTitle?.trimmingCharacters(in: .whitespaces) let label: String? if let custom, !custom.isEmpty { label = custom diff --git a/panel/SessionStore.swift b/panel/SessionStore.swift index f19554b..5ee682e 100644 --- a/panel/SessionStore.swift +++ b/panel/SessionStore.swift @@ -24,15 +24,16 @@ struct Session: Identifiable, Equatable { // which is exactly the pre-Stage-2 behaviour. var tabId: String? var tabName: String? - // Read from ~/.claude/sessions/.json (one file per running claude - // process). Gives us authoritative session identity + state without - // needing a hook event to fire first. All optional — missing for - // non-claude agents, or if the sidecar doesn't exist yet for a freshly - // spawned process. + // Claude's per-pid sidecar (~/.claude/sessions/.json) gives an + // authoritative session id without waiting for a hook event. var claudeSessionID: String? - var claudeName: String? // "main-agent" (default) or user-set - var claudeStatus: String? // "busy" | "idle" | other - var claudeUpdatedAt: Date? // last-activity timestamp from sidecar + // Live agent state, agent-agnostic: Claude fills these from its sidecar, + // Antigravity from its language-server RPC (see SessionStore.enrichAntigravity + // + AntigravityLocalServer). They drive the busy/idle dot, the status line, + // and the busy-first sort; nil for agents that expose no live state. + var liveTitle: String? // session name (Claude sidecar; "main-agent" = default) + var liveStatus: String? // "busy" | "idle" | other + var lastActivityAt: Date? // last-activity timestamp } // Decoded shape of ~/.claude/sessions/.json. Only the fields we @@ -206,7 +207,7 @@ final class SessionStore: ObservableObject { } // Sort: active first, busy above non-busy within active, then by - // most-recent claudeUpdatedAt (or process pid as tiebreaker so + // most-recent lastActivityAt (or process pid as tiebreaker so // ordering stays stable when sidecar timestamps are absent). // Distant past for sessions with no updatedAt sinks them below // any timestamped session. @@ -216,12 +217,12 @@ final class SessionStore: ObservableObject { let rActive = rhs.status == .active if lActive != rActive { return lActive && !rActive } - let lBusy = lhs.claudeStatus == "busy" - let rBusy = rhs.claudeStatus == "busy" + let lBusy = lhs.liveStatus == "busy" + let rBusy = rhs.liveStatus == "busy" if lBusy != rBusy { return lBusy && !rBusy } - let lAt = lhs.claudeUpdatedAt ?? distantPast - let rAt = rhs.claudeUpdatedAt ?? distantPast + let lAt = lhs.lastActivityAt ?? distantPast + let rAt = rhs.lastActivityAt ?? distantPast if lAt != rAt { return lAt > rAt } return lhs.pid < rhs.pid } @@ -276,14 +277,35 @@ final class SessionStore: ObservableObject { tabId: nil, tabName: nil, claudeSessionID: sidecar?.sessionId, - claudeName: sidecar?.name, - claudeStatus: sidecar?.status, - claudeUpdatedAt: sidecar?.updatedAt.map { Date(timeIntervalSince1970: $0 / 1000) } + liveTitle: sidecar?.name, + liveStatus: sidecar?.status, + lastActivityAt: sidecar?.updatedAt.map { Date(timeIntervalSince1970: $0 / 1000) } )) } + enrichAntigravity(&found) return found } + // Antigravity has no per-pid sidecar like Claude; instead the running `agy` + // process serves live session status over a local RPC. Match each agy + // session to its trajectory by workspace path (cwd) and fill the live + // busy/idle state, so agy sessions get the same dot + sort as Claude. + // One RPC call per scan, only when an agy session is present. + private static func enrichAntigravity(_ found: inout [Session]) { + guard found.contains(where: { $0.agent == "agy" }) else { return } + let states = AntigravityLocalServer.liveStatusByWorkspace() + guard !states.isEmpty else { return } + for index in found.indices where found[index].agent == "agy" { + guard let cwd = found[index].projectPath else { continue } + let match = states[cwd] + ?? states.first(where: { cwd == $0.key || cwd.hasPrefix($0.key + "/") })?.value + if let match { + found[index].liveStatus = match.status + found[index].lastActivityAt = match.lastActivityAt + } + } + } + private static func detectAgent(args: String) -> String? { // First token of args is the executable path. let firstToken = args.split(separator: " ", maxSplits: 1).first.map(String.init) ?? "" @@ -425,9 +447,9 @@ private extension Session { // we want the merge to surface the latest, not the stale value // from the previous poll. claudeSessionID: self.claudeSessionID, - claudeName: self.claudeName, - claudeStatus: self.claudeStatus, - claudeUpdatedAt: self.claudeUpdatedAt + liveTitle: self.liveTitle, + liveStatus: self.liveStatus, + lastActivityAt: self.lastActivityAt ) } } diff --git a/panel/Sessions.swift b/panel/Sessions.swift index 44272a1..752227c 100644 --- a/panel/Sessions.swift +++ b/panel/Sessions.swift @@ -377,9 +377,9 @@ private struct SessionRow: View { // Prefer a user-set Claude session name when it's not the default // ("main-agent" is what Claude Code assigns by default; treat it // as not meaningful and fall through to project name). - if let claudeName = session.claudeName, - !claudeName.isEmpty, claudeName != "main-agent" { - return claudeName + if let liveTitle = session.liveTitle, + !liveTitle.isEmpty, liveTitle != "main-agent" { + return liveTitle } return session.projectName ?? "(no project)" } @@ -407,7 +407,7 @@ private struct SessionRow: View { // so a glance at the panel tells the user which agents are // working vs. waiting. Other agents / unknown status default // to the existing green. - switch session.claudeStatus { + switch session.liveStatus { case "busy": return .yellow case "idle": return .green case nil: return .green @@ -426,8 +426,8 @@ private struct SessionRow: View { // activity Nm ago" from the sidecar's updatedAt when available // — more useful than process elapsed time, which only tells // you how long ago the process was spawned. - let head = session.claudeStatus ?? "active" - if let updated = session.claudeUpdatedAt { + let head = session.liveStatus ?? "active" + if let updated = session.lastActivityAt { return "\(head) · \(Self.timeFormatter.localizedString(for: updated, relativeTo: Date()))" } let elapsed = session.elapsed?.trimmingCharacters(in: .whitespaces) ?? ""