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: 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