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
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@
- Claude quota data comes from `GET https://api.anthropic.com/api/oauth/usage`, the endpoint `/usage` itself calls, with `Authorization: Bearer` and `anthropic-beta: oauth-2025-04-20`. Parse the `limits` array (`session`, `weekly_all`, `weekly_scoped` with `scope.model.display_name`), not the legacy top-level buckets: the model-scoped weekly limit exists only there.
- **Never refresh, rotate, or write the OAuth credential.** Read the Keychain item (`Claude Code-credentials` / the login name) through `/usr/bin/security find-generic-password -w` as a subprocess, never `SecItemCopyMatching` from the app: Claude Code recreates the item on every token refresh, which drops the ACL grant Steven gave TokenGauge, so the direct read re-prompted him after every refresh (2026-08-26/27) even with "Always Allow"; `security` is the item creator and reads silently. Keep the token only in process memory (`ClaudeOAuthTokenReader` cache), read once per launch and re-read only after it expires. Claude Code owns that credential and refreshes it; a refresh from here would rotate the refresh token and sign Steven out. An expired token is a distinct recoverable state. With persisted opt-in, TokenGauge may briefly start the official Claude CLI in safe mode without a prompt, then re-read the credential and usage endpoint; Claude Code alone renews it. Bound the owned process group, discard terminal output, and persist retry backoff. Missing credentials, 401 revocation, and 403 must never trigger that startup.
- Call the usage endpoint through an ephemeral `URLSession` with no URL cache: `URLSession.shared` cached a 401 and replayed it (`cache_hit=true`) on every refresh while the token was valid, so the Fable row vanished (2026-08-26). On a real 401, invalidate the token cache and re-read the Keychain once — Claude Code rotates the token.
- Persist only percentages and reset timestamps from that response. The token, the account fields, and the spend figures never reach disk.
- Persist only percentages and reset timestamps from that response. The token, the account fields, and the spend figures never reach disk; the only account data stored is a SHA-256 fingerprint of the account uuid, which scopes the account snapshot and pace continuity.
- The account identity comes from `~/.claude.json` only; `CLAUDE_CONFIG_DIR` is not resolved (the same assumption already made for `~/.claude/projects`), so users who move that directory get no account scoping.
- The status-line capture is tagged with the identity current when `TokenGaugeCapture` writes it, so a Claude Code session that outlives a `claude /login` in another terminal can still attribute its fallback samples to the new account until that session ends.
- The status-line `rate_limits` payload stays as the credential-free fallback through `TokenGaugeCapture`. It carries only the session and all-models windows, so the Fable row is absent while the fallback is in use — leave it absent rather than estimating it.
- Claude activity reads only timestamps, message IDs, model identifiers, and numeric usage fields from local JSONL transcripts, aggregated into hourly per-model buckets.
- The official status line exposes only the `five_hour` and `seven_day` buckets. There is no per-model quota bucket, so per-model figures always come from local transcripts and are labelled as token totals, never as quota.
Expand Down Expand Up @@ -62,7 +64,7 @@
- Preparing a public release does not authorize publishing it. Repository visibility changes, release publication, release tags and assets require explicit publication authorization; preparation or signing approval alone is insufficient.
- UI iteration is M4-only for Steven to review. Broader QA, documentation screenshots, and public release publication require his explicit scope approval.

- Do not log or persist raw JSONL lines, prompts, responses, account identifiers, emails, tokens, or credentials.
- Do not log or persist raw JSONL lines, prompts, responses, account identifiers, emails, tokens, or credentials; only the SHA-256 fingerprint of the account uuid may reach disk.
- Do not invoke a model request to refresh usage.
- Do not scrape provider web pages.
- Do not request Accessibility, Automation or Full Disk Access. Claude uses its existing read-only Keychain credential; explain the system access prompt if its ACL requires one.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,4 @@
"pace.info_method" = "Calculated over at least 30 observed minutes within the last hour. Resets and long gaps are excluded.";
"pace.info_retention" = "When no new usage is recorded, the last active pace stays available with its date. Measurements are stored only on this Mac.";
"pace.info_saved" = "Showing the last measurement with usage while waiting for new data.";
"account.current" = "Account: %@";
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,4 @@
"pace.info_method" = "Se calcula con al menos 30 minutos observados dentro de la última hora. Los reinicios y los huecos largos se excluyen.";
"pace.info_retention" = "Si no se registra consumo nuevo, conservamos el último ritmo activo con su fecha. Las mediciones se guardan solo en este Mac.";
"pace.info_saved" = "Mostramos la última medición con consumo mientras llegan datos nuevos.";
"account.current" = "Cuenta: %@";
17 changes: 11 additions & 6 deletions Sources/TokenGaugeApp/Services/ClaudeAccountUsageClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ struct ClaudeAccountUsageClient: Sendable {
return URLSession(configuration: configuration)
}()

