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
2 changes: 2 additions & 0 deletions build.sh
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ build_app "$APP" "stack-nudge" \
panel/SessionPersistence.swift \
panel/SessionStore.swift \
panel/SessionUsage.swift \
panel/CodexUsage.swift \
panel/AntigravityUsage.swift \
panel/Sessions.swift \
panel/CompactView.swift \
panel/TranscriptStats.swift \
Expand Down
145 changes: 145 additions & 0 deletions panel/AntigravityUsage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import Foundation

// Antigravity (agy) usage, read from the running CLI's local loopback
// Connect-RPC. `agy` serves a language-server `GetUserStatus` endpoint 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, and it's loopback only,
// so nothing leaves the machine). Per-model quota windows + plan credits.
// Source: github.com/steipete/CodexBar#1178.
struct AntigravityQuotaSnapshot: Equatable {
let planType: String?
let models: [ModelQuota]
let promptCredits: Credits?
let flowCredits: Credits?

struct ModelQuota: Equatable {
let label: String // e.g. "Claude Opus 4.6 (Thinking)"
let tier: QuotaTier // utilization 0…100 + reset time
}
struct Credits: Equatable {
let available: Int
let monthly: Int
}
}

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.
func fetch(completion: @escaping (AntigravityQuotaSnapshot?) -> Void) {
DispatchQueue.global(qos: .utility).async { [weak self] in
let result = self?.probe() ?? nil
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]
return formatter
}()

private static func parse(_ data: Data) -> AntigravityQuotaSnapshot? {
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let userStatus = json["userStatus"] as? [String: Any]
else { return nil }

let planStatus = userStatus["planStatus"] as? [String: Any]
let planInfo = planStatus?["planInfo"] as? [String: Any]

var models: [AntigravityQuotaSnapshot.ModelQuota] = []
if let configs = (userStatus["cascadeModelConfigData"] as? [String: Any])?["clientModelConfigs"]
as? [[String: Any]] {
for config in configs {
guard let label = config["label"] as? String,
let quota = config["quotaInfo"] as? [String: Any],
let remaining = (quota["remainingFraction"] as? NSNumber)?.doubleValue
else { continue }
let utilization = max(0, min(100, (1 - remaining) * 100))
let resetsAt = (quota["resetTime"] as? String).flatMap { iso.date(from: $0) }
models.append(.init(label: label,
tier: QuotaTier(utilization: utilization, resetsAt: resetsAt)))
}
}
guard !models.isEmpty else { return nil }

return AntigravityQuotaSnapshot(
planType: planInfo?["planName"] as? String,
models: models,
promptCredits: credits(available: planStatus?["availablePromptCredits"],
monthly: planInfo?["monthlyPromptCredits"]),
flowCredits: credits(available: planStatus?["availableFlowCredits"],
monthly: planInfo?["monthlyFlowCredits"])
)
}

private static func credits(available: Any?, monthly: Any?) -> AntigravityQuotaSnapshot.Credits? {
guard let available = (available as? NSNumber)?.intValue,
let monthly = (monthly as? NSNumber)?.intValue, monthly > 0
else { return nil }
return .init(available: available, monthly: monthly)
}
}
107 changes: 107 additions & 0 deletions panel/CodexUsage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import Foundation

// Codex (ChatGPT-plan) rate limits, mirroring the shape of Claude's quota so
// the Usage tab can render them with the same QuotaTier rows. `primary` is the
// 5-hour rolling window, `secondary` the weekly one. `planType` is the ChatGPT
// tier ("plus", "pro", …) when reported.
struct CodexQuotaSnapshot: Equatable {
let primary: QuotaTier?
let secondary: QuotaTier?
let planType: String?
}

