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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
- Claude history: keep OAuth utilization separate across account switches while preserving continuity through token refreshes.
- Linux CLI: keep Claude OAuth usage subprocess-free, skip version probes, and let Auto bypass unsupported web sources. Thanks @derekszen!
- Usage display: make Usage widgets follow the used-versus-remaining preference already shared by menus and Overview rows (#1738). Thanks @OlegLustenko and @FrancoLan!
- OpenCode Go: keep rolling usage available when the dashboard omits the optional weekly window. Thanks @mohkg1017!

## 0.37.3 — 2026-06-28

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -439,17 +439,19 @@ extension OpenCodeGoUsageFetcher {
text: text),
let rollingReset = self.extractInt(
pattern: #"rollingUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#,
text: text),
let weeklyPercent = self.extractDouble(
pattern: #"weeklyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#,
text: text),
let weeklyReset = self.extractInt(
pattern: #"weeklyUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#,
text: text)
else {
throw OpenCodeGoUsageError.parseFailed("Missing usage fields.")
}

let weeklyPercent = self.extractDouble(
pattern: #"weeklyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#,
text: text)
let weeklyReset = self.extractInt(
pattern: #"weeklyUsage[^}]*?resetInSec\s*:\s*([0-9]+)"#,
text: text)
let hasWeeklyUsage = weeklyPercent != nil && weeklyReset != nil

let monthlyPercent = self.extractDouble(
pattern: #"monthlyUsage[^}]*?usagePercent\s*:\s*([0-9]+(?:\.[0-9]+)?)"#,
text: text)
Expand All @@ -458,12 +460,13 @@ extension OpenCodeGoUsageFetcher {
text: text)

return OpenCodeGoUsageSnapshot(
hasWeeklyUsage: hasWeeklyUsage,
hasMonthlyUsage: monthlyPercent != nil || monthlyReset != nil,
rollingUsagePercent: rollingPercent,
weeklyUsagePercent: weeklyPercent,
weeklyUsagePercent: weeklyPercent ?? 0,
monthlyUsagePercent: monthlyPercent ?? 0,
rollingResetInSec: rollingReset,
weeklyResetInSec: weeklyReset,
weeklyResetInSec: weeklyReset ?? 0,
monthlyResetInSec: monthlyReset ?? 0,
updatedAt: now)
}
Expand Down Expand Up @@ -513,7 +516,7 @@ extension OpenCodeGoUsageFetcher {
let weekly = self.firstDict(from: dict, keys: weeklyKeys)
let monthly = self.firstDict(from: dict, keys: monthlyKeys)

guard let rolling, let weekly else { return nil }
guard let rolling else { return nil }

return self.buildSnapshot(rolling: rolling, weekly: weekly, monthly: monthly, now: now, renewsAt: renewsAt)
}
Expand Down Expand Up @@ -542,7 +545,7 @@ extension OpenCodeGoUsageFetcher {
}
}

