Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,12 @@ All notable changes to trifola are documented here. The format follows
## [Unreleased]

### Added
- **Grok SuperGrok plan usage (Quota + MCP)** — third quota provider showing real SuperGrok
used-% and reset from xAI's billing endpoint, consent-gated (`Show Grok usage` off by
default). Reads `~/.grok/auth.json` only when enabled; injectable HTTP transport keeps tests
offline. `quota_windows` always includes a `grok` block with an honest status when consent,
credentials, or the endpoint are unavailable. Wire format derived from CodexBar (MIT,
© Peter Steinberger); trifola-native implementation.
- **Grok Build as a third session provider** — Trifola now ingests directory-backed
`~/.grok` sessions across the macOS app and npm CLI, joining authoritative summary metadata,
visible chat prose, and ACP per-turn usage with independent incremental offsets. Grok titles,
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,8 @@ machines you manually configure.

| Destination | Trigger | Data sent | How to disable |
|---|---|---|---|
| Anthropic quota API | **Opt-in, off by default** — Settings → quota access must be enabled per provider; nothing is read before consent | OAuth access credential in the authorization header and standard HTTP request metadata; no transcripts. (Codex quota never uses the network — it reads local rollout files only.) | Leave the toggle off (the default), or turn it off at any time; quota windows then show as not enabled |
| Anthropic quota API | **Opt-in, off by default** — Settings → Claude quota; nothing is read before consent | OAuth access credential in the authorization header and standard HTTP request metadata; no transcripts. (Codex quota never uses the network — it reads local rollout files only.) | Leave Claude quota off (the default), or turn it off at any time |
| xAI SuperGrok billing | **Opt-in, off by default** — Settings → Show Grok usage; nothing is read before consent | OAuth bearer from `~/.grok/auth.json` in the authorization header and standard HTTP request metadata; no transcripts | Leave Show Grok usage off (the default), or turn it off at any time |
| models.dev | **Opt-in** when you click **Refresh from models.dev** in the pricing view | A plain GET for the public pricing catalog; no credentials or transcripts | Do not use the refresh action; bundled pricing remains available offline |
| Manually configured SSH/Tailscale host | **Only if configured** in experimental cross-machine settings | SSH connection metadata and bounded transcript path-list requests; matching transcript files are pulled from that machine into Trifola's local mirror | Remove or disable the configured machine |
| Linear | **Opt-in** after adding a Linear key and choosing **Sync to Linear** | Confirmed deadline/project names, descriptions, target dates, and status updates; no transcripts | Do not connect/sync Linear, or remove the key |
Expand Down
22 changes: 16 additions & 6 deletions Sources/Trifola/QuotaSection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -46,16 +46,17 @@ struct QuotaSection: View {
HStack(alignment: .firstTextBaseline) {
SectionLabel("Plan quota")
Spacer()
if consent.claude || consent.codex {
if consent.claude || consent.codex || consent.grok {
QuietTapButton("Retry", action: onRetry)
}
}

providerBlock(.claude, enabled: consent.claude)
providerBlock(.codex, enabled: consent.codex)
providerBlock(.grok, enabled: consent.grok)

if !consent.claude && !consent.codex {
Text("Quota access is off by default. Enable either provider in Settings → Quota; costs and attention remain fully available.")
if !consent.claude && !consent.codex && !consent.grok {
Text("Quota access is off by default. Enable a provider in Settings → Quota; costs and attention remain fully available.")
.font(.callout)
.foregroundStyle(Theme.muted)
}
Expand Down Expand Up @@ -97,9 +98,7 @@ struct QuotaSection: View {
QuotaCreditsRow(credits: credits)
}
}
Text(provider == .claude
? "OAuth usage endpoint · read-only — not dollars"
: "local rollout rate-limit events · no network")
Text(sourceLabel(for: provider))
.font(.caption)
.foregroundStyle(Theme.muted)
} else {
Expand All @@ -111,8 +110,19 @@ struct QuotaSection: View {
.padding(.top, Theme.micro)
}

private func sourceLabel(for provider: Provider) -> String {
switch provider {
case .claude: return "OAuth usage endpoint · read-only — not dollars"
case .codex: return "local rollout rate-limit events · no network"
case .grok: return "SuperGrok plan usage · read-only — not dollars"
}
}

private func unavailableCopy(provider: Provider) -> String {
let status = statuses[provider] ?? "not fetched yet"
if provider == .grok {
return status
}
if provider == .codex {
if status == "no Codex rollouts found" {
return "No Codex rollouts were found. Trifola works fully without quota data."
Expand Down
58 changes: 51 additions & 7 deletions Sources/Trifola/Render.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1464,14 +1464,21 @@ enum ParityRender {
credits: QuotaCredits(hasCredits: true, unlimited: false,
balance: "$42.75"),
fetchedAt: now)
let grok = QuotaSnapshot(
fiveHour: nil,
weekly: QuotaWindow(title: "SuperGrok", usedPercent: 37,
resetsAt: now.addingTimeInterval(5 * 86_400)),
scoped: [],
fetchedAt: now)
let content = VStack(alignment: .leading, spacing: Theme.sectionGap) {
RenderCaption("Plan quota — the production multi-provider section; Claude reads only after consent, Codex is local-only")
RenderCaption("Plan quota — the production multi-provider section; Claude and Grok read only after consent, Codex is local-only")
Card {
QuotaSection(
snapshots: [.claude: claude, .codex: codex],
statuses: [.claude: "ok", .codex: "local rollout"],
snapshots: [.claude: claude, .codex: codex, .grok: grok],
statuses: [.claude: "ok", .codex: "local rollout",
.grok: "ok · SuperGrok"],
source: .keychain,
consent: QuotaConsent(claude: true, codex: true),
consent: QuotaConsent(claude: true, codex: true, grok: true),
now: now)
}
}
Expand Down Expand Up @@ -3788,11 +3795,48 @@ enum QuotaRender {
@MainActor
static func run(to path: String, dark: Bool) {
let now = Date()
let grokAvailable = QuotaSnapshot(
fiveHour: nil,
weekly: QuotaWindow(title: "SuperGrok", usedPercent: 42.5,
resetsAt: now.addingTimeInterval(6 * 86_400)),
scoped: [],
fetchedAt: now)
let content = VStack(alignment: .leading, spacing: 16) {
RenderCaption("Plan quota (W7, plan 04) — the REAL rate-limit windows next to the burn estimate: 5h session window at 91% with a ~35-min reset runway (today's 'resets 10am' moment, predictable in advance), weekly + Custom-scoped weekly calm. Fill is state, not decoration: red ≥90, amber ≥75, accent below.")
RenderCaption("Plan quota (W7, plan 04) — the REAL rate-limit windows next to the burn estimate: 5h session window at 91% with a ~35-min reset runway (today's 'resets 10am' moment, predictable in advance), weekly + Custom-scoped weekly calm. Fill is state, not decoration: red ≥90, amber ≥75, accent below. Grok SuperGrok row when consent is on and usage is available.")
VStack(alignment: .leading, spacing: 10) {
QuotaSection(
snapshots: [
.claude: seededSnapshot(now: now),
.grok: grokAvailable,
],
statuses: [
.claude: "ok · keychain",
.codex: "access off",
.grok: "ok · SuperGrok",
],
source: .keychain,
consent: QuotaConsent(claude: true, codex: false, grok: true),
now: now)
}
.padding(Theme.sectionGap)
.background {
RoundedRectangle(cornerRadius: Theme.radiusCard, style: .continuous).fill(Theme.cardFill)
RoundedRectangle(cornerRadius: Theme.radiusCard, style: .continuous)
.strokeBorder(Theme.cardStroke, lineWidth: 1)
}
Divider()
RenderCaption("Grok row off by default — consent gate shows Access off; no network, no auth.json read.")
VStack(alignment: .leading, spacing: 10) {
QuotaSection(snapshot: seededSnapshot(now: now),
status: "ok", source: .keychain, now: now)
QuotaSection(
snapshots: [:],
statuses: [
.claude: "access off",
.codex: "access off",
.grok: "access off",
],
source: nil,
consent: QuotaConsent(),
now: now)
}
.padding(Theme.sectionGap)
.background {
Expand Down
7 changes: 7 additions & 0 deletions Sources/Trifola/SettingsWindow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,13 @@ private struct QuotaAccessSettings: View {
.font(.footnote)
.foregroundStyle(.secondary)

TapToggle("Show Grok usage", isOn: preferenceBinding(
get: { $0.grokQuotaAccessEnabled },
set: { $0.grokQuotaAccessEnabled = $1 }))
Text("Reads ~/.grok/auth.json, then makes one HTTPS request to xAI's SuperGrok billing endpoint. Off by default.")
.font(.footnote)
.foregroundStyle(.secondary)

Divider().padding(.vertical, 4)

Text("Custom model tier")
Expand Down
3 changes: 3 additions & 0 deletions Sources/Trifola/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,9 @@ if isSelfCheck {
configDirectory: paths.root) },
codexQuota: {
MCPIntrospectionServer.blockingCodexQuotaFetch()
},
grokQuota: {
MCPIntrospectionServer.blockingGrokQuotaFetch()
})
func mcpCall(_ line: String) -> [String: Any]? {
guard let out = mcp.handleLine(line) else { return nil }
Expand Down
50 changes: 46 additions & 4 deletions Sources/TrifolaKit/ClaudeQuota.swift
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ public final class QuotaStore: ObservableObject {
@Published public private(set) var statuses: [Provider: String] = [
.claude: "access off",
.codex: "access off",
.grok: "access off",
]
/// Source-compatible Claude status for older callers.
public var status: String { statuses[.claude] ?? "not fetched yet" }
Expand All @@ -476,13 +477,19 @@ public final class QuotaStore: ObservableObject {
private let refreshCoordinator: ProviderRefreshCoordinator
private let configDirectory: URL
private let codexProvider: any QuotaProvider
private let grokHome: URL
private let grokTransport: any GrokQuotaHTTPTransport

public init(refreshCoordinator: ProviderRefreshCoordinator = .shared,
configDirectory: URL = ClaudePaths.process.root,
codexProvider: any QuotaProvider = CodexQuotaProvider()) {
codexProvider: any QuotaProvider = CodexQuotaProvider(),
grokHome: URL = GrokPaths.process.root,
grokTransport: any GrokQuotaHTTPTransport = URLSession.shared) {
self.refreshCoordinator = refreshCoordinator
self.configDirectory = configDirectory
self.codexProvider = codexProvider
self.grokHome = grokHome
self.grokTransport = grokTransport
}

public convenience init(paths: ClaudePaths,
Expand All @@ -493,8 +500,9 @@ public final class QuotaStore: ObservableObject {

/// Refresh only providers for which the user granted access. The consent
/// value is checked before constructing either probe, so a disabled Claude
/// provider cannot read its credential file or query Keychain, and a
/// disabled Codex provider cannot enumerate rollout files.
/// provider cannot read its credential file or query Keychain, a disabled
/// Codex provider cannot enumerate rollout files, and a disabled Grok
/// provider cannot read auth.json or hit xAI's billing endpoint.
public func refresh(consent: QuotaConsent, minInterval: TimeInterval = 300) async {
latestConsent = consent
let now = Date()
Expand All @@ -508,6 +516,10 @@ public final class QuotaStore: ObservableObject {
snapshots.removeValue(forKey: .codex)
statuses[.codex] = "access off"
}
if !consent.grok {
snapshots.removeValue(forKey: .grok)
statuses[.grok] = "access off"
}
// Revocation must clear visible data even while an older authorized
// probe is suspended. That older probe also re-checks `latestConsent`
// before publishing, so it cannot restore data after access is off.
Expand All @@ -518,7 +530,9 @@ public final class QuotaStore: ObservableObject {
&& !(cooldownUntil[.claude].map { now < $0 } ?? false)
let refreshCodex = consent.codex
&& needsRefresh(provider: .codex, now: now, minInterval: minInterval)
guard refreshClaude || refreshCodex else { return }
let refreshGrok = consent.grok
&& needsRefresh(provider: .grok, now: now, minInterval: minInterval)
guard refreshClaude || refreshCodex || refreshGrok else { return }

inFlight = true
defer { inFlight = false }
Expand All @@ -528,8 +542,11 @@ public final class QuotaStore: ObservableObject {
// overlapping stack/machine/future-provider triggers coalesce here.
let resolved = Locked<Result<ResolvedQuota, ClaudeQuotaError>?>(nil)
let codexResolved = Locked<Result<QuotaSnapshot, QuotaProviderFailure>?>(nil)
let grokResolved = Locked<Result<QuotaSnapshot, GrokQuotaError>?>(nil)
let configDirectory = configDirectory
let codexProvider = codexProvider
let grokHome = grokHome
let grokTransport = grokTransport
var probes: [ProviderRefreshProbe] = []
if refreshClaude {
probes.append(ProviderRefreshProbe(id: "claude.quota") {
Expand All @@ -545,6 +562,18 @@ public final class QuotaStore: ObservableObject {
codexResolved.withLock { $0 = result }
})
}
if refreshGrok {
probes.append(ProviderRefreshProbe(id: "grok.quota") {
guard let creds = GrokQuotaCredentialReader.load(
configDirectory: grokHome) else {
grokResolved.withLock { $0 = .failure(.noCredentials) }
return
}
let result = await GrokQuotaFetcher.fetch(
creds: creds, transport: grokTransport)
grokResolved.withLock { $0 = result }
})
}
_ = await refreshCoordinator.refresh(probes)
// A different provider batch may have won the single flight. Its work is
// authoritative for this trigger; the next cadence tick can try quota.
Expand All @@ -564,6 +593,19 @@ public final class QuotaStore: ObservableObject {
}
}

if refreshGrok, latestConsent.grok,
let grokResult = grokResolved.withLock({ $0 }) {
switch grokResult {
case .success(let grokSnapshot):
snapshots[.grok] = grokSnapshot
statuses[.grok] = grokSnapshot.isEmpty
? "endpoint returned no SuperGrok window" : "ok · SuperGrok"
case .failure(let err):
snapshots.removeValue(forKey: .grok)
statuses[.grok] = GrokQuotaFetcher.describe(err)
}
}

guard refreshClaude, latestConsent.claude,
let result = resolved.withLock({ $0 }) else { return }
switch result {
Expand Down
Loading
Loading