Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
133 changes: 133 additions & 0 deletions panel/AntigravityLocalServer.swift
Original file line number Diff line number Diff line change
@@ -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<Int> = []
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)
}
}
70 changes: 3 additions & 67 deletions panel/AntigravityUsage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Int> = []
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]
Expand Down
6 changes: 3 additions & 3 deletions panel/CompactView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand All @@ -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? {
Expand All @@ -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"
}

Expand Down
4 changes: 2 additions & 2 deletions panel/Panel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
Expand Down
60 changes: 41 additions & 19 deletions panel/SessionStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>.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/<pid>.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/<pid>.json. Only the fields we
Expand Down Expand Up @@ -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.
Expand All @@ -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
}
Expand Down Expand Up @@ -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) ?? ""
Expand Down Expand Up @@ -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
)
}
}
Loading