// Reads Codex's account-level rate limits from the newest rollout JSONL under
// ~/.codex/sessions. Codex records them on each `token_count` event at
// `payload.rate_limits` (primary = 5h, secondary = weekly), with `used_percent`
// on a 0–100 scale and a unix `resets_at`. Local-only — no network, no auth.
//
// The limits are account-wide (not per-session), so the most-recently-written
// rollout holds the freshest values. Returns nil for API-key auth (no
// rate_limits emitted) or when no rollout exists, which the Usage tab treats as
// "no Codex usage to show".
final class CodexQuotaProbe {

private let sessionsDir = "\(NSHomeDirectory())/.codex/sessions"

// Skip re-parsing a rollout we've already read at this path+size+mtime —
// the probe runs on the same 60s/5min cadence as the Claude one, and most
// ticks hit an unchanged file.
private var cacheKey: String?
private var cached: CodexQuotaSnapshot?

// Calls completion on the main queue. File IO runs off-main.
func fetch(completion: @escaping (CodexQuotaSnapshot?) -> Void) {
let dir = sessionsDir
DispatchQueue.global(qos: .utility).async { [weak self] in
let result = self?.read(dir: dir) ?? nil
DispatchQueue.main.async { completion(result) }
}
}

private func read(dir: String) -> CodexQuotaSnapshot? {
guard let newest = Self.newestRollout(in: dir) else { return nil }
let key = "\(newest.path)|\(newest.size)|\(newest.mtime)"
if key == cacheKey { return cached }
let snapshot = Self.parseLatestRateLimits(path: newest.path)
cacheKey = key
cached = snapshot
return snapshot
}

// Newest rollout-*.jsonl by modification date anywhere under the sessions
// tree (it's nested YYYY/MM/DD). Enumeration is stat-only and runs at most
// once per poll tick.
private static func newestRollout(in dir: String) -> (path: String, size: Int, mtime: TimeInterval)? {
let base = URL(fileURLWithPath: dir)
let keys: [URLResourceKey] = [.contentModificationDateKey, .fileSizeKey]
guard let enumerator = FileManager.default.enumerator(
at: base, includingPropertiesForKeys: keys, options: [.skipsHiddenFiles]
) else { return nil }

var best: (path: String, size: Int, mtime: TimeInterval)?
for case let url as URL in enumerator {
guard url.lastPathComponent.hasPrefix("rollout-"),
url.pathExtension == "jsonl" else { continue }
let values = try? url.resourceValues(forKeys: Set(keys))
let mtime = values?.contentModificationDate?.timeIntervalSince1970 ?? 0
let size = values?.fileSize ?? 0
if best == nil || mtime > best!.mtime {
best = (url.path, size, mtime)
}
}
return best
}

// Scan newest-line-first for the latest token_count event carrying
// rate_limits, and map it to the snapshot.
private static func parseLatestRateLimits(path: String) -> CodexQuotaSnapshot? {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe),
let text = String(data: data, encoding: .utf8) else { return nil }

for line in text.split(separator: "\n", omittingEmptySubsequences: true).reversed() {
guard line.contains("rate_limits"),
let lineData = line.data(using: .utf8),
let obj = try? JSONSerialization.jsonObject(with: lineData) as? [String: Any],
let payload = obj["payload"] as? [String: Any],
let rateLimits = payload["rate_limits"] as? [String: Any]
else { continue }

return CodexQuotaSnapshot(
primary: tier(rateLimits["primary"]),
secondary: tier(rateLimits["secondary"]),
planType: rateLimits["plan_type"] as? String
)
}
return nil
}

// `used_percent` is already on a 0–100 scale; `resets_at` is unix seconds.
// Both arrive as JSON numbers, so decode via NSNumber to tolerate int/double.
private static func tier(_ raw: Any?) -> QuotaTier? {
guard let dict = raw as? [String: Any],
let used = (dict["used_percent"] as? NSNumber)?.doubleValue else { return nil }
let resetsAt = (dict["resets_at"] as? NSNumber)
.map { Date(timeIntervalSince1970: $0.doubleValue) }
return QuotaTier(utilization: used, resetsAt: resetsAt)
}
}
Loading