if let rolling, let weekly {
if let rolling {
let snapshot = self.buildSnapshot(
rolling: rolling,
weekly: weekly,
Expand Down Expand Up @@ -590,9 +593,10 @@ extension OpenCodeGoUsageFetcher {
candidate.pathLower.contains("month")
}

let nonRollingIDs = Set((weeklyCandidates + monthlyCandidates).map(\.id))
let rolling = self.pickCandidate(
preferred: rollingCandidates,
fallback: candidates,
fallback: candidates.filter { !nonRollingIDs.contains($0.id) },
pickShorter: true)
let weekly = self.pickCandidate(
from: weeklyCandidates.filter { candidate in
Expand All @@ -605,17 +609,18 @@ extension OpenCodeGoUsageFetcher {
},
pickShorter: false)

guard let rolling, let weekly else { return nil }
guard let rolling else { return nil }

let renewsAt = self.dateValue(from: self.value(from: object as? [String: Any] ?? [:], keys: self.renewAtKeys))
?? inheritedRenewsAt
return OpenCodeGoUsageSnapshot(
hasWeeklyUsage: weekly != nil,
hasMonthlyUsage: monthly != nil,
rollingUsagePercent: rolling.percent,
weeklyUsagePercent: weekly.percent,
weeklyUsagePercent: weekly?.percent ?? 0,
monthlyUsagePercent: monthly?.percent ?? 0,
rollingResetInSec: rolling.resetInSec,
weeklyResetInSec: weekly.resetInSec,
weeklyResetInSec: weekly?.resetInSec ?? 0,
monthlyResetInSec: monthly?.resetInSec ?? 0,
renewsAt: renewsAt,
updatedAt: now)
Expand Down Expand Up @@ -704,26 +709,32 @@ extension OpenCodeGoUsageFetcher {

private static func buildSnapshot(
rolling: [String: Any],
weekly: [String: Any],
weekly: [String: Any]?,
monthly: [String: Any]?,
now: Date,
renewsAt: Date? = nil) -> OpenCodeGoUsageSnapshot?
{
guard let rollingWindow = self.parseWindow(rolling, now: now),
let weeklyWindow = self.parseWindow(weekly, now: now)
else {
guard let rollingWindow = self.parseWindow(rolling, now: now) else {
return nil
}

let weeklyWindow: (percent: Double, resetInSec: Int)?
if let weekly {
guard let parsed = self.parseWindow(weekly, now: now) else { return nil }
weeklyWindow = parsed
} else {
weeklyWindow = nil
}
let monthlyWindow = monthly.flatMap { self.parseWindow($0, now: now) }

return OpenCodeGoUsageSnapshot(
hasWeeklyUsage: weeklyWindow != nil,
hasMonthlyUsage: monthlyWindow != nil,
rollingUsagePercent: rollingWindow.percent,
weeklyUsagePercent: weeklyWindow.percent,
weeklyUsagePercent: weeklyWindow?.percent ?? 0,
monthlyUsagePercent: monthlyWindow?.percent ?? 0,
rollingResetInSec: rollingWindow.resetInSec,
weeklyResetInSec: weeklyWindow.resetInSec,
weeklyResetInSec: weeklyWindow?.resetInSec ?? 0,
monthlyResetInSec: monthlyWindow?.resetInSec ?? 0,
renewsAt: renewsAt,
updatedAt: now)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Foundation

public struct OpenCodeGoUsageSnapshot: Sendable {
public let isBalanceOnly: Bool
public let hasWeeklyUsage: Bool
public let hasMonthlyUsage: Bool
public let rollingUsagePercent: Double
public let weeklyUsagePercent: Double
Expand All @@ -15,6 +16,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable {

public init(
isBalanceOnly: Bool = false,
hasWeeklyUsage: Bool = true,
hasMonthlyUsage: Bool,
rollingUsagePercent: Double,
weeklyUsagePercent: Double,
Expand All @@ -27,6 +29,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable {
updatedAt: Date)
{
self.isBalanceOnly = isBalanceOnly
self.hasWeeklyUsage = hasWeeklyUsage
self.hasMonthlyUsage = hasMonthlyUsage
self.rollingUsagePercent = rollingUsagePercent
self.weeklyUsagePercent = weeklyUsagePercent
Expand All @@ -42,6 +45,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable {
public static func zenBalanceOnly(balanceUSD: Double, updatedAt: Date) -> OpenCodeGoUsageSnapshot {
OpenCodeGoUsageSnapshot(
isBalanceOnly: true,
hasWeeklyUsage: false,
hasMonthlyUsage: false,
rollingUsagePercent: 0,
weeklyUsagePercent: 0,
Expand All @@ -64,18 +68,22 @@ public struct OpenCodeGoUsageSnapshot: Sendable {
}

let rollingReset = self.updatedAt.addingTimeInterval(TimeInterval(self.rollingResetInSec))
let weeklyReset = self.updatedAt.addingTimeInterval(TimeInterval(self.weeklyResetInSec))

let primary = RateWindow(
usedPercent: self.rollingUsagePercent,
windowMinutes: 5 * 60,
resetsAt: rollingReset,
resetDescription: nil)
let secondary = RateWindow(
usedPercent: self.weeklyUsagePercent,
windowMinutes: 7 * 24 * 60,
resetsAt: weeklyReset,
resetDescription: nil)
let secondary: RateWindow?
if self.hasWeeklyUsage {
let weeklyReset = self.updatedAt.addingTimeInterval(TimeInterval(self.weeklyResetInSec))
secondary = RateWindow(
usedPercent: self.weeklyUsagePercent,
windowMinutes: 7 * 24 * 60,
resetsAt: weeklyReset,
resetDescription: nil)
} else {
secondary = nil
}
let tertiary: RateWindow?
if self.hasMonthlyUsage {
let monthlyReset = self.updatedAt.addingTimeInterval(TimeInterval(self.monthlyResetInSec))
Expand Down Expand Up @@ -122,6 +130,7 @@ public struct OpenCodeGoUsageSnapshot: Sendable {
public func withZenBalanceUSD(_ balance: Double?) -> OpenCodeGoUsageSnapshot {
OpenCodeGoUsageSnapshot(
isBalanceOnly: self.isBalanceOnly,
hasWeeklyUsage: self.hasWeeklyUsage,
hasMonthlyUsage: self.hasMonthlyUsage,
rollingUsagePercent: self.rollingUsagePercent,
weeklyUsagePercent: self.weeklyUsagePercent,
Expand Down
85 changes: 81 additions & 4 deletions Tests/CodexBarTests/OpenCodeGoUsageParserTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,77 @@ struct OpenCodeGoUsageParserTests {
#expect(snapshot.monthlyResetInSec == monthlyResetInSec)
}

@Test
func `parses rolling only usage from seroval response`() throws {
let text =
"$R[16]($R[30],$R[41]={rollingUsage:$R[42]={status:\"ok\",resetInSec:5944,usagePercent:17}});"
let now = Date(timeIntervalSince1970: 0)

let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now)
let usage = snapshot.toUsageSnapshot()

#expect(snapshot.rollingUsagePercent == 17)
#expect(snapshot.rollingResetInSec == 5944)
#expect(snapshot.hasWeeklyUsage == false)
#expect(usage.primary?.usedPercent == 17)
#expect(usage.secondary == nil)
#expect(usage.tertiary == nil)
}

@Test
func `parses rolling only usage from JSON response`() throws {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let payload: [String: Any] = [
"usage": [
"rollingUsage": [
"usagePercent": 25,
"resetInSec": 600,
],
],
]
let data = try JSONSerialization.data(withJSONObject: payload)
let text = String(data: data, encoding: .utf8) ?? ""

let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now)
let usage = snapshot.toUsageSnapshot()

#expect(snapshot.rollingUsagePercent == 25)
#expect(snapshot.rollingResetInSec == 600)
#expect(snapshot.hasWeeklyUsage == false)
#expect(usage.primary?.usedPercent == 25)
#expect(usage.secondary == nil)
#expect(usage.tertiary == nil)
}

@Test
func `recovers weekly usage from nested JSON window`() throws {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let payload: [String: Any] = [
"usage": [
"rollingUsage": [
"usagePercent": 25,
"resetInSec": 600,
],
"weeklyUsage": [
"window": [
"usagePercent": 75,
"resetInSec": 7200,
],
],
],
]
let data = try JSONSerialization.data(withJSONObject: payload)
let text = String(data: data, encoding: .utf8) ?? ""

let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now)
let usage = snapshot.toUsageSnapshot()

#expect(snapshot.hasWeeklyUsage == true)
#expect(snapshot.weeklyUsagePercent == 75)
#expect(snapshot.weeklyResetInSec == 7200)
#expect(usage.secondary?.usedPercent == 75)
}

@Test
func `parses subscription from JSON with reset at and ratio percentages`() throws {
let now = Date(timeIntervalSince1970: 1_700_000_000)
Expand Down Expand Up @@ -261,7 +332,7 @@ struct OpenCodeGoUsageParserTests {
}

@Test
func `candidate fallback does not fabricate weekly from non weekly windows`() throws {
func `candidate fallback preserves missing weekly window`() throws {
let now = Date(timeIntervalSince1970: 1_700_000_000)
let payload: [String: Any] = [
"windows": [
Expand All @@ -280,9 +351,15 @@ struct OpenCodeGoUsageParserTests {
let data = try JSONSerialization.data(withJSONObject: payload)
let text = String(data: data, encoding: .utf8) ?? ""

#expect(throws: OpenCodeGoUsageError.self) {
_ = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now)
}
let snapshot = try OpenCodeGoUsageFetcher.parseSubscription(text: text, now: now)
let usage = snapshot.toUsageSnapshot()

#expect(snapshot.rollingUsagePercent == 15)
#expect(snapshot.hasWeeklyUsage == false)
#expect(snapshot.hasMonthlyUsage == true)
#expect(snapshot.monthlyUsagePercent == 30)
#expect(usage.secondary == nil)
#expect(usage.tertiary?.usedPercent == 30)
}

@Test
Expand Down
4 changes: 2 additions & 2 deletions docs/opencode.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,12 @@ read_when:

## Usage mapping
- Primary window: rolling 5-hour usage (`rollingUsage.usagePercent`, `rollingUsage.resetInSec`).
- Secondary window: weekly usage (`weeklyUsage.usagePercent`, `weeklyUsage.resetInSec`).
- Secondary window: optional weekly usage (`weeklyUsage.usagePercent`, `weeklyUsage.resetInSec`).
- Resets computed as `now + resetInSec`.

## Notes
- Responses are `text/javascript` with serialized objects; parse via regex.
- Missing workspace ID or usage fields should raise parse errors.
- Missing workspace ID or rolling usage fields should raise parse errors; omitted weekly usage stays absent.
- Cookie import defaults to Chrome-only to avoid extra browser prompts; pass a browser list to override.
- Set `CODEXBAR_OPENCODE_WORKSPACE_ID` to skip workspace lookup and force a specific workspace.
- Workspace override accepts a raw `wrk_…` ID or a full `https://opencode.ai/workspace/...` URL.
Expand Down