func fetch(now: Date = Date()) throws -> ClaudeAccountSnapshot {
guard let token = ClaudeOAuthTokenReader.read() else {
func fetch(now: Date = Date(), identity: ClaudeAccountIdentity?) throws -> ClaudeAccountSnapshot {
guard let token = ClaudeOAuthTokenReader.read(accountUuid: identity?.accountUuid) else {
throw ClaudeAccountUsageError.authenticationRequired
}
guard !token.isExpired else { throw ClaudeAccountUsageError.credentialExpired }
Expand All @@ -49,14 +49,17 @@ struct ClaudeAccountUsageClient: Sendable {
outcome = try send(request)
} catch ClaudeAccountUsageError.authenticationRequired {
ClaudeOAuthTokenReader.invalidate()
guard let fresh = ClaudeOAuthTokenReader.read(), !fresh.isExpired, fresh.value != token.value else {
guard let fresh = ClaudeOAuthTokenReader.read(accountUuid: identity?.accountUuid), !fresh.isExpired,
fresh.value != token.value
else {
throw ClaudeAccountUsageError.authenticationRequired
}
request.setValue("Bearer \(fresh.value)", forHTTPHeaderField: "Authorization")
outcome = try send(request)
}
let windows = try Self.parseWindows(outcome)
let snapshot = ClaudeAccountSnapshot(capturedAt: now, windows: windows)
let snapshot = ClaudeAccountSnapshot(
capturedAt: now, windows: windows, accountFingerprint: identity?.fingerprint)
if !windows.isEmpty {
try? SecureMetricStore.write(snapshot, to: UsagePaths.claudeAccountCache(homeDirectory: homeDirectory))
}
Expand All @@ -76,11 +79,13 @@ struct ClaudeAccountUsageClient: Sendable {
}
}

func cached() -> ClaudeAccountSnapshot? {
try? SecureMetricStore.read(
func cached(identity: ClaudeAccountIdentity?) -> ClaudeAccountSnapshot? {
let snapshot = try? SecureMetricStore.read(
ClaudeAccountSnapshot.self,
from: UsagePaths.claudeAccountCache(homeDirectory: homeDirectory)
)
guard let snapshot, snapshot.belongs(to: identity?.fingerprint) else { return nil }
return snapshot
}

private func send(_ request: URLRequest) throws -> Data {
Expand Down
10 changes: 9 additions & 1 deletion Sources/TokenGaugeApp/Services/ClaudeRecoveryProcess.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Darwin
import Foundation
import TokenGaugeCore

enum ClaudeRecoveryProcess {
static func run(
Expand All @@ -19,6 +20,13 @@ enum ClaudeRecoveryProcess {
shouldContinue: () -> Bool = { true }, recovered: () -> Bool
) -> Bool {
guard timeout.isFinite, timeout > 0 else { return false }
let workingDirectory = UsagePaths.recoveryWorkingDirectory(homeDirectory: homeDirectory)
do {
try FileManager.default.createDirectory(
at: workingDirectory, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
} catch { return false }
var master: Int32 = -1
var slave: Int32 = -1
guard openpty(&master, &slave, nil, nil, nil) == 0 else { return false }
Expand All @@ -35,7 +43,7 @@ enum ClaudeRecoveryProcess {
defer { posix_spawnattr_destroy(&attributes) }
guard posix_spawnattr_setpgroup(&attributes, 0) == 0,
posix_spawnattr_setflags(&attributes, Int16(POSIX_SPAWN_SETPGROUP | POSIX_SPAWN_CLOEXEC_DEFAULT)) == 0,
posix_spawn_file_actions_addchdir_np(&actions, homeDirectory.path) == 0,
posix_spawn_file_actions_addchdir_np(&actions, workingDirectory.path) == 0,
posix_spawn_file_actions_addclose(&actions, master) == 0
else { return false }
for descriptor in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
Expand Down
3 changes: 2 additions & 1 deletion Sources/TokenGaugeApp/Services/ClaudeSessionRecovery.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ final class ClaudeSessionRecovery: @unchecked Sendable {
shouldContinue: { authorization.isAllowed }
) {
ClaudeOAuthTokenReader.invalidate()
return ClaudeOAuthTokenReader.read().map { !$0.isExpired } ?? false
let accountUuid = ClaudeAccountIdentityReader.current(homeDirectory: homeDirectory)?.accountUuid
return ClaudeOAuthTokenReader.read(accountUuid: accountUuid).map { !$0.isExpired } ?? false
}
}
}
Expand Down
57 changes: 44 additions & 13 deletions Sources/TokenGaugeApp/Services/ClaudeUsageClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@ struct ClaudeUsageResult: Sendable {
let snapshot: ProviderUsageSnapshot
let access: ClaudeAccessState
let lastActivityAt: Date?
let accountFingerprint: String?
let accountLabel: String?

init(
snapshot: ProviderUsageSnapshot, access: ClaudeAccessState, lastActivityAt: Date?,
accountFingerprint: String? = nil, accountLabel: String? = nil
) {
self.snapshot = snapshot
self.access = access
self.lastActivityAt = lastActivityAt
self.accountFingerprint = accountFingerprint
self.accountLabel = accountLabel
}
}

struct ClaudeUsageClient: Sendable {
Expand All @@ -26,24 +39,32 @@ struct ClaudeUsageClient: Sendable {
func fetch(
now: Date = Date(), recoveryAuthorization: ClaudeRecoveryAuthorization = ClaudeRecoveryAuthorization()
) throws -> ClaudeUsageResult {
let identity = ClaudeAccountIdentityReader.current(homeDirectory: homeDirectory)
let buckets = try? fetchModelBuckets()
let account = ClaudeAccountUsageClient(homeDirectory: homeDirectory)
let outcome = Self.readAccount(
fetch: { try account.fetch(now: Date()) },
fetch: { try account.fetch(now: Date(), identity: identity) },
recover: {
ClaudeSessionRecovery.shared.attempt(homeDirectory: homeDirectory, authorization: recoveryAuthorization)
}
)
let capture = try? SecureMetricStore.read(
ClaudeCapturedSnapshot.self,
from: UsagePaths.claudeCapture(homeDirectory: homeDirectory)
)
let capture = Self.capture(
at: UsagePaths.claudeCapture(homeDirectory: homeDirectory),
accountFingerprint: identity?.fingerprint)
return Self.resolve(
account: outcome, cached: account.cached(), capture: capture, modelBuckets: buckets ?? [], now: now,
activityReadSucceeded: buckets != nil
account: outcome, cached: account.cached(identity: identity), capture: capture,
modelBuckets: buckets ?? [], now: now, activityReadSucceeded: buckets != nil,
accountFingerprint: identity?.fingerprint, accountLabel: identity?.label
)
}

static func capture(at url: URL, accountFingerprint: String?) -> ClaudeCapturedSnapshot? {
guard let snapshot = try? SecureMetricStore.read(ClaudeCapturedSnapshot.self, from: url),
snapshot.belongs(to: accountFingerprint)
else { return nil }
return snapshot
}

static func readAccount(
fetch: () throws -> ClaudeAccountSnapshot,
recover: () -> Bool
Expand All @@ -65,15 +86,19 @@ struct ClaudeUsageClient: Sendable {
capture: ClaudeCapturedSnapshot?,
modelBuckets: [ModelTokenBucket],
now: Date,
activityReadSucceeded: Bool = true
activityReadSucceeded: Bool = true,
accountFingerprint: String? = nil,
accountLabel: String? = nil
) -> ClaudeUsageResult {
if case .success(let live) = account, !live.windows.isEmpty {
return ClaudeUsageResult(
snapshot: snapshot(
windows: live.windows, capturedAt: live.capturedAt, modelBuckets: modelBuckets,
activityReadSucceeded: activityReadSucceeded),
access: .live,
lastActivityAt: capture?.capturedAt
lastActivityAt: capture?.capturedAt,
accountFingerprint: accountFingerprint,
accountLabel: accountLabel
)
}
let fallback = Self.fallback(cached: cached, capture: capture, modelBuckets: modelBuckets, now: now)
Expand All @@ -94,7 +119,9 @@ struct ClaudeUsageClient: Sendable {
windows: fallback.windows, capturedAt: fallback.capturedAt, modelBuckets: modelBuckets,
activityReadSucceeded: activityReadSucceeded),
access: access,
lastActivityAt: capture?.capturedAt
lastActivityAt: capture?.capturedAt,
accountFingerprint: accountFingerprint,
accountLabel: accountLabel
)
}

Expand Down Expand Up @@ -144,18 +171,22 @@ struct ClaudeUsageClient: Sendable {

private func fetchModelBuckets() throws -> [ModelTokenBucket] {
guard let executable = resolveCaptureExecutable() else { throw UsageDataError.executableNotFound }
return try Self.historyBuckets(executable: executable)
return try Self.historyBuckets(
executable: executable,
workingDirectory: UsagePaths.recoveryWorkingDirectory(homeDirectory: homeDirectory))
}

static func historyBuckets(
executable: URL,
arguments: [String] = ["--history"],
timeout: TimeInterval = 20
timeout: TimeInterval = 20,
workingDirectory: URL
) throws -> [ModelTokenBucket] {
let result: ProcessResult
do {
result = try ProcessRunner.run(
executable: executable, arguments: arguments, input: Data(), requiredResponseIDs: [], timeout: timeout
executable: executable, arguments: arguments, input: Data(), requiredResponseIDs: [],
timeout: timeout, workingDirectory: workingDirectory
)
} catch UsageDataError.timedOut {
throw UsageDataError.timedOut
Expand Down
11 changes: 9 additions & 2 deletions Sources/TokenGaugeApp/Services/CodexAppServerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ struct CodexAppServerClient: Sendable {
arguments: ["app-server", "--stdio"],
input: Data(input.utf8),
requiredResponseIDs: [2, 3, 4],
timeout: 8
timeout: 8,
workingDirectory: UsagePaths.recoveryWorkingDirectory(homeDirectory: homeDirectory)
)
guard result.exitCode == 0 else {
throw UsageDataError.processFailed("Codex app-server exited with status \(result.exitCode)")
Expand Down Expand Up @@ -78,13 +79,19 @@ enum ProcessRunner {
arguments: [String],
input: Data,
requiredResponseIDs: Set<Int>,
timeout: TimeInterval
timeout: TimeInterval,
workingDirectory: URL
) throws -> ProcessResult {
try FileManager.default.createDirectory(
at: workingDirectory, withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
let process = Process()
let inputPipe = Pipe()
let outputPipe = Pipe()
process.executableURL = executable
process.arguments = arguments
process.currentDirectoryURL = workingDirectory
var environment = ProcessInfo.processInfo.environment
let inheritedPath = environment["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin"
environment["PATH"] = executable.deletingLastPathComponent().path + ":" + inheritedPath
Expand Down
5 changes: 3 additions & 2 deletions Sources/TokenGaugeApp/Services/EffortHistoryClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import os
enum EffortHistoryClient {
private static let collecting = OSAllocatedUnfairLock(initialState: false)

static func collect() throws {
static func collect(homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser) throws {
let acquired = collecting.withLock { active in
guard !active else { return false }
active = true
Expand All @@ -22,7 +22,8 @@ enum EffortHistoryClient {
}
let result = try ProcessRunner.run(
executable: executable, arguments: ["--record-effort-history"], input: Data(),
requiredResponseIDs: [], timeout: 25)
requiredResponseIDs: [], timeout: 25,
workingDirectory: UsagePaths.recoveryWorkingDirectory(homeDirectory: homeDirectory))
guard result.exitCode == 0 else { throw UsageDataError.processFailed("Could not update effort history") }
let receipt = try JSONDecoder().decode([String: Int].self, from: result.standardOutput)
guard let records = receipt["records"], records >= 0 else { throw UsageDataError.invalidPayload }
Expand Down
12 changes: 8 additions & 4 deletions Sources/TokenGaugeApp/Services/HistoryDashboardModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ final class HistoryDashboardModel: ObservableObject {

func load(
mode: HistoryMode, revision: Int, previewSnapshots: [ProviderUsageSnapshot]? = nil,
paceKeys: [HistoryPaceKey] = []
paceKeys: [HistoryPaceKey] = [], accountFingerprint: String? = nil
) async {
let request = UUID()
generation = request
Expand All @@ -136,7 +136,9 @@ final class HistoryDashboardModel: ObservableObject {
cacheOrder.removeAll(keepingCapacity: true)
cacheRevision = revision
}
let cacheKey = first + ":" + last + ":" + paceKeys.map(\.id).sorted().joined(separator: "|")
let cacheKey =
first + ":" + last + ":" + (accountFingerprint ?? "")
+ ":" + paceKeys.map(\.id).sorted().joined(separator: "|")
do {
let result: ReadResult
if let cached = cachedReads[cacheKey] {
Expand All @@ -157,8 +159,10 @@ final class HistoryDashboardModel: ObservableObject {
return ReadResult(
days: Self.makeDays(interval: interval, tokens: tokens, efforts: efforts),
latest: try UsageHistoryStore.recentPaces(
for: paceKeys, limitPerWindow: 1, before: now, matchingLatestQuota: true),
retained: try UsageHistoryStore.recentPaces(for: paceKeys, activeOnly: true, before: now),
for: paceKeys, limitPerWindow: 1, before: now, matchingLatestQuota: true,
accountFingerprint: accountFingerprint),
retained: try UsageHistoryStore.recentPaces(
for: paceKeys, activeOnly: true, before: now, accountFingerprint: accountFingerprint),
first: try UsageHistoryStore.bounds().firstDay)
}.value
}
Expand Down
Loading
Loading